defineSkills()
A per-request skill catalog and one skill tool over generated skill entries.
defineSkills() takes the entries pai generate skills writes and returns a catalog for the agent's instructions and the skill tool that serves them.
import { defineSkills } from "@pai/skills";
import { skills as generated } from "./skills/generated";
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({
// name, model, …
runtimeContext: async ({ identity }) => ({ plan: await planOf(identity) }),
instructions: async (input) => skills.instructions(input),
tools: { ...skills.tools },
});Import
pnpm add @pai/skillsimport { defineSkills, type SkillEntry, type SkillToolOutput } from "@pai/skills";Signature
function defineSkills<TName extends string, TContextSchema extends z.ZodType>(options: {
skills: readonly SkillEntry<TName>[];
contextSchema?: TContextSchema;
available?: (input: { runtimeContext; skill: SkillEntry<TName>; signal? }) => boolean | Promise<boolean>;
sandbox?: true | ((input: { runtimeContext; signal? }) => boolean | Promise<boolean>);
sandboxDir?: string;
}): {
instructions(input: { runtimeContext; signal? }): Promise<string>;
tools: { skill: { tool; enabled } };
sandboxFiles: readonly SandboxSeedFile[];
};Parameters
options: object
skills: readonly SkillEntry[]
The skills export of a generated module. Merge several arrays
freely; duplicate names throw.
name: TName
The frontmatter name, equal to the folder name. The generated
SkillName union types it.
description: string
The frontmatter description with whitespace collapsed: the
skill's catalog line.
requiresSandbox: boolean
True when the frontmatter declares pai-requires: sandbox under
metadata.
body: string
SKILL.md without its frontmatter.
files: readonly SkillsV4File[]
The skill's other files, each an AI SDK SkillsV4File (from
@ai-sdk/provider): { path, data }, where data is
{ type: "text", text }, or { type: "data", data } with base64
for a sandbox skill's binary file.
contextSchema?: z.ZodType
Types the agent's runtimeContext as available, sandbox,
instructions(), and the tool see it. It types the context; it does
not parse it. Describe only the fields the predicates read; the
agent's runtimeContext must include them.
available?: ({ runtimeContext, skill, signal }) => boolean | Promise<boolean>
Whether a skill is offered for this request. Applied to the catalog and again inside the tool, so a skill that isn't offered can't be read by name. Runs for the catalog, each model step, and each call.
sandbox?: true | (({ runtimeContext, signal }) => boolean | Promise<boolean>)
Whether this request has a sandbox: true when the agent always has
one, or a function of the request. Omitting it, or false, gives the
read-only tool; a boolean variable is a type error, so pass
() => flag. Skills with requiresSandbox are offered only when it
is or resolves true, and never when it is omitted. A function runs
only when available offers such a skill.
Setting it makes the skill tool use the sandbox capability: loading
a sandbox skill with a scripts/ folder or binary files copies its
folder into the sandbox. The runtime acquires the binding on every
skill call, so a custom binding must acquire without error on
requests that have no sandbox.
sandboxDir?: string
Sandbox folder for copied skills, at <sandboxDir>/<name>/, each
with a .pai-skill marker recording the copied version. An absolute
folder other than /; defineSkills throws otherwise. The provider
must be able to write it: with GKE keep it under /workspace, or the
first copy fails.
Defaults to "/workspace/.skills".
Returns
A object.
Place the catalog in the agent's instructions and spread the tools into its tools.
instructions: (input) => Promise<string>
The catalog for this request: a ## Skills heading, one line of guidance,
and - name: description for each offered skill. An empty string when no
skill is offered. Takes the agent's instructions input, or any
{ runtimeContext, signal }; runtimeContext is optional when there is no
contextSchema.
tools.skill: { tool, enabled }
The skill tool (id pai.skills.skill), disabled when no skill is offered
for the request. Spread tools into the agent unchanged: the catalog tells
the model to call a tool named skill. With sandbox set, it declares the
sandbox capability.
sandboxFiles: readonly SandboxSeedFile[]
Every file loading a sandbox skill copies, as seed files for the sandbox
binding's seed: each skill's files except
SKILL.md at <sandboxDir>/<name>/, with scripts/ files executable, then
its .pai-skill marker. A load of a seeded skill finds the marker current
and writes nothing. Covers every skill that copies files, whatever
available or a sandbox function says for a request; filter it to leave
one out. Empty without sandbox. Its paths follow sandboxDir, so keep
that inside /workspace and outside /workspace/.pai, or the seed refuses
the list.
Tool results
input: { name: string; path?: string } // path defaults to SKILL.md
output: SkillToolOutput =
| { status: "ok"; name; path; text; files?: string[]; binary?: true; sandboxPath?: string; environmentReset?: true }
| { status: "not_found"; name; path?; available: string[] } // offered skill names, or this skill's files
| { status: "unavailable"; name } // needs a sandbox this request lacksSKILL.md is returned without its frontmatter, with files listing the
skill's other files. A leading ./ in path is ignored; otherwise paths
match exactly. For a sandbox skill with a scripts/ folder or a binary file,
every call first checks its copy in the sandbox, starting the sandbox if
needed, and sandboxPath gives the skill's folder there. environmentReset
reports that the sandbox was recreated, as the sandbox tools do. A binary
file has binary: true and empty text; it is only usable from the sandbox.
The model reads each result as text: the skill wrapped in <skill_content>
with its files listed, a file wrapped in <skill_file>, or a sentence for
not_found and unavailable. Results are saved in the thread, and replay
reads the saved text rather than the skill.