Skip to content

Skill loading

Agents can list and load skills — reusable methodologies (spec-driven development, code review, TDD, …) — on demand at run time. Rather than baking a methodology into an agent's system prompt, OpenSwitchboard exposes the calling agent's scoped skills as a short name+description list in-prompt (progressive disclosure) and lets the model pull the full instructions into its context only when it needs them, via the use_skill tool.

This is delivered in stages. PR1 (this spec) ships the seam + two stores + the seeded bundled skills + the load path. User-uploaded skills backed by a durable store are tracked below as [gap] (PR2).

  • Code: src/skills/ (types.ts, frontmatter.ts, stores.ts, index.ts), tools in src/tools/skills.ts, wired into src/tools/workspace.ts (TOOLSETS + ToolContext) and src/core/dispatch/provision.ts (composePrompt: the progressive-disclosure block) + src/core/dispatcher.ts (the tool context); bundled skills under skills/<slug>/SKILL.md; production stores wired in src/index.ts and src/cli.ts.
  • Docs: AGENTS.md invariants 2 (≥2 impls, core sees the interface), 4 (namespacing), 7 (no hardcoded models).
  • Attribution: the bundled skills are vendored from addyosmani/agent-skills (MIT) at the commit pinned in skills/manifest.yaml (items 9–10); the frontmatter gains only agents scoping, the pinned source link and the upstream provenance block; the body is upstream's, byte-for-byte.
  • Tests: src/skills/skills.test.ts, src/tools/skills.test.ts, src/core/dispatcher.test.ts.

