# Giving an Agent Tools

> How to give a FrontMCP agent tools of its own, how the loop runs them and feeds the results back to the model, what the model hears when a tool fails, and how to limit the loop and add steps before and after it.

Source: https://frontmcp.dev/learn/giving-an-agent-tools

The triage agent in the [last lesson](https://frontmcp.dev/learn/your-first-agent) could only judge what it was handed. A support agent triaging a ticket looks things up first: the ticket, the customer and their plan, and then sets the priority in the help desk. Give an agent tools, and its model can work the same way. FrontMCP runs a loop: it asks the model what to do, runs the tools the model asks for, sends back the results, and asks again, until the model answers. This lesson gives the triage agent tools, follows that loop step by step, and covers what happens when a tool fails, when the model won't stop, and how to add steps of your own.

**You will learn**
- How to give an agent tools, and which of them the client can see
- How the loop runs: tool calls, results and the final answer
- What the model hears when a tool fails
- How to limit the loop with `maxIterations` and `timeout`
- How to add your own steps before and after the loop, and report progress

## An agent with tools

This agent takes a ticket id. To triage it, its model needs three tools: one to read the ticket, one to look up the customer, and one to set the priority. They're ordinary tools, and `tools` on `@Agent` hands them to the agent. Open the **Tests** tab:

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

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

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

@Tool({
  name: "get_ticket",
  description: "Get a support ticket by its id.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
})
export 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: "get_customer",
  description: "Get a customer by id: their name and plan.",
  inputSchema: { id: z.string().describe("Customer id, like C-1") },
})
export class GetCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    const customer = customers.get(id);
    if (!customer) throw new PublicMcpError(`There's no customer ${id}.`);
    return customer;
  }
}

@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"]),
  },
})
export 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 };
  }
}
```

```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 store.ts
export const tickets = new Map([
  ["T-1", { id: "T-1", title: "Cannot log in", customerId: "C-1", status: "open", priority: "none" }],
  ["T-2", { id: "T-2", title: "Invoice total is wrong", customerId: "C-2", status: "open", priority: "none" }],
  ["T-3", { id: "T-3", title: "Export is slow", customerId: "C-3", status: "open", priority: "none" }],
]);

export const customers = new Map([
  ["C-1", { id: "C-1", name: "Acme", plan: "business" }],
  ["C-2", { id: "C-2", name: "Globex", plan: "enterprise" }],
  ["C-3", { id: "C-3", name: "Initech", plan: "free" }],
]);
```

```ts model.example.ts
// Stands in for a real model, which the Playground can't reach. It follows the
// steps in the agent's instructions, one per request: read the ticket, look up
// the customer, set the priority, answer. A real model works the steps out
// itself, from the instructions and the tools it's offered. A real server
// doesn't need this file.
import type { AgentCompletion, AgentLlmAdapter, AgentPrompt, AgentToolDefinition } from "@frontmcp/sdk";

/** Every request FrontMCP made to the model, oldest first. */
export const requests: { prompt: AgentPrompt; tools?: AgentToolDefinition[] }[] = [];

export const model: AgentLlmAdapter = {
  async completion(prompt, tools) {
    requests.push(structuredClone({ prompt, tools }));
    const { ticketId } = JSON.parse(prompt.messages[0].content ?? "{}");
    const results = prompt.messages.filter((m) => m.role === "tool").map((m) => JSON.parse(m.content ?? "null"));
    const [ticket, customer, updated] = results;

    if (!ticket) return callTool("get_ticket", { id: ticketId });
    if (!customer) return callTool("get_customer", { id: ticket.customerId });
    if (!updated) return callTool("set_priority", { id: ticket.id, priority: choose(ticket, customer) });
    return { content: `${ticket.id} is now ${updated.priority} priority.`, finishReason: "stop" };
  },
};

function choose(ticket: { title: string }, customer: { plan: string }) {
  if (customer.plan === "enterprise" || /can't|cannot/i.test(ticket.title)) return "high";
  return customer.plan === "free" ? "low" : "normal";
}

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

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

test("the agent sets the priority, and says so", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(result.json()).toEqual({ response: "T-1 is now high priority." });
  expect(tickets.get("T-1")?.priority).toBe("high");
});

test("an enterprise customer's ticket is high", async ({ mcp }) => {
  await mcp.tools.call("invoke_triage", { ticketId: "T-2" });
  expect(tickets.get("T-2")?.priority).toBe("high");
});

