Your First Agent

AdvancedMCP 2026-07-28

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

You will learn

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

Writing an agent

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

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, 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. description is for the client's model, which decides when to call the agent. systemInstructions are for the agent's own model, which does the work. inputSchema is a Zod shape, as on a tool.
  • llm says which model the agent runs. Here it's { adapter: model }, an object you write; the lesson Connecting a Real Model replaces it with OpenAI or Anthropic.
  • AgentContext is the base class, and there's no execute(). FrontMCP's own sends the input to the model and returns its answer. You can override it when you need to.
  • agents: [Triage] on @App registers 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:

Open
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 optionWhat the client sees
nameThe tool's name, invoke_ and the name, and its annotations.title.
descriptionThe tool's description.
inputSchemaThe tool's input schema. Input that doesn't match fails with INVALID_INPUT before the model is asked, as for any tool.
systemInstructionsNothing. 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:

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

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

What the model sends back is the reply. completion() returns { content, finishReason }, and finishReason: "stop" means the model is done, so its content is the agent's answer. The other value that matters is "tool_calls", which the next lesson 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:

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

Try some challenges

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

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

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