loadSkills
JavaScript · package toolnexus · SPEC §3 · js/src/skill.ts
function loadSkills(input: string | string[] | LoadSkillsOptions): SkillSource
interface SkillSource { skills: Record<string, SkillInfo> tool: Tool // exactly one tool, named "skill" prompt(): string // the catalog to paste into your system prompt}Walks a directory for **/SKILL.md, parses each file’s YAML frontmatter, and returns one tool
called skill. That single tool is the whole progressive-disclosure trick: the model sees only a
short catalog of names and descriptions in the system prompt, and pays for a skill’s full
instructions only on the turn it actually calls skill({ name }).
When to use it
Section titled “When to use it”- You have a folder of markdown playbooks in the open
SKILL.mdformat and want the model to use them without stuffing every one into the context window. - You want to add capability to an agent by writing a file, not by shipping code.
- You are wiring skills into a request you build yourself, outside a toolkit.
Why this and not the alternative
Section titled “Why this and not the alternative”To inspect skills without building a tool at all — including the ones that were rejected — use
listSkills, the validate-only sibling.
Examples
Section titled “Examples”1. Load the shared fixture directory
Section titled “1. Load the shared fixture directory”This is examples/skills, the fixture every port is tested against.
import assert from "node:assert"import { loadSkills } from "toolnexus"
const source = loadSkills("examples/skills")
// Keyed by the `name:` in each SKILL.md frontmatter — not by folder name.assert.deepEqual(Object.keys(source.skills), ["hello-world"])
// ONE tool, whatever the skill count. That is the point of progressive disclosure.assert.equal(source.tool.name, "skill")assert.equal(source.tool.source, "skill")assert.deepEqual(source.tool.inputSchema.required, ["name"])
console.log("ok:", Object.keys(source.skills).join(", "))2. The catalog and the on-demand load
Section titled “2. The catalog and the on-demand load”prompt() is what goes into the system prompt; tool.execute is what the model calls afterwards.
Both halves are byte-identical across the six ports.
import assert from "node:assert"import { loadSkills } from "toolnexus"
const source = loadSkills("examples/skills")
// --- what the model sees up front: names + descriptions only ---const catalog = source.prompt()assert.ok(catalog.startsWith("Skills provide specialized instructions and workflows"))assert.ok(catalog.includes("## Available Skills"))assert.ok(catalog.includes("- **hello-world**: A tiny example skill."))// The body is NOT in the catalog — that is the saving.assert.ok(!catalog.includes("# Hello World Skill"))
// --- what it gets when it calls the tool ---const res = await source.tool.execute({ name: "hello-world" })assert.equal(res.isError, false)assert.ok(res.output.startsWith('<skill_content name="hello-world">'))assert.ok(res.output.includes("# Skill: hello-world"))assert.ok(res.output.includes("# Hello World Skill"))// The base directory is a file:// URL so relative paths inside the skill resolve.assert.ok(res.output.includes("Base directory for this skill: file://"))// Sibling files are sampled and listed so the model knows what it may read.assert.ok(res.output.includes("<file>examples/skills/hello-world/scripts/greet.sh</file>"))assert.equal(res.metadata?.name, "hello-world")
// Unknown names fail as a tool error, not a throw — the model can recover.const miss = await source.tool.execute({ name: "nope" })assert.equal(miss.isError, true)assert.ok(miss.output.includes("Available skills: hello-world"))
console.log("ok: catalog", catalog.split("\n").length, "lines | loaded", res.output.length, "chars")3. The full option surface — dirs, data skills, filter, sample cap
Section titled “3. The full option surface — dirs, data skills, filter, sample cap”Passing a LoadSkillsOptions object instead of a path unlocks the rest of §3: skills supplied as
data, a per-agent allowlist, and control over the sampled file list.
import assert from "node:assert"import { loadSkills } from "toolnexus"
const source = loadSkills({ // Directory skills and data skills merge into one namespace, dirs first. dirs: ["examples/skills"], skills: [ { name: "refund-policy", description: "How to process a refund", content: "1. Verify the order.\n2. Refund to the original method.", resources: ["templates/refund-email.md"], }, ], // Allowlist: >=1 true means "only these". Same semantics as the MCP tools filter. filter: { "refund-policy": true, "hello-world": true }, // 0 => default cap of 10 sampled sibling files, n>0 => cap, -1 => omit the block. sampleLimit: 3,})
assert.deepEqual(Object.keys(source.skills).sort(), ["hello-world", "refund-policy"])
// Data skills never touch disk — their base is a logical skill:// URL.const data = await source.tool.execute({ name: "refund-policy" })assert.ok(data.output.includes("Base directory for this skill: skill://refund-policy/"))assert.ok(data.output.includes("<file>templates/refund-email.md</file>"))assert.equal(data.metadata?.dir, "skill://refund-policy/")
// Catalog entries are sorted by name, not discovery order.const lines = source.prompt().split("\n").filter((l) => l.startsWith("- **"))assert.deepEqual(lines.map((l) => l.split("**")[1]), ["hello-world", "refund-policy"])
// Narrowing for a second agent: same sources, nothing left.const narrowed = loadSkills({ dirs: ["examples/skills"], filter: { "hello-world": false } })assert.deepEqual(Object.keys(narrowed.skills), [])
console.log("ok:", Object.keys(source.skills).sort().join(", "))Options
Section titled “Options”loadSkills accepts a path, an array of paths, or this object:
| Option | Type | What it does |
|---|---|---|
dirs |
string | string[] |
Roots to walk for **/SKILL.md. node_modules and .git are skipped; symlinked dirs are followed, cycles guarded. |
skills |
SkillDef[] |
Skills supplied as data — { name, description?, content, resources?, base? }. Never touches disk. |
filter |
Record<string, boolean> |
Per-agent allowlist. Empty/absent ⇒ all; ≥1 true ⇒ only those; only-false ⇒ drop-list. Unknown names warn. |
sampleLimit |
number |
0 ⇒ default cap of 10 sampled sibling files, n>0 ⇒ cap at n, -1 ⇒ omit <skill_files> entirely. |
Returned SkillSource:
| Member | Type | What it is |
|---|---|---|
skills |
Record<string, SkillInfo> |
Post-filter map, keyed by frontmatter name. |
tool |
Tool |
The single skill tool — source: "skill", one required arg name. |
prompt() |
() => string |
Preamble + ## Available Skills, sorted by name. "No skills are currently available." when empty. |
Duplicate names are first-wins: a later SKILL.md (or data def) reusing a taken name is warned
about and dropped.
See also
Section titled “See also”listSkills— inventory and skip reasons, no tool builtloadSkillswith data and filters — the options surface in depthcreateToolkit— takesskillsDirand wires all of thisTool— whatsource.toolis