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
| Method | Description |
|---|---|
do | Run a piece of work as a named, checkpointed unit. The callback’s return value is persisted and replayed if the step has already succeeded. |
waitForScan | Suspend the run until the scan provisioned in an earlier step completes, or the timeout fires. Resolves with a Scan<T> handle — see below. |
waitForVoiceComplete | Suspend 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>;
}resultsis the scan payload (metadata, intake, measurements, etc.).pdf()andpdfUrl()fetch the results PDF the platform generates as part ofwaitForScan. UsepdfUrl()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
| Property | Type | Description |
|---|---|---|
FACIAL_SCAN | FacialScanTool | The built-in facial scan tool. See Facial scan. |
HEALTH_CALCULATOR | HealthCalculatorTool | Deterministic vital-sign reference math. See Health calculator. |
USER | { getUser } | Org-wide user store. See Users. |
startVoice | function | Start a voice loop call; returns sessionId and joinUrl. See Voice loops. |
VOICE | VoiceSessionTool | Low-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 sendWhile 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