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 insrc/tools/skills.ts, wired intosrc/tools/workspace.ts(TOOLSETS +ToolContext) andsrc/core/dispatch/provision.ts(composePrompt: the progressive-disclosure block) +src/core/dispatcher.ts(the tool context); bundled skills underskills/<slug>/SKILL.md; production stores wired insrc/index.tsandsrc/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 onlyagentsscoping, the pinnedsourcelink and theupstreamprovenance 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)
The seam. The core depends only on the
SkillStoreinterface —list(agent): SkillMeta[](name+description, scoped to that agent) andget(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 askills/dir at startup) andInMemorySkillStore(tests/dev; also the serving engineBundledSkillStorecomposes over its loaded set). The durable DO-backed upload store is PR2, behind this same interface.Skill shape + frontmatter. A
Skillis{ name, description, body, agents, source? }. A bundled skill is aSKILL.mdfile: a----fenced YAML frontmatter block (name,description,agents— a non-empty string array — and optionalsource) followed by the markdown body.parseSkillMarkdownparses it with the repo'syamldependency; 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 parsedbodyis the markdown after the frontmatter.Bundled loading.
loadBundledSkills(dir)reads every<dir>/<slug>/SKILL.md. A directory with noSKILL.mdis skipped; a missingdiryields[](the feature offers no skills rather than crashing the bot); a malformedSKILL.mdthrows with the offending path.Per-agent scoping. Every skill declares the agents it belongs to in frontmatter
agents.list(agent)returns only skills whoseagentsset includes that agent — the review agent's list excludes coding skills and vice-versa.get(name)is scope-agnostic; scope is enforced by theuse_skilltool, which has the caller's agent name.Seeded skills. Review:
code-review-and-quality,security-and-hardening,performance-optimization. Coding:spec-driven-development,incremental-implementation,test-driven-development,doubt-driven-development.Tools (read-only).
list_skillsreturns 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 typedskill_userun event ({ skill, description, agent, source?, upstream?: {repo, commit}, bodyBytes }—upstreamfrom the vendoring provenance of items 9–10) throughToolContext.publishso the load is a first-class fact in the run data, distinct from the generictool_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 thereadonly(review) andfull(coding) toolsets. When no store is on theToolContext(most unit tests), both report themselves unavailable rather than throwing.use_skillrefuses a skill outside the calling agent's scope, and an unknown name, pointing the agent at what it can load.Progressive disclosure. In
dispatch(), when aSkillStoreis onCoreDeps, the dispatcher appends the calling agent's scoped skill name+description list — plus a short instruction to load the relevant one withuse_skillbefore 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 onCoreDepsno 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.Production wiring.
src/index.ts(all channels) andsrc/cli.tsconstruct aBundledSkillStore(DEFAULT_SKILLS_DIR)(./skills, overridable withSWITCHBOARD_SKILLS_DIR) and pass it onCoreDeps. Theskills/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).Vendored through a manifest, never hand-copied. Third-party skills are used, not subsumed:
skills/manifest.yamldeclares each upstream source (a GitHub repo + the ref to follow, pinned to acommitby the sync) and each skill taken from it (name,source, upstreampath, ouragentsscoping).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 listedSKILL.mdat that commit and rewritesskills/<name>/SKILL.mdas: upstream'sname+description, ouragents,source= the upstream file URL at the pinned commit, anupstream: {repo, commit, path}block, and the upstream body unchanged. The sync refuses an upstream whosenamediffers 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.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'supstream.commitequals its source's pinned commit (a manifest bumped without a sync is reported); each file's body digest —sha256(body), recorded by the sync asupstream.bodySha256— matches (a hand-edited body is reported, with no network call); itsagentsequal 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 ownskills/dir, bynpm run skills:checkin CI, and at the end of every sync.Skill.upstreamis parsed from frontmatter (optional; when present all four fields —repo,commit,path,bodySha256— are required) so the provenance is available to the run stream.First-party skills (
local: true). A skill authored in this repo is a manifest entry withlocal: trueandagentsonly — naming asourceorpathon 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 andagentsbut requires noupstreamblock — 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 ofPR_DESCRIPTION_TEMPLATEso it loads on demand (a visibleskill_useevent on every coding run) and lives in one place; the template keeps the**Tour**section and the mandatoryuse_skillload instruction before authoring the Tour steps.
Roadmap (gaps)
[gap]PR2 — user-uploaded skills. A durable DO-backedSkillStore(mirroring the resident/memory remote-plane pattern) plus an upload path, behind the unchangedSkillStoreinterface — the core does not change.- Deferred (later): per-scope (repo/channel) skills; skill versioning;
skill list/skill add/skill removechat commands; skill enable/disable per agent via config.
Validation criteria
| Criterion | Proof |
|---|---|
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) |