The Slash Command YAML Contract: Frontmatter Fields That Actually Matter
A field-by-field breakdown of name, description, and allowed-tools in Claude Code slash command frontmatter, with shipped examples.
The Slash Command YAML Contract: Frontmatter Fields That Actually Matter
Slash commands in Claude Code look deceptively simple. A markdown file in .claude/commands/, a few YAML lines on top, and /your-command works. Most guides stop there. The frontmatter is where commands quietly succeed or silently misbehave, and the three fields that carry actual contract weight are name, description, and allowed-tools. Get them right and the command is portable, discoverable, and safe to share. Get them wrong and you ship a command that either confuses the agent, asks for permissions on every run, or never appears in the picker at all.
This piece walks through each field with shipped examples, the surprising defaults, and a comparative note on when to reach for a slash command versus a skill versus a subagent.
Where slash commands live
A slash command is a markdown file. Two locations are scanned:
.claude/commands/<name>.md\u2014 project-scoped, checked into the repo~/.claude/commands/<name>.md\u2014 user-scoped, available across every project
Project commands win when names collide. The filename without .md becomes the invocation: deploy.md \u2192 /deploy. That alone is enough to register the command, but the frontmatter controls behavior.
---
name: deploy
description: Deploy the current branch to staging via the deploy.sh script
allowed-tools: Bash(./scripts/deploy.sh:*), Read
---
Run `./scripts/deploy.sh` for the current branch. After it completes,
read `deploy.log` and summarize the outcome in 3 bullets.
That's a complete command. The body is a regular prompt \u2014 Claude reads it as if you typed it inline. The frontmatter is where the contract lives.
name \u2014 the field most people skip
The name field is optional in the sense that filename fallback works. It becomes load-bearing the moment you start sharing commands across machines, because filename casing differs between case-insensitive macOS volumes and case-sensitive Linux servers. A Deploy.md that works locally can fail on a CI runner that reads it as deploy.md \u2014 or worse, both files coexist on the macOS clone and one shadows the other.
Setting name: deploy explicitly removes the ambiguity. The agent picker uses name, not the filename, when both are present. For commands you publish to a team or a marketplace, treat name as required.
A second reason: rename refactoring. If name matches the filename, you can rename the file and update the field in one commit. If you skip the field, every reference to /old-name in docs and other commands has to be hunted down by string search. Explicit naming is cheaper than string-grep refactors three months later.
description \u2014 what the picker shows
The description shows up in two places: the slash command picker (when the user types /) and any agent that lists commands programmatically. Two principles compress well:
- Verb-first. "Deploy the current branch\u2026" beats "A command that deploys the current branch\u2026". The picker truncates around 80 characters; front-load the verb.
- Mention the side effect. "Deploy" is too vague. "Deploy to staging via deploy.sh" tells the user this command will run a shell script and hit a real environment. Surprises in the picker are a UX bug.
A description that tells you nothing \u2014 "runs the deploy command" \u2014 is worse than no description, because it occupies picker real estate without paying rent. Compare:
# weak
description: Run the deploy
# strong
description: Deploy current branch to staging.example.com via deploy.sh, then summarize deploy.log
The strong version is 14 words longer and roughly 5\u00d7 more useful. The picker will truncate the tail, but the verb and target are visible at first glance.
allowed-tools \u2014 the field that gates real work
allowed-tools is a comma-separated list of tools the command may invoke. When a user runs the command, those tools are pre-approved for the duration of that command's execution \u2014 no permission prompt, no Y/n interruption. Anything not on the list still triggers a permission prompt as usual.
This is the biggest practical difference between a well-shipped command and a friction-laden one. A command that needs Bash and Read but doesn't declare them prompts the user twice on every invocation. A command that declares allowed-tools: Bash, Read, Write runs to completion without a prompt.
Three patterns worth knowing:
# Specific bash command \u2014 most precise, recommended for shipped commands
allowed-tools: Bash(./scripts/deploy.sh:*), Read
# Whole tool \u2014 broader but acceptable for read-only tools
allowed-tools: Read, Grep, Glob
# Multiple bash patterns \u2014 combine with commas
allowed-tools: Bash(npm run *), Bash(git status), Bash(git diff:*), Read
The :* suffix matches any arguments. Bash(./scripts/deploy.sh:*) allows the deploy script with any flags. Bash(./scripts/deploy.sh) allows it ONLY with zero arguments \u2014 a stricter contract.
Why bother being specific? Because a permissive Bash declaration on a shipped command means anyone running /deploy from your repo grants Claude unrestricted shell access for that turn. Specificity scales with audience. A personal command in ~/.claude/commands/ can declare Bash broadly. A team command in .claude/commands/ should narrow to the exact patterns it needs.
Body convention: argument placeholders
Inside the body, $ARGUMENTS expands to whatever the user typed after the command name. If a user runs /deploy --dry-run, the body sees --dry-run substituted in.
---
name: deploy
description: Deploy current branch with optional flags
allowed-tools: Bash(./scripts/deploy.sh:*), Read
---
Run `./scripts/deploy.sh $ARGUMENTS` for the current branch. If the
script exits non-zero, read `deploy.log` and report the failing step.
A user types /deploy --dry-run --verbose and the agent runs ./scripts/deploy.sh --dry-run --verbose. Without $ARGUMENTS, you'd need a separate command per flag combination \u2014 fast path to clutter.
Slash commands vs skills vs subagents
Three primitives can sit in .claude/, and the choice matters more than the docs spell out:
- Slash command (
.claude/commands/) \u2014 the user invokes it explicitly. Best for repeatable workflows that the user starts on demand:/deploy,/review,/changelog. The agent runs the body once. - Skill (
.claude/skills/) \u2014 the agent invokes it implicitly when the trigger conditions match. Best for capabilities the agent should reach for without being told: a "writing-style" skill that activates on copy edits, a "schema" skill that loads when JSON-LD comes up. The user doesn't type a slash. - Subagent \u2014 a fresh agent context with its own tool budget, spawned by the orchestrator. Best for parallel work, isolation from the parent context, or specialist personas (a security reviewer, a docs reviewer). About 70% slower per invocation than a slash command because of context setup, but worth it when you need clean boundaries.
The rule of thumb: slash commands for human-driven repetition, skills for agent-driven implicit capability, subagents for context isolation or parallelism. Mixing the layers \u2014 a slash command that exists only because someone didn't know skills are an option \u2014 produces the kind of .claude/ directory where nobody can find anything six months later.
Validation: what fails silently
Three things break without obvious error messages:
- Bad YAML indentation. Frontmatter must start with exactly
---on line 1, end with---on a line by itself, and use 2-space indentation if you nest. A tab character anywhere produces "no command found" silently. - Unknown tool name in
allowed-tools. MisspellBashasbashand the field is treated as a literal string match against nothing. The command runs, but every Bash call still prompts. - Conflicting
nameand filename without explicitname. If the file isDeploy.mdand another command setsname: deploy, the picker shows both, and which one fires is locale-dependent. Always setnameexplicitly when shipping.
A 2-line validation pass solves most of these:
# from repo root
for f in .claude/commands/*.md; do
head -20 "$f" | grep -E '^(name|description|allowed-tools):' || echo "MISSING FIELDS: $f"
done
Quick, free, catches the 80% case before review.
References: