# Your First Agent

> How to write a FrontMCP agent with @Agent and AgentContext, register it on an app, call it through its invoke_ tool, and read what its model is sent and what comes back.

Source: https://frontmcp.dev/learn/your-first-agent

Every tool so far does one thing and answers, and the client's model decides what happens next. An **agent** moves some of that deciding into your server. It's a tool that runs a model of its own, with instructions you write, so the client's model can hand it a whole task in one call. This lesson writes an agent that suggests a priority for a new support ticket, calls it the way a client would, and looks at what its model is sent and what comes back.

**You will learn**
- How to write an agent with `@Agent` and `AgentContext`, and register it on an app
- How a client sees an agent: one `invoke_` tool
- What the agent's model is sent, and how FrontMCP builds it from the input
- What comes back, and how `outputSchema` checks it
- How a scripted model stands in for a real one

## Writing an agent

New tickets arrive with a title and whatever the customer wrote, and before anyone works on one, a support agent gives it a priority. That's a judgment call on free text, which a model makes well. Here's an agent that makes it. Open the **Tests** tab:

```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, and treats what it returns
// as the model's reply. This one follows a fixed rule instead of reading the
// ticket. A real server doesn't need this file.
import type { AgentLlmAdapter, AgentPrompt } from "@frontmcp/sdk";

/** Every prompt the model was sent, oldest first. */
export const prompts: AgentPrompt[] = [];

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

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

test("the agent is one tool, `invoke_triage`", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t) => t.name);
  expect(names).toEqual(["invoke_triage"]);
});

test("it answers with its model's reply", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", {
    title: "Cannot log in",
    body: "Since this morning nobody on our team can log in.",
  });
  expect(result).toBeSuccessful();
  expect(result.json()).toEqual({ response: "High. The customer can't work until this is fixed." });
});

test("a ticket that blocks nobody is Normal", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", {
    title: "New invoice address",
    body: "Please send our invoices to the new office from next month.",
  });
  expect(result.json()).toEqual({ response: "Normal. Nothing stops the customer from working." });
});
```

An agent is a class, like a tool:

- **`@Agent({ name, description, systemInstructions, inputSchema, llm })`** describes it. `description` is for the client's model, which decides when to call the agent. `systemInstructions` are for the agent's own model, which does the work. `inputSchema` is a Zod shape, as on a tool.
- **`llm`** says which model the agent runs. Here it's `{ adapter: model }`, an object you write; the lesson [Connecting a Real Model](https://frontmcp.dev/learn/connecting-a-real-model) replaces it with OpenAI or Anthropic.
- **`AgentContext`** is the base class, and there's no `execute()`. FrontMCP's own sends the input to the model and returns its answer. You can [override it](https://frontmcp.dev/learn/giving-an-agent-tools#before-and-after-the-loop) when you need to.
- **`agents: [Triage]`** on `@App` registers it.

> **Note**
The Playground runs in your browser, with no network and no API key, so it can't reach a real model. Every example in this chapter has a `model.example.ts` file that plays one: an object with a `completion()` method, which is all FrontMCP asks of a model. It follows a fixed rule instead of thinking, and records what it was sent so the tests can check it. With a [real model](https://frontmcp.dev/learn/connecting-a-real-model), you don't need it.

## How a client sees an agent

Open the **Capabilities** tab of the example above. The agent is a tool called `invoke_triage`, and nothing about it says there's a model behind it. A client lists it and calls it like any other tool:

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

test("`invoke_triage` is built from the agent's options", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  expect(tool).toEqual({
    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: {
      $schema: "https://json-schema.org/draft/2020-12/schema",
      type: "object",
      properties: {
        title: { type: "string", description: "The ticket's title" },
        body: { type: "string", description: "What the customer wrote" },
      },
      required: ["title", "body"],
    },
    annotations: { title: "triage", readOnlyHint: false, openWorldHint: true },
  });
});

