I spent a week running agent teams and watching the filesystem while they worked. Every JSON file they created, every inbox message, every config change. I logged it all because I wanted to understand the actual coordination mechanism, not the docs version.

What I found: the entire multi-agent system is JSON files on disk. No database, no message broker, no IPC. Agents communicate by writing to each other's inbox files. Tasks are individual JSON files in a shared directory. The team config is a single JSON file that grows as agents join and shrinks as they shut down.

This matters for three reasons:

  1. Debugging. When a team gets stuck, you can read the raw files and see exactly what happened. Which agent claimed which task, what messages were sent, whether a dependency is blocking progress.

  2. Cost awareness. Subagents and agent teams are different systems with very different token costs. Picking the wrong one can 2x your bill.

  3. Building on top of it. Once you see the protocol, you can build your own orchestration, monitoring, or tooling around it.

Everything below comes from real experiments on Claude Code 2.1.45. The JSON examples are from actual team runs, not hypothetical.

Two Systems, Very Different Costs

Claude Code has two multi-agent mechanisms. They look similar from the outside but work differently.

Subagents are the Task tool. You spawn one, it runs in its own context window, it returns a result, it's gone. No new team or task directories are created. The parent gets back the subagent's final text response but can't see intermediate tool calls. Subagents can't spawn other subagents, and they can't talk to each other. The official docs put it directly: "Subagents only report results back to the main agent and never talk to each other."

One wrinkle: subagents have team tools (SendMessage, TeamCreate) available in their toolbox. But they don't share a team context, don't know about each other, and have no shared task queue. A subagent that calls SendMessage will create an inbox file under ~/.claude/teams/default/inboxes/, but the parent never sees it. The tools exist; the coordination layer doesn't.

Agent teams are persistent sessions. Each teammate gets its own context window, its own inbox, and access to a shared task queue. They idle between turns, wake up when messaged, and coordinate through the filesystem. Teams leave a clear trail: config files, task files, inbox files.

The cost difference is real. Teams use significantly more tokens than subagents for equivalent work because each teammate maintains its own context window across multiple wake cycles, and the idle/wake heartbeat adds overhead. Expect roughly 2x the token usage when switching from subagents to teams.

Rule of thumb: if agents don't need to talk to each other, use subagents. If they need to share a task queue, send each other findings, or coordinate across separate sessions, use teams.

┌─────────────────────────────────────────────────────────┐
│                     SUBAGENTS                           │
│                                                         │
│  Parent ──spawn──▶ Agent ──result──▶ Parent             │
│                    (own context, no shared state)       │
│                                                         │
│  Cost: lower (no persistent context)                    │
├─────────────────────────────────────────────────────────┤
│                     AGENT TEAMS                         │
│                                                         │
│  Lead ──spawn──▶ Agent A ◀──inbox──▶ Agent B            │
│    │                ▲                    ▲              │
│    │                └────task queue──────┘              │
│    └──────────config.json + inboxes/─────               │
│                                                         │
│  Cost: ~2x (persistent context + heartbeat overhead)    │
└─────────────────────────────────────────────────────────┘

What's on Disk

When you create a team, Claude Code creates two directory trees:

~/.claude/teams/{team-name}/
├── config.json                  # team config: members, lead, metadata
└── inboxes/
    ├── team-lead.json           # lead's inbox (created on first message)
    ├── frontend-engineer.json   # each agent gets an inbox file
    └── backend-engineer.json    #   ...when someone first writes to them

~/.claude/tasks/{team-name}/
├── .lock                        # empty file, used for flock
├── 1.json                       # task files (one per task)
├── 2.json
├── 3.json
└── 4.json

Inbox files are created lazily. They don't exist until the first message is written TO that agent. The lead's inbox might not appear for minutes after the team is created. It only shows up when the first idle notification arrives.

Config Schema

The config file tracks who's on the team right now. Here's a real one from a 5-agent research team:

{
  "name": "codebase-research",
  "description": "Research team analyzing the sentry-v2 codebase",
  "createdAt": 1771441034855,
  "leadAgentId": "team-lead@codebase-research",
  "leadSessionId": "5708d3dd-a941-48a0-9357-fbba8dfdc905",
  "members": [
    {
      "agentId": "team-lead@codebase-research",
      "name": "team-lead",
      "agentType": "team-lead",
      "model": "claude-opus-4-6",
      "joinedAt": 1771441034855,
      "tmuxPaneId": "",
      "cwd": "/home/user/Projects/sentry-v2",
      "subscriptions": []
    },
    {
      "agentId": "frontend-engineer@codebase-research",
      "name": "frontend-engineer",
      "agentType": "general-purpose",
      "model": "claude-opus-4-6",
      "prompt": "You are a senior frontend engineer...",
      "color": "blue",
      "planModeRequired": false,
      "joinedAt": 1771441084084,
      "tmuxPaneId": "in-process",
      "cwd": "/home/user/Projects/sentry-v2",
      "subscriptions": [],
      "backendType": "in-process"
    }
  ]
}

