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 sees AgentTransferPart parts in their transcript.
  • Cockpit side — a human agent driven by useAgentSession from gecx-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 TransferContextBundle and 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:

  • routeDecision on AgentTransferPart and HandoffStatusEvent — 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 PICKUP wins. state.claimedBy is set.
  • Subsequent PICKUP calls return error: '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:

  • COMPLETE action on the protocol — moves the FSM to a terminal completed state with a completedAt timestamp.
  • HandoffController.complete() — the SDK-side API a cockpit calls when an agent marks the conversation resolved.
  • Cockpit completeSession() — the agent-side action exposed on useAgentSession. 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

Source: docs/concepts/handoff.md