# @Agent

> Declare an agent, a tool that runs its own model loop with its own instructions and tools, so a client's model can hand it a whole task in one call.

Source: https://frontmcp.dev/reference/sdk/agent

`@Agent` declares an [agent](https://frontmcp.dev/learn/your-first-agent): a tool that runs a model of its own, with instructions and tools you give it, so a client's model can hand it a whole task in one call. Clients see one tool, `invoke_<name>`. When it's called, FrontMCP runs the loop: it asks the agent's model, runs the tools the model asks for, sends back their results, and answers with the model's final reply. [Delegating to Agents](https://frontmcp.dev/learn/delegating-to-agents) explains when an agent is worth writing.

```ts
@Agent(options)
class MyAgent extends AgentContext {}
```

---

## Reference

### `@Agent(options)`

Apply `@Agent` to a class that extends `AgentContext`, and list the class in an app's `agents` array. You don't write `execute()`: `AgentContext` has one that runs the model loop.

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

@Agent({
  name: "triage",
  description: "Read a new support ticket and set its priority. Pass the ticket's id.",
  systemInstructions:
    "You triage tickets for a help desk. Read the ticket with get_ticket, then set its priority " +
    "with set_priority: high if the customer can't work, normal otherwise. Say what you did in one sentence.",
  inputSchema: { ticketId: z.string().describe("The ticket's id, like T-1") },
  tools: [GetTicket, SetPriority],
  llm: { provider: "openai", model: "gpt-5", apiKey: { env: "OPENAI_API_KEY" } },
  execution: { maxIterations: 5 },
})
export class Triage extends AgentContext {}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { Triage } from "./triage.agent";

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

[See more examples below.](#usage)

#### Options

Required:

| Option | Type | Description |
| --- | --- | --- |
| `name` | `string` | Clients call the agent as `invoke_<name>`, and the tool's `annotations.title` is `name`. Characters other than letters, digits, `_` and `-` become `_`, with a warning in the server's log: `"Ticket Triage"` is `invoke_Ticket_Triage`. |
| `llm` | `AgentLlmConfig` | The model the agent runs: an adapter you write, OpenAI or Anthropic, or a provider token. See [`llm`](#llm). |

Optional:

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `description` | `string` | `"Invoke the <name> agent."`, followed by the first 100 characters of `systemInstructions` and `...` | What the client's model reads in `tools/list` to decide when to call the agent. Always set it. |
| `systemInstructions` | `string` | `""` | Sent as `system` with every prompt: the instructions for the agent's own model. |
| `inputSchema` | object of Zod types | `{}` | The input schema of `invoke_<name>`, checked like a tool's. Fields it doesn't declare are dropped, so an agent without `inputSchema` sends its model `{}` whatever the client passed. |
| `outputSchema` | object of Zod types | none | Advertised on `invoke_<name>`. The result is checked against it: fields it doesn't declare are dropped, and a result that doesn't match fails the call with `INVALID_OUTPUT`. The model has to answer with JSON. See [Returning structured output](#returning-structured-output). |
| `id` | `string` | `name` | Replaces `name` in the tool's name: `invoke_<id>`. |
| `tools` | tool classes | `[]` | The agent's tools. Only its model sees and calls them: they aren't in `tools/list`, and clients can't call them. The model calls them on the `"agent"` surface, so a tool whose `availableWhen.surface` leaves out `"agent"` isn't offered to it. An agent class in the list is refused when the agent is declared: `tools items must be annotated with @Tool()`. See [Giving an agent tools](#giving-an-agent-tools). |
| `execution` | `AgentExecutionConfig` | see [`execution`](#execution) | Limits and switches for the model loop. |
| `hideFromDiscovery` | `boolean` | `false` | Leaves `invoke_<name>` out of `tools/list`. It can still be called by name. |
| `tags` | `string[]` | `[]` | Labels. They aren't sent to clients in `tools/list`. |
| `providers` | provider classes | `[]` | Providers for the agent's tools. The agent's own `execute()` gets them only through `exports`. See [Sharing a provider with the agent's tools](#sharing-a-provider-with-the-agents-tools). (Changed in 1.9: before, each one also had to be registered on the app or the server, or the server didn't start.) |
| `agents` | agent classes | `[]` | Agents this one may call. Its model is offered each as `invoke_<name>`, and `this.invokeAgent()` calls them. They aren't registered for clients. See [Calling other agents](#calling-other-agents). (New in 1.9: before, nested agents were ignored.) |
| `swarm` | `AgentSwarmConfig` | see [`swarm`](#swarm) | Lets the agent's model call the app's other agents, and limits how deep agents call each other. |
| `resources`, `prompts` | resource and prompt classes | `[]` | Scoped to the agent. Its model reads them, exported or not, with built-in tools: `list_resources` and `read_resource`, `list_prompts` and `get_prompt`. Clients see only those in `exports`. See [Reading the agent's resources and prompts](#reading-the-agents-resources-and-prompts). (Changed in 1.9.4: before, the model was only sent tools, so nothing read them unless they were exported, and the server logged a warning naming them.) |
| `exports` | `{ resources?, prompts?, providers? }` | none | Serves the agent's resources and prompts to clients, in `resources/list` and `prompts/list`, and shares its providers with the app. An export that isn't one of the agent's own, or another key, like `tools`, stops the server from starting. (Changed in 1.9: before, exports were ignored.) |
| `plugins`, `adapters` | plugin and adapter classes | `[]` | Plugins and adapters scoped to the agent. Its plugins run for its own tools; the app's run for them only with [`execution.inheritPlugins`](#execution). See [Giving the agent's tools a plugin](#giving-the-agents-tools-a-plugin). |
| `authorities` | a profile name or a rule | none | Who may call the agent, as on a tool. Callers the rule refuses don't see `invoke_<name>` in `tools/list`, and get `AUTHORITY_DENIED` if they call it. Needs [`@FrontMcp({ authorities })`](https://frontmcp.dev/reference/auth/authorities), or the server doesn't start. See [Limiting who calls an agent, and how often](#limiting-who-calls-an-agent-and-how-often). |
| `rateLimit`, `concurrency`, `timeout`, `availableWhen` | as on [`@Tool`](https://frontmcp.dev/reference/sdk/tool#options) | none | Limit `invoke_<name>` as they limit a tool. A refused call never reaches the model. See [Limiting who calls an agent, and how often](#limiting-who-calls-an-agent-and-how-often). |

Options that plugins add to `@Tool`, like `approval` or `featureFlag`, apply to `invoke_<name>` too, and need their plugin on the agent's app: without it, the server doesn't start (`Unenforced metadata: Agent "…" declares 'approval'`).

#### `llm`

`llm` takes one of three forms:

| Form | Example | Notes |
| --- | --- | --- |
| An adapter | `{ adapter: model }` | Any object with a `completion()` method: see [The model adapter](#the-model-adapter). `adapter` can also be a function that returns one, `(providers) => adapter`; `providers.get(token)` reads a provider. |
| A built-in provider | `{ provider: "openai", model: "gpt-5", apiKey: { env: "OPENAI_API_KEY" } }` | FrontMCP creates an [`OpenAIAdapter` or `AnthropicAdapter`](#built-in-adapters). The fields are below. |
| A token | `LLM_ADAPTER` | A symbol registered as a provider that returns an adapter, like `{ provide: LLM_ADAPTER, name: "model", inject: () => [], useFactory: () => adapter }`. A class as the token fails `@Agent`'s validation. |

The built-in provider's fields:

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `provider` | `"openai" \| "anthropic"` | required | Which adapter to create. Install its package too: `openai` or `@anthropic-ai/sdk`. |
| `model` | `string` | required | The provider's model id. |
| `apiKey` | `string \| { env: string } \| WithConfig` | required | The key, or `{ env: "OPENAI_API_KEY" }` to read it from the environment. The variable is read when the server starts, and the server doesn't start without it: `Environment variable OPENAI_API_KEY is not set`. |
| `baseUrl` | `string` | the provider's | Another endpoint that speaks the same API. |
| `temperature` | `number` | the provider's | Sent with every request. |
| `maxTokens` | `number` | the provider's; `4096` for Anthropic | The longest reply, in tokens. |

A real model can't run in the Playground: it has no network and no API key. [Connecting a Real Model](https://frontmcp.dev/learn/connecting-a-real-model) sets one up, and [Using a real model](#using-a-real-model) below shows what an adapter sends.

#### `execution`

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `maxIterations` | `number` | `10` | How many times the model may be asked. If it still asks for tools the last time, the tools run, and the call fails with `Agent reached maximum iterations`. See [Troubleshooting](#agent-reached-maximum-iterations--without-completing). |
| `timeout` | `number` | `120000` (2 minutes) | How long the model loop may take, in milliseconds. Then the call fails with `Agent execution timed out`, but the loop keeps running in the background. See [Troubleshooting](#agent-execution-timed-out-after-ms). |
| `useToolFlow` | `boolean` | `true` | Runs the agent's tools through FrontMCP's full call flow, which validates the model's arguments against each tool's `inputSchema`. With `false`, a tool gets the model's arguments unchecked. A tool's `authorities` are checked either way. |
| `enableNotifications` | `boolean` | `true` | Sends the client a log message, at level `info`, for each tool call the model asks for: `Calling tool: <name>`; and one at level `error` for each that fails: `Tool <name> failed: ` and the error's public message, the one a client would get for it in production. `false` turns these off, and `enableAutoProgress` with them. The agent's own `this.notify()` and `this.progress()` still send. See [Telling the client how the loop is going](#telling-the-client-how-the-loop-is-going). |
| `enableAutoProgress` | `boolean` | `false` | Sends progress, out of 100, as the loop goes: `Starting LLM call (iteration 1/10)` before each request to the model, `LLM response received` after it, `Executing tool 1/2: get_ticket` before each tool call, and `Agent completed` at the end, or `Agent failed: ` and the error's public message when the model still asks for tools after `maxIterations`. When the adapter throws, the call fails with no last update. Each value is higher than the one before. It also sends a log message for each reply that asks for tools: `Identified 2 tool call(s): get_ticket, get_customer`. A run that [streams its reply](#streaming-the-reply) sends that log message, but none of the progress updates. (Changed in 1.9.3: before, the value could go down, and `Agent failed: ` carried the error's internal message.) |
| `enableStreaming` | `boolean` | `false` | Sends the model's text to the client as the model writes it, each piece as a progress update on the call's progress token. Only for a call that has a progress token, with a model that can stream. See [Streaming the reply](#streaming-the-reply). (Changed in 1.9.4: before, it had no effect, and `true` logged a warning at startup.) |
| `inheritParentTools` | `boolean` | `false` | Also offers the model the tools of the app the agent is in, other agents excepted. They run through the app's call flow, as a client's call would. (Changed in 1.9: before, it had no effect, and its default was documented as `true`.) |
| `notificationInterval` | `number` | `1000` | The shortest time between two of `enableAutoProgress`'s progress updates, in milliseconds. An update that comes sooner is dropped, except the last one. (Changed in 1.9.2: before, it was never read.) |
| `inheritPlugins` | `boolean` | `false` | Also runs the app's and the server's plugins for the agent's tools. A plugin installed in both places runs once. See [Giving the agent's tools a plugin](#giving-the-agents-tools-a-plugin). (New in 1.9: before, it was never read.) |

#### `swarm`

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `canSeeOtherAgents` | `boolean` | `false` | Offers the agent's model the app's other agents, each as `invoke_<name>`, besides its own `agents`. `this.invokeAgent()` can call them too. |
| `visibleAgents` | `string[]` | all agents | Limits which ones, by name. |
| `isVisible` | `boolean` | `true` | `false` hides this agent from the other agents' models and from their `invokeAgent()`. Clients still see it. |
| `maxCallDepth` | `number` | `3` | How many agent-to-agent calls a chain may make. The next one fails with `AGENT_CALL_DEPTH_EXCEEDED`, however it's made: by a model, by `invokeAgent()`, or by `this.callTool("invoke_<name>")`. The default applies without `swarm` too. |

Since 1.9, these settings take effect. Before, FrontMCP accepted `swarm` and ignored it. [Calling other agents](#calling-other-agents) shows them, and [Agents That Call Agents](https://frontmcp.dev/learn/agents-that-call-agents) teaches when to use which.

#### How a call runs

When a client calls `invoke_<name>`:

1. FrontMCP checks the caller against the agent's `authorities`, `availableWhen` and limits, then the arguments against `inputSchema`, as for any tool, and drops fields it doesn't declare. Inside that `tools:call-tool` flow, the agent runs in the `agents:call-agent` flow, so [`AgentCallHook`](https://frontmcp.dev/reference/sdk/hooks#decorator-sets-and-their-flows) hooks run for it too, on a plugin or on the agent class (since 1.8.5).
2. It builds the **user message** from the input: the `query`, `message`, `prompt` or `input` field, if the input has one that isn't empty, as it is; otherwise the whole input as JSON, like `{"ticketId":"T-1"}`.
3. It calls the model: `completion({ system, messages }, tools)`, where `system` is `systemInstructions` and `messages` starts with the user message. A run that [streams its reply](#streaming-the-reply) calls `streamCompletion()` instead, with the same arguments.
4. If the model answers with `finishReason: "tool_calls"` and at least one tool call, FrontMCP runs the calls **one after another**, in order, adds the model's message and one `tool` message per call to `messages`, and goes back to step 3. A tool that fails, or that the agent doesn't have, doesn't fail the call: its message is `{"error":"…"}`, for the model to read.
5. Any other answer ends the loop. Its `content` becomes the result: text that starts with `{` or `[` and parses as JSON is the result as it is (an array arrives as `{ "value": [...] }`, like a tool's); anything else becomes `{ "response": content }`, and `null` becomes `{ "response": "" }`. The result's `_meta["agent/execution"]` is `{ agentId, agentName, durationMs }`.
6. With an `outputSchema`, the result is checked against it.

#### The model adapter

An adapter is any object with this method:

```ts
interface AgentLlmAdapter {
  completion(prompt: AgentPrompt, tools?: AgentToolDefinition[], options?: AgentCompletionOptions): Promise<AgentCompletion>;
  streamCompletion?(prompt: AgentPrompt, tools?: AgentToolDefinition[], options?: AgentCompletionOptions): AsyncGenerator<AgentCompletionChunk>;
}
```

FrontMCP calls `completion()` once per turn of the loop, or `streamCompletion()` in a run that [streams its reply](#streaming-the-reply), with:

- `prompt.system`: the agent's `systemInstructions`, or `""`.
- `prompt.messages`: the conversation so far, as `AgentMessage`s. FrontMCP keeps adding to this same array after `completion()` returns, so copy it, with `structuredClone(prompt)`, if you keep it.
- `tools`: the tools the model is offered, as `{ name, description, parameters }`, where `parameters` is the tool's input as JSON Schema: the agent's tools, and the [built-in tools](#reading-the-agents-resources-and-prompts) that read its resources and prompts, when it has any. `undefined` when there are none.
- `options`: what [`completionOptions()`](#agentcontext) returns: by default `temperature` and `maxTokens` from a built-in provider's `llm`, when it sets them, and `{}` otherwise. (Changed in 1.9.2: before, it was always `undefined`.)

An `AgentMessage` has:

| Field | Type | Description |
| --- | --- | --- |
| `role` | `"user" \| "assistant" \| "tool" \| "system"` | Who wrote it. |
| `content` | `string \| null` | The text. A `tool` message's content is the tool's result as JSON (or the text, if the result is a string). |
| `toolCalls` | `AgentToolCall[]` | On an `assistant` message: the calls the model asked for. |
| `toolCallId` | `string` | On a `tool` message: the `id` of the call it answers. |
| `name` | `string` | On a `tool` message: the tool's name. |

`completion()` returns an `AgentCompletion`:

| Field | Type | Description |
| --- | --- | --- |
| `content` | `string \| null` | The model's text. |
| `finishReason` | `"stop" \| "tool_calls" \| "length" \| "content_filter"` | `"tool_calls"`, with at least one entry in `toolCalls`, runs the tools and asks again. Anything else ends the loop, with `content` as the answer. |
| `toolCalls` | `{ id: string; name: string; arguments: Record<string, unknown> }[]` | The tools to call, with their arguments as an object. Each `id` comes back as the `toolCallId` of that call's `tool` message. |
| `usage` | `{ promptTokens, completionTokens, totalTokens? }` | Optional. It isn't passed on to the client. |
| `raw` | `unknown` | Optional: the provider's own response. |

`streamCompletion()` is optional. It yields the reply in pieces, as `AgentCompletionChunk`s:

| Field | Type | Description |
| --- | --- | --- |
| `type` | `"content" \| "tool_call" \| "done"` | What the chunk carries. |
| `content` | `string` | On a `content` chunk: the next piece of the model's text, which the client gets as it arrives. |
| `toolCall` | `{ id, name?, arguments? }` | On a `tool_call` chunk: a call the model is starting. Chunks with the same `id` add to it. |
| `completion` | `AgentCompletion` | On the last chunk, `done`: the whole reply. The loop takes `finishReason` and `toolCalls` from it, so the tools get their full arguments. Without a `done` chunk, the calls the `tool_call` chunks announced are run. |

The reply's text is what the `content` chunks carried, or the `done` chunk's `content` when none did. `OpenAIAdapter` and `AnthropicAdapter` have a `streamCompletion()`.

#### `AgentContext`

The class your agent extends. It has most of what a tool's context has, the request included: in an agent's own `execute()`, `this.context` is the request's context, `this.clientInfo` and `this.platform` describe the client, and `this.fetch()` sends the tracing headers, as in a tool. (Changed in 1.8.5: before, `this.context` threw there, `this.clientInfo` was empty, and `this.fetch()` sent no tracing headers.) [Context classes](https://frontmcp.dev/reference/sdk/contexts) compares every member. These are the members that matter for agents:

| Member | Description |
| --- | --- |
| `execute(input)` | Runs the model loop and returns the result. Override it to add steps before or after the loop, and call `super.execute(input)` to run it. See [Adding steps before and after the loop](#adding-steps-before-and-after-the-loop). |
| `buildUserMessage(input)` | `protected`. Builds the [user message](#how-a-call-runs). Override it to write the message yourself. |
| `parseAgentResponse(content)` | `protected`. Turns the final `content` into the result. Override it to parse the answer your way. |
| `this.input` | The validated input. |
| `this.metadata` | The agent's options. |
| `this.get(token)`, `this.tryGet(token)` | [Providers](https://frontmcp.dev/reference/sdk/provider), from the app and the server, and the agent's own `providers` that it exports. The agent's *tools* see the app's, the server's and all of the agent's own: see [Sharing a provider with the agent's tools](#sharing-a-provider-with-the-agents-tools). |
| `this.callTool(name, args)` | Calls a tool as the same caller: one of the agent's own `tools`, the `invoke_<name>` of an agent in its `agents`, or any other tool of the server, another agent's `invoke_<name>` included. It returns the tool's result, and throws if that tool fails. The call is on the `"agent"` surface, like the model's: a tool whose `availableWhen.surface` leaves out `"agent"` answers `Tool "…" not found`. (Changed in 1.9.2: before, it only reached the server's tools.) |
| `this.invokeAgent(name, input)` | `protected`. Calls an agent this one can reach, in its `agents` or through `swarm`, and returns its result. It throws `AgentNotFoundError` (`AGENT_NOT_FOUND`) for any other name, `AgentVisibilityError` (`AGENT_VISIBILITY_DENIED`) for an agent with `swarm.isVisible: false`, and `InvalidInputError` when `input` isn't an object. (New in 1.9: before, it always threw.) |
| `this.fail(error)` | Ends the call with `error`. A `PublicMcpError`'s message and code reach the client as they are. |
| `this.auth` | The caller, as in [tools](https://frontmcp.dev/learn/authorizing-calls): `user.sub`, `isAnonymous`, `roles`, `scopes`. |
| `this.notify()`, `this.progress()` | Send the client a log message or a progress update, as in a tool. A log message's `logger` is the agent's `name`. See [Telling the client how the loop is going](#telling-the-client-how-the-loop-is-going). (Changed in 1.9.2: before, under MCP 2026-07-28, they returned `false` and sent nothing.) |
| `this.elicit()` | Asks the user, as in a tool. Under MCP 2026-07-28, `execute()` runs again from the top with the answer, so ask before `super.execute(input)`. See [Asking the user from an agent](#asking-the-user-from-an-agent). |
| `this.respond(value)` | Ends the call with `value` as the result, sent as a returned value would be, with `content` and `structuredContent`. Returning the value does the same. (Changed in 1.8.5: before, `value` replaced the whole result, with no `content`.) |
| `executeTool(name, args)` | `protected`. The loop calls it for each tool call the model asks for, so an override sees them all, and can change the arguments or the result. (Changed in 1.9: before, the loop ran the tools directly.) |
| `completion(prompt, tools, options)` | `protected`. The loop calls it for each request to the model, so an override sees them all, and can change the prompt or the reply. Pass all three on to `super.completion()`, which calls the adapter: without `tools`, the model isn't offered any. (Changed in 1.9.2: before, the loop called the adapter directly, and an override never ran.) |
| `completionOptions()` | `protected`. Returns the `options` the loop passes to every `completion()`. Override it to send your adapter other options. (New in 1.9.2.) |
| `streamCompletion(prompt, tools, options)` | `protected`. What a [streamed run](#streaming-the-reply) calls instead of `completion()`. It yields the adapter's chunks, or, for an adapter without `streamCompletion()`, calls `completion()` and yields one `done` chunk. Override it to stream from a model the adapter can't. (Changed in 1.9.4: before, nothing called it.) |
| `streamsReplies()` | `protected`. Whether this run streams: `true` when `execution.enableStreaming` is set, the call has a progress token, and the model can stream. Override it to decide per run. (New in 1.9.4.) |
| `streamAgentLoop(events)` | `protected`. Runs a streamed loop to its end, sending each piece of text as a progress update, and returns the loop's result. Override it to send the pieces some other way. (New in 1.9.4.) |

#### Built-in adapters

`OpenAIAdapter` and `AnthropicAdapter` translate the loop's prompts and tool calls to each provider's API. `llm: { provider }` creates one for you; create one yourself for the other options:

```ts
import { OpenAIAdapter } from "@frontmcp/sdk";

llm: { adapter: new OpenAIAdapter({ model: "gpt-5", apiKey: process.env.OPENAI_API_KEY!, maxRetries: 1 }) },
```

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `model` | `string` | required | The provider's model id. |
| `apiKey` | `string` | required, unless you pass `client` | A non-empty key. `new OpenAIAdapter({ model, apiKey: "" })` throws `apiKey is required and must be a non-empty string`. |
| `client` | the provider SDK's client | none | A client you created, used instead of `apiKey` and `baseUrl`. The adapter then doesn't load the provider's package. |
| `baseUrl` | `string` | the provider's | Another endpoint that speaks the same API. |
| `temperature` | `number` | the provider's | |
| `maxTokens` | `number` | the provider's; `4096` for `AnthropicAdapter` | |
| `timeout` | `number` | `60000` | How long one request may take, in milliseconds. |
| `maxRetries` | `number` | `3` | How many times to retry a failed request, after waits of 1, 2 and 4 seconds. |
| `api` | `"chat" \| "responses"` | `"chat"` | `OpenAIAdapter` only: the Chat Completions API or the Responses API. |

Without `client`, the adapter loads `openai` or `@anthropic-ai/sdk` on the first request, and fails with `The "openai" package is not installed.` if it can't.

#### Errors

What a client that calls `invoke_<name>` gets:

| When | Code | Text |
| --- | --- | --- |
| The caller fails the agent's `authorities` | `AUTHORITY_DENIED` | `Access denied to Tool "help-desk:invoke_refunds": ` and the reason |
| The agent's `availableWhen` doesn't match where the server runs | `ENTRY_UNAVAILABLE` | `Tool "invoke_read_logs" is not available in the current environment`, then what it requires and what the server has |
| A call over the agent's `rateLimit` or `concurrency` | `RATE_LIMIT_EXCEEDED`, `CONCURRENCY_LIMIT` | As for a tool: see [Guard options](https://frontmcp.dev/reference/sdk/guard#what-a-caller-gets-back) |
| The arguments don't match `inputSchema` | `INVALID_INPUT` | `Invalid tool input`, then each Zod issue |
| The model still asks for tools after `maxIterations` turns | `TOOL_EXECUTION_ERROR` | `Tool "invoke_triage" execution failed: Agent reached maximum iterations (10) without completing` |
| `execution.timeout` runs out | `TOOL_EXECUTION_ERROR` | `Tool "invoke_triage" execution failed: Agent execution timed out after 120000ms` |
| The agent's `timeout.executeMs` runs out | `EXECUTION_TIMEOUT` | `Execution of "invoke_triage" timed out after 30000ms` |
| The adapter throws: no network, a bad key, a rate limit | `TOOL_EXECUTION_ERROR` | `Tool "invoke_triage" execution failed: ` and the adapter's message |
| A chain of agents calling agents goes past `swarm.maxCallDepth` | `AGENT_CALL_DEPTH_EXCEEDED` | `Agent "escalate" can't be called from "escalate" -> "escalate" -> "escalate" -> "escalate": that would be agent call 4, deeper than maxCallDepth 3` (see [Troubleshooting](#agent--cant-be-called-from--that-would-be-agent-call-n-deeper-than-maxcalldepth-m)) |
| `execute()` lets an error from `this.invokeAgent()` through | `AGENT_NOT_FOUND`, `AGENT_VISIBILITY_DENIED` | `Agent not found: …`, `Agent "…" does not have visibility to agent "…"` |
| The result doesn't match `outputSchema` | `INVALID_OUTPUT` | `Tool output validation failed (output does not match outputSchema at priority)`, naming the first field that doesn't match (see [Returning structured output](#returning-structured-output)) |
| `execute()` calls `this.fail(new PublicMcpError(message, code))` | your `code` | your `message` |

In development, a `TOOL_EXECUTION_ERROR`'s text goes on with the original error and its stack. In production, the client gets FrontMCP's [generic message](https://frontmcp.dev/reference/sdk/tool#the-client-sees-internal-frontmcp-error-instead-of-my-message) instead, as for any tool.

Errors *inside* the loop go to the model, not the client: a tool that fails, a tool the agent doesn't have, arguments that don't match a tool's schema, or a resource or prompt the agent doesn't have each become that call's `{"error":"…"}` message, and the loop goes on. A `PublicMcpError`'s message reaches the model word for word, whether the tool throws it or passes it to `this.fail()` (see [Giving an Agent Tools](https://frontmcp.dev/learn/giving-an-agent-tools#when-a-tool-fails)). The client's log gets `Tool <name> failed: ` and the error's public message for each, unless `execution.enableNotifications` is `false`: a `PublicMcpError`'s message, or else FrontMCP's generic message with an error id, which the server's log line for the failure has too. (Changed in 1.9.3: before, the log message had the error's own text, like `connect ECONNREFUSED 10.0.4.7:5432`.)

`@frontmcp/sdk` also exports `AgentLoopExceededError`, `AgentTimeoutError`, `AgentToolNotFoundError`, `AgentLlmError` and `AgentExecutionError`. A client calling an agent never gets them in FrontMCP 1.9.4: check the codes and texts above instead. `AgentCallDepthExceededError`, `AgentVisibilityError` and `AgentNotFoundError` do reach it, with the codes above.

#### Caveats

- An agent's own **`execute()` doesn't see the agent's `providers`** unless they're in `exports.providers`, and neither do the app's tools. The agent's tools see them, and the app's and the server's too.
- **Nested `agents` aren't tools of the server.** A client can't call them, and neither can another tool's `this.callTool()`. The agent that lists them can, with `this.invokeAgent()` or `this.callTool("invoke_<name>")`.
- **The agent's model reads only the agent's own `resources` and `prompts`**, and those a nested agent exports to it, never the app's. Clients see them only when they're exported. (Before 1.9.4, the model got tools only, and they reached nobody unless they were exported.)
- An agent's `authorities`, `rateLimit`, `concurrency`, `timeout` and `availableWhen` **apply to `invoke_<name>`**, as a tool's do. (Before 1.8.3, they had no effect.) `authorities` on the agent's own tools are checked against the caller the agent runs for, and the model gets the refusal as that call's `{"error":"…"}`. See [Resources, prompts, skills and agents](https://frontmcp.dev/reference/auth/authorities#resources-prompts-skills-and-agents).
- **The app's plugins don't run for the agent's tools** unless `execution.inheritPlugins` is `true`. A plugin option on one of them, like `approval`, [stops the server from starting](#unenforced-metadata-tool--declares-approval) when no plugin that runs for that tool enforces it.
- An agent **sends the client a log message for each tool call** its model asks for, `Calling tool: <name>`, unless `execution.enableNotifications` is `false`.
- `execution.timeout` **fails the call but doesn't stop the loop**: the model and the tools keep running in the background, and their side effects happen anyway.
- Every call to an agent is at least one model call, and each round of tool calls is another. With a real model, that's time and money per call: set `maxIterations` to what the task needs.

---

## Usage

### Calling an agent

The Playground can't reach a real model, so each example here has a `model.example.ts` that plays one: an object with a `completion()` method that answers from a fixed rule, and keeps what it was sent so the tests can check it. A real server doesn't need it.

```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.",
  systemInstructions: "You triage tickets for a help desk. Answer high, normal or low, and say why in one sentence.",
  inputSchema: { query: z.string().describe("What the customer wrote") },
  llm: { adapter: model },
})
export class Triage extends AgentContext {}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { Triage } from "./triage.agent";

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

```ts model.example.ts
// Stands in for a real model, which the Playground can't reach. FrontMCP calls
// completion() each time the agent needs the model; this one answers from a
// fixed rule, and keeps what it was sent. A real server doesn't need this file.
import type { AgentLlmAdapter, AgentPrompt, AgentToolDefinition } from "@frontmcp/sdk";

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

export const model: AgentLlmAdapter = {
  async completion(prompt, tools) {
    calls.push({ prompt: structuredClone(prompt), tools });
    const blocked = /can't|cannot|log in|down/i.test(prompt.messages[0].content ?? "");
    return {
      content: blocked ? "High: the customer can't work." : "Normal: nothing is blocked.",
      finishReason: "stop",
    };
  },
};
```

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

test("clients see one tool, `invoke_triage`, with the agent's input", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  expect(tool).toMatchObject({
    name: "invoke_triage",
    description: "Suggest a priority for a new support ticket. Pass what the customer wrote.",
    inputSchema: { properties: { query: { type: "string" } }, required: ["query"] },
    annotations: { title: "triage", readOnlyHint: false, openWorldHint: true },
  });
});

test("the result is the model's answer, as `response`", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { query: "Nobody can log in since this morning." });
  expect(result).toBeSuccessful();
  expect(result.json()).toEqual({ response: "High: the customer can't work." });
});

test("the model is sent the instructions and the query", async ({ mcp }) => {
  calls.length = 0;
  await mcp.tools.call("invoke_triage", { query: "Please change our invoice address." });
  expect(calls).toHaveLength(1);
  expect(calls[0].prompt).toEqual({
    system: "You triage tickets for a help desk. Answer high, normal or low, and say why in one sentence.",
    messages: [{ role: "user", content: "Please change our invoice address." }],
  });
  expect(calls[0].tools).toBeUndefined();
});
```

Open the **Tests** tab. The model was asked once, with the agent's instructions as `system` and the query as the only message. Because the input has a `query` field, the model gets it as it is; other inputs are sent as JSON, as in the next example. [Your First Agent](https://frontmcp.dev/learn/your-first-agent) walks through all of this.

### Giving an agent tools

`tools` gives the agent's model tools to call. Here it reads the ticket, sets its priority, and says what it did, in three turns of the loop:

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

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

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

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

```ts tickets.ts
export type Ticket = { id: string; customer: string; title: string; priority: "unset" | "high" | "normal" };

export const tickets: Record<string, Ticket> = {
  "T-1": { id: "T-1", customer: "Acme", title: "Cannot log in since this morning", priority: "unset" },
  "T-2": { id: "T-2", customer: "Globex", title: "Please update our invoice address", priority: "unset" },
};
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { Triage } from "./triage.agent";

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

```ts model.example.ts
// Stands in for a real model, which the Playground can't reach. It reads the
// ticket, sets a priority from a fixed rule, then says what it did, and keeps
// what it was sent. A real server doesn't need this file.
import type { AgentLlmAdapter, AgentPrompt, AgentToolDefinition } from "@frontmcp/sdk";

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

export const model: AgentLlmAdapter = {
  async completion(prompt, tools) {
    calls.push({ prompt: structuredClone(prompt), tools });
    const last = prompt.messages[prompt.messages.length - 1];

    // First turn: read the ticket the input names.
    if (last.role === "user") {
      const { ticketId } = JSON.parse(last.content ?? "{}");
      return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "get_ticket", arguments: { id: ticketId } }] };
    }

    const result = JSON.parse(last.content ?? "{}");
    if (result.error) return { content: `I couldn't triage it: ${result.error}`, finishReason: "stop" };

    // Second turn: set a priority from the title.
    if (last.name === "get_ticket") {
      const priority = /can't|cannot|down/i.test(result.title) ? "high" : "normal";
      return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_2", name: "set_priority", arguments: { id: result.id, priority } }] };
    }

    // Last turn: say what it did.
    return { content: `${result.id} is now ${result.priority} priority.`, finishReason: "stop" };
  },
};
```

```ts triage.test.ts
import { test, expect } from "@frontmcp/testing";
import { Agent, AgentContext, App, FrontMcpInstance, Tool, ToolContext, z } from "@frontmcp/sdk";
import { calls, model } from "./model.example";
import { GetTicket, SetPriority } from "./ticket.tools";
import { tickets } from "./tickets";

test("clients see the agent, not its tools", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["invoke_triage"]);
  const direct = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(direct).toBeError();
  expect(direct.text()).toBe('Tool "get_ticket" not found');
});

test("the agent reads the ticket, sets its priority and answers", 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["T-1"].priority).toBe("high");
});

test("each tool result goes back to the model", async ({ mcp }) => {
  calls.length = 0;
  await mcp.tools.call("invoke_triage", { ticketId: "T-2" });
  expect(calls).toHaveLength(3);
  expect(calls[0].prompt.messages).toEqual([{ role: "user", content: '{"ticketId":"T-2"}' }]);
  expect(calls[0].tools?.map((t) => t.name)).toEqual(["get_ticket", "set_priority"]);
  expect(calls[1].prompt.messages.slice(1)).toEqual([
    { role: "assistant", content: null, toolCalls: [{ id: "call_1", name: "get_ticket", arguments: { id: "T-2" } }] },
    {
      role: "tool",
      toolCallId: "call_1",
      name: "get_ticket",
      content: '{"id":"T-2","customer":"Globex","title":"Please update our invoice address","priority":"unset"}',
    },
  ]);
});

test("a tool's error goes to the model, not the client", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-404" });
  expect(result).toBeSuccessful();
  expect(result.json()).toEqual({ response: "I couldn't triage it: There's no ticket T-404." });
});

test("a tool whose `surface` leaves out `agent` isn't offered to the model", async () => {
  @Tool({ name: "close_ticket", description: "Close a ticket.", inputSchema: { id: z.string() }, availableWhen: { surface: ["mcp"] } })
  class CloseTicket extends ToolContext {
    async execute({ id }: { id: string }) {
      return { id, closed: true };
    }
  }
  @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [GetTicket, SetPriority, CloseTicket], llm: { adapter: model } })
  class Triage extends AgentContext {}
  @App({ id: "help-desk", name: "Help Desk", agents: [Triage] })
  class HelpDeskApp {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });
  calls.length = 0;
  await server.callTool("invoke_triage", { ticketId: "T-2" });
  await server.dispose();
  expect(calls[0].tools?.map((t) => t.name)).toEqual(["get_ticket", "set_priority"]);
});
```

The client's model sees only `invoke_triage`. The agent's model got the two tools, and after each call, the call's result as a `tool` message. When `get_ticket` failed, the model got `{"error":"There's no ticket T-404."}` and answered with it: the call itself succeeded. [Giving an Agent Tools](https://frontmcp.dev/learn/giving-an-agent-tools) goes further.

The model calls the agent's tools on the `"agent"` surface, and [`getCallSurface()`](https://frontmcp.dev/reference/sdk/context#getcallsurface-and-getrunningtool) is `"agent"` inside them. So an [`availableWhen`](https://frontmcp.dev/reference/server/environment) whose `surface` leaves out `"agent"` keeps a tool from the model, as the last test shows. Changed in 1.8.4: before, nothing marked an agent's calls, and `surface: ["mcp"]` didn't keep a tool from agents.

### Reading the agent's resources and prompts

An agent's `resources` and `prompts` are for its model. An agent that declares a resource or a resource template offers its model two more tools, `list_resources` and `read_resource`, and one that declares a prompt offers `list_prompts` and `get_prompt`. Here triage reads its priority policy and the ticket, then gets the reply it should start from:

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

@Agent({
  name: "triage",
  description: "Suggest a priority for a support ticket. Pass the ticket's id.",
  systemInstructions: "Read the ticket and the priority policy, then get the reply prompt for that priority.",
  inputSchema: { ticketId: z.string() },
  resources: [PriorityPolicy, Ticket],
  prompts: [Reply],
  llm: { adapter: model },
})
export class Triage extends AgentContext {}
```

```ts knowledge.ts
import { Prompt, PromptContext, Resource, ResourceContext, ResourceNotFoundError, ResourceTemplate, type GetPromptResult } from "@frontmcp/sdk";

@Resource({ name: "priority-policy", uri: "policy://priorities", mimeType: "text/markdown", description: "When a ticket is high priority." })
export class PriorityPolicy extends ResourceContext {
  async execute(uri: string) {
    return { contents: [{ uri, mimeType: "text/markdown", text: "High: the customer can't work. Normal: anything else." }] };
  }
}

@ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json", description: "A support ticket." })
export class Ticket extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }) {
    if (id !== "T-1") throw new ResourceNotFoundError(uri);
    return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify({ id, title: "Cannot log in since this morning" }) }] };
  }
}

@Prompt({ name: "reply", description: "The first reply to a customer.", arguments: [{ name: "priority", required: true }] })
export class Reply extends PromptContext {
  async execute({ priority }: Record<string, string>): Promise<GetPromptResult> {
    return {
      description: "The first reply to a customer",
      messages: [
        { role: "user", content: { type: "text", text: `Write the first reply to a ${priority}-priority ticket.` } },
        { role: "assistant", content: { type: "text", text: "Thanks for telling us. We're on it." } },
      ],
    };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { Triage } from "./triage.agent";

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

```ts model.example.ts
// Stands in for a real model, which the Playground can't reach. It lists the
// resources, reads two and gets the reply prompt, then answers, and keeps what
// it was sent. A real server doesn't need this file.
import type { AgentLlmAdapter, AgentMessage } from "@frontmcp/sdk";

export const turns: { tools?: string[]; messages: AgentMessage[] }[] = [];

export const model: AgentLlmAdapter = {
  async completion(prompt, tools) {
    turns.push({ tools: tools?.map((t) => t.name), messages: structuredClone(prompt.messages) });
    if (prompt.messages.length > 1) return { content: "High: the customer can't work.", finishReason: "stop" };
    const { ticketId } = JSON.parse(prompt.messages[0].content ?? "{}");
    return {
      content: null,
      finishReason: "tool_calls",
      toolCalls: [
        { id: "call_1", name: "list_resources", arguments: {} },
        { id: "call_2", name: "read_resource", arguments: { uri: "policy://priorities" } },
        { id: "call_3", name: "read_resource", arguments: { uri: `tickets://${ticketId}` } },
        { id: "call_4", name: "get_prompt", arguments: { name: "reply", arguments: { priority: "high" } } },
      ],
    };
  },
};
```

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

const toolResults = () => turns[1].messages.filter((m) => m.role === "tool").map((m) => m.content);

test("the model is offered a tool for each kind of read", async ({ mcp }) => {
  turns.length = 0;
  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(turns[0].tools).toEqual(["list_resources", "read_resource", "list_prompts", "get_prompt"]);
});

test("a listing comes back as JSON, a resource as its text", async ({ mcp }) => {
  turns.length = 0;
  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  const [listing, policy, ticket] = toolResults();
  expect(JSON.parse(listing ?? "")).toEqual({
    resources: [{ uri: "policy://priorities", name: "priority-policy", description: "When a ticket is high priority.", mimeType: "text/markdown" }],
    resourceTemplates: [{ uriTemplate: "tickets://{id}", name: "ticket", description: "A support ticket.", mimeType: "application/json" }],
  });
  expect(policy).toBe("High: the customer can't work. Normal: anything else.");
  expect(ticket).toBe('{"id":"T-1","title":"Cannot log in since this morning"}');
});

test("a prompt comes back as its description, then each message under its role", async ({ mcp }) => {
  turns.length = 0;
  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(toolResults()[3]).toBe(
    "The first reply to a customer\n\n[user]\nWrite the first reply to a high-priority ticket.\n\n[assistant]\nThanks for telling us. We're on it.",
  );
});

test("a ticket the template doesn't have is that call's error, for the model", async ({ mcp }) => {
  turns.length = 0;
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-404" });
  expect(result).toBeSuccessful();
  expect(toolResults()[2]).toBe('{"error":"Resource not found: tickets://T-404"}');
});

test("clients see none of them, since the agent doesn't export them", async ({ mcp }) => {
  expect(await mcp.resources.list()).toEqual([]);
  expect(await mcp.resources.listTemplates()).toEqual([]);
  expect(await mcp.prompts.list()).toEqual([]);
});
```

The model reads every resource and prompt the agent declares, exported or not: `exports` only decides what clients see. It gets text, never a protocol result:

| Tool | Arguments | What the model gets |
| --- | --- | --- |
| `list_resources` | none | `{ resources: [{ uri, name, title?, description?, mimeType? }], resourceTemplates: [{ uriTemplate, name, title?, description?, mimeType? }] }`, as JSON |
| `read_resource` | `{ uri }`: a listed URI, or a template's with its parts filled in | The resource's text. A resource with several contents has each under its URI and MIME type, and binary content is replaced by a line like `[binary content omitted: brand://logo (image/png, 6 bytes)]`. |
| `list_prompts` | none | `{ prompts: [{ name, title?, description?, arguments? }] }`, as JSON |
| `get_prompt` | `{ name, arguments? }` | The prompt's description, then each message under `[user]` or `[assistant]` |

Each read runs through the agent's own `resources:read-resource`, `prompts:get-prompt` and list [flows](https://frontmcp.dev/reference/server/flows), as a client's would, so the agent's plugins' `ResourceHook` and `PromptHook` hooks run for it (with [`inheritPlugins`](#giving-the-agents-tools-a-plugin), the app's and the server's too), and a read that fails, like the unknown ticket in the fourth test, becomes the call's `{"error":"…"}` for the model. The app's resources and prompts aren't offered: an agent that declares none gets no extra tools. Resources and prompts a [nested agent](#calling-other-agents) exports are offered to the agent that lists it. None of the agent's own tools may take one of these four names, or [the server doesn't start](#agent--has-a-tool-named--the-name-of-a-built-in-tool-its-model-reads-the-agents-resources-and-prompts-with).

New in 1.9.4: before, the model was only sent tools, so an agent's resources and prompts reached nothing unless they were exported, and the server logged a warning naming them.

### Returning structured output

With `outputSchema`, `invoke_<name>` promises a shape, and the client gets `structuredContent` it can rely on. The agent's model has to answer with JSON in that shape, so say so in `systemInstructions`. An answer that doesn't parse, or doesn't match, fails the call:

```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.",
  systemInstructions:
    'You triage tickets for a help desk. Answer only with JSON: {"priority": "high" | "normal", "reason": "<one sentence>"}.',
  inputSchema: { query: z.string().describe("What the customer wrote") },
  outputSchema: { priority: z.enum(["high", "normal"]), 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. It answers
// with JSON, except about refunds, where it forgets the format. A real server
// doesn't need this file.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    const text = prompt.messages[0].content ?? "";
    if (/refund/i.test(text)) return { content: "Refunds go to billing, so I'd say normal.", finishReason: "stop" };
    const answer = /can't|cannot|down/i.test(text)
      ? { priority: "high", reason: "The customer can't work." }
      : { priority: "normal", reason: "Nothing is blocked." };
    return { content: JSON.stringify(answer), finishReason: "stop" };
  },
};
```

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

test("`invoke_triage` advertises the output schema", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  expect(tool.outputSchema).toMatchObject({ properties: { priority: { enum: ["high", "normal"] } }, required: ["priority", "reason"] });
});

test("a JSON answer is the result", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { query: "Our dashboard is down." });
  expect(result.raw.structuredContent).toEqual({ priority: "high", reason: "The customer can't work." });
});

test("an answer that isn't JSON fails the call", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { query: "I want a refund." });
  expect(result).toBeError("INVALID_OUTPUT");
  expect(result.text()).toBe("Tool output validation failed (output does not match outputSchema at priority)");
});
```

The plain-text answer became `{ "response": "Refunds go to billing, so I'd say normal." }`, which has no `priority`, and the error names that first field. Real models mostly follow a format they're asked for, but not always, so a client should be ready for the call to fail. To accept other answers, override [`parseAgentResponse()`](#agentcontext).

Changed in 1.8.7: a mismatch is `INVALID_OUTPUT` with that one-line text, as it is for a tool. In 1.8.5 and 1.8.6 it was `TOOL_EXECUTION_ERROR`, with the original `InvalidOutputError` and its stack trace in the development text; 1.8.4 sent `INVALID_OUTPUT`.

### Sharing a provider with the agent's tools

An agent's tools see the providers of the agent's app and of the server, as the app's own tools do. Put a provider they share with the app's tools in `@App({ providers })`: both get the same instance, so what the agent changes, the client reads:

```ts main.ts active
import { App, FrontMcp } from "@frontmcp/sdk";
import { ListTickets } from "./ticket.tools";
import { TicketStore } from "./ticket-store";
import { Triage } from "./triage.agent";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [ListTickets],
  agents: [Triage],
  providers: [TicketStore], // 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.agent.ts
import { Agent, AgentContext, z } from "@frontmcp/sdk";
import { model } from "./model.example";
import { GetTicket, SetPriority } from "./ticket.tools";

@Agent({
  name: "triage",
  description: "Read a new support ticket and set its priority. Pass the ticket's id.",
  systemInstructions: "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 ticket.tools.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";

@Tool({ name: "list_tickets", description: "List the support tickets and their priorities.", inputSchema: {} })
export class ListTickets extends ToolContext {
  async execute() {
    return { tickets: this.get(TicketStore).all() };
  }
}

@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 }) {
    return this.get(TicketStore).get(id);
  }
}

@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: "high" | "normal" }) {
    return this.get(TicketStore).setPriority(id, priority);
  }
}
```

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

type Ticket = { id: string; title: string; priority: "unset" | "high" | "normal" };

@Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL })
export class TicketStore {
  private tickets: Ticket[] = [
    { id: "T-1", title: "Cannot log in since this morning", priority: "unset" },
    { id: "T-2", title: "Please update our invoice address", priority: "unset" },
  ];

  all() {
    return this.tickets;
  }

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

  setPriority(id: string, priority: "high" | "normal") {
    const ticket = this.tickets.find((t) => t.id === id)!;
    ticket.priority = priority;
    return ticket;
  }
}
```

```ts model.example.ts
// Stands in for a real model, which the Playground can't reach. It reads the
// ticket, sets a priority from a fixed rule, then says what it did. A real
// server doesn't need this file.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    const last = prompt.messages[prompt.messages.length - 1];
    if (last.role === "user") {
      const { ticketId } = JSON.parse(last.content ?? "{}");
      return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "get_ticket", arguments: { id: ticketId } }] };
    }
    const result = JSON.parse(last.content ?? "{}");
    if (last.name === "get_ticket") {
      const priority = /can't|cannot|down/i.test(result.title) ? "high" : "normal";
      return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_2", name: "set_priority", arguments: { id: result.id, priority } }] };
    }
    return { content: `${result.id} is now ${result.priority} priority.`, finishReason: "stop" };
  },
};
```

```ts shared.test.ts
import { test, expect } from "@frontmcp/testing";
import { Agent, AgentContext, App, FrontMcpInstance, z } from "@frontmcp/sdk";
import { model } from "./model.example";
import { GetTicket, ListTickets, SetPriority } from "./ticket.tools";
import { TicketStore } from "./ticket-store";

test("the client reads what the agent's tools changed", async ({ mcp }) => {
  const priorities = async () =>
    (await mcp.tools.call("list_tickets", {})).json().tickets.map((t: { priority: string }) => t.priority);
  expect(await priorities()).toEqual(["unset", "unset"]);

  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(await priorities()).toEqual(["high", "unset"]);
});

test("a provider on the server is shared too", async () => {
  @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [GetTicket, SetPriority], llm: { adapter: model } })
  class Triage extends AgentContext {}
  @App({ id: "help-desk", name: "Help Desk", tools: [ListTickets], agents: [Triage] })
  class HelpDeskApp {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "Help Desk", version: "1.0.0" }, apps: [HelpDeskApp], providers: [TicketStore] });
  const priorityOfT2 = async () =>
    ((await server.callTool("list_tickets", {})).structuredContent as { tickets: { priority: string }[] }).tickets[1].priority;
  try {
    expect(await priorityOfT2()).toBe("unset");
    await server.callTool("invoke_triage", { ticketId: "T-2" });
    expect(await priorityOfT2()).toBe("normal");
  } finally {
    await server.dispose();
  }
});

test("a provider on the agent, and in its `exports`, is shared with the app too", async () => {
  @Agent({
    name: "triage",
    inputSchema: { ticketId: z.string() },
    tools: [GetTicket, SetPriority],
    providers: [TicketStore],
    exports: { providers: [TicketStore] },
    llm: { adapter: model },
  })
  class Triage extends AgentContext {}
  @App({ id: "help-desk", name: "Help Desk", tools: [ListTickets], agents: [Triage] })
  class HelpDeskApp {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "Help Desk", version: "1.0.0" }, apps: [HelpDeskApp] });
  try {
    await server.callTool("invoke_triage", { ticketId: "T-1" });
    const { tickets } = (await server.callTool("list_tickets", {})).structuredContent as { tickets: { priority: string }[] };
    expect(tickets.map((t) => t.priority)).toEqual(["high", "unset"]);
  } finally {
    await server.dispose();
  }
});
```

A provider in `@FrontMcp({ providers })` works too, and is shared by every app. A provider only the agent's tools use can go in `@Agent({ providers })`. Add it to `exports: { providers }` as well, as the last test does, and the app's tools and the agent's own `execute()` get the same instance. (Changed in 1.9: before, a provider in `@Agent({ providers })` alone stopped the server from starting, and `exports` was ignored. Changed in 1.9.2: before, an agent's tools didn't see the app's providers.) The same tool class can also be in both lists, the app's `tools` and the agent's, when the client should be able to call it too.

### Adding steps before and after the loop

Override `execute()` to check the input, prepare what the model gets, or add to its answer, and call `super.execute(input)` for the loop. `execute()` and `buildUserMessage()` see the app's providers. Here the agent refuses unknown tickets before asking the model, sends the model the ticket rather than its id, and adds the id to the answer:

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

@Agent({
  name: "triage",
  description: "Suggest a priority for a support ticket. Pass the ticket's id.",
  systemInstructions: "You triage tickets for a help desk. Answer high or normal, and say why in one sentence.",
  inputSchema: { ticketId: z.string() },
  llm: { adapter: model },
})
export class Triage extends AgentContext {
  async execute(input: { ticketId: string }) {
    if (!this.get(TicketStore).get(input.ticketId)) {
      this.fail(new PublicMcpError(`There's no ticket ${input.ticketId}.`, "TICKET_NOT_FOUND"));
    }
    const answer = (await super.execute(input)) as { response: string };
    return { ticketId: input.ticketId, ...answer };
  }

  protected buildUserMessage(input: { ticketId: string }) {
    const ticket = this.get(TicketStore).get(input.ticketId)!;
    return `${ticket.customer} wrote: ${ticket.title}`;
  }
}
```

```ts ticket-store.ts
import { Provider } from "@frontmcp/sdk";

@Provider({ name: "TicketStore" })
export class TicketStore {
  private tickets = [
    { id: "T-1", customer: "Acme", title: "Cannot log in since this morning" },
    { id: "T-2", customer: "Globex", title: "Please update our invoice address" },
  ];

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

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";
import { Triage } from "./triage.agent";

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

```ts model.example.ts
// Stands in for a real model, which the Playground can't reach. It answers from
// a fixed rule and keeps what it was sent. A real server doesn't need this file.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const sent: (string | null)[] = [];

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    const text = prompt.messages[0].content;
    sent.push(text);
    return { content: /can't|cannot|down/i.test(text ?? "") ? "High: the customer can't work." : "Normal: nothing is blocked.", finishReason: "stop" };
  },
};
```

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

test("the model gets the ticket, and the answer gets the id", async ({ mcp }) => {
  sent.length = 0;
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(sent).toEqual(["Acme wrote: Cannot log in since this morning"]);
  expect(result.json()).toEqual({ ticketId: "T-1", response: "High: the customer can't work." });
});

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

Failing before the loop saves a model call. `execute()` can also call other tools of the server with `this.callTool()`, another agent's `invoke_<name>` included: [Agents That Call Agents](https://frontmcp.dev/learn/agents-that-call-agents#handing-a-task-off-from-code) hands a task from one agent to another that way.

### Telling the client how the loop is going

An agent's call lasts as long as its whole loop. While it runs, the client hears from the agent and from its tools: the agent's own `this.notify()` and `this.progress()`, a `Calling tool: <name>` log message for each tool call the model asks for, and whatever the tools send. `execution.enableAutoProgress` adds progress updates for each step of the loop:

```ts agents.ts active
import { Agent, AgentContext, z } from "@frontmcp/sdk";
import { model, slowModel } from "./model.example";
import { GetTicket } from "./ticket.tools";

@Agent({
  name: "triage",
  inputSchema: { ticketId: z.string() },
  tools: [GetTicket],
  execution: { enableAutoProgress: true },
  llm: { adapter: model },
})
export class Triage extends AgentContext {
  async execute(input: { ticketId: string }) {
    await this.notify(`Triaging ${input.ticketId}`);
    return super.execute(input);
  }
}

@Agent({
  name: "quiet_triage",
  inputSchema: { ticketId: z.string() },
  tools: [GetTicket],
  execution: { enableNotifications: false },
  llm: { adapter: model },
})
export class QuietTriage extends AgentContext {}

@Agent({
  name: "slow_triage",
  inputSchema: { ticketId: z.string() },
  tools: [GetTicket],
  execution: { enableAutoProgress: true, notificationInterval: 10 },
  llm: { adapter: slowModel },
})
export class SlowTriage extends AgentContext {}
```

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

@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 }) {
    await this.notify(`Reading ${id}`);
    await new Promise((resolve) => setTimeout(resolve, 20)); // a database read
    return { id, title: "Cannot log in since this morning" };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { QuietTriage, SlowTriage, Triage } from "./agents";

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

```ts model.example.ts
// Stand-ins for a real model: they read the ticket, then answer. `slowModel`
// reads two tickets, and takes 20 ms per request, as a real model takes seconds.
// A real server doesn't need this file.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    if (prompt.messages.length === 1) {
      return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "get_ticket", arguments: { id: "T-1" } }] };
    }
    return { content: "High", finishReason: "stop" };
  },
};

export const slowModel: AgentLlmAdapter = {
  async completion(prompt) {
    await new Promise((resolve) => setTimeout(resolve, 20));
    if (prompt.messages.length === 1) {
      const read = (id: string) => ({ id: `call_${id}`, name: "get_ticket", arguments: { id } });
      return { content: null, finishReason: "tool_calls", toolCalls: [read("T-1"), read("T-2")] };
    }
    return { content: "High", finishReason: "stop" };
  },
};
```

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

const logOf = (received: { method: string; params?: any }[]) =>
  received.filter((n) => n.method === "notifications/message").map((n) => `${n.params.logger}: ${n.params.data.message}`);
const progressOf = (received: { method: string; params?: any }[]) =>
  received.filter((n) => n.method === "notifications/progress").map((n) => `${n.params.progress} ${n.params.message}`);

test("the client hears from the agent, its loop and its tool", async ({ mcp }) => {
  const notifications = mcp.notifications.collect();
  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(logOf(notifications.received)).toEqual([
    "triage: Triaging T-1",
    "triage: Identified 1 tool call(s): get_ticket",
    "triage: Calling tool: get_ticket",
    "get_ticket: Reading T-1",
  ]);
});

test("a quick loop reports only its start and its end", async ({ mcp }) => {
  const notifications = mcp.notifications.collect();
  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(progressOf(notifications.received)).toEqual(["0 Starting LLM call (iteration 1/10)", "100 Agent completed"]);
});

test("`enableNotifications: false` leaves the tool's own message", async ({ mcp }) => {
  const notifications = mcp.notifications.collect();
  await mcp.tools.call("invoke_quiet_triage", { ticketId: "T-1" });
  expect(logOf(notifications.received)).toEqual(["get_ticket: Reading T-1"]);
});

test("a slower loop reports more, each value higher than the last", async ({ mcp }) => {
  const notifications = mcp.notifications.collect();
  await mcp.tools.call("invoke_slow_triage", { ticketId: "T-1" });
  expect(progressOf(notifications.received)).toEqual([
    "0 Starting LLM call (iteration 1/10)",
    "4 LLM response received",
    "7 Executing tool 2/2: get_ticket",
    "8 Starting LLM call (iteration 2/10)",
    "12 LLM response received",
    "100 Agent completed",
  ]);
});
```

Each log message's `logger` is the name of whoever sent it: the agent, or the tool. With `enableAutoProgress`, progress goes from 0 to 100 over the loop, and an update that comes less than `notificationInterval` (1000 ms by default) after the last one is dropped, except the last: a quick loop reports only its start and its end, and `Executing tool 1/2`, which comes right after `LLM response received`, is dropped here too. Progress updates go only to a client that sent a progress token with its call, as for a tool. Each turn of the loop has an equal share of the range from 0 to 80: its model call gets the first half of it, its tool calls the second. So with `maxIterations: 10`, the second turn starts at 8, and every update is higher than the one before, as MCP requires. (Changed in 1.9.3: before, a turn's model call only counted the turns before it, so its value could be lower than the last tool call's: 14, then 8.)

`enableNotifications: false` stops the `Calling tool:` and `Tool … failed:` messages, and `enableAutoProgress`'s updates with them. A `Tool … failed:` message carries the error's public message, as a client would get it in production: a `PublicMcpError`'s own, otherwise `Internal FrontMCP error. Please contact support with error ID: …`, and the server log has the error under that id (see [Troubleshooting](#an-agents-tool-fails-with-provider--is-not-available)). A called agent and its tools report to the client of the outer call, as [called tools do](https://frontmcp.dev/reference/sdk/call-tool#progress-from-the-called-tool): the [Research Assistant](https://frontmcp.dev/examples/research-assistant) shows it.

Changed in 1.9.2: before, under MCP 2026-07-28, an agent's own messages and progress never reached the client, only its tools'.

### Streaming the reply

A model writes its reply a piece at a time. With `execution.enableStreaming`, the agent sends each piece to the client as it arrives, as a progress update on the call's progress token, so a client can show the reply before the loop ends. The model has to stream too: the adapter needs a `streamCompletion()`, as `OpenAIAdapter` and `AnthropicAdapter` have:

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

@Agent({
  name: "triage",
  inputSchema: { ticketId: z.string() },
  tools: [GetTicket],
  execution: { enableStreaming: true, enableAutoProgress: true },
  llm: { adapter: model },
})
export class Triage extends AgentContext {}
```

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

@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 }) {
    await this.notify(`Reading ${id}`);
    return { id, title: "Cannot log in since this morning" };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { Triage } from "./triage.agent";

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

```ts model.example.ts
// Stands in for a real model, which the Playground can't reach. It says what
// it's doing, reads the ticket, then answers, in the pieces a real model streams
// its text in, and keeps which method FrontMCP called. A real server doesn't need this file.
import type { AgentCompletion, AgentLlmAdapter, AgentPrompt } from "@frontmcp/sdk";

const getTicket = { id: "call_1", name: "get_ticket", arguments: { id: "T-1" } };

function reply(prompt: AgentPrompt): { pieces: string[]; completion: AgentCompletion } {
  if (prompt.messages.length === 1) {
    const pieces = ["Reading ", "the ticket. "];
    return { pieces, completion: { content: pieces.join(""), finishReason: "tool_calls", toolCalls: [getTicket] } };
  }
  const pieces = ["High: ", "the customer can't log in."];
  return { pieces, completion: { content: pieces.join(""), finishReason: "stop" } };
}

export const called: string[] = [];

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    called.push("completion");
    return reply(prompt).completion;
  },
  async *streamCompletion(prompt) {
    called.push("streamCompletion");
    const { pieces, completion } = reply(prompt);
    for (const content of pieces) yield { type: "content", content };
    yield { type: "done", completion };
  },
};
```

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

type Received = { method: string; params?: any }[];
const streamOf = (received: Received) =>
  received.map((n) => (n.method === "notifications/progress" ? `${n.params.progress} ${n.params.message}` : `${n.params.logger}: ${n.params.data.message}`));

test("each piece of the model's text is a progress update, as it arrives", async ({ mcp }) => {
  const notifications = mcp.notifications.collect();
  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(streamOf(notifications.received)).toEqual([
    "1 Reading ",
    "2 the ticket. ",
    "triage: Identified 1 tool call(s): get_ticket",
    "triage: Calling tool: get_ticket",
    "get_ticket: Reading T-1",
    "3 High: ",
    "4 the customer can't log in.",
  ]);
  const progress = notifications.received.filter((n) => n.method === "notifications/progress");
  expect(progress.every((n) => n.params?.total === undefined)).toBe(true);
});

test("the result is the final reply, as without streaming", async ({ mcp }) => {
  called.length = 0;
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(result.json()).toEqual({ response: "High: the customer can't log in." });
  expect(called).toEqual(["streamCompletion", "streamCompletion"]);
});

test("a call without a progress token isn't streamed", async ({ mcp }) => {
  called.length = 0;
  const response = await mcp.raw.request({ method: "tools/call", params: { name: "invoke_triage", arguments: { ticketId: "T-1" } } });
  expect(response.result.structuredContent).toEqual({ response: "High: the customer can't log in." });
  expect(called).toEqual(["completion", "completion"]);
});
```

Each piece is one `notifications/progress`: `progress` counts the pieces across the whole run, 1, 2, 3, so it only goes up, `message` is the piece, and there's no `total`, since nobody knows how long the reply will be. Join the messages to rebuild the text. The text the model writes before it calls a tool is streamed too, and the count goes on after the tool runs. The tools get their arguments from the stream's `done` chunk, as [The model adapter](#the-model-adapter) describes. The result is what it would be without streaming. An agent with an `outputSchema` streams its text too, and the result is checked once the loop ends, so a reply that doesn't match fails with `INVALID_OUTPUT` after the client has had all of it.

A streamed run sends none of `enableAutoProgress`'s progress updates, which would be mixed into the text on the same token; its `Identified 1 tool call(s)` log message still goes out, as the first test shows. The run isn't streamed, and works as without the option, when:

- the call has no progress token, as the last test shows: in the Call tab, untick **Stream progress & logs** and call again;
- the adapter has no `streamCompletion()`, unless the agent class overrides `streamCompletion()`;
- the agent class overrides `completion()` but not `streamCompletion()`, so the override still sees every request to the model.

An agent that another agent's model calls isn't streamed either: its reply is the calling model's tool result. To decide per run, override `streamsReplies()`. New in 1.9.4: before, `enableStreaming` had no effect, and `true` logged a warning at startup.

### Calling other agents

An agent's `agents` are agents it may call. Its model is offered each one as a tool, `invoke_<name>`, with that agent's `description` and input, and calling it runs the other agent's own loop, with its own model and tools. Its answer comes back to the first model as the call's result. Clients don't see the nested agent:

```ts agents.ts active
import { Agent, AgentContext, z } from "@frontmcp/sdk";
import { billingModel, triageModel } from "./model.example";
import { GetTicket } from "./ticket.tools";

@Agent({
  name: "billing",
  description: "Answer a customer's question about an invoice or a charge.",
  inputSchema: { question: z.string().describe("The customer's question, with the invoice number") },
  llm: { adapter: billingModel },
})
export class Billing extends AgentContext {}

@Agent({
  name: "triage",
  description: "Read a support ticket and answer it. Pass the ticket's id.",
  systemInstructions: "Read the ticket with get_ticket. Ask the billing agent about invoices and charges, and pass on its answer.",
  inputSchema: { ticketId: z.string() },
  tools: [GetTicket],
  agents: [Billing], // triage's model may call billing
  llm: { adapter: triageModel },
})
export class Triage extends AgentContext {}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { Triage } from "./agents";

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

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

const tickets: Record<string, string> = {
  "T-1": "I can't log in since this morning.",
  "T-2": "I was charged twice for invoice INV-7.",
};

@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 }) {
    return { id, text: tickets[id] ?? "" };
  }
}
```

```ts model.example.ts
// Stand-ins for the two agents' models, which the Playground can't reach. Triage's
// reads the ticket, asks billing about charges, and passes billing's answer on.
// `offered` keeps the tools each model was offered. A real server doesn't need this file.
import type { AgentLlmAdapter, AgentToolDefinition } from "@frontmcp/sdk";

export const offered: Record<string, AgentToolDefinition[][]> = { triage: [], billing: [] };

export const triageModel: AgentLlmAdapter = {
  async completion(prompt, tools = []) {
    offered.triage.push(tools);
    const last = prompt.messages.at(-1)!;
    if (last.role === "user") {
      const { ticketId } = JSON.parse(last.content!);
      return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "c1", name: "get_ticket", arguments: { id: ticketId } }] };
    }
    if (last.name === "get_ticket") {
      const ticket = JSON.parse(last.content!);
      if (!/invoice|charge/i.test(ticket.text)) return { content: "The accounts team should take it.", finishReason: "stop" };
      return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "c2", name: "invoke_billing", arguments: { question: ticket.text } }] };
    }
    return { content: `Billing says: ${JSON.parse(last.content!).response}`, finishReason: "stop" };
  },
};

export const billingModel: AgentLlmAdapter = {
  async completion(prompt, tools) {
    offered.billing.push(tools ?? []);
    return { content: "INV-7 was charged twice: refund the second charge.", finishReason: "stop" };
  },
};
```

```ts agents.test.ts
import { test, expect } from "@frontmcp/testing";
import { Agent, AgentContext, App, FrontMcpInstance, z } from "@frontmcp/sdk";
import { Billing } from "./agents";
import { offered, triageModel } from "./model.example";
import { GetTicket } from "./ticket.tools";

test("triage's model is offered billing as a tool, and gets its answer", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-2" });
  expect(result.json()).toEqual({ response: "Billing says: INV-7 was charged twice: refund the second charge." });
  expect(offered.triage[0].map((t) => t.name)).toEqual(["get_ticket", "invoke_billing"]);
  expect(offered.triage[0][1].description).toBe("Answer a customer's question about an invoice or a charge.");
});

test("clients see triage, not billing", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["invoke_triage"]);
  expect(await mcp.tools.call("invoke_billing", { question: "INV-7?" })).toBeError("TOOL_NOT_FOUND");
});

test("`invokeAgent()` and `this.callTool()` call it from code", async () => {
  @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, agents: [Billing], llm: { adapter: triageModel } })
  class Triage extends AgentContext {
    async execute(input: { ticketId: string }) {
      const question = `${input.ticketId}: I was charged twice for invoice INV-7.`;
      const invoked = await this.invokeAgent("billing", { question });
      const called = await this.callTool("invoke_billing", { question });
      return { invoked, called: called.structuredContent };
    }
  }
  @App({ id: "help-desk", name: "Help Desk", 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-2" });
    const answer = { response: "INV-7 was charged twice: refund the second charge." };
    expect(result.structuredContent).toEqual({ invoked: answer, called: answer });
  } finally {
    await server.dispose();
  }
});

test("with `swarm`, the model sees the app's other agents instead", async () => {
  @Agent({ name: "accounts", inputSchema: { question: z.string() }, llm: { adapter: triageModel } })
  class Accounts extends AgentContext {}
  @Agent({
    name: "triage",
    inputSchema: { ticketId: z.string() },
    tools: [GetTicket],
    swarm: { canSeeOtherAgents: true, visibleAgents: ["billing"] },
    llm: { adapter: triageModel },
  })
  class Triage extends AgentContext {}
  @App({ id: "help-desk", name: "Help Desk", agents: [Triage, Billing, Accounts] })
  class HelpDeskApp {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });
  try {
    offered.triage.length = 0;
    await server.callTool("invoke_triage", { ticketId: "T-1" });
    expect(offered.triage[0].map((t) => t.name)).toEqual(["get_ticket", "invoke_billing"]);
    expect((await server.listTools()).tools.map((t) => t.name).sort()).toEqual(["invoke_accounts", "invoke_billing", "invoke_triage"]);
  } finally {
    await server.dispose();
  }
});
```

A nested agent belongs to the agent that lists it: the model's calls to it, and the agent's own `this.invokeAgent()` and `this.callTool("invoke_billing")`, reach it, while a client's call answers `Tool "invoke_billing" not found`, and so does another tool's `this.callTool()`. (Changed in 1.9.2: before, the agent's own `this.callTool()` didn't reach it either.) Agents registered side by side on the app are tools of the server: `swarm: { canSeeOtherAgents: true }` offers them to the model, `visibleAgents` picks which, and clients can call them too, as the last test shows. `this.callTool("invoke_<name>")` reaches them from code, as [Agents That Call Agents](https://frontmcp.dev/learn/agents-that-call-agents) does.

Each call from one agent to another counts toward `swarm.maxCallDepth`, 3 by default, so a chain that keeps going stops with [`AGENT_CALL_DEPTH_EXCEEDED`](#agent--cant-be-called-from--that-would-be-agent-call-n-deeper-than-maxcalldepth-m). New in 1.9: before, `agents` and `swarm` were ignored, `invokeAgent()` always threw, and nothing limited the depth.

### Writing an agent as a function

`agent(options)(handler)` declares an agent without a class. The handler runs instead of the model loop: it gets the input and the agent's context, and what it returns is the result. `llm` is still required, but the model isn't asked, so the function form suits an agent whose work is code. For the loop, write a class.

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

export const Summarize = agent({
  name: "summarize",
  description: "Shorten what a customer wrote to its first five words.",
  inputSchema: { text: z.string() },
  llm: { adapter: model },
})((input) => ({ summary: input.text.split(" ").slice(0, 5).join(" ") }));
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { Summarize } from "./summarize.agent";

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

```ts model.example.ts
// Stands in for a real model. The handler doesn't ask it. A real server doesn't need this file.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export let asked = 0;

export const model: AgentLlmAdapter = {
  async completion() {
    asked += 1;
    return { content: "", finishReason: "stop" };
  },
};
```

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

test("the handler's result is the call's result, and the model isn't asked", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_summarize", { text: "Nobody on our team can log in since this morning." });
  expect(result.json()).toEqual({ summary: "Nobody on our team can" });
  expect(asked).toBe(0);
});
```

Changed in 1.9: before, the handler never ran, and every call failed.

### Asking the user from an agent

`this.elicit()` works in an agent's own `execute()` as it does [in a tool](https://frontmcp.dev/reference/sdk/elicit): the client shows the form, and under MCP 2026-07-28 FrontMCP runs `execute()` again from the top with the answer. Ask before `super.execute(input)`, so the model isn't asked until the user has answered, and not at all if they decline:

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

@Agent({ name: "triage", inputSchema: { ticketId: z.string() }, llm: { adapter: model } })
export class Triage extends AgentContext {
  async execute(input: { ticketId: string }) {
    const answer = await this.elicit(`Set ${input.ticketId} to high priority?`, z.object({ confirm: z.boolean() }));
    if (answer.status !== "accept" || !answer.content?.confirm) return { response: "Left as it is." };
    return super.execute(input);
  }
}
```

```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 {}

export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], elicitation: { enabled: true } };

@FrontMcp(config)
export default class HelpDeskServer {}
```

```ts model.example.ts
// Stands in for a real model, which the Playground can't reach. It counts how
// often it's asked. A real server doesn't need this file.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export let asked = 0;

export const model: AgentLlmAdapter = {
  async completion() {
    asked += 1;
    return { content: "High: the customer can't work.", finishReason: "stop" };
  },
};
```

```ts elicit.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";
import { asked } from "./model.example";

test("the client shows the question, and the agent goes on with the answer", async ({ mcp }) => {
  let question: string | undefined;
  mcp.onElicitation((request) => {
    question = request.message;
    return { action: "accept", content: { confirm: true } };
  });
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(question).toBe("Set T-1 to high priority?");
  expect(result.json()).toEqual({ response: "High: the customer can't work." });
});

test("a declined question ends the call before the model is asked", async ({ mcp }) => {
  mcp.onElicitation(() => ({ action: "decline" }));
  const before = asked;
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(result.json()).toEqual({ response: "Left as it is." });
  expect(asked).toBe(before);
});

test("a client without forms gets the fallback, and the answer runs the agent", async () => {
  // createDirect() runs the server in-process, as the user you name. Its client can't show forms.
  const server = await FrontMcpInstance.createDirect(config);
  const asNour = { authContext: { user: { sub: "nour" } } };
  const result = await server.callTool("invoke_triage", { ticketId: "T-1" }, asNour);
  const { elicitId } = result._meta?.elicitationPending as { elicitId: string };
  const answer = await server.callTool("sendElicitationResult", { elicitId, action: "accept", content: { confirm: true } }, asNour);
  await server.dispose();
  expect(answer.structuredContent).toEqual({ response: "High: the customer can't work." });
});
```

Code before `this.elicit()` runs once per round, so keep side effects after it. A client that can't show forms gets FrontMCP's [fallback](https://frontmcp.dev/reference/sdk/elicit#the-model-is-asked-to-call-sendelicitationresult): the result asks its model to collect the answer and call `sendElicitationResult`, which only the caller who was asked may answer, and that answer runs the agent again, as the last test shows.

Changed in 1.8.4: before, an agent's `this.elicit()` always ended the call with the fallback, even for a client that shows forms, and the answer failed with `Tool "triage" not found` instead of running the agent.

### Limiting who calls an agent, and how often

`authorities`, `rateLimit`, `concurrency`, `timeout` and `availableWhen` work on `@Agent` as on `@Tool`: they apply to `invoke_<name>`. A caller the agent's rule refuses doesn't see it in `tools/list`, and a call over a limit is refused before the model is asked, so these options also cap what clients can spend on your model:

```ts agents.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. At most 2 calls a minute.",
  inputSchema: { query: z.string().describe("What the customer wrote") },
  rateLimit: { maxRequests: 2, windowMs: 60_000 },
  llm: { adapter: model },
})
export class Triage extends AgentContext {}

