# 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 |
| `<workroot>/.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
Relative paths below resolve against that directory.
<body>
```
`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 /<name> 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.
- `/<slug>`, 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.