# Delegating to Agents

> When the client's model shouldn't do every step itself. FrontMCP agents run a model of their own, with their own instructions and tools, so a client can hand off a whole task in one call; skills teach the client's model a procedure instead.

Source: https://frontmcp.dev/learn/delegating-to-agents

Every tool in this course so far does one thing, and the client's model decides what to call next. Some tasks are a string of those decisions. Triaging a ticket means reading it, looking up the customer, choosing a priority and setting it. The client's model can do that one call at a time, if it knows how and you trust it with every tool. Or your server can do it: an **agent** is a tool that runs a model of its own, with instructions and tools you choose, so the client's model hands over the whole task in one call. This chapter covers agents in FrontMCP 1.8, and **skills**, the other way to teach a model a procedure, and points out where 1.8 behaves differently from its docs.

**In this chapter**
- [How to write an agent, and what a client sees of it](https://frontmcp.dev/learn/your-first-agent)
- [How to give an agent tools, and what its loop does with them](https://frontmcp.dev/learn/giving-an-agent-tools)
- [How to connect an agent to a real model, and what that costs](https://frontmcp.dev/learn/connecting-a-real-model)
- [How one agent hands a task to another](https://frontmcp.dev/learn/agents-that-call-agents)
- [How to teach the client's model a procedure with a skill](https://frontmcp.dev/learn/teaching-the-model-skills)

> **Note**
Two kinds of agent meet in this chapter's help desk. The people who answer tickets are **support agents**. An **agent** on its own is always a FrontMCP agent, the thing this chapter is about.

## Your first agent

An agent is a class, like a tool. `@Agent` gives it a description for the client's model, instructions for its own model, an input schema, and `llm`, the model it runs. It has no `execute()`: FrontMCP sends the input to the model and returns the model's answer. A client sees one tool, `invoke_triage`:

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

@Agent({
  name: "triage",
  description: "Suggest a priority for a new support ticket, with a one-line reason. Pass the ticket's title and body.",
  systemInstructions:
    "You triage tickets for a help desk. Give the ticket a priority, High, Normal or Low, " +
    "and say why in one sentence. High is for anything that stops the customer from working.",
  inputSchema: {
    title: z.string().describe("The ticket's title"),
    body: z.string().describe("What the customer wrote"),
  },
  llm: { adapter: model },
})
export class Triage extends AgentContext {}
```

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

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

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

```ts model.example.ts
// Stands in for a real model, which the Playground can't reach. FrontMCP calls
// completion() each time the agent needs the model. This one follows a fixed
// rule instead of reading the ticket. A real server doesn't need this file.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    const blocked = /can't|cannot|down/i.test(prompt.messages[0].content ?? "");
    return {
      content: blocked ? "High. The customer can't work until this is fixed." : "Normal. Nothing stops the customer from working.",
      finishReason: "stop",
    };
  },
};
```

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

test("the agent is one tool, `invoke_triage`", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  expect(tool).toMatchObject({
    name: "invoke_triage",
    description: "Suggest a priority for a new support ticket, with a one-line reason. Pass the ticket's title and body.",
    inputSchema: { properties: { title: { type: "string" }, body: { type: "string" } } },
  });
});

test("its result is the model's answer", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { title: "Cannot log in", body: "Nobody on our team can log in." });
  expect(result.json()).toEqual({ response: "High. The customer can't work until this is fixed." });
});
```

The Playground can't reach a real model, so every example in this chapter has a `model.example.ts` that plays one: an object with a `completion()` method, which is all FrontMCP asks of a model. It follows a script, so the examples are the same every time you run them.

Read more: [Your First Agent](https://frontmcp.dev/learn/your-first-agent)
Learn how to write and register an agent, how a client sees it, what its model is sent and how FrontMCP builds that from the input, and what comes back, checked by `outputSchema`.

## Giving an agent tools

With `tools`, an agent's model can look things up and change things, as a support agent would. FrontMCP runs a loop: it asks the model what to do, runs the tools the model asks for, gives it the results, and asks again, until the model answers. The tools are the agent's alone. The client doesn't see them:

*[Illustration: An agent's loop. The MCP client makes one call, tools/call invoke_triage with a ticket id. Inside, in a loop of up to 10 turns, triage asks its model for a completion with the prompt and its tools; the model asks for get_ticket; triage runs get_ticket and gets the ticket's title and status; triage asks the model again with the tool result; the model answers. Triage returns that answer to the client as the call's result.]*
```ts triage.agent.ts active
import { Agent, AgentContext, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { model } from "./model.example";

export const tickets = new Map([
  ["T-1", { id: "T-1", title: "Cannot log in", priority: "none" }],
  ["T-3", { id: "T-3", title: "Export is slow", priority: "none" }],
]);

@Tool({ name: "get_ticket", description: "Get a support ticket by its id.", inputSchema: { id: z.string().describe("Ticket id, like T-1") } })
class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.get(id);
    if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`);
    return ticket;
  }
}

@Tool({
  name: "set_priority",
  description: "Set a ticket's priority.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1"), priority: z.enum(["high", "normal", "low"]) },
})
class SetPriority extends ToolContext {
  async execute({ id, priority }: { id: string; priority: "high" | "normal" | "low" }) {
    const ticket = tickets.get(id);
    if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`);
    ticket.priority = priority;
    return { id, priority };
  }
}

@Agent({
  name: "triage",
  description: "Triage a support ticket: read it and set its priority. Pass the ticket id.",
  systemInstructions:
    "You triage support tickets. Read the ticket with get_ticket, then set its priority with set_priority: " +
    "high if the customer can't work, normal otherwise. Finish with one sentence that says what you set.",
  inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") },
  tools: [GetTicket, SetPriority],
  llm: { adapter: model },
})
export class Triage extends AgentContext {}
```

```ts model.example.ts
// Stands in for a real model. It follows the agent's instructions, one step
// per request: read the ticket, set the priority, answer.
import type { AgentCompletion, AgentLlmAdapter } from "@frontmcp/sdk";

export let requests = 0;

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    requests++;
    const { ticketId } = JSON.parse(prompt.messages[0].content ?? "{}");
    const [ticket, updated] = prompt.messages.filter((m) => m.role === "tool").map((m) => JSON.parse(m.content ?? "null"));
    if (!ticket) return callTool("get_ticket", { id: ticketId });
    if (!updated) return callTool("set_priority", { id: ticketId, priority: /cannot/i.test(ticket.title) ? "high" : "normal" });
    return { content: `${ticketId} is now ${updated.priority} priority.`, finishReason: "stop" };
  },
};

function callTool(name: string, args: Record<string, unknown>): AgentCompletion {
  return { content: null, finishReason: "tool_calls", toolCalls: [{ id: `call_${name}`, name, arguments: args }] };
}
```

```ts tools.test.ts
import { test, expect } from "@frontmcp/testing";
import { requests } from "./model.example";
import { tickets } from "./triage.agent";

test("the model asks for two tools, then answers", async ({ mcp }) => {
  const before = requests;
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-3" });
  expect(requests - before).toBe(3);
  expect(result.json()).toEqual({ response: "T-3 is now normal priority." });
  expect(tickets.get("T-3")?.priority).toBe("normal");
});

test("the client sees the agent, not its tools", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["invoke_triage"]);
  expect(await mcp.tools.call("get_ticket", { id: "T-1" })).toBeError("TOOL_NOT_FOUND");
});
```

One call to `invoke_triage`, three requests to the agent's model. This loop is what every agent runs:

1. The client's model calls `invoke_triage`, and waits.
2. FrontMCP sends the agent's model its instructions, the input and the agent's tools.
3. The model asks for a tool. FrontMCP runs it and adds the call and its result to the conversation.
4. FrontMCP sends the whole conversation back, and the model decides again, as often as it needs, up to a limit you set.
5. When the model answers instead of asking for a tool, the answer is `invoke_triage`'s result.

The client's model sees steps 1 and 5. Everything between, including every tool call and every failed one, happens on your server.

Read more: [Giving an Agent Tools](https://frontmcp.dev/learn/giving-an-agent-tools)
Learn how the loop runs step by step, which tools the client and the agent can each see, what the model hears when a tool fails, how to limit the loop, how to add your own steps before and after it, and what a client hears while it runs.

## Connecting a real model

Outside the Playground, `llm` names a provider, a model and where the API key is:

```ts triage.agent.ts
@Agent({
  name: "triage",
  // …
  llm: {
    provider: "anthropic", // or "openai", with the `openai` package
    model: "claude-opus-5",
    apiKey: { env: "ANTHROPIC_API_KEY" },
  },
})
export class Triage extends AgentContext {}
```

The Playground can't run this: it has no network and no API key. A real model also changes what an agent costs. Every turn of the loop is a request to the provider, paid for by your server's key, carrying the whole conversation so far, and the client waits for all of them.

Read more: [Connecting a Real Model](https://frontmcp.dev/learn/connecting-a-real-model)
Learn which packages to install, where the key comes from and what happens when it's missing, exactly what FrontMCP sends Anthropic and OpenAI, how failed requests are retried, and how to see and keep down what each call costs.

## Agents that call agents

Billing questions need billing's tools and billing's rules. FrontMCP's docs describe nesting agents and "swarm" settings for this, but in 1.8 neither connects one agent to another. What works is one agent calling another the way it would call any tool, from its own `execute()`:

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

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

@Agent({
  name: "triage",
  description: "Answer a customer's message, or hand billing questions to the billing agent.",
  systemInstructions:
    "Answer the customer's message. If it's about an invoice or a charge, answer only with " +
    '{"handoff": "billing", "question": "<the customer\'s words>"}.',
  inputSchema: { message: z.string().describe("What the customer wrote") },
  llm: { adapter: triageModel },
})
export class Triage extends AgentContext {
  async execute(input: { message: string }) {
    const answer = (await super.execute(input)) as Answer; // triage's own loop
    if (answer.handoff === "billing") {
      const billing = await this.callTool("invoke_billing", { question: answer.question });
      return billing.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.",
  systemInstructions: "You answer billing questions for a help desk.",
  inputSchema: { question: z.string().describe("The customer's question") },
  llm: { adapter: billingModel },
})
export class Billing extends AgentContext {}
```

```ts model.example.ts
// Stand-ins for the two agents' models.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const triageModel: AgentLlmAdapter = {
  async completion(prompt) {
    const message = prompt.messages[0].content ?? "";
    if (/invoice|charge|INV-/i.test(message)) {
      return { content: JSON.stringify({ handoff: "billing", question: message }), finishReason: "stop" };
    }
    return { content: "A support agent will look at this today.", finishReason: "stop" };
  },
};

export const billingModel: AgentLlmAdapter = {
  async completion() {
    return { content: "The second charge on INV-7 is refunded. It takes 3 to 5 business days.", finishReason: "stop" };
  },
};
```

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

test("a billing question is answered by billing", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { message: "INV-7 was charged twice." });
  expect(result.json()).toEqual({ response: "The second charge on INV-7 is refunded. It takes 3 to 5 business days." });
});

test("anything else, triage answers itself", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { message: "The export button is grey." });
  expect(result.json()).toEqual({ response: "A support agent will look at this today." });
});
```

Read more: [Agents That Call Agents](https://frontmcp.dev/learn/agents-that-call-agents)
Learn what two agents on one server see of each other, what nested agents and swarm settings do in 1.8, how to hand a task off from `execute()`, what to do when the other agent fails, and how to stop handoffs that never end.

## Teaching the model skills

An agent does a task with your model. A **skill** hands the procedure to the client's model instead: the steps, the tools they use and the rules, which the client's model reads and then follows with your tools. A skill adds no tools; FrontMCP serves it as a document, `SKILL.md`:

```ts refund.skill.ts active
import { Skill, SkillContext } from "@frontmcp/sdk";

@Skill({
  name: "refund-duplicate-charge",
  description: "Refund a customer who was charged more than once for the same invoice.",
  instructions: `1. Read the invoice with get_invoice. Refund only if it has more than one charge.
2. Refund every charge after the first with refund_charge. Never refund the first charge.`,
  tools: ["get_invoice", "refund_charge"],
})
export class RefundDuplicateCharge extends SkillContext {}
```

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

@Tool({ name: "get_invoice", description: "Get an invoice, with its charges.", inputSchema: { id: z.string() } })
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, charges: [{ id: "ch_1", amount: "49 EUR" }, { id: "ch_2", amount: "49 EUR" }] };
  }
}

@Tool({ name: "refund_charge", description: "Refund one charge.", inputSchema: { chargeId: z.string() } })
export class RefundCharge extends ToolContext {
  async execute({ chargeId }: { chargeId: string }) {
    return { refunded: chargeId };
  }
}
```

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

test("a skill adds no tools", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["get_invoice", "refund_charge"]);
});

test("it's served as a SKILL.md resource", async ({ mcp }) => {
  const md = (await mcp.resources.read("skill://refund-duplicate-charge/SKILL.md")).text();
  expect(md).toContain("name: refund-duplicate-charge");
  expect(md).toContain("Never refund the first charge.");
});
```

Read more: [Teaching the Model Skills](https://frontmcp.dev/learn/teaching-the-model-skills)
Learn how to write a skill with instructions, tools, parameters and examples, how a client finds and reads skills, how to search and load them with FrontMCP's own requests, where instructions can come from, and when to write a skill, an agent or a prompt.

## Tool, agent or skill?

| If the work… | Write |
| --- | --- |
| Is one action the model calls and uses the answer of right away | A tool |
| Is a procedure the client's model can follow with your tools, once it knows the steps | A skill |
| Needs a model of its own, uses tools the client shouldn't have, or should run the same way for every client | An agent |

The difference that's easiest to miss is who pays. A skill runs on the client's model. An agent runs on yours, for every client that calls it, so write one when the task is worth that, and keep it behind [authentication](https://frontmcp.dev/learn/authenticating-clients) and [rate limits](https://frontmcp.dev/learn/limiting-calls).

## What's next?

Start the chapter with [Your First Agent](https://frontmcp.dev/learn/your-first-agent). To see an agent with a full set of help desk tools in one server, read the [Triage Agent](https://frontmcp.dev/examples/triage-agent) example, and look up every option in the [`@Agent`](https://frontmcp.dev/reference/sdk/agent) and [`@Skill`](https://frontmcp.dev/reference/sdk/skill) references. After this chapter, [Tools with a UI](https://frontmcp.dev/learn/tools-with-a-ui) shows how a tool can answer with a small page the host displays, not just data.