@Agent({
  name: "refunds",
  description: "Decide whether to refund an order. For team leads, one decision at a time.",
  inputSchema: { query: z.string().describe("The order and what went wrong") },
  authorities: "lead",
  concurrency: { maxConcurrent: 1 },
  llm: { adapter: model },
})
export class Refunds extends AgentContext {}

@Agent({
  name: "read_logs",
  description: "Explain the server's recent errors.",
  inputSchema: { query: z.string() },
  availableWhen: { runtime: ["deno"] }, // only on the Deno deployment
  llm: { adapter: model },
})
export class ReadLogs extends AgentContext {}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { ReadLogs, Refunds, Triage } from "./agents";

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  // The rules `authorities` names. Without this option, the server doesn't start.
  authorities: { profiles: { lead: { roles: { any: ["lead"] } } } },
};

@FrontMcp(config)
export default class HelpDeskServer {}
```

```ts model.example.ts
// Stands in for a real model, which the Playground can't reach. It takes a
// moment to answer, like a real one, and counts how often it's asked.
// A real server doesn't need this file.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export let asked = 0;

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    asked += 1;
    await new Promise((resolve) => setTimeout(resolve, 50));
    const text = prompt.messages[0].content ?? "";
    return { content: /refund/i.test(text) ? "Refund it: the order arrived broken." : "High: the customer can't work.", finishReason: "stop" };
  },
};
```

```ts limits.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";
import { asked } from "./model.example";

