# Teaching the Model Skills

> How to write a FrontMCP skill with @Skill, how clients find and read skills through the skill:// resources and FrontMCP's skills/search and skills/load requests, where instructions can come from, and when to write a skill instead of an agent or a prompt.

Source: https://frontmcp.dev/learn/teaching-the-model-skills

Some of what a support agent knows isn't a single action but a procedure: to refund a duplicate charge, read the invoice, refund every charge after the first and never the first, then tell the customer how long it takes. The tools for each step already exist. What the client's model lacks is the order and the rules. A **skill** is that procedure, written down: instructions, the tools they use, and when to use them, served by your server for the client's model to read and follow itself. This lesson writes one, and shows how a client finds and reads it.

**You will learn**
- How to write a skill with `@Skill`: description, instructions, tools, parameters and examples
- What a skill adds to your server, and how a client finds and reads it
- How to search for and load skills with FrontMCP's `skills/search` and `skills/load`
- Where a skill's instructions can come from: inline, a file or a URL
- When to write a skill, an agent or a prompt

## Writing a skill

The help desk has the three tools a duplicate-charge refund needs. Here's the procedure that ties them together. Open the **Tests** tab:

```ts refund.skill.ts active
import { Skill, SkillContext } from "@frontmcp/sdk";

@Skill({
  name: "refund-duplicate-charge",
  description: "Refund a customer who was charged more than once for the same invoice, and tell them on their ticket.",
  instructions: `1. Read the invoice with get_invoice. Refund only if it has more than one charge.
2. Refund every charge after the first with refund_charge. Never refund the first charge.
3. Reply on the ticket with reply_to_ticket: say how much was refunded, and that it takes 3 to 5 business days.`,
  tools: [
    "get_invoice",
    { name: "refund_charge", purpose: "Refund one duplicate charge", required: true },
    "reply_to_ticket",
  ],
  parameters: [
    { name: "ticketId", description: "The customer's ticket, like T-2", required: true },
    { name: "invoiceId", description: "The invoice that was charged twice, like INV-7", required: true },
  ],
  examples: [
    {
      scenario: "A customer writes on T-2 that INV-7 was charged twice",
      parameters: { ticketId: "T-2", invoiceId: "INV-7" },
      expectedOutcome: "The second charge is refunded, and T-2 says so",
    },
  ],
})
export class RefundDuplicateCharge extends SkillContext {}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { RefundDuplicateCharge } from "./refund.skill";
import { GetInvoice, RefundCharge, ReplyToTicket } from "./tools";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetInvoice, RefundCharge, ReplyToTicket],
  skills: [RefundDuplicateCharge],
})
class HelpDeskApp {}

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

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

export const refunded: string[] = [];
export const replies: string[] = [];

@Tool({ name: "get_invoice", description: "Get an invoice, with its charges.", inputSchema: { id: z.string() } })
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, charges: [{ id: "ch_1", amount: "49 EUR" }, { id: "ch_2", amount: "49 EUR" }] };
  }
}

@Tool({ name: "refund_charge", description: "Refund one charge.", inputSchema: { chargeId: z.string() } })
export class RefundCharge extends ToolContext {
  async execute({ chargeId }: { chargeId: string }) {
    refunded.push(chargeId);
    return { refunded: chargeId };
  }
}

@Tool({ name: "reply_to_ticket", description: "Reply to the customer on a ticket.", inputSchema: { ticketId: z.string(), message: z.string() } })
export class ReplyToTicket extends ToolContext {
  async execute({ ticketId, message }: { ticketId: string; message: string }) {
    replies.push(`${ticketId}: ${message}`);
    return { sent: true };
  }
}
```

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

test("a skill adds no tools", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t) => t.name);
  expect(names).toEqual(["get_invoice", "refund_charge", "reply_to_ticket"]);
});

test("it adds two resources: the skill's SKILL.md, and an index", async ({ mcp }) => {
  const resources = await mcp.resources.list();
  expect(resources.map((r) => r.uri).sort()).toEqual(["skill://index.json", "skill://refund-duplicate-charge/SKILL.md"]);
  expect(resources.find((r) => r.uri.endsWith("SKILL.md"))).toMatchObject({
    mimeType: "text/markdown",
    annotations: { audience: ["assistant"] },
  });
});