test("a free customer's ticket is low", async ({ mcp }) => {
  await mcp.tools.call("invoke_triage", { ticketId: "T-3" });
  expect(tickets.get("T-3")?.priority).toBe("low");
});
```

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

test("the model is offered the agent's tools, with JSON Schemas", async ({ mcp }) => {
  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  const tools = requests[0].tools ?? [];
  expect(tools.map((t) => t.name)).toEqual(["get_ticket", "get_customer", "set_priority"]);
  expect(tools[0]).toEqual({
    name: "get_ticket",
    description: "Get a support ticket by its id.",
    parameters: {
      $schema: "https://json-schema.org/draft/2020-12/schema",
      type: "object",
      properties: { id: { type: "string", description: "Ticket id, like T-1" } },
      required: ["id"],
    },
  });
});

test("each tool call and its result join the conversation", async ({ mcp }) => {
  const before = requests.length;
  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(requests.length - before).toBe(4);

  const ticket = expect.stringMatching(/^\{"id":"T-1","title":"Cannot log in","customerId":"C-1"/);
  expect(requests.at(-1)?.prompt.messages).toEqual([
    { role: "user", content: '{"ticketId":"T-1"}' },
    { role: "assistant", content: null, toolCalls: [{ id: "call_get_ticket", name: "get_ticket", arguments: { id: "T-1" } }] },
    { role: "tool", content: ticket, toolCallId: "call_get_ticket", name: "get_ticket" },
    { role: "assistant", content: null, toolCalls: [{ id: "call_get_customer", name: "get_customer", arguments: { id: "C-1" } }] },
    { role: "tool", content: '{"id":"C-1","name":"Acme","plan":"business"}', toolCallId: "call_get_customer", name: "get_customer" },
    { role: "assistant", content: null, toolCalls: [{ id: "call_set_priority", name: "set_priority", arguments: { id: "T-1", priority: "high" } }] },
    { role: "tool", content: '{"id":"T-1","priority":"high"}', toolCallId: "call_set_priority", name: "set_priority" },
  ]);
});
```

The tools are written like any other; nothing in them knows an agent calls them. What's new is on `@Agent`:

- **`tools: [GetTicket, GetCustomer, SetPriority]`** gives the agent's model these tools, and only these.
- **`systemInstructions`** say which tool is for what, and in what order. A real model reads the tools' descriptions too, but the instructions are where you say how a support agent would go about it.

The model in `model.example.ts` follows those instructions to the letter: its replies are the ones a real model would make on a good day, one step at a time.

## The loop, step by step

One call to `invoke_triage` made four requests to the model, and the model in that example recorded each one. Open `loop.test.ts` in it: each request carries the whole conversation so far, and the test reads the last.

This is the loop every agent runs:

1. FrontMCP sends the model the system instructions, the conversation, and the agent's tools, each as a name, a description and its input as JSON Schema.
2. The model replies with `finishReason: "tool_calls"` and a list of `toolCalls`, each with an `id` it makes up, a tool `name` and `arguments`.
3. FrontMCP adds that reply to the conversation, runs each tool call, and adds one `tool` message per call: the result as JSON, with the call's `id` as `toolCallId`.
4. It sends the longer conversation back to the model, and the model decides what to do next.
5. The first reply that isn't a tool call ends the loop. Its `content` is the agent's answer, as in the [last lesson](https://frontmcp.dev/learn/your-first-agent#what-comes-back).

*[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.]*
Each request carries everything before it. By the fourth, the model is reading the ticket and the customer again, along with every tool's schema. That's what a model needs to decide the next step, and it's also what you pay for with a [real model](https://frontmcp.dev/learn/connecting-a-real-model#cost-and-latency).

The tools' input is checked against their `inputSchema`, as for a client's call. When one reply asks for several tools, FrontMCP runs them one after another, in the order the model gave them; in 1.9.4, `execution` has no option to run them in parallel.

## What the client sees

An agent's tools belong to the agent. They aren't in `tools/list`, and a client that calls one by name gets `TOOL_NOT_FOUND`. It works the other way too: by default, the agent's model is offered its own tools and nothing else, not even the app's:

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

@Tool({
  name: "search_tickets",
  description: "Search support tickets by words in their title.",
  inputSchema: { query: z.string() },
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: [{ id: "T-1", title: "Cannot log in" }].filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) };
  }
}

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

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

```ts visibility.test.ts
import { test, expect } from "@frontmcp/testing";
import { Agent, AgentContext, App, FrontMcpInstance, z } from "@frontmcp/sdk";
import { SearchTickets } from "./main";
import { model, requests } from "./model.example";
import { GetTicket } from "./triage.agent";

test("the client sees the agent and the app's tool", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t) => t.name);
  expect(names).toEqual(["invoke_triage", "search_tickets"]);
});

test("the client can't call the agent's tools", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result).toBeError("TOOL_NOT_FOUND");
  expect(result.text()).toBe('Tool "get_ticket" not found');
});

test("the agent's model isn't offered the app's tools", async ({ mcp }) => {
  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(requests[0].tools?.map((t) => t.name)).toEqual(["get_ticket", "set_priority"]);
});

test("unless the agent sets `inheritParentTools`", async () => {
  @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [GetTicket], execution: { inheritParentTools: true }, llm: { adapter: model } })
  class Triage extends AgentContext {}
  @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets], agents: [Triage] })
  class HelpDeskApp {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });
  try {
    requests.length = 0;
    await server.callTool("invoke_triage", { ticketId: "T-1" });
    expect(requests[0].tools?.map((t) => t.name)).toEqual(["get_ticket", "search_tickets"]);
  } finally {
    await server.dispose();
  }
});
```

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

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