test("input that doesn't match `inputSchema` never reaches the model", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { title: "Cannot log in" });
  expect(result).toBeError("INVALID_INPUT");
});
```

```ts triage.agent.ts
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 model.example.ts
// Stands in for a real model. See the first example on this page.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const model: AgentLlmAdapter = {
  async completion() {
    return { content: "High. The customer can't work until this is fixed.", finishReason: "stop" };
  },
};
```

From here on, examples leave out `main.ts`: when an example exports an agent but no app, the Playground registers it in one for you, as it does tools.

| `@Agent` option | What the client sees |
| --- | --- |
| `name` | The tool's name, `invoke_` and the name, and its `annotations.title`. |
| `description` | The tool's description. |
| `inputSchema` | The tool's input schema. Input that doesn't match fails with `INVALID_INPUT` before the model is asked, as for any tool. |
| `systemInstructions` | Nothing. They go to the agent's model only. |

FrontMCP marks every agent's tool `readOnlyHint: false` and `openWorldHint: true`, whatever the agent does. The [`@Agent` reference](https://frontmcp.dev/reference/sdk/agent) has the rest of the naming rules, like `id`, which names the tool instead of `name`.

> **Pitfall: Don't leave out the description**
Without a `description`, FrontMCP makes one up from the start of the system instructions:

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

@Agent({
  name: "triage",
  // 🚩 no description
  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(), body: z.string() },
  llm: { adapter: model },
})
export class Triage extends AgentContext {}
```

```ts model.example.ts
// Stands in for a real model. See the first example on this page.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const model: AgentLlmAdapter = {
  async completion() {
    return { content: "High. The customer can't work until this is fixed.", finishReason: "stop" };
  },
};
```

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

test("🚩 the description is the instructions' first 100 characters", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  expect(tool.description).toBe(
    "Invoke the triage agent. You triage tickets for a help desk. Give the ticket a priority, High, Normal or Low, and say why in ...",
  );
});
```

The system instructions are written for the agent's model, as if it were doing the work. The client's model needs something else: what the agent is for and what to pass it, as in any [tool description](https://frontmcp.dev/learn/writing-descriptions-agents-understand). Write both.

## What the agent's model is sent

When `invoke_triage` is called, FrontMCP calls the adapter's `completion(prompt, tools)`. `prompt` is the whole conversation so far, and at the start that's the system instructions and one message from the "user", made from the input. The model in this example records every prompt, and the test reads the first:

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

test("the model gets the instructions and the input as JSON", async ({ mcp }) => {
  await mcp.tools.call("invoke_triage", { title: "Cannot log in", body: "Since this morning nobody on our team can log in." });
  expect(prompts[0]).toEqual({
    system:
      "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.",
    messages: [{ role: "user", content: '{"title":"Cannot log in","body":"Since this morning nobody on our team can log in."}' }],
  });
});
```

```ts triage.agent.ts
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 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, and treats what it returns
// as the model's reply. This one follows a fixed rule instead of reading the
// ticket. A real server doesn't need this file.
import type { AgentLlmAdapter, AgentPrompt } from "@frontmcp/sdk";

/** Every prompt the model was sent, oldest first. */
export const prompts: AgentPrompt[] = [];

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

- **`system`** is `systemInstructions`, or `""` if you didn't set any.
- **`messages`** starts with one `{ role: "user", content }`. The model reads the input as JSON, so field names like `title` and `body` are part of what it reads: name them for the model.

What the model sends back is the reply. `completion()` returns `{ content, finishReason }`, and `finishReason: "stop"` means the model is done, so its `content` is the agent's answer. The other value that matters is `"tool_calls"`, which the [next lesson](https://frontmcp.dev/learn/giving-an-agent-tools) covers.

> **Pitfall: A `query` or `message` field hides the rest of the input**
When the input has a field called `query`, `message`, `prompt` or `input`, and it isn't empty, FrontMCP sends the model that field alone, as it is, and drops everything else:

```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. Pass what the customer wrote, and who they are.",
  systemInstructions: "You triage tickets for a help desk. Give the ticket a priority, High, Normal or Low, and say why.",
  inputSchema: {
    message: z.string().describe("What the customer wrote"),
    customer: z.string().describe("The customer's company"),
  },
  llm: { adapter: model },
})
export class Triage extends AgentContext {}
```

```ts model.example.ts
// Stands in for a real model. It records what it was sent.
import type { AgentLlmAdapter, AgentPrompt } from "@frontmcp/sdk";

