Human handoff
Put the caller through to a person, and have a way out when nobody answers.
Project: examples/human-handoff. Put the caller through to a person, and have a way out when
nobody answers.
import { Agent, GlobalTools, SystemPrompt, Tool, TransferCallTool, z } from "curb";
<Agent>
<SystemPrompt>
When the caller asks for a person, call transfer_call straight away.
When the transfer fails, tell them nobody could be reached and offer a callback.
</SystemPrompt>
<GlobalTools>
<TransferCallTool to="+493012345678" ringTimeoutSeconds={25} />
<Tool
name="request_callback"
description="Ask the support desk to call the caller back."
schema={z.object({ phone: z.string().describe("The number to call back on.") })}
success="The callback is recorded."
onSubmit={(input) => {
// Send it where someone reads it: your own API here, or send.toHost (below).
return { ok: true, data: input };
}}
/>
</GlobalTools>
</Agent>
The model passes one sentence as announce, which the caller hears before anything is
dialed. They then hear a ringback tone. The model reads the outcome in plain words. When the
person leaves and the caller is still there, the agent takes the call back. A mailbox that picks
up counts as answered, so switch it off on the target number.
A transfer needs a host that dials: KugelAudio does on a hosted phone call, with the conditions in
Outbound calls and forwarding, and your
own host can (Host protocol). In curb dev and on a browser
call it is refused.
Under curb say and curb test no host answers, so a real transfer ends as failed and your
fallback runs: leave the tool unfaked to test that. To test the path where someone picks up, fake
it. A fake replaces the tool’s handler, so anything the handler would have set stays unset.
Transfer from your own code
On a hosted phone call the agent can put the caller through to another number. The
ready tool lets the model do it; useTransferCall() is the same thing for your own code.
import { Instruction, TransferCallTool, Tool, useTransferCall } from "curb";
export function Handover() {
const transfer = useTransferCall();
return (
<>
<Instruction>When the caller asks for a person, call transfer_call.</Instruction>
<TransferCallTool to="+493012345678" ringTimeoutSeconds={25} />
<Tool
name="call_claims_desk"
description="Put the caller through to the claims desk."
onCall={async () => {
const { outcome } = await transfer({
to: "+493098765432",
announce: "One moment, I am connecting you to the claims desk.",
});
return { ok: outcome === "answered", message: `Transfer: ${outcome}.` };
}}
/>
</>
);
}
transfer({ to, ringTimeoutSeconds?, announce? })resolves{ outcome, code?, message?, transferId? }withoutcomeone ofanswered,no_answer,busy,failed,refused. It never rejects for an outcome; a badto, aringTimeoutSecondsoutside 10 to 60 or a blankannouncethrowsTypeError(on<TransferCallTool>, an authoring error when it renders).- The caller never waits in silence.
announceis said first, in the agent’s voice: it cannot be interrupted, and the number is dialed only once it has played. Withoutannouncenothing is said. Then the caller hears a ringback tone (425 Hz, 1 s on, 4 s off) until the outcome arrives, whatever it is, or the call ends. Outside a phone call nothing plays. answered: the target is in the call and the agent is handed off: it stops listening and speaking. When the target leaves and the caller is still there, the agent takes the call back and is told the transfer ended. The caller hanging up ends the call.refused: nothing was dialed, and one error line in the log names the code.upgrade_requiredmeans the plan has nooutbound-callsfeature;transfer_unsupported_sessionmeans this is not a phone call (a browser session orcurb dev), where transfers never work;transfer_in_progress, one transfer at a time.failedwith codetransfer_no_reply: the host did not answer the request within 20 s, or sent no result within the ring time plus 30 s.<TransferCallTool>takesto, and optionallyname(defaulttransfer_call),descriptionandringTimeoutSeconds. The model calls it withannounce, the one sentence it says to the caller before connecting (“One moment, I am connecting you to our sales team.”); a call without one is refused and nobody is dialed, so the model calls again with it. The tool tells the model each outcome in plain words (“nobody answered”, “the line was busy”) so it can tell the caller.
A refused transfer comes back as outcome: "refused" with a code:
code | Why |
|---|---|
upgrade_required | The organization’s plan has no outbound calls |
transfer_unsupported_session | Not a phone call (a browser call or the preview) |
transfer_unsupported_number | The call’s number is from your own Twilio or Telnyx account |
transfer_in_progress | Another transfer of this call is still ringing |
callback_destination_blocked | The number is outside Germany, Austria and Switzerland, or premium-rate |
Next steps
- Complex handoffs: hand over by code, to a skill, or with a note.