@Tool({ name: "get_ticket", description: "Get a support ticket by its id.", inputSchema: { id: z.string() } })
export 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(), priority: z.enum(["high", "normal", "low"]) } })
class SetPriority extends ToolContext {
  async execute({ id, priority }: { id: string; priority: "high" | "normal" | "low" }) {
    return { id, priority };
  }
}

@Agent({
  name: "triage",
  description: "Triage a support ticket 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.",
  inputSchema: { ticketId: z.string() },
  tools: [GetTicket, SetPriority],
  llm: { adapter: model },
})
export class Triage extends AgentContext {}
```

```ts model.example.ts
// Stands in for a real model. It records every request, and answers at once.
import type { AgentLlmAdapter, AgentPrompt, AgentToolDefinition } from "@frontmcp/sdk";

export const requests: { prompt: AgentPrompt; tools?: AgentToolDefinition[] }[] = [];

export const model: AgentLlmAdapter = {
  async completion(prompt, tools) {
    requests.push(structuredClone({ prompt, tools }));
    return { content: "T-1 is now high priority.", finishReason: "stop" };
  },
};
```

That split is often what you want. `set_priority` changes the help desk; with it inside the agent, a client can only set a priority by going through triage and its rules. When both should have a tool, like a read-only `get_ticket`, list the same class in `@App({ tools })` and in `@Agent({ tools })`. To offer the model every tool of the app, set `execution: { inheritParentTools: true }` on the agent: its model then gets the app's tools besides its own, other agents excepted, and they run as a client's call to them would. (New in 1.9: before, the option had no effect.)

An agent that declares `resources` or `prompts` of its own also offers its model tools to read them, `list_resources` and `read_resource`, `list_prompts` and `get_prompt`: see [Reading the agent's resources and prompts](https://frontmcp.dev/reference/sdk/agent#reading-the-agents-resources-and-prompts). (New in 1.9.4.)

**Deep dive: Which providers an agent's tools see**
A tool inside an agent gets [providers](https://frontmcp.dev/learn/sharing-state-with-providers) as the app's own tools do: the app's and the server's. Here `TicketStore` is on the app, and `get_ticket` is both the client's and the agent's. Both calls find it:

```ts main.ts active
import { Agent, AgentContext, App, FrontMcp, Provider, Tool, ToolContext, z } from "@frontmcp/sdk";
import { model } from "./model.example";

@Provider({ name: "TicketStore" })
export class TicketStore {
  find(id: string) {
    return id === "T-1" ? { id, title: "Cannot log in" } : undefined;
  }
}

@Tool({ name: "get_ticket", description: "Get a support ticket by its id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return this.get(TicketStore).find(id);
  }
}

@Agent({
  name: "triage",
  description: "Triage a support ticket. Pass the ticket id.",
  inputSchema: { ticketId: z.string() },
  tools: [GetTicket],
  llm: { adapter: model },
})
export class Triage extends AgentContext {}

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], agents: [Triage], providers: [TicketStore] })
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. It reads the ticket, and repeats what it heard.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    const heard = prompt.messages.find((m) => m.role === "tool");
    if (heard) return { content: `Heard: ${heard.content}`, finishReason: "stop" };
    return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "get_ticket", arguments: { id: "T-1" } }] };
  },
};
```

```ts providers.test.ts
import { test, expect } from "@frontmcp/testing";
import { Agent, AgentContext, App, FrontMcpInstance, z } from "@frontmcp/sdk";
import { GetTicket, TicketStore } from "./main";
import { model } from "./model.example";

test("a client's call finds the app's provider", async ({ mcp }) => {
  expect((await mcp.tools.call("get_ticket", { id: "T-1" })).json()).toEqual({ id: "T-1", title: "Cannot log in" });
});

test("so does the same tool inside the agent", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(result.json()).toEqual({ response: 'Heard: {"id":"T-1","title":"Cannot log in"}' });
});

test("a provider on @Agent is for the agent's tools", async () => {
  @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [GetTicket], providers: [TicketStore], llm: { adapter: model } })
  class Triage extends AgentContext {}
  @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], agents: [Triage] })
  class HelpDeskApp {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });
  try {
    const result = await server.callTool("invoke_triage", { ticketId: "T-1" });
    expect(result.structuredContent).toEqual({ response: 'Heard: {"id":"T-1","title":"Cannot log in"}' });
    await expect(server.callTool("get_ticket", { id: "T-1" })).rejects.toThrow('Provider "TicketStore" is not available');
  } finally {
    await server.dispose();
  }
});
```

A provider that only the agent's tools use can go on the agent instead, with `@Agent({ providers })`, as the last test does: the app's tools don't see it there. (Changed in 1.9.2: before, an agent's tools didn't see the app's providers, only the server's and the agent's own.) The other examples on this page keep their data in a module, which every tool sees.

## When a tool fails

A tool that fails doesn't end the loop. FrontMCP catches the error, and the model reads it as that tool's result, `{"error": "…"}`, then decides what to do. This model gets five things wrong in its first reply:

```ts model.example.ts active
// Stands in for a real model. This one makes five mistakes in its first reply,
// to show what the model hears back about each one. Then it gives up.
import type { AgentLlmAdapter, AgentPrompt } from "@frontmcp/sdk";

