CookbookWorkflows
Plan with approval
Generate a plan, pause for human review, execute on approval. Plan + flow + signal.
This recipe builds a workflow that proposes a plan, suspends for human review, and resumes only after a user approves (or modifies) it. The plan persists in CruxStore so the UI can render it live; the flow suspends so server resources don't sit idle.
Primitives used
plan()from@crux/core/planfor the persistent plan documentflow()from@crux/core/flowwithflow.suspend()for the approval pausesignalFlow()(orhandle.signal()) to resume once the user clicks ApproveusePlan()from@crux/reactfor live UI subscription
When to reach for this pattern
- The agent's plan needs human approval before consequential action
- The wait between plan and approval may be minutes to days
- You want server resources released during the wait — no spinning
Full code
Backend
import { plan, updatePlan } from '@crux/core/plan'
import { flow } from '@crux/core/flow'
import { generate } from '@crux/ai'
import { openai } from '@ai-sdk/openai'
import { z } from 'zod'
const writerFlow = flow('writer-flow', async (flow) => {
// Step 1: produce a plan via LLM
const planContent = await flow.step('draft-plan', async () => {
const result = await generate(planPrompt, {
model: openai('gpt-4o'),
input: { brief: flow.input.brief },
})
return result.text
})
// Step 2: persist the plan
const planHandle = await flow.step('persist-plan', async () => {
const p = await plan({
title: flow.input.title,
content: planContent,
metadata: { status: 'draft', flowId: flow.flowId },
})
return { planId: p.id }
})
// Step 3: suspend until approval
const approval = await flow.suspend('await-approval', {
schema: z.object({ approved: z.boolean(), revisions: z.string().optional() }),
timeout: '7d',
})
if (!approval.approved) {
return { status: 'rejected', planId: planHandle.planId }
}
// Step 4: execute the plan
return flow.step('execute', async () => {
return generate(executePrompt, {
model: openai('gpt-4o'),
input: { plan: planContent, revisions: approval.revisions },
})
})
})Approval mutation
import { signalFlow } from '@crux/core/flow'
import { updatePlan } from '@crux/core/plan'
export async function approvePlan(planId: string, flowId: string, revisions?: string) {
await updatePlan(planId, { metadata: { status: 'approved' } })
await signalFlow(flowId, 'await-approval', { approved: true, revisions })
}
export async function rejectPlan(planId: string, flowId: string) {
await updatePlan(planId, { metadata: { status: 'rejected' } })
await signalFlow(flowId, 'await-approval', { approved: false })
}Live UI
'use client'
import { usePlan } from '@crux/react'
export function PlanReview({ planId, flowId }: { planId: string; flowId: string }) {
const plan = usePlan(planId)
if (!plan) return <p>Loading...</p>
return (
<article>
<h1>{plan.title}</h1>
<pre>{plan.content}</pre>
<p>Status: {String(plan.metadata?.status)}</p>
<button onClick={() => approvePlan(planId, flowId)}>Approve</button>
<button onClick={() => rejectPlan(planId, flowId)}>Reject</button>
</article>
)
}How it works
- The flow drafts the plan and persists it. The plan document lives in
CruxStore; the UI subscribes viausePlan()and re-renders when the content changes. flow.suspend('await-approval', { schema, timeout })releases the server. The flow's state is checkpointed; nothing runs until a signal arrives. The schema validates the signal payload on resume.- The mutation signals the flow.
signalFlow(flowId, name, payload)writes to the store; on Convex, schedule the resume action from an app-local signal helper. - The flow resumes from after
suspend(). Earlierflow.step()results are cached — only the post-suspend work re-runs.
Variations
Allow plan revisions
If the user can edit the plan instead of approving as-is, signal with the revised content. The execute step receives it via the validated approval.revisions field.
Multiple approvers
Use a different signal name per role ('await-finance', 'await-legal') and chain suspends. Each must signal in turn.
Plans on Convex
Use flow() from @crux/convex/server so the flow handle exposes .action, .handler, .args, and .signal() for Convex-aware start/resume.