test("SKILL.md is the skill's metadata, then its instructions", async ({ mcp }) => {
  const md = (await mcp.resources.read("skill://refund-duplicate-charge/SKILL.md")).text();
  expect(md).toBe(`---
name: refund-duplicate-charge
description: Refund a customer who was charged more than once for the same invoice, and tell them on their ticket.
tools:
  - get_invoice
  - name: refund_charge
    purpose: Refund one duplicate charge
    required: true
  - reply_to_ticket
parameters:
  - name: ticketId
    description: The customer's ticket, like T-2
    required: true
    type: string
  - name: invoiceId
    description: The invoice that was charged twice, like INV-7
    required: true
    type: string
examples:
  - scenario: A customer writes on T-2 that INV-7 was charged twice
    parameters:
      ticketId: T-2
      invoiceId: INV-7
    expectedOutcome: The second charge is refunded, and T-2 says so
---

1. Read the invoice with get_invoice. Refund only if it has more than one charge.
2. Refund every charge after the first with refund_charge. Never refund the first charge.
3. Reply on the ticket with reply_to_ticket: say how much was refunded, and that it takes 3 to 5 business days.
`);
});
```

A skill is a class, like a tool, with no code of its own:

- **`name`** is its id, in kebab-case: lowercase letters, numbers and hyphens.
- **`description`** is what a model reads when it decides whether this skill fits the task, so say when to use it.
- **`instructions`** are the procedure. Write them for a model that has never seen your help desk: the steps in order, the tool for each, and the rules it mustn't break.
- **`tools`** names the tools the procedure uses. A name is enough; `{ name, purpose, required }` adds why the tool is there and whether the skill works without it.
- **`parameters`** and **`examples`** say what the model needs to know before it starts, and show one case from start to end.
- **`skills: [...]`** on `@App` registers it. **`SkillContext`** is the base class, and the class body stays empty.

The first test is the one to notice: a skill adds no tools. It doesn't *do* anything. FrontMCP serves it as a document, `SKILL.md`: the metadata as YAML, then the instructions. That's the format of the [Agent Skills](https://agentskills.io/specification) standard, so a model that knows skills from elsewhere knows how to read it.

> **Note**
`tools` also takes tool classes, `tools: [GetInvoice]`, or `{ tool: GetInvoice, purpose }`, and FrontMCP lists each by the tool's name. A class can't leave a skill behind when the tool is renamed, the way a name in a string can. The [search and load](#searching-and-loading-skills) example has one. Changed in 1.9: before, a tool class stopped the server from starting.

## How a client finds a skill

A skill reaches the client as [resources](https://frontmcp.dev/learn/exposing-data-with-resources), following the proposed MCP skills extension (SEP-2640): `skill://index.json` lists every skill with its description and address, and `skill://<name>/SKILL.md` is each one. A client that supports skills reads the index, picks a skill by its description, reads its `SKILL.md`, and then its model follows the steps with your tools. This test plays that client:

*[Illustration: How a client uses a skill. The MCP client reads skill://index.json from your server and gets each skill's name and description. It picks refund-duplicate-charge by its description and reads skill://refund-duplicate-charge/SKILL.md, getting the instructions, which it puts in its model's context. The client's model then calls get_invoice, refund_charge and reply_to_ticket on your server. The server only answered reads and tool calls; it never ran the skill.]*
```ts client.test.ts active
import { test, expect } from "@frontmcp/testing";
import { refunded, replies } from "./tools";

test("a client finds the skill, reads it, and follows it", async ({ mcp }) => {
  // 1. Read the index, and pick the skill whose description fits the task.
  const index = (await mcp.resources.read("skill://index.json")).json();
  const skill = index.skills.find((s: { description?: string }) => s.description?.includes("charged more than once"));
  expect(skill).toEqual({
    type: "skill-md",
    name: "refund-duplicate-charge",
    description: "Refund a customer who was charged more than once for the same invoice, and tell them on their ticket.",
    url: "skill://refund-duplicate-charge/SKILL.md",
  });

  // 2. Read its SKILL.md.
  const md = (await mcp.resources.read(skill.url)).text();
  expect(md).toContain("Never refund the first charge.");

  // 3. Follow the steps with the server's tools, as the model would.
  const invoice = (await mcp.tools.call("get_invoice", { id: "INV-7" })).json();
  for (const charge of invoice.charges.slice(1)) {
    await mcp.tools.call("refund_charge", { chargeId: charge.id });
  }
  await mcp.tools.call("reply_to_ticket", { ticketId: "T-2", message: "We refunded the duplicate charge of 49 EUR. It takes 3 to 5 business days." });

  expect(refunded).toEqual(["ch_2"]);
  expect(replies).toHaveLength(1);
});

test("the server says it has skills, and its instructions list them", async ({ mcp }) => {
  const { result } = (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" })) as any;
  expect(result.capabilities.extensions).toEqual({
    "io.modelcontextprotocol/tasks": {},
    "io.modelcontextprotocol/skills": {},
  });
  expect(result.instructions).toBe(
    "Help desk tools.\n\n---\n\nAvailable skills (read the `skill://index.json` resource for each skill's `SKILL.md` URI):\n\n" +
      "- **refund-duplicate-charge**: Refund a customer who was charged more than once for the same invoice, and tell them on their ticket.",
  );
});
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { RefundDuplicateCharge } from "./refund.skill";
import { GetInvoice, RefundCharge, ReplyToTicket } from "./tools";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetInvoice, RefundCharge, ReplyToTicket],
  skills: [RefundDuplicateCharge],
})
class HelpDeskApp {}

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