export const prompts: AgentPrompt[] = [];

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    prompts.push(structuredClone(prompt));
    if (prompt.messages.length > 1) return { content: "I couldn't triage T-1.", finishReason: "stop" };
    return {
      content: null,
      finishReason: "tool_calls",
      toolCalls: [
        { id: "call_1", name: "get_customer", arguments: { id: "C-9" } }, // a customer that doesn't exist
        { id: "call_2", name: "get_ticket", arguments: { id: "T-7" } }, // the database is down for this one
        { id: "call_3", name: "set_priority", arguments: { id: "T-1", priority: "urgent" } }, // not a priority
        { id: "call_4", name: "close_ticket", arguments: { id: "T-1" } }, // a tool the agent doesn't have
        { id: "call_5", name: "get_ticket", arguments: { id: "T-9" } }, // a ticket that doesn't exist
      ],
    };
  },
};
```

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

@Agent({
  name: "triage",
  description: "Triage a support ticket: read it, look up its customer, and set its priority. Pass the ticket id.",
  systemInstructions: "You triage support tickets. Read the ticket, look up its customer, then set its priority.",
  inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") },
  tools: [GetTicket, GetCustomer, SetPriority],
  llm: { adapter: model },
})
export class Triage extends AgentContext {}
```

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

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

@Tool({ name: "get_customer", description: "Get a customer by id: their name and plan.", inputSchema: { id: z.string().describe("Customer id, like C-1") } })
export class GetCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    const customer = customers.get(id);
    if (!customer) throw new PublicMcpError(`There's no customer ${id}. Customer ids are on the ticket.`);
    return customer;
  }
}

@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"]) },
})
export class SetPriority extends ToolContext {
  async execute({ id, priority }: { id: string; priority: "high" | "normal" | "low" }) {
    return { id, priority };
  }
}
```

```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 store.ts
const tickets = new Map([["T-1", { id: "T-1", title: "Cannot log in", customerId: "C-1" }]]);

export const customers = new Map([["C-1", { id: "C-1", name: "Acme", plan: "business" }]]);

// Stands in for the ticket database, which fails for T-7 the way a database does when it's down.
export async function findTicket(id: string) {
  if (id === "T-7") throw new Error("connect ECONNREFUSED 10.0.4.7:5432");
  return tickets.get(id);
}
```

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

test("the model reads each failure as that tool's result", async ({ mcp }) => {
  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  const results = prompts[1].messages.filter((m) => m.role === "tool").map((m) => m.content);
  expect(results).toEqual([
    '{"error":"There\'s no customer C-9. Customer ids are on the ticket."}',
    '{"error":"Tool \\"get_ticket\\" execution failed: connect ECONNREFUSED 10.0.4.7:5432"}',
    '{"error":"Invalid tool input"}',
    '{"error":"Tool \\"close_ticket\\" not found in agent \\"triage\\". Available tools: [get_ticket, get_customer, set_priority]"}',
    '{"error":"There\'s no ticket T-9."}',
  ]);
});

test("the call still succeeds, with whatever the model said", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(result).toBeSuccessful();
  expect(result.json()).toEqual({ response: "I couldn't triage T-1." });
});

test("the client's log has a line for each failure", async ({ mcp }) => {
  const log = mcp.notifications.collect();
  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  const errors = log.received.filter((n) => n.params?.level === "error").map((n) => n.params?.data);
  expect(errors).toHaveLength(5);
  expect(errors[0]).toEqual({ message: "Tool get_customer failed: There's no customer C-9. Customer ids are on the ticket." });
  expect(errors[1].message).toMatch(/^Tool get_ticket failed: Internal FrontMCP error\. Please contact support with error ID: err_\w+$/);
});
```

| What went wrong | What the model reads |
| --- | --- |
| The tool threw a `PublicMcpError`, or passed one to `this.fail()` | Its message, word for word. |
| The tool threw any other error | `Tool "get_ticket" execution failed:` and the message. That's the development message, which the Playground shows. |
| The arguments don't match the tool's `inputSchema` | `Invalid tool input`, and nothing about which argument or why. |
| The agent has no tool by that name | `Tool "close_ticket" not found in agent "triage".`, and the names it does have. |

So the model hears about every failure and can try something else: ask for the right customer id, try again, or say it couldn't. `invoke_triage` succeeded, and its answer is all the client's model gets: it hears about a failure only if the agent's model says so. The client's log does get a line for each failure, as the last test shows, from the messages an agent sends about its loop (see [Progress during the loop](#progress-during-the-loop)). That line has the public message only: a `PublicMcpError`'s, or else FrontMCP's generic `Internal FrontMCP error` with an error id, so the database's address stays on the server. The server's own log line for the failure has the same id and the whole error. (Changed in 1.9.3: before, the client's log line had `connect ECONNREFUSED 10.0.4.7:5432`.)

Two things follow. Fail with a `PublicMcpError` whose message says what to do next, because that's the only kind of failure that tells the model something it can act on. And give every argument a `.describe()`, with the allowed values: a model that gets `Invalid tool input` has to guess what it got wrong.

## Limiting the loop

The model decides when the loop ends, and a model can keep asking for tools without getting anywhere. FrontMCP stops it after `execution.maxIterations` requests to the model, 10 by default, and fails the call. `execution.timeout` limits the whole loop in milliseconds, 120000 by default:

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

export const searches: string[] = [];

@Tool({ name: "search_tickets", description: "Search tickets by words in their title.", inputSchema: { query: z.string() } })
class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    searches.push(query);
    return { tickets: [] };
  }
}

