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.

  1. Jidoka.Handoff is the validated record of a single transfer: id, conversation_id, from_agent, to_agent, to_agent_id, name, message, optional summary/reason, forwarded context, and metadata.
  2. Jidoka.Operation.Source.Handoff is the operation source that compiles a DSL handoff entry into one Agent.Spec.Operation whose idempotency is :unsafe_once and kind is :handoff.
  3. Jidoka.Handoff.OwnerStore is the storage behaviour: owner/1, put_owner/2, reset/1. The default store is Jidoka.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

AspectHandoffSubagent
ScopeFuture turns of a conversation.One nested task during the current turn.
Result to callerA small data payload (handoff, owner).The subagent's structured output.
Idempotency:unsafe_once. Recommended to gate with a control.:idempotent by default.
RoutingApplication dispatcher reads Jidoka.handoff/1.Jidoka runs the subagent call inside the turn.
ResetJidoka.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."
end

The compiled operation has:

  • name: "specialist_agent",
  • idempotency: :unsafe_once,
  • metadata["source"] = "handoff", metadata["kind"] = "handoff",
  • a JSON-schema describing the expected arguments (message, optional summary, 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
end

The 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")
#=> nil

reset_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]
end

See 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: :public copies the parent's public context map. Tighten it with forward_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
end

For 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

SymptomLikely CauseFix
{:error, {:invalid_handoff_module, ...}} at compile timeThe 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 runtimeThe 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 turnThe 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 testsThe default InMemory store is process-wide.Call Jidoka.reset_handoff/1 in setup/on_exit, or configure a per-test store module.

Reference

  • Tools And Operations - the operation contract the handoff source rides on.
  • Controls - input/operation/output policy, including the approval flow recommended for :unsafe_once handoffs.
  • Agent DSL - the tools block and how handoff is authored.
  • Runtime And Harness - sessions, snapshots, and how an application dispatcher reads ownership between turns.