```ts refund.skill.ts
import { Skill, SkillContext } from "@frontmcp/sdk";

@Skill({
  name: "refund-duplicate-charge",
  description: "Refund a customer who was charged more than once for the same invoice, and tell them on their ticket.",
  instructions: `1. Read the invoice with get_invoice. Refund only if it has more than one charge.
2. Refund every charge after the first with refund_charge. Never refund the first charge.
3. Reply on the ticket with reply_to_ticket: say how much was refunded, and that it takes 3 to 5 business days.`,
  tools: ["get_invoice", { name: "refund_charge", purpose: "Refund one duplicate charge", required: true }, "reply_to_ticket"],
})
export class RefundDuplicateCharge extends SkillContext {}
```

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

export const refunded: string[] = [];
export const replies: string[] = [];

@Tool({ name: "get_invoice", description: "Get an invoice, with its charges.", inputSchema: { id: z.string() } })
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, charges: [{ id: "ch_1", amount: "49 EUR" }, { id: "ch_2", amount: "49 EUR" }] };
  }
}

@Tool({ name: "refund_charge", description: "Refund one charge.", inputSchema: { chargeId: z.string() } })
export class RefundCharge extends ToolContext {
  async execute({ chargeId }: { chargeId: string }) {
    refunded.push(chargeId);
    return { refunded: chargeId };
  }
}

@Tool({ name: "reply_to_ticket", description: "Reply to the customer on a ticket.", inputSchema: { ticketId: z.string(), message: z.string() } })
export class ReplyToTicket extends ToolContext {
  async execute({ ticketId, message }: { ticketId: string; message: string }) {
    replies.push(`${ticketId}: ${message}`);
    return { sent: true };
  }
}
```

The skill never runs. The client's model reads it and does the work, with your tools and its own tokens, and FrontMCP checks each tool call as it would any other: the skill's rules are advice to the model, not limits on the tools. "Never refund the first charge" is only as strong as the model that reads it, so a rule that must hold belongs in `refund_charge` itself.

The second test is how a client learns the server has skills. `server/discover` lists the extension `io.modelcontextprotocol/skills`, and a client that knows it looks for `skill://index.json`. A client that doesn't know it still sees the `SKILL.md` resources in `resources/list`, marked for the assistant. And FrontMCP lists every skill in the server's `instructions`, after your own: its name, its description, and where to read it, so the model knows the skills are there before it reads anything. (Changed in 1.9.3: before, under 2026-07-28, the `instructions` had nothing about skills, and you had to write a sentence there pointing the model at the index.)

## Searching and loading skills

FrontMCP also answers requests of its own for skills: `skills/search` finds skills by the words in a task, and `skills/load` returns skills ready to use. They're JSON-RPC methods, not tools, so they're for clients written to use them, like your own. FrontMCP's docs call them `searchSkills` and `loadSkill` tools; in 1.9.3 they aren't in `tools/list`. Here a second skill names a tool the server doesn't have:

