Writing a skill
Skills are markdown files loaded from disk and selected per turn.
A skill is a markdown document. Fifteen ship in skills/, they are loaded from disk at boot, and the ones that matter are selected per turn and rendered into the employee's prompt. Writing one is the cheapest way to change how the office behaves.
Anatomy
A skill file is a front-matter block, a closing fence, and a markdown body. This is a real one, from skills/code-review.md:
---
id: code-review
name: Code Review
description: "Critique code for correctness, clarity and operability, with specific, actionable findings."
tags: [review, critique, correctness, readability]
taskClasses: [review]
---
# Code Review
A review is not a gate to wave code through; it is the last place a defect can be
caught cheaply. Review for correctness, clarity and operability, in that order.
A finding without a suggested fix is a complaint, not a review.
The fence has to be the very first line of the file, and the parser looks for the next line that is exactly ---. Everything after that is the body. Front matter is parsed by a hand-written reader rather than a YAML library, which decides what syntax is safe to use:
- Lists are written
[a, b, c]on one line. That is the only list syntax there is. - Quotes are stripped if the value starts and ends with a matching
"or'. Use them for any description containing a colon, because the parser splits on the first colon it finds. - Numbers and booleans are inferred:
true,falseand anythingNumber()accepts become non-strings. That is only ever useful forestimatedTokens. - Comments: a line whose first non-space character is
#is skipped. A line with no colon at all is skipped too, silently.
The real front-matter type
Four keys are required and four are optional. The type is SkillFrontmatter in the core package:
| Key | Required | What the engine does with it |
|---|---|---|
id | yes | The stable identity. Candidate lists, role skillIds, workspace skillIds and the tie-break in the scorer are all keyed on it. A file with no id is skipped, with that as the reason. |
name | yes | Display name, and the ## Skill: <name> heading the body is rendered under in the prompt. Also searched as keyword material. |
description | yes | One line for the index and the console. Searched as keyword material, so the words in it are load-bearing. |
tags | no | Loose grouping for humans. Also searched as keyword material. Absent tags parse as an empty list, which is legal — the file still loads. (id, name and description are the three that are genuinely required; a missing one refuses the file with its name.) |
taskClasses | no | The strongest signal available: matching one scores 100 + the keyword overlap. Real values are the thirteen task classes: intake, routing, summarize, planning, research, debate, workshop, design, architecture, coding, review, testing, ops. |
requiresTools | no | Declarative only. Nothing in the loader or the selector reads it; it is documentation for whoever reads the skill next. None of the fifteen shipped files set it. |
estimatedTokens | no | Defaults to ceil(body.length / 4), floored at 1, when the front matter does not supply a finite number. It is recorded; nothing in this build uses it to trim or reject a skill. |
version | no | Carried through onto the loaded skill and otherwise unused. None of the fifteen shipped files set it. |
The fifteen shipped files use only five keys: id, name, description, tags and taskClasses. requiresTools, estimatedTokens and version exist in the type and are honoured by the parser, but nothing in the shipped catalogue exercises them. Treat them as available, not as load-bearing.
How a skill gets into a turn
Skills are not pasted into every prompt. A role's skillIds is an index, not a system prompt: on each turn the engine picks a small number of skills, renders their full bodies under headings, and puts those in the prompt. The selection runs once per turn, against the actual task text. Here is the whole algorithm, in order.
1. The candidate set is an intersection
The engine takes the workspace floor's enabled skillIds and the role's skillIds, and keeps only what is in both. A skill the floor has not enabled is not available to anyone; a skill the floor enabled but the role does not list is not available to that employee. If the intersection comes out empty, the role falls back to the whole enabled list — so a new role with an empty skillIds competes across the entire catalogue rather than getting nothing.
2. The first two candidates are always in
The engine passes the first two ids of that usable list as alwaysIds. They are selected unconditionally, with the reason Assigned to this role by default (Name). and via: 'role-default' — and, importantly, they are not counted against the limit. The real engine call passes limit: 3, so a turn can carry up to five skills: two defaults plus three scored.
3. Everything else is scored
Each remaining candidate is tokenised and compared:
- The task text is the run brief, the stage purpose and the objective, joined by blank lines.
- It is lowercased and split on any run of characters outside
[a-z0-9]; tokens of one character are dropped. - Each surviving token is substring-matched — not word-matched — against the lowercased concatenation of the skill's
name,descriptionandtags. - If the stage's task class appears in the skill's
taskClasses, the score is100 +the number of matched tokens, andviaistask-class. - Otherwise, if anything matched, the score is just the matched-token count, and
viaiskeyword. A skill with no task-class match and no keyword overlap scores nothing and is never selected.
Results are sorted by score descending, then by id ascending for ties, and the top limit are taken. Both the score and a readable reason are attached to each selection, so "why did that skill show up" has an answer in the record.
The task class comes from the stage, not from the text. It is a fixed mapping from the stage kind: intake → intake, plan → planning, research → research, debate → debate, workshop → workshop, design → design, architect → architecture, build → coding, review → review, test → testing, integrate → ops, report → summarize. So taskClasses: [coding] means "on every build stage", and nothing about the words in the brief can change that.
One honesty note on the type: SkillSelection.via is declared as 'role-default' | 'keyword' | 'model-choice' | 'task-class', but the selector never produces model-choice. There is no code path in this build where a model is asked to choose its own skills; the field is reserved for a design that was not implemented. If you see it in a type definition, do not build against it.
A worked example, end to end
Suppose this skill lives at skills/estimation.md:
---
id: estimation
name: Estimation
description: "Give a range, name the assumption it rests on, and say what would change it."
tags: [estimation, planning, uncertainty, ranges]
taskClasses: [planning, intake]
---
# Estimation
An estimate without a range is a wish. Give three numbers: the optimistic case, the
likely case and the pessimistic case, and say which assumption moves the answer
between them.
## Separate the estimate from the commitment
The estimate is what you believe. The commitment is what someone has agreed to.
Do not launder one into the other by sounding confident.
## Anti-patterns
- A single number with no range and no assumption named.
- Padding every estimate by the same percentage "to be safe".
- Revising the estimate to match the deadline you have already been given.
## Say this / not that
- Say: "3 to 6 days; the range is driven by whether the existing schema can be reused."
- Not: "About a week."
estimatedTokens is not declared, so the loader records ceil(body.length / 4) — a few hundred for a body this size. Now assume a run on the product-build pipeline reaches its plan stage, the task class is therefore planning, and the employee is the cto role, whose shipped skillIds are system-design, api-design, code-review, debate-and-critique and technical-writing. Assume the floor has enabled the whole catalogue.
The intersection is those five ids, and the first two — system-design and api-design — are alwaysIds. Both are selected via role-default, and neither counts against the limit. That is a real and slightly surprising consequence: adding a sixth skill to a role's list at the front does not make it available on a turn, it makes it mandatory on every turn.
The remaining three are scored against the task text. Only estimation is not in the role's list at all, so in this example it is never a candidate — which is the point worth internalising: task-class matching cannot rescue a skill the role does not carry. If you want a skill to be reachable by the CTO, add its id to the role. If you want it on nearly every turn of that kind for everyone, put it in the floor's skillIds and in each role's index.
A more realistic variant: the role is frontend-dev-1, with skillIds frontend-implementation, debugging, testing-strategy. The first two are defaults. Add estimation to the floor and to the role and the usable list becomes four, so frontend-implementation and debugging are defaults, and testing-strategy and estimation compete for three slots — both fit, because only two are competing and the limit is three. Selection is generous when the role carries few skills and ruthlessly ordered when it carries many.
What actually makes a skill good
The fifteen shipped skills are worth reading as a set, because they share a shape. What they have in common is not style, it is that each one is a decision procedure.
- It says what to do, not what to value. "Name the failure mode before the opinion" changes output; "be rigorous" does not. Every shipped skill has a section of the second kind of sentence rephrased as the first.
- It gives the shape of the output. The shipped examples almost all end with a checklist or a "say this / not that" pair. If a downstream stage or a human reads the result, tell the model what structure to produce.
- It names the anti-patterns. A rule is easier to apply when the failure it prevents has a name. "A test that always passes proves nothing" is more actionable than "write good tests".
- It is short. A skill competes for prompt budget with the task itself, and it is competing against three other skills. The shipped files are around forty lines; that is not an accident.
- It is front-loaded. The opening paragraph is the part that survives a distracted read, so put the instruction there rather than saving it for the end.
- Its
descriptionandtagscontain the words that will appear in a brief. This is the mechanical reason to care: those fields are what keyword scoring searches. A great body behind a description like "Notes" will lose every tie.
Pick taskClasses deliberately. A match is worth a hundred points, which is more than any realistic keyword overlap can produce — a skill with twenty matching tokens scores twenty, and a task-class match scores at least a hundred. That makes taskClasses the difference between "sometimes selected" and "always selected on those stages". Claiming five classes to be safe is how a skill becomes noise on turns that do not need it.
Where a skill lives and when it is read
The loader runs in the server's boot sequence before the process starts listening: it reads the configured skills directory, creates it if it is missing, takes only files whose name ends in .md (case-insensitively, and only regular files — subdirectories are ignored), parses each one, and sorts the result by id. Skills contributed by plugins are merged into the same catalogue, with sourcePath set to plugin:<plugin id> rather than a path on disk.
An empty skills directory is a supported state: the loader creates it and returns an empty list, and the office boots with a catalogue of zero. A file that cannot be parsed is skipped with its reason and never fatal — the failure is logged as a warning with the file name and the exact problem, a one-line summary follows it, and everything else loads normally. That containment is deliberate and worth understanding: this call happens before listen(), so throwing on a typo in a markdown file would be the difference between a wrong skill and an office that does not start.
Real skip reasons are plain sentences naming the file, for example:
Skill file /srv/dev3d/skills/code-review.md is missing the required frontmatter key "id".
Skill file /srv/dev3d/skills/notes.md is missing the required frontmatter key "name".
Skill file /srv/dev3d/skills/style.md is missing the required frontmatter key "description".
Two failure modes are not reported as skips because they are not failures. A file whose first line is not --- parses as having no front matter at all and then fails on the missing id — so the message you get is about id, not about the fence. A file whose opening fence never closes gets the same treatment and the same misleading message.
The plugin route
If you want to ship a skill to somebody else rather than to your own office, a plugin can carry it inline under contributes.skills, with the body as a JSON string. The fields are the same — id, name, description, tags, optional taskClasses — plus a required non-empty body. The validator drops a skill entry that is missing any of them and reports which index it dropped, so a malformed entry costs you that skill and not the plugin. From the engine's point of view a plugin skill is an ordinary skill in the catalogue and is selected by exactly the algorithm above. Building a plugin covers the rest of the manifest.
What to check if it did not work
| Symptom | Most likely cause | What to do |
|---|---|---|
| The skill is not in the catalogue at all. | The process read the directory before you wrote the file. Skills are loaded once, at boot. | Restart the server. Adding a file underneath a running office changes nothing. |
A warning says the file is missing id, name or description — but you wrote all three. |
The opening --- is not the first line, or the closing --- is missing, so the block was never parsed as front matter. |
Make the fence the literal first line of the file, and give it a matching closing fence on its own line. |
| The file is in the directory but nothing loads it. | Its name does not end in .md, or it is in a subdirectory. |
The loader takes regular files matching *.md at the top level only. Move and rename it. |
| The skill loads, but it is never selected. | It is not in the role's skillIds, or not in the floor's enabled set, so it is not a candidate at all. |
Add it to both. Task-class matching cannot promote a skill that is not a candidate. |
| It is a candidate but still loses. | No taskClasses match on that stage, and too little keyword overlap to beat the competition. |
Add the right task class, and put the words that appear in real briefs into description and tags. |
| It is selected on every single turn of a stage. | It is one of the first two ids in the role's list, so it is an alwaysIds default and the limit does not apply. |
Move it later in the role's skillIds so it has to earn its place on keyword or task class. |
| A description with a colon is truncated after the colon. | The hand-written parser splits on the first : in the line. |
Wrap the whole value in double quotes, as every shipped description does. |
| Selection reasons mention tokens you did not expect. | Matching is substring-based, so state matches statement and release matches released. |
Read the reason on the selection rather than guessing; it lists the matched tokens verbatim. |
You are looking for via: 'model-choice' in the record. |
It exists in the type and is never produced by the selector. | Ignore it. Only role-default, task-class and keyword occur in practice. |
Changing estimatedTokens changed nothing. |
Nothing in this build reads it to trim, rank or reject a skill. | Leave it unset and let the loader compute ceil(body.length / 4), or set it for the record. |
Where to go next
Linked from
Did this page answer your question?