Debugging log
How to tell whether you actually got an agent team
Agents appeared in the panel and there was no way to tell whether a team had formed, whether they coordinated, or whether these were plain subagents
The only live record is a config directory deleted when the session exits, and the obvious ways to read it return false positives
Ask Claude Code for an agent team and you will usually see agents appear in the panel below the prompt. That is not evidence that you got one.
Agent Teams and subagents are separate features spawned by the same Agent tool, and
the agent teams docs are candid that
“the panel alone doesn’t confirm a team formed.” That page also gives you the map: the
config.json and tasks paths, the inboxes/{agent-name}.json path, the naming rule,
that both are created at session startup, that the lead’s entry carries the agent type
team-lead, and that the config directory is removed when the session ends.
What it doesn’t tell you is that the obvious ways to read that map are wrong — and one of the wrong answers is convincing enough that I believed it for a while.
Before any of it: nothing exists unless the feature is on. It’s experimental and off by default.
{ "env": { "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" } }
With that unset there is no directory to read, so every check below reports “no team” for a session that never had the feature available. Confirm the flag before concluding anything.
Check 1: count the members, while the session lives
for d in ~/.claude/teams/session-*/; do
jq -r --arg dir "$(basename "$d")" '
"\($dir) teammates=\([.members[] | select(.agentType != "team-lead")] | length) dirs=\([.members[].cwd] | unique | join(" + "))"
' "$d/config.json" 2>/dev/null
done
session-a1b2c3d4 teammates=6 dirs=/Users/me/code/other-project
session-b2c3d4e5 teammates=0 dirs=/Users/me/code/something-else
session-c3d4e5f6 teammates=6 dirs=/Users/me/code/this-project + /Users/me/code/this-project/.claude/worktrees/wip
Above zero is a real team. Zero means you got something else, whatever the panel showed you.
That glob covers every session on the machine, and if you run this feature you probably
have several open — so two rows can both show teammates and only one is yours. The cwd
values narrow it down, but note the third row: cwd is recorded per member, and the lead’s
can differ from the teammates’. That happens if the session moved, as it does in a git
worktree, and it means matching your pwd against a single member is unreliable. For
certainty, read leadSessionId from the top level of config.json and compare it against
the session you care about.
Two caveats on the count itself, both found the hard way. Members are not removed when they die — I lost teammates to API errors mid-task and they kept appearing afterwards — and the count includes teammates that finished long ago. This tells you a team formed, not that one is working now.
Check 2: the transcript, after the session is gone
The config directory is deleted when the session exits, which is the most likely reason you can’t answer this question at all. Transcripts persist. Find yours:
ls -lt ~/.claude/projects/"$(pwd | sed 's|[/.]|-|g')"/*.jsonl | head -5
The project directory is your working directory with / and . both replaced by -.
Don’t just take the newest. If you’re asking from a Claude Code session, the newest is the session you’re sitting in — which contains none of the calls you’re looking for, so you’d conclude no team formed. Pick the one whose timestamp matches the session that ended.
Then, against that file:
jq -r 'select(.message.content | type == "array") | .message.content[]
| select(.type == "tool_use" and .name == "Agent")
| if .input.name then "teammate: \(.input.name)" else "subagent (unnamed)" end' \
~/.claude/projects/<slug>/<session-id>.jsonl
Against a session whose team directory was deleted hours earlier, this still recovers the roster:
teammate: sanitizer
teammate: editor
teammate: schema
The trap here is the bridge between the two checks. You cannot always get from a
directory name to a transcript, and the rule turns out to be conditional. The docs say the
directory is session- plus the first eight characters of the session ID. In all three of
my scripted runs — where I passed --session-id myself — that was false: one team
directory was session-f64255bd while the transcript holding the Agent calls was
065d4d3a, and the other two mismatched the same way. In all three sessions I launched
normally, leadSessionId matched the directory name exactly.
So the documented rule holds unless you supply the session ID yourself, which is worth
knowing in both directions: on a normal launch the directory name is a reliable handle,
and under --session-id it isn’t.
Check 3: whether they coordinated at all
A team can form and still not coordinate. Different question.
inboxes/ is created lazily, per recipient, on first delivery — so point this at the one
directory you identified in check 1 rather than globbing, or another session’s mailboxes
will answer for yours:
ls ~/.claude/teams/session-c3d4e5f6/inboxes/ 2>/dev/null || echo "nothing has messaged"
If it’s empty or missing, no agent messaged another. If files exist, messaging happened — but that’s all you get, because they’re drained as messages are delivered and are usually empty when you look. Existence is a boolean, not a volume. And it’s per recipient, not per member: the session I’m writing from has six teammates and four mailboxes, because only four have been written to.
The shared task list turned out to be no signal at all. Given how central it is to the
documentation I expected ~/.claude/tasks/{team}/ to fill up. In a real three-teammate
team it stayed empty, and the TaskCreated and TaskCompleted hooks — armed for that run —
fired zero times. They coordinated entirely by messaging and never created a task.
Check 4: a hook, armed before the run
The three checks above read state after the fact. To know in advance, instrument first.
The hooks reference documents the pieces:
PostToolUse carries an agent_id that is “present only when the hook fires inside a
subagent call,” TeammateIdle fires when an agent-team teammate is about to go idle,
PostToolUse fires only on success while PostToolUseFailure handles failures, and exit 2
means different things per event — it feeds stderr back to the model on PostToolUse, and
on TeammateIdle it prevents the teammate going idle. So the logger must always exit 0, or
it changes the behaviour it’s measuring:
#!/usr/bin/env bash
# log-hook.sh
INPUT="$(cat)"
printf '%s' "$INPUT" | jq -c '{
event: .hook_event_name, tool: .tool_name,
agent_id: .agent_id, agent_type: .agent_type
}' >> "${LAB_RUN_LOG:-/tmp/agent-teams.jsonl}"
exit 0
Register it for PostToolUse and TeammateIdle and pass it per-run with --settings, so
your usual config is untouched.
The undocumented part, which is what makes this work. The docs define agent_id as a
subagent identifier, and teammates are explicitly not subagents. But agent_id is
populated for agent-team teammates too — mine appeared as asanitizer-…, aeditor-…,
aschema-…. That gives you a three-way answer nothing else provides:
- a
TeammateIdleevent → a team existed - only
PostToolUseevents carrying anagent_id→ subagents - neither → the session did the work itself
jq -r '"\(if .agent_id then "delegated" else "lead" end)\t\(.tool // .event)"' \
/tmp/agent-teams.jsonl | sort | uniq -c | sort -rn
22 delegated Bash
17 delegated Read
10 lead TeammateIdle
7 delegated SendMessage
4 lead Bash
Read the TeammateIdle rows carefully: they say lead. That isn’t a contradiction — the
event concerns a teammate but is delivered to the lead, so it carries no agent_id. Count
the event, not the label.
Note what this log can’t tell you. It deliberately drops tool payloads, so seven teammate
SendMessage calls are equally consistent with seven teammate-to-lead reports. It shows
messaging happened, not who talked to whom.
Worth running alongside check 1 rather than instead of it. Hooks caught a real team that my directory check had confidently scored as “no team,” because I was watching a path that never existed — one instrument was wrong and the other wasn’t.
What actually makes a teammate
Not the phrasing, and not a separate tool. Both mechanisms use Agent; a teammate is one
spawned with a name, which is what makes it addressable for messaging.
Check 2 demonstrates this on its own. Run against the session I’m writing from, it prints:
teammate: operator-read
teammate: skeptic-read
subagent (unnamed)
teammate: operator-two
teammate: skeptic-two
subagent (unnamed)
teammate: operator-three
teammate: operator-four
Six named spawns and two unnamed ones. That session’s config.json lists exactly six
members — the same six names. Neither unnamed spawn appears anywhere in it. Named spawns
join the team; unnamed ones run, return their result, and are never members.
They also behave differently once running, and this will bite you: the unnamed spawns’
final text came back as a return value. The named ones went idle and sent idle
notifications carrying no result at all. On more than one occasion a teammate finished good
work that never reached me — and at least once its SendMessage reported success without
arriving, so I can’t tell you whether that’s a delivery problem or something else. Either
way the practical answer is the same: don’t put anything you need in the message
channel. Have teammates write to a file and treat the message as a receipt.
Takeaways
- Confirm the flag first. With
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMSunset, every check here says “no team” about a session that never had the option. - Both obvious reads are false positives — the directory exists from startup, and the
natural
jqcounts the lead as a teammate. - A
nameon the spawn is what makes a teammate, and named spawns return nothing; they go idle. Have them write to a file. - Scope every check to one session. The team directory, the mailboxes and the transcripts all live in per-machine globs, and the newest is rarely the one you want.
Session identifiers and paths are changed. Quoted output is otherwise captured as run:
the member listings, the jq results, the hook tallies, and the transcript output. Where
the documentation states a fact — the paths, the naming rule, startup creation, the hook
fields and their exit-code semantics — it’s credited above rather than presented as a
discovery.
I also ran a small experiment on whether asking explicitly changes what you get. It isn’t here: two adversarial reviewers showed the prompts differed in more than the one variable I claimed, so the comparison doesn’t support the conclusion I drew. That’s a follow-up, run properly, or not at all.