# Triage Agent

> A help desk server whose triage agent reads a new ticket, looks up the customer and related tickets, then sets the priority and the team, with a skill that tells a client's model how to triage a whole queue with it.

Source: https://frontmcp.dev/examples/triage-agent

Every new ticket at a help desk needs the same few decisions before anyone works on it: how urgent is it, which team should have it, and is it about something the customer already reported? This example is a help desk server that makes them with an [agent](https://frontmcp.dev/learn/your-first-agent). A client calls one tool, `invoke_triage`, with a ticket's id; the agent's model reads the ticket, looks up the customer and their open tickets, sets the priority, assigns the ticket, and answers with what it decided. A [skill](https://frontmcp.dev/learn/teaching-the-model-skills) tells the client's model how to use the agent to triage a whole queue.

**You will learn**
- How to split work between the client's model and an agent, and which tools each one gets
- How an agent's tools share data with the app's tools through the app's providers
- How to return a result the client can rely on with `outputSchema`, and refuse bad calls before the model runs
- How a scripted model stands in for a real one, and what the real model is sent at each turn
- How a skill can drive an agent, and how to test all of it

## The server

Here is the whole server. Open the **Call** tab: `invoke_triage` has triaged T-1, and says why. Call `list_tickets` to see T-1 now `open`, high priority, with the enterprise team. Then open the **Tests** tab.

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

export const TRIAGE_INSTRUCTIONS = `You triage new tickets for a help desk.
1. Read the ticket with get_ticket.
2. Look up the customer with get_customer, and find the customer's open tickets about the same product with search_tickets.
3. Set the priority with set_priority: high if the customer can't work, normal otherwise.
4. Assign the ticket with assign_ticket: billing tickets to billing, enterprise customers to enterprise, everyone else to support.
5. Answer only with JSON: {"ticketId", "priority", "team", "related": [ids of the open tickets you found], "reason": "<one sentence>"}.`;

@Agent({
  name: "triage",
  description:
    "Triage one new support ticket: set its priority, assign it to a team, and find the customer's related open tickets. " +
    "Returns what it decided and why. Pass the ticket's id.",
  systemInstructions: TRIAGE_INSTRUCTIONS,
  inputSchema: { ticketId: z.string().describe("A new ticket's id, like T-1") },
  outputSchema: {
    ticketId: z.string(),
    priority: z.enum(["high", "normal"]),
    team: z.enum(["support", "enterprise", "billing"]),
    related: z.array(z.string()),
    reason: z.string(),
  },
  tools: [GetTicket, GetCustomer, SearchTickets, SetPriority, AssignTicket],
  execution: { maxIterations: 6, timeout: 60_000 },
  llm: { adapter: model },
})
export class TriageAgent extends AgentContext {
  async execute(input: { ticketId: string }) {
    // Refuse what the model can't fix, before paying for a model call.
    const ticket = this.get(TicketStore).get(input.ticketId);
    if (!ticket) {
      this.fail(new PublicMcpError(`There's no ticket ${input.ticketId}.`, "TICKET_NOT_FOUND"));
    }
    if (ticket.status !== "new") {
      this.fail(
        new PublicMcpError(
          `${ticket.id} is already triaged: ${ticket.priority} priority, with ${ticket.team}.`,
          "ALREADY_TRIAGED",
        ),
      );
    }
    return super.execute(input);
  }
}
```

```ts ticket.tools.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { CustomerStore, TicketStore, type Priority, type Product, type Status, type Team } from "./stores";

// The client's tool

@Tool({
  name: "list_tickets",
  description: "List support tickets, optionally only those with one status: new (not triaged yet), open or closed.",
  inputSchema: { status: z.enum(["new", "open", "closed"]).optional() },
  annotations: { readOnlyHint: true },
})
export class ListTickets extends ToolContext {
  async execute({ status }: { status?: Status }) {
    const tickets = this.get(TicketStore).search({ status });
    return { tickets: tickets.map(({ id, title, status, priority, team }) => ({ id, title, status, priority, team })) };
  }
}

// The agent's tools

@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 }) {
    const ticket = this.get(TicketStore).get(id);
    if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`);
    return ticket;
  }
}

@Tool({ name: "get_customer", description: "Get a customer by id: name and plan.", inputSchema: { id: z.string() } })
export class GetCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    const customer = this.get(CustomerStore).get(id);
    if (!customer) throw new PublicMcpError(`There's no customer ${id}.`);
    return customer;
  }
}

