CCLearn

Arguments: $ARGUMENTS, positions, names and ${CLAUDE_*}

verified against Claude Code 2.1.261 · 2026-09-0915 min

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 $ARGUMENTS placeholder. — Skills

Everything you type after the command name is the argument text. The docs' own example:

yaml
---
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

PlaceholderExpands to
$ARGUMENTSThe full argument string as typed
$ARGUMENTS[N]The argument at 0-based index N
$NShorthand for the same — $0 is the first, $1 is the second
$nameA name declared in the arguments frontmatter list, mapped by position

The documented, correct version of that skill:

yaml
---
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 $issue expands to the first argument and $branch to the second. — Skills

yaml
---
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 / typeClaude receives
$2, with only one argument passed$2 — the placeholder, unchanged, as text
$branch, with no second argumentNothing — an empty string
/my-skill "hello world" second$0 is hello world, $1 is second
$ARGUMENTS for that same invocationThe full argument string as typed
/summarize "$ARGUMENTS from yesterday" into a body with Summarize $0Summarize $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:

VariableResolves 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 the allowed-tools frontmatter. […] Using the same variable in both places lets a skill run a bundled script without a permission prompt. — Skills

yaml
---
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 123 loads both skills and passes the trailing text 123 as $ARGUMENTS to 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

  1. A skill's body reads Migrate the $1 component from $2 to $3 and you run /migrate-component SearchBar JavaScript TypeScript. Nothing errors. What does Claude actually receive, including at $3?

    Show answer

    Claude receives Migrate the JavaScript component from TypeScript to $3. Positions are zero-indexed, so $1 is the second argument and $2 the third; $3 has no argument at its position, so it stays in the prompt as the literal text $3.

    SearchBar never appears at all. It sits at $0, nothing in the body references $0, and the ARGUMENTS: 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 $1 is 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.

  2. 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 answer

    They fail in opposite directions, and neither errors. $branch is 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 no ARGUMENTS: 4412 is appended: 4412 appears nowhere in that prompt.

    $1 is 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 appends ARGUMENTS: 4412 to the end of the skill content. A missing index leaves debris; a missing name leaves nothing. Both skills wanted $issue or $0, and if the second argument is genuinely required, say so in the body — no frontmatter field enforces it.

  3. 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 answer

    Use ${CLAUDE_SKILL_DIR} — bash ${CLAUDE_SKILL_DIR}/scripts/check.sh. It resolves to the directory containing this skill's SKILL.md regardless 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_DIR exists to return to the original directory after each Bash command, which tells you what a cd does 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 and allowed-tools Bash 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.

  4. 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 answer

    Only /write-tests expands. Expansion stops at the first token that is not an inline user-invocable skill, and /code-review runs as a forked subagent, so it ends the run there. That token and everything after it become the argument text, so /write-tests receives the literal string /code-review /fix-issue 4412 — /fix-issue never loads, and 4412 arrives only as part of that text.

    The version is v2.1.218, when /code-review became a forked subagent. Before it, /code-review ran inline and stacked like any other skill, so the same message gave you three skills sharing the argument 4412. 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.

Sources