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); // 1The 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 turnsCustom 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.",
},
],
});