@Agent({
  name: "triage",
  description: "Triage a support ticket. Pass the ticket id.",
  systemInstructions: "You triage support tickets. Look for similar tickets first.",
  inputSchema: { ticketId: z.string() },
  tools: [SearchTickets],
  llm: { adapter: model },
  execution: { maxIterations: 3 },
})
export class Triage extends AgentContext {}

@Agent({
  name: "summarize",
  description: "Summarize a support ticket in one sentence. Pass the ticket id.",
  inputSchema: { ticketId: z.string() },
  tools: [SearchTickets],
  llm: { adapter: slowModel },
  execution: { timeout: 300 },
})
export class Summarize extends AgentContext {}
```

```ts model.example.ts
// Stand-ins for real models. `model` never finds what it's looking for, and
// keeps searching. `slowModel` takes 200 ms to answer each request, and
// searches twice before it answers.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export let requests = 0;

export const model: AgentLlmAdapter = {
  async completion() {
    requests++;
    const query = ["log in", "login", "sign in", "password"][requests % 4];
    return { content: null, finishReason: "tool_calls", toolCalls: [{ id: `call_${requests}`, name: "search_tickets", arguments: { query } }] };
  },
};

export const slowModel: AgentLlmAdapter = {
  async completion(prompt) {
    await new Promise((resolve) => setTimeout(resolve, 200));
    const searches = prompt.messages.filter((m) => m.role === "tool").length;
    if (searches === 2) return { content: "A customer can't log in.", finishReason: "stop" };
    const query = searches === 0 ? "log in" : "login";
    return { content: null, finishReason: "tool_calls", toolCalls: [{ id: `call_${searches}`, name: "search_tickets", arguments: { query } }] };
  },
};
```

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

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test("the model is asked 3 times, and the call fails", async ({ mcp }) => {
  const before = requests;
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(requests - before).toBe(3);
  expect(result).toBeError("TOOL_EXECUTION_ERROR");
  expect(result.text()).toMatch(/^Tool "invoke_triage" execution failed: Agent reached maximum iterations \(3\) without completing/);
});

test("a loop that takes too long fails the call, 🚩 and keeps going", async ({ mcp }) => {
  const before = searches.length;
  const result = await mcp.tools.call("invoke_summarize", { ticketId: "T-1" });
  expect(result).toBeError("TOOL_EXECUTION_ERROR");
  expect(result.text()).toMatch(/^Tool "invoke_summarize" execution failed: Agent execution timed out after 300ms/);
  expect(result.durationMs).toBeLessThan(400);
  expect(searches.length - before).toBe(1); // the first search, before the timeout

  await wait(250);
  expect(searches.length - before).toBe(2); // the second, after the call had failed
});
```

With `maxIterations: 3`, the model was asked three times and the tool ran three times. The last search's result never reached the model, and the call failed with `TOOL_EXECUTION_ERROR`: `Agent reached maximum iterations (3) without completing`. With `timeout: 300`, the call failed after 300 ms with `Agent execution timed out after 300ms`. Both are the development messages; in production, FrontMCP hides the message of a `TOOL_EXECUTION_ERROR`, so a client only learns that the call failed.

> **Pitfall: A timeout doesn't stop the loop**
When `timeout` fails the call, FrontMCP stops waiting for the loop, but doesn't stop it. The request to the model in flight still finishes, and if the model had asked for tools, they'd still run, with no one to hear the result. So a timeout doesn't protect you from a tool that changes something: keep tools that change things idempotent, as for [retried jobs](https://frontmcp.dev/learn/running-jobs-in-the-background#retrying-a-flaky-job).

Set `maxIterations` from what the task needs. Triage takes four requests on a good day, so 6 leaves room for a retry or two, while the default of 10 lets a confused model make ten paid requests before anyone hears about it. Set `timeout` below the time your clients wait for a tool call.

## Before and after the loop

`AgentContext` runs the loop in `execute(input)`. Override it to do something before the model is asked, after it's done, or instead, and call `super.execute(input)` for the loop itself. The model's answer is a sentence; this agent adds the priority the ticket actually has in the store, which a client can rely on:

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

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

@Tool({ name: "set_priority", description: "Set a ticket's priority.", inputSchema: { id: z.string(), 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 and set its priority. Pass the ticket id.",
  systemInstructions: "You triage support tickets. Set the ticket's priority with set_priority, then say what you did.",
  inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") },
  tools: [SetPriority],
  llm: { adapter: model },
})
export class Triage extends AgentContext {
  async execute(input: { ticketId: string }) {
    const answer = await super.execute(input); // the loop
    return { ...answer, priority: tickets.get(input.ticketId)?.priority ?? "none" };
  }
}
```

```ts model.example.ts
// Stands in for a real model: sets a priority, then answers in a sentence.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    if (prompt.messages.some((m) => m.role === "tool")) return { content: "I've looked at T-1 and set its priority.", finishReason: "stop" };
    return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "set_priority", arguments: { id: "T-1", priority: "high" } }] };
  },
};
```

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

test("the result has the model's answer and the stored priority", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(result.json()).toEqual({ response: "I've looked at T-1 and set its priority.", priority: "high" });
});
```

