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 RecordStore so the UI can render it live; the flow suspends so server resources don't sit idle.
Primitives used
plan()from@use-crux/core/planfor the persistent plan documentflow()from@use-crux/core/flowwithflow.suspend()for the approval pausesignalFlow()(orhandle.signal()) to resume once the user clicks ApproveusePlan()from@use-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 } from "@use-crux/core/plan";
import { flow } from "@use-crux/core/flow";
import { generate } from "@use-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 "@use-crux/core/flow";
import { plan } from "@use-crux/core/plan";
export async function approvePlan(
planId: string,
flowId: string,
revisions?: string,
) {
await plan.ref(planId).update({ metadata: { status: "approved" } });
await signalFlow(flowId, "await-approval", { approved: true, revisions });
}
export async function rejectPlan(planId: string, flowId: string) {
await plan.ref(planId).update({ metadata: { status: "rejected" } });
await signalFlow(flowId, "await-approval", { approved: false });
}Live UI
"use client";
import { usePlan } from "@use-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
RecordStore; 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 @use-crux/convex/server so the flow handle exposes .action, .handler, .args, and .signal() for Convex-aware start/resume.