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, Switchboard 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/toolsets.ts(TOOLSETS),src/tools/runnableTool.ts(ToolContext.skills) 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 third-party 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. The first-partycode-review-and-qualityskill adapts Codex's public review rubric and the candidate-verification method described for Claude Code Review to this review contract. - 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 shared writing rules and 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.The shared writing rules apply to every successful skill load, including local and vendored skills. They require ASD-STE100 Simplified Technical English for all client-authored prose: chat replies, progress updates, PR titles and descriptions, review comments, issue text, commit messages, documentation and reports. Use short, direct sentences, active voice and concrete terms. Preserve technical names, code, identifiers and direct quotations. The official reference is https://www.asd-ste100.org/. These rules are added by
use_skill; the stored skill body and upstream provenance stay intact.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).pr-description(skills/pr-description/SKILL.md, coding-scoped) carries the description-writing craft from agent-coding.md item 3.code-review-and-quality(skills/code-review-and-quality/SKILL.md, review-scoped) carries the high-signal issue-selection method; the system prompt keeps its short publication bar so a missing skill store cannot restore the old report-everything behavior.
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/core/harness/pi/relay.test.ts::runRelayedTool — a native tool run in the bot for pi::runs the tool with the run's context under the call's span — a tracing executor, the span on what it publishes — and answers pi's content; 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 its size; GET /runs/:id/events carries one skill_use event between that call's tool_call and tool_result. |
use_skill adds the shared writing rules to local and vendored skill loads | [unit] src/tools/skills.test.ts::use_skill tool::adds the shared writing rules to local and vendored skill loads |
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-description skill carries the description contract (the map's fields with the schema's caps, the pointer choice rule, anchor rules incl. mechanical verification, never-embed, the fold, repush rule) and is a coding-scoped local skill | [unit] src/skills/prDescriptionSkill.test.ts (8) |
The code-review-and-quality skill is review-scoped and local, with no vendored upstream block; its high-signal policy filters uncertainty and low impact while preserving explicit project rules | [unit] src/skills/manifest.test.ts::the bundled skills/ dir passes the drift check; src/agents/registry.test.ts::review prompts: structured findings through submit_verdict (agent-ship item 6)::all review paths filter uncertain and low-impact observations before submitting findings |
| Sync end-to-end (network) | [agent] npm run skills:sync on a clean checkout → prints one <source>: <ref> → <sha> line, pr-description: local (authored here, not synced), code-review-and-quality: 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) |