Development

Adding Human Handoff to an Agent: Design and Metrics

Antonio Evans10 min read

A cutaway diagram of an agent pipeline: on the left, a user message enters an agent loop drawn as three stacked boxes (plan, act, respond).
Key points

Key points

  1. 1A handoff is a state transition the agent can request through an explicit tool call, not an exception that surfaces when everything else fails.
  2. 2Trigger escalation on four conditions you can measure: an explicit request for a person, a confidence score below a threshold set from data, a repeated tool failure, or a match on a policy list.
  3. 3Emit handoff facts as span attributes and events so a single trace shows who was escalated, why, who took it, and how long the person waited.
  4. 4Route assignees with a scored function over skill match, availability, queue depth, and recency, and always define a fallback when nobody is online.

Treat the handoff as a state transition, not a crash

Most first attempts at handoff wrap the agent call in a try/except and send the user to a human when something throws. That produces a handoff record only in the failure case, so you cannot count escalations you never saw, cannot distinguish a frustrated user from a broken tool, and cannot route anything because you did not capture why the escalation happened.

A better design makes escalation a first-class tool in the agent's toolset, alongside search, lookup, and update. The agent calls it deliberately, the runtime validates the arguments against a schema, and every call writes a structured row. The short answer format is a function with a fixed signature:

{
  "name": "escalate",
  "description": "Transfer the conversation to a human agent. Use when the user asks for a person, when you cannot resolve the request in two attempts, or when the topic is on the restricted list.",
  "parameters": {
    "type": "object",
    "properties": {
      "reason": {"enum": ["user_request", "low_confidence", "repeated_failure", "policy_risk"]},
      "priority": {"enum": ["p1", "p2", "p3"]},
      "summary": {"type": "string", "maxLength": 1200},
      "topic": {"type": "string"},
      "blocking_facts": {"type": "array", "items": {"type": "string"}}
    },
    "required": ["reason", "priority", "summary", "topic"]
  }
}

The reason enum is the most valuable field in the whole schema: it turns every escalation into a category you can count and chart. Capping summary forces the model to compress what matters for the human instead of dumping the transcript, and blocking_facts (account ID, order number, error code) lets the assignee start working without a discovery call.

The runtime, not the model, owns the side effects: it persists the record, runs the routing function, starts the queue timer, and emits the telemetry. Keep the tool idempotent on a conversation ID so a model that calls it twice in one turn does not open two tickets.

The four trigger conditions

The agent should not decide alone when the stakes are high. Put each trigger where it can be evaluated deterministically, and let the model call the tool only for the triggers that genuinely need judgment.

Explicit user request. Detect phrases like "human," "agent," "representative," or "supervisor" in the input, and treat them as a hard trigger. This is the one condition where you should not optimize for containment: every extra turn spent talking the user out of a person raises the chance they leave. If a request is detected, escalate on the same turn.

Low confidence. Confidence needs a source. Three practical options: a calibrated probability from the model's own logprobs on a classification step, a small scorer model that grades whether the draft answer is supported by retrieved facts using an evaluation pattern, or a deterministic signal like "retrieval returned zero documents above the similarity cutoff." Whatever you use, set the threshold from data, not intuition. Pull a sample of past conversations, label each as resolved or not resolved, and pick the cutoff that maximizes the number of real failures caught while keeping the escalation volume your staff can absorb. If you want to run both a classifier and a fallback model behind one key to keep the scorer and the agent on the same platform, Quanta Inference exposes text and embedding models through one OpenAI-compatible API.

Repeated failure. Count tool calls by name and arguments within a conversation. If the same call fails twice with the same error class, escalate rather than let the model retry a third time. This is a deterministic rule the runtime can enforce without asking the model, so implement it in code and tell the agent about it in the system prompt so it also cooperates.

Policy risk. Maintain a list of topics that always go to a person: billing disputes above a value cutoff, safety incidents, account deletion, legal threats, and anything touching a regulated process. Match on a classifier plus a keyword backstop. Risk-based routing, where review is reserved for high-impact or uncertain decisions rather than every decision, is the standard argument in the human-in-the-loop literature (Databricks): it is what makes oversight scale.

