claude-code - 💡(How to fix) Fix Skill tool feedback loop: getPromptForCommand body re-injected as isMeta after every model-invoked Skill call [4 comments, 3 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
anthropics/claude-code#54535Fetched 2026-04-30 06:42:57
View on GitHub
Comments
4
Participants
3
Timeline
11
Reactions
0
Timeline (top)
labeled ×5commented ×4mentioned ×1subscribed ×1

Root Cause

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.

Fix Action

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.

Code Example

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}`};
}

---

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 };
}

---

function SG7(H, $) {
  if (!$) return H;
  return H.map(q => {
    if (q.type === "user") return { ...q, sourceToolUseID: $ };
    return q;
  });
}

---

{
  "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"
}

---

---
     description: "Trigger the loop bug"
     ---

     Invoke the `repro:loop` skill now using the Skill tool. Pass through any arguments the user provided.

---

[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
RAW_BUFFERClick to expand / collapse

Preflight Checklist

  • I have searched existing issues and this hasn't been reported yet (closest matches: #54023, #41105, #19729, #48071 — all distinct, see "Related issues" below)
  • This is a single bug report
  • 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:

  1. The injected meta message should include a <command-name> tag (or equivalent loop-breaker) so the existing safety check trips, or
  2. The Skill tool should not inject getPromptForCommand content as a follow-up meta message when invoked by the model (only on user-typed slash commands), or
  3. 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:

  1. Create a plugin at ~/.claude/plugins/cache/local/repro/0.1.0/:
    • .claude-plugin/plugin.json with name: repro, version: 0.1.0
    • skills/loop/SKILL.md — any minimal skill with frontmatter name: loop, description: "Trigger the loop bug", plus a few lines of body
    • commands/loop.md:
      ---
      description: "Trigger the loop bug"
      ---
      
      Invoke the `repro:loop` skill now using the Skill tool. Pass through any arguments the user provided.
  2. Enable the plugin.
  3. Send a user message containing /repro:loop mid-sentence (not at message start, to avoid the auto-load path): let's load the /repro:loop skill here
  4. Observe the model calls Skill(repro:loop) → tool result Launching skill: repro:loop → harness injects the commands/loop.md body as an isMeta:true user message → model calls Skill(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: true only 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 (/clear and cron-spawn losing --plugin-dir context), 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.md in 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)

extent analysis

TL;DR

To fix the loop caused by the Skill tool injecting its own command as a meta message, modify the tool to include a <command-name> tag in the injected message or prevent it from injecting the command when invoked by the model.

Guidance

  • Identify and modify the mapToolResultToToolResultBlockParam function to include a <command-name> tag in the tool result content when the Skill tool is invoked by the model.
  • Consider modifying the Cs$ function to prevent injecting the skill's body content as a meta message when the tool is invoked by the model.
  • Review the SG7 function to ensure it correctly tags the injected message with sourceToolUseID and consider adding additional logic to prevent the loop.
  • Verify the fix by testing the Skill tool with a degenerate commands/<skill>.md body and ensuring the model does not enter an infinite loop.

Example

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} <command-name>${H.commandName}</command-name>`};
}

Notes

The provided code snippets and issue description suggest that the problem lies in the interaction between the Skill tool and the model. However, without access to the full codebase, it is difficult to provide a comprehensive solution. The suggested modifications are based on the provided information and may require additional changes to fully resolve the issue.

Recommendation

Apply a workaround by modifying the commands/<skill>.md body to include substantive instructions instead of a self-referential command, as this will prevent the loop until a more permanent

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

claude-code - 💡(How to fix) Fix Skill tool feedback loop: getPromptForCommand body re-injected as isMeta after every model-invoked Skill call [4 comments, 3 participants]