test("`rateLimit`: the third call in a minute is refused, and the model isn't asked", async ({ mcp }) => {
  const query = "Nobody can log in.";
  expect(await mcp.tools.call("invoke_triage", { query })).toBeSuccessful();
  expect(await mcp.tools.call("invoke_triage", { query })).toBeSuccessful();
  const before = asked;
  const third = await mcp.tools.call("invoke_triage", { query });
  expect(third).toBeError("RATE_LIMIT_EXCEEDED");
  expect(third.text()).toMatch(/^Rate limit exceeded\. Retry after \d+ seconds$/);
  expect(asked).toBe(before);
});

test("`authorities`: an anonymous caller doesn't see `invoke_refunds`, and can't call it", async ({ mcp }) => {
  expect(await mcp.tools.list()).not.toContainTool("invoke_refunds");
  const result = await mcp.tools.call("invoke_refunds", { query: "Refund order 1042? It arrived broken." });
  expect(result).toBeError("AUTHORITY_DENIED");
  expect(result.text()).toBe(`Access denied to Tool "help-desk:invoke_refunds": profile:lead: roles.any: user has none of 'lead'`);
});

test("a lead can call it, one call at a time (`concurrency`)", async () => {
  // createDirect() runs the server in-process, as the user you name.
  const server = await FrontMcpInstance.createDirect(config);
  const asLead = { authContext: { user: { sub: "nour", roles: ["lead"] } } };
  const query = "Refund order 1042? It arrived broken.";
  const [first, second] = await Promise.allSettled([
    server.callTool("invoke_refunds", { query }, asLead),
    server.callTool("invoke_refunds", { query }, asLead),
  ]);
  await server.dispose();
  expect(first).toMatchObject({ status: "fulfilled", value: { structuredContent: { response: "Refund it: the order arrived broken." } } });
  expect(second).toMatchObject({ status: "rejected", reason: { message: 'Concurrency limit reached for "invoke_refunds" (max: 1)' } });
});

