Handoffs let an agent delegate the rest of the run to another agent. The model sees each handoff as a tool named transfer_to_<agent_name>; when it calls one, the runner switches the active agent and continues the loop with the full conversation.
billing := &agents.Agent{Name: "Billing agent", Instructions: agents.StaticInstructions("…")}
refund := &agents.Agent{Name: "Refund agent", HandoffDescription: "Handles refund requests end to end.", Instructions: agents.StaticInstructions("…")}
triage := &agents.Agent{
Name: "Triage agent",
Handoffs: []agents.Handoff{agents.HandoffTo(billing), agents.HandoffTo(refund)},
}
agents.HandoffTo(target) builds a no-input tool named transfer_to_<sanitized name> whose description includes the target’s HandoffDescription.
For a custom tool name, an input schema, side effects or dynamic targets, build the Handoff struct directly:
type escalationInput struct {
Reason string `json:"reason" jsonschema:"why the conversation is being escalated"`
}
schema, _ := agents.SchemaFor[escalationInput](true)
h := agents.Handoff{
ToolName: "escalate_to_human_review",
ToolDescription: "Escalate the conversation for human review.",
InputJSONSchema: schema,
AgentName: escalation.Name,
Target: escalation,
OnHandoff: func(ctx context.Context, rc *agents.RunContext, argsJSON string) error {
var in escalationInput
_ = json.Unmarshal([]byte(argsJSON), &in)
log.Printf("escalating: %s", in.Reason)
return nil // an error here aborts the run
},
}
A handoff whose target depends on the arguments sets OnInvoke instead of
Target — it runs when the model selects the handoff and its return value is
the agent switched to. Leave Target nil in that case: it is the static
declaration, and a consumer enumerating the handoff graph (an approval UI
rebuilding an agent registry, say) trusts it without invoking any callback.
A hand-built
Handoffis strict by default — the zero value ofNonStrictSchemaopts in to strict mode. SetNonStrictSchema: trueonly for a schema strict mode cannot express.
| Field | Purpose |
|---|---|
ToolName / ToolDescription |
What the model sees |
InputJSONSchema / NonStrictSchema |
Optional typed handoff input (strict by default) |
Target |
The agent switched to, as a static declaration (HandoffTo fills it) |
OnInvoke |
Resolves the target at runtime; overrides Target when set |
OnHandoff |
Side-effect callback when the handoff fires (e.g. prefetch data) |
InputFilter |
Rewrites the conversation the next agent sees (below) |
IsEnabled |
Gates whether the handoff is offered to the model this run |
A Handoff with neither Target nor OnInvoke has no one to switch to;
selecting it fails the run with a *UserError.
By default the next agent sees the entire conversation. An InputFilter rewrites it — for example to drop earlier tool noise before delegating:
h := agents.HandoffTo(faq)
h.InputFilter = func(d agents.HandoffInputData) agents.HandoffInputData {
d.InputHistory = removeToolItems(d.InputHistory)
return d
}
HandoffInputData.InputHistory is the full conversation as input items, up to and including the handoff. The filter affects only what the next agent sees — what is saved to a session is unaffected.
Note: the filter receives one flattened
InputHistorylist, not a pre/post split — a filter that needs the boundary can find it by identity.
For multi-agent chains, agents.NestHandoffHistory is a ready-made filter that folds the prior conversation into one compact summary message for the next agent, cutting tokens and tool-call noise:
h := agents.HandoffTo(billing)
h.InputFilter = agents.NestHandoffHistory(agents.NestHistoryOptions{})
The default folds the transcript into a single assistant message wrapped in fixed <CONVERSATION HISTORY> markers. On a later handoff the filter flattens any earlier summary back into its transcript before re-folding, so a chain of handoffs yields one flat summary rather than a summary-of-summaries. Customize via NestHistoryOptions:
Mapper — a HandoffHistoryMapper that folds the transcript your own way (e.g. call an LLM for a real summary instead of the default JSON-per-line transcript). Only the default summary shape is flattened by later handoffs; a custom mapper’s summaries are treated as opaque messages.The transcript is serialized one JSON item per line, which round-trips through UnmarshalInputItem when flattened — a line-delimited format nests reliably, where free text does not.
Models follow handoffs better when the instructions mention them:
triage.Instructions = agents.StaticInstructions(`You are a triage agent for a customer support system.
You can transfer the conversation to specialist agents using the transfer tools.
Transfers are seamless: do not mention or draw attention to them.`)
InputJSONSchema before OnHandoff runs — nested required, types, enums and bounds, not only root-level keys — and a violation fails the run with a *ModelBehaviorError naming the JSON-pointer path. Arguments must be a JSON object: "" and null are read as {}, which a schema declaring root-level required keys rejects with the familiar “Handoff function expected non-null input, but got None”. A no-input transfer (the default HandoffTo) accepts absent, empty and extra-keyed objects, but its schema still says "type": "object", so a payload that is not an object fails like any other’s. Schema default values are not applied: OnHandoff, OnInvoke and the session all see the model’s raw argument string, and a value invented during validation would not be in it. A Handoff with no InputJSONSchema is not validated at all; one whose schema this SDK cannot compile keeps the object and required-keys checks and skips the rest.OnHandoff hooks and the receiving agent’s OnHandoff agent hook both fire on every handoff.RunResult.LastAgent is the agent that ultimately answered — useful for routing the user’s next message.