@Tool({
  name: "search_tickets",
  description: "Find tickets by customer, product and status.",
  inputSchema: {
    customerId: z.string().optional(),
    product: z.enum(["dashboard", "api", "billing"]).optional(),
    status: z.enum(["new", "open", "closed"]).optional(),
  },
})
export class SearchTickets extends ToolContext {
  async execute(filter: { customerId?: string; product?: Product; status?: Status }) {
    return { tickets: this.get(TicketStore).search(filter) };
  }
}

@Tool({
  name: "set_priority",
  description: "Set a ticket's priority.",
  inputSchema: { id: z.string(), priority: z.enum(["high", "normal"]) },
})
export class SetPriority extends ToolContext {
  async execute({ id, priority }: { id: string; priority: Priority }) {
    const ticket = this.get(TicketStore).update(id, { priority });
    return { id: ticket.id, priority: ticket.priority };
  }
}

@Tool({
  name: "assign_ticket",
  description: "Assign a ticket to a team. The ticket becomes open.",
  inputSchema: { id: z.string(), team: z.enum(["support", "enterprise", "billing"]) },
})
export class AssignTicket extends ToolContext {
  async execute({ id, team }: { id: string; team: Team }) {
    const ticket = this.get(TicketStore).update(id, { team, status: "open" });
    return { id: ticket.id, team: ticket.team, status: ticket.status };
  }
}
```

```ts stores.ts
import { Provider, ProviderScope } from "@frontmcp/sdk";

export type Status = "new" | "open" | "closed";
export type Product = "dashboard" | "api" | "billing";
export type Priority = "high" | "normal";
export type Team = "support" | "enterprise" | "billing";

export type Ticket = {
  id: string;
  customerId: string;
  product: Product;
  title: string;
  body: string;
  status: Status;
  priority: Priority | null; // null until triaged
  team: Team | null;
};

@Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL })
export class TicketStore {
  private tickets: Ticket[] = [
    { id: "T-1", customerId: "C-1", product: "dashboard", title: "Cannot log in to the dashboard", body: "Since this morning nobody on our team can log in.", status: "new", priority: null, team: null },
    { id: "T-2", customerId: "C-2", product: "billing", title: "Charged twice for INV-7", body: "Our card was charged twice this month.", status: "new", priority: null, team: null },
    { id: "T-3", customerId: "C-1", product: "dashboard", title: "Dashboard is slow", body: "Reports take a minute to load.", status: "open", priority: "normal", team: "enterprise" },
    { id: "T-4", customerId: "C-3", product: "api", title: "API returns error 500", body: "Every call to /orders fails since the last deploy.", status: "new", priority: null, team: null },
    { id: "T-5", customerId: "C-1", product: "api", title: "New API key", body: "Please send us a second key.", status: "closed", priority: "normal", team: "enterprise" },
    { id: "T-6", customerId: "C-3", product: "dashboard", title: "Export to CSV", body: "Can we export a report to CSV?", status: "new", priority: null, team: null },
  ];

  get(id: string) {
    return this.tickets.find((t) => t.id === id);
  }

  search(filter: { customerId?: string; product?: Product; status?: Status }) {
    return this.tickets.filter(
      (t) =>
        (!filter.customerId || t.customerId === filter.customerId) &&
        (!filter.product || t.product === filter.product) &&
        (!filter.status || t.status === filter.status),
    );
  }

  update(id: string, changes: Partial<Pick<Ticket, "priority" | "team" | "status">>) {
    const ticket = this.get(id);
    if (!ticket) throw new Error(`There's no ticket ${id}.`);
    Object.assign(ticket, changes);
    return ticket;
  }
}

export type Customer = { id: string; name: string; plan: "enterprise" | "standard" };

@Provider({ name: "CustomerStore", scope: ProviderScope.GLOBAL })
export class CustomerStore {
  private customers: Customer[] = [
    { id: "C-1", name: "Acme", plan: "enterprise" },
    { id: "C-2", name: "Globex", plan: "standard" },
    { id: "C-3", name: "Initech", plan: "standard" },
  ];

  get(id: string) {
    return this.customers.find((c) => c.id === id);
  }
}
```

```ts model.example.ts
// Stands in for a real model, which the Playground can't reach. FrontMCP calls
// completion() once per turn of the loop, with the conversation so far. This one
// looks at which tools have answered, and takes the next step of the
// instructions, applying their rules mechanically where a real model would read
// and judge. It keeps what it was sent, for the tests. A real server doesn't
// need this file.
import type { AgentCompletion, AgentLlmAdapter, AgentPrompt, AgentToolDefinition } from "@frontmcp/sdk";

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

