Crux
API Reference@use-crux/core

Flows

Suspendable, resumable flows with named steps, signals, and devtools tracing.

import {
  flow,
  signalFlow,
  cancelFlow,
  listFlows,
  createFlowId,
  FlowSuspendedError,
  FlowCancelledError,
  FlowExpiredError,
} from "@use-crux/core/flow";

Overview

Flows group generate() calls into structured pipelines with named steps, automatic devtools tracing, suspend/resume, and retry/fallback. A run opens a canonical flow.run span, and each executed flow.step() opens a canonical flow.step child. Generated spans inside a flow inherit the active observability context for structured pipeline grouping in devtools. Flows can nest; calling flow().run() inside another flow preserves the parent-child relationship in the graph.

Flows support suspend/resume for human-in-the-loop workflows. When flow.suspend() is called, the flow persists its state to the configured RecordStore and returns { status: 'suspended' }. The observability graph records a flow.suspension marker linked to the causing step, and RunDetail presents that marker beside the other flow steps so successful generations and completed steps do not remain visually stuck. Pass the flow ID to .resume(flowId) to resume a previously suspended flow.

flow(name, handler)

Define a named flow and return a frozen FlowHandle. Separates definition from execution: the handler is captured once, then .run() can be called repeatedly with different inputs and options. Input-bearing flows infer their input type from the handler's second parameter.

ParameterTypeDescription
namestringHuman-readable flow name
handler(flow: FlowScope<TInput>, input?: TInput) => T | ...Flow function with access to flow.step(), flow.suspend(), flow.cancel(), and optional input data

Returns: FlowHandle<T, TInput> -- frozen handle with .run(), .resume(), .signal(), .cancel(), and .name

import { flow } from "@use-crux/core/flow";
import { generate } from "@use-crux/ai";

const researchFlow = flow(
  "research",
  async (flow, input: { topic: string }) => {
    const plan = await flow.step("plan", () =>
      generate(planner, { model, input: { topic: input.topic } }),
    );
    return flow.step("search", () =>
      generate(searcher, { model, input: { plan } }),
    );
  },
);

const result = await researchFlow.run({ topic: "TypeScript monorepos" });
if (result.status === "completed") {
  console.log(result.output);
}

FlowHandle<T, TInput>

The frozen handle returned by flow().

Property / MethodTypeDescription
namestringThe flow's registered name
run(input, options?)Promise<FlowResult<T>>Execute an input-bearing flow
run(options?)Promise<FlowResult<T>>Execute a no-input flow
resume(flowId, options?)Promise<FlowResult<T>>Resume a suspended flow with its persisted input
signal(flowId, signalName, payload?)Promise<void>Send a signal to a suspended instance. Delegates to signalFlow() internally.

FlowRunOptions

Options passed to handle.run().

FieldTypeDescription
flowIdstring?Use a specific ID for cross-action correlation
parentFlowIdstring?Explicit parent flow ID for cross-action nesting
goalstring?Goal description for devtools display

FlowResumeOptions

Options passed to handle.resume().

FieldTypeDescription
parentFlowIdstring?Explicit parent flow ID for cross-action nesting
goalstring?Goal description for devtools display

FlowScope<TInput>

The scope object passed to the flow handler. Generic over TInput (defaults to void).

Property / MethodTypeDescription
flowIdstringThe flow's unique identifier
inputTInputTyped input data restored from the run input
resultsRecord<string, unknown>Auto-populated after each step completes, keyed by step label
step(label, fn, options?)Promise<T>Execute a named step. fn can be () => T or (flow: FlowScope) => T
suspend(name, options?)Promise<T>Suspend the flow at a named point and wait for an external signal
waitUntil(name, conditionFn, options?)Promise<void>Suspend until a condition function returns true
waitFor(event, options?)Promise<TPayload>Runtime-bound durable event wait
defer(task, input)Promise<{ workId }>Runtime-bound durable child task enqueue at the next barrier
after(task, delay, input)Promise<void>Runtime-bound durable child task timer at the next barrier
untilIdle({ scope: 'current-flow' })Promise<void>Runtime-bound wait for child work in the current flow
cancel(reason?)neverCancel the flow with an optional reason

