diff --git a/apps/app/src/components/decisions/ProcessBuilder/GenerateProcessModal.tsx b/apps/app/src/components/decisions/ProcessBuilder/GenerateProcessModal.tsx
new file mode 100644
index 000000000..f1b33da7e
--- /dev/null
+++ b/apps/app/src/components/decisions/ProcessBuilder/GenerateProcessModal.tsx
@@ -0,0 +1,90 @@
+'use client';
+
+import { trpc } from '@op/api/client';
+import { Button } from '@op/ui/Button';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from '@op/ui/Modal';
+import { TextField } from '@op/ui/TextField';
+import { toast } from '@op/ui/Toast';
+import { useRouter } from 'next/navigation';
+import { useState } from 'react';
+
+import { useTranslations } from '@/lib/i18n';
+
+export const GenerateProcessModal = ({
+ isOpen,
+ onOpenChange,
+}: {
+ isOpen: boolean;
+ onOpenChange: (isOpen: boolean) => void;
+}) => {
+ const t = useTranslations();
+ const router = useRouter();
+ const [description, setDescription] = useState('');
+
+ const generateProcess =
+ trpc.decision.generateProcessFromDescription.useMutation({
+ onSuccess: (data) => {
+ onOpenChange(false);
+ router.push(`/decisions/${data.slug}/edit`);
+ },
+ onError: (error) => {
+ toast.error({
+ message: t('Failed to generate template'),
+ title: error.message,
+ });
+ },
+ });
+
+ const handleGenerate = () => {
+ if (description.trim().length < 10) {
+ return;
+ }
+ generateProcess.mutate({ description: description.trim() });
+ };
+
+ return (
+
+ {t('Describe your decision-making process')}
+
+
+ {t(
+ "Tell us about your decision-making process and we'll create a template for you.",
+ )}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderProcessSelector.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderProcessSelector.tsx
index d6d3287f7..181105ba2 100644
--- a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderProcessSelector.tsx
+++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderProcessSelector.tsx
@@ -6,10 +6,13 @@ import { Avatar } from '@op/ui/Avatar';
import { Header1, Header2 } from '@op/ui/Header';
import { Skeleton } from '@op/ui/Skeleton';
import { useRouter } from 'next/navigation';
-import { Suspense } from 'react';
+import { Suspense, useState } from 'react';
+import { LuSparkles } from 'react-icons/lu';
import { useTranslations } from '@/lib/i18n';
+import { GenerateProcessModal } from './GenerateProcessModal';
+
export const ProcessBuilderProcessSelector = () => {
const t = useTranslations();
@@ -34,6 +37,7 @@ const TemplateList = () => {
const t = useTranslations();
const [templatesData] = trpc.decision.listProcesses.useSuspenseQuery({});
const templates = templatesData?.processes;
+ const [isGenerateModalOpen, setIsGenerateModalOpen] = useState(false);
const createDecisionInstance =
trpc.decision.createInstanceFromTemplate.useMutation({
@@ -42,26 +46,46 @@ const TemplateList = () => {
},
});
- if (!templates?.length) {
- return (
-
-
{t('No templates found')}
-
- );
- }
+ return (
+ <>
+
+
+ {templates?.map((template) => (
+ {
+ createDecisionInstance.mutate({
+ templateId: template.id,
+ name: `New ${template.name}`,
+ });
+ }}
+ />
+ ))}
- return templates.map((template) => (
- {
- createDecisionInstance.mutate({
- templateId: template.id,
- name: `New ${template.name}`,
- });
- }}
- />
- ));
+
+ >
+ );
};
export const ProcessBuilderProcessCard = ({
diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json
index 101f11b6f..019b46481 100644
--- a/apps/app/src/lib/i18n/dictionaries/bn.json
+++ b/apps/app/src/lib/i18n/dictionaries/bn.json
@@ -690,5 +690,13 @@
"Complete all required phase fields": "Complete all required phase fields",
"Create a proposal template": "Create a proposal template",
"Fix errors in the proposal template": "Fix errors in the proposal template",
- "Invite members": "Invite members"
+ "Invite members": "Invite members",
+ "Describe your process": "Describe your process",
+ "Tell us about your decision-making process and we'll create a template for you.": "Tell us about your decision-making process and we'll create a template for you.",
+ "Describe your decision-making process": "Describe your decision-making process",
+ "Generate template": "Generate template",
+ "Generating your template...": "Generating your template...",
+ "Failed to generate template": "Failed to generate template",
+ "Process description": "Process description",
+ "e.g., A hiring process where candidates submit applications, a committee reviews them, then we do ranked-choice voting": "e.g., A hiring process where candidates submit applications, a committee reviews them, then we do ranked-choice voting"
}
diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json
index b3938a7bf..5e4209930 100644
--- a/apps/app/src/lib/i18n/dictionaries/en.json
+++ b/apps/app/src/lib/i18n/dictionaries/en.json
@@ -683,5 +683,13 @@
"Complete all required phase fields": "Complete all required phase fields",
"Create a proposal template": "Create a proposal template",
"Fix errors in the proposal template": "Fix errors in the proposal template",
- "Invite members": "Invite members"
+ "Invite members": "Invite members",
+ "Describe your process": "Describe your process",
+ "Tell us about your decision-making process and we'll create a template for you.": "Tell us about your decision-making process and we'll create a template for you.",
+ "Describe your decision-making process": "Describe your decision-making process",
+ "Generate template": "Generate template",
+ "Generating your template...": "Generating your template...",
+ "Failed to generate template": "Failed to generate template",
+ "Process description": "Process description",
+ "e.g., A hiring process where candidates submit applications, a committee reviews them, then we do ranked-choice voting": "e.g., A hiring process where candidates submit applications, a committee reviews them, then we do ranked-choice voting"
}
diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json
index 75c663116..03ccbdc91 100644
--- a/apps/app/src/lib/i18n/dictionaries/es.json
+++ b/apps/app/src/lib/i18n/dictionaries/es.json
@@ -682,5 +682,13 @@
"Complete all required phase fields": "Complete all required phase fields",
"Create a proposal template": "Create a proposal template",
"Fix errors in the proposal template": "Fix errors in the proposal template",
- "Invite members": "Invite members"
+ "Invite members": "Invite members",
+ "Describe your process": "Describe your process",
+ "Tell us about your decision-making process and we'll create a template for you.": "Tell us about your decision-making process and we'll create a template for you.",
+ "Describe your decision-making process": "Describe your decision-making process",
+ "Generate template": "Generate template",
+ "Generating your template...": "Generating your template...",
+ "Failed to generate template": "Failed to generate template",
+ "Process description": "Process description",
+ "e.g., A hiring process where candidates submit applications, a committee reviews them, then we do ranked-choice voting": "e.g., A hiring process where candidates submit applications, a committee reviews them, then we do ranked-choice voting"
}
diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json
index e8b1ce185..758e7ec27 100644
--- a/apps/app/src/lib/i18n/dictionaries/fr.json
+++ b/apps/app/src/lib/i18n/dictionaries/fr.json
@@ -682,5 +682,13 @@
"Complete all required phase fields": "Complete all required phase fields",
"Create a proposal template": "Create a proposal template",
"Fix errors in the proposal template": "Fix errors in the proposal template",
- "Invite members": "Invite members"
+ "Invite members": "Invite members",
+ "Describe your process": "Describe your process",
+ "Tell us about your decision-making process and we'll create a template for you.": "Tell us about your decision-making process and we'll create a template for you.",
+ "Describe your decision-making process": "Describe your decision-making process",
+ "Generate template": "Generate template",
+ "Generating your template...": "Generating your template...",
+ "Failed to generate template": "Failed to generate template",
+ "Process description": "Process description",
+ "e.g., A hiring process where candidates submit applications, a committee reviews them, then we do ranked-choice voting": "e.g., A hiring process where candidates submit applications, a committee reviews them, then we do ranked-choice voting"
}
diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json
index 5e4eefe13..bced626ca 100644
--- a/apps/app/src/lib/i18n/dictionaries/pt.json
+++ b/apps/app/src/lib/i18n/dictionaries/pt.json
@@ -678,5 +678,13 @@
"Complete all required phase fields": "Complete all required phase fields",
"Create a proposal template": "Create a proposal template",
"Fix errors in the proposal template": "Fix errors in the proposal template",
- "Invite members": "Invite members"
+ "Invite members": "Invite members",
+ "Describe your process": "Describe your process",
+ "Tell us about your decision-making process and we'll create a template for you.": "Tell us about your decision-making process and we'll create a template for you.",
+ "Describe your decision-making process": "Describe your decision-making process",
+ "Generate template": "Generate template",
+ "Generating your template...": "Generating your template...",
+ "Failed to generate template": "Failed to generate template",
+ "Process description": "Process description",
+ "e.g., A hiring process where candidates submit applications, a committee reviews them, then we do ranked-choice voting": "e.g., A hiring process where candidates submit applications, a committee reviews them, then we do ranked-choice voting"
}
diff --git a/services/api/src/routers/decision/processes/generateProcess.ts b/services/api/src/routers/decision/processes/generateProcess.ts
new file mode 100644
index 000000000..8eb54556a
--- /dev/null
+++ b/services/api/src/routers/decision/processes/generateProcess.ts
@@ -0,0 +1,314 @@
+import { anthropic } from '@ai-sdk/anthropic';
+import { assertUserByAuthId, createInstanceFromTemplateCore } from '@op/common';
+import { CommonError, UnauthorizedError } from '@op/common';
+import { db } from '@op/db/client';
+import { decisionProcesses } from '@op/db/schema';
+import { TRPCError } from '@trpc/server';
+import { generateObject } from 'ai';
+import { z } from 'zod';
+
+import { decisionProfileWithSchemaEncoder } from '../../../encoders/decision';
+import { commonAuthedProcedure, router } from '../../../trpcFactory';
+
+/**
+ * Zod schema matching DecisionSchemaDefinition for AI structured output.
+ */
+const phaseRulesSchema = z.object({
+ proposals: z
+ .object({
+ submit: z.boolean().optional(),
+ edit: z.boolean().optional(),
+ review: z.boolean().optional(),
+ })
+ .optional(),
+ voting: z
+ .object({
+ submit: z.boolean().optional(),
+ edit: z.boolean().optional(),
+ })
+ .optional(),
+ advancement: z
+ .object({
+ method: z.enum(['date', 'manual']),
+ })
+ .optional(),
+});
+
+const selectionPipelineBlockSchema = z.object({
+ id: z.string(),
+ type: z.enum(['sort', 'limit', 'filter']),
+ name: z.string().optional(),
+ sortBy: z
+ .array(
+ z.object({
+ field: z.string(),
+ order: z.enum(['asc', 'desc']),
+ }),
+ )
+ .optional(),
+ count: z.union([z.number(), z.object({ variable: z.string() })]).optional(),
+});
+
+const selectionPipelineSchema = z.object({
+ version: z.string(),
+ blocks: z.array(selectionPipelineBlockSchema),
+});
+
+const settingsSchema = z.object({
+ type: z.literal('object'),
+ required: z.array(z.string()).optional(),
+ properties: z.record(
+ z.string(),
+ z.object({
+ type: z.enum(['number', 'string', 'boolean']),
+ title: z.string(),
+ description: z.string().optional(),
+ minimum: z.number().optional(),
+ maximum: z.number().optional(),
+ default: z.union([z.number(), z.string(), z.boolean()]).optional(),
+ }),
+ ),
+ ui: z
+ .record(
+ z.string(),
+ z.object({
+ 'ui:widget': z.string().optional(),
+ 'ui:placeholder': z.string().optional(),
+ }),
+ )
+ .optional(),
+});
+
+const phaseDefinitionSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ description: z.string().optional(),
+ rules: phaseRulesSchema,
+ selectionPipeline: selectionPipelineSchema.optional(),
+ settings: settingsSchema.optional(),
+});
+
+const processConfigSchema = z.object({
+ hideBudget: z.boolean().optional(),
+ categories: z
+ .array(
+ z.object({
+ id: z.string(),
+ label: z.string(),
+ description: z.string(),
+ }),
+ )
+ .optional(),
+ requireCategorySelection: z.boolean().optional(),
+ allowMultipleCategories: z.boolean().optional(),
+});
+
+const decisionSchemaDefinitionSchema = z.object({
+ id: z.string(),
+ version: z.string(),
+ name: z.string(),
+ description: z.string().optional(),
+ config: processConfigSchema.optional(),
+ phases: z.array(phaseDefinitionSchema).min(1),
+});
+
+const SYSTEM_PROMPT = `You are a decision process designer. Given a description of a decision-making process, generate a DecisionSchemaDefinition that models it.
+
+A DecisionSchemaDefinition defines the phases of a structured decision process. Each phase has:
+- id: a kebab-case identifier
+- name: human-readable name
+- description: what happens in this phase
+- rules: controls what actions are allowed (proposals.submit, voting.submit, etc.) and how the phase advances (date or manual)
+- selectionPipeline (optional): how proposals are filtered/sorted when advancing to the next phase
+- settings (optional): configurable settings for the phase as JSON Schema
+
+Key design principles:
+1. Phases should flow logically (e.g., submission -> review -> voting -> results)
+2. Only the submission phase should allow proposal submissions (proposals.submit: true)
+3. Only the voting phase should allow vote submissions (voting.submit: true)
+4. Use manual advancement by default (method: 'manual')
+5. The id should be a unique kebab-case string
+6. Always include a final "results" phase where everything is locked down
+7. Keep settings simple and relevant to each phase
+8. The top-level id should be a kebab-case identifier for the whole process
+9. version should be "1.0.0"
+
+Here is an example of a Simple Voting process for reference:
+
+{
+ "id": "simple",
+ "version": "1.0.0",
+ "name": "Simple Voting",
+ "description": "Basic approval voting where members vote for multiple proposals.",
+ "phases": [
+ {
+ "id": "submission",
+ "name": "Proposal Submission",
+ "description": "Members submit proposals for consideration.",
+ "rules": {
+ "proposals": { "submit": true },
+ "voting": { "submit": false },
+ "advancement": { "method": "manual" }
+ },
+ "settings": {
+ "type": "object",
+ "properties": {
+ "maxProposalsPerMember": {
+ "type": "number",
+ "title": "Maximum Proposals Per Member",
+ "description": "How many proposals can each member submit?",
+ "minimum": 1,
+ "default": 3
+ }
+ }
+ }
+ },
+ {
+ "id": "review",
+ "name": "Review & Shortlist",
+ "description": "Reviewers evaluate and shortlist proposals.",
+ "rules": {
+ "proposals": { "submit": false },
+ "voting": { "submit": false },
+ "advancement": { "method": "manual" }
+ }
+ },
+ {
+ "id": "voting",
+ "name": "Voting",
+ "description": "Members vote on shortlisted proposals.",
+ "rules": {
+ "proposals": { "submit": false },
+ "voting": { "submit": true },
+ "advancement": { "method": "manual" }
+ },
+ "settings": {
+ "type": "object",
+ "required": ["maxVotesPerMember"],
+ "properties": {
+ "maxVotesPerMember": {
+ "type": "number",
+ "title": "Maximum Votes Per Member",
+ "description": "How many proposals can each member vote for?",
+ "minimum": 1,
+ "default": 3
+ }
+ }
+ },
+ "selectionPipeline": {
+ "version": "1.0.0",
+ "blocks": [
+ {
+ "id": "sort-by-likes",
+ "type": "sort",
+ "name": "Sort by likes count",
+ "sortBy": [{ "field": "voteData.likesCount", "order": "desc" }]
+ },
+ {
+ "id": "limit-by-votes",
+ "type": "limit",
+ "name": "Take top N",
+ "count": { "variable": "maxVotesPerMember" }
+ }
+ ]
+ }
+ },
+ {
+ "id": "results",
+ "name": "Results",
+ "description": "View final results and winning proposals.",
+ "rules": {
+ "proposals": { "submit": false },
+ "voting": { "submit": false },
+ "advancement": { "method": "manual" }
+ }
+ }
+ ]
+}
+
+Generate a process definition appropriate for the user's description. Be creative but practical.`;
+
+export const generateProcessRouter = router({
+ generateProcessFromDescription: commonAuthedProcedure({
+ rateLimit: { windowSize: 60, maxRequests: 5 },
+ })
+ .input(
+ z.object({
+ description: z.string().min(10).max(2000),
+ }),
+ )
+ .output(decisionProfileWithSchemaEncoder)
+ .mutation(async ({ ctx, input }) => {
+ const { user, logger } = ctx;
+
+ try {
+ // Resolve user info
+ const dbUser = await assertUserByAuthId(user.id);
+ const ownerProfileId = dbUser.currentProfileId ?? dbUser.profileId;
+ if (!ownerProfileId) {
+ throw new UnauthorizedError('User must have an active profile');
+ }
+ if (!user.email) {
+ throw new CommonError('User must have an email address');
+ }
+
+ // Generate the decision schema from the description
+ const result = await generateObject({
+ model: anthropic('claude-sonnet-4-5-20250929'),
+ system: SYSTEM_PROMPT,
+ prompt: input.description,
+ schema: decisionSchemaDefinitionSchema,
+ });
+ const schema = result.object as z.infer<
+ typeof decisionSchemaDefinitionSchema
+ >;
+
+ // Save the generated schema as a new decision process template
+ const [template] = await db
+ .insert(decisionProcesses)
+ .values({
+ name: schema.name,
+ description: schema.description,
+ processSchema: schema,
+ createdByProfileId: ownerProfileId,
+ })
+ .returning();
+
+ if (!template) {
+ throw new CommonError('Failed to create decision process template');
+ }
+
+ // Create an instance from the new template
+ const profile = await createInstanceFromTemplateCore({
+ templateId: template.id,
+ name: schema.name,
+ description: schema.description,
+ ownerProfileId,
+ creatorAuthUserId: user.id,
+ creatorEmail: user.email,
+ });
+
+ logger.info('AI-generated decision process created', {
+ userId: user.id,
+ processName: schema.name,
+ templateId: template.id,
+ });
+
+ return decisionProfileWithSchemaEncoder.parse(profile);
+ } catch (error: unknown) {
+ logger.error('Failed to generate decision process', {
+ userId: user.id,
+ error,
+ });
+
+ if (error instanceof TRPCError) {
+ throw error;
+ }
+
+ throw new TRPCError({
+ message: 'Failed to generate decision process',
+ code: 'INTERNAL_SERVER_ERROR',
+ });
+ }
+ }),
+});
diff --git a/services/api/src/routers/decision/processes/index.ts b/services/api/src/routers/decision/processes/index.ts
index aa7af7df0..e39e75b02 100644
--- a/services/api/src/routers/decision/processes/index.ts
+++ b/services/api/src/routers/decision/processes/index.ts
@@ -1,10 +1,12 @@
import { mergeRouters } from '../../../trpcFactory';
import { createProcessRouter } from './createProcess';
+import { generateProcessRouter } from './generateProcess';
import { getProcessRouter } from './getProcess';
import { listProcessesRouter } from './listProcesses';
export const processesRouter = mergeRouters(
createProcessRouter,
+ generateProcessRouter,
getProcessRouter,
listProcessesRouter,
);
diff --git a/turbo.json b/turbo.json
index b064a1194..1628c050c 100644
--- a/turbo.json
+++ b/turbo.json
@@ -47,6 +47,7 @@
"MAILCHIMP_API_KEY",
"MAILCHIMP_API_SERVER",
"MAILCHIMP_AUDIENCE_ID",
+ "ANTHROPIC_API_KEY",
"🌎 ------ PUBLIC VARS ------ 🌎",
"NEXT_PUBLIC_SUPABASE_URL",
"NEXT_PUBLIC_SUPABASE_ANON_KEY",