# Skills from `.agents/skills` Give the harness the skill mechanic Claude Code and OpenCode have: a directory of Markdown instruction files the agent can pull in when one is relevant, and the user can force with a slash command. Two ways in, one way to load. The agent calls a `skill` tool; the user types `/slug`, which becomes a user turn the model answers by calling that same tool. Nothing loads a skill body except the tool, so both paths produce identical transcripts and `-p "/slug ..."` works without a second implementation. ## Format A skill is a directory containing `SKILL.md`: ``` ~/.agents/skills/commit-helper/SKILL.md /references/conventions.md /scripts/changelog.sh ``` `SKILL.md` is YAML frontmatter followed by the instructions: ```markdown --- name: commit-helper description: Use when writing commit messages for this repo --- Read the last twenty commits for tone before writing anything. Conventions are in references/conventions.md. ``` `name` and `description` are required. `description` is the only thing the model sees until the skill is invoked, so it carries the whole triggering decision; the body can be any length. Unknown frontmatter keys are ignored rather than rejected, so a skill written for another harness still loads here. The directory name is the slug and the authority. A `name` that disagrees with the directory is a load error naming both, because the alternative is a skill that answers to a name nobody can see in the tree. Slugs match `^[a-z0-9][a-z0-9-]*$`. Anything else is skipped: the slug becomes a slash command and a JSON enum value, and neither wants spaces in it. ## Discovery Two roots, scanned in order, project shadowing personal on a slug collision: | Root | Scope | | --- | --- | | `~/.agents/skills` | every repo this user opens | | `/.agents/skills` | this repo, and checked in with it | One level deep only. `~/.agents/skills/foo/bar/SKILL.md` is not a skill, it is a file inside skill `foo`. Discovery runs once at startup. A skill added while the harness is running is not picked up, because the listing sits in the cached prompt prefix and rebuilding it mid-session would cost a cache miss on every subsequent turn for a file that changes approximately never. A malformed skill is skipped with a notice in the transcript naming the path and the reason. A missing root directory is not an error at all — neither root exists on a fresh machine, and that is the normal case. A root that exists but cannot be read is reported the same way a malformed skill is. Nothing about skills aborts startup; a harness that will not open because of a stray file in `~` is worse than one that runs without the skill it was going to load. ## Loading: the `skill` tool `internal/tools/skill.go` registers one tool: ``` skill(name: string, args?: string) -> string ``` `name` is a JSON enum of the discovered slugs, so a hallucinated skill name is a schema violation the model sees immediately instead of a lookup failure it has to reason about. It returns the body with a preamble naming the skill's directory: ``` Skill: commit-helper Directory: /home/joni/.agents/skills/commit-helper Join relative paths onto the directory above before reading them. ``` The wording matters. `read_file` anchors a relative path to the working root, not to the skill directory, so a preamble promising that relative paths "resolve against that directory" is a lie the model will act on: it asks for `references/conventions.md`, gets a bare "no such file", and has nothing to work back from. The directory is given absolutely and the model is told to join onto it. `read_file`'s not-found error carries the other half. When the tool has extra roots — which it only does when skills were discovered — a miss on a relative path names them, so a model that ignores the preamble still learns where the file it wants actually lives. With no skills there are no extra roots and the message stays exactly as terse as it was. `args`, when present, is appended under an `ARGUMENTS:` line. The tool is read-only and runs unattended like `read_file` — it reads one file from a directory discovered at startup, and cannot reach anything the read tools could not already reach once the jail change below lands. Invoking the same skill twice in a session returns the body twice. Suppressing the repeat would save tokens and cost the model the ability to re-read instructions after compaction has dropped them. ## Telling the model skills exist `agent.Options` grows `Skills []skills.Skill`. When non-empty, `request()` appends to the system prompt: ``` Skills are instruction sets for specific kinds of work. Invoke one with the skill tool when its description matches what you are doing — before starting, not after. A user message beginning with / is a request to invoke that skill; everything after the name is its arguments. Available skills: commit-helper: Use when writing commit messages for this repo ... ``` This is appended, not interpolated, and is constant for the session, so the cached prefix still matches request to request. The reasoning at `internal/agent/loop.go:22` holds: what breaks caching is content that varies per request, not content that varies per session. With no skills discovered, nothing is appended and no tool is registered. A user with no `.agents/skills` anywhere sees a harness identical to today's, down to the token count. ## User invocation `internal/tui/model.go` gains two cases ahead of the unknown-command error at line 448: - `/skills` lists slug, description and source root, or says none were found. - `/`, where the slug is one of the discovered skills, sends the line verbatim as a user turn. An unrecognised slash command still errors exactly as it does now. The check is against the discovered set, so a typo is caught by the harness rather than sent to the model to puzzle over. `/help` gains `/skills` in its command list. The line-based front end needs no change. `/slug` reaches the model as text, the system prompt explains what a leading slash means, and the tool does the rest — so `-p "/commit-helper fix the flaky test"` works for free. ## Path jail `jail()` at `internal/tools/read.go:141` takes one root. Skill bodies reference their own bundled files, and for a personal skill those live under `~`, outside the working root, where the jail correctly refuses them today. `jail` grows a set of extra permitted roots. A path is allowed if it resolves under the working root **or** under any extra root, with the same symlink resolution applied to each so the extra roots cannot be used to step sideways. Which tools get them: | Tool | Extra roots | Why | | --- | --- | --- | | `read_file` | skill dirs | read `references/conventions.md` | | `glob` | skill dirs | find what a skill ships | | `grep` | skill dirs | search it | | `edit_file` | none | a skill is input, not a work product | | `write_file` | none | as above | | `bash` | none | unchanged; `cwd` stays the working root | Skills are readable and never writable. The agent cannot rewrite its own instructions through a tool, which is worth more than the convenience of letting it. The extra roots are the individual skill directories, not the two parent roots. A skill can read its own bundle and its siblings' bundles; it cannot read arbitrary files under `~` that happen to sit next to the skills directory. `resolveBase` is unchanged. Only the jail check learns about extra roots. ## Flags `-no-skills` skips discovery entirely: no tool, no system prompt addition, no extra jail roots. It exists so a developer can measure what skills cost, and as the off switch when a skill is misbehaving. The eval runner needs no flag. It builds its own registry at `evals/runner.go:191` and simply never calls `Discover`, so a task's token counts cannot move because of what happens to be in the developer's home directory. METRICS.md compares runs, and that only means something if the prompt prefix is the same between them. ## Dependency `gopkg.in/yaml.v3` for the frontmatter. Descriptions are prose and routinely use folded (`>-`) and quoted scalars; a two-key line parser mis-reads those and fails by silently not loading the skill, which is the worst available failure mode for this feature. ## Package shape ``` internal/skills discovery and parsing; no knowledge of tools, agent or tui ``` ```go type Skill struct { Name string // slug, from the directory Description string Body string // SKILL.md after the frontmatter Dir string // absolute Source string // "personal" | "project" } // Discover scans the personal and project roots. Every failure is a problem, // never an error: nothing about skills prevents the harness from starting. func Discover(home, workroot string) (found []Skill, problems []error) ``` `home` and `workroot` are parameters rather than looked up inside, so tests point them at `t.TempDir()`. ## Testing `internal/skills`: - a well-formed skill loads: name, description, body, dir, source - project shadows personal on the same slug - missing frontmatter, missing `description`, `name` disagreeing with the directory, and a bad slug are each reported as problems, and the other skills in the same root still load - a folded and a quoted description parse to the same text as a plain one - neither root existing yields no skills, no problems, no error - `a/b/SKILL.md` two levels down is not discovered `internal/tools`: - `skill` returns the preamble, directory and body; `args` appends `ARGUMENTS:` - the schema enumerates exactly the discovered slugs - `read_file` reads a file under a skill dir outside the working root - `write_file` and `edit_file` still refuse that same path - a path outside both the working root and every extra root is still refused, including through a symlink from inside a skill dir `internal/agent`: - the listing is appended to the system prompt when skills are present, and the prompt is byte-identical to today's when they are not - the system prompt is identical across two consecutive requests in a session `internal/tui`: - `/skills` lists them; `/skills` with none found says so - `/commit-helper args` sends a user turn rather than erroring - `/bogus` still errors - `/help` mentions `/skills` ## Out of scope Tab completion for slash commands, `allowed-tools` restrictions per skill, skills invoking other skills, remote or plugin skill sources, and hot reload. Each is a separate change on top of this one, and none is needed to match what Claude Code does with a directory of Markdown files.