TriggerWhere it runsDefault priorityFailure mode if missing
Explicit user requestRuntime, on every inbound messagep2User repeats, then abandons
Low confidenceAfter the drafting stepp3, p2 if money involvedConfident wrong answers ship
Repeated failureTool wrapper, per conversationp2Retry loop burns tokens and time
Policy riskInbound, classifier plus keywordsp1Compliance breach

The handoff record

The record is the spine of the whole system. If it is a flat, well named row, routing, metrics, and audit all read from the same place. A workable schema:

FieldTypeWhy it exists
handoff_idUUIDIdempotency key and join key for logs and spans
conversation_idstringGroups every turn and every trace in one conversation
created_attimestampStarts the time-to-pickup clock
reasonenumThe four-value enum above; drives every breakdown
priorityenumOrdering in the queue and paging rules
topicstringSkill matching input
summarystringWhat the human reads first
blocking_factsarrayAccount, order, error code
confidencefloatThe score that triggered escalation, if any
failed_toolstring, nullableWhich call failed twice
assignee_idstring, nullableSet on pickup
assigned_attimestamp, nullableTime-to-pickup ends here
resolutionenum, nullableresolved, bounced, abandoned
transcript_refURIPointer, not the transcript itself

Two decisions matter here. First, store a pointer to the transcript rather than duplicating it, or the row becomes a privacy liability. Second, keep failed_tool and confidence nullable and separate: a user-requested handoff has no failing tool, and conflating "no value" with "zero" will corrupt your aggregates.

This is the same discipline that makes general structured logging useful: key-value fields with consistent names across services let you filter, group, and aggregate instead of guessing where a value sits in a free-text line (SigNoz). The most commonly skipped step is field-name consistency, where one service logs userId and another logs user_id and no single query works across both (OpenObserve). Decide the names once, put them in a schema file, and enforce them at write time.

Emitting handoff spans and attributes

Instrument with OpenTelemetry so the handoff appears inside the same trace as the model call and tool calls that led to it, whichever backend you use (OpenTelemetry). A trace is a tree of spans that share a trace ID, each carrying attributes and events, which is exactly the shape you need to answer "what happened in the two turns before this person got involved" (traces).

Use the GenAI conventions where they already fit: gen_ai.conversation.id binds every span across every trace in one conversation, gen_ai.agent.name identifies the agent, and gen_ai.operation.name classifies the span as a chat, tool, or agent invocation (Honeycomb). The full attribute registry lives with the GenAI semantic conventions (semconv).

For the handoff itself, create a dedicated span named escalate (or handoff) as a child of the agent invocation span, and add these attributes:

with tracer.start_as_current_span("handoff.escalate") as span:
    span.set_attribute("gen_ai.conversation.id", conversation_id)
    span.set_attribute("handoff.id", handoff_id)
    span.set_attribute("handoff.reason", reason)          # the enum
    span.set_attribute("handoff.priority", priority)
    span.set_attribute("handoff.topic", topic)
    span.set_attribute("handoff.confidence", confidence)  # omit if None
    span.set_attribute("handoff.queue_depth_at_assign", queue_depth)
    span.set_attribute("handoff.skills_required", ",".join(skills))
    span.add_event("handoff.recorded", {"handoff.id": handoff_id})

When the person picks up, close the loop with a second span (handoff.pickup) carrying assignee_id, handoff.wait_seconds, and whether the routing function fell through to a fallback. Attributes are cheap and set per event; use a new span only for units of work that need a duration or a boundary (Honeycomb). Time-to-pickup is a duration you want to measure, so it earns a span; the reason code is a fact about one event, so it earns an attribute.

One caution the OpenTelemetry GenAI docs make explicit: prompt content and tool arguments are not captured by default because they can hold sensitive data, and enabling content capture populates spans with full messages and tool results (GenAI observability). Handoff summaries are user content. Set the summary as an attribute only if your backend and retention policy allow it, and otherwise keep it in the record store behind transcript_ref.

