Long-running flow
A flow that suspends and resumes across action boundaries, hours or days. Bounded by timeout, signaled to resume.
This recipe shows a flow that performs an LLM step, suspends to wait for an external event (webhook, cron, scheduled job), then resumes and continues. Server resources are released during the wait: only the final resume re-runs the suspended portion of the handler.
Primitives used
flow()withflow.step()andflow.suspend()signalFlow()to resume from external code- A
RecordStoreto persist flow state across the suspension
When to reach for this pattern
- The work has a wait point longer than your function timeout (10s on Vercel, 5 min on Convex)
- The wait is for an external event: a webhook, an inbox poll, a daily cron, a third-party callback
- You want to never lose progress: pre-wait steps shouldn't re-run after resume
Full code
import { flow, signalFlow } from "@use-crux/core/flow";
import { generate } from "@use-crux/ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
type OnboardingInput = {
email: string;
name: string;
goal: string;
};
const onboardingFlow = flow(
"onboarding",
async (flow, input: OnboardingInput) => {
// Step 1: send a welcome email immediately
await flow.step("send-welcome", async () => {
await emailClient.send({
to: input.email,
template: "welcome",
data: { name: input.name },
});
});
// Step 2: wait up to 24h for the user to verify their email
const verification = await flow.suspend("email-verified", {
schema: z.object({ verifiedAt: z.string() }),
timeout: "24h",
});
// Step 3: generate a personalized first-week plan
const plan = await flow.step("first-week-plan", async () => {
const result = await generate(onboardingPlanPrompt, {
model: openai("gpt-4o"),
input: { name: input.name, signupGoal: input.goal },
});
return result.text;
});
// Step 4: schedule daily check-in emails
return flow.step("schedule-checkins", async () => {
return scheduleEmails(input.email, plan);
});
},
);
// Start
const handle = onboardingFlow;
const { flowId } = await handle.run({
email: "user@example.com",
name: "Henri",
goal: "shipping",
});
// flowId is persisted; can be returned to the caller
// Later, webhook fires when the user clicks the verification link
export async function handleEmailVerified(flowId: string, verifiedAt: string) {
await signalFlow(flowId, "email-verified", { verifiedAt });
}
// Resume happens automatically on the next handle.resume(flowId), usually
// scheduled by the platform (Convex can schedule the resume action from an app-local signal helper).How it works
flow.step()caches results. Each named step's output is persisted. If the flow resumes, completed steps are skipped.flow.suspend()checkpoints the flow. State (input, completed-step results, current position) is persisted toRecordStorekeyed byflowId. The handler returns control; the server is free.- Signal the flow from anywhere.
signalFlow(flowId, 'email-verified', payload)writes the signal. The schema insuspend()validates the payload when resume happens. - Resume re-runs the handler from the start. Each
flow.step()returns its cached result without re-executing. When execution reaches the suspension point, the recorded signal is consumed andsuspend()returns the payload. The flow continues from there. timeout: '24h'auto-expires the flow if no signal arrives. The flow result becomes{ status: 'expired' }.
Variations
Wait for a condition, not a signal
Use flow.waitUntil('name', conditionFn) to suspend until a condition function returns true. Core rechecks the
condition only when the flow is explicitly resumed; if you want periodic checks, schedule resume attempts in your
adapter or application code.
Retry transient failures
flow.step('name', fn, { retry: { attempts: 3, backoff: 'exponential' } }) retries the step on failure. Combines with suspend: only steps within the retry succeed before suspend can save them.
Cancel from outside
cancelFlow(flowId, reason) sets the flow's stored status to 'cancelled'. The next resume returns { status: 'cancelled', cancelReason: reason }.
Convex specifics
Use flow() from @use-crux/convex/server for Convex-aware resume scheduling. From mutation-only modules, create an app-local helper that calls signalFlow() and then ctx.scheduler.runAfter().