test("`availableWhen`: an agent for Deno isn't offered here", async ({ mcp }) => {
  expect(await mcp.tools.list()).not.toContainTool("invoke_read_logs");
  const result = await mcp.tools.call("invoke_read_logs", { query: "Why did last night's export fail?" });
  expect(result).toBeError("ENTRY_UNAVAILABLE");
  expect(result.text()).toMatch(/^Tool "invoke_read_logs" is not available in the current environment \(requires: \{"runtime":\["deno"\]\}\)/);
});
```

The Playground's caller is anonymous, so it sees only `invoke_triage`. The limits and their errors are the same as a tool's, and [Guard options](https://frontmcp.dev/reference/sdk/guard) covers them: whose calls share a count (`partitionBy`), queueing with `queueTimeoutMs`, and `timeout: { executeMs }`, which fails the call with `EXECUTION_TIMEOUT` but, like [`execution.timeout`](#agent-execution-timed-out-after-ms), doesn't stop the loop. Say the limits in the agent's `description`, so the client's model can plan around them. [Authorities](https://frontmcp.dev/reference/auth/authorities) has the rules `authorities` takes.

Before FrontMCP 1.8.3, these options had no effect on an agent. They now apply, so check the ones your agents already declare.

### Giving the agent's tools a plugin

An agent's tools run apart from the app, and so do its plugins: a plugin in `@Agent({ plugins })` runs for the tools the agent's model calls, and a plugin in `@App({ plugins })` runs for the app's tools and for `invoke_<name>`, but not for the agent's tools unless the agent sets `execution: { inheritPlugins: true }`. Here one plugin that records every tool that runs is in both places:

```ts triage.agent.ts active
import { Agent, AgentContext, z } from "@frontmcp/sdk";
import { CallLog } from "./call-log.plugin";
import { model } from "./model.example";
import { GetTicket } from "./ticket.tools";

