Dynamic injection with !`cmd` — and when to refuse it
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:
---
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 wrote | What happens |
|---|---|
!`git status --short` at a line start, or after whitespace | Recognised, runs, output replaces it |
KEY=!`cmd` — ! immediately after another character | Left as literal text; the command does not run |
| A placeholder produced by another command's output | Inserted 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:
## 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 shell | Result |
|---|---|
0 | Output injected |
1 from grep, rg, egrep, fgrep, find, diff, test, [, git diff, git grep | Treated as a normal result; output injected |
1 from anything else | Invocation aborted |
2 or higher, including from the commands above | Invocation 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 returns | What happens |
|---|---|
| allow | The command runs, silently |
| ask | Invocation aborted — you are never asked |
| deny | Invocation aborted |
| No matching rule | Invocation 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
bashshell, 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… | Because | Instead |
|---|---|---|
| Takes more than a couple of minutes | The 2-minute timeout: killed, which aborts the skill, or backgrounded, and then the skill renders without the data you wanted | Let Claude run it as an ordinary tool call |
| Exits non-zero as a normal outcome | Any non-zero exit outside the carveout aborts | Append the escape hatch above, or ask for it in instructions |
| Is only sometimes relevant | It runs on every invocation, before anything about the task is known | Instruct Claude to run it when the situation calls for it |
| Writes, deletes, pushes or deploys | No prompt, no model judgement, and it fires before the skill is read | A normal tool call, so the permission prompt exists |
| Produces bulky output | Under 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 data | Narrow 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": truein settings. Each command is replaced with[shell command execution disabled by policy]instead of being run. Bundled and managed skills are not affected. — Skills
{
"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
-
A teammate's
/auditskill worked for a month and today prints onlyShell 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 answerAnswer
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
|| trueto that line. Note which commands cannot be the culprit: exit 1 fromgrep,rg,find,diff,test,git diffand 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. -
You add
!`aws s3 sync ./dist s3://prod`to a deploy skill and setdisable-model-invocation: trueso only you can start it. Is the deploy now safe? Walk the sequence of events from the moment you type/deploy.Show answerAnswer
No.
disable-model-invocationcontrols 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 —allowand the sync runs silently, anything else (ask,deny, or no matching rule) aborts the whole invocation. So./distis 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./distwas the build you meant. This is the "writes, deletes, pushes or deploys" row: make it a normal tool call so the permission prompt exists. -
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 answerAnswer
The
!sits immediately after another character. The inline form is only recognised when!is at the start of a line or immediately after whitespace, soVERSION=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. -
Which of these belong in an injected command and which in the instructions:
git status --short,npm test,cat package.json,terraform apply?Show answerAnswer
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 alongsidegit diffandnode --version.Instructions for
npm testandterraform apply, for different reasons.npm testexits 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;|| truedefuses the exit code but not the timeout.terraform applywrites: no prompt, no model judgement, and it fires before the skill is even read.cat package.jsonis 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.