jonaki

Handoffs

Let one agent transfer the conversation to a specialist — depth-limited and ping-pong-proof.

Each entry in handoffs becomes a synthetic tool the model can call, named transfer_to_<snake_name>.

import { Agent, run, tool } from "@rohhitrz/jonaki";
import * as z from "zod";

const lookupInvoice = tool({
  name: "lookup_invoice",
  description: "Look up an invoice by id.",
  input: z.object({ invoiceId: z.string() }),
  run: ({ invoiceId }) => `Invoice ${invoiceId}: $420.00, status PAID.`,
});

const billing = new Agent({
  name: "billing",
  instructions: "You are a billing specialist. Be concise.",
  tools: [lookupInvoice],
});

const technical = new Agent({
  name: "technical",
  instructions: "You are a support engineer. Diagnose service problems.",
});

const triage = new Agent({
  name: "triage",
  instructions:
    "Do not answer questions yourself. Decide whether the request is billing or technical, then transfer immediately.",
  handoffs: [billing, technical],
});

const result = await run(triage, "I was charged twice for INV-1190.");

console.log(result.state.agentName);     // "billing"
console.log(result.state.handoffDepth);  // 1

The triage agent is shown two tools it did not define — transfer_to_billing and transfer_to_technical — and routing is just a tool call.

What a transfer changes

After a handoff, the receiving agent's instructions, tools, and model apply to every later turn. state.agentName points at it, and the trace records the move:

const handoff = result.steps.find((step) => step.type === "handoff");
// { type: "handoff", from: "triage", to: "billing", reason: "...", depth: 1 }

The reason is whatever the model supplied when it called the transfer tool.

Controlling what the receiver sees

By default the receiving agent inherits the full history. Pass a contextFilter to narrow it:

import { Agent, userMessagesOnly } from "@rohhitrz/jonaki";

const triage = new Agent({
  name: "triage",
  handoffs: [
    billing,                                                // full history
    { agent: technical, contextFilter: userMessagesOnly },  // user turns only
  ],
});

Built-in filters

import { userMessagesOnly, lastMessages } from "@rohhitrz/jonaki";

userMessagesOnly;   // keep only the user's own turns
lastMessages(6);    // keep system messages plus the last 6 turns

Custom filters

A filter is (messages, ctx) => messages:

import { Agent } from "@rohhitrz/jonaki";

const noSecrets = new Agent({
  name: "triage",
  handoffs: [
    {
      agent: billing,
      contextFilter: (messages, ctx) =>
        messages.filter((m) => !m.content.includes("INTERNAL")),
    },
  ],
});

ctx carries { runId, from, to, reason }.

Filters compound

If triage strips secrets before handing to billing, billing cannot leak them by handing onward to a third agent. Every filter along the chain is applied, in order.

Turns produced after a transfer are always visible — a filter applies to the history that existed at the moment of the handoff, not to new work.

Depth limits

Chained handoffs are capped by maxHandoffDepth, default 3:

import { run } from "@rohhitrz/jonaki";

await run(triage, "help", { maxHandoffDepth: 2 });

Exceeding it fails with HANDOFF_DEPTH_EXCEEDED before the transfer happens.

Ping-pong is refused

A transfer straight back to the agent the conversation just came from is blocked. Rather than throwing, Jonaki reports it to the model so it can pick another route:

Error: cannot transfer back to "triage" — the conversation just came from
there. Handle the request here, or transfer to a different agent.

This stops A→B→A loops without killing the run.

Naming

Tool names are derived from the agent name:

import { handoffToolName } from "@rohhitrz/jonaki";

handoffToolName("billing");            // "transfer_to_billing"
handoffToolName("Technical Support");  // "transfer_to_technical_support"

Override it per handoff if you need to:

import { Agent } from "@rohhitrz/jonaki";

const triage = new Agent({
  name: "triage",
  handoffs: [
    {
      agent: billing,
      toolName: "escalate_to_billing",
      description: "Escalate a payment dispute to the billing team.",
    },
  ],
});

On this page