Noddle Deck
All posts

Agent Engineering series

Slash Commands: Reusable Prompts You Can Ship

Noddle Deck team14 min read
slash commandspromptsdeveloper workflow

You've typed some version of the same prompt into a coding agent a dozen times this month. "Review this migration for reversibility and lock duration." "Write a changelog entry from the diff on this branch." "Summarize the open PRs assigned to me and flag anything stale." Each time you retype it, you're re-deriving the same instructions, in slightly different wording, with slightly different coverage of the details that matter. Nobody reviews that prompt for correctness the way they'd review a shell script, because it never got promoted to the status of something worth reviewing — it's just something you type into a chat box and hope comes out right this time too.

A slash command is what happens when you stop retyping and start shipping. It's a prompt saved to a file, with a name, optional parameters, and a place in your repo where the rest of the team can find it, use it, and improve it. The prompt doesn't get smarter by being a file — but it does stop being a private habit and starts being a piece of tooling with an author, a version history, and a diff.

Key takeaways

  • A command is a Markdown file with frontmatter and a template body. You invoke it explicitly with /name; nothing about it fires on its own.
  • $ARGUMENTS captures everything typed after the command name as one string; $1, $2, ... let you address individual words positionally when the shape of the input is more structured.
  • ! runs a shell command and inlines its stdout into the expanded prompt before the model ever sees it; @ inlines a file's contents the same way — both happen at expansion time, not as tool calls the model has to decide to make.
  • Commands are for moments you choose; skills are for moments the agent has to recognize on its own. Picking the wrong one either forces you to invoke something that should be automatic, or lets something fire when you wanted control.
  • Repo-level commands (.claude/commands/) are reviewed team tooling; user-level commands (~/.claude/commands/) are personal shortcuts. A command doesn't graduate from one to the other automatically — someone has to decide it's ready to be everyone's default.
  • A command can't call another command directly — there's no nesting syntax for it. Share logic between commands the same way you'd share logic between scripts: pull the common part into a file both templates @-reference.

Commands vs. skills: who decides the moment#

Noddle Deck already covers Agent Skills in this series, and the two concepts get confused constantly because they can look similar on disk — both are Markdown files with a bit of frontmatter, both encode a procedure, both live in a directory the agent reads at startup. The difference isn't the file format. It's who's in the driver's seat.

A skill is discovered and triggered by the agent: it scans every installed skill's description, matches it against the current task, and loads the ones that fit — you never type anything to make that happen. A command is invoked by you: you type /review-migration payments/002_add_index.sql, and nothing runs until that keystroke. The agent has zero say in whether a command fires. That's not a limitation, it's the point — a command is for the exact moment you already know what you want done, and you want it done the same way every time you ask.

Figure 1

Command vs. skill decision

Command vs. skill decision diagramWho decides the momentthis action should run?you invoke itSlash commandYou type /name at theexact moment you want it,for a task you control.it auto-triggersSkillThe agent notices the taskmatches the description andloads it without being asked.
If you're the one who knows the right moment to act, reach for a command. If the agent needs to recognize that moment on its own from context, reach for a skill instead.