@Agent({
  name: "triage",
  description: "Read a support ticket and suggest a priority. Pass the ticket's id.",
  inputSchema: { ticketId: z.string() },
  tools: [GetTicket],
  plugins: [CallLog], // runs for get_ticket
  llm: { adapter: model },
})
export class Triage extends AgentContext {}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CallLog } from "./call-log.plugin";
import { Triage } from "./triage.agent";

@App({ id: "help-desk", name: "Help Desk", agents: [Triage], plugins: [CallLog] }) // runs for invoke_triage
export class HelpDeskApp {}
```

```ts call-log.plugin.ts
import { DynamicPlugin, FlowCtxOf, Plugin, ToolHook } from "@frontmcp/sdk";

export const calls: string[] = [];

@Plugin({ name: "call-log", description: "Records the name of every tool that runs" })
export class CallLog extends DynamicPlugin<object> {
  @ToolHook.Will("execute")
  async record(ctx: FlowCtxOf<"tools:call-tool">) {
    calls.push(ctx.state.tool?.metadata.name ?? "unknown");
  }
}
```

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

@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 }) {
    return { id, title: "Cannot log in since this morning" };
  }
}
```

```ts model.example.ts
// Stands in for a real model, which the Playground can't reach. It reads the
// ticket, then answers. A real server doesn't need this file.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    const last = prompt.messages[prompt.messages.length - 1];
    if (last.role === "user") {
      const { ticketId } = JSON.parse(last.content ?? "{}");
      return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "get_ticket", arguments: { id: ticketId } }] };
    }
    return { content: "High: the customer can't log in.", finishReason: "stop" };
  },
};
```

