.agents/skillsGive 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.
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:
---
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.
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.
skill toolinternal/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.
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.
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.
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.
-no-skills skips discovery entirely: no tool, no system prompt addition, no
extra jail roots. The eval runner sets it, so a task's token counts do not 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.
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.
internal/skills discovery and parsing; no knowledge of tools, agent or tui
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. Malformed skills are
// returned as problems rather than errors; only an unreadable root is an error.
func Discover(home, workroot string) (found []Skill, problems []error, err error)
home and workroot are parameters rather than looked up inside, so tests
point them at t.TempDir().
internal/skills:
description, name disagreeing with the
directory, and a bad slug are each reported as problems, and the other skills
in the same root still loada/b/SKILL.md two levels down is not discoveredinternal/tools:
skill returns the preamble, directory and body; args appends ARGUMENTS:read_file reads a file under a skill dir outside the working rootwrite_file and edit_file still refuse that same pathinternal/agent:
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 /skillsTab 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.