HCC Docs

Transfers

The flow moves the caller to someone else with c.transfer. A tool asks for it by returning handoff, and code outside the flow by sending a custom event. transfer is blind: whoever is connected (the agent, a dialed party) leaves, the target joins, and the caller stays on.

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

export const route = defineCallFlow(
  'route',
  {
    tools: {
      wants_human: tool({
        description: 'The caller wants a person.',
        input: z.object({}),
        afterSpeech: true,
        handler: () => handoff('human'),
      }),
      to_closer: tool({
        description: 'Hand over to a closer.',
        input: z.object({}),
        handler: () => handoff('closer'),
      }),
      book: tool({
        description: 'Book a slot.',
        input: z.object({ slot: z.string() }),
        handler: () => ({ reply: 'Booked' }),
      }),
    },
    agents: {
      qualifier: agent('agt_qualifier', { tools: ['wants_human', 'to_closer'] }),
      closer: agent('agt_closer', { tools: ['book'] }),
    },
    handoffs: ['human', 'closer'],
    events: ['escalate'],
  },
  function* (c) {
    const session = yield* c.connect('qualifier', { until: ['escalate'] });
    try {
      if (session.interrupted) {
        yield* c.transfer(sip('sip:desk@pbx.example.com'));
      } else if (session.handoff === 'closer') {
        yield* c.transfer('closer'); // connects the closer with its own bound tools
      } else if (session.handoff === 'human') {
        yield* c.say('Connecting you now.');
        yield* c.transfer(number('+15550100003'), {
          timeout: '25s', // ring time before it fails with no_answer
          callerId: '+15550100009', // defaults to the call's number
        });
      } else {
        yield* c.hangup();
      }
    } catch (error) {
      if (!(error instanceof TransferFailed)) throw error;
      // error.reason: busy | no_answer | failed | unavailable; error.target: { type, agentId | to | uri }
      yield* c.say('Nobody is available right now.');
      yield* c.hangup({ disposition: `transfer_${error.reason}` });
    }
  },
);

await hcc.calls.emit(callId, 'escalate'); // from your CRM: transfer a running call
  • The caller is never left alone. If the target is busy, doesn't answer or can't be reached, the caller waits while your catch decides. If you don't catch it, the call's Default flow runs (its default agent, or an apology).
  • Events: call.transfer.started, call.transfer.completed and call.transfer.failed, plus the call.participant.* Events of whoever joins and leaves (reason: handoff).
  • A transfer that works never comes back, so nothing after it runs. Use c.connect(number | sip) for a plain bridge: the code after it runs when the bridge ends or nobody answers.
  • Calls only grow into a conference when they bridge to a number or a SIP endpoint. A call that just talks to a stream agent never gets one.
  • There is no REST transfer endpoint: hcc.calls.emit is how code outside the flow transfers a call, and only while the flow is at a c.gather or c.connect whose until names the event. Once the caller is in a queue or with a person, the event just waits in the inbox. A warm transfer from a Rep is different: see Warm transfers.