Custom agent definitions (.claude/agents/) are silently ignored for team agents

Status Closed — not planned
Reported on v2.1.68
Maintainer reply None cached
Activity 12 comments · opened Mar 4, 2026 · closed May 24, 2026

Summary

When a custom agent definition (.claude/agents/<name>.md) is used to spawn a team agent (via the Agent tool with team_name parameter), all frontmatter fields and the markdown body (system prompt) are silently ignored. Only model and agent_type (in SubagentStart/SubagentStop hooks) work correctly.

The same custom agent definitions work fully when spawned as pure subagents (without team_name).

Reproduction

  1. Create .claude/agents/my-agent.md:
---
name: my-agent
description: Test agent with baked-in identity.
model: opus
hooks:
  PreToolUse:
    - matcher: ""
      hooks:
        - type: command
          command: echo "hook fired"
skills:
  - my-skill
disallowedTools: Write, Edit
---

# You are: My Agent

You have a specific identity and instructions baked in.
  1. Spawn as pure subagent (works correctly):
Agent(subagent_type="my-agent", prompt="What is your role?")

→ Agent receives the markdown body as system prompt, hooks fire, model is applied.

  1. Spawn as team agent (broken):
Agent(subagent_type="my-agent", team_name="my-team", name="team-agent" prompt="What is your role?")

→ Agent has NO custom system prompt. Only project-wide rules (.claude/rules/) and CLAUDE.md files are loaded. Agent cannot identify itself.

What is silently ignored for team agents