Behavior (PR1 — seam + load path)

  1. The seam. The core depends only on the SkillStore interface — list(agent): SkillMeta[] (name+description, scoped to that agent) and get(name): Skill | undefined (the full skill incl. body) — never a concrete store (AGENTS.md invariant 2). Two implementations ship (invariant 2): BundledSkillStore (loads seeded skills from a skills/ dir at startup) and InMemorySkillStore (tests/dev; also the serving engine BundledSkillStore composes over its loaded set). The durable DO-backed upload store is PR2, behind this same interface.

  2. Skill shape + frontmatter. A Skill is { name, description, body, agents, source? }. A bundled skill is a SKILL.md file: a ----fenced YAML frontmatter block (name, description, agents — a non-empty string array — and optional source) followed by the markdown body. parseSkillMarkdown parses it with the repo's yaml dependency; a missing frontmatter block or a missing/mistyped required field throws (a malformed bundled skill is a build bug to surface, not silently drop). The parsed body is the markdown after the frontmatter.

  3. Bundled loading. loadBundledSkills(dir) reads every <dir>/<slug>/SKILL.md. A directory with no SKILL.md is skipped; a missing dir yields [] (the feature offers no skills rather than crashing the bot); a malformed SKILL.md throws with the offending path.

  4. Per-agent scoping. Every skill declares the agents it belongs to in frontmatter agents. list(agent) returns only skills whose agents set includes that agent — the review agent's list excludes coding skills and vice-versa. get(name) is scope-agnostic; scope is enforced by the use_skill tool, which has the caller's agent name.

  5. Seeded skills. Review: code-review-and-quality, security-and-hardening, performance-optimization. Coding: spec-driven-development, incremental-implementation, test-driven-development, doubt-driven-development.

  6. Tools (read-only). list_skills returns the calling agent's scoped skills (name + description). use_skill(name) returns that skill's full body as the tool result (→ into the model's context), with the source appended as a footer — and publishes a typed skill_use run event ({ skill, description, agent, source?, upstream?: {repo, commit}, bodyBytes }upstream from the vendoring provenance of items 9–10) through ToolContext.publish so the load is a first-class fact in the run data, distinct from the generic tool_call (see run-visibility.md item 1 for the stream contract and live-view.md item 21 for the run-page row); a refused load publishes nothing. Both add only text to context — they never touch the workspace — so they are in both the readonly (review) and full (coding) toolsets. When no store is on the ToolContext (most unit tests), both report themselves unavailable rather than throwing. use_skill refuses a skill outside the calling agent's scope, and an unknown name, pointing the agent at what it can load.

  7. Progressive disclosure. In dispatch(), when a SkillStore is on CoreDeps, the dispatcher appends the calling agent's scoped skill name+description list — plus a short instruction to load the relevant one with use_skill before working — after the agent's own instructions (it is guidance about the agent's tools, not advisory context like the memory block, which rides on the front). Bodies are never dumped into the prompt — they load on demand. An agent with no scoped skills (general, research) gets no block, and with no store on CoreDeps no block is added at all — the request is unchanged in both cases. Each agent's identity and invariants are preserved: the review agent stays read-only and still defers PR posting to the system, because the block is appended to the unchanged agent prompt.

  8. Production wiring. src/index.ts (all channels) and src/cli.ts construct a BundledSkillStore(DEFAULT_SKILLS_DIR) (./skills, overridable with SWITCHBOARD_SKILLS_DIR) and pass it on CoreDeps. The skills/ dir is copied into the container image (Dockerfile). Bundled skills re-load from disk on restart, so no durable in-memory state is introduced (AGENTS.md invariant 6).

  9. Vendored through a manifest, never hand-copied. Third-party skills are used, not subsumed: skills/manifest.yaml declares each upstream source (a GitHub repo + the ref to follow, pinned to a commit by the sync) and each skill taken from it (name, source, upstream path, our agents scoping). npm run skills:sync (scripts/skills-sync.ts, the only writer of vendored files) resolves every source's ref to a commit, records it in the manifest, fetches each listed SKILL.md at that commit and rewrites skills/<name>/SKILL.md as: upstream's name + description, our agents, source = the upstream file URL at the pinned commit, an upstream: {repo, commit, path} block, and the upstream body unchanged. The sync refuses an upstream whose name differs from the manifest entry (a moved/renamed skill is a manifest edit, not something to paper over) and refuses to render from an unpinned source. Inheriting upstream changes is a sync (the pin moves, bodies update, the diff is reviewed like any PR); adding a third-party skill is one manifest entry + a sync. parseManifest (src/skills/manifest.ts) fails loudly on a skill whose source is undeclared, a duplicate name, a non-GitHub repo, a malformed commit, or a missing field.

  10. The tree provably matches the manifest. checkVendoredSkills(dir) is the offline half: every manifest entry has <name>/SKILL.md; every vendored skill is in the manifest (a hand-copied skill is reported); each file's upstream.commit equals its source's pinned commit (a manifest bumped without a sync is reported); each file's body digestsha256(body), recorded by the sync as upstream.bodySha256 — matches (a hand-edited body is reported, with no network call); its agents equal the manifest's; the directory is named after the skill. Skill names are plain slugs ([a-z0-9-]), since the name is the directory the sync writes into. It returns one message per problem and is run three ways: by the suite against the repo's own skills/ dir, by npm run skills:check in CI, and at the end of every sync. Skill.upstream is parsed from frontmatter (optional; when present all four fields — repo, commit, path, bodySha256 — are required) so the provenance is available to the run stream.

  11. First-party skills (local: true). A skill authored in this repo is a manifest entry with local: true and agents only — naming a source or path on a local entry throws (it is confused about what it is), and a non-local entry still requires both. The sync skips local entries (<name>: local (authored here, not synced)); git history is their integrity, so the check verifies presence, directory naming and agents but requires no upstream block — and reports one if present (a local skill claiming vendored provenance). First local skill: pr-tour (skills/pr-tour/SKILL.md, coding-scoped) — the Tour-writing craft from agent-coding.md item 3, moved out of PR_DESCRIPTION_TEMPLATE so it loads on demand (a visible skill_use event on every coding run) and lives in one place; the template keeps the **Tour** section and the mandatory use_skill load instruction before authoring the Tour steps.

Roadmap (gaps)

  • [gap] PR2 — user-uploaded skills. A durable DO-backed SkillStore (mirroring the resident/memory remote-plane pattern) plus an upload path, behind the unchanged SkillStore interface — the core does not change.
  • Deferred (later): per-scope (repo/channel) skills; skill versioning; skill list/skill add/skill remove chat commands; skill enable/disable per agent via config.

Validation criteria

CriterionProof
parseSkillMarkdown parses name/description/agents/source and strips the frontmatter from the body; source optional[unit] src/skills/skills.test.ts::parseSkillMarkdown
Malformed frontmatter (missing block, missing/empty name/description/agents) throws[unit] src/skills/skills.test.ts::parseSkillMarkdown
InMemorySkillStore.list scopes by agent (review list excludes coding skills and vice-versa); returns name+description only[unit] src/skills/skills.test.ts::InMemorySkillStore scoping
get returns the full skill including its body, scope-agnostically; undefined for unknown[unit] src/skills/skills.test.ts::InMemorySkillStore scoping
BundledSkillStore reads every <slug>/SKILL.md, parses + scopes; missing dir → []; malformed file throws with its path[unit] src/skills/skills.test.ts::BundledSkillStore (loads from a skills/ dir)
skillGuidanceBlock: review's block lists the review skill's description and NOT a coding skill; undefined for an agent with no scoped skills; never includes bodies[unit] src/skills/skills.test.ts::skillGuidanceBlock (progressive disclosure)
list_skills returns the calling agent's scoped metadata; unavailable-graceful with no store[unit] src/tools/skills.test.ts::list_skills tool
use_skill publishes a skill_use event with the skill's metadata on success, nothing on a refused load, and works without a publisher; the event reaches the run stream between the tool's own call and result; the run page renders it as its own row[unit] src/tools/skills.test.ts::use_skill tool::publishes a … event…, ::publishes nothing on a refused load…; src/runner.test.ts::run-visibility events::a tool's ctx.publish reaches onEvent…; src/channels/runTimeline.test.ts::createRunTimeline — skill_use; web/src/pages/runPage.test.ts::RunPage — history mode::update_status renders as a quiet line and a loaded skill as its own row…, web/src/lib/runPageModel.test.ts::calls, groups, folding::a skill lands as its own row inside the step, never a call card. [agent] agent:review <PR URL> on a run where the model loads code-review-and-quality → the run page shows a violet 📚 skill code-review-and-quality row under the use_skill card with the source link and N KB into context; GET /runs/:id/events carries one skill_use event between that call's tool_call and tool_result.
use_skill returns the requested skill's full body into the tool result; refuses out-of-scope and unknown names; unavailable-graceful with no store[unit] src/tools/skills.test.ts::use_skill tool
Both skill tools are in the coding (full) and review (readonly) toolsets, not web/none[unit] src/tools/skills.test.ts::skill toolset wiring
A review run's system prompt gains the review skill list (excluding coding skills); general (no scoped skills) is byte-identical with or without a store; no store → no block[unit] src/core/dispatcher.test.ts::skill loading / progressive disclosure …
The store reaches the tool context: use_skill in a run returns the body into the next model turn[unit] src/core/dispatcher.test.ts::skill loading / progressive disclosure … > passes the store to the tool context…
Review invariants intact under progressive disclosure (read-only; defers PR posting to the system)[unit] src/agents/registry.test.ts::review post-step: prompts defer posting to the system …
Manifest parse: sources + skills resolved; unpinned source allowed; undeclared source, duplicate name, non-GitHub repo, malformed commit, missing field all throw naming the entry[unit] src/skills/manifest.test.ts::parseManifest (4)
Pinned upstream URLs: blob URL for source, raw URL for the fetch[unit] src/skills/manifest.test.ts::upstream URLs
Vendored render: upstream body byte-for-byte; frontmatter = upstream name/description + our agents + pinned source + upstream block; name mismatch and unpinned source refused[unit] src/skills/manifest.test.ts::renderVendoredSkill (3)
Drift check: clean tree → []; missing vendored file, hand-copied skill, stale commit, hand-edited body (digest mismatch, offline), rescoped agents, misnamed directory each reported; trailing-newline-only differences are not drift[unit] src/skills/manifest.test.ts::checkVendoredSkills (the offline drift check) (7)
Skill names must be plain slugs (no path, .., uppercase)[unit] src/skills/manifest.test.ts::parseManifest::rejects a skill name that is not a plain slug…
The repo's skills/ dir matches skills/manifest.yaml (so a hand edit fails the suite and CI)[unit] src/skills/manifest.test.ts::the bundled skills/ dir passes the drift check; CI step npm run skills:check
upstream frontmatter (repo, commit, path, bodySha256) parsed; a partial block throws[unit] src/skills/skills.test.ts::parseSkillMarkdown (upstream cases)
Local entries: parsed with agents only; local + source/path throws; non-local without source/path throws[unit] src/skills/manifest.test.ts::parseManifest::parses a local entry…
Local check: passes with no upstream; missing file, drifted agents, and a present upstream block each reported[unit] src/skills/manifest.test.ts::checkVendoredSkills…::a local skill passes with no upstream block…
The pr-tour skill carries the Tour contract (step shape, anchor rules incl. mechanical verification, embed conditions, catch-all, repush rule, markdown IA) and is a coding-scoped local skill[unit] src/skills/prTourSkill.test.ts (7)
Sync end-to-end (network)[agent] npm run skills:sync on a clean checkout → prints one <source>: <ref> → <sha> line, pr-tour: local (authored here, not synced), and one <skill>: unchanged per vendored skill, ends skills:check ok, git status clean. Then move a source's ref to an older tag or edit one vendored body by hand → npm run skills:check exits 1 naming the file.
PR2 durable user-uploaded skill store[gap] not built (see roadmap)