What `execute()` returns is the result. Before the loop, `this.fail()` works as in a tool, and ends the call without a single request to the model: the [second challenge](#try-some-challenges) uses it to refuse closed tickets.

### Each request and each tool call

Inside the loop, `AgentContext` calls two more methods you can override. `completion(prompt, tools, options)` sends one request to the model, and `executeTool(name, args)` runs one tool call the model asked for. An override of either sees every one, and can change what goes in or what comes back; call `super` to do the work:

```ts triage.agent.ts active
import { Agent, AgentContext, Tool, ToolContext, z } from "@frontmcp/sdk";
import type { AgentCompletion, AgentCompletionOptions, AgentPrompt, AgentToolDefinition } from "@frontmcp/sdk";
import { model } from "./model.example";

export const overrides: string[] = [];

@Tool({ name: "get_ticket", description: "Get a support ticket by its id.", inputSchema: { id: z.string() } })
class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in" };
  }
}

@Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [GetTicket], llm: { adapter: model } })
export class Triage extends AgentContext {
  protected async completion(prompt: AgentPrompt, tools?: AgentToolDefinition[], options?: AgentCompletionOptions): Promise<AgentCompletion> {
    overrides.push(`completion: ${prompt.messages.length} message(s)`); // each request to the model
    return super.completion(prompt, tools, options);
  }
  protected async executeTool(name: string, args: Record<string, unknown>) {
    overrides.push(`executeTool: ${name}`); // each tool call
    return super.executeTool(name, args);
  }
}
```

```ts model.example.ts
// Stands in for a real model: reads the ticket, then answers.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    if (prompt.messages.some((m) => m.role === "tool")) return { content: "T-1 is high priority.", finishReason: "stop" };
    return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "get_ticket", arguments: { id: "T-1" } }] };
  },
};
```

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

test("the loop calls `completion()` for each request and `executeTool()` for each tool call", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(result.json()).toEqual({ response: "T-1 is high priority." });
  expect(overrides).toEqual(["completion: 1 message(s)", "executeTool: get_ticket", "completion: 3 message(s)"]);
});
```

Pass `tools` and `options` on to `super.completion()`: without `tools`, the model isn't offered any. `options` holds what the agent's `llm` sets for every request, `temperature` and `maxTokens`, and is empty for an `adapter` of your own. To change what a tool does for everyone who calls it, change the tool or add a [hook](https://frontmcp.dev/learn/hooking-into-calls) instead. (Changed in 1.9.2: before, the loop called the adapter directly, and a `completion()` override never ran. Before 1.9, an `executeTool()` override never ran either.)

## Progress during the loop

An agent's call takes as long as all its requests to the model and all its tool calls together. With a real model that's seconds to minutes, and the client's user waits the whole time. What can the client hear before the answer arrives?

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

@Tool({ name: "get_ticket", description: "Get a support ticket by its id.", inputSchema: { id: z.string() } })
class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    await this.notify(`Reading ticket ${id}`);
    return { id, title: "Cannot log in", status: "open" };
  }
}

@Agent({
  name: "triage",
  description: "Triage a support ticket. Pass the ticket id.",
  systemInstructions: "You triage support tickets. Read the ticket with get_ticket first.",
  inputSchema: { ticketId: z.string() },
  tools: [GetTicket],
  llm: { adapter: model },
  execution: { enableAutoProgress: true },
})
export class Triage extends AgentContext {
  async execute(input: { ticketId: string }) {
    await this.notify("Starting triage");
    return super.execute(input);
  }
}
```

```ts model.example.ts
// Stands in for a real model: reads the ticket, then answers.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    if (prompt.messages.some((m) => m.role === "tool")) return { content: "T-1 is high priority.", finishReason: "stop" };
    return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "get_ticket", arguments: { id: "T-1" } }] };
  },
};
```

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

test("the client hears from the agent and from its tools", async ({ mcp }) => {
  const notifications = mcp.notifications.collect();
  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  const messages = notifications.received.filter((n) => n.method === "notifications/message");
  expect(messages.map((n) => n.params)).toEqual([
    { level: "info", logger: "triage", data: { message: "Starting triage" } },
    { level: "info", logger: "triage", data: { message: "Identified 1 tool call(s): get_ticket" } },
    { level: "info", logger: "triage", data: { message: "Calling tool: get_ticket" } },
    { level: "info", logger: "get_ticket", data: { message: "Reading ticket T-1" } },
  ]);
});

