Your First Agent
Every tool so far does one thing and answers, and the client's model decides what happens next. An agent moves some of that deciding into your server. It's a tool that runs a model of its own, with instructions you write, so the client's model can hand it a whole task in one call. This lesson writes an agent that suggests a priority for a new support ticket, calls it the way a client would, and looks at what its model is sent and what comes back.
You will learn
- How to write an agent with
@AgentandAgentContext, and register it on an app - How a client sees an agent: one
invoke_tool - What the agent's model is sent, and how FrontMCP builds it from the input
- What comes back, and how
outputSchemachecks it - How a scripted model stands in for a real one
Writing an agent
New tickets arrive with a title and whatever the customer wrote, and before anyone works on one, a support agent gives it a priority. That's a judgment call on free text, which a model makes well. Here's an agent that makes it. Open the Tests tab:
import { Agent, AgentContext, z } from "@frontmcp/sdk";
import { model } from "./model.example";
@Agent({
name: "triage",
description: "Suggest a priority for a new support ticket, with a one-line reason. Pass the ticket's title and body.",
systemInstructions:
"You triage tickets for a help desk. Give the ticket a priority, High, Normal or Low, " +
"and say why in one sentence. High is for anything that stops the customer from working.",
inputSchema: {
title: z.string().describe("The ticket's title"),
body: z.string().describe("What the customer wrote"),
},
llm: { adapter: model },
})
export class Triage extends AgentContext {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
An agent is a class, like a tool:
@Agent({ name, description, systemInstructions, inputSchema, llm })describes it.descriptionis for the client's model, which decides when to call the agent.systemInstructionsare for the agent's own model, which does the work.inputSchemais a Zod shape, as on a tool.llmsays which model the agent runs. Here it's{ adapter: model }, an object you write; the lesson Connecting a Real Model replaces it with OpenAI or Anthropic.AgentContextis the base class, and there's noexecute(). FrontMCP's own sends the input to the model and returns its answer. You can override it when you need to.agents: [Triage]on@Appregisters it.
How a client sees an agent
Open the Capabilities tab of the example above. The agent is a tool called invoke_triage, and nothing about it says there's a model behind it. A client lists it and calls it like any other tool:
import { test, expect } from "@frontmcp/testing";
test("`invoke_triage` is built from the agent's options", async ({ mcp }) => {
const [tool] = await mcp.tools.list();
expect(tool).toEqual({
name: "invoke_triage",
description: "Suggest a priority for a new support ticket, with a one-line reason. Pass the ticket's title and body.",
inputSchema: {
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: {
title: { type: "string", description: "The ticket's title" },
body: { type: "string", description: "What the customer wrote" },
},
required: ["title", "body"],
},
annotations: { title: "triage", readOnlyHint: false, openWorldHint: true },
});
});
test("input that doesn't match `inputSchema` never reaches the model", async ({ mcp }) => {
const result = await mcp.tools.call("invoke_triage", { title: "Cannot log in" });
expect(result).toBeError("INVALID_INPUT");
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
From here on, examples leave out main.ts: when an example exports an agent but no app, the Playground registers it in one for you, as it does tools.
@Agent option | What the client sees |
|---|---|
name | The tool's name, invoke_ and the name, and its annotations.title. |
description | The tool's description. |
inputSchema | The tool's input schema. Input that doesn't match fails with INVALID_INPUT before the model is asked, as for any tool. |
systemInstructions | Nothing. They go to the agent's model only. |
FrontMCP marks every agent's tool readOnlyHint: false and openWorldHint: true, whatever the agent does. The @Agent reference has the rest of the naming rules, like id, which names the tool instead of name.
What the agent's model is sent
When invoke_triage is called, FrontMCP calls the adapter's completion(prompt, tools). prompt is the whole conversation so far, and at the start that's the system instructions and one message from the "user", made from the input. The model in this example records every prompt, and the test reads the first:
import { test, expect } from "@frontmcp/testing";
import { prompts } from "./model.example";
test("the model gets the instructions and the input as JSON", async ({ mcp }) => {
await mcp.tools.call("invoke_triage", { title: "Cannot log in", body: "Since this morning nobody on our team can log in." });
expect(prompts[0]).toEqual({
system:
"You triage tickets for a help desk. Give the ticket a priority, High, Normal or Low, " +
"and say why in one sentence. High is for anything that stops the customer from working.",
messages: [{ role: "user", content: '{"title":"Cannot log in","body":"Since this morning nobody on our team can log in."}' }],
});
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
systemissystemInstructions, or""if you didn't set any.messagesstarts with one{ role: "user", content }. The model reads the input as JSON, so field names liketitleandbodyare part of what it reads: name them for the model.
What the model sends back is the reply. completion() returns { content, finishReason }, and finishReason: "stop" means the model is done, so its content is the agent's answer. The other value that matters is "tool_calls", which the next lesson covers.
What comes back
The agent's answer is the model's last reply. A reply that's text arrives as { response: text }, as in the first example. A reply that's a JSON object arrives as that object. So when a client needs fields, not prose, ask the model for JSON, and declare what it should look like with outputSchema:
import { Agent, AgentContext, z } from "@frontmcp/sdk";
import { model } from "./model.example";
@Agent({
name: "triage",
description: "Suggest a priority for a new support ticket, with a one-line reason. Pass the ticket's title and body.",
systemInstructions:
"You triage tickets for a help desk. Answer with JSON only: " +
'{"priority": "high" | "normal" | "low", "reason": one sentence}. ' +
"High is for anything that stops the customer from working.",
inputSchema: {
title: z.string().describe("The ticket's title"),
body: z.string().describe("What the customer wrote"),
},
outputSchema: {
priority: z.enum(["high", "normal", "low"]),
reason: z.string(),
},
llm: { adapter: model },
})
export class Triage extends AgentContext {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
FrontMCP checks the model's answer against outputSchema as it checks a tool's result. A reply with a priority the help desk doesn't have fails the call with INVALID_OUTPUT, and the text names the field that didn't match, priority. So does a reply that isn't JSON at all, because it arrives as { response }, which has no priority. That's better than handing the client's model a priority nobody uses, but the call still failed, and whoever called it has to try again. Say exactly what you want in systemInstructions, and keep outputSchema as the check.
Changed in 1.8.7: FrontMCP 1.8.5 and 1.8.6 sent this failure as a TOOL_EXECUTION_ERROR with a stack trace in the development text, where 1.8.4 sent INVALID_OUTPUT. In 1.8.7 it's INVALID_OUTPUT again, with only the one line.
Recap
- An agent is a class that extends
AgentContext, decorated with@Agent({ name, description, systemInstructions, inputSchema, llm }), and registered with@App({ agents: [...] }). It needs noexecute(). - A client sees one tool,
invoke_<name>, with the agent'sdescriptionandinputSchema. The system instructions go to the agent's model only, so write a description for the client's model too. llm: { adapter }takes any object withcompletion(prompt, tools). It's called with{ system, messages }and returns{ content, finishReason };"stop"ends the agent's work.- The model is sent the input as JSON, unless the input has a
query,message,promptorinputfield, which is then sent alone. OverridebuildUserMessage(input)to write the message yourself. - A text reply arrives as
{ response }, a JSON object as itself.outputSchemais advertised and checked: a reply that doesn't match fails the call.
Try some challenges
Each challenge runs hidden checks against your code. Edit the code, then press Check.
Challenge 1 of 3
Register the agent, and describe it
The triage agent is written, but a client connected to this server only finds search_tickets. Register the agent, and give it a description that tells the client's model what it's for: suggesting a priority for a ticket.
import { Agent, AgentContext, App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
import { model } from "./model.example";
@Agent({
name: "triage",
systemInstructions: "You triage tickets for a help desk. Give the ticket a priority, High, Normal or Low, and say why.",
inputSchema: { title: z.string(), body: z.string() },
llm: { adapter: model },
})
class Triage extends AgentContext {}
@Tool({ name: "search_tickets", description: "Search tickets by words in their title", inputSchema: { query: z.string() } })
class SearchTickets extends ToolContext {
async execute({ query }: { query: string }) {
return { tickets: [] };
}
}
@App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] })
class HelpDeskApp {}
@FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] })
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.