Arguments: $ARGUMENTS, positions, names and ${CLAUDE_*}
By the end you can
- Parameterise a skill with $ARGUMENTS and say what happens when no placeholder receives one
- Address arguments by zero-based position and by name via the arguments field
- Predict what renders when an argument is missing, quoted, or itself looks like a placeholder
- Use ${CLAUDE_SKILL_DIR} and ${CLAUDE_PROJECT_DIR} so a bundled script resolves from anywhere
- State the stacking limit and the argument every stacked skill shares
The release-notes skill from Lesson 7.1 takes no input: it does the same thing
every time you type it. The moment you want a skill to act on something — this
issue, that component, this branch — you either write six near-identical skills
or one that takes a parameter. The substitution rules are mechanical and mostly
obvious, and the handful that are not fail quietly enough that the skill still
runs and describes the wrong thing.
$ARGUMENTS, and the fallback when nothing receives it
Both you and Claude can pass arguments when invoking a skill. Arguments are available via the
$ARGUMENTSplaceholder. — Skills
Everything you type after the command name is the argument text. The docs' own example:
---
name: fix-issue
description: Fix a GitHub issue
disable-model-invocation: true
---
Fix GitHub issue $ARGUMENTS following our coding standards.Run /fix-issue 123 and Claude receives "Fix GitHub issue 123 following our
coding standards…". Note which frontmatter field the docs pair this with:
disable-model-invocation: true, from Lesson 7.2. A skill you hand a specific
value to is usually a skill you start yourself.
If you pass arguments and nothing in the body catches them, they are not lost:
If you invoke a skill with arguments but no placeholder in the skill's content receives one, Claude Code appends
ARGUMENTS: <your input>to the end of the skill content so Claude still sees what you typed. — Skills
That is a safety net, not a feature. Fix GitHub issue 123 following our coding standards and a skill about fixing issues with a stray ARGUMENTS: 123 glued to
the bottom are not the same prompt. "Receives one" is also defined precisely: an
indexed placeholder with no argument at its position stays as literal text and
does not count, while a named placeholder counts even when its position is
empty, because it expands to an empty string.
Positions are zero-indexed
| Placeholder | Expands to |
|---|---|
$ARGUMENTS | The full argument string as typed |
$ARGUMENTS[N] | The argument at 0-based index N |
$N | Shorthand for the same — $0 is the first, $1 is the second |
$name | A name declared in the arguments frontmatter list, mapped by position |
The documented, correct version of that skill:
---
name: migrate-component
description: Migrate a component from one language to another
---
Migrate the $0 component from $1 to $2.
Preserve all existing behavior and tests.$ARGUMENTS[0], $ARGUMENTS[1], $ARGUMENTS[2] is the long form of the same
three placeholders, and is worth the extra characters in a skill someone else
will edit.
Named arguments
Declare names in frontmatter and the positions get labels:
Names map to positions in order, so with
arguments: [issue, branch]the placeholder$issueexpands to the first argument and$branchto the second. — Skills
---
name: backport
description: Backport a merged fix to a release branch
argument-hint: [issue-number] [branch]
arguments: [issue, branch]
---
Backport the fix for issue $issue onto $branch.The field accepts a space-separated string or a YAML list. Names do not remove
the positional coupling — they document it. Reordering the arguments list
silently changes what $issue means, so that list is part of the skill's
interface, not a comment.
argument-hint is a different thing again and does less than its name suggests:
Hint shown during autocomplete to indicate expected arguments. Example:
[issue-number]or[filename] [format]. — Skills
Autocomplete help, and nothing else the docs describe. No documented behaviour checks what you typed against the hint, and a skill invoked with fewer arguments than the hint names still runs. The next section is what that looks like.
Missing, quoted, and literal
Six behaviours, all silent, all worth being able to predict:
| You write / type | Claude receives |
|---|---|
$2, with only one argument passed | $2 — the placeholder, unchanged, as text |
$branch, with no second argument | Nothing — an empty string |
/my-skill "hello world" second | $0 is hello world, $1 is second |
$ARGUMENTS for that same invocation | The full argument string as typed |
/summarize "$ARGUMENTS from yesterday" into a body with Summarize $0 | Summarize $ARGUMENTS from yesterday |
\$1.00 in prose | $1.00 |
A missing index leaves visible debris in the prompt; a missing name leaves
nothing. Neither is an error, so if a skill genuinely needs its second argument,
say so in the body — "if no branch was given, ask which one" — because no
frontmatter field in the reference will say it for you. The two forms also
disagree about what a value is: indexed arguments use shell-style quoting,
while $ARGUMENTS always expands to the full argument string as typed.
The fifth row is the one that surprises people. An argument value containing
$1 or $ARGUMENTS is inserted as literal text and not expanded again —
substitution is one pass, not a loop. The last row is the escape hatch, and it is
narrow: a backslash escapes a $ before a digit, before ARGUMENTS, or before a
declared argument name, and is left unchanged anywhere else. A doubled backslash
such as \\$1 leaves both backslashes in place and still expands the argument.
The ${CLAUDE_*} variables
A second family of substitutions, filled from the environment rather than from what you typed:
| Variable | Resolves to |
|---|---|
${CLAUDE_SKILL_DIR} | The directory containing this skill's SKILL.md |
${CLAUDE_PROJECT_DIR} | The project root — the same path hooks and MCP servers receive |
${CLAUDE_SESSION_ID} | The current session ID |
${CLAUDE_EFFORT} | The current effort level: low, medium, high, xhigh or max |
${CLAUDE_PLUGIN_ROOT}, ${CLAUDE_PLUGIN_DATA} | Plugin skills only — Level 12 |
${CLAUDE_SKILL_DIR} is the one that stops a skill breaking in a way you will
struggle to reproduce. A bundled script referenced by a relative path resolves
against wherever the session's shell currently is, and that moves: Claude Code
ships an environment variable, CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR, whose
entire job is to "return to the original working directory after each Bash or
PowerShell command" (Environment
variables) — which tells you what a
cd does when it is unset. The docs give the variable exactly that purpose:
reference scripts or files bundled with the skill, regardless of the current
working directory. ${CLAUDE_PROJECT_DIR} does the same job for project-local
files such as ${CLAUDE_PROJECT_DIR}/.claude/hooks/helper.sh, independent of
where the skill is installed. That substitution requires Claude Code v2.1.196 or
later.
The ordering is documented and cuts against the previous section: an argument
value containing $1 is inserted literally, but Claude Code still replaces
${CLAUDE_*} variables after it inserts the arguments. A backslash does not
prevent that substitution either. The two families do not behave alike, and the
${CLAUDE_*} one is the greedier of the two.
One variable, two substitution sites
Claude Code substitutes
${CLAUDE_SKILL_DIR}and${CLAUDE_PROJECT_DIR}in two places: the skill's markdown content, and Bash rules in theallowed-toolsfrontmatter. […] Using the same variable in both places lets a skill run a bundled script without a permission prompt. — Skills
---
name: render-chart
description: Render a chart from a CSV file
allowed-tools: Bash(${CLAUDE_SKILL_DIR}/scripts/render.sh *)
---
Run `${CLAUDE_SKILL_DIR}/scripts/render.sh <csv-file>` to render the chart.Installed at ~/.claude/skills/render-chart/, both occurrences expand to that
directory, so the permission rule matches the exact command the body tells Claude
to run and the script runs without prompting. Lesson 7.2 established that
allowed-tools is a turn-scoped grant rather than a sandbox; this is the case
where making the grant match is the hard part, and the variable is what keeps
the two strings identical wherever the skill ends up. Hardcode the path instead
and you have written the install location of one machine into two places: move
the skill into a project checkout, or onto a teammate's machine, and the body
names a script that is not there — and you now have to remember to edit the
allowed-tools rule to match every time you edit the body.
Stacking skills in one message
A command is only recognised at the start of your message, and text after the command name becomes its arguments. Skills are the one exception, and only from v2.1.199 (Commands):
You can also stack several skills at the start of one message. Typing
/write-tests /fix-issue 123loads both skills and passes the trailing text123as$ARGUMENTSto each of them. — Skills
Three limits define what this is good for:
- Six, at most. The first skill plus up to five more stacked after it.
- Expansion stops at the first token that is not an inline user-invocable
skill. That token and everything after it become the argument text for every
skill that did expand. A skill that runs as a forked subagent —
/code-review— ends the run there, and so does one whose own arguments may start with a slash, such as/loop. - One argument string, shared. Every expanded skill receives the same trailing text. There is no per-skill argument.
Two version lines run through that, and both matter the moment a teammate is on
an older build. Before v2.1.199 only the first skill loaded, and it received
/fix-issue 123 as literal argument text — one skill plus a strange-looking
argument, rather than a stack. And /code-review runs as a forked subagent only
from v2.1.218; before that it ran inline and stacked like any other skill, so
which token stops expansion was once a question about the version as much as
about the skill.
So stacking composes instructions, not parameters. /write-tests /fix-issue 4412 is two sets of standing instructions pointed at one issue number, which is
useful. Anything that needs two skills to receive different values is two
messages.
What you are actually publishing
An argument turns a skill from a note-to-self into an interface, and not one of
the rules above raises an error when you get it wrong. That makes the first real
invocation the test: run it with arguments you can recognise in the output, and
check that the work Claude starts is the work your template
described. Lesson 5.5 was about a run that completes and is still wrong; a skill
that renders $1 as the second argument and proceeds cheerfully is that shape,
moved into a file you wrote.
Checkpoint
-
A skill's body reads
Migrate the $1 component from $2 to $3and you run/migrate-component SearchBar JavaScript TypeScript. Nothing errors. What does Claude actually receive, including at$3?Show answerAnswer
Claude receives
Migrate the JavaScript component from TypeScript to $3. Positions are zero-indexed, so$1is the second argument and$2the third;$3has no argument at its position, so it stays in the prompt as the literal text$3.SearchBarnever appears at all. It sits at$0, nothing in the body references$0, and theARGUMENTS:safety net does not fire here because two placeholders did receive arguments — the net is only appended when no placeholder receives one. The shell habit that$1is the first argument is the trap: the skill runs, the output is confidently about the wrong component, and it reads like a model mistake rather than an off-by-one in your template. Write$0,$1,$2— or the long form$ARGUMENTS[0]and friends. -
Two skills declare
arguments: [issue, branch]. One body uses$branch, the other uses$1. You invoke each with a single argument,4412. What does each rendered sentence look like?Show answerAnswer
They fail in opposite directions, and neither errors.
$branchis the second position, which is empty, and a named placeholder with an empty position expands to an empty string — so the sentence renders with a silent gap where the branch should be. Worse, a named placeholder counts as receiving an argument even when its position is empty, so noARGUMENTS: 4412is appended:4412appears nowhere in that prompt.$1is also the second position, but an indexed placeholder with no argument at its position stays as literal text — the sentence keeps a visible$1— and it does not count as receiving one, so Claude Code appendsARGUMENTS: 4412to the end of the skill content. A missing index leaves debris; a missing name leaves nothing. Both skills wanted$issueor$0, and if the second argument is genuinely required, say so in the body — no frontmatter field enforces it. -
A skill body runs
bash scripts/check.sh. It works when you start Claude Code in the repository root and stops working after Claude has changed directory. Which substitution fixes it, and which second place must that same string appear for the script to run without a permission prompt?Show answerAnswer
Use
${CLAUDE_SKILL_DIR}—bash ${CLAUDE_SKILL_DIR}/scripts/check.sh. It resolves to the directory containing this skill'sSKILL.mdregardless of the current working directory, which is exactly the documented purpose. A relative path instead resolves against wherever the session's shell currently is, and that moves;CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIRexists to return to the original directory after each Bash command, which tells you what acddoes when it is unset.The second place is the Bash rule in
allowed-tools:Bash(${CLAUDE_SKILL_DIR}/scripts/check.sh *). Claude Code substitutes the variable in both the markdown content andallowed-toolsBash rules, and using the same variable in both is what makes the rule match the exact command the body tells Claude to run — so the script runs without prompting wherever the skill is installed. If the script is project-local rather than bundled with the skill,${CLAUDE_PROJECT_DIR}is the variable, in the same two places, and it needs v2.1.196 or later. -
You type
/write-tests /code-review /fix-issue 4412. Which of those three skills expand, what argument text does the one that did receive, and which version change made that the answer rather than a three-skill stack?Show answerAnswer
Only
/write-testsexpands. Expansion stops at the first token that is not an inline user-invocable skill, and/code-reviewruns as a forked subagent, so it ends the run there. That token and everything after it become the argument text, so/write-testsreceives the literal string/code-review /fix-issue 4412—/fix-issuenever loads, and4412arrives only as part of that text.The version is v2.1.218, when
/code-reviewbecame a forked subagent. Before it,/code-reviewran inline and stacked like any other skill, so the same message gave you three skills sharing the argument4412. Which token stops expansion was once a question about your build as much as about the skill — and on anything older than v2.1.199 you land back on this same rendered result for a different reason again, since only the first skill loaded and everything after it was literal argument text.