Debugging log

How to tell whether you actually got an agent team

Date
· updated
Tags
claude
Verified against
Claude Code 2.1.220
Symptom

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

Cause

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 TeammateIdle event → a team existed
  • only PostToolUse events carrying an agent_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

  1. Confirm the flag first. With CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS unset, every check here says “no team” about a session that never had the option.
  2. Both obvious reads are false positives — the directory exists from startup, and the natural jq counts the lead as a teammate.
  3. A name on the spawn is what makes a teammate, and named spawns return nothing; they go idle. Have them write to a file.
  4. 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.