Write your first skill
A skill is a folder with a manifest and a markdown body. This page builds one from an empty directory to a compiled, tested skill — layer by layer, with the bounds the validator actually enforces.
1. Create the folder
Everything a skill is lives in one directory. Two files are required; the rest are optional and can be added later.
test-gaps/
skill.toml # REQUIRED — manifest
SKILL.md # REQUIRED — instructions
prompts/ # optional — named prompt templates
scripts/ # optional — deterministic helpers
evals/ # optional — *.eval.yaml
fixtures/ # optional — fixture material for evals
mkdir test-gaps && cd test-gaps
Skill names match ^[a-z][a-z0-9-]{1,40}$: lowercase, starts with a letter, two to forty-one characters, hyphens allowed. Keep the folder name and skill.name the same — installs land at .kitbash/skills/<name>/, keyed by the manifest name.
A folder with only SKILL.md still loads. That is the skills.sh and Claude Skills convention, and Kitbash reads it as KSF-minus-manifest: it synthesizes permissive defaults (budget 6000, standing 250, no declared permissions) and flags the skill as unmanifested at install, list, and compile. Write the manifest — the defaults are a compatibility shim, not a target.
2. Start with the smallest manifest that validates
Two tables are required: [skill] and [context]. Everything else defaults to the most restrictive value, so a minimal manifest declares no permissions, no network, no writes.
[skill]
name = "test-gaps"
version = "0.1.0"
description = "Name the code paths a diff adds or changes that no test exercises"
[context]
budget = 1200
That is a valid skill. The two numbers in [context] are the whole reason KSF has a manifest at all:
- budget — the ceiling on the compiled body when the skill is active. If the compiled output exceeds it,
kitbash compilefails the build rather than silently overflowing someone's context window. - standing — the ceiling on what is present when the skill is not invoked. This is the number that keeps thirty installed skills from quietly eating the window.
Token counts are estimated at roughly four characters per token. Budget enforcement uses that estimate; treat it as a guardrail, not an invoice.
The bounds the validator enforces
These mirror spec/schema/skill.schema.json. Violating any of them is a hard manifest error — the skill will not load at all.
| Field | Rule |
|---|---|
skill.name | required · ^[a-z][a-z0-9-]{1,40}$ |
skill.version | required · semver x.y.z, optional -prerelease suffix |
skill.description | required · 10–200 characters |
context.budget | required · integer, 50–20000 |
context.standing | integer, 0–500 · defaults to 100 |
context.disclosure | lazy or eager · defaults to lazy |
A manifest that misses one reports every failure at once and stops:
test-gaps/skill.toml: invalid manifest
- context.standing must be 0–500
3. Layer on the optional tables
Add these one at a time, and only when the skill actually needs them. Every table you add is something a reviewer has to read before installing.
triggers
[triggers]
commands = ["/test-gaps"]
auto = []
events = ["pre-push"]
commands are explicit invocations to register and must be slash-prefixed and lowercase (^/[a-z][a-z0-9-]*$); lint fails on anything else. auto holds natural-language trigger hints, honored where the target supports them. events are hook points for gate-mode skills, drawn from pre-push, pre-commit, ci, pr-open.
The Claude Code adapter compiles each declared command into a native slash command at .claude/commands/<cmd>.md — a thin shim that loads the skill.
permissions
[permissions]
tools = ["read", "grep", "bash:git diff *"]
network = false
write = false
This is the block a reviewer reads at install time, and the block org policy gates on — deny_network, deny_write, and max_budget in kitbash.toml are checked against it and are not bypassable with --yes. See trust & review.
Permissions are enforced natively where the platform supports it; otherwise they are compiled into the instructions and surfaced at install review. An adapter must never claim enforcement it does not have. The tool-string grammar ("bash:git diff *") is stable in shape but provisional and advisory in meaning today — declare honestly, do not rely on it as a sandbox.
artifacts
[artifacts]
produces = ["findings@1"]
consumes = ["plan@1"]
Artifacts are JSON files under .kitbash/artifacts/, named <type>.json or <type>/<id>.json, each with a versioned schema in spec/schema/artifacts/. Every reference must match name@version; lint reports malformed ones. v0.1 reserves plan@1, findings@1, verify@1, and benchmark@1.
consumes declares optional inputs. The skill must still work when they are absent — write the body so a missing artifact degrades the run rather than breaking it.
targets
[targets]
requires = []
mode = "skill"
requires declares capabilities the skill needs: scripts, hooks, subagents, network. No adapter delivers any of them today — emit() writes instructions but does not copy a skill's scripts/, install a hook, or wire a subagent — so any value here degrades on every one of the nine targets, and each produces a visible warning at compile:
⚠ test-gaps → cursor: target lacks "scripts"; compiled instruction-only (degraded)
Under --strict that warning becomes a build failure. Silent degradation is a conformance violation — leave requires empty unless the skill genuinely cannot function without the capability.
mode is skill or gate. A gate-mode skill must route its verdict through a script exit code or a schema-validated artifact. A bare model opinion is not a gate verdict.
lore and dependencies
[lore]
reads = ["conventions", "invariants"]
writes = ["conventions"]
[dependencies]
plan = "^1.0"
[lore] declares which project-knowledge collections the skill reads or writes, from decisions, conventions, invariants, map. Writes only ever happen through human-reviewed proposals. The table is experimental and may move out of core KSF.
[dependencies] names other skills by semver range. The resolver installs the transitive closure and pins every node in kitbash.lock; cycles are an install-time error. A dependency's produces artifacts become available to the dependent's consumes — and nothing else crosses the edge. Permissions and budgets never inherit; each manifest stands alone. The table is provisional.
Unknown tables and unrecognized enum values warn rather than fail, so a skill written against a newer KSF still loads on an older compiler:
⚠ schema — unknown table [trigger] — typo or unsupported, ignored
4. Write SKILL.md
Plain markdown, no frontmatter — the adapters generate whatever frontmatter their target needs. Write it as a procedure the agent follows, not as a description of a procedure.
The first paragraph is the standing stub
The first real paragraph of SKILL.md is the standing stub: what the skill does and when to invoke it. Leading markdown headings are skipped when the stub is located, so a # Title line cannot smuggle a bloated paragraph past the check.
This matters more than any other line you will write. On a lazy target the stub is the only part of the skill that exists until it is invoked — it is the entire basis on which the agent decides whether to load the rest. Too vague and the skill never fires; too long and it taxes every session, multiplied by every skill installed. That is what context.standing caps.
At the four-characters-per-token estimate, standing = 60 is about 240 characters. Overrun it and lint fails, and so does compile:
✗ test-gaps
· manifest — test-gaps@0.1.0
· references
· budget — body ~425 tok / budget 1200
✗ standing — stub ~102 tok / limit 60
· artifacts — produces 1, consumes 0
linted 1 skill(s) · 1 failure(s) · 0 warning(s)
Everything after the stub loads only on invocation, and is capped by context.budget.
Template variables
Double-braced variables in the body resolve at compile time. Three namespaces exist:
| Variable | Resolves to |
|---|---|
{{lore.<collection>}} | A pointer to .kitbash/lore/<collection>/, with instructions to skip silently if absent. Declare the collection in [lore] reads. |
{{artifact.<type>}} | The path .kitbash/artifacts/<type>.json, which the agent reads at invocation time. |
{{prompt.<name>}} | The contents of prompts/<name>.md, inlined. A missing file is an error. |
An unresolved {{...}} is a hard error, not a warning. Anything still in brace form after resolution — an unknown namespace, a typo, spaces around the dot — stops lint, preview, and compile. It is never passed through into generated output, because a template variable that leaks into an agent's instructions is an instruction the agent will try to follow.
✗ references — test-gaps: unknown template variable {{prompts.foo}}
✗ references — test-gaps: unresolved template variable "{{ lore . conventions }}"
— unknown namespace or malformed (spec §3)
5. prompts/, scripts/, evals/
All three are optional. Add them when the skill has outgrown a single markdown file.
prompts/
Named markdown fragments pulled into the body by {{prompt.<name>}}. Useful when several steps share a rubric and you want one copy of it. The inlined text counts against context.budget like any other body content.
scripts/
Deterministic helpers in any executable form. They are never executed at install time — only when the agent invokes the skill, and only within the declared permissions. Make them idempotent, or name them so the difference is obvious: check-* versus apply-*. If the skill cannot work without them, declare requires = ["scripts"] and accept the degradation warning — no target delivers scripts yet, so it degrades everywhere.
evals/
One *.eval.yaml per behavior worth defending. The static tier — schema, budgets, dead references, injection heuristics — needs no eval file and always runs under kitbash lint and kitbash test. Audit and behavioral tiers need a runner:
tier: behavioral
fixture: fixtures/express-app
task: "Run /test-gaps on the current branch"
runner: claude-code
assert:
- artifact: findings@1
where: { file: "src/db.ts" }
expect: { severity: high }
- files_unchanged: ["**"]
The current build reports what it did and did not run:
note: evals/ present — audit & behavioral tiers need a runner
(not in this build); static tier ran
6. The loop
Five commands, run from the repo root. lint and preview both accept a plain path, so you can iterate on a skill before it is installed anywhere.
kitbash lint
kitbash lint ./test-gaps
✓ test-gaps
· manifest — test-gaps@0.1.0
· references
· budget — body ~364 tok / budget 1200
· standing — stub ~41 tok / limit 60
· artifacts — produces 1, consumes 0
linted 1 skill(s) · 0 failure(s) · 0 warning(s)
Schema conformance, template resolution, both budgets, artifact and trigger shape, and prompt-injection heuristics. Failures exit 1; --strict makes warnings exit 1 too.
kitbash preview
kitbash preview ./test-gaps
Renders exactly what each adapter will emit, with the standing cost per target. Read the frontmatter it generates — that is what the agent sees first.
preview: test-gaps@0.1.0
─── claude-code [lazy] lazy (0 tok standing) ───
→ .claude/skills/test-gaps/SKILL.md
---
name: test-gaps
description: "Name the code paths a diff adds or changes that no test exercises"
---
<!-- generated by kitbash — do not edit; source: .kitbash/skills/test-gaps @ 0.1.0 -->
List the code paths this diff adds or changes that no test exercises, and name
the missing cases. Invoke with /test-gaps before opening a PR, or run it on
pre-push.
...
kitbash install
kitbash install file:./test-gaps
A local path installs through the same review gate as a remote source, which is the point — you get to read your own skill the way a stranger would.
review: test-gaps@0.1.0 — Name the code paths a diff adds or changes that no test exercises
budget 1200 tokens · standing 60 · lazy disclosure · mode skill
permissions: tools [read, grep, bash:git diff *] · network no · write no
install test-gaps@0.1.0? [y/N] y
installed test-gaps@0.1.0
pinned in kitbash.lock
next: kitbash compile
Installs are content-hashed into kitbash.lock, so editing the source folder afterwards does not update the installed copy. To pick up changes:
kitbash remove test-gaps && kitbash install file:./test-gaps --yes
kitbash compile
kitbash compile
→ .claude/skills/test-gaps/SKILL.md
→ .claude/commands/test-gaps.md
→ AGENTS.md
⚠ test-gaps → agentsmd: agentsmd is eager and cannot lazy-load; this skill
costs ~364 tokens standing every session (declared limit: 60)
compiled 1 skill(s) for 2 agent target(s)
That warning is not a mistake in your manifest — it is the honest cost of an eager target. Kitbash compiles to the cheapest mode each target supports, so Claude Code, Cursor, Copilot, Devin, Gemini CLI and the .agents/skills/ path honor disclosure = "lazy"; Cline, Aider and the AGENTS.md floor have no deferred mode and carry the whole body every session regardless. The gap is the case for keeping bodies short. See the token budget benchmark and adapters & targets.
kitbash test
kitbash test test-gaps
The same static tier as lint, run over installed skills, plus a note when evals/ is present. Wire kitbash lint --strict and kitbash compile --strict into CI so a budget overrun or a dead reference fails the pipeline instead of a session.
A complete skill
This validates as written. Modeled on examples/skills/prereview, which ships in the repo and is worth reading next to it.
test-gaps/skill.toml
[skill]
name = "test-gaps"
version = "0.1.0"
description = "Name the code paths a diff adds or changes that no test exercises"
license = "Apache-2.0"
authors = ["Your Name <you@example.com>"]
[triggers]
commands = ["/test-gaps"]
events = ["pre-push"]
[context]
budget = 1200
standing = 60
disclosure = "lazy"
[permissions]
tools = ["read", "grep", "bash:git diff *"]
network = false
write = false
[artifacts]
produces = ["findings@1"]
[targets]
requires = []
mode = "skill"
[lore]
reads = ["conventions"]
test-gaps/SKILL.md
List the code paths this diff adds or changes that no test exercises, and name
the missing cases. Invoke with /test-gaps before opening a PR, or run it on
pre-push.
## Procedure
1. Collect the diff under review: staged changes if any, otherwise the branch
diff against the default branch. Consider **only** lines in the diff.
2. For each changed function, enumerate its branches: happy path, each error
path, each boundary (empty, null, zero, max), and each early return.
3. Locate the tests that reach each branch. A test counts only if it actually
executes that line — a test that imports the module proves nothing.
4. Check the project's testing conventions:
{{lore.conventions}}
If a convention names a required test tier for this kind of change, an
untested path that skips it is high severity.
5. Write gaps to `.kitbash/artifacts/findings.json` (`findings@1`), most severe
first: `file`, `line`, `severity`, `summary`, `scenario`.
6. Report one line per gap: `path:line severity: the branch, and the input that
reaches it.` No praise, no coverage percentages, no gaps outside the diff.
## Boundaries
- Do not write tests unless asked. Name the gap and the input that would reach it.
- Generated files, fixtures, and vendored code are out of scope. Say so once and
skip them.
- If a path is genuinely unreachable, say that instead of inventing a test for it.
The stub measures about 41 tokens against a limit of 60, and the whole body about 364 against a budget of 1200 — room to grow without a rewrite.
Where to go next
- Skill format (KSF) — every field, and which ones are frozen for 1.0.
- CLI reference — every command, flag, and exit code.
- Adapters & targets — what each of the nine outputs supports, and where skills degrade.
- Trust & review — the install gate, and
[policy]allowlists for teams.