A handoff transfers future conversation ownership to another agent. Jidoka
records the owner in Jidoka.Handoff.OwnerStore; your application reads that
data to route the next turn. A handoff is different from a subagent call:
handoffs change who owns the next turn, while subagents handle one bounded task
inside the current turn and return a result.
When To Use This
- Use this guide when one agent should permanently (until reset) take over a conversation, such as routing from a triage bot to a support specialist.
- Use this guide when integrating handoff routing into your own application dispatcher.
- Do not use this guide for one-shot delegation that returns a value to the caller; use the subagent source for that.
- Do not use this guide for short-term tool calls; those are operations (see Tools And Operations).
Prerequisites
- A working Jidoka agent module (see Getting Started).
- Familiarity with the operation contract from Tools And Operations.
- No provider keys are required for the deterministic examples below.
mix deps.get
mix test
Quick Example
A handoff source lives in the tools block and exposes one operation per
target agent. When the model calls that operation, the handoff is recorded
in the owner store and returned to the current turn as data.
defmodule MyApp.SpecialistAgent do
use Jidoka.Agent
agent :specialist_agent do
model "openai:gpt-4o-mini"
instructions "You are a billing specialist."
end
end
defmodule MyApp.TriageAgent do
use Jidoka.Agent
agent :triage_agent do
model "openai:gpt-4o-mini"
instructions "Hand off to specialist_agent for billing questions."
end
tools do
handoff MyApp.SpecialistAgent, as: :specialist_agent
end
end
llm = fn _intent, journal ->
case map_size(journal.results) do
0 ->
{:ok,
%{
type: :operation,
name: "specialist_agent",
arguments: %{
"message" => "User has a billing question.",
"conversation_id" => "conv-1"
}
}}
_ ->
{:ok, %{type: :final, content: "Connecting you to a specialist."}}
end
end
{:ok, _result} = MyApp.TriageAgent.run_turn("Why is my bill higher?", llm: llm)
Jidoka.handoff("conv-1")
#=> %{agent: MyApp.SpecialistAgent, agent_id: "conv-1:specialist_agent", handoff: %Jidoka.Handoff{...}, updated_at_ms: 1_234}After the turn, the application can read Jidoka.handoff("conv-1") to see
who owns future turns. Routing the next user message to that agent is the
application's responsibility.
Concepts
A handoff is three pieces of data and one storage boundary.
Jidoka.Handoffis the validated record of a single transfer:id,conversation_id,from_agent,to_agent,to_agent_id,name,message, optionalsummary/reason, forwardedcontext, andmetadata.Jidoka.Operation.Source.Handoffis the operation source that compiles a DSLhandoffentry into oneAgent.Spec.Operationwhoseidempotencyis:unsafe_onceandkindis:handoff.Jidoka.Handoff.OwnerStoreis the storage behaviour:owner/1,put_owner/2,reset/1. The default store isJidoka.Handoff.OwnerStore.InMemory, an ETS-backed table good for tests and single-node demos. Applications can configure another module through:jidoka, :handoff_owner_store.
╭──────────────╮ ╭───────────────────────╮ ╭───────────────────╮
│ tools block │────▶│ Operation.Source │────▶│ Agent.Spec │
│ handoff X │ │ .Handoff (compile) │ │ .Operation │
╰──────────────╯ ╰───────────────────────╯ ╰─────────┬─────────╯
│
▼
╭──────────────────╮
│ Model decision │
│ {:op, name, args}│
╰─────────┬────────╯
│
▼
╭─────────────────────────────╮
│ Handoff source capability │
│ - validate arguments │
│ - build Jidoka.Handoff │
│ - put_owner/2 │
│ - return data to the turn │
╰─────────────┬───────────────╯
│
▼
╭─────────────────────────────────────────╮
│ OwnerStore (ETS or app-supplied module) │
╰─────────────────────┬───────────────────╯
│
▼
╭───────────────────────────────╮
│ Jidoka.handoff(conversation) │
│ -> %{agent, agent_id, ...} │
╰───────────────────────────────╯The turn that invokes the handoff still completes normally. The current agent receives the handoff payload (id, message, projected handoff data) as the operation result and produces its final assistant content. The ownership change only affects future turns the application chooses to route.
Handoff Vs Subagent
| Aspect | Handoff | Subagent |
|---|---|---|
| Scope | Future turns of a conversation. | One nested task during the current turn. |
| Result to caller | A small data payload (handoff, owner). | The subagent's structured output. |
| Idempotency | :unsafe_once. Recommended to gate with a control. | :idempotent by default. |
| Routing | Application dispatcher reads Jidoka.handoff/1. | Jidoka runs the subagent call inside the turn. |
| Reset | Jidoka.reset_handoff/1. | N/A. |
Pick handoff when the persona for the next message should change. Pick subagent when the current persona needs a focused helper to answer one question.
How To
Step 1: Declare A Handoff In The DSL
The handoff source needs the target agent module (which must define
spec/0) and an operation name. as: controls the operation name and is
required when registering multiple handoffs for the same target.
tools do
handoff MyApp.SpecialistAgent,
as: :specialist_agent,
description: "Hand off billing questions to the specialist."
endThe compiled operation has:
name: "specialist_agent",idempotency: :unsafe_once,metadata["source"] = "handoff",metadata["kind"] = "handoff",- a JSON-schema describing the expected arguments (
message, optionalsummary,reason,conversation_id,context).
Step 2: Run A Turn That Invokes The Handoff
Make sure the operation arguments include a message and, when you want
the owner to be tied to a conversation, a conversation_id. In production
the LLM produces those arguments; in tests, pin them in a fake LLM.
llm = fn _intent, journal ->
case map_size(journal.results) do
0 ->
{:ok,
%{
type: :operation,
name: "specialist_agent",
arguments: %{
"message" => "User has a billing question.",
"conversation_id" => "conv-1",
"reason" => "out of scope"
}
}}
_ ->
{:ok, %{type: :final, content: "Transferring you to a billing specialist."}}
end
end
{:ok, result} = MyApp.TriageAgent.run_turn("Why is my bill higher?", llm: llm)result.content carries the assistant's final message; the operation
result inside result.agent_state.operation_results carries the handoff
payload.
Step 3: Read Ownership From The Store
After the turn, the owner store has the new owner recorded under the conversation id.
case Jidoka.handoff("conv-1") do
%{agent: agent_module, agent_id: agent_id, handoff: handoff} ->
{agent_module, agent_id, handoff.message}
nil ->
:no_owner
end
#=> {MyApp.SpecialistAgent, "conv-1:specialist_agent", "User has a billing question."}agent_id is derived from the handoff target. With target: :auto
(default) it becomes "<conversation_id>:<operation_name>". With
target: {:peer, peer_id} or {:peer, {:context, :key}} the application
fully controls the id.
Step 4: Route Future Turns
Routing belongs to the application. A typical dispatcher checks the store first, then falls back to the original agent.
def dispatch(conversation_id, input) do
case Jidoka.handoff(conversation_id) do
%{agent: agent_module} -> agent_module.chat(input)
nil -> MyApp.TriageAgent.chat(input)
end
endThe harness never silently routes for you. This is intentional: the same data drives logging, audit, and UI presentation.
Step 5: Reset Ownership
When an interaction is over, or when the application wants its default selection back, clear the owner.
:ok = Jidoka.reset_handoff("conv-1")
Jidoka.handoff("conv-1")
#=> nilreset_handoff/1 is also useful in test teardown to keep the ETS table
clean between examples.
Step 6: Gate Handoffs With A Control
Because handoff operations are :unsafe_once, declaring an explicit
operation control is the recommended pattern. The control matches on
kind: :handoff and can block, interrupt, or log:
defmodule MyApp.ConfirmHandoff do
use Jidoka.Control, name: "confirm_handoff"
@impl true
def call(%Jidoka.Runtime.Controls.OperationContext{} = op) do
if op.metadata["agent"] == inspect(MyApp.SpecialistAgent) do
{:interrupt, :handoff_requires_approval}
else
:cont
end
end
end
controls do
operation MyApp.ConfirmHandoff, when: [kind: :handoff]
endSee Controls for the full approval lifecycle.
Common Patterns
- Always include a
conversation_id. Without one the owner key falls back to the operation name, which is rarely what you want. - Use
target: {:peer, {:context, :session_id}}when you already track sessions in your application; the owner id then matches your existing identifier. - Forward only the public context. The default
forward_context: :publiccopies the parent's public context map. Tighten it withforward_context: {:only, [...]}when secrets might leak. - Reset after terminal events. Clearing the owner after "ticket closed" or "session ended" stops stale handoffs from steering future traffic.
- Pair handoffs with the InMemory owner store in tests. Reset between examples to keep ETS entries from leaking across cases.
Testing
Handoff tests focus on the data the source emits and the side effect on the owner store. No provider call is needed.
defmodule MyApp.TriageHandoffTest do
use ExUnit.Case, async: false
setup do
:ok = Jidoka.reset_handoff("conv-1")
on_exit(fn -> Jidoka.reset_handoff("conv-1") end)
:ok
end
test "records the specialist as the new owner" do
llm = fn _intent, journal ->
case map_size(journal.results) do
0 ->
{:ok,
%{
type: :operation,
name: "specialist_agent",
arguments: %{
"message" => "Billing question.",
"conversation_id" => "conv-1"
}
}}
_ ->
{:ok, %{type: :final, content: "Transferring you."}}
end
end
assert {:ok, _result} = MyApp.TriageAgent.run_turn("Why is my bill higher?", llm: llm)
assert %{
agent: MyApp.SpecialistAgent,
agent_id: "conv-1:specialist_agent",
handoff: %Jidoka.Handoff{message: "Billing question."}
} = Jidoka.handoff("conv-1")
end
endFor applications using a custom store, replace
Jidoka.Handoff.OwnerStore.InMemory through application configuration; the
public Jidoka.handoff/1 and Jidoka.reset_handoff/1 calls do not change.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
{:error, {:invalid_handoff_module, ...}} at compile time | The target module does not define spec/0. | Make sure the target uses Jidoka.Agent (or otherwise exposes spec/0). |
{:error, {:invalid_handoff_payload, :message}} at runtime | The LLM called the operation without a non-empty message argument. | Tighten the prompt or supply a richer description; the schema requires message. |
Jidoka.handoff(id) returns nil after a turn | The arguments did not include a conversation_id and the context did not provide one either. | Either pass a conversation_id argument, set it in the turn context:, or use a target: {:peer, ...} mapping. |
{:error, {:missing_handoff_peer_context, key}} | A {:peer, {:context, key}} target needed a context value that was not present. | Add the key to context: for the turn (context: %{tenant_id: ...}). |
| ETS owner store leaks across tests | The default InMemory store is process-wide. | Call Jidoka.reset_handoff/1 in setup/on_exit, or configure a per-test store module. |
Reference
Jidoka.Handoff- the handoff data contract:new/2,new!/2,from_input/2, struct fields.Jidoka.Operation.Source.Handoff- operation source that compiles atools do handoff ... endentry.Jidoka.Handoff.OwnerStore- storage behaviour and delegator:owner/1,put_owner/2,reset/1.Jidoka.Handoff.OwnerStore.InMemory- default ETS-backed store.Jidoka- public facade:Jidoka.handoff/1,Jidoka.reset_handoff/1.
Related Guides
- Tools And Operations - the operation contract the handoff source rides on.
- Controls - input/operation/output policy, including the
approval flow recommended for
:unsafe_oncehandoffs. - Agent DSL - the
toolsblock and howhandoffis authored. - Runtime And Harness - sessions, snapshots, and how an application dispatcher reads ownership between turns.