The lead entry is minimal: identity and location. Teammates have extra fields: prompt (the spawn instructions), color (for UI), planModeRequired, and backendType.

The agentId format is always {name}@{team-name}. The subscriptions array is always empty.

Important: config.json reflects the current state. When an agent shuts down, it gets removed from the members array. The config shrinks as each agent leaves, and TeamDelete removes the file entirely once the last one is gone.

Task Files

Each task is a standalone JSON file:

{
  "id": "1",
  "subject": "Analyze frontend architecture (dashboard package)",
  "description": "Deep-dive into the frontend codebase at packages/dashboard/...",
  "activeForm": "Analyzing frontend architecture",
  "status": "completed",
  "owner": "frontend-engineer",
  "blocks": [],
  "blockedBy": []
}

IDs are strings, not integers. The owner field is absent (not null) on unclaimed tasks. activeForm is the present-tense label shown in the UI spinner while the task runs.

Every time an agent is spawned, an internal tracking task is also created:

{
  "id": "5",
  "subject": "frontend-engineer",
  "description": "You are a senior frontend engineer on a research team...",
  "status": "in_progress",
  "blocks": [],
  "blockedBy": [],
  "metadata": { "_internal": true }
}

These _internal tasks track the agent's lifecycle. They persist even after the agent shuts down. The agent gets removed from config.json, but its tracking task stays in the task directory.

How Spawning Works

When the lead spawns a teammate, three things happen in quick succession:

  1. Config update. The new agent is added to the members array in config.json.

  2. Internal task created. A _internal tracking task is written to the tasks directory.

  3. Agent starts. The new process begins running with its spawn prompt.

From a filesystem monitor watching a real team creation:

[  25.7s] MOD config.json (1638b)  | config: 2 members [team-lead, worker]
[  25.7s] NEW 1.json (272b)        | task 1: status=in_progress owner=none subj="worker"

Config update and internal task creation happen in the same sub-second window. The agent's inbox file does NOT get created here. It only appears when someone first writes to it.

Agents are spawned sequentially, not in parallel. In a 4-agent team, each agent joined about 6-7 seconds after the previous one.

The Message Protocol

Agents can't hear each other's text output. The system prompt tells every teammate: "Your plain text output is NOT visible to the team lead or other teammates. To communicate with anyone on your team, you MUST use this tool."

All communication goes through inbox files. Each inbox is a JSON array of message objects:

[
  {
    "from": "worker",
    "text": "All tasks completed:\n\n1. **Task 001** - Created...\n2. **Task 002** - Created...",
    "summary": "All 2 tasks completed successfully",
    "timestamp": "2026-02-18T18:39:39.925Z",
    "color": "blue",
    "read": false
  }
]

Plain text messages have a text field with the content and summary for the UI preview. System events are different. The text field contains stringified JSON:

{
  "from": "greeter",
  "text": "{\"type\":\"idle_notification\",\"from\":\"greeter\",\"timestamp\":\"2026-02-18T18:33:29.456Z\",\"idleReason\":\"available\"}",
  "timestamp": "2026-02-18T18:33:29.456Z",
  "color": "blue",
  "read": false
}

System Event Types

Type

Direction

Purpose

idle_notification

Agent → Lead

"I'm free for work" (heartbeat)

shutdown_request

Lead → Agent

"Please shut down"

shutdown_approved

Agent → Lead

"Shutting down now"

task_assignment

Agent → Self

Agent claims a task (self-notification)

plan_approval_request

Agent → Lead

Plan ready for review

permission_request

Agent → Lead

Agent needs tool permission

The permission_request type isn't in the official docs. I discovered it when an agent needed Bash access and sent the lead a structured permission escalation:

{
  "type": "permission_request",
  "request_id": "perm-1771439599752-nu2yhdy",
  "agent_id": "farewell-builder",
  "tool_name": "Bash",
  "description": "Create target directory and verify",
  "input": { "command": "mkdir -p /home/user/Projects/validation-exp && ls -la /home/user/Projects/" },
  "permission_suggestions": [
    { "type": "addDirectories", "directories": ["/home/user/Projects/validation-exp"], "destination": "session" },
    { "type": "setMode", "mode": "acceptEdits", "destination": "session" }
  ]
}

