Build your own Claude Code
Skills & slash commands·25 min

Invoke a skill

0 / 1
# grade this stage (from your course directory)
npx theinference test --stage invoke --watch

Parsing got you the body. Invoking makes it live. When the user types /fix auth bug, the agent loads the skill's body and the model never sees the raw template — it sees a body where every placeholder has been filled in. Fix $ARGUMENTS[0] becomes Fix auth. That substitution pass is this step.

The arguments the user typed after the command, the current session id, the skill's own directory — all of them get spliced into the body before it reaches the model. This is also where dynamic context lands in real Claude Code; here you build the variable-expansion core that the rest is layered on.

The substitution grammar
$ARGUMENTS            all args, space-joined        "auth bug"
$ARGUMENTS[0]         the arg at index 0            "auth"
$0, $1, ...           positional arg by index       $0 → "auth"
${CLAUDE_SESSION_ID}  the current session id        "s1"
${CLAUDE_SKILL_DIR}   the skill's own directory     "/skills/x"

/fix auth bug   →   args = ["auth", "bug"]

What to build

Your task

Implement expandSubstitutions

Implement expandSubstitutions(body, env) in src/skills.ts. env carries { args: string[], sessionId?: string, skillDir?: string }. Replace every occurrence of each placeholder in body:

  • $ARGUMENTS[n] → env.args[n], or "" if out of range. Do this first — before bare $ARGUMENTS — or the [n] gets eaten.
  • $ARGUMENTS → env.args.join(" ").
  • $n (a bare digit, e.g. $0, $1) → env.args[n], or "".
  • ${CLAUDE_SESSION_ID} → env.sessionId ?? "".
  • ${CLAUDE_SKILL_DIR} → env.skillDir ?? "".

Wire program.ts skill-invoke to call it and print { expanded: expandSubstitutions(C.body, C.env) }.

tests/adapters.ts
import { expandSubstitutions, type SubstitutionEnv } from '../src/skills'

export function runExpand(body: string, env: SubstitutionEnv): string {
  return expandSubstitutions(body, env)
}
Indexed form before the bare form

Order is load-bearing. If you replace $ARGUMENTS first, the text $ARGUMENTS[0] becomes auth bug[0] — the indexed match is gone and the [0] is left dangling. So compile the indexed pattern (/\$ARGUMENTS\[(\d+)\]/g) and run it before the plain $ARGUMENTS replacement. The same care applies to $n: it is just a bare digit after $, so write the regex so it doesn't accidentally swallow part of a longer token. Replace every occurrence (use a global replace), not just the first.

Missing values expand to empty, not to the literal

A placeholder that has no value resolves to the empty string — never to the literal $ARGUMENTS[5] text. An out-of-range index, an absent sessionId, an unset skillDir: all of them become "". The model should never see a raw ${...} token in the expanded body; if it does, your substitution missed a case.

How it's graded

Process mode runs program.ts skill-invoke over the fixture and compares { expanded } to expected.json. The case body is Fix $ARGUMENTS[0] in ${CLAUDE_SKILL_DIR}; all=$ARGUMENTS; first=$0 with env = { args: ["auth", "bug"], skillDir: "/skills/x", sessionId: "s1" }, so the expected output is Fix auth in /skills/x; all=auth bug; first=auth. Note how $ARGUMENTS[0] and $0 both resolve to auth while bare $ARGUMENTS joins to auth bug — that's the ordering trap, graded directly.