test("`enableAutoProgress` reports the loop's progress", async ({ mcp }) => {
  const notifications = mcp.notifications.collect();
  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  const progress = notifications.received.filter((n) => n.method === "notifications/progress");
  expect(progress.map(({ params: { progress, total, message } }) => ({ progress, total, message }))).toEqual([
    { progress: 0, total: 100, message: "Starting LLM call (iteration 1/10)" },
    { progress: 100, total: 100, message: "Agent completed" },
  ]);
});
```

The client hears from the agent and from the tools it runs, during the call, and the Call tab shows it all above the result:

- The agent's own `this.notify()` and `this.progress()` send [notifications](https://frontmcp.dev/learn/reporting-progress) as a tool's do: `Starting triage`.
- For each tool call the model asks for, the agent sends `Calling tool: get_ticket`, and `Tool get_ticket failed: …` with the error's public message, at level `error`, when the call fails. `execution: { enableNotifications: false }` turns these off.
- The tools send their own, as usual: `Reading ticket T-1`.
- `execution: { enableAutoProgress: true }` adds progress updates as the loop goes, out of 100, and an `Identified 1 tool call(s)` message for each reply that asks for tools. It sends at most one update per `execution.notificationInterval`, 1000 ms by default, besides the last, so a loop as quick as this one reports only its start and its end.
- `execution: { enableStreaming: true }` sends the model's text as it's written instead, each piece as a progress update, when the model can stream: see [Streaming the reply](https://frontmcp.dev/reference/sdk/agent#streaming-the-reply). (New in 1.9.4: before, the option had no effect.)

(Changed in 1.9.2: before, under MCP 2026-07-28, none of the agent's own messages reached the client, only its tools'.)

## Recap

- `@Agent({ tools: [...] })` gives the agent's model those tools, and by default no others. The client can't see or call them; list a tool in `@App({ tools })` too when both should have it, or set `execution.inheritParentTools` to offer the model all of the app's.
- The loop: FrontMCP sends the conversation and the tools' schemas, runs each tool call the model asks for, one at a time, adds the results as `tool` messages, and asks again, until a reply isn't a tool call.
- A failing tool doesn't end the loop: the model reads `{"error": "…"}`. Only a `PublicMcpError` tells it something useful; bad arguments only say `Invalid tool input`. The client's model hears nothing unless the agent's model says so.
- `execution.maxIterations` (10) and `execution.timeout` (120000 ms) fail the call with `TOOL_EXECUTION_ERROR`. A timeout doesn't stop the loop.
- Override `execute(input)` and call `super.execute(input)` to add steps before and after. Override `completion()` to see each request to the model, and `executeTool()` to see each tool call.
- During the loop, the client hears from the agent's `this.notify()` and `this.progress()`, a `Calling tool: …` message per tool call, and the tools' own notifications; `enableAutoProgress` adds progress for each step.

## Try some challenges

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

### Challenge: Give the agent the tool it needs
The instructions tell the model to look up the customer, but the agent doesn't have `get_customer`, so the model is told the tool doesn't exist and sets every priority to normal. Give the agent the tool, without letting clients call it directly.

```ts main.ts active
import { Agent, AgentContext, App, FrontMcp, z } from "@frontmcp/sdk";
import { model } from "./model.example";
import { GetCustomer, GetTicket, SetPriority } from "./tools";

@Agent({
  name: "triage",
  description: "Triage a support ticket: read it, look up its customer, and set its priority. Pass the ticket id.",
  systemInstructions:
    "You triage support tickets. Read the ticket with get_ticket and look up its customer with get_customer. " +
    "Then set its priority with set_priority: high for the enterprise plan, normal otherwise.",
  inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") },
  tools: [GetTicket, SetPriority],
  llm: { adapter: model },
})
class Triage extends AgentContext {}

@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 main.ts solution
import { Agent, AgentContext, App, FrontMcp, z } from "@frontmcp/sdk";
import { model } from "./model.example";
import { GetCustomer, GetTicket, SetPriority } from "./tools";

@Agent({
  name: "triage",
  description: "Triage a support ticket: read it, look up its customer, and set its priority. Pass the ticket id.",
  systemInstructions:
    "You triage support tickets. Read the ticket with get_ticket and look up its customer with get_customer. " +
    "Then set its priority with set_priority: high for the enterprise plan, normal otherwise.",
  inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") },
  tools: [GetTicket, GetCustomer, SetPriority],
  llm: { adapter: model },
})
class Triage extends AgentContext {}

@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 tools.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { customers, tickets } from "./store";

@Tool({ name: "get_ticket", description: "Get a support ticket by its id.", inputSchema: { id: z.string() } })
export 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: "get_customer", description: "Get a customer by id: their name and plan.", inputSchema: { id: z.string() } })
export class GetCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    const customer = customers.get(id);
    if (!customer) throw new PublicMcpError(`There's no customer ${id}.`);
    return customer;
  }
}

@Tool({ name: "set_priority", description: "Set a ticket's priority.", inputSchema: { id: z.string(), priority: z.enum(["high", "normal", "low"]) } })
export 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 };
  }
}
```

```ts store.ts
export const tickets = new Map([
  ["T-1", { id: "T-1", title: "Cannot log in", customerId: "C-1", priority: "none" }],
  ["T-2", { id: "T-2", title: "Invoice total is wrong", customerId: "C-2", priority: "none" }],
]);

