Triage Agent
Every new ticket at a help desk needs the same few decisions before anyone works on it: how urgent is it, which team should have it, and is it about something the customer already reported? This example is a help desk server that makes them with an agent. A client calls one tool, invoke_triage, with a ticket's id; the agent's model reads the ticket, looks up the customer and their open tickets, sets the priority, assigns the ticket, and answers with what it decided. A skill tells the client's model how to use the agent to triage a whole queue.
You will learn
- How to split work between the client's model and an agent, and which tools each one gets
- How an agent's tools share data with the app's tools through the app's providers
- How to return a result the client can rely on with
outputSchema, and refuse bad calls before the model runs - How a scripted model stands in for a real one, and what the real model is sent at each turn
- How a skill can drive an agent, and how to test all of it
The server
Here is the whole server. Open the Call tab: invoke_triage has triaged T-1, and says why. Call list_tickets to see T-1 now open, high priority, with the enterprise team. Then open the Tests tab.
import { Agent, AgentContext, PublicMcpError, z } from "@frontmcp/sdk";
import { model } from "./model.example";
import { TicketStore } from "./stores";
import { AssignTicket, GetCustomer, GetTicket, SearchTickets, SetPriority } from "./ticket.tools";
export const TRIAGE_INSTRUCTIONS = `You triage new tickets for a help desk.
1. Read the ticket with get_ticket.
2. Look up the customer with get_customer, and find the customer's open tickets about the same product with search_tickets.
3. Set the priority with set_priority: high if the customer can't work, normal otherwise.
4. Assign the ticket with assign_ticket: billing tickets to billing, enterprise customers to enterprise, everyone else to support.
5. Answer only with JSON: {"ticketId", "priority", "team", "related": [ids of the open tickets you found], "reason": "<one sentence>"}.`;
@Agent({
name: "triage",
description:
"Triage one new support ticket: set its priority, assign it to a team, and find the customer's related open tickets. " +
"Returns what it decided and why. Pass the ticket's id.",
systemInstructions: TRIAGE_INSTRUCTIONS,
inputSchema: { ticketId: z.string().describe("A new ticket's id, like T-1") },
outputSchema: {
ticketId: z.string(),
priority: z.enum(["high", "normal"]),
team: z.enum(["support", "enterprise", "billing"]),
related: z.array(z.string()),
reason: z.string(),
},
tools: [GetTicket, GetCustomer, SearchTickets, SetPriority, AssignTicket],
execution: { maxIterations: 6, timeout: 60_000 },
llm: { adapter: model },
})
export class TriageAgent extends AgentContext {
async execute(input: { ticketId: string }) {
// Refuse what the model can't fix, before paying for a model call.
const ticket = this.get(TicketStore).get(input.ticketId);
if (!ticket) {
this.fail(new PublicMcpError(`There's no ticket ${input.ticketId}.`, "TICKET_NOT_FOUND"));
}
if (ticket.status !== "new") {
this.fail(
new PublicMcpError(
`${ticket.id} is already triaged: ${ticket.priority} priority, with ${ticket.team}.`,
"ALREADY_TRIAGED",
),
);
}
return super.execute(input);
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
In the Call tab, call invoke_triage with T-4: Initech's API fails, so it's high priority, for support. Call it with T-3 and it's refused: that ticket is already open. The Capabilities tab lists the skill's SKILL.md as a resource; read it to see what a client's model would.
How it fits together
- A client calls
invoke_triagewith a ticket's id. The agent'sexecute()refuses tickets that don't exist or were already triaged, then runs the model loop. - The agent's model reads the ticket with
get_ticket. - It looks up the customer with
get_customer, and the customer's open tickets about the same product withsearch_tickets, both in one turn. - It decides, then records the decision with
set_priorityandassign_ticket, again in one turn. Assigning a ticket opens it. - It answers with JSON, which FrontMCP checks against
outputSchemaand returns to the client asstructuredContent. - A client whose model follows the
triage-new-ticketsskill does this for every new ticket: it lists them withlist_tickets, and callsinvoke_triagefor each.
The client's model gets two tools and a skill; the agent's model gets five tools. The client can't set a priority by itself: every change to a ticket goes through the agent, and its instructions.
The files
stores.ts: tickets and customers
Two providers, both GLOBAL, so every tool gets the same instance. TicketStore holds six tickets: four new ones for the agent, one open one that's related to T-1 (same customer, same product), and a closed one. CustomerStore holds three customers, one of them on the enterprise plan. In a real server, these would read your help desk's database.
main.ts: one app
@App({
id: "help-desk",
name: "Help Desk",
tools: [ListTickets],
agents: [TriageAgent],
skills: [TriageNewTickets],
providers: [TicketStore, CustomerStore],
})The app lists one tool, one agent, one skill and the two stores. An agent's tools see the app's providers, as the app's own tools do, so list_tickets and the agent's five tools all get the same stores: what the agent sets, the client reads. See Sharing a provider with the agent's tools. (Changed in 1.9.2: before, an agent's tools only saw the server's providers, so the stores had to go on @FrontMcp, where they still work.) The agent appears to clients as the tool invoke_triage; the skill adds no tool, only resources.
ticket.tools.ts: one tool for the client, five for the agent
list_tickets is in the app's tools, so clients see it. It's read-only, and says so with readOnlyHint. The other five are only in the agent's tools: its model can call them, and nothing else can. That's the split to make when some actions should always follow the same rules, like setting a priority: the client asks the agent, and the agent's instructions decide.
The agent's tools throw a PublicMcpError when an id doesn't exist. Inside the loop, that doesn't fail the call: the model gets {"error":"There's no ticket T-9."} as the tool's result, and can try something else. Giving an Agent Tools shows that happening.
triage.agent.ts: the agent
The options divide the audience:
descriptionis for the client's model: what the agent does, what it returns, and what to pass. It decides from this when to callinvoke_triage.systemInstructionsare for the agent's model: the steps and the rules, and the JSON to answer with.inputSchemais the tool's input. Because it has noqueryfield, the model gets the input as JSON:{"ticketId":"T-1"}.outputSchemamakes the result dependable: the client getsstructuredContentwith these fields, or anINVALID_OUTPUTerror naming the first field that doesn't match, if the model answered anything else. See Returning structured output.executionlimits the loop: six turns where four are enough, and a minute.
execute() runs before the loop. It refuses unknown tickets and tickets that are already triaged, with a PublicMcpError whose code the client can act on. Both checks cost nothing, while asking the model would cost a call. It gets TicketStore with this.get(), like any tool, then calls super.execute(input) for the loop. Adding steps before and after the loop has more.
model.example.ts: the scripted model
const ticket = resultOf(prompt, "get_ticket");
if (!ticket) return callTools(["get_ticket", { id: ticketId }]);FrontMCP calls completion() once per turn, with the whole conversation so far: the user message, then for each turn the model's tool calls and their results. The stand-in looks for the result of each step in the conversation, and asks for the first step that hasn't happened yet. When two calls don't depend on each other, it asks for both in one turn, as real models can; FrontMCP runs them one after the other. The rules it applies, blocked means high and billing goes to billing, are the ones in TRIAGE_INSTRUCTIONS. A real model reads those and applies judgment, which the stand-in can't. Your First Agent looks at the prompt in detail.
triage.skill.ts: using the agent for a whole queue
The skill is for the client's model. It says how to triage everything that's waiting: list the new tickets, call invoke_triage for each, and report. Its tools are the two the client has, so skills/load reports the skill as complete. A skill can name an agent's invoke_ tool like any other tool, because to the client that's what it is.
Why not make the agent triage the whole queue? It could, with list_tickets in its tools. But one ticket per call keeps each call short and its result simple, lets the client show progress between tickets, and leaves the client's model free to stop, or to handle one ticket differently because the user asked.
triage.test.ts
The tests check what a client sees: two tools, the agent's decisions as structuredContent, and the tickets changed afterwards. They also check what the model was sent at each turn, from turns in model.example.ts, the refusals that happen before the model runs, a model that answers in words, and the skill. The last test follows the skill the way a client's model would.
All the tests but the sixth share one server, in order, and triaging a ticket changes it for the tests after it. So the test after the one that triages T-2 checks that triaging it again is refused, and the last test triages whatever is still new, whichever tests ran before it. The sixth starts a server of its own, so the ticket it triages is one the others never see. Testing Your Server covers the test API.
Running it with a real model
Replace the stand-in with a real model, and keep everything else:
@Agent({
// ...
llm: { provider: "openai", model: "gpt-5", apiKey: { env: "OPENAI_API_KEY" } },
})yarn add openai
export OPENAI_API_KEY=sk-...Then delete model.example.ts. The Playground can't run this: it has no network and no key. Connecting a Real Model covers the providers, and what to know about each:
- Answers vary. A real model may ask for tools in another order, skip
search_tickets, or answer in words instead of JSON.outputSchematurns a bad answer into anINVALID_OUTPUTerror instead of a wrong result, with the textTool output validation failed (output does not match outputSchema at ticketId). The tools it called have run by then: the ticket is triaged, and calling again is refused withALREADY_TRIAGED, whose message says what was decided. The sixth test shows both. - Each turn is a model call. This agent takes four, so a queue of 50 tickets is 200 calls.
maxIterations: 6caps a confused model at six. - Each turn takes time, often seconds.
timeout: 60_000fails a call that takes more than a minute, but it doesn't stop the loop: tools the model already asked for still run. Here, a lateset_priorityis harmless. - The key is the server's. Every client's triage runs on your account.
@Agent's ownrateLimitcaps how often clients can run it, so set one before you expose the agent.
Ideas to try
Each of these is a change to the Playground above. Add a test for each.
- Give the agent a
reply_to_customertool, and have it tell the customer of a high-priority ticket that someone is on it. Check that it's only called for high-priority tickets. - Let the client skip the model for tickets you can decide by rule: in
execute(), assign everybillingticket to billing without callingsuper.execute(), and return the same shape as the model would. - Add a
lowpriority for questions, like T-6: in the instructions, theset_prioritytool,outputSchemaand the stand-in's rules. - Make
get_customerfail for C-3, and change the stand-in so that it still assigns the ticket, to support, when the customer can't be found. Check thereasonit gives.