Traces and logs do different jobs here. Logs record discrete events and are fine for audit; traces preserve the relationship between steps, which is what you need because an agent can take a different path on every run and a failing turn cannot be reconstructed from an interleaved log stream (Braintrust). Keep both, joined by handoff_id.

Routing the handoff to the right person

Routing is a ranking problem, and you should solve it as a scored function rather than a chain of if-statements. For each candidate assignee, compute:

  • Skill match: 1.0 if the assignee holds every required skill for the topic, 0.6 if they hold the primary skill, 0 otherwise. Derive required skills from topic and the classifier, not from free text.
  • Availability: 1.0 if online and under their concurrent-chat cap, 0.5 if online but at cap, 0 if offline. Tracking a per-assignee cap is what stops your best agent from being handed six chats at once.
  • Load: normalize current open handoffs against the team median, so a person sitting at twice the median scores lower.
  • Recency and continuity: small bonus if the assignee handled this conversation or this account before, which cuts re-explanation time.

A simple weighted sum (skills 0.45, availability 0.30, load 0.15, continuity 0.10) is enough to start. Sort candidates, then apply priority as a queue-ordering rule rather than a scorer weight: p1 handoffs skip the line, p3 handoffs wait for capacity. If you want the weights to respond to outcomes later, log the chosen assignee and the resolution on every handoff so you can fit the weights from your own data instead of arguing about them.

The fallback path is not optional. If no candidate scores above zero on availability, do not leave the user hanging or silently keep them with the agent. Pick one of three explicit behaviors and tell the user which one applies:

  1. Callback: record the handoff, tell the user when a person will reach out, and stop the agent from making commitments.
  2. Asynchronous ticket: convert the handoff into a queued item with the summary and blocking facts attached, and give the user a reference number.
  3. Deferred with status: hold the conversation open, tell the user the expected wait, and let them leave a message.

Whichever you choose, set handoff.routing_fallback on the span so the fallback rate is visible. A rising fallback rate is your first signal that staffing and escalation volume have diverged.

The metrics that tell you whether handoff works

Instrument against service level indicators built from what users experience, not from infrastructure health, and set objectives against those indicators with an explicit error budget (OpenObserve). The vocabulary is worth keeping straight: the SLI is the measurement, the SLO is the target over a window, and the error budget is one minus the SLO (SRE golden rules). Latency figures are the clearest example of why the distinction matters: an average latency can look healthy while one request in twenty is slow, which is why you set targets on a high percentile rather than the mean (Nova AIOps).

Track these as your handoff dashboard:

MetricDefinitionWhy it changes a decision
Escalation rateHandoffs divided by conversationsThe headline containment number
Escalation rate by reasonSame, split by the four-value enumTells you which trigger is firing and why
Time to pickupAssigned minus created, p50 and p95The number users actually feel
Bounce rateHandoffs reopened within 24 hours of resolutionSummary quality and routing accuracy
Fallback rateHandoffs with no online assigneeStaffing signal
Agent turns before handoffTurn count at the moment escalate is calledWhether triggers fire early enough
Post-handoff resolution costMean handle time per reasonWhether escalation is absorbing the right work

Two of these are the ones most teams forget. Agent turns before handoff exposes the failure mode where your low-confidence threshold is set so conservatively that users exhaust themselves before the agent escalates. If the median is above three turns for user-requested handoffs, your detection is late. Bounce rate is the quality check on the summary field: a high bounce rate on policy-risk handoffs usually means the summary omits a blocking fact the human had to rediscover.

Set the objective where it belongs. A defensible starting SLO is "95 percent of p2 handoffs are picked up within five minutes," measured on the trace data, with alerting on the burn rate rather than a single threshold breach. Review escalation rate by reason monthly. If one reason dominates and the resolved rate after handoff is high, that reason is a candidate for the agent to learn to handle, which is the point of the loop: every human correction is training data for the next version of the agent, not a dead end in a review queue (Databricks).

