Skip to content

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 }).

  • You have a folder of markdown playbooks in the open SKILL.md format 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.

To inspect skills without building a tool at all — including the ones that were rejected — use listSkills, the validate-only sibling.

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(", "))

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(", "))

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.