Human handoff

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? } with outcome one of answered, no_answer, busy, failed, refused. It never rejects for an outcome; a bad to, a ringTimeoutSeconds outside 10 to 60 or a blank announce throws TypeError (on <TransferCallTool>, an authoring error when it renders).
  • The caller never waits in silence. announce is said first, in the agent’s voice: it cannot be interrupted, and the number is dialed only once it has played. Without announce nothing 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_required means the plan has no outbound-calls feature; transfer_unsupported_session means this is not a phone call (a browser session or curb dev), where transfers never work; transfer_in_progress, one transfer at a time.
  • failed with code transfer_no_reply: the host did not answer the request within 20 s, or sent no result within the ring time plus 30 s.
  • <TransferCallTool> takes to, and optionally name (default transfer_call), description and ringTimeoutSeconds. The model calls it with announce, 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:

codeWhy
upgrade_requiredThe organization’s plan has no outbound calls
transfer_unsupported_sessionNot a phone call (a browser call or the preview)
transfer_unsupported_numberThe call’s number is from your own Twilio or Telnyx account
transfer_in_progressAnother transfer of this call is still ringing
callback_destination_blockedThe number is outside Germany, Austria and Switzerland, or premium-rate

Next steps