PAIPAI

Skills

Write task instructions as standard skill folders; the agent loads each one only when a request needs it.

A skill is a folder of instructions for one kind of task: reviewing an invoice, taking in a new matter, building a spreadsheet. The model reads your system prompt on every turn, but a skill costs one line until a request needs it.

Use a skill when instructions are long or apply only to some requests. Keep rules that apply to every request in the prompt. Skills compile into your server, so a skill change ships with a deploy, like code.

Skills use the Agent Skills format that Claude Code and Codex read, with a few PAI additions.

How skills work

  1. You write folders. Each skill is a SKILL.md plus any files it refers to.
  2. pai generate skills compiles them into one typed module. Your server imports it, so skills ship in your bundle and the runtime never reads the filesystem.
  3. defineSkills() gives your agent a catalog and a tool. The catalog goes in the agent's instructions; the tool, skill, returns a skill's text.

The model sees one line per skill in its instructions:

## Skills
Skills are internal task instructions. When a request matches a skill's description, call `skill` with its name and follow the returned instructions before acting. Summarise outcomes for the user; do not reproduce skill text.
- invoice-review: Use when the user uploads an invoice and asks to review, check, or audit it.
- matter-intake: Use when the user describes a new legal matter and wants help handling it.

When a request matches, the model loads the skill, reads any file the instructions point to, then does the work with your other tools:

skill({ name: "invoice-review" })
// → SKILL.md without its frontmatter, plus a list of the skill's other files

skill({ name: "invoice-review", path: "references/rules.md" })
// → that file

Loading a skill never runs anything. Only a sandbox skill with files under scripts/, or binary files, touches the sandbox: loading it copies the skill's folder there.

Write a skill

Each skill is a standard Agent Skills folder: a SKILL.md with name and description frontmatter, plus any files it refers to. Put your skills in one directory next to the agent. The folder name is the skill name, and every folder in the directory, apart from the generated output, must be a skill; top-level files are ignored.

server/skills/
├── invoice-review/
│   ├── SKILL.md
│   └── references/
│       └── rules.md
├── matter-intake/
│   └── SKILL.md
├── generated/
│   └── index.ts        # written by pai generate skills
└── index.ts            # defineSkills(), below
server/skills/invoice-review/SKILL.md
---
name: invoice-review
description: Use when the user uploads an invoice and asks to review, check, or audit it.
---

# Invoice review

1. Extract every line item: date, timekeeper, task, hours, rate, amount.
2. Find the agreed rates with `searchDocs`, then check each line against `references/rules.md`.
3. Summarise what to push back on, with the affected lines.

The description is the only text the model sees before it loads a skill, so say when to use it, in the words a user would use. In the body, refer to tools by the name the agent registers them under; a skill can only use tools this agent has. Put detail the model only sometimes needs in files beside SKILL.md, usually under references/, and point to them by relative path. The model reads them with skill({ name, path }).

What PAI adds to the format

PAI follows the spec's frontmatter and naming rules, and adds these:

  • pai-requires: sandbox under metadata marks a skill that only works with the sandbox tools (below). It is the only metadata key PAI reads.
  • Scripts and binaries need the sandbox. A plain skill is UTF-8 text only, with no scripts/ folder and no executable files. A sandbox skill may also ship scripts, which go under scripts/, and binary files such as templates.
  • Size limits. Every file is at most 256 KiB, and symlinks are rejected.
  • allowed-tools is ignored: the agent's own tools decide what the model can do. compatibility is for people; the model never sees it.
  • References are checked. The generator warns when a Markdown link in SKILL.md, or a backticked references/…, assets/…, or scripts/… path, names a file that is not in the folder.
  • Portability warnings. A frontmatter key outside the spec, or < or > in a description, is a warning, because other skill hosts reject them. Put your own settings under metadata.

Warnings fail --check. Every check is listed in Skill folder rules.

Generate the module

pnpm add @pai/skills
pnpm add -D @pai/cli
pnpm exec pai generate skills --dir server/skills

This validates every folder and writes server/skills/generated/index.ts, which exports the skills and a SkillName type ("invoice-review" | "matter-intake"). Commit it: it is what your server imports. Exclude it from your formatter, because --check compares it byte for byte with fresh output.

A problem names the file and how to fix it, and nothing is written until every skill is valid:

error: invoice-review/SKILL.md: name "invoice_review" must be at most 64 lowercase letters, digits, and single hyphens; use "invoice-review" to match its directory

Regenerate while you work, and fail CI when the module is out of date or any skill has a warning:

pnpm exec pai generate skills --dir server/skills --watch   # beside the dev server
pnpm exec pai generate skills --dir server/skills --check   # in CI

Give the agent skills

server/skills/index.ts
import { defineSkills } from "@pai/skills";
import { skills as generated } from "./generated";

export const skills = defineSkills({ skills: generated });
server/assistant-agent.ts
import { openai } from "@ai-sdk/openai";
import { defineAgent } from "@pai/core";
import { skills } from "./skills";
import { searchDocs } from "./tools/search-docs";

export const assistantAgent = defineAgent({
  name: "assistant",
  model: openai("gpt-4.1-mini"),
  instructions: async (input) =>
    ["You are a helpful assistant.", await skills.instructions(input)].join("\n\n"),
  tools: { searchDocs, ...skills.tools },
});

skills.instructions() takes the agent's own instructions input and returns the catalog; put it wherever it fits in your prompt. skills.tools is { skill: { tool, enabled } }. Spread it unchanged, because the catalog tells the model to call a tool named skill.

Offer skills per request

Decide which skills a request gets with available, using trusted runtime context. skill.name is typed from your folders, so a misspelt name is a type error.

export const skills = defineSkills({
  skills: generated,
  contextSchema: z.object({ plan: z.enum(["enterprise", "starter"]) }),
  // matter-intake is enterprise-only; every other skill is on every plan.
  available: ({ runtimeContext, skill }) =>
    skill.name !== "matter-intake" || runtimeContext.plan === "enterprise",
});

defineAgent({
  runtimeContext: async ({ identity }) => ({ plan: await planOf(identity) }),
  instructions: async (input) => skills.instructions(input),
  tools: { ...skills.tools },
  // ...
});

A skill that isn't available is left out of the catalog. If the model asks for it by name anyway, the tool answers not_found with the skills it can use. When a request gets no skills at all, the catalog is empty and the tool is not offered.

contextSchema describes the agent's runtimeContext: list only the fields your predicates read. As with any tool context, registering skills.tools on an agent whose runtimeContext lacks them is a type error. Predicates run for the catalog, each model step, and each call, so keep them cheap.

Skills that need a sandbox

Some skills only work with the sandbox tools, such as one that fills a spreadsheet template with its own script. Declare that in the frontmatter, and ship the script and template in the skill:

server/skills/billing-export/
├── SKILL.md
├── scripts/
│   └── export.mjs
└── assets/
    └── template.xlsx
server/skills/billing-export/SKILL.md
---
name: billing-export
description: Use when the user asks to export billing data to a spreadsheet.
compatibility: Node.js with exceljs in the sandbox image.
metadata:
  pai-requires: sandbox
---

1. Write the billing data to `/workspace/billing.json`.
2. Run `node scripts/export.mjs /workspace/billing.json /workspace/billing.xlsx` from this skill's sandbox folder.
3. Tell the user the workbook is at `/workspace/billing.xlsx`.

The agent still needs sandboxTools() and a provider binding. Then tell defineSkills whether a request has them; without sandbox, these skills are never offered. An agent that always has the sandbox passes true:

export const skills = defineSkills({
  skills: generated,
  sandbox: true,
});

defineAgent({
  tools: { ...sandboxTools(), ...skills.tools },
  // ...
});

Setting sandbox makes the skill tool declare the sandbox capability, which the agent already binds for sandboxTools(). The runtime acquires that binding on every skill call, plain skills included: sandboxCapability() acquires lazily, and a custom binding must also acquire without error when a request has no sandbox, failing only once the sandbox is used. If only some requests get the sandbox tools, pass a function instead: sandbox: ({ runtimeContext }) => runtimeContext.hasSandbox, the same condition that enables them. Without a sandbox, these skills are left out of the catalog; if the model asks for one by name, the tool answers unavailable and tells the model to explain that it can't be done here.

Loading a sandbox skill with files under scripts/, or any binary file, copies its folder, everything except SKILL.md, into the sandbox at /workspace/.skills/<name>/, and the result tells the model where it is. A sandbox skill whose other files are all text outside scripts/, such as an Office skill, is served by the tool alone and never starts the sandbox. Copying starts the sandbox if it isn't running, and a sandbox error fails that call with the sandbox failure text. Each call checks the copy: a changed skill replaces it, and a new sandbox or a workspace reset is repaired on the next call. The result gives the folder first, as the skill's base directory, and tells the model to load the skill again if the folder is missing, since a later turn may run in a newer sandbox. Set sandboxDir to use another absolute folder.