export const prompts: AgentPrompt[] = [];

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    prompts.push(structuredClone(prompt));
    return { content: "High. The customer can't work until this is fixed.", finishReason: "stop" };
  },
};
```

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

test("🚩 the model never hears about the customer", async ({ mcp }) => {
  await mcp.tools.call("invoke_triage", { message: "Nothing works since your update.", customer: "Acme" });
  expect(prompts[0].messages).toEqual([{ role: "user", content: "Nothing works since your update." }]);
});
```

That's handy for an agent that takes a single question as `query`, and a trap for one that takes more. Either give the fields other names, or write the message yourself: `AgentContext` builds it in `protected buildUserMessage(input)`, which returns a string, and you can override it. The [third challenge](#try-some-challenges) does.

## What comes back

The agent's answer is the model's last reply. A reply that's text arrives as `{ response: text }`, as in the first example. A reply that's a JSON object arrives as that object. So when a client needs fields, not prose, ask the model for JSON, and declare what it should look like with `outputSchema`:

```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. Answer with JSON only: " +
    '{"priority": "high" | "normal" | "low", "reason": 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"),
  },
  outputSchema: {
    priority: z.enum(["high", "normal", "low"]),
    reason: z.string(),
  },
  llm: { adapter: model },
})
export class Triage extends AgentContext {}
```

```ts model.example.ts
// Stands in for a real model. It answers in JSON, as the instructions ask, but
// when a customer shouts, it makes up a priority of its own, and a question
// makes it forget the JSON, as models do now and then.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    const ticket = prompt.messages[0].content ?? "";
    if (ticket.includes("?")) return { content: "Normal: it's a question.", finishReason: "stop" };
    const priority = /!!!/.test(ticket) ? "urgent" : /can't|cannot|down/i.test(ticket) ? "high" : "normal";
    const reason = priority === "normal" ? "Nothing stops the customer from working." : "The customer can't work.";
    return { content: JSON.stringify({ priority, reason }), finishReason: "stop" };
  },
};
```

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

test("a JSON reply is the result", 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({ priority: "high", reason: "The customer can't work." });
  expect(result.raw.structuredContent).toEqual(result.json());
});

test("`outputSchema` is in tools/list", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  expect(tool.outputSchema).toMatchObject({
    properties: { priority: { enum: ["high", "normal", "low"] }, reason: { type: "string" } },
    required: ["priority", "reason"],
  });
});

test("a reply that doesn't match fails the call", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { title: "DOWN AGAIN", body: "Fix it now!!!" });
  expect(result).toBeError("INVALID_OUTPUT");
  expect(result.text()).toBe("Tool output validation failed (output does not match outputSchema at priority)");
});

test("so does a reply that isn't JSON", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { title: "Export", body: "How do I export my tickets?" });
  expect(result).toBeError("INVALID_OUTPUT");
  expect(result.text()).toBe("Tool output validation failed (output does not match outputSchema at priority)");
});
```

FrontMCP checks the model's answer against `outputSchema` as it checks a tool's result. A reply with a priority the help desk doesn't have fails the call with `INVALID_OUTPUT`, and the text names the field that didn't match, `priority`. So does a reply that isn't JSON at all, because it arrives as `{ response }`, which has no `priority`. That's better than handing the client's model a priority nobody uses, but the call still failed, and whoever called it has to try again. Say exactly what you want in `systemInstructions`, and keep `outputSchema` as the check.

Changed in 1.8.7: FrontMCP 1.8.5 and 1.8.6 sent this failure as a `TOOL_EXECUTION_ERROR` with a stack trace in the development text, where 1.8.4 sent `INVALID_OUTPUT`. In 1.8.7 it's `INVALID_OUTPUT` again, with only the one line.

## Recap

- An agent is a class that extends `AgentContext`, decorated with `@Agent({ name, description, systemInstructions, inputSchema, llm })`, and registered with `@App({ agents: [...] })`. It needs no `execute()`.
- A client sees one tool, `invoke_<name>`, with the agent's `description` and `inputSchema`. The system instructions go to the agent's model only, so write a description for the client's model too.
- `llm: { adapter }` takes any object with `completion(prompt, tools)`. It's called with `{ system, messages }` and returns `{ content, finishReason }`; `"stop"` ends the agent's work.
- The model is sent the input as JSON, unless the input has a `query`, `message`, `prompt` or `input` field, which is then sent alone. Override `buildUserMessage(input)` to write the message yourself.
- A text reply arrives as `{ response }`, a JSON object as itself. `outputSchema` is advertised and checked: a reply that doesn't match fails the call.

## Try some challenges

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

### Challenge: Register the agent, and describe it
The `triage` agent is written, but a client connected to this server only finds `search_tickets`. Register the agent, and give it a description that tells the client's model what it's for: suggesting a priority for a ticket.

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

@Agent({
  name: "triage",
  systemInstructions: "You triage tickets for a help desk. Give the ticket a priority, High, Normal or Low, and say why.",
  inputSchema: { title: z.string(), body: z.string() },
  llm: { adapter: model },
})
class Triage extends AgentContext {}

@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 }) {
    return { tickets: [] };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] })
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, Tool, ToolContext, 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.",
  inputSchema: { title: z.string(), body: z.string() },
  llm: { adapter: model },
})
class Triage extends AgentContext {}

@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 }) {
    return { tickets: [] };
  }
}

@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 model.example.ts
// Stands in for a real model, which the Playground can't reach.
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." : "Normal. Nothing is blocked.", finishReason: "stop" };
  },
};
```

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