```ts search.test.ts active
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { byClassConfig, strictConfig } from "./other-servers";

test("skills/search finds skills by the words in a task", async ({ mcp }) => {
  const { result } = (await mcp.raw.request({
    jsonrpc: "2.0",
    id: 1,
    method: "skills/search",
    params: { query: "customer was charged twice" },
  })) as any;
  expect(result.skills).toEqual([
    expect.objectContaining({ name: "refund-duplicate-charge", score: expect.any(Number), source: "local" }),
  ]);
  expect(result.guidance).toBe("Found 1 matching skill(s). Use skills/load with skill IDs to load full content.");
});

test("skills/load returns instructions, and which tools are missing", async ({ mcp }) => {
  const { result } = (await mcp.raw.request({
    jsonrpc: "2.0",
    id: 2,
    method: "skills/load",
    params: { skillIds: ["refund-duplicate-charge", "escalate-outage"] },
  })) as any;
  const [refund, outage] = result.skills;
  expect(refund).toMatchObject({ isComplete: true, missingTools: [] });
  expect(refund.instructions).toContain("Never refund the first charge.");
  expect(outage).toMatchObject({
    isComplete: false,
    availableTools: ["get_invoice"],
    missingTools: ["page_on_call"],
  });
  expect(outage.tools).toEqual([
    { name: "get_invoice", available: true, inputSchema: expect.objectContaining({ type: "object" }) },
    { name: "page_on_call", purpose: "Wake the on-call engineer", available: false },
  ]);
  expect(outage.formattedContent).toMatch(/^# Skill: escalate-outage\n/);
  expect(result.summary.combinedWarnings).toEqual([
    'Skill "escalate-outage" references missing tools: page_on_call. Some functionality may be limited.',
  ]);
});

test('`toolValidation: "strict"` stops a server that lacks a tool', async () => {
  await expect(FrontMcpInstance.createDirect(strictConfig)).rejects.toThrow(
    "Skill 'escalate-outage' failed tool validation: missing tools [page_on_call]",
  );
});

test("a tool class in `tools` works like its name", async () => {
  const server = await FrontMcpInstance.createDirect(byClassConfig);
  try {
    const { contents } = await server.readResource("skill://check-invoice/SKILL.md");
    expect(contents[0].text).toContain("tools:\n  - get_invoice\n  - name: refund_charge\n    purpose: Refund one duplicate charge\n");
  } finally {
    await server.dispose();
  }
});
```

```ts skills.ts
import { Skill, SkillContext } from "@frontmcp/sdk";

@Skill({
  name: "refund-duplicate-charge",
  description: "Refund a customer who was charged more than once for the same invoice, and tell them on their ticket.",
  instructions: `1. Read the invoice with get_invoice. Refund only if it has more than one charge.
2. Refund every charge after the first with refund_charge. Never refund the first charge.
3. Reply on the ticket with reply_to_ticket: say how much was refunded, and that it takes 3 to 5 business days.`,
  tools: ["get_invoice", { name: "refund_charge", purpose: "Refund one duplicate charge", required: true }],
})
export class RefundDuplicateCharge extends SkillContext {}

@Skill({
  name: "escalate-outage",
  description: "Escalate a ticket that reports an outage to the on-call engineer.",
  instructions: "1. Check the customer's invoices with get_invoice to see their plan.\n2. Page the on-call engineer with page_on_call.",
  tools: ["get_invoice", { name: "page_on_call", purpose: "Wake the on-call engineer", required: true }],
})
export class EscalateOutage extends SkillContext {}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { EscalateOutage, RefundDuplicateCharge } from "./skills";
import { GetInvoice, RefundCharge } from "./tools";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetInvoice, RefundCharge],
  skills: [RefundDuplicateCharge, EscalateOutage],
})
class HelpDeskApp {}

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

```ts other-servers.ts
import { App, Skill, SkillContext } from "@frontmcp/sdk";
import { GetInvoice, RefundCharge } from "./tools";

@Skill({
  name: "escalate-outage",
  description: "Escalate a ticket that reports an outage to the on-call engineer.",
  instructions: "1. Check the customer's invoices with get_invoice to see their plan.\n2. Page the on-call engineer with page_on_call.",
  tools: ["get_invoice", "page_on_call"],
  toolValidation: "strict",
})
class StrictEscalateOutage extends SkillContext {}

@App({ id: "strict", name: "Strict", tools: [GetInvoice], skills: [StrictEscalateOutage] })
class StrictApp {}

export const strictConfig = { info: { name: "strict", version: "1.0.0" }, apps: [StrictApp] };