/** A mistake real models make, switched on by a test. */
export const scenario: { slip: "none" | "answers in words" } = { slip: "none" };

export const model: AgentLlmAdapter = {
  async completion(prompt, tools) {
    turns.push({ prompt: structuredClone(prompt), tools });
    const { ticketId } = JSON.parse(prompt.messages[0].content ?? "{}");

    // Step 1: read the ticket.
    const ticket = resultOf(prompt, "get_ticket");
    if (!ticket) return callTools(["get_ticket", { id: ticketId }]);

    // Step 2: the customer and their open tickets about the same product, in one turn.
    const customer = resultOf(prompt, "get_customer");
    const search = resultOf(prompt, "search_tickets");
    if (!customer || !search) {
      return callTools(
        ["get_customer", { id: ticket.customerId }],
        ["search_tickets", { customerId: ticket.customerId, product: ticket.product, status: "open" }],
      );
    }

    // Steps 3 and 4: decide, then write both in one turn.
    const blocked = /can't|cannot|down|error/i.test(`${ticket.title} ${ticket.body}`);
    const priority = blocked ? "high" : "normal";
    const team = ticket.product === "billing" ? "billing" : customer.plan === "enterprise" ? "enterprise" : "support";
    if (!resultOf(prompt, "assign_ticket")) {
      return callTools(["set_priority", { id: ticket.id, priority }], ["assign_ticket", { id: ticket.id, team }]);
    }

    // Step 5: answer with JSON.
    if (scenario.slip === "answers in words") return { content: "Done: I set the priority and assigned the ticket.", finishReason: "stop" };
    const answer = {
      ticketId: ticket.id,
      priority,
      team,
      related: search.tickets.map((t: { id: string }) => t.id),
      reason: blocked ? `${customer.name} can't work until this is fixed.` : `Nothing is blocked for ${customer.name}.`,
    };
    return { content: JSON.stringify(answer), finishReason: "stop" };
  },
};

/** The result of the last call to `tool`, parsed, or undefined if it wasn't called yet. */
function resultOf(prompt: AgentPrompt, tool: string) {
  const message = [...prompt.messages].reverse().find((m) => m.role === "tool" && m.name === tool);
  return message ? JSON.parse(message.content ?? "null") : undefined;
}

let callCount = 0;

function callTools(...calls: [name: string, args: Record<string, unknown>][]): AgentCompletion {
  const toolCalls = calls.map(([name, args]) => ({ id: `call_${++callCount}`, name, arguments: args }));
  return { content: null, finishReason: "tool_calls", toolCalls };
}
```

```ts triage.skill.ts
import { Skill } from "@frontmcp/sdk";

@Skill({
  name: "triage-new-tickets",
  description: "Triage every new support ticket, and tell the user which ones are urgent.",
  instructions: `1. List the new tickets with list_tickets, status "new".
2. Triage each one with invoke_triage, passing its id. It sets the priority and the team, and says why.
3. Tell the user which tickets are high priority and who has them, and which ones are related to a ticket that's already open.`,
  tools: ["list_tickets", { name: "invoke_triage", purpose: "Triage one ticket", required: true }],
  examples: [
    {
      scenario: "Monday morning, with the weekend's tickets waiting",
      expectedOutcome: "Every new ticket has a priority and a team, and the user knows which ones are urgent",
    },
  ],
})
export class TriageNewTickets {}
```

```ts main.ts
import "reflect-metadata";
import { App, FrontMcp } from "@frontmcp/sdk";
import { CustomerStore, TicketStore } from "./stores";
import { TriageAgent } from "./triage.agent";
import { TriageNewTickets } from "./triage.skill";
import { ListTickets } from "./ticket.tools";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [ListTickets],
  agents: [TriageAgent],
  skills: [TriageNewTickets],
  providers: [TicketStore, CustomerStore], // for the app's tools and the agent's
})
export class HelpDeskApp {}

@FrontMcp({
  info: { name: "Help Desk", version: "1.0.0" },
  apps: [HelpDeskApp],
})
export default class HelpDeskServer {}
```

```ts triage.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { HelpDeskApp } from "./main";
import { scenario, turns } from "./model.example";
import { TRIAGE_INSTRUCTIONS } from "./triage.agent";

const listTickets = async (mcp: any, status?: string) =>
  (await mcp.tools.call("list_tickets", status ? { status } : {})).json().tickets;

