Skill tool feedback loop: getPromptForCommand body re-injected as isMeta after every model-invoked Skill call
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet (closest matches: #54023, #41105, #19729, #48071 — all distinct, see "Related issues" below)
- [x] This is a single bug report
- [x] I am using the latest version of Claude Code
What's wrong?
The Skill tool (model-invoked) unconditionally injects the slash-command body for the named skill as an isMeta: true user message after every successful invocation, tagged with sourceToolUseID. When that body is self-referential — e.g. literally "invoke the skill via the Skill tool" — the model follows the instruction, calls Skill again, and the harness re-injects the same body. The loop continues until the model breaks out by responding with text instead of a tool call.
The Skill tool's docstring includes a safety check:
"If you see a <command-name> tag in the current conversation turn, the skill has ALREADY been loaded — follow the instructions directly instead of calling this tool again"
But the meta message injected by the harness contains only the slash-command body — no <command-name> tag — so this check never trips for model-invoked Skill calls. (The tag is only emitted on the user-typed-slash path, and only when the slash command is at message start, per the design confirmed in #19729.)
What should happen?
After the Skill tool returns, the model should not be in a position where the only available next action is to call Skill again. Either:
- The injected meta message should include a
<command-name>tag (or equivalent loop-breaker) so the existing safety check trips, or - The Skill tool should not inject
getPromptForCommandcontent as a follow-up meta message when invoked by the model (only on user-typed slash commands), or - The model should be told via the meta message that the skill is already loaded (current text says only "invoke the skill via the Skill tool" — pure redirect with no acknowledgement of state).
Mechanism (from binary + session JSONL)
From the v2.1.122 binary (strings -n 10 on ~/.local/share/claude/versions/2.1.122):
mapToolResultToToolResultBlockParam(H, $) {
if ("status" in H && H.status === "forked")
return {type:"tool_result", tool_use_id:$,
content:`Skill "${H.commandName}" completed (forked execution).\n${H.result}`};
return {type:"tool_result", tool_use_id:$,
content:`Launching skill: ${H.commandName}`};
}
For non-forked (default) Skill invocations, the tool result is just Launching skill: <name> — no SKILL.md content in the tool result itself. The skill's body content is delivered separately via:
async function Cs$(H, $, q) {
let _ = (await H.getPromptForCommand($, q))
.map(w => w.type === "text" ? w.text : "")
.join("\n");
// ...
let D = [$8({content: _})];
return { skillContent: _, ..., promptMessages: D };
}
promptMessages is built from getPromptForCommand (which reads the slash-command .md file) and gets injected as user messages. SG7 tags them with sourceToolUseID:
function SG7(H, $) {
if (!$) return H;
return H.map(q => {
if (q.type === "user") return { ...q, sourceToolUseID: $ };
return q;
});
}
The session JSONL confirms the resulting message shape:
{
"type": "user",
"message": {"role": "user", "content": [{"type": "text", "text": "Invoke the `superpowers:brainstorming` skill now using the Skill tool. Pass through any arguments the user provided.\n"}]},
"isMeta": true,
"sourceToolUseID": "toolu_01G6aqW1BtAz4f9zWXHBU7kg"
}
No <command-name> tag, so the Skill-tool safety check (quoted above) doesn't fire on next turn.
Steps to reproduce
This requires a plugin where a skill has a degenerate commands/<skill>.md body (one that effectively says "invoke the skill"). In our environment, the plugin cache has such a file for superpowers' brainstorming skill — origin unknown, since the file isn't in obra/superpowers at any tag from v3.1.0 through v5.0.7 or current main, and the literal string isn't in the Claude Code binary. So the file can't have come from upstream OR from a current Claude Code template. We suspect it's a legacy artifact from an earlier Claude Code version that auto-generated such files; it persists across plugin upgrades because version-bump rsync doesn't --delete per-file.
To repro deterministically without depending on that artifact:
- Create a plugin at
~/.claude/plugins/cache/local/repro/0.1.0/:
.claude-plugin/plugin.jsonwithname: repro,version: 0.1.0skills/loop/SKILL.md— any minimal skill with frontmattername: loop,description: "Trigger the loop bug", plus a few lines of bodycommands/loop.md:
```
---
description: "Trigger the loop bug"
---
Invoke the repro:loop skill now using the Skill tool. Pass through any arguments the user provided.
```
- Enable the plugin.
- Send a user message containing
/repro:loopmid-sentence (not at message start, to avoid the auto-load path):
let's load the /repro:loop skill here
- Observe the model calls
Skill(repro:loop)→ tool resultLaunching skill: repro:loop→ harness injects thecommands/loop.mdbody as anisMeta:trueuser message → model callsSkill(repro:loop)again → repeat.
Actual session evidence
Session d4ba0ae4-d68a-41f8-bebd-e1ef60b1c7be.jsonl, message indices 7–25 (truncated):
[7] user/user : "let's load the /superpowers:brainstorming skill here"
[16] assistant/tool_use : Skill(superpowers:brainstorming)
[17] user/tool_result : "Launching skill: superpowers:brainstorming"
toolUseResult={success:true, commandName:"superpowers:brainstorming"}
[18] user/text isMeta=True : "Invoke the `superpowers:brainstorming` skill now using the Skill tool. Pass through any arguments the user provided.\n"
sourceToolUseID=toolu_01G6aqW1BtAz4f9zWXHBU7kg
[23] assistant/tool_use : Skill(superpowers:brainstorming) ← model follows the meta msg, calls again
[24] user/tool_result : "Launching skill: superpowers:brainstorming"
[25] user/text isMeta=True : "Invoke the `superpowers:brainstorming` skill now ..." ← same body re-injected
The model only escaped the loop by responding with text instead of a tool call.
Workaround
Replace the offending commands/<skill>.md body with the actual SKILL.md content (so the meta injection delivers substantive instructions). Survives until the plugin cache is overwritten.
Related issues
- #54023 (open) — "Skill-loader returns 'Launching skill' but body fails to load" — same surface symptom (loop after "Launching skill"), but reporter hypothesizes args-size/buffer-limit cause. Different proximate cause; same harness-side mechanism is plausibly involved.
- #41105 (closed COMPLETED) — "Skill invocation is invisible when auto-loaded via slash command" — documents the positional behavior (slash command at start auto-loads, mid-sentence does not). The mid-sentence path is what routes execution into the model-invoked Skill tool, where this bug surfaces.
- #19729 (closed NOT_PLANNED) — "Skills with
disable-model-invocation: trueonly visible when slash command is at message start" — confirms the positional design is intentional. So the mid-sentence path that exposes this bug isn't going away. - #48071 (open) — "Inline plugin skill content not delivered after /clear" — different cause (
/clearand cron-spawn losing--plugin-dircontext), but related family of "Skill tool returns success but content isn't delivered." - #29074 (open) — "Plugin cache not cleared on uninstall/reinstall" — explains how stale per-file artifacts (like the phantom
commands/brainstorming.mdin our cache) can persist across upgrades.
Claude Code version
2.1.122
Platform
Anthropic API
Operating system
Ubuntu/Debian Linux
Terminal/shell
bash, kitty
Claude model
claude-opus-4-7 (1M context)
This issue has 5 comments on GitHub. Read the full discussion on GitHub ↗