@Skill({
  name: "check-invoice",
  description: "Check an invoice for duplicate charges, and refund them.",
  instructions: "Read the invoice with get_invoice. Refund every charge after the first with refund_charge.",
  tools: [GetInvoice, { tool: RefundCharge, purpose: "Refund one duplicate charge" }],
})
class CheckInvoice extends SkillContext {}

@App({ id: "by-class", name: "By class", tools: [GetInvoice, RefundCharge], skills: [CheckInvoice] })
class ByClassApp {}

export const byClassConfig = { info: { name: "by-class", version: "1.0.0" }, apps: [ByClassApp] };
```

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

@Tool({ name: "get_invoice", description: "Get an invoice, with its charges.", inputSchema: { id: z.string() } })
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, charges: [{ id: "ch_1", amount: "49 EUR" }, { id: "ch_2", amount: "49 EUR" }] };
  }
}

@Tool({ name: "refund_charge", description: "Refund one charge.", inputSchema: { chargeId: z.string() } })
export class RefundCharge extends ToolContext {
  async execute({ chargeId }: { chargeId: string }) {
    return { refunded: chargeId };
  }
}
```

`skills/load` answers with more than `SKILL.md`: each tool the skill names, with `available` and, if it's there, its input schema; `availableTools` and `missingTools`; `isComplete`, false when a named tool is missing; and `formattedContent`, all of it as one markdown document a client can hand its model. A skill that names a missing tool is still served and still loads, and the server starts anyway, so check `missingTools` in your tests, as the [first challenge](#try-some-challenges) does. Or set `toolValidation: "strict"` on the skill: then a missing tool stops the server from starting, as the third test shows. (Changed in 1.9: before, `"strict"` didn't stop it.) The last test names the tools by class.

## Where instructions come from

Instructions can be written inline, as so far, or kept in a markdown file next to the code: `{ file: "./refund.md" }`, relative to the file that declares the skill. Long procedures are easier to write and review as markdown. The Playground has no files, so here's what it looks like on a real server:

```ts refund.skill.ts
@Skill({
  name: "refund-duplicate-charge",
  description: "Refund a customer who was charged more than once for the same invoice, and tell them on their ticket.",
  instructions: { file: "./refund-duplicate-charge.md" },
  tools: ["get_invoice", "refund_charge", "reply_to_ticket"],
})
export class RefundDuplicateCharge extends SkillContext {}
```

The third source is a URL, for procedures that live somewhere else, like the support team's handbook. `docs.example.ts` stands in for that site, as `billing.example.ts` does for billing in [Calling Other Services](https://frontmcp.dev/learn/calling-other-services):

```ts refund.skill.ts active
import { Skill, SkillContext } from "@frontmcp/sdk";

@Skill({
  name: "refund-duplicate-charge",
  description: "Refund a customer who was charged more than once for the same invoice, and tell them on their ticket.",
  instructions: { url: "https://handbook.help-desk.example/refunds/duplicate-charge.md" },
  tools: ["get_invoice", "refund_charge", "reply_to_ticket"],
})
export class RefundDuplicateCharge extends SkillContext {}
```

```ts docs.example.ts
// Stands in for the support handbook at https://handbook.help-desk.example, which the
// Playground can't reach. It answers requests for that host in-process, records them,
// and passes everything else to the real fetch. A real server doesn't need this file.

/** Every handbook page that was fetched, oldest first. */
export const fetched: string[] = [];

const pages: Record<string, string> = {
  "/refunds/duplicate-charge.md": "1. Read the invoice with get_invoice.\n2. Refund every charge after the first with refund_charge.\n3. Tell the customer with reply_to_ticket.\n",
};

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  if (url.host !== "handbook.help-desk.example") return realFetch(input, init);
  fetched.push(url.pathname);
  const page = pages[url.pathname];
  return page ? new Response(page, { headers: { "content-type": "text/markdown" } }) : new Response("Not found", { status: 404 });
};
```

```ts main.ts
import "./docs.example";
import { App, FrontMcp } from "@frontmcp/sdk";
import { RefundDuplicateCharge } from "./refund.skill";

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

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

```ts url.test.ts
import { test, expect } from "@frontmcp/testing";
import { fetched } from "./docs.example";