| Feature | Pure subagent | Team agent |
|---|---|---|
| System prompt (markdown body) | ✅ Works | ✅ Works |
| skills frontmatter | ✅ Works | ❌ Silently ignored (known: #29441 / #24780) |
| disallowedTools frontmatter | ✅ Works | ✅ Works |
| Frontmatter hooks | ✅ Works | ❌ Silently ignored |
| model frontmatter | ✅ Works | ✅ Works |
| agent_type in SubagentStart/Stop | ✅ Works | ✅ Works |

Impact

This makes custom agent definitions nearly useless for team-based workflows. Teams cannot:

  • Bake role identity into agents (must use spawn prompt instead)
  • Enforce tool restrictions via disallowedTools
  • Attach lifecycle hooks to specific agent types
  • Preload skills into team agents

The silent failure is particularly problematic — there are no warnings or errors. Users may believe their agent definitions are being applied when they are not.

Environment

  • Claude Code version: 2.1.68
  • Platform: macOS (Darwin 24.5.0, arm64)
  • Agent teams enabled via CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1

Related issues

  • #29441 / #24780: skills field silently ignored for team agents (subset of this bug)
  • #14859: Feature request for agent hierarchy fields in all hook events

View original on GitHub ↗

12 Comments

github-actions[bot] · 5 months ago

Found 2 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/24780
  2. https://github.com/anthropics/claude-code/issues/25608

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

marcoabreu · 5 months ago

Update: It seems like the system prompt and the disallowed tools work in 2.1.69. skills frontmatter and hooks still are broken.

paulodearaujo · 5 months ago

Regression on v2.1.81 — system prompt still not loaded for team agents

The author's update mentioned system prompt working in v2.1.69, but on v2.1.81 (macOS Darwin 25.4.0, arm64) the system prompt body from custom agent .md files is still not injected for team agents.

Reproduction

1. Plugin agent (plugin agents/ directory):

Agent(
  subagent_type="content-maker:analyst-maker",
  team_name="my-team",
  name="analyst",
  prompt="What are your instructions? List all sections from your system prompt."
)

Agent responds with only the generic Claude Code system prompt + CLAUDE.md sections. Zero content from analyst-maker.md body (182 lines with specific etapas, output format, checklist).

2. Project-level agent (~/.claude/agents/):

Created ~/.claude/agents/test-identity.md:

---
name: test-identity
description: Test agent with specific identity
model: sonnet
---

# You are AGENT PARROT

You ALWAYS start responses with "PARROT HERE!".
You have 3 secret rules:
1. SECRET RULE ALPHA: Never use the word "probably"
2. SECRET RULE BETA: Always mention bananas
3. SECRET RULE GAMMA: End every response with "--- Signed: Parrot v3.7"

Spawned as teammate → responded as generic agent, no mention of parrot/bananas/rules.

3. Minimal prompt test:

Even with a minimal prompt (just asking "who are you?"), the agent definition body is not loaded. This rules out the systemPromptMode: "replace" hypothesis.

What works vs what doesn't

| Feature | Status |
|---|---|
| agentType in config.json | ✅ Correctly set to content-maker:analyst-maker |
| model from frontmatter | ✅ Inherited (opus from frontmatter appears in config) |
| description from frontmatter | ✅ Listed in system-reminder as available agent type |
| Body content (system prompt) | ❌ Not injected |

Evidence from transcript

The JSONL transcript (~/.claude/projects/*/subagents/agent-*.jsonl) shows the first user message contains ONLY the prompt field wrapped in <teammate-message> tags. No system message, no agent definition content. The agent's first response is purely generic.

Source code trace

In the minified binary (v2.1.81), the g4R function (inProcessRunner) has the code path:

if (agentDefinition) {
  let L = agentDefinition.getSystemPrompt();
  if (L) y.push(...)
}

This suggests the agentDefinition is either null or getSystemPrompt() returns falsy when the teammate is spawned.

Environment

  • Claude Code: 2.1.81
  • Platform: macOS Darwin 25.4.0 (arm64)
  • Agent Teams: enabled via CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
  • Tested with both plugin agents and ~/.claude/agents/ project-level agents

This is a blocker for any workflow that relies on pre-built agent definitions with Agent Teams.

Generated with Claude Code

pedrobarretocw · 5 months ago

UP

koriit-kontakt · 4 months ago

I'm also experiencing this issue, which is frustrating...

Koriit · 4 months ago

As of v2.1.97 it's still not fixed.
---

Workaround: SubagentStart hook that injects agent definition into team agents
---

I've been hit by this too (both the system prompt regression and the skills issue). I built a SubagentStart hook workaround that covers system prompt body and partially skills frontmatter for team agents. Sharing in case it helps others.

Note: There's an additional undocumented issue - additionalContext injected via SubagentStart hooks is not persisted across turns for team agents. The agent receives it on the first turn but loses it on subsequent turns. My workaround addresses this by re-injecting on every turn.

Note2: In the leaked source files I noticed that for teammates the agent.type is replaced with agent name which may be why proper files are not loaded. This problem is also present in the hook input, which is why I had to resort to reading transcript files.

How it works

  1. The SubagentStart hook fires on every teammate turn
  2. It reads transcript_path from the hook input and scans the JSONL for the most recent Agent tool_use where input.name or input.subagent_type matches the hook's agent_type - and where input.team_name is set (teammates only; regular subagents work fine)
  3. Uses input.subagent_type from the transcript to locate the correct .md file (since agent_type in the hook payload contains the agent's display name for teammates, not the qualified plugin:agent type). Should work for plugins (official and custom), local agents, and user agents.
  4. Parses the skills: list from YAML frontmatter and generates a <LOAD-DEPENDENCY> block instructing the agent to load them
  5. Strips frontmatter, wraps the body + skill instructions in <system-reminder> tags, and emits it as additionalContext

What it does NOT cover

  • Skills loading is best-effort: the <LOAD-DEPENDENCY> instruction tells the agent to proactively load its skills, but it's a hint - not a guarantee.
  • Per-agent hooks frontmatter: the SubagentStart hook can only inject context, not register new hooks dynamically. Global hooks only.
  • True system prompt placement: additionalContext is injected as context, not as the actual system prompt. Behavior is close enough for most agents but not identical.

Key design decisions

  • Transcript-based resolution: the hook reads the session transcript to find which Agent tool_use spawned this teammate. This is necessary because agent_type in the hook payload is the agent's display name (e.g., "pr-opener"), not the qualified type (e.g., "pull-request:pr-opener") needed to locate the .md file.
  • Re-inject every turn: I intentionally fire on every turn because additionalContext is not persisted across teammate turns - without re-injection the agent loses its definition after the first turn. I'm unsure whether SubagentStart firing on every teammate turn is intended behavior or a separate bug, but in my case it's actually saving me - without it, the workaround would only work on the first turn.
  • Teammate-only: the hook skips injection when team_name is absent in the transcript tool_use, since regular subagents receive their definition correctly.
  • Retry on first spawn: the transcript entry may not be flushed when the hook first fires, so it retries up to 3 times with 500ms delay.

Implementation

The hook is a single Python script (stdlib only) installed as a plugin.

hooks/hooks.json

{
  "hooks": {
    "SubagentStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PLUGIN_ROOT}/hooks/inject-agent-def"
          }
        ]
      }
    ]
  }
}

hooks/inject-agent-def (remeber to do chmod +x)

#!/usr/bin/env python3
"""
Workaround for anthropics/claude-code#30703
Injects agent definition body as additionalContext on SubagentStart.

Resolution flow:
  1. Read agent_type + transcript_path from stdin JSON
  2. Scan transcript JSONL for most recent Agent tool_use where
     input.team_name is set (teammate spawn only)  AND
     input.name == agent_type  OR  input.subagent_type == agent_type
  3. If not found or older than 30s → exit 0 (no injection)
  4. Use input.subagent_type from that entry to locate the .md file
  5. Strip frontmatter, emit additionalContext JSON

Only applies to teammate spawns (team_name present in tool_use input).
Regular subagents receive their definition correctly from Claude Code.
No fallback if transcript_path is absent — without it we risk
injecting on every teammate turn (Claude Code bug).

Dependencies: python3 stdlib, claude CLI (for marketplace list)
Debug: set CLAUDE_HOOKS_DEBUG_FILE=/path/to/file
"""

import json
import os
import sys
import time
import subprocess
from pathlib import Path
from datetime import datetime
SCRIPT_NAME = os.path.basename(sys.argv[0])
CORR_ID = f'{os.getpid():05d}'
DEBUG_FILE = os.environ.get('CLAUDE_HOOKS_DEBUG_FILE', '')


def dbg(msg):
    if not DEBUG_FILE:
        return
    ts = datetime.now().strftime('%Y-%m-%dT%H:%M:%S')
    line = f'[{ts}] [{SCRIPT_NAME}] [{CORR_ID}] {msg}\n'
    try:
        with open(DEBUG_FILE, 'a') as f:
            f.write(line)
    except Exception:
        pass


def strip_frontmatter(content):
    """Strip YAML frontmatter (between first and second ---), return body."""
    lines = content.splitlines(keepends=True)
    dashes_seen = 0
    result_lines = []
    for line in lines:
        if line.rstrip('\n\r') == '---':
            dashes_seen += 1
            continue
        if dashes_seen >= 2:
            result_lines.append(line)
    return ''.join(result_lines).rstrip('\n')


def extract_skills(content):
    """Extract the skills list from YAML frontmatter. Returns list of skill strings."""
    lines = content.splitlines()
    dashes_seen = 0
    in_skills = False
    skills = []
    for line in lines:
        if line.rstrip() == '---':
            dashes_seen += 1
            if dashes_seen == 2:
                break
            continue
        if dashes_seen == 0:
            continue
        if line.startswith('skills:'):
            in_skills = True
            continue
        if in_skills:
            stripped = line.strip()
            if stripped.startswith('- '):
                skills.append(stripped[2:].strip())
            elif line and not line[0].isspace():
                in_skills = False  # new top-level key, skills list ended
    return skills


def get_marketplace_locations():
    """Run claude plugins marketplace list --json, return list of installLocation strings."""
    try:
        result = subprocess.run(
            ['claude', 'plugins', 'marketplace', 'list', '--json'],
            capture_output=True, text=True, timeout=10
        )
        data = json.loads(result.stdout)
        locations = []
        for entry in data:
            loc = entry.get('installLocation')
            if loc and loc != 'null':
                locations.append(loc)
        return locations
    except Exception as e:
        dbg(f'get_marketplace_locations error: {e}')
        return []


def find_agent_file(agent_type, cwd):
    """Locate the .md file for agent_type. Returns path string or None."""
    if ':' in agent_type:
        # Plugin branch: "pluginName:agentName" or "pluginName:ns:agentName"
        plugin_name = agent_type.split(':')[0]
        agent_name = agent_type.rsplit(':', 1)[1]
        dbg(f"plugin branch: plugin='{plugin_name}' agent='{agent_name}'")

        # Search marketplace install locations
        locations = get_marketplace_locations()
        for install_loc in locations:
            f = os.path.join(install_loc, 'plugins', plugin_name, 'agents', f'{agent_name}.md')
            dbg(f'checking marketplace path: {f}')
            if os.path.isfile(f):
                dbg(f'found in marketplace: {f}')
                return f

        # Cache fallback — glob, sort, take last
        cache_dir = Path.home() / '.claude' / 'plugins' / 'cache'
        pattern = f'*/{plugin_name}/*/agents/{agent_name}.md'
        matches = sorted(cache_dir.glob(pattern))
        if matches:
            found = str(matches[-1])
            dbg(f'found in cache: {found}')
            return found

    else:
        # Local branch: project .claude/agents/ then user ~/.claude/agents/
        dbg(f"local branch: agent_type='{agent_type}'")

        f = os.path.join(cwd, '.claude', 'agents', f'{agent_type}.md')
        dbg(f'checking project path: {f}')
        if os.path.isfile(f):
            dbg(f'found in project: {f}')
            return f

        f = str(Path.home() / '.claude' / 'agents' / f'{agent_type}.md')
        dbg(f'checking user path: {f}')
        if os.path.isfile(f):
            dbg(f'found in user dir: {f}')
            return f

    return None


def find_in_transcript(transcript_path, agent_type):
    """
    Scan transcript JSONL for the most recent Agent tool_use matching agent_type.
    Returns (subagent_type_str, timestamp_str) or (None, None).
    Returns (None, ts_str) if found but stale.
    """
    last_match = None  # (subagent_type, timestamp_str)

    try:
        with open(transcript_path, 'r', encoding='utf-8') as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    entry = json.loads(line)
                except Exception:
                    continue

                if entry.get('type') != 'assistant':
                    continue

                msg = entry.get('message', entry)
                content = msg.get('content', [])
                if not isinstance(content, list):
                    continue

                for block in content:
                    if not isinstance(block, dict):
                        continue
                    if block.get('type') != 'tool_use':
                        continue
                    if block.get('name') != 'Agent':
                        continue

                    inp = block.get('input', {})
                    name_match = agent_type == inp.get('name') or agent_type == inp.get('subagent_type')
                    if name_match and not inp.get('team_name'):
                        dbg(f"skipping tool_use: agent_type='{agent_type}' matched but team_name not set — not a teammate spawn")
                        continue
                    # Only inject for teammate spawns (team_name set).
                    # Regular subagents get their definition injected correctly by Claude Code.
                    if inp.get('team_name') and name_match:
                        resolved = inp.get('subagent_type') or inp.get('name')
                        ts = entry.get('timestamp')
                        last_match = (resolved, ts)

    except Exception as e:
        dbg(f'find_in_transcript error: {e}')
        return (None, None)

    if last_match is None:
        return (None, None)

    return last_match


def main():
    raw = sys.stdin.read()
    dbg(f'INPUT: {raw}')

    try:
        data = json.loads(raw)
    except Exception as e:
        dbg(f'failed to parse stdin JSON: {e}')
        return

    agent_type = data.get('agent_type') or ''
    cwd = data.get('cwd') or ''
    transcript_path = data.get('transcript_path') or ''

    dbg(f"START agent_type='{agent_type}' cwd='{cwd}' transcript_path='{transcript_path}'")

    if not agent_type:
        dbg('empty agent_type, skipping')
        return

    if not transcript_path:
        dbg('no transcript_path, skipping')
        return

    # Retry when not found — transcript may not be flushed yet at hook fire time.
    RETRIES = 3
    RETRY_DELAY = 0.5
    resolved_type, ts_str = None, None
    for attempt in range(RETRIES):
        resolved_type, ts_str = find_in_transcript(transcript_path, agent_type)
        if resolved_type is not None:
            break
        if attempt < RETRIES - 1:
            dbg(f'no match on attempt {attempt + 1}/{RETRIES}, retrying in {RETRY_DELAY}s')
            time.sleep(RETRY_DELAY)

    if resolved_type is None:
        dbg(f'no matching teammate spawn found in transcript, skipping')
        return

    dbg(f"transcript resolved: agent_type='{agent_type}' → resolved='{resolved_type}' ts='{ts_str}'")

    agent_file = find_agent_file(resolved_type, cwd)
    if not agent_file:
        dbg(f"no agent file found for resolved='{resolved_type}', exiting")
        return

    dbg(f'using agent file: {agent_file}')

    try:
        with open(agent_file, 'r', encoding='utf-8') as f:
            file_content = f.read()
    except Exception as e:
        dbg(f'failed to read agent file: {e}')
        return

    body = strip_frontmatter(file_content)
    if not body:
        dbg('body is empty (frontmatter only?), exiting')
        return

    skills = extract_skills(file_content)
    if skills:
        skill_lines = '\n'.join(f'- `{s}`' for s in skills)
        load_dep = (
            f'<LOAD-DEPENDENCY>\n'
            f'Before starting, you MUST carefully load the following skills if not already loaded:\n'
            f'{skill_lines}\n'
            f'Do not proceed without them for ANYTHING even simple tasks or questions.\n'
            f'</LOAD-DEPENDENCY>\n'
        )
        dbg(f'injecting {len(skills)} skills: {", ".join(skills)}')
    else:
        load_dep = ''

    wrapped = f'<system-reminder>\n{body}\n\n{load_dep}</system-reminder>' if load_dep else f'<system-reminder>\n{body}\n</system-reminder>'
    dbg(f'injecting definition ({len(body.splitlines())} lines)')

    output = json.dumps({
        'hookSpecificOutput': {
            'hookEventName': 'SubagentStart',
            'additionalContext': wrapped
        }
    })
    dbg(f'OUTPUT: {output}')
    sys.stdout.write(output)


try:
    main()
except Exception as e:
    if DEBUG_FILE:
        dbg(f'unhandled exception: {e}')
sys.exit(0)
Leu-s · 4 months ago

Confirming still reproduces on v2.1.101 (macOS Darwin 24.6, CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1), four versions after @Koriit's v2.1.97 confirmation.

Adding one data point to the silently-ignored-frontmatter list: memory:. Declaring memory: project in a teammate's .claude/agents/*.md has no effect — the .claude/agent-memory/<agent-name>/MEMORY.md file is simply not loaded into the teammate's system prompt. For the same definition spawned as a pure (non-team) subagent on the same machine, memory: loads correctly. Extending the OP's table:

| Feature | Pure subagent | Team agent |
|---|---|---|
| memory: frontmatter | ✅ Works | ❌ Silently ignored (new) |

I also want to independently confirm @Koriit's finding that hook-injected additionalContext does not persist across turns for teammates. We hit the same quirk building a SubagentStart-hook workaround for this issue, and independently arrived at the same fix (re-inject on a Stop hook every turn). That two separate implementations converged on the same workaround is a strong signal it's real behavior, not an implementation artifact — worth surfacing as a first-class documented behavior or fixing.

Longer-form context and a production use case is in my comment on the #24316 feature request thread. Happy to provide more reproduction detail if useful.

github-actions[bot] · 3 months ago

Closing for now — inactive for too long. Please open a new issue if this is still relevant.

olarcher · 3 months ago

Update from a fresh local POC on Claude Code 2.1.150:

I can no longer reproduce the full original issue. In my test, custom .claude/agents/*.md definitions are now applied to teammate agents:

  • Agent body/system prompt markers: work for teammates
  • memory: project: works for teammates
  • skills: frontmatter: works for teammates
  • model: frontmatter: works; my team lead ran Opus while the custom teammate ran Sonnet

The remaining thing I can still reproduce is narrower: per-agent hooks: frontmatter is ignored for teammate agents.

Minimal setup:

---
name: repro-team-identity
description: Reproduction agent for teammate hook loading
model: sonnet
memory: project
hooks:
  PreToolUse:
    - matcher: "Read"
      hooks:
        - type: command
          command: python3 scripts/record-agent-hook.py
---

The agent is asked to run Read on README.md.

Observed behavior:

  • Spawned as a normal subagent: hook fires and writes pure.hook-events.jsonl
  • Spawned with team_name / name: teammate runs Read, but no hook event is written
  • The teammate transcript contains the expected agent body and memory markers, so this looks specific to hooks: frontmatter, not custom-agent loading overall

Corrected report from the POC:

{
  "reproductions": {
    "system_prompt_body": false,
    "memory_frontmatter": false,
    "pre_tool_hook_frontmatter": true
  },
  "still_reproduces": true,
  "team_markers": {
    "identity_marker": true,
    "greeting_marker": true,
    "memory_marker": true,
    "tool_policy_marker": true
  },
  "team_hook_fired": false
}

So from my current test, #30703 appears mostly fixed in 2.1.150. The remaining actionable issue is that teammate agents silently ignore per-agent hooks: frontmatter.

Koriit · 3 months ago

@olarcher thanks for posting, I wouldn't notice otherwise. I also confirm your findings. 🙌

jackalai · 2 months ago

Cross-linking a related feature request I just filed: #67424 — proposes opt-in AgentDefinition.projectDir / inheritProjectSettings so subagent sessions can load the parent project's .claude/settings.json hooks. Confirmed unaddressed as of Claude Code v2.1.172 (v2.1.172's pre-warmed-worker settings-leak fix is a different mechanism). Referencing this thread because the May 2026 comment here documents the same per-agent hooks: frontmatter being ignored.

tehlowkeywiz · 1 month ago

Adding a data point on Claude Code 2.1.206 (2026-07-10): the tools / disallowedTools frontmatter fields are still not enforced for teammate agents, even though (per the May update above on 2.1.150) body, memory, skills, and model now apply. Note that the 2.1.150 test above didn't cover tool restrictions — so this may be a never-fixed remainder of the original report rather than a regression.

Setup

Custom agent definition at ~/.claude/agents/code-reviewer.md (agent teams enabled via CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1):

---
name: code-reviewer
tools: Read, Bash, mcp__linear-server__get_issue, mcp__linear-server__list_comments
disallowedTools: Write, Edit, mcp__linear-server__save_issue, mcp__linear-server__save_comment
model: sonnet
memory: user
---

From a fresh session (started after the definition was last modified — ruling out the stale-registry effect where mid-session edits to agents/*.md don't reach new spawns), I spawned the same agent type two ways and asked each to (a) enumerate its available tools and (b) attempt exactly one Write to a scratch path and report the verbatim tool result.

Results

| Spawn path | Tool surface reported by the agent | Write attempt | Restrictions enforced? |
|---|---|---|---|
| TeammateAgent tool with a name (team member) | Read, Bash, mcp__linear-server__get_issue, mcp__linear-server__list_comments, Write, Edit, SendMessage, TaskCreate, TaskGet, TaskList, TaskUpdate — i.e. the allowlist plus what looks like a baseline teammate toolset | Succeeded: "File created successfully at: <scratch path>" — file existence and content verified on disk by the parent session | ❌ No |
| One-shot subagent — same subagent_type, no name | Read, Bash, mcp__linear-server__get_issue, mcp__linear-server__list_comments — exactly the allowlist | N/A — no Write tool present in its surface to attempt | ✅ Yes |

So enforcement diverges by spawn path on the same definition in the same session: one-shot subagents get exactly the allowlist; teammates get the allowlist plus Write, Edit, SendMessage, and the Task* tools regardless of disallowedTools.

Why it matters

The agent-teams docs state the teammate "honors that definition's tools allowlist," and the natural use case is read-only roles (e.g. a code reviewer that can inspect but not modify). On the teammate path that read-only posture is currently convention-only — the agent has live Write/Edit capability and nothing but its system prompt discouraging their use.

Happy to provide the full probe transcripts if useful.