HCC Docs

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.run runs again on every event. Local variables replace ctx.state, and a let in a loop just works.
  • Anything with an effect goes in c.run. An unwrapped CRM note is written on every keypress, and Date.now() in a branch can fail a later event.
  • await isn't allowed in a function*. Put it inside c.run. A missing yield* 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 key to your own systems as an idempotency key, so a run that repeats does nothing new. It's the same each time that c.run runs on that call, and differs from any other's on the call, the same name in a loop included.
  • key holds 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, HistoryTooLarge is thrown at the yield*, or the event fails and onError runs.

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 handoff and interrupted, 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 its completed or failed and 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.connect normally doesn't run: finally and onHangup do. 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 check throws, a RunFailed with a generic message is thrown at the yield*, 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 of 2026, or { choice: '1' } for 1. The flow can't catch this: onError runs 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: 2026 is in 2026-11-03, 1234 is in ORD-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.