# Agents That Call Agents

> How one FrontMCP agent hands a task to another. Nested agents and swarm settings that let its model call another agent, handing off from execute() with this.callTool(), what to do when the other agent fails, hiding helper agents, and the depth limit for handoffs that never stop.

Source: https://frontmcp.dev/learn/agents-that-call-agents

The triage agent reads a ticket and decides who should handle it. When the ticket is about an invoice, the best one to answer it is another agent: a billing agent, with billing's tools and billing's rules. This lesson connects the two, in two ways: by letting triage's model call billing, and by handing the ticket to billing from triage's own code, where your code decides.

**You will learn**
- What two agents on one server see of each other, and what the client sees
- How `agents: [...]` and `swarm` let one agent's model call another
- How to hand a task to another agent from `execute()` with `this.callTool()`
- What to do when the other agent fails
- How FrontMCP stops handoffs that never end, and how to stop them sooner

## Two agents on one server

Billing questions need an invoice tool and billing's rules, which triage shouldn't carry around. So the help desk gets a second agent, `billing`, with its own tool and its own instructions, registered on the app next to `triage`. As in the rest of this chapter, `model.example.ts` stands in for the models, which the Playground can't reach; with two agents, it has one model for each. Open the **Tests** tab:

```ts main.ts active
import { App, FrontMcp } from "@frontmcp/sdk";
import { BillingAgent, TriageAgent } from "./agents";

@App({ id: "help-desk", name: "Help Desk", agents: [TriageAgent, BillingAgent] })
class HelpDeskApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
})
export default class Server {}
```

```ts agents.ts
import { Agent, AgentContext, z } from "@frontmcp/sdk";
import { billingModel, triageModel } from "./model.example";
import { GetInvoice, GetTicket } from "./tools";

@Agent({
  name: "triage",
  description: "Read a support ticket and say who should handle it.",
  systemInstructions: "Read the ticket with get_ticket, then say who should handle it.",
  inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") },
  tools: [GetTicket],
  llm: { adapter: triageModel },
})
export class TriageAgent extends AgentContext {}

@Agent({
  name: "billing",
  description: "Answer a customer's question about an invoice or a charge.",
  systemInstructions: "Look the invoice up with get_invoice. Refund duplicate charges; never refund more than was charged.",
  inputSchema: { question: z.string().describe("The customer's question, with the invoice number") },
  tools: [GetInvoice],
  llm: { adapter: billingModel },
})
export class BillingAgent extends AgentContext {}
```

```ts tools.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets: Record<string, string> = {
  "T-1": "I can't log in since this morning.",
  "T-2": "I was charged twice for invoice INV-7.",
};

@Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, text: tickets[id] ?? "" };
  }
}

@Tool({ name: "get_invoice", description: "Get an invoice, with how many times it was charged.", inputSchema: { id: z.string() } })
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, amount: "49 EUR", charges: 2 };
  }
}
```

```ts model.example.ts
// Stand-ins for the models the two agents would call. The Playground can't reach a
// real model, so each one follows a script: on the first turn it calls its tool, and
// on the second it answers from the tool's result. `seen` records the tools each
// model was offered. A real server doesn't need this file: see Connecting a Real Model.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const seen: Record<string, string[][]> = { triage: [], billing: [] };

export const triageModel: AgentLlmAdapter = {
  async completion(prompt, tools = []) {
    seen.triage.push(tools.map((tool) => tool.name));
    const last = prompt.messages.at(-1)!;
    if (last.role === "user") {
      const { ticketId } = JSON.parse(last.content!);
      return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "c1", name: "get_ticket", arguments: { id: ticketId } }] };
    }
    const ticket = JSON.parse(last.content!);
    if (/invoice|charge/i.test(ticket.text)) {
      return { content: `${ticket.id} is a billing question: ask the billing agent about it.`, finishReason: "stop" };
    }
    return { content: `${ticket.id} is a login problem: the accounts team should take it.`, finishReason: "stop" };
  },
};

export const billingModel: AgentLlmAdapter = {
  async completion(prompt, tools = []) {
    seen.billing.push(tools.map((tool) => tool.name));
    const last = prompt.messages.at(-1)!;
    if (last.role === "user") {
      const invoiceId = last.content!.match(/INV-\d+/)?.[0];
      return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "c1", name: "get_invoice", arguments: { id: invoiceId } }] };
    }
    const invoice = JSON.parse(last.content!);
    return { content: `${invoice.id} was charged twice: refund the second charge of ${invoice.amount}.`, finishReason: "stop" };
  },
};
```