```ts plugins.test.ts
import { test, expect } from "@frontmcp/testing";
import { Agent, AgentContext, App, FrontMcpInstance, z } from "@frontmcp/sdk";
import { CallLog, calls } from "./call-log.plugin";
import { model } from "./model.example";
import { GetTicket } from "./ticket.tools";

test("the app's plugin runs for `invoke_triage`, the agent's for `get_ticket`", async ({ mcp }) => {
  calls.length = 0;
  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(calls).toEqual(["invoke_triage", "get_ticket"]);
});

const runWithAppPluginOnly = async (execution: { inheritPlugins?: boolean }) => {
  @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [GetTicket], execution, llm: { adapter: model } })
  class Triage extends AgentContext {}
  @App({ id: "help-desk", name: "Help Desk", agents: [Triage], plugins: [CallLog] })
  class HelpDeskApp {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });
  calls.length = 0;
  await server.callTool("invoke_triage", { ticketId: "T-1" });
  await server.dispose();
  return [...calls];
};

test("with the plugin only on the app, it doesn't run for the agent's tools", async () => {
  expect(await runWithAppPluginOnly({})).toEqual(["invoke_triage"]);
});

test("unless the agent sets `inheritPlugins`", async () => {
  expect(await runWithAppPluginOnly({ inheritPlugins: true })).toEqual(["invoke_triage", "get_ticket"]);
});
```