step(label, fn, options?)

Execute a named step within the flow. Opens a canonical flow.step span and restores observability context so generated child spans nest under the step. On resume, completed steps are replayed from cache (skip-replay) and are not re-emitted.

For the Flows beta, label is the durable step identity. Each flow.step() label must be unique within one flow execution; duplicate labels throw before the runtime reads from the replay cache. Prefer stable literal labels. Dynamic labels are advanced and should only come from deterministic values that will be the same across retries and resumes.

fn accepts both plain functions () => T and flow-aware functions (flow: FlowScope) => T. Flow-aware functions receive the scope automatically, enabling external step definitions that access flow.input, flow.results, etc.

suspend(name, options?)

Suspend the flow and wait for an external signal. Throws internally to unwind the call stack -- no code after suspend() executes in the current call. The current flow emits a flow.suspension observability marker and rolls the flow/run presentation status to suspended; the causing step can still end ok. On resume, the signal payload is returned (typed if schema is provided).

waitUntil(name, conditionFn, options?)

Suspend the flow until a condition function returns true. Core evaluates the condition during the initial run and again only when your app explicitly resumes the flow. If the condition is true, execution continues; if false, the flow re-suspends. Core does not poll, schedule, or wake flows in the background.

Runtime-backed flow APIs

These APIs require a configured Runtime Engine. Without config({ runtime }), they throw RUNTIME_REQUIRED. Object-bound suspend(), signal(), and resume() remain available without runtime config.

waitFor(event, options?)

Suspend until a durable event arrives. On first execution Crux registers a waiter in the runtime store; on replay the same await resolves with the event payload.

const approval = await flow.waitFor(
  { name: "document.approved", schema: approvalSchema },
  { match: { documentId }, timeout: "24h" },
);

match compares top-level JSON payload fields. timeout schedules a durable timeout through the runtime.

defer(task, input)

Buffer an executable durable task to run independently. The task becomes durable at the next suspension or completion barrier, so replay cannot enqueue it twice.

const { workId } = await flow.defer(embedDocument, { documentId });

The target must be a durableTask() from @use-crux/core/runtime, not a plan-ledger task.

after(task, delay, input)

Buffer a durable child task timer. The timer is independent of the parent flow and becomes durable at the next barrier.

await flow.after(sendReminder, "2d", { userId });

untilIdle({ scope: 'current-flow' })

Suspend until child work created by the current flow reaches terminal state.

await flow.untilIdle({ scope: "current-flow" });

Global idle is not a v1 runtime primitive.

SuspendOptions<T>

FieldTypeDescription
schemaZodType<T>?Zod schema for the expected signal payload. Validated on signal delivery.
timeoutstring?Timeout duration (e.g., '24h', '30m'). Flow expires if not signaled within this period.
onExpired(state) => void?Callback invoked when a flow is detected as expired on resume.

StepOptions

Retry and fallback options for a flow step.

FieldTypeDescription
retry.attemptsnumberMax attempts (including the first)
retry.delaynumber?Base delay in ms (default: 1000)
retry.backoff'linear' | 'exponential'?Backoff strategy
fallback() => Promise<T> | TFallback if all retries fail

FlowResult<T>

Discriminated union on status:

VariantFieldsDescription
completedoutput: T, flowIdFlow finished normally
suspendedflowId, suspendedAtFlow paused at a suspend point
cancelledflowId, cancelReason?Flow cancelled via flow.cancel() or cancelFlow()
expiredflowId, suspendedAtSuspended flow timed out before a signal arrived

Every variant also carries _meta: OperationResultMeta for the current flow.run invocation. Resume preserves the validated W3C trace and opens a fresh span, so a resumed result has the same traceId and a fresh spanId. Invocation-local result IDs are removed from persisted input, step output, signals, and scheduled work; continuation trace carriers remain intact. See Operation Result Correlation.

FlowSnapshot

Persisted flow state stored in RecordStore under the crux:flow:{flowId} key.