```ts agents.test.ts
import { test, expect } from "@frontmcp/testing";
import { seen } from "./model.example";

test("the client sees both agents, and none of their tools", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t) => t.name).sort();
  expect(names).toEqual(["invoke_billing", "invoke_triage"]);
});

test("the client's model can chain the two", async ({ mcp }) => {
  const triage = await mcp.tools.call("invoke_triage", { ticketId: "T-2" });
  expect(triage.json()).toEqual({ response: "T-2 is a billing question: ask the billing agent about it." });
  const billing = await mcp.tools.call("invoke_billing", { question: "I was charged twice for invoice INV-7." });
  expect(billing.json()).toEqual({ response: "INV-7 was charged twice: refund the second charge of 49 EUR." });
});

test("each model is offered only its own agent's tools", async () => {
  expect(seen.triage).toEqual([["get_ticket"], ["get_ticket"]]);
  expect(seen.billing).toEqual([["get_invoice"], ["get_invoice"]]);
});
```

Each agent is a tool for the client, `invoke_triage` and `invoke_billing`, and a closed room inside: triage's model is offered `get_ticket` and nothing else, billing's is offered `get_invoice`. Neither model knows the other agent exists.

So the client's model does the connecting. It calls `invoke_triage`, reads that T-2 is a billing question, and calls `invoke_billing` with the customer's words. That's often enough, and it's the simplest arrangement. It costs a round trip through the client's model, though, and only works if that model thinks of it. For the help desk, a ticket should go in and an answer come out, so the next step is to let triage ask billing itself.

## Letting triage's model call billing

`@Agent({ agents })` lists the agents an agent may call. Here billing moves into triage's `agents`, and out of the app's: it's triage's helper now, not the client's.

```ts agents.ts active
import { Agent, AgentContext, z } from "@frontmcp/sdk";
import { billingModel, triageModel } from "./model.example";
import { GetInvoice, GetTicket } from "./tools";

@Agent({
  name: "billing",
  description: "Answer a customer's question about an invoice or a charge.",
  systemInstructions: "Look the invoice up with get_invoice. Refund duplicate charges; never refund more than was charged.",
  inputSchema: { question: z.string().describe("The customer's question, with the invoice number") },
  tools: [GetInvoice],
  llm: { adapter: billingModel },
})
export class BillingAgent extends AgentContext {}

@Agent({
  name: "triage",
  description: "Read a support ticket and answer it, asking the billing agent about invoices and charges.",
  systemInstructions: "Read the ticket with get_ticket. Ask the billing agent about invoices and charges, and pass on its answer.",
  inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") },
  tools: [GetTicket],
  agents: [BillingAgent], // triage's model may call billing
  llm: { adapter: triageModel },
})
export class TriageAgent extends AgentContext {}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { TriageAgent } from "./agents";

@App({ id: "help-desk", name: "Help Desk", agents: [TriageAgent] })
class HelpDeskApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
})
export default class Server {}
```

```ts tools.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets: Record<string, string> = {
  "T-1": "I can't log in since this morning.",
  "T-2": "I was charged twice for invoice INV-7.",
};

@Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, text: tickets[id] ?? "" };
  }
}

@Tool({ name: "get_invoice", description: "Get an invoice, with how many times it was charged.", inputSchema: { id: z.string() } })
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, amount: "49 EUR", charges: 2 };
  }
}
```

```ts model.example.ts
// Stand-ins for the two agents' models. Triage's reads the ticket, asks billing
// about charges, and passes billing's answer on; billing's looks the invoice up.
// `seen` records the tools each model was offered.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const seen: Record<string, string[][]> = { triage: [], billing: [] };

export const triageModel: AgentLlmAdapter = {
  async completion(prompt, tools = []) {
    seen.triage.push(tools.map((tool) => tool.name));
    const last = prompt.messages.at(-1)!;
    if (last.role === "user") {
      const { ticketId } = JSON.parse(last.content!);
      return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "c1", name: "get_ticket", arguments: { id: ticketId } }] };
    }
    if (last.name === "get_ticket") {
      const ticket = JSON.parse(last.content!);
      if (!/invoice|charge/i.test(ticket.text)) {
        return { content: `${ticket.id} is a login problem: the accounts team should take it.`, finishReason: "stop" };
      }
      return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "c2", name: "invoke_billing", arguments: { question: ticket.text } }] };
    }
    return { content: JSON.parse(last.content!).response, finishReason: "stop" };
  },
};

export const billingModel: AgentLlmAdapter = {
  async completion(prompt, tools = []) {
    seen.billing.push(tools.map((tool) => tool.name));
    const last = prompt.messages.at(-1)!;
    if (last.role === "user") {
      const invoiceId = last.content!.match(/INV-\d+/)?.[0];
      return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "c1", name: "get_invoice", arguments: { id: invoiceId } }] };
    }
    const invoice = JSON.parse(last.content!);
    return { content: `${invoice.id} was charged twice: refund the second charge of ${invoice.amount}.`, finishReason: "stop" };
  },
};
```