The task_assignment type is a self-notification. When an agent claims a task, it writes a task_assignment event to its own inbox — not the lead's. This is how the system tracks which agent picked up which task.

The read Flag

In interactive sessions, the lead processes messages in real-time, so all messages end up with read: true. In headless sessions (claude -p), messages stay read: false. The headless lead doesn't seem to process the inbox delivery cycle properly.

Task Dependencies

Dependencies are declarative, not imperative. When task 1 finishes, the system does NOT update task 2's file to say "you're unblocked now." Instead, every time an agent calls TaskList, the system reads all task files, checks which are completed, and computes what's available.

The blockedBy field in each task file never changes after creation:

{
  "id": "3",
  "subject": "Integration testing",
  "status": "pending",
  "blockedBy": ["1", "2"]
}

Even after tasks 1 and 2 complete, task 3's file still says "blockedBy": ["1", "2"]. Availability is evaluated fresh on every TaskList call.

You can debug stuck dependency chains by reading the JSON directly and cross-referencing status values yourself.

Idle, Wake, and Shutdown

The Heartbeat

When an agent finishes a turn, it goes idle and starts sending idle_notification events to the team lead every 2-4 seconds. These pings do two things: tell the lead "I'm free for work" and act as a heartbeat. If the pings stop, the agent probably crashed.

This also explains the orphaned agents problem. When a team lead crashes, the workers keep pinging into the void. Nobody's listening, nobody's assigning work, but the agents sit there idling indefinitely.

Idle notifications dominate the inbox. In a typical team run, over half the messages in the lead's inbox are idle pings. The rest are findings, shutdown approvals, and task assignments.

The Shutdown Handshake

Shutdown is a request/response protocol. The lead sends shutdown_request to each agent individually, and each agent responds with shutdown_approved. As each agent approves, it gets removed from config.json.

The lead sends shutdown requests in quick succession (a few hundred milliseconds apart), but agents don't approve in the same order. Whichever agent finishes its current turn fastest approves first. You can watch config.json shrink as each agent approves: 5 members to 3, to 2, to 1.

After the last agent shuts down, TeamDelete removes everything atomically: config.json, all task files, all inbox files, and the .lock file.

Plan Approval

When you spawn a teammate with planModeRequired: true, the agent is restricted to read-only tools (Glob, Grep, Read, etc.) until the lead approves its plan.

The flow:

  1. Agent researches using read-only tools

  2. Agent writes a plan and calls ExitPlanMode

  3. System sends plan_approval_request to the lead's inbox

  4. Lead reviews and sends plan_approval_response (approve or reject with feedback)

  5. If approved, agent exits plan mode and gets full tool access

Useful when you want the lead to sign off before an agent starts writing files or running commands.

Try It Yourself

Watch a Team in Real Time

Open a second terminal while agent teams are running:

# See your team's config and current members
cat ~/.claude/teams/*/config.json | python3 -m json.tool

# Watch tasks get created and claimed
watch -n 1 'ls -la ~/.claude/tasks/*/'