export const customers = new Map([
  ["C-1", { id: "C-1", name: "Acme", plan: "business" }],
  ["C-2", { id: "C-2", name: "Globex", plan: "enterprise" }],
]);
```

```ts model.example.ts
// Stands in for a real model. It follows the instructions, and when a tool
// fails, it carries on without it.
import type { AgentCompletion, AgentLlmAdapter, AgentToolDefinition } from "@frontmcp/sdk";

export const offered: string[][] = [];

export const model: AgentLlmAdapter = {
  async completion(prompt, tools?: AgentToolDefinition[]) {
    offered.push((tools ?? []).map((t) => t.name));
    const { ticketId } = JSON.parse(prompt.messages[0].content ?? "{}");
    const [ticket, customer, updated] = prompt.messages.filter((m) => m.role === "tool").map((m) => JSON.parse(m.content ?? "null"));
    if (!ticket) return callTool("get_ticket", { id: ticketId });
    if (!customer) return callTool("get_customer", { id: ticket.customerId });
    if (!updated) return callTool("set_priority", { id: ticketId, priority: customer.plan === "enterprise" ? "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 hidden
import { test, expect } from "@frontmcp/testing";
import { tickets } from "./store";
import { offered } from "./model.example";

test("the model is offered `get_customer`", async ({ mcp }) => {
  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(offered.at(-1)).toContain("get_customer");
});

test("Globex is on enterprise, so T-2 is high", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-2" });
  expect(result.json()).toEqual({ response: "T-2 is now high priority." });
  expect(tickets.get("T-2")?.priority).toBe("high");
});

test("a client still can't call `get_customer`", async ({ mcp }) => {
  expect(await mcp.tools.list()).not.toContainTool("get_customer");
});
```

**Hint:**
Look at what the agent's `tools` lists. A tool in `@App({ tools })` is the client's, not the agent's.

**Solution:**
Adding `GetCustomer` to `@Agent({ tools })` offers it to the agent's model, and only to it. Before, the model's call got `Tool "get_customer" not found in agent "triage"` as its result, carried on without a plan, and set `normal`. Adding it to `@App({ tools })` instead would have let clients call it, and still not given it to the agent.

### Challenge: Don't triage closed tickets
Triage runs on closed tickets too, which wastes a request to the model and changes the priority of a ticket nobody is working on. Refuse a closed ticket before the model is asked, with a `PublicMcpError` that says it's closed.

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

@Agent({
  name: "triage",
  description: "Triage an open support ticket 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.",
  inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") },
  tools: [GetTicket, SetPriority],
  llm: { adapter: model },
})
export class Triage extends AgentContext {}
```

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

@Agent({
  name: "triage",
  description: "Triage an open support ticket 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.",
  inputSchema: { ticketId: z.string().describe("Ticket id, like T-1") },
  tools: [GetTicket, SetPriority],
  llm: { adapter: model },
})
export class Triage extends AgentContext {
  async execute(input: { ticketId: string }) {
    if (tickets.get(input.ticketId)?.status === "closed") {
      this.fail(new PublicMcpError(`${input.ticketId} is closed. Reopen it before triaging it.`));
    }
    return super.execute(input);
  }
}
```

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

@Tool({ name: "get_ticket", description: "Get a support ticket by its id.", inputSchema: { id: z.string() } })
export 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(), priority: z.enum(["high", "normal", "low"]) } })
export 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 };
  }
}
```

```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 store.ts
export const tickets = new Map([
  ["T-1", { id: "T-1", title: "Cannot log in", status: "open", priority: "none" }],
  ["T-4", { id: "T-4", title: "Refund not received", status: "closed", priority: "normal" }],
]);
```

```ts model.example.ts
// Stands in for a real model: reads the ticket, sets a priority, answers.
import type { AgentCompletion, AgentLlmAdapter } from "@frontmcp/sdk";

/** The ticket ids the model was asked about, oldest first. */
export const asked: string[] = [];

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    const { ticketId } = JSON.parse(prompt.messages[0].content ?? "{}");
    asked.push(ticketId);
    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: "high" });
    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 closed.test.ts hidden
import { test, expect } from "@frontmcp/testing";
import { asked } from "./model.example";
import { tickets } from "./store";

test("a closed ticket fails with `PUBLIC_ERROR`", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-4" });
  expect(result).toBeError("PUBLIC_ERROR");
  expect(result.text()).toMatch(/closed/i);
});

test("the model is never asked about it", async ({ mcp }) => {
  await mcp.tools.call("invoke_triage", { ticketId: "T-4" });
  expect(asked).not.toContain("T-4");
  expect(tickets.get("T-4")?.priority).toBe("normal");
});

test("an open ticket is still triaged", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(result.json()).toEqual({ response: "T-1 is now high priority." });
});
```

**Hint:**
The loop runs in a method you can override. Check the ticket first, and only then call the method you overrode.

**Solution:**
Overriding `execute(input)` puts your code in front of the loop. `this.fail()` with a `PublicMcpError` ends the call before `super.execute(input)` runs, so no request goes to the model and no tool runs. Leaving the check to the model would cost a request, and depends on the model obeying an instruction it may not follow.