```ts nested.test.ts
import { test, expect } from "@frontmcp/testing";
import { Agent, AgentContext, z } from "@frontmcp/sdk";
import { BillingAgent } from "./agents";
import { seen, triageModel } from "./model.example";

test("a billing ticket comes back with billing's answer", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-2" });
  expect(result.json()).toEqual({ response: "INV-7 was charged twice: refund the second charge of 49 EUR." });
});

test("triage's model is offered billing as a tool, and billing's model its own", async ({ mcp }) => {
  await mcp.tools.call("invoke_triage", { ticketId: "T-2" });
  expect(seen.triage[0]).toEqual(["get_ticket", "invoke_billing"]);
  expect(seen.billing[0]).toEqual(["get_invoice"]);
});

test("the client sees triage only", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["invoke_triage"]);
  expect(await mcp.tools.call("invoke_billing", { question: "Was INV-7 charged twice?" })).toBeError("TOOL_NOT_FOUND");
});

test("an agent class in another agent's `tools` is refused when the agent is declared", () => {
  expect(() => {
    @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [BillingAgent as never], llm: { adapter: triageModel } })
    class Triage extends AgentContext {}
  }).toThrow("tools items must be annotated with @Tool()");
});
```

Triage's model is offered a third tool now, `invoke_billing`, with billing's `description` and input. When it calls it, FrontMCP runs billing's whole loop, with billing's model, tools and instructions, and hands billing's answer back to triage's model as the call's result. The client made one call. It can't call billing itself: an agent in another agent's `agents` belongs to that agent. List agents in `agents`, not in `tools`, which only takes tools, as the last test shows.

When billing should stay a tool of the server, which clients can call too, register both agents on the app, side by side, and give triage `swarm: { canSeeOtherAgents: true }`. Its model is then offered the app's other agents, and `visibleAgents: ["billing"]` narrows them to the ones you name. The [`@Agent` reference](https://frontmcp.dev/reference/sdk/agent#calling-other-agents) shows both.

Changed in 1.9: before, FrontMCP accepted `agents` and `swarm` on an `@Agent` and ignored them, so triage's model was offered `get_ticket` and nothing else.

> **Pitfall: The model decides whether to call billing**
Triage's instructions ask its model to ask billing about charges, and this stand-in always does. A real model usually will, and sometimes won't: it may answer a billing question itself, or ask billing something else. When a billing ticket must reach billing, don't leave it to the model. Do it in code, as the next section does.

## Handing a task off from code

