Flows as generators
A flow is one function that reads top to bottom. Pass it as the third argument, and use yield* where a call waits.
import { agent, defineCallFlow, env, handoff, tool, TransferFailed } from '@hcc/sdk';
import { z } from 'zod';
export const support = defineCallFlow(
'support',
{
tools: {
talk_to_human: tool({
description: 'Hand the caller to a person.',
input: z.object({}),
handler: () => handoff('human'),
}),
},
agents: { assistant: agent(env('SUPPORT_AGENT_ID'), { tools: 'all' }) },
handoffs: ['human'],
*onError(c, error) {
yield* c.say('Sorry, something went wrong.');
yield* c.hangup({ disposition: 'on_error' });
},
},
function* (c) {
const { digits } = yield* c.gather('Press 1 for our assistant, 2 for your balance.');
if (digits === '2') {
const balance = yield* c.run('balance', () => fetchBalance(c.call.from));
yield* c.say(`Your balance is ${balance}.`);
return yield* c.hangup();
}
const session = yield* c.connect('assistant');
if (session.handoff !== 'human') return yield* c.hangup();
try {
yield* c.transfer(salesQueue);
} catch (error) {
if (!(error instanceof TransferFailed)) throw error;
yield* c.say('Nobody can take your call. Please leave a message.');
yield* c.record({ maxLengthSeconds: 120 });
}
},
);How it runs
We keep nothing of your code. On every event your server gets the call's history, runs the function again from the top, and feeds it the answers it has already had. So:
- Everything outside
c.runruns again on every event. Local variables replacectx.state, and aletin a loop just works. - Anything with an effect goes in
c.run. An unwrapped CRM note is written on every keypress, andDate.now()in a branch can fail a later event. awaitisn't allowed in afunction*. Put it insidec.run. A missingyield*is an error when that event runs.
Run a side effect
c.run(name, (key) => ...) stores its result, and a replay doesn't run it again.
- It's at least once. If the server dies, or another event of the call lands first, before the result is stored, it runs again.
- Pass
keyto your own systems as an idempotency key, so a run that repeats does nothing new. It's the same each time thatc.runruns on that call, and differs from any other's on the call, the same name in a loop included. keyholds ids, a position and a hash of the name, nothing the caller entered. The position is where the run sits in the call, so a deploy that adds a step before it gives it another key.- Size. A result is JSON, at most 4 KB; the whole history is at most 64 KB. Past those,
HistoryTooLargeis thrown at theyield*, or the event fails andonErrorruns.
Connect an agent
c.connect(agent) waits for the session to end. A tool that returns handoff(name, data) ends it, and c.connect returns with handoff set. Otherwise a tool just answers the agent, and returning actions from one is an error on this kind of flow. handoffs: [...] is optional, and turns a mistyped name into a compile error.
When the agent just finishes, the agent's stream closes, the call asks us what's next, and the code after c.connect runs with reason: 'ended'.
- Branch on
handoffandinterrupted, not on other reasons. At that moment nobody has said why the stream closed, so a session that failed looks the same. If the platform's report happens to arrive first, you'll get itscompletedorfailedand a summary instead. - There's usually no summary: the flow doesn't wait for it. It comes in
conversation.transcript.ready, later. - If the caller hangs up on the agent, the code after
c.connectnormally doesn't run:finallyandonHangupdo. The exception is a call that never asks us what's next and whose report arrives before the carrier says the call ended: that report runs it with nobody listening, so don't assume the caller heard what follows. - In the simulator, the documented order is the default;
sim.agent.end('completed', { order: 'webhook_first' })sends the report first.
Handle a failed transfer
A failed transfer throws TransferFailed at the yield*, with its reason. Uncaught, the platform does what it does for any transfer: the queue's overflow, or the Default flow. A transfer that works never comes back.
Wait for a custom event
Declare events: ['paid']. yield* c.gather(prompt, { until: ['paid'] }) or c.connect(..., { until }) returns { interrupted } when one lands; others wait in an inbox for yield* c.event('paid'). Play something first (c.play(url, { loop: 100 })): with nothing holding the caller, the call ends.
Take sensitive digits
c.gather(prompt, { sensitive: true, check }) gives the digits only to check, and stores what it returns. The flow gets { result }, or { interrupted }, or neither on silence, when check doesn't run. Declare onError, and return a value that can't equal the entry, such as { matched: true }.
- If
checkthrows, aRunFailedwith a generic message is thrown at theyield*, so a card number in an error can't reach the history. - A result holding the entry as a whole value fails the event: as itself, as a number, or as a key, at any depth. That's so even when it only happens to match:
{ memberSince: 2026 }for an entry of2026, or{ choice: '1' }for1. The flow can't catch this:onErrorruns if you declared one, otherwise the Default flow, which may hang up. - The entry inside a longer string only warns, without the value, and it's stored:
2026is in2026-11-03,1234is inORD-1234, and a caller who entered the right code can't be hung up over that. So don't build text from the entry. - What it can't see is yours to avoid: part of the entry, the entry changed or hashed, or the entry joined to other letters or digits.
- An entry under 3 characters isn't compared as a number, so
{ attempts: 1 }isn't refused.
When the caller hangs up
A hangup replays to where the call was, runs your finally, then the onHangup hook. A c.run in a finally isn't recorded, and other steps there are skipped. finally can run more than once if the hangup is retried.
Hooks
onError and onVoicemail are generators too, and run in place of the main function. A failure in onError goes to the Default flow. onNoAnswer and onHangup are plain hooks.