@Agent

@Agent declares an 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 explains when an agent is worth writing.

@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.

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

Options

Required:

OptionTypeDescription
namestringClients 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.
llmAgentLlmConfigThe model the agent runs: an adapter you write, OpenAI or Anthropic, or a provider token. See llm.

Optional:

OptionTypeDefaultDescription
descriptionstring"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.
systemInstructionsstring""Sent as system with every prompt: the instructions for the agent's own model.
inputSchemaobject 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.
outputSchemaobject of Zod typesnoneAdvertised 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.
idstringnameReplaces name in the tool's name: invoke_<id>.
toolstool 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.
executionAgentExecutionConfigsee executionLimits and switches for the model loop.
hideFromDiscoverybooleanfalseLeaves invoke_<name> out of tools/list. It can still be called by name.
tagsstring[][]Labels. They aren't sent to clients in tools/list.
providersprovider 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. (Changed in 1.9: before, each one also had to be registered on the app or the server, or the server didn't start.)
agentsagent 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. (New in 1.9: before, nested agents were ignored.)
swarmAgentSwarmConfigsee swarmLets the agent's model call the app's other agents, and limits how deep agents call each other.
resources, promptsresource 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. (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? }noneServes 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, adaptersplugin 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. See Giving the agent's tools a plugin.
authoritiesa profile name or a rulenoneWho 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 }), or the server doesn't start. See Limiting who calls an agent, and how often.
rateLimit, concurrency, timeout, availableWhenas on @ToolnoneLimit invoke_<name> as they limit a tool. A refused call never reaches the model. See 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:

FormExampleNotes
An adapter{ adapter: model }Any object with a completion() method: see 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. The fields are below.
A tokenLLM_ADAPTERA 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:

FieldTypeDefaultDescription
provider"openai" | "anthropic"requiredWhich adapter to create. Install its package too: openai or @anthropic-ai/sdk.
modelstringrequiredThe provider's model id.
apiKeystring | { env: string } | WithConfigrequiredThe 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.
baseUrlstringthe provider'sAnother endpoint that speaks the same API.
temperaturenumberthe provider'sSent with every request.
maxTokensnumberthe provider's; 4096 for AnthropicThe 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 sets one up, and Using a real model below shows what an adapter sends.

execution

FieldTypeDefaultDescription
maxIterationsnumber10How 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.
timeoutnumber120000 (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.
useToolFlowbooleantrueRuns 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.
enableNotificationsbooleantrueSends 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.
enableAutoProgressbooleanfalseSends 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 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.)
enableStreamingbooleanfalseSends 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. (Changed in 1.9.4: before, it had no effect, and true logged a warning at startup.)
inheritParentToolsbooleanfalseAlso 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.)
notificationIntervalnumber1000The 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.)
inheritPluginsbooleanfalseAlso 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. (New in 1.9: before, it was never read.)

swarm

FieldTypeDefaultDescription
canSeeOtherAgentsbooleanfalseOffers the agent's model the app's other agents, each as invoke_<name>, besides its own agents. this.invokeAgent() can call them too.
visibleAgentsstring[]all agentsLimits which ones, by name.
isVisiblebooleantruefalse hides this agent from the other agents' models and from their invokeAgent(). Clients still see it.
maxCallDepthnumber3How 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 shows them, and 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 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 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:

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, with:

  • prompt.system: the agent's systemInstructions, or "".
  • prompt.messages: the conversation so far, as AgentMessages. 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 that read its resources and prompts, when it has any. undefined when there are none.
  • options: what completionOptions() 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:

FieldTypeDescription
role"user" | "assistant" | "tool" | "system"Who wrote it.
contentstring | nullThe text. A tool message's content is the tool's result as JSON (or the text, if the result is a string).
toolCallsAgentToolCall[]On an assistant message: the calls the model asked for.
toolCallIdstringOn a tool message: the id of the call it answers.
namestringOn a tool message: the tool's name.

completion() returns an AgentCompletion:

FieldTypeDescription
contentstring | nullThe 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.
rawunknownOptional: the provider's own response.

streamCompletion() is optional. It yields the reply in pieces, as AgentCompletionChunks:

FieldTypeDescription
type"content" | "tool_call" | "done"What the chunk carries.
contentstringOn 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.
completionAgentCompletionOn 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 compares every member. These are the members that matter for agents:

MemberDescription
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.
buildUserMessage(input)protected. Builds the user message. 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.inputThe validated input.
this.metadataThe agent's options.
this.get(token), this.tryGet(token)Providers, 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.
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.authThe caller, as in tools: 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. (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.
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 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:

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

llm: { adapter: new OpenAIAdapter({ model: "gpt-5", apiKey: process.env.OPENAI_API_KEY!, maxRetries: 1 }) },
OptionTypeDefaultDescription
modelstringrequiredThe provider's model id.
apiKeystringrequired, unless you pass clientA non-empty key. new OpenAIAdapter({ model, apiKey: "" }) throws apiKey is required and must be a non-empty string.
clientthe provider SDK's clientnoneA client you created, used instead of apiKey and baseUrl. The adapter then doesn't load the provider's package.
baseUrlstringthe provider'sAnother endpoint that speaks the same API.
temperaturenumberthe provider's
maxTokensnumberthe provider's; 4096 for AnthropicAdapter
timeoutnumber60000How long one request may take, in milliseconds.
maxRetriesnumber3How 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:

WhenCodeText
The caller fails the agent's authoritiesAUTHORITY_DENIEDAccess denied to Tool "help-desk:invoke_refunds": and the reason
The agent's availableWhen doesn't match where the server runsENTRY_UNAVAILABLETool "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 concurrencyRATE_LIMIT_EXCEEDED, CONCURRENCY_LIMITAs for a tool: see Guard options
The arguments don't match inputSchemaINVALID_INPUTInvalid tool input, then each Zod issue
The model still asks for tools after maxIterations turnsTOOL_EXECUTION_ERRORTool "invoke_triage" execution failed: Agent reached maximum iterations (10) without completing
execution.timeout runs outTOOL_EXECUTION_ERRORTool "invoke_triage" execution failed: Agent execution timed out after 120000ms
The agent's timeout.executeMs runs outEXECUTION_TIMEOUTExecution of "invoke_triage" timed out after 30000ms
The adapter throws: no network, a bad key, a rate limitTOOL_EXECUTION_ERRORTool "invoke_triage" execution failed: and the adapter's message
A chain of agents calling agents goes past swarm.maxCallDepthAGENT_CALL_DEPTH_EXCEEDEDAgent "escalate" can't be called from "escalate" -> "escalate" -> "escalate" -> "escalate": that would be agent call 4, deeper than maxCallDepth 3 (see Troubleshooting)
execute() lets an error from this.invokeAgent() throughAGENT_NOT_FOUND, AGENT_VISIBILITY_DENIEDAgent not found: …, Agent "…" does not have visibility to agent "…"
The result doesn't match outputSchemaINVALID_OUTPUTTool output validation failed (output does not match outputSchema at priority), naming the first field that doesn't match (see Returning structured output)
execute() calls this.fail(new PublicMcpError(message, code))your codeyour 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 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). 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.
  • 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 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.

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 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:

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 goes further.

The model calls the agent's tools on the "agent" surface, and getCallSurface() is "agent" inside them. So an availableWhen 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:

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

ToolArgumentsWhat the model gets
list_resourcesnone{ 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 inThe 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_promptsnone{ 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, as a client's would, so the agent's plugins' ResourceHook and PromptHook hooks run for it (with inheritPlugins, 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 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.

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:

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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().

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:

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

Open
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}`;
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 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:

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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). A called agent and its tools report to the client of the outer call, as called tools do: the 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:

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 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:

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 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. 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.

Open
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(" ") }));

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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: 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:

Open
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);
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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: 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:

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 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, doesn't stop the loop. Say the limits in the agent's description, so the client's model can plan around them. 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:

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 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.

Using a real model

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

yarn add openai
export OPENAI_API_KEY=sk-...
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:

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 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:

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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. 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.

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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. (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. 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.
  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.

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.

Unenforced metadata: Tool "…" declares 'approval'

The server didn't start, because a tool in an agent's tools has approval, and no Approval plugin 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:

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The model gets the plugin's refusal as the tool's result; 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.

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 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, 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:

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 would offer under one of these names isn't offered; the built-in is. (New in 1.9.4, with the built-in tools.)