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: [...]andswarmlet one agent's model call another - How to hand a task to another agent from
execute()withthis.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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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 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.
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), 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(). Its instructions now ask the model to answer with a handoff when a ticket is about billing, and execute() acts on it:
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;
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Read execute() from the top:
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, soanswer.handoffis"billing".this.callTool("invoke_billing", { question })runs the billing agent, with its own model, tools and instructions, as if a client had calledinvoke_billing.result.structuredContentis 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, this.callTool("invoke_billing") finds it too. So does this.invokeAgent("billing", input), 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 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:
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.` };
}
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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;
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
escalateknows its tier, so it can stop at a fixed one and hand the ticket to a person. The second challenge adds that check.
The 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@Agentoffers its model those agents asinvoke_<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, andthis.callTool("invoke_<name>", args)runs the other agent, whose answer is the result'sstructuredContent. Let the model decide whether to hand off, and the code do it. hideFromDiscovery: truetakes a helper agent out oftools/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 1 of 2
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.
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;
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.