Chapter 17
Chapter 17 — Agent Teams
Agent Teams let you run multiple thClaws agents in parallel, coordinating through a filesystem-based mailbox and task queue. Useful when work genuinely fans out: backend + frontend at the same time, one agent writing tests while another implements, etc.
Teams are opt-in — they spin up extra processes and burn tokens fast.
From the GUI. Click the gear icon → the Workspace section has
an Agent Teams row with an on/off pill. Click to toggle. The change
writes teamEnabled: true to .thclaws/settings.json and you’ll see
a yellow “Restart the app for this to take effect” notice — team
tools are registered at session spawn, so the running shared session
needs a respawn to pick them up.
From the CLI or by hand:
// .thclaws/settings.json
{ "teamEnabled": true }
With teamEnabled: false (the default), no team tools are registered
and no inbox poller runs. The Team tab in the GUI stays visible either
way — it shows an empty-state pointer (“No team agents running — ask
the agent to create a team”) so you can always see when a team starts
up. Sub-agents (Chapter 15) are unaffected by this flag.
⚠ Provider constraint:
agent/*models cannot use thClaws teams. Theagent/*provider (Chapter 6) shells out to your localclaudeCLI as a subprocess. That subprocess uses Claude Code’s own built-in toolset (Agent,Bash,Edit,Read,ScheduleWakeup,Skill,ToolSearch,Write) and does not see thClaws’s tool registry — so even withteamEnabled: true, ourTeamCreate/SpawnTeammate/ etc. are unreachable from the model. To use thClaws teams, switch to any non-agent/*provider (claude-opus-5,claude-sonnet-5,gpt-5, …) via/modelor/provider. The system prompt grounds the model to tell you this explicitly if you ask for a team while onagent/*— rather than silently calling Claude Code’s separate built-in TeamCreate that writes to~/.claude/teams/(invisible to thClaws).
With agent/* and teamEnabled: false, the same grounding tells the
model to NOT fall back to Claude Code’s TeamCreate / Agent /
TodoWrite / AskUserQuestion / ToolSearch built-ins — otherwise
the model would happily fabricate a “team created” response with
nothing actually written to .thclaws/state/team/. See dev-log 078 for
the audit that motivated this.
Anatomy
.thclaws/state/team/
├── config.json team config (members, lead)
├── inboxes/{agent}.json per-agent inbox (JSON array)
├── tasks/{id}.json task queue entries
├── tasks/_hwm high-water mark for task IDs
├── agents/{agent}/status.json heartbeat + current task
└── agents/{agent}/output.log teammate stdout/stderr (background spawns)
Everything is a file — no DB, no broker. fs2 advisory locking keeps
inbox writes atomic across processes.
Team tools
All added to the agent’s registry when teamEnabled: true:
| Tool | Purpose |
|---|---|
TeamCreate |
Create a team with named agents |
SpawnTeammate |
Launch a teammate process (tmux pane or background) |
SendMessage |
Write to a teammate’s inbox |
CheckInbox |
Read unread messages, mark as read |
TeamStatus |
Agents + task queue summary |
TeamTaskCreate |
Add a task (with optional dependencies) |
TeamTaskList |
List tasks by status |
TeamTaskClaim |
Claim a pending unblocked task (teammate) |
TeamTaskComplete |
Mark done + notify lead |
TeamMerge |
Merge a teammate’s worktree branch back into main |
Spinning up a team
Typical lead prompt:
❯ Create a team with two members: "backend" (for the API) and
"frontend" (for the React app). Use backend.md and frontend.md
definitions under .thclaws/agents/. Spawn both now.
The lead calls TeamCreate then SpawnTeammate twice. Teammate
processes boot as thclaws --team-agent backend --team-dir <abs path>
(and similar), each with its own inbox and status file. Those flags
set THCLAWS_TEAM_AGENT and THCLAWS_TEAM_DIR in the child, which is
what everything downstream — the role guards, the sandbox root, the
inbox poller — keys off.
Agent names become git identifiers, so they’re validated: 1–64
characters of letters, digits, _ or -, starting with a letter,
digit or _. A name is also the branch (team/<name>) and the
worktree directory (.worktrees/<name>), which is why my team! or a
name with a slash is rejected up front rather than failing later at
git worktree add.
TeamCreate also changes the lead’s own role. Its result tells the
model it is now a coordinator: delegate through SendMessage and
TeamTaskCreate, use Read/Glob/Grep only to review, and don’t
build things itself. That is reinforced at the tool level by the role
guards below — the prompt asks, the guards enforce.
Running style
SpawnTeammate picks one of three modes, in this order:
| Situation | What happens |
|---|---|
| Already inside a tmux session | Each teammate opens in a split pane of the current session, laid out tiled |
| tmux installed, but you’re not in a session | A detached session named thclaws-team is created; further teammates split into it |
| No tmux at all | Each teammate runs as a plain background process, with stdout and stderr redirected to agents/{name}/output.log |
The third mode is fully supported — the GUI Team tab reads that log — but you lose the ability to attach a terminal and type at a teammate directly.
Where there is a tmux session, attach with /team:
❯ /team
attaching to tmux session 'thclaws-team'...
(press Ctrl+B then D to detach back here)
With no session to attach to, /team prints the roster instead:
❯ /team
Team agents (no tmux session):
backend — working (task: t1)
frontend — idle (task: -)
If tmux isn’t installed at all it says so and points at
brew install tmux. TeamStatus works regardless — it reads the
status files, not tmux.
Spawning is verified, not fire-and-forget. SpawnTeammate waits
for the new teammate to write its own status file (leaving the
spawning placeholder), which normally takes under a second. A
background process that dies on boot is reported with the tail of its
output.log rather than silently counting as started.
Each pane is a full teammate REPL. You can talk directly to one:
❯ (on lead) send to frontend: "the /users endpoint now returns a new
`displayName` field — update the profile page"
That becomes a SendMessage into frontend’s inbox. The frontend
teammate picks it up on its next poll (1s interval), works on it,
and reports back via SendMessage to the lead.
Task queue
Instead of direct messaging, you can post tasks:
TeamTaskCreate(
subject: "Integration tests for /orders",
description: "Write integration tests for the /orders endpoints, \
covering the 400 and 409 paths",
owner: "backend",
blocked_by: ["1", "2"]
)
| Field | Required | Meaning |
|---|---|---|
subject |
yes | Short title, what shows in TeamTaskList |
description |
yes | The actual instructions the claiming teammate reads |
owner |
no | Reserve the task for one teammate — only that name can claim it. Omit for first-come-first-served |
blocked_by |
no | Task IDs that must complete first |
You don’t choose the id. Task IDs are assigned from a
high-water-mark file (tasks/_hwm) under a lock, so two teammates
posting at once can’t collide. Read the id back from the tool’s result
before referencing it in a later blocked_by.
A misspelt owner is rejected against the team config rather than
accepted — otherwise the task would sit unclaimable forever, waiting
for a teammate that doesn’t exist.
Teammates auto-claim pending unblocked tasks when idle (no inbox
messages, no in-flight task). A task with blocked_by becomes
claimable only once every one of those tasks is completed. There are
three states in all: pending, in_progress, completed.
Workflow:
- Lead posts tasks
1,2,3, with3blocked by1and2. backendandfrontendeach claim whatever is claimable.- Done →
TeamTaskCompletefires anidle_notificationat the lead. - When
1and2are both complete,3unblocks and whoever is idle picks it up.
Worktree isolation and the team filesystem sandbox
An agent def can set isolation: worktree:
---
name: backend
model: claude-sonnet-5
tools: Read, Write, Edit, Bash, Glob, Grep
isolation: worktree
---
You own the backend services. Work in your own git worktree so you
don't collide with the frontend teammate.
You can also set it declaratively on TeamCreate, per member, which
is the preferred route for an ad-hoc team that has no agent-def files:
TeamCreate(
name: "shopflow",
agents: [
{ name: "backend", role: "API", isolation: "worktree" },
{ name: "frontend", role: "React", isolation: "worktree" },
{ name: "qa", role: "tests" }
]
)
Either way, never write git worktree add … into a teammate’s
prompt. Isolation is a setting, not a shell command — a prompt that
tells the teammate to run it usually lands the worktree somewhere
outside .worktrees/, and TeamCreate will warn you if it spots that
string in a prompt.
On spawn, thClaws creates <workspace>/.worktrees/backend on branch
team/backend and runs that teammate process with cwd =
<workspace>/.worktrees/backend/. Changes written to relative paths stay
on that teammate’s branch until the lead calls TeamMerge:
TeamMerge(only: ["backend"])
That runs git merge team/backend into the lead’s current branch
(usually main). The parameters are:
| Parameter | Meaning |
|---|---|
into |
Target branch. Default: the repo’s current branch |
only |
Allow-list of teammate names. Omitted → every team/* branch with commits ahead of the target |
dry_run |
Report what would be merged, merge nothing. Default: false |
cleanup |
After a successful merge, remove .worktrees/<name> and delete the merged branch. Default: false |
Run dry_run: true first to see whether there is anything ahead. If a
teammate shows zero commits, ping it to commit inside its own worktree
before merging — work that is only in its working tree isn’t on the
branch yet. The tool reports commit counts and conflicts rather than
failing silently.
If <workspace> is not a git repo yet when the first worktree teammate
spawns, thClaws runs git init plus an empty initial commit for you,
so there’s nothing to pre-initialise.
How a teammate’s sandbox differs from a standalone session
Read the standalone sandbox rules in Chapter 5 first. Solo, the sandbox root is the cwd the session opened in.
For a teammate in a team, the sandbox root is always the lead’s
workspace, not the teammate’s own cwd. SpawnTeammate exports
THCLAWS_PROJECT_ROOT to the teammate process, and Sandbox::init()
reads that env var first, falling back to cwd only for a solo
session. So every teammate may write anywhere under the workspace
(except .thclaws/), no matter which folder it was placed in.
The cwd is then pointed at whatever folder suits the job:
isolation: worktree→ cwd =<workspace>/.worktrees/<name>/- no isolation → cwd =
<workspace>, same as the lead
The two settings answer different questions: cwd decides where a relative path resolves; the sandbox root decides how far write permission reaches.
Two classes of file a teammate can write
| Path form | Where it lands | Who sees it, when |
|---|---|---|
relative (src/server.ts) from a worktree |
<workspace>/.worktrees/backend/src/server.ts, on branch team/backend |
others see it after TeamMerge |
absolute (<workspace>/docs/api-spec.md) from a worktree |
<workspace>/docs/api-spec.md, on the workspace tree’s main |
immediately, no merge |
relative (tests/api.test.ts) from a non-isolated teammate |
<workspace>/tests/api.test.ts on main |
immediately |
The pattern that fell out of building the ShopFlow team:
- Shared contract (API spec, shared TS types) — have backend write it to an absolute workspace path as soon as it exists, so frontend and qa can read it without waiting for a merge.
- Implementation (handlers, models, server) — relative paths inside the worktree; the lead merges when it’s ready.
- Tests — qa runs non-isolated and writes straight into the workspace tree, running them once the implementation has merged.
Specially denied paths
On top of the standalone rules (.. escapes, symlink escapes, anything
outside the root):
- No teammate may write
<workspace>/.thclaws/— use the team tools instead. - Cross-worktree writes (backend writing
<workspace>/.worktrees/frontend/…) are not blocked. That is prompt-design’s job, matching the Claude Code reference implementation, which also declines to block it. If you want a harness-level guard, add a hook in.thclaws/settings.json.
Why this model
The obvious alternative — “sandbox = the teammate’s cwd”, so a worktree teammate can only write inside its own worktree — means every shared artefact has to go through a merge before anyone else can read it. That adds round trips for no benefit and serialises a team you spun up to run in parallel.
The current model (sandbox = workspace, cwd = worktree) matches how
people actually use git worktrees: one shell at the repo root for shared
work, cd into a worktree only for branch-specific edits. It is also
what the Claude Code reference implementation does
(getOriginalCwd() in utils/permissions/filesystem.ts).
Plan Approval (convention)
If your prompt to the lead mentions “Plan Approval”, “with plan approval”, or similar wording, the system reads it as a lead↔teammate convention — NOT a request to ask the human user:
- Each teammate, before starting non-trivial work, sends a brief plan (1–3 lines: what they’ll do, what they’ll touch) to the lead via SendMessage.
- Lead reviews and replies “approved, proceed” or “revise: …”.
- Teammate waits for the ack, then executes.
The lead is the approver — never the user, even when a human is watching. The mode only activates when the user prompt explicitly mentions it; otherwise teammates execute work directly so default behavior is preserved. Defined in default_prompts/lead.md and default_prompts/agent_team.md.
Role guards (lead vs teammate)
To stop an LLM lead from accidentally wiping a teammate’s files (e.g.
the actual rm -rf tests/ we observed in a test run), BashTool /
Write / Edit have hard guards:
Lead — refused regardless of --accept-all:
| Command | Why blocked |
|---|---|
git reset --hard <ref> |
discards committed work |
git clean -f / -d |
deletes untracked files |
git push --force / git rebase |
rewrites shared history |
git worktree remove / prune |
kills teammate’s process + worktree |
git checkout -- <path> / git checkout . / git restore --worktree / git restore . |
discards teammate’s uncommitted work |
git merge --abort |
tears down a merge instead of delegating |
rm -rf / -fr / -r |
destructive removal |
Write / Edit (any path) |
lead is a coordinator, not the author |
git push -f counts the same as git push --force. The list matches on
the lowercased command text, so case games don’t get through.
Obfuscation is refused, not decoded. A destructive command the lead
builds through $VAR, $(…), backticks, eval or brace expansion is
rejected outright — the guard can’t verify what such a string will
expand to, so it declines rather than guessing. Run a plain literal
command, or hand the destructive step to the teammate who owns it.
Write/Edit exception: when a git merge is in progress AND the target file currently contains <<<<<<< markers, the lead may write the resolved version. Once it commits the merge, MERGE_HEAD disappears and the block snaps back on automatically.
Teammate — refused:
| Command | Why blocked |
|---|---|
git reset --hard <branch-name> (e.g. main, origin/main, team/backend) |
resets your branch tip to a different branch — discards your own commits |
Still allowed (legitimate same-branch recovery): HEAD~N, HEAD@{N}, HEAD^, hex SHAs, tags/....
When a guard fires, the tool returns an error explaining what’s blocked and what to do instead (e.g. “delegate to a teammate via SendMessage” or “use HEAD~N rather than main”). Well-trained models redirect rather than retry.
Editor stubs for teammates
SpawnTeammate sets EDITOR=true VISUAL=true GIT_EDITOR=true GIT_SEQUENCE_EDITOR=true on every teammate process.
So commands that would open an editor (git commit -e, git commit with no -m, git rebase -i) don’t hang waiting for human input via /dev/tty — the true builtin exits 0 immediately, and git uses whatever message was already provided via -F/-t or commits empty per default. Prevents vi or nano from stalling the entire team mid-run.
Protocol messages
Standard message types teammates and lead exchange:
| Type | From → To | Meaning |
|---|---|---|
idle_notification |
teammate → lead | “I finished task X” — carries idle_reason, the task id, its final status and a summary |
shutdown_request |
lead → teammate | “Stop and exit cleanly” — sent to every member when the lead exits |
shutdown_approved |
teammate → lead | “Nothing in flight; I’m stopping.” The teammate writes stopped and exits |
shutdown_rejected |
teammate → lead | “Still have unfinished tasks” — the teammate keeps polling |
abort_turn |
lead → teammate | Cancel the current turn cooperatively — the only way to interrupt a headless teammate, which never receives Ctrl+C |
user |
user → teammate | Free-form text (via send to <agent>: …) |
idle_notification is not only a “done” signal. Its idle_reason
field distinguishes the outcomes the lead has to react to differently:
idle_reason |
What it means |
|---|---|
available |
Clean finish, ready for the next task |
interrupted |
The turn was cut short |
failed |
The turn failed — a provider or config error, not a code problem |
blocked |
Gave up mid-task (hit max_iterations or a time budget); the task is left for the lead to re-drive |
Shutdown is a negotiation, not a kill. On exit the lead sends
shutdown_request to every member and waits ~1.2 s. A teammate with a
queued message or an in-progress task answers shutdown_rejected and
carries on. Only then do the hard fallbacks run — the child handles the
lead owns, and (on Unix) a pkill on the teammates’ --team-dir
argument. So closing the lead does not, by itself, discard a teammate’s
work in flight.
Monitoring in the GUI
The Team tab shows one pane per teammate plus a lead pane mirroring
the main terminal. ANSI colours are translated to HTML: green for LLM
text, cyan for prompts and inbox messages, dim for tool starts and
token lines, yellow for errors or hit-max-iterations.
Status comes from each teammate’s own status.json — no false crash
flagging based on missing heartbeats. You’ll see spawning (written by
the lead, before the teammate has booted), then idle, working, and
finally stopped once it exits.
When not to use teams
Most tasks are fine with a single agent + sub-agents via Task
(Chapter 15). Reach for teams only when the parallelism is real and
the overhead pays for itself. A good litmus: if you could hand each
teammate’s task to a different human contractor without coordination
headaches, it’s a team shape.