An agent can call another the way it would call any tool on the server. It has [`this.callTool(name, args)`](https://frontmcp.dev/reference/sdk/call-tool), which runs a tool through the server's normal call flow, and to the server, an agent on the app is the tool `invoke_<name>`. So billing goes back on the app, next to triage, and triage keeps its model loop and adds a step [after it, in `execute()`](https://frontmcp.dev/learn/giving-an-agent-tools#before-and-after-the-loop). Its instructions now ask the model to answer with a *handoff* when a ticket is about billing, and `execute()` acts on it:

*[Illustration: A handoff from code. The MCP client makes one call, invoke_triage. Triage asks its model which team should take the ticket, and the model answers with a handoff to billing. Triage's execute() then calls this.callTool with invoke_billing; billing runs its own loop with its own model and tools and returns its answer as a CallToolResult; triage returns billing's answer to the client, which only ever saw the one call.]*
```ts triage.agent.ts active
import { Agent, AgentContext, z } from "@frontmcp/sdk";
import { triageModel } from "./model.example";
import { GetTicket } from "./tools";

type Answer = { response?: string; handoff?: "billing"; question?: string };

@Agent({
  name: "triage",
  description: "Read a support ticket and answer it, or hand billing questions to the billing agent.",
  systemInstructions:
    "Read the ticket with get_ticket, then say who should handle it. If it's about an invoice or a " +
    'charge, answer only with {"handoff": "billing", "question": "<the customer\'s words>"}.',
  inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") },
  tools: [GetTicket],
  llm: { adapter: triageModel },
})
export class TriageAgent extends AgentContext {
  async execute(input: { ticketId: string }) {
    const answer = (await super.execute(input)) as Answer; // triage's own model loop
    if (answer.handoff === "billing") {
      const result = await this.callTool("invoke_billing", { question: answer.question });
      return result.structuredContent as Answer;
    }
    return answer;
  }
}
```

```ts billing.agent.ts
import { Agent, AgentContext, z } from "@frontmcp/sdk";
import { billingModel } from "./model.example";

@Agent({
  name: "billing",
  description: "Answer a customer's question about an invoice or a charge.",
  inputSchema: { question: z.string().describe("The customer's question, with the invoice number") },
  hideFromDiscovery: true, // triage is the way in
  llm: { adapter: billingModel },
})
export class BillingAgent extends AgentContext {}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { BillingAgent } from "./billing.agent";
import { TriageAgent } from "./triage.agent";

@App({ id: "help-desk", name: "Help Desk", agents: [TriageAgent, BillingAgent] })
class HelpDeskApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
})
export default class Server {}
```

```ts tools.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets: Record<string, string> = {
  "T-1": "I can't log in since this morning.",
  "T-2": "I was charged twice for invoice INV-7.",
};

@Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, text: tickets[id] ?? "" };
  }
}
```

```ts model.example.ts
// Stand-ins for the two agents' models. Triage's model follows its instructions:
// billing tickets get a handoff. Billing's model answers at once here.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const triageModel: AgentLlmAdapter = {
  async completion(prompt) {
    const last = prompt.messages.at(-1)!;
    if (last.role === "user") {
      const { ticketId } = JSON.parse(last.content!);
      return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "c1", name: "get_ticket", arguments: { id: ticketId } }] };
    }
    const ticket = JSON.parse(last.content!);
    if (/invoice|charge/i.test(ticket.text)) {
      return { content: JSON.stringify({ handoff: "billing", question: ticket.text }), finishReason: "stop" };
    }
    return { content: `${ticket.id} is a login problem: the accounts team should take it.`, finishReason: "stop" };
  },
};

export const billingModel: AgentLlmAdapter = {
  async completion() {
    return { content: "INV-7 was charged twice: refund the second charge of 49 EUR.", finishReason: "stop" };
  },
};
```

```ts handoff.test.ts
import { test, expect } from "@frontmcp/testing";

test("a billing ticket comes back with billing's answer", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-2" });
  expect(result.json()).toEqual({ response: "INV-7 was charged twice: refund the second charge of 49 EUR." });
});

test("any other ticket comes back with triage's answer", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(result.json()).toEqual({ response: "T-1 is a login problem: the accounts team should take it." });
});

test("only triage is listed, but billing can still be called by name", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["invoke_triage"]);
  expect(await mcp.tools.call("invoke_billing", { question: "Was INV-7 charged twice?" })).toBeSuccessful();
});
```

Read `execute()` from the top:

1. **`super.execute(input)`** runs triage's own loop: its model reads the ticket and answers. For T-2 the answer is the text `{"handoff": "billing", "question": "I was charged twice for invoice INV-7."}`, and FrontMCP turns a final answer that parses as JSON into an object, so `answer.handoff` is `"billing"`.
2. **`this.callTool("invoke_billing", { question })`** runs the billing agent, with its own model, tools and instructions, as if a client had called `invoke_billing`.
3. **`result.structuredContent`** is billing's answer, `{ response: "…" }`, and triage returns it as its own.

The model decides *whether* to hand off, and the code does the handing. That split is the point: the model is good at reading a ticket, and the code makes sure a handoff goes to an agent that exists, with the input it expects, and that billing's answer reaches the client as billing gave it. The client sees one call, `invoke_triage`, and one answer.

`this.callTool()` reaches agents on the app, and also the agents in triage's own `agents`: with billing nested in triage, as in the [last section](#letting-triages-model-call-billing), `this.callTool("invoke_billing")` finds it too. So does [`this.invokeAgent("billing", input)`](https://frontmcp.dev/reference/sdk/agent#calling-other-agents), which returns billing's answer itself rather than a `CallToolResult`. (Changed in 1.9.2: before, `this.callTool()` didn't reach a nested agent, and answered `Tool "invoke_billing" not found`.)

Now that triage hands billing questions off, the client doesn't need `invoke_billing`, so `billing.agent.ts` sets `hideFromDiscovery: true`, which takes it out of `tools/list`. It hides the agent; it doesn't lock it. The last test calls `invoke_billing` by name, and it works, so an agent whose work needs protecting still needs [authorization](https://frontmcp.dev/learn/authorizing-calls) like any tool.

## When the other agent fails

When the agent you call fails, `this.callTool()` doesn't return an error result: it throws. Here the model provider behind billing is overloaded, which `model.example.ts` switches on with `provider.overloaded`. Open the **Tests** tab:

```ts triage.agent.ts active
import { Agent, AgentContext, z } from "@frontmcp/sdk";
import { triageModel } from "./model.example";
import { GetTicket } from "./tools";

type Answer = { response?: string; handoff?: "billing"; question?: string };

@Agent({
  name: "triage",
  description: "Read a support ticket and answer it, or hand billing questions to the billing agent.",
  systemInstructions:
    "Read the ticket with get_ticket, then say who should handle it. If it's about an invoice or a " +
    'charge, answer only with {"handoff": "billing", "question": "<the customer\'s words>"}.',
  inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") },
  tools: [GetTicket],
  llm: { adapter: triageModel },
})
export class TriageAgent extends AgentContext {
  async execute(input: { ticketId: string }) {
    const answer = (await super.execute(input)) as Answer;
    if (answer.handoff !== "billing") return answer;
    try {
      const result = await this.callTool("invoke_billing", { question: answer.question });
      return result.structuredContent as Answer;
    } catch (error) {
      this.logger.warn(`Billing couldn't answer ${input.ticketId}: ${(error as Error).message}`);
      return { response: `${input.ticketId} is a billing question, and the billing agent can't answer right now. A person from billing will pick it up.` };
    }
  }
}
```

```ts billing.agent.ts
import { Agent, AgentContext, z } from "@frontmcp/sdk";
import { billingModel } from "./model.example";

@Agent({
  name: "billing",
  description: "Answer a customer's question about an invoice or a charge.",
  inputSchema: { question: z.string().describe("The customer's question, with the invoice number") },
  hideFromDiscovery: true,
  llm: { adapter: billingModel },
})
export class BillingAgent extends AgentContext {}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { BillingAgent } from "./billing.agent";
import { TriageAgent } from "./triage.agent";

@App({ id: "help-desk", name: "Help Desk", agents: [TriageAgent, BillingAgent] })
class HelpDeskApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
})
export default class Server {}
```

```ts tools.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets: Record<string, string> = {
  "T-1": "I can't log in since this morning.",
  "T-2": "I was charged twice for invoice INV-7.",
};

@Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, text: tickets[id] ?? "" };
  }
}
```

```ts model.example.ts
// Stand-ins for the two agents' models, as in the example above.
// `provider.overloaded` makes billing's model fail, as a real provider does when it's busy.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const provider = { overloaded: true };

export const triageModel: AgentLlmAdapter = {
  async completion(prompt) {
    const last = prompt.messages.at(-1)!;
    if (last.role === "user") {
      const { ticketId } = JSON.parse(last.content!);
      return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "c1", name: "get_ticket", arguments: { id: ticketId } }] };
    }
    const ticket = JSON.parse(last.content!);
    if (/invoice|charge/i.test(ticket.text)) {
      return { content: JSON.stringify({ handoff: "billing", question: ticket.text }), finishReason: "stop" };
    }
    return { content: `${ticket.id} is a login problem: the accounts team should take it.`, finishReason: "stop" };
  },
};

export const billingModel: AgentLlmAdapter = {
  async completion() {
    if (provider.overloaded) throw new Error("The model provider is overloaded. Try again in 30 seconds.");
    return { content: "INV-7 was charged twice: refund the second charge of 49 EUR.", finishReason: "stop" };
  },
};
```

```ts failure.test.ts
import { test, expect } from "@frontmcp/testing";
import { provider } from "./model.example";

test("triage answers anyway when billing fails", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-2" });
  expect(result).toBeSuccessful();
  expect(result.json()).toEqual({
    response: "T-2 is a billing question, and the billing agent can't answer right now. A person from billing will pick it up.",
  });
});

test("billing's answer comes through when billing is up", async ({ mcp }) => {
  provider.overloaded = false;
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-2" });
  expect(result.json()).toEqual({ response: "INV-7 was charged twice: refund the second charge of 49 EUR." });
});
```

The error's message is `Tool "invoke_billing" execution failed: ` followed by billing's own error. Take the `try` out and run the tests: triage's call fails too, and the client gets `Tool "invoke_triage" execution failed: Tool "invoke_billing" execution failed: The model provider is overloaded…`, which tells the client's model nothing it can act on.

So catch it. What triage answers instead is up to the help desk; here it says who will pick the ticket up, and logs the real error for whoever runs the server (open the **Logs** tab).

## Handoffs that never stop

A handoff can lead to another handoff. The help desk has support tiers, and `escalate` passes a ticket up one tier at a time, calling itself, until a tier's model can solve it. Only tier 4 can solve T-5. FrontMCP limits how many calls from one agent to another a chain may make, with `swarm.maxCallDepth`, 3 by default. Here it's 2:

```ts escalate.agent.ts active
import { Agent, AgentContext, z } from "@frontmcp/sdk";
import { tierModel } from "./model.example";

type Answer = { response?: string; escalate?: boolean };

@Agent({
  name: "escalate",
  description: "Work on a ticket at one support tier, and pass it up a tier if this one can't solve it.",
  systemInstructions: 'Try to solve the ticket. If you can\'t, answer only with {"escalate": true}.',
  inputSchema: {
    ticketId: z.string().describe("Ticket id, like T-5"),
    tier: z.number().int().min(1).describe("The support tier working on it, from 1"),
  },
  swarm: { maxCallDepth: 2 },
  llm: { adapter: tierModel },
})
export class EscalateAgent extends AgentContext {
  async execute(input: { ticketId: string; tier: number }) {
    const answer = (await super.execute(input)) as Answer;
    if (answer.escalate) {
      const result = await this.callTool("invoke_escalate", { ticketId: input.ticketId, tier: input.tier + 1 });
      return result.structuredContent as Answer;
    }
    return answer;
  }
}
```

```ts model.example.ts
// A stand-in for the model at each tier: only tier 4 can solve T-5, and every tier
// below escalates. `tiers` records which tiers worked on a ticket.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const tiers: number[] = [];

export const tierModel: AgentLlmAdapter = {
  async completion(prompt) {
    const { ticketId, tier } = JSON.parse(prompt.messages[0].content!);
    tiers.push(tier);
    if (tier < 4) return { content: JSON.stringify({ escalate: true }), finishReason: "stop" };
    return { content: `Tier ${tier} solved ${ticketId}: the customer's SSO certificate had expired.`, finishReason: "stop" };
  },
};
```

```ts escalate.test.ts
import { test, expect } from "@frontmcp/testing";
import { tiers } from "./model.example";

test("the ticket stops at tier 3, and the call fails", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_escalate", { ticketId: "T-5", tier: 1 });
  expect(result).toBeError("AGENT_CALL_DEPTH_EXCEEDED");
  expect(result.text()).toBe(
    'Agent "escalate" can\'t be called from "escalate" -> "escalate" -> "escalate": that would be agent call 3, deeper than maxCallDepth 2',
  );
  expect(tiers).toEqual([1, 2, 3]);
});
```

Tier 1 called tier 2, and tier 2 called tier 3: two calls from one agent to another. Tier 3's call to tier 4 would have been the third, so FrontMCP refused it, with `AGENT_CALL_DEPTH_EXCEEDED`. Tier 3's `this.callTool()` threw, and so did tier 2's and tier 1's, so the client's whole call failed, with the message above. The limit counts every way one agent calls another: a model's call, `this.invokeAgent()`, and `this.callTool("invoke_<name>")`, as here.

Changed in 1.9: before, `maxCallDepth` wasn't enforced, and the ticket went up all four tiers.

The limit keeps a chain from running forever, whether an agent calls itself or two agents hand a ticket back and forth ("that's billing", "that's not billing"). But it stops the chain by failing the call, after every tier below it has paid for its model call, and the client gets an error instead of an answer. With the default of 3, T-5 would have reached tier 4 and been solved; a ticket no tier can solve would fail the same way at tier 4. So write a limit of your own, that ends the chain with an answer:

- **Hand off in one direction.** Triage hands off to billing; billing answers, and never hands back. A helper agent that can't answer says so, and the agent that called it decides what happens next.
- **Count the steps when an agent calls itself.** `escalate` knows its tier, so it can stop at a fixed one and hand the ticket to a person. The [second challenge](#try-some-challenges) adds that check.

The [Research Assistant](https://frontmcp.dev/examples/research-assistant) example is a coordinator that calls a narrower agent, checks what comes back, and refuses an answer that cites something it wasn't given.

## Recap

- Agents registered on the same app don't see each other: each agent's model is offered only its own tools. The client sees each agent as `invoke_<name>`, and its model can chain them.
- `agents: [...]` on an `@Agent` offers its model those agents as `invoke_<name>` tools, and keeps them from the client. `swarm: { canSeeOtherAgents: true }` offers the app's other agents instead. Either way, the model decides whether to call them. (Before 1.9, both were ignored.)
- An agent hands a task to another from `execute()`: `super.execute(input)` runs its own model, and `this.callTool("invoke_<name>", args)` runs the other agent, whose answer is the result's `structuredContent`. Let the model decide whether to hand off, and the code do it.
- `hideFromDiscovery: true` takes a helper agent out of `tools/list`; a client can still call it by name.
- When the other agent fails, `this.callTool()` throws. Catch it, and answer something the client's model can use.
- `swarm.maxCallDepth`, 3 by default, fails a chain of agent calls that goes deeper, by failing the client's whole call. Hand off in one direction, or count the steps, so a chain ends with an answer first.

## Try some challenges

Each challenge runs hidden checks against your code. Edit the code, then press **Check**.

### Challenge: Hand off to the right specialist
The help desk has a second specialist now: `accounts`, for login problems. Triage's model answers with `{"handoff": "billing"}` or `{"handoff": "accounts"}` and the customer's question, but `execute()` only knows about billing. Make triage hand each ticket to the agent its model names.

```ts triage.agent.ts active
import { Agent, AgentContext, z } from "@frontmcp/sdk";
import { triageModel } from "./model.example";
import { GetTicket } from "./tools";