Worked example: sizing the threshold

Take a support agent that handles 10,000 conversations a day. You label a 1,000-conversation sample and find 180 unresolved. You score each with your confidence signal and get this table:

Confidence bandConversationsUnresolvedUnresolved rate
Below 0.42009648%
0.4 to 0.73506318%
Above 0.7450214.7%

Setting the threshold at 0.7 escalates 550 conversations in the sample, or 55 percent, and catches 159 of the 180 failures, about 88 percent. At 10,000 daily conversations that is 5,500 handoffs a day, which almost certainly exceeds your staffing. Setting it at 0.4 escalates 20 percent and catches 96 failures, or 53 percent, leaving 84 bad conversations a day with no human. Neither number is right in the abstract; the decision rule is to choose the highest threshold whose daily escalation volume your team can absorb at the pickup SLO, then close the gap by adding the deterministic triggers (repeated failure, policy risk) that catch failures confidence misses at low volume.

That is the split most teams get wrong: they tune the confidence threshold to fix quality, when the threshold is really a staffing dial. Repeated-failure and policy triggers are quality controls, because they cost almost nothing in volume and catch the failures that a confident model gets wrong.

Run the scoring model and the escalation classifier side by side while you tune, then move the winning configuration into production. When you are ready to put an expert, not just a threshold, on a business line, Quanta Skills is the library behind every Quanta expert, installable into a coding agent or staffed by a person.

FAQ

FAQ

Should the agent or the runtime decide when to hand off?

Both, on different triggers. The runtime owns the deterministic ones (explicit request, repeated tool failure, policy match) because they can be evaluated without model judgment and must never be skipped. The agent owns the judgment-based one (low confidence after a drafting step) because it is the only component that has seen the conversation. Validate every agent-driven call against the schema and log it either way.

How do I set the confidence threshold without guessing?

Label a sample of past conversations as resolved or unresolved, score each with your confidence signal, and build a band table showing how many failures you catch at each cutoff and how many escalations it produces. Pick the highest cutoff whose escalation volume your team can absorb at your pickup objective, then add deterministic triggers to recover quality. Re-run the exercise when the agent changes.

What exactly should go in the summary the human reads?

The reason code, one sentence on what the user wants, the blocking facts (account, order, error code) as separate values, and what the agent already tried and failed. Cap the length. Do not paste the transcript. If bounce rate on a reason code is high, the missing information is almost always a blocking fact that should have been captured at the tool call.

Do I need a separate trace for the handoff?

Yes, as a child span of the agent invocation, because time-to-pickup is a duration you want to measure and the handoff is a distinct unit of work. Facts about the escalation (reason, priority, confidence) belong as attributes on that span. When the person picks up, emit a second span so wait time and assignment are both queryable.

What happens when nobody is online?

Choose one explicit behavior and surface it to the user: a scheduled callback, an asynchronous ticket with a reference number, or a held conversation with an expected wait. Record it as a routing fallback in the handoff record and as a span attribute, then alert on the fallback rate. Silent failure to route is the one outcome that turns a good handoff design into a lost customer.

Sources

Sources

  1. What is Human-in-the-Loop (HITL)?
  2. What Is Structured Logging? Benefits and Tips | SigNoz
  3. Structured Logging in Production: Best Practices for Scalable Systems
  4. OpenTelemetry
  5. Traces
  6. A Guide to Instrumenting AI Agents for the Agent Timeline
  7. Gen AI
  8. Span or Attribute in OpenTelemetry Custom Instrumentation
  9. Inside the LLM Call: GenAI Observability with OpenTelemetry
  10. Tracing vs logging for LLM apps: what's the difference and ...
  11. How to Actually Set Meaningful SLOs (Most Teams Are Doing It Wrong)
  12. The Three Golden Rules of Reliability: SLOs, SLIs, and Error ...
  13. The Four Golden Signals of Monitoring: A Complete Guide (2026)
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles