Handoff (agent transfer protocol)
The SDK's handoff platform turns "transfer me to a human" from a UI hint into a real cross-system protocol. This concept doc explains the model; see the cockpit guide and the CCaaS guide for integration steps.
Mental model
A handoff has three actors:
- Customer side — the chat session driven by
useChatSession/ChatSession. The customer seesAgentTransferPartparts in their transcript. - Cockpit side — a human agent driven by
useAgentSessionfromgecx-chat-cockpit/react. The cockpit sees the customer transcript, customer info, and cockpit-only controls (pick up, hold, resolve, transfer). - CCaaS adapter — the back-end system of record (Salesforce,
Google CCAI, ServiceNow, Zendesk, Genesys, Five9). The adapter sees
a
TransferContextBundleand records the engagement.
A HandoffController exists on both sides and runs the same protocol
state machine. The transport propagates handoff.status events between
them; both sides stay in sync.
Transfer types
type TransferType =
| 'bot_to_human' // default — bot → live agent
| 'bot_to_bot' // router → specialist agent (A2A)
| 'human_to_human' // tier 1 → tier 2 escalation
| 'supervisor_consult' // agent ↔ supervisor side-channel
| 'blind' // hand-off without context (legacy)
| 'warm'; // hand-off with full context bundle
AgentTransferPart carries transferType and the legacy status
discriminator. Older clients that switch on type === 'agent-transfer'
keep working; the new types are purely additive.
Internal handoff via agent graph
The 'bot_to_bot' transfer type is used by agent graphs when a router dispatches a turn to a specialist over Google's A2A protocol. It is the internal handoff path — fast, in-process (router decision <200 ms p99), observable through analytics, and completely separate from the human-handoff FSM.
Two optional fields support internal handoff observability:
routeDecisiononAgentTransferPartandHandoffStatusEvent— which specialist was chosen and which rule matched.graphPath— ordered list of router and specialist node ids the turn traversed.
The HandoffController is not invoked for internal handoff. Routing happens inside createAgentGraphTransport() (a ChatTransport wrapper) before the request reaches the handoff layer. External (bot-to-human) handoff continues through HandoffController unchanged, including for graphs that route to the reserved 'human' target.
See Agent Graphs for the routing model and Agent Graph guide for the API.
State machine
idle ─requested→ requested
│
├─queue→ queued ─pickup→ ringing ─accept→ connected
│ │
│ ├─hold→ on_hold ─resume→ connected
│ │
│ ├─complete→ completed
│ │
│ └─end→ ended
│
└─(any)→ failed | cancelled
The state machine lives in
packages/gecx-chat/src/handoff/protocol.ts.
Transitions are pure: reduceHandoffProtocol(prev, action) → next.
Conflict resolution
When two agents try to PICKUP the same queued transfer:
- First
PICKUPwins.state.claimedByis set. - Subsequent
PICKUPcalls returnerror: 'HANDOFF_ALREADY_CLAIMED'and leave state unchanged.
Idempotency
REQUEST carries an idempotencyKey. Retransmitting the same key while
the previous request is still non-terminal is a no-op. A different key
during a non-terminal session is rejected as HANDOFF_DUPLICATE_REQUEST.
Context bundle
buildTransferContextBundle({ ... }) produces a TransferContextBundle:
interface TransferContextBundle {
bundleId: string;
sessionId: string;
transferType: TransferType;
identity?: ChatIdentity;
transcriptSummary?: string;
transcriptDigest: string; // stable hash of normalized transcript
messageCount: number;
warmContext?: WarmTransferContext;
voice?: VoiceHandoffEnvelope;
customAttributes?: Record<string, string | number | boolean>;
createdAt: string;
}
The bundle is what CCaaS adapters consume. The customer's
AgentTransferPart references the bundle by bundleId and embeds the
lean fields the UI renders.
Telephony coexistence
A VoiceHandoffEnvelope ({ sipUri, callerId, codec, dtmfSupported })
travels in the bundle. The state machine remains in connected; the
transport is expected to swap the audio peer. The reference SIP plumbing
is customer-side and out of the SDK's scope.
Completion semantics
A resolved handoff is not the same as a generic ended session. The protocol distinguishes them:
COMPLETEaction on the protocol — moves the FSM to a terminalcompletedstate with acompletedAttimestamp.HandoffController.complete()— the SDK-side API a cockpit calls when an agent marks the conversation resolved.- Cockpit
completeSession()— the agent-side action exposed onuseAgentSession. The default cockpit surface renders this as a "Resolve" button.
completed is distinct from ended (server- or client-terminated session). Analytics, governance audit, and CSAT triggers can treat resolved handoffs differently from generic session endings.
Audit
Every transition emits a handoff_transition event from the session.
The cockpit emits cockpit-action audit events (pickup, hold,
transfer, complete, …). Wire both into your governance audit sink
for a complete handoff timeline.
See also
- Agent cockpit guide — how to embed
useAgentSessionin your own agent desktop. - Agent graphs — internal
bot_to_bothandoff via A2A specialists. - CCaaS integration guide — per-platform adapter setup.
- Sentiment and intent signals — sentiment-driven escalation
reuses
HandoffController.requestTransfer()unchanged. - Migration from widget — how prior widget handoff hooks map to the new protocol.
docs/concepts/handoff.md