Skill tool feedback loop: getPromptForCommand body re-injected as isMeta after every model-invoked Skill call

Resolved 💬 5 comments Opened Apr 29, 2026 by Avicennasis Closed May 31, 2026

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:

  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.
```

  1. Enable the plugin.
  2. 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

  1. 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)

View original on GitHub ↗

This issue has 5 comments on GitHub. Read the full discussion on GitHub ↗