CCLearn

Dynamic injection with !`cmd` — and when to refuse it

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

By the end you can

  • Inject live command output into a skill and say when the command runs
  • Recognise the two places the placeholder is silently not recognised
  • Explain why a non-zero exit takes the whole invocation down, and defuse it
  • State why an injected command never prompts, and what that risks in a repo you did not write
  • Decide against injection when a command is slow, conditional, or side-effecting
  • Turn injection off by policy with disableSkillShellExecution, and say which skills the switch does not reach

Your review skill opens with "run git diff HEAD and summarise it." It works most of the time. Sometimes Claude runs a slightly different diff, sometimes it stops to ask permission, and sometimes it decides it already knows what changed and skips the step. Injection removes the choice — the command runs before Claude sees a word of the skill — and charges you a failure mode that takes the whole skill down and a permission path that never asks.

What the syntax does, and when it runs

The !`<command>` syntax runs shell commands before the skill content is sent to Claude. The command output replaces the placeholder, so Claude receives actual data, not the command itself. — Skills

The first skill the documentation teaches is built on it:

yaml
---
description: Summarizes uncommitted changes and flags anything risky. Use when the user asks what changed, wants a commit message, or asks to review their diff.
---

## Current changes

!`git diff HEAD`

## Instructions

Summarize the changes above in two or three bullet points, then list any risks you notice such as missing error handling, hardcoded values, or tests that need updating. If the diff is empty, say there are no uncommitted changes.

Every fact below follows from the order of operations. You invoke the skill; Claude Code runs each command through the Bash tool — the default where bash is available, and the shell every claim here assumes — in your session, now. The rendered text, diff inlined, becomes the message that enters the conversation. Only then does the model read anything, so it never had the option to skip git diff or to run it differently. That is the point, and it is why the failures below are yours to design around rather than the model's to recover from.

Two places the placeholder is not recognised

Both are silent. Nothing errors; the command simply never runs.

What you wroteWhat happens
!`git status --short` at a line start, or after whitespaceRecognised, runs, output replaces it
KEY=!`cmd` — ! immediately after another characterLeft as literal text; the command does not run
A placeholder produced by another command's outputInserted as plain text, never re-scanned

The inline form is only recognized when ! appears at the start of a line or immediately after whitespace. — Skills

Row three is a second, separate rule: substitution runs once over the original file, and command output "is not re-scanned for further !`<command>` placeholders, so a command cannot emit a placeholder for a later pass to expand" (Skills). For anything longer than a line, use the fenced form rather than stringing inline placeholders:

text
## Environment
```!
node --version
git status --short
```

A failed command takes the whole invocation with it

A failed command aborts the entire skill invocation, not just its own placeholder. Claude never sees the skill content for that invocation. The abort shows Shell command failed for pattern "...". — Skills

Not degraded, not "that section came back empty". Nothing. And the error names a pattern rather than your skill, so it reads like an unrelated shell failure.