# Read the team lead's inbox
cat ~/.claude/teams/*/inboxes/team-lead.json | python3 -m json.tool

The Filesystem Monitor

This is the script I used for my experiments. It polls the team directories every 0.5 seconds and logs every file creation, modification, and deletion with timestamps:

#!/bin/bash
# monitor-team.sh — watch agent team filesystem activity in real time
# Usage: ./monitor-team.sh <team-name>

TEAM_NAME="${1:?Usage: $0 <team-name>}"
TEAMS_DIR="$HOME/.claude/teams/$TEAM_NAME"
TASKS_DIR="$HOME/.claude/tasks/$TEAM_NAME"

echo "Monitoring team: $TEAM_NAME"
echo "Teams dir: $TEAMS_DIR"
echo "Tasks dir: $TASKS_DIR"
echo "Polling every 0.5s..."
echo "================================================================================"

declare -A PREV_STATE
START_TIME=$(python3 -c "import time; print(time.time())")

while true; do
    NOW=$(python3 -c "import time; print(time.time())")
    ELAPSED=$(python3 -c "print(f'{$NOW - $START_TIME:8.1f}s')")

    # Check all files in both directories
    for dir in "$TEAMS_DIR" "$TASKS_DIR"; do
        [ -d "$dir" ] || continue
        while IFS= read -r -d '' file; do
            rel="${file#$HOME/.claude/}"
            size=$(wc -c < "$file" 2>/dev/null || echo "0")
            mod=$(stat -f "%m" "$file" 2>/dev/null || echo "0")
            key="$rel"
            current="${size}:${mod}"

            if [ -z "${PREV_STATE[$key]}" ]; then
                echo "[$ELAPSED] NEW $rel (${size}b)"
                PREV_STATE[$key]="$current"
            elif [ "${PREV_STATE[$key]}" != "$current" ]; then
                echo "[$ELAPSED] MOD $rel (${size}b)"
                PREV_STATE[$key]="$current"
            fi
        done < <(find "$dir" -type f -print0 2>/dev/null)
    done

    # Check for deleted files
    for key in "${!PREV_STATE[@]}"; do
        full="$HOME/.claude/$key"
        if [ ! -f "$full" ]; then
            echo "[$ELAPSED] DEL $key"
            unset "PREV_STATE[$key]"
        fi
    done

    sleep 0.5
done

Start it before you create a team:

chmod +x monitor-team.sh
./monitor-team.sh my-team-name

Then in another terminal, create a team and watch the filesystem come alive. You'll see config.json appear first, then task files as the lead creates work, then config grow as agents join, then inbox files appear as agents start communicating.

Debug a Stuck Team

# Which tasks are blocked and by what?
python3 -c "
import json, glob, os
for f in sorted(glob.glob(os.path.expanduser('~/.claude/tasks/*/*.json'))):
    if '.lock' in f: continue
    t = json.load(open(f))
    if t.get('blockedBy'):
        status_map = {}
        for bf in glob.glob(os.path.dirname(f) + '/*.json'):
            if '.lock' in bf: continue
            bt = json.load(open(bf))
            status_map[bt['id']] = bt['status']
        blocked_statuses = [f'{bid}({status_map.get(bid, \"?\")})'  for bid in t['blockedBy']]
        print(f'Task {t[\"id\"]}: {t[\"subject\"]} — blocked by {blocked_statuses}, status: {t[\"status\"]}')
"
# What messages are sitting unread?
python3 -c "
import json, glob, os
for f in sorted(glob.glob(os.path.expanduser('~/.claude/teams/*/inboxes/*.json'))):
    msgs = json.load(open(f))
    unread = [m for m in msgs if not m.get('read')]
    if unread:
        agent = os.path.basename(f).replace('.json', '')
        print(f'{agent}: {len(unread)} unread messages')
        for m in unread[-3:]:
            text = m['text'][:80]
            print(f'  from {m[\"from\"]}: {text}')
"
# How many agents are alive in each team?
python3 -c "
import json, glob, os
for f in sorted(glob.glob(os.path.expanduser('~/.claude/teams/*/config.json'))):
    cfg = json.load(open(f))
    team = cfg['name']
    members = [m['name'] for m in cfg.get('members', [])]
    print(f'{team}: {len(members)} members — {members}')
"

Building Your Own Orchestration

The protocol is simple enough to replicate. Here's the core loop in pseudocode:

Team Lead

create config.json with lead as only member
create task files in tasks directory

for each agent to spawn:
    add agent to config.json members array
    create _internal tracking task
    start agent process with spawn prompt

loop:
    read inbox for new messages
    for each message:
        if idle_notification: agent is free, assign work if available
        if permission_request: approve or deny tool access
        if plan_approval_request: review plan, approve or reject
        if shutdown_approved: remove agent from config
        if plain text: process findings/results
    if all tasks complete:
        send shutdown_request to each agent
    if all agents shut down:
        delete team files

Teammate Agent

read spawn prompt and system instructions
call TaskList to find available work
claim an unblocked task (set owner, status=in_progress)
write task_assignment to own inbox

do the work (read files, write code, run commands)

send findings to lead or other agents via SendMessage
mark task completed
call TaskList for more work

when idle: send idle_notification every ~3 seconds
when shutdown_request received: send shutdown_approved and exit

Message Delivery

to send message to agent X:
    read ~/.claude/teams/{team}/inboxes/X.json (or create if absent)
    append new message object to array
    write file back

    message object:
        from: sender name
        text: content (plain text or stringified JSON for events)
        summary: short preview (for plain text messages)
        timestamp: ISO 8601
        color: sender's color
        read: false

The filesystem acts as the message bus. The .lock file provides mutual exclusion via flock() when multiple agents write to the same task directory concurrently.

Tested on Claude Code 2.1.45. The filesystem protocol may change in future versions

Keep Reading