test("the client sees two tools, and the agent's tools stay private", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name).sort()).toEqual(["invoke_triage", "list_tickets"]);
  expect(await mcp.tools.call("set_priority", { id: "T-1", priority: "high" })).toBeError();
});

test("T-1: Acme can't log in, so it's high priority, for the enterprise team", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(result).toBeSuccessful();
  expect(result.raw.structuredContent).toEqual({
    ticketId: "T-1",
    priority: "high",
    team: "enterprise",
    related: ["T-3"],
    reason: "Acme can't work until this is fixed.",
  });
  expect((await listTickets(mcp)).find((t: { id: string }) => t.id === "T-1")).toEqual({
    id: "T-1",
    title: "Cannot log in to the dashboard",
    status: "open",
    priority: "high",
    team: "enterprise",
  });
});

test("a billing ticket goes to billing, after four turns", async ({ mcp }) => {
  turns.length = 0;
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-2" });
  expect(result.json()).toEqual({
    ticketId: "T-2",
    priority: "normal",
    team: "billing",
    related: [],
    reason: "Nothing is blocked for Globex.",
  });
  expect(turns).toHaveLength(4);
  expect(turns[0].prompt.system).toBe(TRIAGE_INSTRUCTIONS);
  expect(turns[0].prompt.messages).toEqual([{ role: "user", content: '{"ticketId":"T-2"}' }]);
  expect(turns[0].tools?.map((t) => t.name)).toEqual(["get_ticket", "get_customer", "search_tickets", "set_priority", "assign_ticket"]);

  // What the model asked for at each turn: it reads before it writes.
  const asked = turns[3].prompt.messages
    .filter((m) => m.role === "assistant")
    .map((m) => m.toolCalls?.map((c) => c.name));
  expect(asked).toEqual([["get_ticket"], ["get_customer", "search_tickets"], ["set_priority", "assign_ticket"]]);
});

test("a ticket is only triaged once", async ({ mcp }) => {
  turns.length = 0;
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-2" }); // triaged by the test before
  expect(result).toBeError("ALREADY_TRIAGED");
  expect(turns).toEqual([]);
  expect(result.text()).toBe("T-2 is already triaged: normal priority, with billing.");
});

test("an unknown ticket is refused before the model is asked", async ({ mcp }) => {
  turns.length = 0;
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-99" });
  expect(result).toBeError("TICKET_NOT_FOUND");
  expect(result.text()).toBe("There's no ticket T-99.");
  expect(turns).toEqual([]);
});

test("an answer in words fails the call, after the tools have run", async () => {
  // A server of its own, so the ticket this changes is one the other tests don't use.
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });
  scenario.slip = "answers in words";
  const failed = await server.callTool("invoke_triage", { ticketId: "T-6" }).catch((error) => error);
  scenario.slip = "none";
  const again = await server.callTool("invoke_triage", { ticketId: "T-6" }).catch((error) => error);
  await server.dispose();

  expect(failed).toMatchObject({
    code: "INVALID_OUTPUT",
    message: "Tool output validation failed (output does not match outputSchema at ticketId)",
  });
  expect(again).toMatchObject({ code: "ALREADY_TRIAGED", message: "T-6 is already triaged: normal priority, with support." });
});

test("the skill is complete: both of its tools are on the server", async ({ mcp }) => {
  const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "skills/load", params: { skillIds: ["triage-new-tickets"] } });
  expect(response.result.skills[0]).toMatchObject({ availableTools: ["list_tickets", "invoke_triage"], isComplete: true });
});