type Answer = { response?: string; handoff?: "billing" | "accounts"; question?: string };

@Agent({
  name: "triage",
  description: "Read a support ticket and hand it to the specialist agent who should answer it.",
  systemInstructions:
    'Read the ticket with get_ticket. Answer only with {"handoff": "billing" or "accounts", "question": "<the customer\'s words>"}.',
  inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") },
  tools: [GetTicket],
  llm: { adapter: triageModel },
})
export class TriageAgent extends AgentContext {
  async execute(input: { ticketId: string }) {
    const answer = (await super.execute(input)) as Answer;
    if (answer.handoff === "billing") {
      const result = await this.callTool("invoke_billing", { question: answer.question });
      return result.structuredContent as Answer;
    }
    return answer;
  }
}
```

```ts triage.agent.ts solution
import { Agent, AgentContext, z } from "@frontmcp/sdk";
import { triageModel } from "./model.example";
import { GetTicket } from "./tools";

type Answer = { response?: string; handoff?: "billing" | "accounts"; question?: string };

const specialists = { billing: "invoke_billing", accounts: "invoke_accounts" };

@Agent({
  name: "triage",
  description: "Read a support ticket and hand it to the specialist agent who should answer it.",
  systemInstructions:
    'Read the ticket with get_ticket. Answer only with {"handoff": "billing" or "accounts", "question": "<the customer\'s words>"}.',
  inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") },
  tools: [GetTicket],
  llm: { adapter: triageModel },
})
export class TriageAgent extends AgentContext {
  async execute(input: { ticketId: string }) {
    const answer = (await super.execute(input)) as Answer;
    const tool = answer.handoff && specialists[answer.handoff];
    if (tool) {
      const result = await this.callTool(tool, { question: answer.question });
      return result.structuredContent as Answer;
    }
    return answer;
  }
}
```

```ts specialists.agent.ts
import { Agent, AgentContext, z } from "@frontmcp/sdk";
import { accountsModel, billingModel } from "./model.example";

@Agent({
  name: "billing",
  description: "Answer a customer's question about an invoice or a charge.",
  inputSchema: { question: z.string() },
  llm: { adapter: billingModel },
})
export class BillingAgent extends AgentContext {}

@Agent({
  name: "accounts",
  description: "Help a customer who can't log in.",
  inputSchema: { question: z.string() },
  llm: { adapter: accountsModel },
})
export class AccountsAgent extends AgentContext {}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { AccountsAgent, BillingAgent } from "./specialists.agent";
import { TriageAgent } from "./triage.agent";

@App({ id: "help-desk", name: "Help Desk", agents: [TriageAgent, BillingAgent, AccountsAgent] })
class HelpDeskApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
})
export default class Server {}
```

```ts tools.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets: Record<string, string> = {
  "T-1": "I can't log in since this morning.",
  "T-2": "I was charged twice for invoice INV-7.",
};

@Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, text: tickets[id] ?? "" };
  }
}
```

```ts model.example.ts
// Stand-ins for the three agents' models. Triage hands billing questions to
// billing and everything else to accounts; the specialists answer at once.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const triageModel: AgentLlmAdapter = {
  async completion(prompt) {
    const last = prompt.messages.at(-1)!;
    if (last.role === "user") {
      const { ticketId } = JSON.parse(last.content!);
      return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "c1", name: "get_ticket", arguments: { id: ticketId } }] };
    }
    const ticket = JSON.parse(last.content!);
    const handoff = /invoice|charge/i.test(ticket.text) ? "billing" : "accounts";
    return { content: JSON.stringify({ handoff, question: ticket.text }), finishReason: "stop" };
  },
};

export const billingModel: AgentLlmAdapter = {
  async completion() {
    return { content: "INV-7 was charged twice: refund the second charge of 49 EUR.", finishReason: "stop" };
  },
};

export const accountsModel: AgentLlmAdapter = {
  async completion() {
    return { content: "The account was locked after 5 failed logins. Send a reset link.", finishReason: "stop" };
  },
};
```

```ts route.test.ts hidden
import { test, expect } from "@frontmcp/testing";

test("a login ticket gets the accounts agent's answer", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(result.json()).toEqual({ response: "The account was locked after 5 failed logins. Send a reset link." });
});