In practice, most teams end up with both, and the split is usually clean once you ask the right question: does someone have to decide "now"? A release note draft, a migration review kicked off by a specific PR, a request to summarize this exact thread — those are commands, because a human is the trigger. A house style that should apply to every PR review regardless of who remembers to ask for it, or a checklist that should run whenever a file under migrations/ changes — those are skills, because the agent is the trigger. Forcing a command to do a skill's job means someone eventually forgets to type /review-migration and a bad migration ships. Forcing a skill to do a command's job means it fires (or doesn't) based on wording you don't fully control, on a task where you wanted a guarantee, not a guess.

The anatomy of a command file#

Strip a command down to its parts and there isn't much to it — which is exactly why it's a good default for turning a repeated prompt into something reviewable. A command file has a small YAML frontmatter block up top, followed by the actual template body in Markdown, the same shape you'd write the prompt in if you were just going to paste it directly.

.claude/commands/review-migration.md
---
description: Review a SQL migration file for reversibility, lock duration, and backward compatibility
argument-hint: <path-to-migration-file>
allowed-tools: Read, Grep, Bash(git diff:*)
---
Review the migration at $ARGUMENTS for:
1. Reversibility — does a corresponding `down` migration exist and
actually undo the `up` migration cleanly?
2. Lock duration — does any `ALTER TABLE` risk a long-held lock on a
large table? Flag anything not wrapped for online DDL.
3. Backward compatibility — will the previous app version still work
against the schema mid-deploy?
Context on the current schema, if useful:
!`git log -3 --oneline -- $ARGUMENTS`
Summarize findings as a pass/fail per check, not a wall of prose.

description is what shows up when you or a teammate types / and skims the list of available commands — write it the way you'd write a menu item, not a docstring. argument-hint is the autocomplete hint shown after the command name; it doesn't validate anything, it's purely there so the person typing knows what to type next. allowed-tools scopes down what the expanded prompt is permitted to invoke — a review command has no business running git push, and saying so explicitly is cheaper than hoping the model infers the same boundary on its own.

$ARGUMENTS, $1, $2 — capturing what the user typed#

Everything typed after the command name is available to the template two ways. $ARGUMENTS is the whole remainder as one string — perfect for the common case where the argument is a single path, ticket number, or free-text description that doesn't need to be split apart. $1, $2, and so on address individual space-separated words positionally, which matters once a command needs more than one distinct input:

.claude/commands/backport.md
---
description: Backport a merged PR to a release branch
argument-hint: <pr-number> <release-branch>
---
Cherry-pick the commits from PR #$1 onto $2, resolve any conflicts by
keeping the release branch's version of config files, and open a new PR
targeting $2.

Invoked as /backport 4821 release/2.4, $1 resolves to 4821 and $2 to release/2.4. There's no schema validation here — if someone invokes the command with the arguments in the wrong order, the template will happily substitute them in the wrong order too. That's a real limitation, and the practical mitigation is the same one you'd use for any CLI with positional arguments: keep the argument count low, put the hint in argument-hint, and reach for a script (via the bash embed below) to validate anything that actually needs checking before the rest of the template runs.

The bash embed — running a command before the model sees the prompt#

A line starting with ! followed by a backtick-quoted command runs that command at expansion time and splices its stdout directly into the prompt. This is not a tool call the model chooses to make — it happens deterministically, every time, before the model ever sees the expanded text. That distinction matters: if a step in your command is "check whether this branch is up to date with main," you don't want to hope the model remembers to run that check; you want it already answered by the time the model starts reasoning.

markdown
## Current branch status
!`git status --short`
## Diff against main
!`git diff main...HEAD --stat`
Write a changelog entry summarizing the change above.

The allowed-tools frontmatter field, specifically the Bash(...) syntax, is what scopes which shell commands a ! embed is permitted to run — treat it the same way you'd treat a CI job's permissions: as narrow as the command actually needs, not as broad as "whatever might be useful someday."

The file reference — inlining a file's contents#

An @ followed by a path inlines that file's contents into the expanded prompt, the same way the bash embed inlines command output. It's the difference between telling the model "go read the style guide" and just handing it the style guide directly — the latter doesn't depend on the model deciding to go look, because there's no separate lookup step to skip.

markdown
Rewrite the function at $ARGUMENTS to match our style guide:
@docs/style-guide.md
Keep the function's behavior identical — this is a style pass, not a
refactor.

Frontmatter fields are optional, but earn their keep fast

A command with just a Markdown body and no frontmatter at all still works — description, argument-hint, and allowed-tools are quality-of-life and safety additions, not requirements. Skip them for a quick personal command you're iterating on; add them back before you commit it for the team to use, the same way you'd add a docstring before merging a function other people will call.

How invocation actually works#

When you type /review-migration payments/002_add_index.sql and hit enter, four things happen in order, and understanding the order matters because it's where most confusing bugs live.

Figure 2

Command invocation pipeline

Command invocation pipeline diagram/cmd argstep 1user types the invocationResolve filestep 2match name to .md fileExpand templatestep 3substitute $ARGUMENTSModel callstep 4expanded prompt is sent
The command name resolves to a file before anything else happens. Only after the template is fully expanded — arguments substituted, bash embeds run, file references inlined — does the result get sent to the model as a normal prompt.
  • Resolve. The agent matches review-migration against the filenames of every installed command and finds review-migration.md. If two commands share a name in different namespaces (more on that below), the more specific namespace wins.
  • Expand — arguments. $ARGUMENTS, $1, $2 are substituted with the text typed after the command name.
  • Expand — embeds. Every ! shell command runs and its stdout is inlined; every @ file reference is read and its contents inlined.
  • Send. The fully expanded text — no more $ARGUMENTS, no more ! or @ markers, just plain text — is sent to the model as a normal prompt. From this point on, it behaves exactly like anything else you could have typed by hand.

That last point is worth sitting with: a command is not a special object the model reasons about differently. It's a templating step that happens before the model is involved at all. Everything you know about writing a good prompt directly still applies to writing a good command template — vague instructions produce vague results whether you typed them fresh or a command typed them for you.

Distilling a repeated prompt into a command#

The signal that a prompt deserves to become a command is boring and reliable: you've typed some close variant of it three or more times. At that point the marginal cost of writing the command file is lower than the accumulated cost of retyping, and — this is the part that actually matters — writing it down forces you to notice which parts of the prompt are fixed and which parts change every time.

Figure 3

Prompt-to-command distillation

Prompt-to-command distillation diagramOne-off promptReview the migration inpayments/002_add_index.sqlfor reversibility and lockduration before merging.retyped from scratch every PRdistillreview-migration.mdReview the migration in$ARGUMENTSfor reversibility and lockduration before merging./review-migration payments/002_add_index.sql
The parts of a one-off prompt that changed every time you typed it — here, the specific file path — become an argument slot. Everything else becomes the fixed template body.

That's the whole distillation process: take the prompt you've been retyping, underline the words that were different each time, and replace those with $ARGUMENTS or positional parameters. Everything else — the instructions, the checklist, the formatting request — becomes the fixed body that doesn't change between invocations. If nothing in the prompt ever changes between uses, you don't need a parameter at all, and a zero-argument command is completely normal.

Naming and namespacing#

Command names show up in a flat-feeling list once you type /, so naming discipline matters more than it looks like it should. A few conventions save real confusion later:

  • Name it like a CLI subcommand, not a sentence. review-migration reads clearly next to a dozen other commands. can-you-check-if-this-migration-is-safe doesn't, and it's harder to type correctly under pressure, which defeats the point of having a shortcut at all.
  • Use subdirectories as namespaces. A command saved to .claude/commands/db/review-migration.md is invoked as /db:review-migration. Once a team has more than about ten commands, namespacing by domain — db:, release:, docs: — keeps the autocomplete list scannable the same way subpackages keep a large codebase navigable.
  • Don't let two commands overlap in scope. If /review-pr and /review-migration both plausibly apply to a PR that touches a migration file, someone will eventually invoke the wrong one and get a review that missed the thing the other command would have caught. Narrow each command's job until the names don't compete.

Anti-patterns to avoid#

  • A command that should really be a skill. If you're invoking the same command on every single PR without exception, and the only reason a human is still in the loop is that nobody automated the trigger, that's a sign the logic belongs in a skill (or a hook — see the next post in this series) instead of a command someone has to remember to type.
  • Cramming validation logic into prose. "Make sure the branch name matches the ticket number pattern" is a three-line regex check, not a paragraph of instructions for the model to apply by eye every time. Run it as a bash embed and hand the model the pass/fail result instead.
  • Overloading $ARGUMENTS as a junk drawer. Passing five different unrelated values through one string and asking the model to parse them out itself reintroduces exactly the ambiguity a command was supposed to remove. Use $1...$N for genuinely structured input, or split the command in two.
  • Unscoped allowed-tools. Leaving the field off entirely, or setting it wide open, means a command written for read-only review can end up running arbitrary shell commands if the prompt is ever manipulated or the model misinterprets a step. Scope it to what the command actually needs.

Bash embeds run with your permissions

A ! embed executes on your machine, under your shell, with your credentials — the same as running that command by hand. Review a command file's bash embeds with the same scrutiny you'd apply to a shell script someone asked you to run, before you add it to a shared repo.

Worked example: one command, three expansion mechanisms#

The examples so far each isolated one mechanism at a time so the concept was easy to spot. Real commands almost never stay that clean — a useful command usually needs positional arguments, a bash embed, and a file reference all in the same template. Here's one built that way, followed by exactly what the model receives after every marker in it has been expanded.

The command file#

.claude/commands/release-notes.md
---
description: Draft release notes for a PR against a target branch
argument-hint: <pr-number> <target-branch>
allowed-tools: Read, Bash(git log:*), Bash(git diff:*)
---
Draft release notes for PR #$1, to be merged into $2.
Follow the house style below — don't invent a new format:
@docs/release-notes-style.md
Recent commit history on this branch, for context on what actually
shipped (not just what the PR title claims):
!`git log --oneline -5`
Diff summary against $2:
!`git diff $2...HEAD --stat`
Write one entry per meaningful change. Skip anything that's pure
refactor with no user-facing effect.

What the model actually receives#

Invoked as /release-notes 4821 release/2.4, every $1 and $2 is substituted, both ! commands have already run on your machine, and the @ reference has already been read from disk — the model never sees a marker, a shell command, or a file path that it has to go fetch itself. This is the plain text that lands in context:

bash
Draft release notes for PR #4821, to be merged into release/2.4.
Follow the house style below — don't invent a new format:
# Release notes style
- One bullet per change, past tense, no ticket numbers in the text
- Lead with user impact, not implementation detail
- Group under Added / Fixed / Changed, omit empty groups
Recent commit history on this branch, for context on what actually
shipped (not just what the PR title claims):
a3f9d21 fix: correct rounding in seat-count billing calc
6c11b08 feat: add CSV export to team usage page
0e4a7fc test: cover CSV export edge cases
9b2d150 chore: bump lockfile
5f0a933 feat: add CSV export to team usage page (wip)
Diff summary against release/2.4:
app/team/usage/page.tsx | 48 +++++++++++++++++
lib/billing/seat-count.ts | 6 +-
2 files changed, 51 insertions(+), 3 deletions(-)
Write one entry per meaningful change. Skip anything that's pure
refactor with no user-facing effect.

Notice the model is left to do exactly the part that needs judgment — collapsing five commits (one of them a duplicate WIP) and a two-file diff stat into release notes that read like something a person wrote — and none of the part that's pure mechanics. Nothing here depended on the model remembering to check the diff or deciding to go read a style guide; both were already done by the time it started reasoning.

How this fails in practice#

Commands look too simple to fail interestingly — it's just template substitution, after all — but the same four problems keep showing up once a team has more than a handful of them in real use.

Positional arguments land in the wrong slot#

Someone invokes /backport release/2.4 4821 instead of /backport 4821 release/2.4, and the template substitutes them exactly where they landed — $1 becomes release/2.4, $2 becomes 4821, and the agent tries to cherry-pick commits from PR #release/2.4. There's no schema validating that $1 should look like a number and $2 like a branch name — the template has no idea what either value means, it just substitutes strings. The fix is to keep positional arguments to two, maybe three, and add a bash embed early in the template that checks the shape of what it received — a one-line [[ "$1" =~ ^[0-9]+$ ]] || echo "expected a PR number, got: $1" gives the model something concrete to notice before it acts on a swapped argument instead of after.

The bash embed fails silently#

A command that worked for months starts expanding with an empty diff summary, and nobody notices until a release note is missing a whole feature. The embed ran git diff $2...HEAD --stat against a branch that no longer exists (renamed, deleted after merge); git wrote an error to stderr, and the template has no mechanism to surface that — it inlines whatever came back, including nothing. Bash embeds fail the way any shell command fails: quietly, unless you check for it. A fallback on anything that can plausibly return empty — !`git diff $2...HEAD --stat || echo "diff failed: check that $2 exists"` — turns a silent gap into a visible one.

allowed-tools scope creeps until it stops meaning anything#

review-migration.md started with allowed-tools: Read, Grep, Bash(git diff:*). Eight months and eleven small edits later, it's allowed-tools: Read, Grep, Bash(*) — someone needed one more git subcommand, widening the wildcard was faster than naming it, and nobody flagged it because a frontmatter diff doesn't look like a security change in a normal PR review. The fix isn't technical, it's a review habit: treat a diff that widens allowed-tools the same way you'd treat one that widens an IAM policy — call it out, and ask whether the actual subcommand needed can be named instead of the whole tool left open.

The same command name behaves differently for different teammates#

One engineer's /review-migration checks lock duration and reversibility. A teammate's does something slightly narrower, even though both typed the same command name in the same repo. Usually a personal command at ~/.claude/commands/review-migration.md is shadowing the repo's .claude/commands/review-migration.md for that one person, left over from an earlier experiment. Nothing warns you when a user-level command shares a name with a project-level one — it just resolves to whichever precedence picks, silently, for that person only. Checking ~/.claude/commands/ for stale duplicates is worth doing the first time a command "works for me but not for you."

Design decisions: where a command lives, and whether it can call another#

Repo-level vs. user-level namespace#

A command saved to .claude/commands/ is committed, reviewed, and shared — it's tooling the whole team gets the moment they pull the branch. A command saved to ~/.claude/commands/ lives only on your machine, isn't reviewed by anyone, and is the right place for something you're still iterating on or that's genuinely personal — a shortcut for how you like your commit messages formatted, say, rather than a house style the team has agreed on. The practical rule: start anything experimental at the user level, where getting it wrong costs you nothing but your own time, and promote it to the repo once it's stable enough that you'd be comfortable with a teammate depending on it without asking you first. Commands that never graduate — because nobody remembered to, not because they weren't ready — are exactly how you end up with the previous failure mode.

Can a command call another command?#

Not directly — there's no syntax for one command template to invoke another by name, the way a function calls another function. What actually happens if you write /other-command inside a command's body is that it gets treated as plain text, expanded and sent to the model like everything else, not resolved as a nested invocation. If two commands genuinely share a chunk of logic — the same style guide reference, the same validation embed — the pattern that works is composition through a shared file, not composition through invocation: pull the common part into its own file under docs/ or a dedicated .claude/commands/_shared/ convention, and have both command templates @-reference it. Each command stays a flat, fully-expanded prompt — the thing that makes it possible to reason about exactly what the model receives, the property this entire post has been building toward — and the shared logic stays in exactly one place to edit.

Commands in Noddle Deck#

You don't need to start every command from a blank file. The Noddle Deck marketplace carries 329 slash commands across categories like git, testing, devops, and documentation — each one already structured the way this post describes: frontmatter, a clear argument hint, and a template that's been used enough times to have the rough edges worn off. Persona packs bundle the ones that make sense for a given role alongside their matching skills, so a fresh install already has a working /review-migration-style command instead of an empty commands folder.

bash
noddle-deck pack install developer

Installed commands land in ~/.claude/commands/noddle-deck-{pack}/, ready to autocomplete the next time you type /. Reading one end to end is a fast way to see argument handling, bash embeds, and tool scoping applied to a command that's already been through real use — a solid starting point for the next command you write for your own team.

Put this into practice

Noddle Deck packs ship curated skills and slash-commands for your role — install one and see this in action.

Browse persona packs