test("`invoke_triage` is listed", async ({ mcp }) => {
  expect(await mcp.tools.list()).toContainTool("invoke_triage");
});

test("its description mentions a priority", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "invoke_triage");
  expect(tool?.description).toMatch(/priority/i);
  expect(tool?.description).not.toMatch(/^Invoke the triage agent/);
});

test("it triages a ticket", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { title: "Cannot log in", body: "Nobody can log in." });
  expect(result.json()).toEqual({ response: "High. The customer can't work." });
});
```

**Hint:**
`@App` has a list for agents next to `tools`. The text the client's model reads is a different option from the one the agent's model reads.

**Solution:**
`agents: [Triage]` on `@App` adds the `invoke_triage` tool. Without a `description`, FrontMCP would have described it as "Invoke the triage agent." and the start of the system instructions, which are written for the agent's model. The description says what the tool is for and what to pass, for the model deciding whether to call it.

### Challenge: Keep the team the model suggests
The model now also suggests which team should take the ticket, and says so in its JSON. But the result only has `priority` and `reason`: FrontMCP drops fields that `outputSchema` doesn't list. Add `team`, which is `"accounts"`, `"billing"` or `"product"`, so it reaches the client and clients know it's there.

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

@Agent({
  name: "triage",
  description: "Suggest a priority and a team for a new support ticket. Pass the ticket's title and body.",
  systemInstructions:
    "You triage tickets for a help desk. Answer with JSON only: " +
    '{"priority": "high" | "normal" | "low", "team": "accounts" | "billing" | "product", "reason": one sentence}.',
  inputSchema: { title: z.string(), body: z.string() },
  outputSchema: {
    priority: z.enum(["high", "normal", "low"]),
    reason: z.string(),
  },
  llm: { adapter: model },
})
export class Triage extends AgentContext {}
```

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

@Agent({
  name: "triage",
  description: "Suggest a priority and a team for a new support ticket. Pass the ticket's title and body.",
  systemInstructions:
    "You triage tickets for a help desk. Answer with JSON only: " +
    '{"priority": "high" | "normal" | "low", "team": "accounts" | "billing" | "product", "reason": one sentence}.',
  inputSchema: { title: z.string(), body: z.string() },
  outputSchema: {
    priority: z.enum(["high", "normal", "low"]),
    team: z.enum(["accounts", "billing", "product"]),
    reason: z.string(),
  },
  llm: { adapter: model },
})
export class Triage extends AgentContext {}
```

```ts model.example.ts
// Stands in for a real model, which the Playground can't reach.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    const ticket = prompt.messages[0].content ?? "";
    const answer = /invoice|refund|charge/i.test(ticket)
      ? { priority: "normal", team: "billing", reason: "A billing question, and nothing is blocked." }
      : { priority: "high", team: "accounts", reason: "The customer can't log in." };
    return { content: JSON.stringify(answer), finishReason: "stop" };
  },
};
```

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

test("the result has the `team`", 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({ priority: "high", team: "accounts", reason: "The customer can't log in." });
});

test("`team` is in the output schema, with its three values", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  expect(tool.outputSchema?.properties?.team).toMatchObject({ enum: ["accounts", "billing", "product"] });
});

test("a billing ticket goes to `billing`", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { title: "Wrong invoice", body: "We were charged twice." });
  expect(result.json().team).toBe("billing");
});
```

