Skip to Content
ConceptsVoice loops

Voice loops

A voice loop is a loop that extends VoiceAgent instead of Workflow. The platform owns the realtime session — speech-to-text, text-to-speech, and the WebRTC transport — and calls your onTurn() once it has transcribed each spoken utterance. Your job is to decide what to say.

Use voice loops for phone intake, follow-up calls, and any flow where the user speaks rather than types.

Shape

export class HealthLoop extends VoiceAgent { async onTurn(transcript, ctx) { // The system prompt is set in the editor's System prompt panel. return ctx.OLLIE.reply({ messages: [...ctx.messages, { role: "user", content: transcript }], tools: ctx.tools(), }); } }
PieceDescription
transcriptWhat the caller just said, as text.
ctx.messagesPrior turns of this call, oldest first.
ctx.bodyThe JSON body the call was started with (via ctx.startVoice({ body })).
ctx.systemPromptThe saved system prompt from the System prompt panel — applied automatically unless you pass system to reply.
ctx.OLLIE.replyGenerate the spoken reply. Returns a streaming text response the platform pipes to text-to-speech.
ctx.signalAborts when the caller barges in or the call ends.

Return the Response from ctx.OLLIE.reply() directly — the platform handles the rest.

Voice loops use onTurn(transcript, ctx) — two positional arguments, not a destructured object. There is no step parameter. Do not use ctx.OLLIE.chat() here; that API is for agent loops only.

OLLIE.reply options

type VoiceReplyInput = { system?: string; messages: { role: "system" | "user" | "assistant"; content: string }[]; tools?: LoopToolSpec[]; thinking?: "low" | "medium" | "high"; options?: { /** Inject the caller's most recent completed scan into the system context. */ useLatestScan?: boolean; }; };

Pass [...ctx.messages, { role: "user", content: transcript }] as messages so the model sees the full call history plus the latest utterance. Include { role: "system", content: "..." } turns to inject per-turn instructions that aren’t written to the stored transcript.

Starting a voice call from a workflow loop

Workflow loops start voice calls with ctx.startVoice and wait with step.waitForVoiceComplete:

export class HealthLoop extends Workflow { async run(event: HealthEvent, step: HealthStep) { const user = await step.do("Identify patient", async ({ ctx }) => { return ctx.USER.getUser({ id: event.body.patientId }); }); const { sessionId, joinUrl } = await step.do("Start call", async ({ ctx }) => { return ctx.startVoice({ loopId: "my-voice-loop-id", user, body: { reason: "follow-up" }, // to: "+15551234567", // omit for browser calls; set for outbound phone }); }); // Hand joinUrl to the patient while the run is suspended const call = await step.waitForVoiceComplete({ sessionId, timeout: "30 minutes", }); return { summary: call.summary, turns: call.transcript.length }; } }
FieldDescription
sessionIdPass to step.waitForVoiceComplete.
joinUrlURL the end user opens in a browser to join the call.
callSidPresent when to is set — the outbound phone call identifier.

The call runs independently until you await waitForVoiceComplete. While suspended, the workflow loop isn’t consuming compute.

Join URL

Voice calls are joined from the browser at a URL shaped like:

https://loops.ollie.health/voice/<orgId>/<loopId>/<sessionId>

Hand this link to the caller via SMS, email, or an in-app button. For outbound phone calls (to set), the platform dials the number and bridges the call to the voice loop.

Workflow and voice tools mid-call

Voice loops support the same tool mechanism as agent loops. Declare tools with ctx.workflow() / ctx.voice(), build the list with ctx.tools({ loops }), and pass it to ctx.OLLIE.reply:

async onTurn(transcript, ctx) { const intakeTool = ctx.workflow({ loopId: "intake-workflow-loop-id", description: "Start a facial scan intake", inputSchema: z.object({ patientId: z.string() }), }); return ctx.OLLIE.reply({ messages: [...ctx.messages, { role: "user", content: transcript }], tools: ctx.tools({ loops: { start_intake: intakeTool } }), }); }

Workflow tools start a background run — the model receives { runId, workflowInstanceId, … }, not the child loop’s output. Voice tools return { runId, sessionId, joinUrl, callSid? }. Do not chain workflow tools for synchronous API lookups (search, slots, booking) where the model needs each step’s data; call those APIs directly in the voice loop instead. See Loop-as-tool.

Runs and observability

Voice loops produce runs — one per call. Open the Runs tab to see call history. Each run’s steps include wait-for-voice:<sessionId> (when started from a workflow) and the internal summary step. The run’s output includes the call summary and transcript metadata.

Where to go next

  • Loop-as-tool — declare other loops as tools, return shapes, and limitations.
  • Step contextwaitForVoiceComplete and the Call handle.
  • Agent loops — chat-based loops with the same tool mechanism.
  • Users — identify callers and inject scan context with useLatestScan.
Last updated on