test("the page is fetched when the server starts, and then kept", async ({ mcp }) => {
  expect(fetched).toEqual(["/refunds/duplicate-charge.md"]);
  const md = (await mcp.resources.read("skill://refund-duplicate-charge/SKILL.md")).text();
  expect(md).toContain("2. Refund every charge after the first with refund_charge.");
  await mcp.resources.read("skill://refund-duplicate-charge/SKILL.md");
  await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "skills/load", params: { skillIds: ["refund-duplicate-charge"] } });
  expect(fetched).toEqual(["/refunds/duplicate-charge.md"]);
});
```

FrontMCP fetches the page when the server starts, and then keeps it: three reads, one fetch. If the handbook is down then, the server still starts: it logs `Failed to load skill refund-duplicate-charge: …` and tries again the next time it needs the page. A page that changes later isn't picked up until the server restarts. A file is also read at startup; a missing one is logged then, and reading the skill fails with `ENOENT: no such file or directory`.

## Skill, agent or prompt?

A skill, an [agent](https://frontmcp.dev/learn/your-first-agent) and a [prompt](https://frontmcp.dev/learn/reusable-prompts) all hand a model some instructions. What differs is whose model follows them, and who picks them:

| | Skill | Agent | Prompt |
| --- | --- | --- | --- |
| The client sees | Resources, `skill://…/SKILL.md` | A tool, `invoke_<name>` | An entry in `prompts/list` |
| Who picks it | The client's model, from its description | The client's model, like any tool | The user, from a menu |
| Who does the work | The client's model, with your tools | Your server's model, with the agent's own tools | The client's model |
| Who pays for the model | The client | You | The client |
| Use it when | The client's model can do the job if it knows the steps | The job needs its own model, tools the client shouldn't have, or should run the same way for every client | The user starts a task and fills in a form |

The same procedure can be served both ways: the refund steps as a skill for clients whose model should do the work, and as an agent's `systemInstructions` for clients that would rather hand it off. Put the text in one constant, and give it to both.

## Recap

- `@Skill({ name, description, instructions, tools, parameters, examples })` writes a procedure down, and `@App({ skills })` registers it. A skill adds no tools and runs no code: the client's model reads it and does the work.
- FrontMCP serves each skill as `skill://<name>/SKILL.md`, its metadata as YAML and then its instructions, and lists them all in `skill://index.json`. `server/discover` lists the extension `io.modelcontextprotocol/skills`, and FrontMCP adds a list of the skills to the server's `instructions`.
- `skills/search` and `skills/load` are FrontMCP's own JSON-RPC requests, not tools. `skills/load` says which of a skill's tools are missing; a skill with missing tools still loads.
- Name a skill's tools as strings, as `{ name, purpose, required }`, or by class (since 1.9). `toolValidation: "strict"` stops the server from starting when one is missing.
- Instructions can be inline, `{ file }` or `{ url }`. Files and URLs are read when the server starts (a failure is logged, not fatal), and a URL is fetched once.
- Write a skill when the client's model can do the job once it knows the steps, an agent when your server's model should do it, and a prompt when the user starts it.

## Try some challenges

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

### Challenge: Point the skill at the right tools
`close-solved-ticket` loads, but `skills/load` says it's incomplete: one of the tools it names doesn't exist on the server. Find it, and fix the skill so every tool it names is available.

```ts close.skill.ts active
import { Skill, SkillContext } from "@frontmcp/sdk";

@Skill({
  name: "close-solved-ticket",
  description: "Close a ticket the customer confirmed is solved, and thank them.",
  instructions: "1. Read the ticket with get_ticket and check the customer said it's solved.\n2. Thank them with reply_to_ticket.\n3. Close it with close_ticket.",
  tools: ["get_ticket", "reply_to_ticket", "close_tickets"],
})
export class CloseSolvedTicket extends SkillContext {}
```

```ts close.skill.ts solution
import { Skill, SkillContext } from "@frontmcp/sdk";

@Skill({
  name: "close-solved-ticket",
  description: "Close a ticket the customer confirmed is solved, and thank them.",
  instructions: "1. Read the ticket with get_ticket and check the customer said it's solved.\n2. Thank them with reply_to_ticket.\n3. Close it with close_ticket.",
  tools: ["get_ticket", "reply_to_ticket", "close_ticket"],
})
export class CloseSolvedTicket extends SkillContext {}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { CloseSolvedTicket } from "./close.skill";
import { CloseTicket, GetTicket, ReplyToTicket } from "./tools";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetTicket, ReplyToTicket, CloseTicket],
  skills: [CloseSolvedTicket],
})
class HelpDeskApp {}

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

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

@Tool({ name: "get_ticket", description: "Get a ticket.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, text: "Thanks, it works again!" };
  }
}

@Tool({ name: "reply_to_ticket", description: "Reply to the customer on a ticket.", inputSchema: { ticketId: z.string(), message: z.string() } })
export class ReplyToTicket extends ToolContext {
  async execute() {
    return { sent: true };
  }
}

@Tool({ name: "close_ticket", description: "Close a ticket.", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "closed" };
  }
}
```

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

