Skip to Content
ConceptsStep context

Step context

Every workflow loop’s run() receives a step object as its second argument. step exposes checkpointed work (do), suspension primitives (waitForScan, waitForVoiceComplete), and inside a step.do callback you also receive a context object (ctx) that gives that step access to the platform’s built-in tools.

Step

type Step = { do<T>( name: string, fn: (args: { ctx: StepCtx }) => Promise<T> | T, ): Promise<T>; waitForScan<T = Record<string, unknown>>(opts: { timeout: string; }): Promise<Scan<T>>; waitForVoiceComplete(opts: { sessionId: string; timeout?: string; }): Promise<Call>; };

Methods

MethodDescription
doRun a piece of work as a named, checkpointed unit. The callback’s return value is persisted and replayed if the step has already succeeded.
waitForScanSuspend the run until the scan provisioned in an earlier step completes, or the timeout fires. Resolves with a Scan<T> handle — see below.
waitForVoiceCompleteSuspend the run until a voice call started with ctx.startVoice ends (or the timeout fires). Resolves with a Call handle — see below.

Step names must be unique within a single run() and stable across deploys — they’re the identifier the platform uses to match a step against its replay record. For the full durability model, see Runtime and organizations.

waitForScan and waitForVoiceComplete also run internal durable sub-steps (AI summary generation, PDF rendering for scans; transcript summary for voice) before resolving. Those appear in the run timeline as wait-for-scan:generate-summary, wait-for-scan:generate-pdf, and wait-for-voice:<sessionId>:summary.

Scan and Call handles

waitForScan no longer returns the raw scan payload — it returns a Scan<T> handle:

class Scan<T> { readonly id: string; readonly results: T; pdf(): Promise<Uint8Array>; pdfUrl(): Promise<string>; }
  • results is the scan payload (metadata, intake, measurements, etc.).
  • pdf() and pdfUrl() fetch the results PDF the platform generates as part of waitForScan. Use pdfUrl() when sending the report through WhatsApp, email, or another channel that needs a hosted link.

waitForVoiceComplete returns a Call handle:

class Call { readonly sessionId: string; readonly transcript: { role: "user" | "assistant"; content: string }[]; readonly summary: string | null; }

StepCtx

type StepCtx = { FACIAL_SCAN: FacialScanTool; HEALTH_CALCULATOR: HealthCalculatorTool; USER: { getUser(input: { id: string }): Promise<User> }; startVoice(input: { loopId: string; user?: User; body?: unknown; to?: string; // E.164 for outbound phone calls; omit for browser calls }): Promise<{ sessionId: string; joinUrl: string; callSid?: string }>; VOICE: VoiceSessionTool; // lower-level; prefer waitForVoiceComplete };

Properties

PropertyTypeDescription
FACIAL_SCANFacialScanToolThe built-in facial scan tool. See Facial scan.
HEALTH_CALCULATORHealthCalculatorToolDeterministic vital-sign reference math. See Health calculator.
USER{ getUser }Org-wide user store. See Users.
startVoicefunctionStart a voice loop call; returns sessionId and joinUrl. See Voice loops.
VOICEVoiceSessionToolLow-level call-session controls used internally by waitForVoiceComplete.

ctx is only available inside a step.do callback, and on startVoice at the top level of run(). Outside of a step — between steps, after return — it doesn’t exist.

Access the context

Destructure ctx from the step callback’s argument:

await step.do("Generate a health scan", async ({ ctx }) => { await ctx.FACIAL_SCAN.create({ expiresIn: 3600, metadata: { patientId }, }); });

If your step doesn’t need a tool, you can ignore the argument entirely:

await step.do("Compute total", async () => { return items.reduce((sum, item) => sum + item.price, 0); });

Examples

Provision a scan, then wait for it

step.do kicks off the scan; step.waitForScan parks the run until results land:

await step.do("Generate a health scan", async ({ ctx }) => { await ctx.FACIAL_SCAN.create({ expiresIn: 3600, metadata: { patientId, phoneNumber }, }); }); const scan = await step.waitForScan({ timeout: "30 minutes" }); console.log(scan.results.metadata.patientId); const reportUrl = await scan.pdfUrl(); // ready to send

While suspended, the loop isn’t consuming compute. When the scan completes, the run resumes from the waitForScan line with a Scan handle whose results field holds the payload.

Start a voice call and wait for it

const user = await step.do("Identify patient", async ({ ctx }) => { return ctx.USER.getUser({ id: event.body.patientId }); }); const { sessionId, joinUrl } = await step.do("Start intake call", async ({ ctx }) => { return ctx.startVoice({ loopId: "intake-voice-loop-id", user, body: { reason: "follow-up" }, }); }); // Hand joinUrl to the patient (SMS, email, etc.) while the run is suspended const call = await step.waitForVoiceComplete({ sessionId, timeout: "30 minutes", }); return { summary: call.summary, turns: call.transcript.length };

Peek at scan state without waiting

For flows that want to short-circuit when a scan has already failed, read state directly with getResults:

const { status } = await step.do("Check scan", async ({ ctx }) => { return ctx.FACIAL_SCAN.getResults(); }); if (status === "failed") { return { error: "scan failed before processing" }; }

Carry per-run context through the scan

Anything passed as metadata to FACIAL_SCAN.create is echoed back on completion, so use it for IDs you’ll need on the other side instead of refetching them:

await step.do("Generate a health scan", async ({ ctx }) => { await ctx.FACIAL_SCAN.create({ expiresIn: 3600, metadata: { patientId, phoneNumber, source: event.body.source }, }); }); const scan = await step.waitForScan<{ metadata: { patientId: string } }>({ timeout: "30 minutes", }); console.log(scan.results.metadata.patientId); // same patientId you put in
Last updated on