With `inheritPlugins`, the agent's tools also run the app's and the server's plugins, and a plugin installed in both places runs once. (New in 1.9: before, FrontMCP never read `inheritPlugins`.) A plugin that enforces an option of the agent's tools, like [Approval](https://frontmcp.dev/reference/plugins/approval) for `approval`, has to run for them, in the agent's `plugins` or on the app with `inheritPlugins`, or the server doesn't start: see [Troubleshooting](#unenforced-metadata-tool--declares-approval).

### Using a real model

With a real model, only `llm` changes. Install the provider's package and give the agent a key:

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

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

The Playground can't do that: it has no network and no key. It can run the adapter, though, with a stand-in client, which shows what FrontMCP sends OpenAI for each turn of the loop:

```ts triage.agent.ts active
import { Agent, AgentContext, OpenAIAdapter, z } from "@frontmcp/sdk";
import { openai } from "./openai.example";
import { GetTicket } from "./ticket.tools";

@Agent({
  name: "triage",
  description: "Suggest a priority for a support ticket. Pass the ticket's id.",
  systemInstructions: "You triage tickets. Read the ticket with get_ticket, then answer high or normal, and why.",
  inputSchema: { ticketId: z.string() },
  tools: [GetTicket],
  // With the real `openai` package, pass `apiKey` instead of `client`.
  llm: { adapter: new OpenAIAdapter({ model: "gpt-5", client: openai }) },
})
export class Triage extends AgentContext {}
```

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

@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 }) {
    return { id, title: "Cannot log in since this morning" };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { Triage } from "./triage.agent";

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

```ts openai.example.ts
// Stands in for the OpenAI client, which the Playground can't use: no network,
// no key. It answers the way the Chat Completions API does, and keeps every
// request it gets. With the real `openai` package, you don't need this file.
type Request = { messages: { role: string; content: string | null }[] };

export const requests: Request[] = [];

export const openai = {
  chat: {
    completions: {
      async create(request: Request) {
        requests.push(structuredClone(request));
        const last = request.messages[request.messages.length - 1];
        if (last.role === "user") {
          const { ticketId } = JSON.parse(last.content ?? "{}");
          const call = { id: "call_1", type: "function", function: { name: "get_ticket", arguments: JSON.stringify({ id: ticketId }) } };
          return { choices: [{ index: 0, finish_reason: "tool_calls", message: { role: "assistant", content: null, tool_calls: [call] } }] };
        }
        const answer = "High: the customer can't log in.";
        return { choices: [{ index: 0, finish_reason: "stop", message: { role: "assistant", content: answer } }] };
      },
    },
  },
  responses: {
    async create() {
      throw new Error("This example uses the Chat Completions API.");
    },
  },
};
```

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

test("the adapter sends a chat request with the instructions and the tools", async ({ mcp }) => {
  requests.length = 0;
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(result.json()).toEqual({ response: "High: the customer can't log in." });
  expect(requests).toHaveLength(2);
  expect(requests[0]).toMatchObject({
    model: "gpt-5",
    messages: [
      { role: "system", content: "You triage tickets. Read the ticket with get_ticket, then answer high or normal, and why." },
      { role: "user", content: '{"ticketId":"T-1"}' },
    ],
    tools: [{ type: "function", function: { name: "get_ticket", description: "Get a support ticket by id." } }],
  });
});

test("the tool's result goes back as a `tool` message", async ({ mcp }) => {
  requests.length = 0;
  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(requests[1].messages.slice(2)).toEqual([
    {
      role: "assistant",
      content: null,
      tool_calls: [{ id: "call_1", type: "function", function: { name: "get_ticket", arguments: '{"id":"T-1"}' } }],
    },
    { role: "tool", tool_call_id: "call_1", content: '{"id":"T-1","title":"Cannot log in since this morning"}' },
  ]);
});
```

`AnthropicAdapter` takes the same options, except `api`, and sends Anthropic's Messages API format: `system` as its own field, `tools` with an `input_schema`, and tool results as `tool_result` blocks. [Connecting a Real Model](https://frontmcp.dev/learn/connecting-a-real-model) covers keys, packages, cost and latency.

---

## Troubleshooting

### `Agent reached maximum iterations (…) without completing`

The model was asked `maxIterations` times (10 by default), and still asked for tools the last time. The tools of that last turn ran, but their results never reached the model:

```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 id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in since this morning" };
  }
}

@Agent({
  name: "triage",
  inputSchema: { ticketId: z.string() },
  tools: [GetTicket],
  execution: { maxIterations: 3, enableAutoProgress: true },
  llm: { adapter: model },
})
export class Triage extends AgentContext {}
```

```ts model.example.ts
// Stands in for a model that never decides: it reads the ticket again every
// time it's asked. A real server doesn't need this file.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export let asked = 0;

export const model: AgentLlmAdapter = {
  async completion() {
    asked += 1;
    return { content: null, finishReason: "tool_calls", toolCalls: [{ id: `call_${asked}`, name: "get_ticket", arguments: { id: "T-1" } }] };
  },
};
```

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

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

test("the last progress update says only that the agent failed", 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.at(-1)?.params).toMatchObject({
    progress: 100,
    message: expect.stringMatching(/^Agent failed: Internal FrontMCP error\. Please contact support with error ID: err_\w+$/),
  });
});
```

Check that the instructions say when the model is done, and that the tools give it what it needs to finish: a model that doesn't find what it's looking for tends to ask again. Raise `maxIterations` only if the task really takes more turns. The error, and every model call before it, is lost to the client, so a low limit that fails is cheaper than a high one that fails.

With `enableAutoProgress`, the last progress update is `Agent failed: ` and FrontMCP's generic message with an error id, as the second test shows. (Changed in 1.9.3: before, it had the error's own text.)

### `Agent execution timed out after …ms`

The model loop took longer than `execution.timeout` (2 minutes by default). The client gets the error then, but the loop isn't stopped: the model call in progress, and any tool calls after it, still run in the background.

An agent's `timeout: { executeMs }` is a second limit, the [one tools have](https://frontmcp.dev/reference/sdk/guard#timeout). It fails the call with `EXECUTION_TIMEOUT` and `Execution of "invoke_triage" timed out after …ms` instead, and doesn't stop the loop either, as the second test shows.

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

@Agent({
  name: "triage",
  inputSchema: { query: z.string() },
  execution: { timeout: 50 },
  llm: { adapter: model },
})
export class Triage extends AgentContext {}
```

```ts model.example.ts
// Stands in for a model that takes 200 ms to answer. A real server doesn't need this file.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const answered: string[] = [];

export const model: AgentLlmAdapter = {
  async completion() {
    await new Promise((resolve) => setTimeout(resolve, 200));
    answered.push("High");
    return { content: "High", finishReason: "stop" };
  },
};
```

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

test("the call fails at the timeout, and the model answers later anyway", async ({ mcp }) => {
  answered.length = 0;
  const started = Date.now();
  const result = await mcp.tools.call("invoke_triage", { query: "Nobody can log in." });
  expect(Date.now() - started).toBeLessThan(200);
  expect(result).toBeError("TOOL_EXECUTION_ERROR");
  expect(result.text()).toMatch(/^Tool "invoke_triage" execution failed: Agent execution timed out after 50ms/);

  expect(answered).toEqual([]);
  await new Promise((resolve) => setTimeout(resolve, 250));
  expect(answered).toEqual(["High"]);
});

test("`timeout: { executeMs }` fails it with `EXECUTION_TIMEOUT`, and doesn't stop the loop either", async () => {
  @Agent({ name: "triage", inputSchema: { query: z.string() }, timeout: { executeMs: 50 }, llm: { adapter: model } })
  class Triage extends AgentContext {}
  @App({ id: "help-desk", name: "Help Desk", agents: [Triage] })
  class HelpDeskApp {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });

  answered.length = 0;
  await expect(server.callTool("invoke_triage", { query: "Nobody can log in." })).rejects.toThrow(
    'Execution of "invoke_triage" timed out after 50ms',
  );
  expect(answered).toEqual([]);
  await new Promise((resolve) => setTimeout(resolve, 250));
  expect(answered).toEqual(["High"]);
  await server.dispose();
});
```