const load = async (mcp: any) =>
  (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "skills/load", params: { skillIds: ["close-solved-ticket"] } })).result.skills[0];

test("the skill is complete: no tool is missing", async ({ mcp }) => {
  expect(await load(mcp)).toMatchObject({ isComplete: true, missingTools: [] });
});

test("it names get_ticket, reply_to_ticket and close_ticket", async ({ mcp }) => {
  expect((await load(mcp)).availableTools).toEqual(["get_ticket", "reply_to_ticket", "close_ticket"]);
});
```

**Hint:**
Compare the names in the skill's `tools` with the `name` of each tool in `tools.ts`. `skills/load` would name the missing one in `missingTools`.

**Solution:**
The skill named `close_tickets`, and the tool is `close_ticket`. FrontMCP serves the skill anyway, so a typo like this reaches clients unless something checks `missingTools`, or the skill sets `toolValidation: "strict"`. A test like the hidden one catches it the moment a tool is renamed.

### Challenge: Say what the skill needs
A model that loads `close-solved-ticket` has to guess which ticket to close. Add a required parameter `ticketId`, described as `The ticket to close, like T-2`, and an example: the scenario `The customer on T-2 writes that it works again`, with `ticketId` `T-2`, and the expected outcome `T-2 is thanked and closed`.

```ts close.skill.ts active
import { Skill, SkillContext } from "@frontmcp/sdk";

@Skill({
  name: "close-solved-ticket",
  description: "Close a ticket the customer confirmed is solved, and thank them.",
  instructions: "1. Read the ticket with get_ticket and check the customer said it's solved.\n2. Thank them with reply_to_ticket.\n3. Close it with close_ticket.",
  tools: ["get_ticket", "reply_to_ticket", "close_ticket"],
})
export class CloseSolvedTicket extends SkillContext {}
```

```ts close.skill.ts solution
import { Skill, SkillContext } from "@frontmcp/sdk";

@Skill({
  name: "close-solved-ticket",
  description: "Close a ticket the customer confirmed is solved, and thank them.",
  instructions: "1. Read the ticket with get_ticket and check the customer said it's solved.\n2. Thank them with reply_to_ticket.\n3. Close it with close_ticket.",
  tools: ["get_ticket", "reply_to_ticket", "close_ticket"],
  parameters: [{ name: "ticketId", description: "The ticket to close, like T-2", required: true }],
  examples: [
    {
      scenario: "The customer on T-2 writes that it works again",
      parameters: { ticketId: "T-2" },
      expectedOutcome: "T-2 is thanked and closed",
    },
  ],
})
export class CloseSolvedTicket extends SkillContext {}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { CloseSolvedTicket } from "./close.skill";

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

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

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

const read = async (mcp: any) => (await mcp.resources.read("skill://close-solved-ticket/SKILL.md")).text();

test("SKILL.md lists `ticketId` as a required parameter", async ({ mcp }) => {
  expect(await read(mcp)).toContain(`parameters:
  - name: ticketId
    description: The ticket to close, like T-2
    required: true
    type: string`);
});

test("SKILL.md has the example", async ({ mcp }) => {
  expect(await read(mcp)).toContain(`examples:
  - scenario: The customer on T-2 writes that it works again
    parameters:
      ticketId: T-2
    expectedOutcome: T-2 is thanked and closed`);
});
```

**Hint:**
Both are arrays on `@Skill`. A parameter is `{ name, description, required }`; an example is `{ scenario, parameters, expectedOutcome }`.

**Solution:**
`parameters` and `examples` go into the front matter of `SKILL.md`, which is what a client's model reads before it starts. A parameter's `type` defaults to `string`, which is why the check expects `type: string` though the solution doesn't set it. The example's `parameters` are the values for that case, so a model sees what a real `ticketId` looks like.
