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(),
});
}
}| Piece | Description |
|---|---|
transcript | What the caller just said, as text. |
ctx.messages | Prior turns of this call, oldest first. |
ctx.body | The JSON body the call was started with (via ctx.startVoice({ body })). |
ctx.systemPrompt | The saved system prompt from the System prompt panel — applied automatically unless you pass system to reply. |
ctx.OLLIE.reply | Generate the spoken reply. Returns a streaming text response the platform pipes to text-to-speech. |
ctx.signal | Aborts 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 };
}
}| Field | Description |
|---|---|
sessionId | Pass to step.waitForVoiceComplete. |
joinUrl | URL the end user opens in a browser to join the call. |
callSid | Present 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 context —
waitForVoiceCompleteand theCallhandle. - Agent loops — chat-based loops with the same tool mechanism.
- Users — identify callers and inject scan context with
useLatestScan.