**Hint:**
The agent's result goes through the same check as a tool's: only what `outputSchema` describes gets through.

**Solution:**
`team: z.enum(["accounts", "billing", "product"])` in `outputSchema` lets the field through, advertises it in `tools/list`, and fails the call if the model ever names a team that doesn't exist. The instructions already asked for it; the schema is what makes FrontMCP keep it.

### Challenge: Tell the model who the customer is
This agent takes the customer's `message` and their `plan`, because a customer on the `enterprise` plan gets High for anything that blocks them. But the model only ever gets the message. Make the user message it's sent contain both, for example `Plan: enterprise. Message: Nothing works since your update.`, without changing the agent's input schema.

```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. Pass what the customer wrote, and their plan.",
  systemInstructions: "You triage tickets for a help desk. Give the ticket a priority, High, Normal or Low, and say why.",
  inputSchema: {
    message: z.string().describe("What the customer wrote"),
    plan: z.enum(["free", "business", "enterprise"]).describe("The customer's plan"),
  },
  llm: { adapter: model },
})
export class Triage extends AgentContext {}
```

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

type Input = { message: string; plan: "free" | "business" | "enterprise" };

@Agent({
  name: "triage",
  description: "Suggest a priority for a new support ticket. Pass what the customer wrote, and their plan.",
  systemInstructions: "You triage tickets for a help desk. Give the ticket a priority, High, Normal or Low, and say why.",
  inputSchema: {
    message: z.string().describe("What the customer wrote"),
    plan: z.enum(["free", "business", "enterprise"]).describe("The customer's plan"),
  },
  llm: { adapter: model },
})
export class Triage extends AgentContext {
  protected buildUserMessage({ message, plan }: Input) {
    return `Plan: ${plan}. Message: ${message}`;
  }
}
```

```ts model.example.ts
// Stands in for a real model. It records what it was sent.
import type { AgentLlmAdapter, AgentPrompt } from "@frontmcp/sdk";

export const prompts: AgentPrompt[] = [];

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    prompts.push(structuredClone(prompt));
    const ticket = prompt.messages[0].content ?? "";
    return { content: /enterprise/.test(ticket) ? "High. An enterprise customer is blocked." : "Normal.", finishReason: "stop" };
  },
};
```

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

test("the model is sent the plan", async ({ mcp }) => {
  await mcp.tools.call("invoke_triage", { message: "Nothing works since your update.", plan: "enterprise" });
  expect(prompts.at(-1)?.messages[0].content).toContain("enterprise");
});

test("and the message", async ({ mcp }) => {
  await mcp.tools.call("invoke_triage", { message: "The export button is grey.", plan: "free" });
  const content = prompts.at(-1)?.messages[0].content;
  expect(content).toContain("The export button is grey.");
  expect(content).toContain("free");
});

test("the input schema still has `message` and `plan`", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  expect(Object.keys(tool.inputSchema.properties ?? {}).sort()).toEqual(["message", "plan"]);
});

test("an enterprise customer gets High", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { message: "Nothing works since your update.", plan: "enterprise" });
  expect(result.json()).toEqual({ response: "High. An enterprise customer is blocked." });
});
```

**Hint:**
Because the input has a field called `message`, FrontMCP sends that field alone. `AgentContext` has a method that builds the user message, and a subclass can override it.

**Solution:**
Overriding `buildUserMessage(input)` replaces FrontMCP's rule: whatever string it returns is the user message. Writing it yourself also lets you phrase it for the model, rather than sending raw JSON. Renaming `message` would have worked too, but it changes what clients send, which the challenge ruled out.
