Skip to main content

Human handoff

Handing the conversation to a human​

Some conversations should end up with a person: the customer asks for one, the issue is outside the agent's scope, or a policy says so. A human handoff is how your agent passes a conversation to your support team and steps back. In Parlant it has three parts: the session goes quiet, a label puts it in an operator queue, and metadata records the reason. When the operator is done, one update puts the agent back in charge.

What you'll learn
  • A tool that silences the agent and marks the session for an operator
  • A policy that decides when to transfer
  • How your backend finds queued sessions and lets an operator reply
  • How to hand the conversation back to the agent
Talk this page through with your coding agent
A running robot passing a chat-bubble baton to a support agent wearing a headset

This playbook builds all three parts, plus the operator side that finds the conversation and hands it back.

What the playbook uses​

A session's mode is auto by default, meaning the agent automatically replies to every new customer message in the session. For human control, we switch the session's mode to manual, where it records messages into storage while the agent stays silent.

Labels are markers you can later filter sessions by on the Dashboard, and session metadata is a small JSON bucket tied to the session, which you can also see and query on the Dashboard or in code.

The transfer tool​

To put the session in manual mode, we use a tool. The @p.tool decorator turns a function into something the agent can call, and its parameters become the values the agent fills in from the conversation. Here the only parameter is reason, which the agent writes in its own words.

Inside a tool, p.Session.current is the session the agent is replying in, so the tool can change it directly. A tool returns a p.ToolResult: its data is what the model reads, and control directives can change the session's mode.

Each line has a job: the label puts the session in the operator queue, the metadata records why, data tells the agent what to say, and control parks the session after that reply goes out:

import parlant.sdk as p


@p.tool
async def transfer_to_human(reason: str) -> p.ToolResult:
await p.Session.current.metadata.set("handoff_reason", reason) # why, for the operator
await p.Session.current.labels.add("needs-human") # the queue marker

return p.ToolResult(
data=( # what the agent reads, so it knows what to say
"Transfer requested. Let the customer know a human agent "
"will pick the conversation up shortly."
),
control={"mode": "manual"}, # after this reply, the agent goes quiet
)

When to transfer​

We attach the tool to a policy so that it is brought into context at relevant moments, just like the policy itself.

await agent.create_policy(
title="Transfer to a person",
content=(
"When the customer asks to speak with a person, or when their request is "
"clearly outside what you can help with, transfer them with `transfer_to_human`, "
),
tools=[transfer_to_human],
)

The operator queue​

The operator side lives in your backend, which talks to Parlant over HTTP with the admin auth token, the server-side credential that sees every session. Authorization covers who may call what.

Your operator dashboard is a query for the label. The backend lists sessions with it:

GET /sessions?labels=needs-human&limit=25

Each session's metadata.handoff_reason says why it is there. A session's history is a log of events, each with an offset, a position number that grows by one. To show the conversation, read its events from offset zero; the last agent message is the one that told the customer someone would pick up.

When an operator replies, post a human-agent message. It needs a participant so the transcript shows who spoke, and it triggers no agent processing:

POST /sessions/{session_id}/events
Content-Type: application/json

{
"kind": "message",
"source": "human_agent",
"message": "Hi, this is Alex from support. I've got your order details in front of me.",
"participant": {"id": "op_alex", "display_name": "Alex"}
}

The customer's chat UI needs nothing new for this. The message arrives on the same event feed, with source: "human_agent", so render it as an operator bubble rather than an agent one.

Handing back​

When the operator is done, the backend switches the session back to automatic and clears the queue marker. One PATCH on the session does all three: it changes the mode, removes the label, and unsets the metadata key:

PATCH /sessions/{session_id}
Content-Type: application/json

{
"mode": "auto",
"labels": {"remove": ["needs-human"]},
"metadata": {"unset": ["handoff_reason"]}
}

From the next customer message, the agent responds again, and the operator's messages are part of the history it reads. From Python, the same handback is:

session = await server.get_session(id=session_id)
await session.update(mode="auto")
await session.labels.remove("needs-human")
await session.metadata.delete("handoff_reason")

Two refinements​

Tell the customer while they wait. A session can speak without the model. If the queue is long, have the backend post a verbatim status line with session.emit_message(...), so the customer is not staring at silence in manual mode.

Count handoffs. Because the label is applied by the tool, every transfer is countable with the same query the dashboard uses. Session analytics turns that into a reporting practice. If you want to know which policy caused it, give each transfer policy its own label as well, with labels=["handoff-refund-limit"], and Parlant adds it when the policy matches.