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 networkhcc 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 deploybefore you commit, so.hcc/versionsandhcc.gen.tsgo in with the code, andhcc deploy --checkin CI. Then ship the server, thenhcc 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, andhcc deploy --checkfails on that. - Numbers.
hcc deploy --livepoints each declared number at its flow, and needsnumbers:writeonly 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_andhcc_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.
hccimports every file underincludethat namesdefineCallFlow,defineCampaign,defineQueueordefineRecordingPolicy, on Node. Keeplisten()andcloudflare:imports out of them. Each scan is a fresh process, without your API keys. - Bindings come from
c.envin a flow, orctx.envin 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.hccreads them from the environment,.dev.varsand.envin 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(…))andc.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.fallbackwith the reasonrepeat_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
onHangupis the exception: it's retried up to three times with the samectx.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.