Exit code, default bash shellResult
0Output injected
1 from grep, rg, egrep, fgrep, find, diff, test, [, git diff, git grepTreated as a normal result; output injected
1 from anything elseInvocation aborted
2 or higher, including from the commands aboveInvocation aborted

That carveout list is exact, and its edges are the trap: the tools reference names no matches for pgrep and jq -e, and files that differ for cmp, as cases that count as failures anyway (Tools reference). The documented defusal is one suffix — "append || true to any other command you expect to exit non-zero. A check script that exits 1 when it finds problems is one example" (Skills).

Injected commands never ask

Lesson 1.3 taught the permission prompt as the moment you get a say. Injected commands do not have that moment.

Injected commands never prompt for permission. When a command's permission check returns anything other than allow, Claude Code aborts the invocation. This includes a rule that would normally ask you. — Skills

Permission check returnsWhat happens
allowThe command runs, silently
askInvocation aborted — you are never asked
denyInvocation aborted
No matching ruleInvocation aborted

Exactly two outcomes, then: it runs without telling you, or the skill does not run at all. Pre-approving with allowed-tools (Lesson 7.2) stops an unmatched command aborting, but has a ceiling — "a matching ask or deny rule still aborts the invocation regardless of allowed-tools."

The sharp end of that is someone else's repository. Lesson 7.2's warning is that workspace trust does not gate allowed-tools; put it together with the order of operations above and a skill you did not write can run a command on your machine, pre-approved by its own frontmatter, before the model reads anything and before you approve anything. git clone, claude, /their-skill. Reading a stranger's ! lines is the same habit as reading a diff before accepting it, one step earlier.

Working directory, timeout, stderr, output size

An injected command shares the working directory, timeout and output handling of Claude's own shell commands. Four mechanics follow from that (Skills):

  • Working directory. Each command runs in the session shell's current working directory, which moves when Claude runs cd. Paths that must resolve identically every time need ${CLAUDE_SKILL_DIR} or ${CLAUDE_PROJECT_DIR}, which Lesson 7.3 covers.
  • Timeout. Each runs under the Bash tool's default 2-minute timeout. One the tool moves to the background still renders, with the injected text naming the background task; one it never auto-backgrounds is killed, and that aborts the invocation like any other failure.
  • stderr. Under the default bash shell, stderr is merged into stdout, so every deprecation warning lands in the prompt as context you pay for.
  • Output size. Output past the Bash tool's inline ceiling — roughly 30,000 characters by default — "arrives as a file path plus a short preview, not truncated text" (Skills; the ceiling is in Tools reference). A huge diff does not inline itself; it becomes a path Claude has to go and read.

When to refuse it

The docs do not give a rule for when to leave injection out. This table is engineering judgement, and every row traces back to a mechanic above — check each as you read it.

Do not inject when the command…BecauseInstead
Takes more than a couple of minutesThe 2-minute timeout: killed, which aborts the skill, or backgrounded, and then the skill renders without the data you wantedLet Claude run it as an ordinary tool call
Exits non-zero as a normal outcomeAny non-zero exit outside the carveout abortsAppend the escape hatch above, or ask for it in instructions
Is only sometimes relevantIt runs on every invocation, before anything about the task is knownInstruct Claude to run it when the situation calls for it
Writes, deletes, pushes or deploysNo prompt, no model judgement, and it fires before the skill is readA normal tool call, so the permission prompt exists
Produces bulky outputUnder the inline ceiling it is charged every later turn, because the body persists (Lesson 7.5); over it, you get a file path instead of the dataNarrow the command to the paths you need

Injection is right when a command is fast, read-only, reliably zero-exit, and needed every single time. git diff, git status --short, node --version — facts you always want, cheap to fetch, boring when they fail.

Turning it off by policy

To disable this behavior for skills and custom commands from user, project, plugin, or additional-directory sources, set "disableSkillShellExecution": true in settings. Each command is replaced with [shell command execution disabled by policy] instead of being run. Bundled and managed skills are not affected. — Skills

json
{
  "disableSkillShellExecution": true
}

Two properties matter. The replacement is a fixed string, [shell command execution disabled by policy], so what reaches Claude says plainly that a command was skipped rather than leaving a section mysteriously empty — but it does not show you the command, and reading the ! lines is still a job for you and the file. And in managed settings the switch is one-way: "A true in managed settings can't be overridden by false elsewhere" (Settings reference). Set in your own settings, it is a way to open an unfamiliar repository with that repository's skill commands replaced instead of run. One category sits outside the switch in the other direction: Claude Code "never runs these commands on your machine when they appear in skills synced from your claude.ai account, regardless of this setting" (Skills), so everything in this lesson is about skills that live on your disk.

Checkpoint

  1. A teammate's /audit skill worked for a month and today prints only Shell command failed for pattern "...". The skill file has not changed. What is the most likely cause, and what suffix on one line fixes it?

    Show answer

    One of its injected commands exited non-zero for the first time — almost certainly a lint or check command that exits 1 when it finds problems. The file did not change because the file is not what changed; the repository is. A failed command aborts the entire skill invocation rather than just its own placeholder, so Claude never sees the skill content, which is why you get an error and no output at all.

    Append || true to that line. Note which commands cannot be the culprit: exit 1 from grep, rg, find, diff, test, git diff and the rest of the carveout list is treated as a normal result, so look for something outside that list, or anything exiting 2 or higher. The message naming a pattern rather than the skill is what makes this read like an unrelated shell failure.

  2. You add !`aws s3 sync ./dist s3://prod` to a deploy skill and set disable-model-invocation: true so only you can start it. Is the deploy now safe? Walk the sequence of events from the moment you type /deploy.

    Show answer

    No. disable-model-invocation controls who may start the skill; it says nothing about what happens once it starts, and injection happens before the model is involved at all.

    The sequence: you type /deploy. Claude Code runs each !`…` command through the Bash tool, in your session, now. The permission check runs without prompting you — allow and the sync runs silently, anything else (ask, deny, or no matching rule) aborts the whole invocation. So ./dist is either already in production or the skill did not run. Only then is the rendered text sent to Claude, which reads a skill describing a deploy that has already happened. No prompt, no model judgement, and no moment where you could check that ./dist was the build you meant. This is the "writes, deletes, pushes or deploys" row: make it a normal tool call so the permission prompt exists.

  3. You write VERSION=!`git rev-parse --short HEAD` in a skill body and the version never appears, though the command runs fine in your shell. Why?

    Show answer

    The ! sits immediately after another character. The inline form is only recognised when ! is at the start of a line or immediately after whitespace, so VERSION= in front of it means the placeholder is left as literal text and the command never runs. Claude receives the line exactly as you typed it, backticks and all — no error, no warning, no marker, which is why you have to check ! lines by eye.

    The shell habit is the trap: there is no variable to assign to. Injection replaces the placeholder with the command's output, so put the placeholder at the start of its own line under a heading, or use the fenced ```! form for several commands.

  4. Which of these belong in an injected command and which in the instructions: git status --short, npm test, cat package.json, terraform apply?

    Show answer

    Inject git status --short. It is the shape injection is for — fast, read-only, reliably zero-exit, and wanted every time — and the lesson names it alongside git diff and node --version.

    Instructions for npm test and terraform apply, for different reasons. npm test exits non-zero as a normal outcome, so the first failing test aborts the whole invocation, and a slow suite runs into the 2-minute timeout; || true defuses the exit code but not the timeout. terraform apply writes: no prompt, no model judgement, and it fires before the skill is even read. cat package.json is the judgement call — it is fast, read-only and zero-exit, so inject it if the skill needs it on every invocation, and leave it to Claude if it is only sometimes relevant, since an injected command runs before anything about the task is known and its output is then charged on every later turn.

Sources