HCC Docs

Call flows

A call flow is typed call logic in your own code. HCC POSTs each call event to your server, and your handler returns what the call does next. Handlers keep nothing in memory between events, so they run on Workers, serverless functions or any number of Node instances.

Three things make a project: a config, exports, and a server.

// hcc.config.ts
import { defineConfig } from '@hcc/sdk';

export default defineConfig({
  endpoint: 'https://example.com/hcc', // where HCC reaches your server
  // include: ['src/**/*.ts'],         // the default
});
// src/index.ts: on Workers. Elsewhere: `export const { POST } = handler(registry)` (Next.js and
// other Fetch routers), or `app.post('/hcc', handler(registry).node)` before express.json().
import { handler } from '@hcc/sdk';
import registry from './hcc.gen.ts'; // generated: don't edit it, do commit it

export default handler(registry);
hcc deploy          # to test (HCC_TEST_API_KEY). Writes hcc.gen.ts and .hcc/versions: commit both
hcc prune           # removes the .hcc/versions nothing needs (HCC_API_KEY): commit that
hcc deploy --live   # to live and test (HCC_API_KEY); --no-test for live only
hcc deploy --check  # writes and changes nothing; exits 1 if anything differs, or a frozen version is missing. For CI.
hcc generate        # just hcc.gen.ts: no key, no network

hcc finds what you export, by what it is, in any file under include. The file names and export names are up to you.

import { agent, defineCallFlow, env, handoff, tool } from '@hcc/sdk';
import { z } from 'zod';

export const qualify = defineCallFlow(
  'qualify',
  {
    input: z.object({ leadId: z.string(), name: z.string() }), // checked when the call is created
    timeout: '2s', // budget for each event (max 5s)
    numbers: [env('SALES_NUMBER')], // live calls to this number run this flow (hcc deploy --live)
    tools: {
      book: tool({
        description: 'Book a demo slot for the lead.',
        input: z.object({ slot: z.string() }),
        handler: async ({ slot }, call) => {
          await crm.book(call.id, slot);
          return { reply: `Booked for ${slot}` }; // JSON: back to the agent
        },
      }),
      not_interested: tool({
        description: 'The lead is not interested.',
        input: z.object({}),
        handler: () => handoff('not_interested'), // ends the session: the flow decides what's next
      }),
    },
    agents: {
      // The opt-in: we keep exactly these tools on this agent in its platform.
      qualifier: agent(env('QUALIFIER_AGENT_ID'), { tools: ['book', 'not_interested'] }),
    },
    handoffs: ['not_interested'],
    events: ['lead.flagged'],
    onHangup: (event, ctx) => crm.log(ctx.input.leadId, event),
    *onError(c) {
      yield* c.say('Sorry, something went wrong.');
      yield* c.hangup();
    },
  },
  function* (c) {
    const session = yield* c.connect('qualifier', {
      variables: { name: c.input.name },
      until: ['lead.flagged'], // this event ends the session
    });
    if (session.interrupted) {
      const { reason } = session.interrupted.payload as { reason: string };
      yield* c.say(reason);
    }
    yield* c.hangup({ disposition: session.handoff ?? 'completed' });
  },
);

// Start calls: `input` is typed by the flow
await hcc.calls.create({ from, to, flow: qualify, input: { leadId: 'l1', name: 'Ana' } });
await hcc.calls.emit(callId, 'lead.flagged', { reason: 'vip' }); // ends the wait on `until`

Deploy a flow

hcc deploy activates what's in your code, which is also how you roll back. New calls run the active version, and a call finishes on the version it started on (see During a deploy). Your server never registers anything: it only serves.

  • In CI: run hcc deploy before you commit, so .hcc/versions and hcc.gen.ts go in with the code, and hcc deploy --check in CI. Then ship the server, then hcc deploy --live. The other way round, new calls reach a server that doesn't have the flow yet. Files written only on a CI machine are gone by the next build, and hcc deploy --check fails on that.
  • Numbers. hcc deploy --live points each declared number at its flow, and needs numbers:write only when one has to change. On a key without it, everything else deploys, it says what's left, and it exits 0. A number that runs the flow and no code declares is left alone, with a warning.
  • Environments. hcc_test_ and hcc_live_ keys have separate flow registries, flow endpoints and calls.
  • Test tools. A live deploy's test copy syncs tools only to test branches, so a shared agent keeps the live tool set on its main; otherwise the simulator plays agents from the flow's own bindings.

Write a flow file

  • It must be safe to import. hcc imports every file under include that names defineCallFlow, defineCampaign, defineQueue or defineRecordingPolicy, on Node. Keep listen() and cloudflare: imports out of them. Each scan is a fresh process, without your API keys.
  • Bindings come from c.env in a flow, or ctx.env in a tool.
  • A flow made by a factory isn't found: see Without hcc.config.ts.
  • Values that vary by deployment are env('NAME'): an agent id, a number, a queue's overflow. hcc reads them from the environment, .dev.vars and .env in that order, and lists every missing one before sending anything. Your server reads them too, so set them in both places. The version depends on the name and not the value, so test and live run one version. In tests, testCall(flow, { env }).
  • Agent tools are synchronous: the agent waits for your handler, within the tool's timeout (default: the flow's). See Agent tools and bindings.
  • Queues and Reps are reached with c.transfer(queue(…)) and c.transfer(rep(…)). See Queues and reps.

Serve a flow

handler reads HCC_API_KEY and HCC_TEST_API_KEY from the environment, and serves each environment you give it a key for. They need only flows:read, to fetch the signing secret; or set HCC_FLOW_SECRET and HCC_TEST_FLOW_SECRET.

It reports what it serves once per key. The Flows page and your logs say if that differs from what's deployed: "Served, not deployed", "Different version", "Different agent" or "Not served".

When an event repeats or fails

A repeated event isn't a second run. If a carrier or platform sends an event twice, the repeat gets your first answer, and your handler doesn't run.

  • If the first is still running, the repeat waits for it, for up to 14 s, or takes over if it died.
  • Past that a caller hears an apology and the call ends, because the carrier is about to give up anyway, and you get a call.flow.fallback with the reason repeat_timeout.
  • On a tool call or a custom event the repeat gets an error or applied: false, and the call goes on.
  • We keep that answer sealed until the call ends, and not at all past 64 KB.
  • A failed onHangup is the exception: it's retried up to three times with the same ctx.eventId.

An error your code doesn't catch runs onError, then the number's Default flow. Each fallback is published as a call.flow.fallback Event.