Skill Authoring Guidelines
Read this before creating or modifying any skill. These conventions ensure skills are consistent, minimal, and fast to invoke.
Skill structure
skills/<skill-name>/
├── SKILL.md # Entry point (required)
├── scripts/ # Helper scripts (optional)
└── references/ # Reference docs cited from SKILL.md (optional)
- Entry point is always
SKILL.md - Place in
~/.claude/skills/for global skills,.claude/skills/for project-local - Shared resources go in
~/.claude/skills/shared/ - Creating the directory is not enough to keep the skill. A skills directory is normally
ignore-everything-then-allowlist, so a new skill is untracked and invisible until you add its
un-ignore line — see Gitignore for the exact check. Do it when you create the
directory, not at the end; the
skill-tracked.pyhook flags a miss when the SKILL.md is written, but the hook is a net, not the plan.
Addressing Claude project data (per-project memory, sessions, etc.)
If your skill needs to read or write anything under ~/.claude/projects/<project-id>/ — most commonly a project's memory directory — see ~/.claude/skills/skill/references/claude-project-memory-paths.md. It documents the path mangling rule, the cross-platform CWD recipe, and the common gotchas. Cite that file from your skill rather than re-deriving the algorithm.
Frontmatter
Every SKILL.md starts with YAML frontmatter:
---
name: <skill-name> # kebab-case, matches directory name
description: >- # one-line summary for the skill list + trigger rules
What the skill does.
TRIGGER when: <conditions>.
allowed-tools: <tool-list> # optional — restricts which tools the skill can use
---
Description
The description field serves double duty: it appears in the skill list shown to the model, and it controls when the model auto-invokes the skill. Write it so that:
- The first sentence summarizes what the skill does (shown in the skill list)
- A TRIGGER when: clause lists the conditions for auto-invocation (if applicable)
- Optionally, a DO NOT TRIGGER when: clause prevents false positives
allowed-tools
Whitelist the minimum set of tools the skill needs. Use glob patterns for Bash:
allowed-tools: Bash(git diff:*), Bash(git add:*), Read, Glob
Omit allowed-tools only if the skill genuinely needs unrestricted access.
Writing the skill body
Be prescriptive, not conversational
Skills are instructions, not documentation. Write them as numbered steps with exact commands. The model follows them literally — vague guidance produces inconsistent results.
Reference shared rules
Don't duplicate conventions that exist in shared files. Reference them:
Read `~/.claude/skills/shared/bash-rules.md` for bash command constraints.
Read `~/.claude/skills/shared/commit-message-rules.md` for commit message formatting.
Don't leave the detail in two places
When a new skill covers ground an existing memory or learning already documents, the skill takes the operational detail and the older file slims to a pointer — never both in full. Two full copies drift the moment one is updated, and which one gets read depends on whichever trigger fired first.
Update the pointer sites in the same change: the CLAUDE.md index line, and any hook whose injected reminder cites the old file. Keep in the memory only what the skill can't carry — the preference itself and the why — since a memory is always loaded while a skill is invoked on demand.
Extract optional depth into references/
SKILL.md should carry only what every invocation needs. Deep, situational, or rarely-touched knowledge belongs in references/<topic>.md with a short pointer from SKILL.md describing when to consult it. The model reads the reference on demand, so the entry point stays lean and the detail loads only when relevant.
Good candidates for extraction:
- Recipes used by only a minority of invocations (e.g. a complex error-recovery flow, an uncommon platform variation)
- Long tables, example matrices, or edge-case catalogs
- Background rationale that helps when something goes wrong but isn't needed on the happy path
- Cross-skill knowledge where SKILL.md-level duplication would drift (the "Addressing Claude project data" section below is an example — most skills don't need path mangling, so it lives in
references/claude-project-memory-paths.md)
Pointer style — cite with enough context that the model knows when to follow the link:
If <condition>, see `references/<topic>.md` for <what it covers>.
Keep it a one-liner in SKILL.md; the reference carries the depth.
Always confirm before destructive actions
If the skill performs irreversible operations (commits, pushes, deletes, moves), require explicit user confirmation before executing. Show the plan first, then ask.
Scope guard
State explicitly what the skill does NOT do. An "Out of scope" section prevents scope creep:
## Out of scope
- Do NOT amend existing commits
- Do NOT create or switch branches
Injecting dynamic context with ! commands
The !`command`` syntax in SKILL.md runs shell commands automatically as preprocessing before the skill content reaches the model. The command output replaces the placeholder inline — the model never sees the command, only the results. This eliminates the reconnaissance phase entirely.
See ~/.claude/learnings/skill-context-evaluator.md for known limitations, workarounds, and which commands work reliably in context.
How to write a Context section
When creating or updating a skill, identify every piece of data the skill needs before it can start its real work. For each one, add a labeled ! line. Place the Context section at the top of the skill body (before the first process step).
Format — each line is a labeled command (backticks are escaped in these examples so invoking THIS skill doesn't execute them — preprocessing runs ! lines even inside fenced code blocks; write them unescaped in your skill):
## Context
- Uncommitted changes: !\`git status --short\`
- Diff summary: !\`git diff HEAD --stat\`
- Recent history: !\`git log --oneline -10\`
- Build errors: !\`npm run build 2>&1 | tail -n 50\`
For commands too complex for a single line, use a script:
- Precondition checks: !\`bash ~/.claude/skills/<skill-name>/scripts/preflight.sh\`
What qualifies as a context command
- Read-only — avoid mutations. Exception: idempotent setup commands (e.g.
git reset HEADto unstage) are allowed if they must run before the data-collection commands that follow. - No command substitution —
$()inside!commands is blocked by the permission checker. Use simple commands or fallback chains with||(e.g.git log @{upstream}..HEAD 2>/dev/null). - Deterministic — runs the same way on every invocation, not conditional on prior results. Commands that can legitimately fail (e.g.
gh pr viewwhen no PR exists) must go in the skill body, not in context — the context evaluator treats any non-zero exit as a fatal error. - Labeled — the label describes what data the command provides
- Output-scoped — limit output to what the skill actually needs. Use flags (
--short,--oneline,--stat,--format), line limits (-n,--limit,head -n), field selectors (--json ... --jq), or filters (grep,tail) to trim noise. Unbounded output wastes context. - Complete — if a process step needs data on every invocation, that data must be in the Context section. Process steps should never need to re-run a command that Context could have provided. When reviewing a skill, scan every command in the process steps — if it runs unconditionally, it belongs in Context. Since
!commands are replaced by their output during preprocessing, the model never sees the command text — only the label and the output. Process steps must reference context data by its label (e.g. "use Diff summary"), never by the command that produced it (e.g. not "rungit diff --stat"). - CWD-portable — global skills can be invoked from any project's working directory. Context commands that read files OUTSIDE that CWD (e.g. the skill's own config at
~/.claude/skills/<name>/...) can be denied by Claude Code's auto-mode classifier as "scope escalation". The credential-heuristic is especially trigger-happy on.envfilenames. See "Cross-CWD safety" below.
Process steps should use context output directly
Since ! commands are preprocessed, the data is always present when the model reads the skill. Process steps should reference the context output directly — no need to re-run commands or check whether data is available. When referring to context data in process steps, use the same label from the Context section (e.g. "Ignore rules from Context above"), not the underlying filename or command — the model sees labels, not filenames.
Prune unused context items
Every context item must be referenced by at least one process step (by its label). When reviewing or updating a skill, scan the Context section and remove any items that no process step uses — unused context wastes the preprocessing budget and pollutes the model's input with irrelevant data.
Cross-CWD safety
A user can invoke any global skill from any project. The skill's own config / reference files live in ~/.claude/skills/<name>/, which is outside the user's CWD whenever the CWD isn't ~/.claude/. Claude Code's auto-mode classifier runs heuristics on every Bash command — when a ! Context command reads outside the CWD, especially from .env-suffixed files, it may deny with: "scope escalation beyond the <repo> repo and may expose credentials".
Mitigations, in order of preference:
- Existence-check, not content-read. Use
!+test -f ~/.claude/skills/<name>/config/<file> && echo PRESENT || echo MISSINGand branch on the literalPRESENT/MISSINGin process steps. No file content is read; the credential heuristic doesn't fire. - Avoid
.envfilenames for skill-local config. Use.conf,.ini,.txt, or no extension. The classifier treats.envas credential-bearing regardless of actual contents. - Whitelist with
:*wildcard inallowed-toolswhen the compound formcmd && X || Yis needed:Bash(test -f ~/.claude/skills/<name>/config/<file>:*). The:*allows arguments and shell continuations after the prefix. - Read content via the
Readtool, not Bash, when content really is required.Read(~/.claude/skills/<name>/config/<file>)declared inallowed-toolsdoesn't go through the Bash classifier.
If the skill's own script (Bash(python3 ~/.claude/skills/<name>/scripts/<script>)) is also denied when invoked from outside CWD, the user can add a project-local or global permission rule in settings.json allowing scripts under ~/.claude/skills/.
Full-width terminal output
When a skill renders output meant to fill the terminal — a table, an aligned/wrapped listing — it must detect the terminal width, because the rendering script can't.
Why the script can't self-detect. A skill's helper runs with its stdout piped (the ! context capture, or the Bash tool), so shutil.get_terminal_size() / tput cols inside the script return a fallback, not the real window. For the same reason, width-dependent rendering must not live in a Context ! line — that always renders at the fallback width. Render it in a process step instead, after detecting the width.
The pattern (reuse the github-status skill's implementation):
- Detect the width yourself, in a process step:
- Windows: the PowerShell tool evaluating
$Host.UI.RawUI.WindowSize.Width— the PowerShell tool specifically;powershell.exefrom Bash gets its own console and reports the wrong value. - macOS/Linux:
tput cols(or$COLUMNS).
- Windows: the PowerShell tool evaluating
- Subtract a small gutter (≈2 columns) — Claude Code's TUI indents tool/message output, so content exactly as wide as the window gets its right edge clipped.
- Pass the result to the helper as
--width <N>; the helper honors--width, else falls back toget_terminal_size()then a fixed default. If detection fails, omit--widthand accept the fallback.
Add PowerShell and Bash(tput cols:*) to allowed-tools for the detection step. Working examples: ~/.claude/skills/github-status (a bordered table) and ~/.claude/skills/memo (a wrapped, aligned listing).
Is the destination published?
Ask this before writing the skill's content, not before committing it. The Gitignore section below answers "will this be tracked?"; it does not answer "will this be public?", and a skill can be correctly tracked straight into a public repo.
gh repo view --json isPrivate,nameWithOwner,visibility
Most skills are pure method and publish fine. What does not: per-host inventories, which systems are unprotected, where sensitive or personal data sits, account identifiers, internal hostnames and schedules. None of it looks like a credential, which is exactly why it reads as safe to write.
When a skill needs both, split by sensitivity rather than trimming: the procedure stays in the public skill, the inventory moves to the private repo that owns the thing it describes, and the skill points at it via a sibling checkout — refusing to proceed when that checkout is absent rather than guessing. See ~/.claude/learnings/cross-repo-rule-delegation.md for the delegation mechanics and ~/.claude/memory/feedback_check_destination_visibility.md for why storing it once where it belongs beats keeping a redacted copy.
Doing this first matters because it is cheap first and expensive later: by the time the content exists it has been shaped around the wrong assumption, so the fix is a restructure rather than a decision.
Gitignore
A skills directory is usually covered by an ignore-everything-then-allowlist rule, so a new skill can end up untracked with no signal at all: it never appears in git status, and the only copy lives on the machine that created it. Add the un-ignore line when you create the directory — don't defer it to a final check, and don't skip it because the skill obviously sits inside a tracked repo.
Do not "simplify" this by inverting the rule to commit-everything-except. The allowlist is
deliberate: the skills directory also holds third-party skills the user installed (plugins,
~/.agents/skills), and an inverted rule would start tracking those into the dotfiles repo.
Deny-by-default with a per-skill opt-in is the correct posture; its one cost is the missed line,
which the audit and the write hook below exist to catch.
The skill-tracked.py PostToolUse hook runs this check automatically when a SKILL.md is written, and reports the ignoring file, line and pattern. Treat it as a backstop for the case you forgot, not as the mechanism — it fires only on Write, only for a file named exactly SKILL.md, and only after the fact. A skill added any other way still slips through, which is what the audit below is for.
Ask git instead of reading an ignore file by hand:
- Resolve the symlink —
readlink ~/.claude/skillsgives the real directory inside the dotfiles repo. - From that repo, check the new skill by its repo-relative path:
git check-ignore -v claude/skills/<skill-name>/
Interpret the result:
| Result | Meaning | Action |
|---|---|---|
| exit 1, no output | not ignored | tracked — done |
exit 0 + a <file>:<line>:<pattern> citation |
ignored | add !claude/skills/<skill-name>/ to the cited file, beside its siblings, and re-run until it exits 1 |
exit 128, is outside repository |
the ~/.claude/... symlink path was passed |
retry with the resolved path from step 1 |
Two traps this avoids:
- Checking a file that can't hold the pattern. The allowlist normally lives in the dotfiles repo's own
.gitignore, not the global one (git config --global core.excludesfile). Inspecting only the global file reports "nothing excludes it" while the skill stays untracked. Onlycheck-ignorenames the file and line that actually decides. - Reading exit 128 as success. It is non-zero, so a bare
if git check-ignore …; thenclassifies the skill as tracked when the path was merely unresolvable.
To catch skills that are already missing an entry, audit them all from the repo root:
bash ~/.claude/scripts/audit-skill-tracking.sh
It prints one line per skill that a wildcard rule is swallowing, and nothing when all are
accounted for. It covers both layouts (claude/skills/ and a project's .claude/skills/).
An entry is cleared one of two ways, and both are durable:
- Keep the skill — add
!claude/skills/<name>/to.gitignorebeside its siblings. - Keep it local on purpose — add its directory name to
claude/untracked-skills.local.txt(next toskills/, so the blanketskills/*rule cannot swallow the file recording the decision). That file is itself gitignored and per-machine: which skills are deliberately unshared differs between machines, so committing it would push one machine's decisions onto the other. A fresh clone has no file and decides each skill again there.
Two further cases are filtered without needing an entry: symlinked skills, whose content is
installed elsewhere and was never this repo's to commit, and skills matched by an exact,
wildcard-free path in .gitignore, which already reads as a deliberate exclusion.
Never leave such a decision in conversation alone — it dies at the next /clear, and the skill is
raised again on every commit thereafter.
/commit runs the same script, so a forgotten skill also surfaces at the moment it would
otherwise be left out of a commit — the case the write hook cannot see, because it only fires
when a SKILL.md goes through the Write tool. A directory that was copied, moved, renamed, or
unpacked by a plugin reaches the repo without one.
Don't hand-roll this with ls. A shell that classifies (ls -F, or an ls alias) appends / to
directories and @ to symlinks, so piping ls through sed 's|$|/|' yields annotate// and
firecrawl@/ — paths that do not exist. check-ignore still matches those against a broad
pattern like claude/skills/*, so the audit looks correct while silently mis-reporting under a
narrower one.
Every line of output is an untracked skill; no output means all are tracked. A skill that should also be published needs its ### <Title> section in the repo README — being tracked is necessary, not sufficient.
Shell environment
Skills that configure bash functions (like build and deploy) should reference ~/.claude/learnings/shell-environment.md for the canonical list of expected functions and the verification checklist. When adding a new bash function to a skill, update that file too.
Cross-platform scripts (Windows + macOS)
Skills run on both Windows (Git Bash) and macOS (zsh/bash), so every script and ! context command a skill emits must work on both — never assume one OS.
- Shell rc file — don't hardcode
~/.bashrc. Detect the target per-OS (zsh on macOS, bash on Windows Git Bash / Linux) and grep across all candidates when checking whether a function is installed. Reuse the probe thedeploy/buildskills already use:case "$(uname -s)" in Darwin) echo "~/.zshrc" ;; MINGW*|MSYS*|CYGWIN*) echo "~/.bashrc" ;; *) [ -n "$ZSH_VERSION" ] || [ "${SHELL##*/}" = "zsh" ] && echo "~/.zshrc" || echo "~/.bashrc" ;; esac - OS-specific commands — branch on
uname -sand give both arms (as the Full-width terminal section does:tput colson macOS/Linux vs the PowerShell tool on Windows). Never emit a command that exists on only one OS without a fallback. - Stay POSIX — a snippet appended to the rc may run under bash or zsh. Use constructs both accept (
[ … ],case,$( )); avoid bash-only[[ … ]]/arrays where a POSIX form works, and avoid zsh-only syntax. - Beware tool flag drift — macOS ships BSD
sed/grep/datewhose flags differ from GNU (e.g.sed -ineeds an arg on BSD). Prefer portable invocations, or cross-platform tools the skill can already rely on (git,node,python). - Paths — forward slashes everywhere (see Conventions);
~/$HOMEresolve on both; never assume drive letters. - Verify on the OS you're on, and reason explicitly about the other before shipping — a Windows-only
!command silently breaks a macOS user's skill run, and vice versa.
Conventions
- One command per Bash call in skills that use
allowed-toolswith Bash patterns (see~/.claude/skills/shared/bash-rules.md) - Imperative mood in instructions ("Run", "Check", "Ask the user")
- No
cd— the working directory is the project root - Forward slashes in all paths (Windows bash compatibility)
- Heredocs for multi-line content passed to commands