FieldTypeDescription
flowIdstringFlow identifier
namestringFlow name
statusstringCurrent status
suspendedAtstringSuspend point name
completedStepsRecord<string, { output, durationMs }>Cached step results keyed by durable step label
traceContextRecord<string, string | undefined>Trace context metadata
createdAtnumberUnix timestamp (ms)
updatedAtnumberUnix timestamp (ms)

Lifecycle Functions

signalFlow(flowId, name, payload?)

Low-level primitive. signalFlow() only writes the signal to the RecordStore -- it does NOT trigger the flow to resume. Prefer handle.signal() which delegates to this internally. In Convex mutation-only modules, pair signalFlow() with ctx.scheduler.runAfter() in an app-local helper that also schedules the resume action. For flows with local signal maps, prefer handle.signal() so declared schemas validate before persistence.

Write a signal payload to the store at crux:signal:{flowId}:{name}. The signal is picked up on the next resume.

ParameterTypeDescription
flowIdstringThe suspended flow's ID
namestringSignal name (must match the suspend() point name)
payloadJsonObject?Signal data, validated against the suspend's schema on resume

cancelFlow(flowId, reason?)

Cancel a suspended flow by updating its stored snapshot status to 'cancelled'.

ParameterTypeDescription
flowIdstringThe flow's ID
reasonstring?Cancellation reason

handle.cancel(flowId)

Cancel a flow through the handle that defined it. Without a Runtime Engine, this delegates to cancelFlow(). With a Runtime Engine, it atomically marks the durable work and flow snapshot cancelled and cleans up the flow's waiter/timer registrations. Repeated calls and unknown flow ids are idempotent no-ops.

Cancelling a parent flow does not cancel independent child work created through flow.defer() or flow.after().

listFlows(options?)

Query the store for flow snapshots.

ParameterTypeDescription
options.statusstring?Filter by flow status (e.g., 'suspended')

Returns: Promise<FlowSummary[]> -- array of { flowId, name, status, suspendedAt, createdAt, updatedAt, timeoutAt? }

createFlowId()

Generates a unique flow ID for cross-boundary correlation. Returns string.

Error Classes

These errors are used internally for control flow -- they are caught by the flow runtime and converted to FlowResult variants. You should not normally need to catch them.

ErrorDescription
FlowSuspendedErrorThrown by flow.suspend() to unwind the call stack
FlowCancelledErrorThrown by flow.cancel() to unwind the call stack
FlowExpiredErrorThrown when a suspended flow's timeout has been exceeded

Suspend/Resume Example

import { flow, signalFlow } from "@use-crux/core/flow";
import { z } from "zod";

const approvalFlow = flow(
  "content-approval",
  async (flow, input: { topic: string }) => {
    const draft = await flow.step("draft", () =>
      generate(writer, { model, input }),
    );

    // Suspend and wait for human approval
    const approval = await flow.suspend("review", {
      schema: z.object({
        approved: z.boolean(),
        feedback: z.string().optional(),
      }),
      timeout: "24h",
      onExpired: ({ flowId }) => console.log(`Flow ${flowId} expired`),
    });

    if (!approval.approved) {
      flow.cancel("Rejected by reviewer");
    }

    return flow.step("publish", () =>
      generate(publisher, {
        model,
        input: { draft, feedback: approval.feedback },
      }),
    );
  },
);

// Start the flow
const result = await approvalFlow.run({ topic: "AI safety" });
// result.status === 'suspended'

// Later, signal the flow to resume
await approvalFlow.signal(result.flowId, "review", {
  approved: true,
  feedback: "Great draft, just fix the intro.",
});

// Resume the flow
const final = await approvalFlow.resume(result.flowId);
// final.status === 'completed'

Types

import type {
  FlowHandle,
  FlowResumeOptions,
  FlowRunOptions,
  FlowScope,
  FlowResult,
  FlowSnapshot,
  FlowSummary,
  ListFlowsOptions,
  SuspendOptions,
  StepOptions,
} from "@use-crux/core/flow";

import {
  FlowSuspendedError,
  FlowCancelledError,
  FlowExpiredError,
} from "@use-crux/core/flow";

On this page