test("a billing ticket still gets the billing agent's answer", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-2" });
  expect(result.json()).toEqual({ response: "INV-7 was charged twice: refund the second charge of 49 EUR." });
});
```

**Hint:**
Each specialist is the tool `invoke_` plus its name. Which of the two names comes from the model?

**Solution:**
The model's `handoff` names the specialist, and a small map turns it into the tool to call. You could build the name with `` `invoke_${answer.handoff}` ``, but the map keeps the model from choosing any tool on the server: a handoff to a name that isn't in it is ignored, and triage returns its model's answer as it is.

### Challenge: Stop escalating at tier 3
`escalate` passes a ticket up the support tiers until one solves it, and only FrontMCP's depth limit stops it, by failing the call. Tier 3 is the last tier with agents; above it are people. Change `execute()` so that when tier 3 can't solve a ticket, the call answers `{ "response": "No tier could solve T-5. An engineer will take it." }` (with the ticket's id) instead of escalating again.

```ts escalate.agent.ts active
import { Agent, AgentContext, z } from "@frontmcp/sdk";
import { tierModel } from "./model.example";

type Answer = { response?: string; escalate?: boolean };

@Agent({
  name: "escalate",
  description: "Work on a ticket at one support tier, and pass it up a tier if this one can't solve it.",
  systemInstructions: 'Try to solve the ticket. If you can\'t, answer only with {"escalate": true}.',
  inputSchema: {
    ticketId: z.string().describe("Ticket id, like T-5"),
    tier: z.number().int().min(1).describe("The support tier working on it, from 1"),
  },
  llm: { adapter: tierModel },
})
export class EscalateAgent extends AgentContext {
  async execute(input: { ticketId: string; tier: number }) {
    const answer = (await super.execute(input)) as Answer;
    if (answer.escalate) {
      const result = await this.callTool("invoke_escalate", { ticketId: input.ticketId, tier: input.tier + 1 });
      return result.structuredContent as Answer;
    }
    return answer;
  }
}
```

```ts escalate.agent.ts solution
import { Agent, AgentContext, z } from "@frontmcp/sdk";
import { tierModel } from "./model.example";

type Answer = { response?: string; escalate?: boolean };

const LAST_TIER = 3;

@Agent({
  name: "escalate",
  description: "Work on a ticket at one support tier, and pass it up a tier if this one can't solve it.",
  systemInstructions: 'Try to solve the ticket. If you can\'t, answer only with {"escalate": true}.',
  inputSchema: {
    ticketId: z.string().describe("Ticket id, like T-5"),
    tier: z.number().int().min(1).describe("The support tier working on it, from 1"),
  },
  llm: { adapter: tierModel },
})
export class EscalateAgent extends AgentContext {
  async execute(input: { ticketId: string; tier: number }) {
    const answer = (await super.execute(input)) as Answer;
    if (!answer.escalate) return answer;
    if (input.tier >= LAST_TIER) {
      return { response: `No tier could solve ${input.ticketId}. An engineer will take it.` };
    }
    const result = await this.callTool("invoke_escalate", { ticketId: input.ticketId, tier: input.tier + 1 });
    return result.structuredContent as Answer;
  }
}
```

```ts model.example.ts
// A stand-in for the model at each tier. Tier 2 can solve T-4; no tier can solve
// T-5, so it escalates until FrontMCP's depth limit fails the call. `tiers` records which tiers worked.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const tiers: number[] = [];

export const tierModel: AgentLlmAdapter = {
  async completion(prompt) {
    const { ticketId, tier } = JSON.parse(prompt.messages[0].content!);
    tiers.push(tier);
    if (ticketId === "T-4" && tier === 2) {
      return { content: "Tier 2 solved T-4: the customer's cache needed clearing.", finishReason: "stop" };
    }
    return { content: JSON.stringify({ escalate: true }), finishReason: "stop" };
  },
};
```

```ts escalate.test.ts hidden
import { test, expect } from "@frontmcp/testing";
import { tiers } from "./model.example";

test("a ticket no tier can solve stops at tier 3", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_escalate", { ticketId: "T-5", tier: 1 });
  expect(result.json()).toEqual({ response: "No tier could solve T-5. An engineer will take it." });
  expect(tiers).toEqual([1, 2, 3]);
});

test("a ticket tier 2 can solve still gets tier 2's answer", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_escalate", { ticketId: "T-4", tier: 1 });
  expect(result.json()).toEqual({ response: "Tier 2 solved T-4: the customer's cache needed clearing." });
});
```

**Hint:**
`execute()` knows its tier from its input. Check it after the model has answered, and before calling the next tier.

**Solution:**
Each call knows its tier, so the tier that would escalate past 3 answers instead of calling `invoke_escalate` again. The check comes after the model's answer, so tier 3 still gets its chance to solve the ticket, and before the call, so tier 4 never starts. Without it, T-5 climbs to tier 4, whose call to tier 5 is past the default `maxCallDepth` of 3, so the whole call fails with `AGENT_CALL_DEPTH_EXCEEDED`, after four paid model calls and with no answer for the client.
