Agents & handoffs
The persona, per-agent models and hooks, and transferring control.
An Agent is the persona: instructions, tools, optional per-agent models, and
lifecycle hooks. It holds no live connection — one agent is reusable across
sessions, and a session can swap which agent is active. Keeping the persona
separate from the runtime (AgentSession)
is what makes handoffs cheap: you switch the active agent — even its models —
without tearing down the session or its transport.
Define an agent
agent, err := agents.NewAgent(agents.AgentOptions{
Instructions: "You are a friendly booking assistant.",
Tools: []tool.Entry{bookTable},
LLM: model, // optional per-agent model; else the session default
OnEnter: func(ctx context.Context, ac *agents.AgentContext) error {
// Speak first when this agent becomes active (on_enter → generate_reply):
_, err := ac.GenerateReply(agents.GenerateReplyOptions{
Instructions: "Greet the caller and offer to book a table.",
})
return err
},
})
if err != nil {
log.Fatal(err)
}- Per-agent models (
STT/LLM/TTS/VAD) override the session default while this agent is active — so a handoff can change persona and the underlying model/voice. - Hooks:
OnEnter/OnExitfire as the agent becomes / stops being active (the hooks a handoff runs);OnUserTurnCompletedruns after a user turn commits, before generation — the place to inspect or augment context (e.g. RAG).
OnEnter can speak first. The AgentContext exposes the same speech family
as the session — ac.GenerateReply(...) (LLM reply), ac.Say(...) (fixed text),
ac.SayStream(...) (streamed text), and ac.SayAudio(...) (raw PCM) — so you can
greet the moment the agent becomes active with the identical API you'd use at the
session level (the reference on_enter → generate_reply). These are valid only
while the agent is active (from OnEnter or OnUserTurnCompleted); a suspended
or already-exited agent's context returns an error instead. On a
tool-driven handoff you can alternatively pass a non-nil returns value (below)
to trigger a follow-up reply on the new agent.
Handoff — replace the active agent
A handoff transfers control for good — the new agent owns the rest of the conversation. Two ways:
From a tool (graceful drain)
Inside a tool, agents.RequestHandoff transfers control after the tool batch.
The current reply isn't cut off — it drains onto the new agent.
transfer, err := tool.New("transfer_to_billing",
func(ctx context.Context, run *tool.RunContext, _ *struct{}) (*struct{}, error) {
agents.RequestHandoff(run, billingAgent, "Transferring you to billing.")
return &struct{}{}, nil
},
tool.WithDescription("Hand the caller off to the billing agent."),
)
if err != nil {
log.Fatal(err)
}The third argument (returns any, typically a short string) becomes the tool
call's output and triggers a follow-up reply on the new agent; pass nil to hand
off silently and let the new agent's OnEnter drive the next turn (e.g. by
calling ac.GenerateReply(...)).
Two tools in one batch requesting different target agents is a conflict — the switch is ignored and a warning is logged. Request at most one handoff per turn.
Explicitly (hard swap)
Outside a tool — from your own control logic — swap the active agent directly.
This is a hard handoff: in-flight speech is cancelled, OnExit/OnEnter run.
if err := session.UpdateAgent(ctx, specialist); err != nil {
return err
}Swaps are serialised against session close and tool-driven handoffs, so they never interleave.
Handoff vs. delegate
Handoff is one-way. When you instead need a bounded answer back — collect an address, run one survey step — delegate to a sub-agent and resume; see Tasks & sub-agents.
RequestHandoff
Route/triage from inside a tool, graceful drain. One-way.
UpdateAgent
Hard swap from outside a tool. Cancels current speech. One-way.
DelegateTask
Delegate a bounded job and come back with a value.
A graceful handoff appears in context in
examples/supervisor
(escalate_to_human).
Next
- Supervisor pattern & scaling — when to hand off vs. delegate vs. route.
- Tasks & sub-agents — delegate and resume.
- Full API: Reference → Agent.