Give the timeout room for a few slow model calls: each one can take seconds. Tools with side effects may run after the client was told the call failed, so make them safe to repeat.

### An agent's tool fails with `Provider "…" is not available`

The agent's tool asked for a provider that isn't registered where the agent is: on the agent, its app or the server. A provider on another app doesn't count. The client doesn't see this error, because it's a tool error inside the loop: the model gets it, and answers as best it can. The client's log gets `Tool get_ticket failed: ` with only an error id, and the server's log has the error under that id. Here `TicketStore` is on the `tickets` app, and the agent is in `help-desk`. The scripted model passes the error on:

```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 {
  get(id: string) {
    return { id, title: "Cannot log in since this morning" };
  }
}

@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 }) {
    return this.get(TicketStore).get(id);
  }
}

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

// 🚩 TicketStore is on another app
@App({ id: "tickets", name: "Tickets", tools: [GetTicket], providers: [TicketStore] })
export class TicketsApp {}

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

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

```ts model.example.ts
// Stands in for a model that reads the ticket, then repeats what the tool said.
// A real server doesn't need this file.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    const last = prompt.messages[prompt.messages.length - 1];
    if (last.role === "user") {
      return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "get_ticket", arguments: { id: "T-1" } }] };
    }
    return { content: last.content, finishReason: "stop" };
  },
};
```

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

test("the model gets the error, and the call succeeds", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(result).toBeSuccessful();
  expect(result.json().error).toContain('Provider "TicketStore" is not available: not found in local or parent registries');
});

test("the client's log gets only an error id", 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.message);
  expect(errors).toEqual([expect.stringMatching(/^Tool get_ticket failed: Internal FrontMCP error\. Please contact support with error ID: err_\w+$/)]);
});

test("the other app's own tool finds it", async ({ mcp }) => {
  expect((await mcp.tools.call("get_ticket", { id: "T-1" })).json()).toEqual({ id: "T-1", title: "Cannot log in since this morning" });
});
```

Register the provider on the agent's app, on the agent, or, to share it between apps, on the server with `@FrontMcp({ providers })`: see [Sharing a provider with the agent's tools](#sharing-a-provider-with-the-agents-tools). (Changed in 1.9.2: before, an agent's tools didn't see their own app's providers either, so servers written for 1.9.1 often have them on `@FrontMcp`, where they still work.)

### The model gets `Tool "…" not found in agent "…". Available tools: […]`

The model asked for a tool the agent doesn't offer it. An agent's model gets its `tools`, its nested `agents`, the agents `swarm` lets it see, and, with `execution.inheritParentTools`, the app's tools: nothing else. The model reads this message as the call's result and can try again; if it keeps asking for tools the agent doesn't have, the call ends with [`Agent reached maximum iterations`](#agent-reached-maximum-iterations--without-completing). Add the tool to `tools`, or check that `systemInstructions` names the tools as they're spelled.

### `Tool "invoke_…" not found`

The client called an agent the server doesn't have. Check, in order:

1. The class is in an app's `agents` array, and the app is in `@FrontMcp({ apps })`. An agent only in another agent's `agents` is for that agent: clients can't call it, and neither can other tools' `this.callTool()`. See [Calling other agents](#calling-other-agents).
2. The name: `invoke_` and the agent's `id` if it has one, else its `name`, with characters other than letters, digits, `_` and `-` replaced by `_`.
3. If `tools/list` doesn't show it, it may set `hideFromDiscovery: true`. It can still be called by name. `tools/list` also leaves an agent out for callers its `authorities` refuse, who get `AUTHORITY_DENIED`, and where its `availableWhen` doesn't match, which answers `ENTRY_UNAVAILABLE`: see [Limiting who calls an agent, and how often](#limiting-who-calls-an-agent-and-how-often).

### `Environment variable … is not set`

The server didn't start because an agent's `llm.apiKey` is `{ env: "…" }` and that variable isn't set. FrontMCP reads it when the server starts, not when the agent is first called. Set it in the environment the server runs in.

### `Tool output validation failed (output does not match outputSchema at …)`

The agent has an `outputSchema`, and the model's answer didn't match it, often because it answered in plain text, which becomes `{ "response": "…" }`. The client gets the code `INVALID_OUTPUT`, and the text names the first field that didn't match. See [Returning structured output](#returning-structured-output).

### `Unenforced metadata: Tool "…" declares 'approval'`

The server didn't start, because a tool in an agent's `tools` has `approval`, and no [Approval plugin](https://frontmcp.dev/reference/plugins/approval) runs for it. An app's plugins run for its agents' tools only with `execution.inheritPlugins`, so without it the option would do nothing, and FrontMCP refuses to start rather than run the tool unchecked. `featureFlag` without the Feature Flags plugin stops it the same way. Put the plugin in `@Agent({ plugins })`, or set `inheritPlugins` with the plugin on the app:

```ts refunds.agent.ts active
import { Agent, AgentContext, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";
import { model } from "./model.example";

@Tool({ name: "issue_refund", description: "Refund an order.", inputSchema: { orderId: z.string() }, approval: true })
export class IssueRefund extends ToolContext {
  async execute({ orderId }: { orderId: string }) {
    return { orderId, refunded: true };
  }
}

@Agent({
  name: "refunds",
  description: "Refund an order. Pass its id.",
  inputSchema: { orderId: z.string() },
  tools: [IssueRefund],
  plugins: [ApprovalPlugin.init()], // enforces `approval` on the agent's tools
  llm: { adapter: model },
})
export class Refunds extends AgentContext {}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { Refunds } from "./refunds.agent";

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

```ts model.example.ts
// Stands in for a model that asks for the refund, then repeats what the tool
// said. A real server doesn't need this file.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    const last = prompt.messages[prompt.messages.length - 1];
    if (last.role === "user") {
      const { orderId } = JSON.parse(last.content ?? "{}");
      return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "call_1", name: "issue_refund", arguments: { orderId } }] };
    }
    return { content: last.content, finishReason: "stop" };
  },
};
```

```ts approval.test.ts
import { test, expect } from "@frontmcp/testing";
import { Agent, AgentContext, App, FrontMcpInstance, z } from "@frontmcp/sdk";
import { ApprovalPlugin } from "@frontmcp/plugin-approval";
import { model } from "./model.example";
import { IssueRefund } from "./refunds.agent";

test("the agent's plugin holds the refund until someone approves it", async ({ mcp }) => {
  const result = await mcp.tools.call("invoke_refunds", { orderId: "1042" });
  expect(result.json()).toEqual({ error: 'Tool "agent:refunds:issue_refund" requires approval to execute. Allow?' });
});

const startWithPluginOnApp = (execution: { inheritPlugins?: boolean }) => {
  @Agent({ name: "refunds", inputSchema: { orderId: z.string() }, tools: [IssueRefund], execution, llm: { adapter: model } })
  class Refunds extends AgentContext {}
  @App({ id: "help-desk", name: "Help Desk", agents: [Refunds], plugins: [ApprovalPlugin.init()] })
  class HelpDeskApp {}
  return FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });
};

test("with the plugin only on the app, the server doesn't start", async () => {
  await expect(startWithPluginOnApp({})).rejects.toThrow(
    `Unenforced metadata: Tool "refunds:issue_refund" declares 'approval' (enforced by ApprovalPlugin from @frontmcp/plugin-approval)`,
  );
});

test("with `inheritPlugins`, the app's plugin enforces it", async () => {
  const server = await startWithPluginOnApp({ inheritPlugins: true });
  try {
    const result = await server.callTool("invoke_refunds", { orderId: "1042" });
    expect(result.structuredContent).toEqual({ error: 'Tool "agent:refunds:issue_refund" requires approval to execute. Allow?' });
  } finally {
    await server.dispose();
  }
});
```

The model gets the plugin's refusal as the tool's result; [Approval](https://frontmcp.dev/reference/plugins/approval) covers how a user grants it. (Changed in 1.9.2: before, the startup check didn't count a plugin the agent inherits, so `inheritPlugins` didn't help here.) When the message names an `Agent "…"`, the option is on `@Agent` itself, where it applies to `invoke_<name>`: that plugin goes on the app.

### The client's log has `Calling tool: …` messages you didn't send

The agent sends one for each tool call its model asks for, and `Tool … failed: …` for each that fails, with the error's public message. Set `execution: { enableNotifications: false }` to stop them: see [Telling the client how the loop is going](#telling-the-client-how-the-loop-is-going).

### `Agent "…" can't be called from …: that would be agent call N, deeper than maxCallDepth M`

A chain of agents calling agents went past `swarm.maxCallDepth`, 3 by default. The message lists the agents running, from the one the client called. When a model made the call, the model gets this as the call's `{"error":"…"}`, and the loop goes on; when code made it, with `invokeAgent()` or `this.callTool("invoke_<name>")`, it throws, and the client gets `AGENT_CALL_DEPTH_EXCEEDED` unless the agent catches it. An agent that calls itself, or two that hand a task back and forth, are the usual cause: give the chain a way to end, as [Agents That Call Agents](https://frontmcp.dev/learn/agents-that-call-agents#handoffs-that-never-stop) does. Raise `maxCallDepth` only when the chain really is that long. (New in 1.9: before, nothing limited the depth.)

### `Agent "…" exports resources "…", which is not one of its own resources`

The server didn't start, because `exports` names a resource, prompt or provider that isn't in the agent's own `resources`, `prompts` or `providers`. Add it there too. `exports` takes only those three keys: any other, like `tools`, fails with `Unrecognized key`.

### `Agent "…" has a tool named "…", the name of a built-in tool its model reads the agent's resources and prompts with`

The server didn't start, with an `AgentConfigurationError`. The agent declares resources or prompts, so its model is offered [`list_resources` and `read_resource`, or `list_prompts` and `get_prompt`](#reading-the-agents-resources-and-prompts), and one of the agent's own tools has one of those names: the model couldn't tell them apart. That includes a plugin's or an adapter's tool on the agent, and a nested agent's `invoke_<name>`. Rename the tool:

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

@Resource({ name: "priority-policy", uri: "policy://priorities", mimeType: "text/markdown" })
export class PriorityPolicy extends ResourceContext {
  async execute(uri: string) {
    return { contents: [{ uri, text: "High: the customer can't work. Normal: anything else." }] };
  }
}

@Tool({ name: "read_ticket", description: "Read a support ticket by id.", inputSchema: { id: z.string() } })
export class ReadTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in since this morning" };
  }
}

@Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [ReadTicket], resources: [PriorityPolicy], llm: { adapter: model } })
export class Triage extends AgentContext {}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { Triage } from "./triage.agent";

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

```ts model.example.ts
// Stands in for a real model: it answers at once, and keeps the names of the
// tools it was offered. A real server doesn't need this file.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const offered: (string[] | undefined)[] = [];

export const model: AgentLlmAdapter = {
  async completion(prompt, tools) {
    offered.push(tools?.map((t) => t.name));
    return { content: "High", finishReason: "stop" };
  },
};
```

```ts names.test.ts
import { test, expect } from "@frontmcp/testing";
import { Agent, AgentContext, App, FrontMcpInstance, Tool, ToolContext, z } from "@frontmcp/sdk";
import { model, offered } from "./model.example";
import { PriorityPolicy } from "./triage.agent";

test("a tool named `read_resource` stops the server from starting", async () => {
  @Tool({ name: "read_resource", description: "Read a support ticket by id.", inputSchema: { id: z.string() } })
  class ReadResource extends ToolContext {
    async execute({ id }: { id: string }) {
      return { id };
    }
  }
  @Agent({ name: "triage", inputSchema: { ticketId: z.string() }, tools: [ReadResource], resources: [PriorityPolicy], llm: { adapter: model } })
  class Triage extends AgentContext {}
  @App({ id: "help-desk", name: "Help Desk", agents: [Triage] })
  class HelpDeskApp {}

  await expect(FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] })).rejects.toThrow(
    `Agent "triage" has a tool named "read_resource", the name of a built-in tool its model reads the agent's resources and prompts with. Rename the tool.`,
  );
});

test("renamed, the model is offered the built-ins and the tool", async ({ mcp }) => {
  offered.length = 0;
  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(offered).toEqual([["list_resources", "read_resource", "read_ticket"]]);
});
```

Only the names the agent is offered count: an agent without prompts may have a tool named `get_prompt`. A tool of the app that [`inheritParentTools`](#execution) would offer under one of these names isn't offered; the built-in is. (New in 1.9.4, with the built-in tools.)