test("following the skill triages every new ticket", async ({ mcp }) => {
  const waiting = await listTickets(mcp, "new");
  expect(waiting.map((t: { id: string }) => t.id)).toEqual(["T-4", "T-6"]);

  const decisions = [];
  for (const ticket of waiting) {
    decisions.push((await mcp.tools.call("invoke_triage", { ticketId: ticket.id })).json());
  }
  expect(decisions).toEqual([
    { ticketId: "T-4", priority: "high", team: "support", related: [], reason: "Initech can't work until this is fixed." },
    { ticketId: "T-6", priority: "normal", team: "support", related: [], reason: "Nothing is blocked for Initech." },
  ]);
  expect(await listTickets(mcp, "new")).toEqual([]);
});
```

In the Call tab, call `invoke_triage` with T-4: Initech's API fails, so it's high priority, for support. Call it with T-3 and it's refused: that ticket is already open. The **Capabilities** tab lists the skill's `SKILL.md` as a resource; read it to see what a client's model would.

## How it fits together

*[Illustration: The Triage Agent at work. The client's model, which has two tools and a skill, calls invoke_triage with a ticket id. The agent's execute() first refuses a missing or already-triaged ticket with TICKET_NOT_FOUND or ALREADY_TRIAGED. Then it runs four model turns: the model asks for get_ticket and the agent runs it; with the result, the model asks for get_customer and search_tickets; with those results, for set_priority and assign_ticket; and on the fourth turn it answers with the priority, team and reason, which are checked against outputSchema and returned to the client as structuredContent.]*
1. A client calls `invoke_triage` with a ticket's id. The agent's `execute()` refuses tickets that don't exist or were already triaged, then runs the model loop.
2. The agent's model reads the ticket with `get_ticket`.
3. It looks up the customer with `get_customer`, and the customer's open tickets about the same product with `search_tickets`, both in one turn.
4. It decides, then records the decision with `set_priority` and `assign_ticket`, again in one turn. Assigning a ticket opens it.
5. It answers with JSON, which FrontMCP checks against `outputSchema` and returns to the client as `structuredContent`.
6. A client whose model follows the `triage-new-tickets` skill does this for every new ticket: it lists them with `list_tickets`, and calls `invoke_triage` for each.

The client's model gets two tools and a skill; the agent's model gets five tools. The client can't set a priority by itself: every change to a ticket goes through the agent, and its instructions.

## The files

### `stores.ts`: tickets and customers

Two [providers](https://frontmcp.dev/reference/sdk/provider), both `GLOBAL`, so every tool gets the same instance. `TicketStore` holds six tickets: four `new` ones for the agent, one `open` one that's related to T-1 (same customer, same product), and a `closed` one. `CustomerStore` holds three customers, one of them on the enterprise plan. In a real server, these would read your help desk's database.

### `main.ts`: one app

```ts main.ts
@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [ListTickets],
  agents: [TriageAgent],
  skills: [TriageNewTickets],
  providers: [TicketStore, CustomerStore],
})
```

The app lists one tool, one agent, one skill and the two stores. An agent's tools see the app's providers, as the app's own tools do, so `list_tickets` and the agent's five tools all get the same stores: what the agent sets, the client reads. See [Sharing a provider with the agent's tools](https://frontmcp.dev/reference/sdk/agent#sharing-a-provider-with-the-agents-tools). (Changed in 1.9.2: before, an agent's tools only saw the server's providers, so the stores had to go on `@FrontMcp`, where they still work.) The agent appears to clients as the tool `invoke_triage`; the skill adds no tool, only resources.

### `ticket.tools.ts`: one tool for the client, five for the agent

`list_tickets` is in the app's `tools`, so clients see it. It's read-only, and says so with `readOnlyHint`. The other five are only in the agent's `tools`: its model can call them, and nothing else can. That's the split to make when some actions should always follow the same rules, like setting a priority: the client asks the agent, and the agent's instructions decide.

The agent's tools throw a `PublicMcpError` when an id doesn't exist. Inside the loop, that doesn't fail the call: the model gets `{"error":"There's no ticket T-9."}` as the tool's result, and can try something else. [Giving an Agent Tools](https://frontmcp.dev/learn/giving-an-agent-tools#when-a-tool-fails) shows that happening.

### `triage.agent.ts`: the agent

The options divide the audience:

- `description` is for the **client's** model: what the agent does, what it returns, and what to pass. It decides from this when to call `invoke_triage`.
- `systemInstructions` are for the **agent's** model: the steps and the rules, and the JSON to answer with.
- `inputSchema` is the tool's input. Because it has no `query` field, the model gets the input as JSON: `{"ticketId":"T-1"}`.
- `outputSchema` makes the result dependable: the client gets `structuredContent` with these fields, or an `INVALID_OUTPUT` error naming the first field that doesn't match, if the model answered anything else. See [Returning structured output](https://frontmcp.dev/reference/sdk/agent#returning-structured-output).
- `execution` limits the loop: six turns where four are enough, and a minute.

`execute()` runs before the loop. It refuses unknown tickets and tickets that are already triaged, with a [`PublicMcpError`](https://frontmcp.dev/reference/sdk/tool#returning-an-error-the-model-can-read) whose code the client can act on. Both checks cost nothing, while asking the model would cost a call. It gets `TicketStore` with `this.get()`, like any tool, then calls `super.execute(input)` for the loop. [Adding steps before and after the loop](https://frontmcp.dev/reference/sdk/agent#adding-steps-before-and-after-the-loop) has more.

### `model.example.ts`: the scripted model

```ts model.example.ts
const ticket = resultOf(prompt, "get_ticket");
if (!ticket) return callTools(["get_ticket", { id: ticketId }]);
```

FrontMCP calls `completion()` once per turn, with the whole conversation so far: the user message, then for each turn the model's tool calls and their results. The stand-in looks for the result of each step in the conversation, and asks for the first step that hasn't happened yet. When two calls don't depend on each other, it asks for both in one turn, as real models can; FrontMCP runs them one after the other. The rules it applies, blocked means high and billing goes to billing, are the ones in `TRIAGE_INSTRUCTIONS`. A real model reads those and applies judgment, which the stand-in can't. [Your First Agent](https://frontmcp.dev/learn/your-first-agent#what-the-agents-model-is-sent) looks at the prompt in detail.

### `triage.skill.ts`: using the agent for a whole queue

The skill is for the client's model. It says how to triage everything that's waiting: list the new tickets, call `invoke_triage` for each, and report. Its `tools` are the two the client has, so `skills/load` reports the skill as complete. A skill can name an agent's `invoke_` tool like any other tool, because to the client that's what it is.

Why not make the agent triage the whole queue? It could, with `list_tickets` in its tools. But one ticket per call keeps each call short and its result simple, lets the client show progress between tickets, and leaves the client's model free to stop, or to handle one ticket differently because the user asked.

### `triage.test.ts`

The tests check what a client sees: two tools, the agent's decisions as `structuredContent`, and the tickets changed afterwards. They also check what the model was sent at each turn, from `turns` in `model.example.ts`, the refusals that happen before the model runs, a model that answers in words, and the skill. The last test follows the skill the way a client's model would.

All the tests but the sixth share one server, in order, and triaging a ticket changes it for the tests after it. So the test after the one that triages T-2 checks that triaging it again is refused, and the last test triages whatever is still new, whichever tests ran before it. The sixth starts a server of its own, so the ticket it triages is one the others never see. [Testing Your Server](https://frontmcp.dev/learn/testing-your-server) covers the test API.

## Running it with a real model

Replace the stand-in with a real model, and keep everything else:

```ts triage.agent.ts
@Agent({
  // ...
  llm: { provider: "openai", model: "gpt-5", apiKey: { env: "OPENAI_API_KEY" } },
})
```

```bash
yarn add openai
export OPENAI_API_KEY=sk-...
```

Then delete `model.example.ts`. The Playground can't run this: it has no network and no key. [Connecting a Real Model](https://frontmcp.dev/learn/connecting-a-real-model) covers the providers, and what to know about each:

- **Answers vary.** A real model may ask for tools in another order, skip `search_tickets`, or answer in words instead of JSON. `outputSchema` turns a bad answer into an `INVALID_OUTPUT` error instead of a wrong result, with the text `Tool output validation failed (output does not match outputSchema at ticketId)`. The tools it called have run by then: the ticket is triaged, and calling again is refused with `ALREADY_TRIAGED`, whose message says what was decided. The sixth test shows both.
- **Each turn is a model call.** This agent takes four, so a queue of 50 tickets is 200 calls. `maxIterations: 6` caps a confused model at six.
- **Each turn takes time**, often seconds. `timeout: 60_000` fails a call that takes more than a minute, but it doesn't stop the loop: tools the model already asked for still run. Here, a late `set_priority` is harmless.
- **The key is the server's.** Every client's triage runs on your account. `@Agent`'s own `rateLimit` [caps how often clients can run it](https://frontmcp.dev/reference/sdk/agent#limiting-who-calls-an-agent-and-how-often), so set one before you expose the agent.

## Ideas to try

Each of these is a change to the Playground above. Add a test for each.

1. Give the agent a `reply_to_customer` tool, and have it tell the customer of a high-priority ticket that someone is on it. Check that it's only called for high-priority tickets.
2. Let the client skip the model for tickets you can decide by rule: in `execute()`, assign every `billing` ticket to billing without calling `super.execute()`, and return the same shape as the model would.
3. Add a `low` priority for questions, like T-6: in the instructions, the `set_priority` tool, `outputSchema` and the stand-in's rules.
4. Make `get_customer` fail for C-3, and change the stand-in so that it still assigns the ticket, to support, when the customer can't be found. Check the `reason` it gives.
