Skip to main content

guides/handoffs.md

# Handoffs

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](tools-and-operations.md)).

## Prerequisites

- A working Jidoka agent module (see [Getting Started](getting-started.md)).
- Familiarity with the operation contract from
  [Tools And Operations](tools-and-operations.md).
- No provider keys are required for the deterministic examples below.

```bash
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.

```elixir
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`.

```diagram
╭──────────────╮     ╭───────────────────────╮     ╭───────────────────╮
│ 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.

```elixir
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.

```elixir
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.

```elixir
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.

```elixir
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.

```elixir
: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:

```elixir
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](controls.md) 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.

```elixir
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

| 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`](`Jidoka.Handoff`) - the handoff data contract:
  `new/2`, `new!/2`, `from_input/2`, struct fields.
- [`Jidoka.Operation.Source.Handoff`](`Jidoka.Operation.Source.Handoff`) -
  operation source that compiles a `tools do handoff ... end` entry.
- [`Jidoka.Handoff.OwnerStore`](`Jidoka.Handoff.OwnerStore`) - storage
  behaviour and delegator: `owner/1`, `put_owner/2`, `reset/1`.
- [`Jidoka.Handoff.OwnerStore.InMemory`](`Jidoka.Handoff.OwnerStore.InMemory`) -
  default ETS-backed store.
- [`Jidoka`](`Jidoka`) - public facade: `Jidoka.handoff/1`,
  `Jidoka.reset_handoff/1`.

## Related Guides

- [Tools And Operations](tools-and-operations.md) - the operation contract
  the handoff source rides on.
- [Controls](controls.md) - input/operation/output policy, including the
  approval flow recommended for `:unsafe_once` handoffs.
- [Agent DSL](agent-dsl.md) - the `tools` block and how `handoff` is
  authored.
- [Runtime And Harness](runtime-and-harness.md) - sessions, snapshots, and
  how an application dispatcher reads ownership between turns.