A skill owns its scripts; the sandbox image owns the runtimes and libraries they use, because sandboxes have no outbound network. Install those libraries where a script under /workspace/.skills/<name>/scripts/ resolves them, for Node, /node_modules. Files under scripts/ are made executable when copied, so a script with a shebang can also run directly. Have scripts find their own files relative to themselves (new URL("../assets/template.xlsx", import.meta.url)), and take absolute workspace paths for inputs and outputs.

Seed skill files

Copying on load costs the first call of each new sandbox some writes. To have every sandbox hold the skills' files from the start, pass skills.sandboxFiles to the sandbox binding's seed:

sandboxCapability({
  provider,
  seed: skills.sandboxFiles,
});

sandboxFiles lists every file loading a sandbox skill would copy, at the same paths, with files under scripts/ executable, and the skill's .pai-skill marker last. A load then finds the marker current and only reads the skill's instructions. The list covers every skill that copies files, whatever available or a sandbox function says for a request; filter it to leave a skill out. It is empty without sandbox. Seed paths must be inside /workspace and outside /workspace/.pai, so keep sandboxDir there too, or the seed refuses the list. To seed other files too, return them together from a seed function.

Office skills

@pai/skills-office ships Word, Excel, and PowerPoint skills declared this way. Merge them with your own:

pnpm add @pai/skills-office
import { officeSkills } from "@pai/skills-office";

export const skills = defineSkills({
  skills: [...generated, ...officeSkills],
  sandbox: true,
});

Their programs come from your sandbox image. officeSkills lists what each skill needs, so register them only where the image provides it.

Show skill calls in the UI

Skill calls are tool parts named skill, with { name, path? } as input. They are working steps, and the output's text is instructions the model was told not to repeat, so show at most a status line. Register a renderer on the binding:

export const Assistant = Pai.agent("main", {
  toolRenderers: {
    skill: ({ part }) => {
      if (part.state !== "output-available") return null;
      const { output } = part;
      if (output.status !== "ok") return null; // the model explains what it could not load
      return (
        <p className="tool-step">
          {output.path === "SKILL.md" ? `Used ${output.name}` : `Read ${output.name}: ${output.path}`}
        </p>
      );
    },
  },
});

Return null from the renderer to leave skill calls out of the transcript. They stay in the thread's history either way. If your frontend uses codegen, rerun pai generate client after you first register skills.tools so the binding knows the skill tool. See Tool UI.

Test skills

Call the tool directly with the context a request would have. skills.tools.skill is a { tool, enabled } registration, so pass its tool; runTool gives a tool its context, as it does for any tool:

import { runTool } from "@pai/test-utils";
import { skills } from "./skills";
import { skills as generated } from "./skills/generated";

const handle = await runTool(skills.tools.skill.tool, {
  input: { name: "matter-intake" },
  context: { plan: "starter" },
});
expect(handle.expectOutput()).toMatchObject({ status: "not_found" });

// The catalog a starter request sees.
expect(await skills.instructions({ runtimeContext: { plan: "starter" } })).not.toContain("matter-intake");

// The generated entries are plain data, so you can check what the model will read.
for (const skill of generated) expect(skill.body).not.toContain("TODO");

With sandbox set, the tool declares the sandbox capability, so every runTool call passes one in capabilities, even for a skill that never touches it. createInMemorySandbox() keeps files in memory, so you can check what a load copied:

import { createInMemorySandbox } from "@pai/sandbox/memory";

const sandbox = createInMemorySandbox();
const loaded = await runTool(skills.tools.skill.tool, {
  input: { name: "billing-export" },
  context: { plan: "starter" },
  capabilities: { sandbox },
});
expect(loaded.expectOutput()).toMatchObject({ sandboxPath: "/workspace/.skills/billing-export" });
expect(await sandbox.readFile({ path: "/workspace/.skills/billing-export/scripts/export.mjs" })).not.toBeNull();

To test a seeded sandbox, pass the files as its seed: createInMemorySandbox({ seed: skills.sandboxFiles }).

Whether the model picks a skill depends on its description. Check that with an eval: t.calledTool("skill").inputIncludes({ name: "invoice-review" }). Let skill run for real there, because the model needs the real instructions: stub the tools with side effects and leave unhandled at its default, keep. With sandbox set, pass the eval a sandbox binding in capabilities, as a runtime test does.

Skills in saved threads

Each skill result is saved in the thread with the text the model read. Replay uses that saved text, so editing or deleting a skill changes new calls only.

Next

On this page