# Agent Fleet Safety and Cost Controls The settings, hooks, permission rules, budgets and kill switches that keep a fleet of agents from costing more than it saves or doing something you cannot undo. Companion to `agent-fleet-playbook.md`. Version 1.0, 2026-08-21. Written against Claude Code 2.1.239. Verify anything version-sensitive against https://code.claude.com/docs before you rely on it. Source article: https://jwatte.com/blog/claude-code-agent-fleet-org-chart/ --- ## 0. The five numbers to know before you build anything | Limit | Default | How to change it | |---|---|---| | Concurrent subagents in one session | 20 | `CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS` | | Subagent nesting depth below the main conversation | 3 layers | `CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH` | | Recurring in-session scheduled tasks before they expire | 7 days | Recreate, or move to a durable scheduler | | Scheduled tasks a single session can hold | 50 | Not configurable | | Idle background sessions before the supervisor stops them | about 1 hour | Pin the session so it is not stopped | The nesting number is the one that shapes the org chart. Three layers below the main conversation is exactly enough for lead, project lead, and IC. A fourth management layer does not fit under the default, and raising the limit is usually the wrong answer to the problem that made you want it. --- ## 1. Where settings live, and which one wins Claude Code reads several settings files and applies them in a fixed order. Know the order before you debug why a rule is not taking effect. Roughly lowest to highest precedence: 1. User settings: `~/.claude/settings.json` 2. Project settings: `.claude/settings.json` (checked into the repo, shared with the team) 3. Local project settings: `.claude/settings.local.json` (not checked in, yours only) 4. A `--settings` payload passed on the command line 5. Managed settings, deployed by an administrator Managed settings apply last and cannot be overridden from below. That is the whole point of them, and it is the control an organization actually needs: a developer cannot turn off a policy their administrator set. Practical layering for a fleet: * **Managed or project settings**: the deny rules and anything that must be true for everyone. * **Project settings**: the allowlist of boring commands, so the whole team stops approving the same thing forty times a day. * **Local settings**: your own conveniences. Nothing safety-relevant. --- ## 2. Permissions: deny at the edges, allow the boring middle Permission rules are written per tool, with an optional specifier in parentheses. A deny rule beats an allow rule. ```json { "permissions": { "defaultMode": "default", "allow": [ "Bash(npm test)", "Bash(npm run build)", "Bash(git status)", "Bash(git diff:*)", "Read", "Grep", "Glob" ], "deny": [ "Bash(rm -rf:*)", "Bash(curl:*)", "Read(./.env)", "Read(./secrets/**)", "WebFetch" ], "ask": [ "Bash(git push:*)", "Write", "Edit" ] } } ``` Three habits that matter more than the exact list: **Deny reads, not just writes.** A denied write stops damage. A denied read stops the secret from entering a context window in the first place, and once it is in a context window it is in a transcript on disk. `Read(./.env)` and `Read(./secrets/**)` are the two most valuable lines most people never add. **Allowlist by exact command, not by prefix, where you can.** `Bash(npm test)` is a promise. `Bash(npm:*)` is a wish. **Approval fatigue is a security failure, not a UX complaint.** A human who has clicked yes forty times today is not a control. Every prompt you eliminate with a written rule makes the remaining prompts mean something. ### Turning off agent messaging entirely If your organization does not want agents talking to each other at all, the deny rules take the bare tool names, and the inbound side is a separate setting: ```json { "permissions": { "deny": ["SendMessage", "ListAgents"] }, "crossSessionInbound": "refuse" } ``` Denying `SendMessage` removes messaging to subagents and to team teammates as well, since the same tool serves all of it. Set both, or you have closed one door. --- ## 3. Inbound message controls A session decides what to do with messages arriving from your other sessions through `crossSessionInbound`: | Value | Behaviour | |---|---| | `accept` | Every message is delivered to Claude | | `hold` | A notice appears, the message is not delivered until you approve it | | `refuse` | Messages are dropped without delivery | When no value is set, the behaviour depends on the two sessions' permission modes: a session that bypasses permission prompts holds inbound messages for your approval unless the sender also bypasses. The held-message dialog expires after five minutes by default, controlled by `dialogExpiry`, and the message is then dropped. For an unattended worker started with `-p`, set `accept` in its own `--settings` payload rather than in your user settings, so the change applies to that worker and not to every session you run. ### The mismatched-permission-class deadlock Set this deliberately on every lead. Leaving it to the default produces the single most confusing failure in a two-lead setup. When no value applies, Claude Code decides per message from the two sessions' permission modes, sorting them into two classes: sessions that bypass permission prompts, and sessions that prompt. A receiving session that **bypasses** prompts holds every inbound message for your approval, and delivers it only when the sender also bypasses. The dialog expires after `dialogExpiry`, five minutes by default, and the message is dropped. So one lead started with permissions skipped and one started normally **will not talk to each other while you are away.** Every message dies in an unanswered dialog. The sender does get a notice that its message was held and later expired, but the recipient of that notice is an agent in a session nobody is watching, nothing retries, and both leads keep reporting healthy. Two ways out, and you must pick one deliberately: * Set `crossSessionInbound` to `accept` on both leads, explicitly. * Or keep both leads in the same permission class. The mismatch is what triggers it. To require your explicit approval before any message leaves the machine: ```json { "isolatePeerMachines": true } ``` A `true` from any settings scope applies, so a checked-in project file can turn the requirement on but cannot turn it off. --- ## 4. What a message from another agent can and cannot do This is the security model that makes a talking fleet safe, and it is worth reading twice because it is the difference between delegation and a hole in your permission system. * A message from another agent **never counts as your consent**. It cannot answer a pending permission prompt on your behalf. * An agent that was denied an action **cannot route around it** by asking another agent to do it. The receiving session's own rules still apply. * **Commands inside message text do not run.** A `/compact` in the body of a message arrives as plain text. * The receiving agent is told the message came **from another Claude session, not from you**. * In auto mode, a relayed approval claim is treated as untrusted input, and each message is reviewed before delivery. The operational conclusion: inter-agent messages are data with a known sender. Write your charters so agents treat them that way, and never build a workflow whose safety depends on one agent honouring another agent's claim that something was approved. --- ## 5. Loop protection, and why you still need your own Claude Code has built-in protection against two agents talking forever: * Repeated messages per sender are rate limited. * Identical repeats inside a short window are dropped. * At most 50 accepted messages are queued for the receiver to read. * A rapid burst to one session is refused at the sender, which is told to batch or wait. * Same-machine messages over roughly a million characters are refused before they leave. That means a pure message loop between two sessions stops on its own. It does not mean a two-lead design is automatically safe, because the expensive loop is not a message loop. It is two agents that each keep doing real work in response to the other: re-planning, re-reading, re-verifying. Nothing in the transport layer can see that. Your controls for it are the message budget, the requirement that a message carries a recommendation, and the rule that a disagreement escalates on the second round rather than looping a third time. --- ## 6. The cheap health check The naive mutual watch is a timer that sends "are you alive" to the other lead every few minutes. Do not build that. When a message arrives at an idle session, it starts a new turn, and a new turn sends that session's whole context. A heartbeat into an idle session is one of the most expensive things you can schedule, and it grows more expensive the longer that session has been alive. The cheap version already exists. `SendMessage` takes a `notify_when_idle` input that subscribes to a one-shot notice when the other session next goes idle or exits. Subscribing on its own does not start a turn in the watched session and does not spend its tokens. The notice fires once, and the subscription is dropped after 12 hours if nothing arrives. Two constraints to design around: * Only the main conversation can subscribe, and only to sessions on the same machine. A subagent or a teammate that tries gets told no. * It is one-shot. Re-subscribe after each notice, and treat "no notice within 12 hours" as its own signal rather than as silence. So the health check is event-driven, not polled: subscribe, wait, and when the notice arrives, decide whether idle means finished or stalled by looking at whether an artifact appeared. Polling is the fallback for the case a notice cannot cover, and it should run on the order of tens of minutes, not seconds. --- ## 7. Watchdog hooks Hooks are the deterministic layer. They run whether or not the model decides to, which is exactly what you want from a control. The events most useful to a fleet: | Event | Fires when | Use it for | |---|---|---| | `SubagentStart` | A subagent is spawned | Log who started what, stamp a start time | | `SubagentStop` | A subagent finishes | Verify an artifact exists before the result is accepted | | `Stop` | The main agent finishes responding | Append to the log, update the board timestamp | | `TeammateIdle` | A teammate is about to go idle | Exit 2 to push back and keep it working | | `TaskCompleted` | A task is being marked complete | Exit 2 to refuse a completion that has no evidence | | `PreToolUse` | Before a tool call | Block a class of command outright | | `PreCompact` | Before context compaction | Checkpoint state to the board while it still exists | | `SessionEnd` | A session terminates | Flush anything in memory that should have been on disk | Exit codes are the interface: **0** means no decision, **2** blocks the action and sends stderr back as the reason, and anything else is a non-blocking error. ### The artifact gate The single highest-value hook in a fleet. It refuses to let a task be marked complete unless the artifact it promised actually exists and is not empty. ```json { "hooks": { "TaskCompleted": [ { "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/artifact-gate.sh" } ] } ] } } ``` ```bash #!/bin/bash # .claude/hooks/artifact-gate.sh # Refuse a completion whose named artifact is missing or empty. input=$(cat) task_id=$(echo "$input" | jq -r '.task_id // empty') manifest=".claude/fleet/artifacts/$task_id" if [ ! -f "$manifest" ]; then echo "No artifact manifest for task $task_id. Write the artifact path to $manifest before completing." >&2 exit 2 fi while IFS= read -r path; do [ -z "$path" ] && continue if [ ! -s "$path" ]; then echo "Artifact missing or empty: $path" >&2 exit 2 fi done < "$manifest" exit 0 ``` Then prove it works. Point a manifest at a file that does not exist and confirm the completion is refused. A gate you have never watched fail is a decoration. ### The board freshness check ```bash #!/bin/bash # .claude/hooks/board-freshness.sh (wire to Stop) # Warn when a project board has not been touched in 24 hours. now=$(date +%s) stale=0 for board in .claude/fleet/boards/*.md; do [ -e "$board" ] || continue mtime=$(date -r "$board" +%s 2>/dev/null || stat -c %Y "$board") age=$(( (now - mtime) / 3600 )) if [ "$age" -ge 24 ]; then echo "STALE BOARD: $board has not changed in ${age}h" >&2 stale=1 fi done exit 0 # advisory, not blocking ``` Advisory on purpose. A stale board is a signal, not a reason to stop the session. --- ## 8. Model tiering, in one place Set the tier per seat in the agent definition rather than globally, and you will typically cut fleet cost by more than any other single change. ```yaml --- name: reviewer description: Adversarial reviewer. Use before any contract is accepted. tools: Read, Grep, Glob model: opus effort: high --- ``` ```yaml --- name: ic-mechanical description: Renames, formatting, extraction, and other mechanical passes with no judgement. tools: Read, Edit, Grep, Glob model: haiku effort: low maxTurns: 40 --- ``` Four frontmatter fields do most of the cost work: * `model` sets the tier for that seat. * `effort` sets the reasoning budget. Thinking tokens bill as output tokens, so a mechanical seat left at a high effort level is paying frontier prices for a rename. * `maxTurns` caps how long a seat can grind before it stops. This is your per-seat circuit breaker. * `tools` cuts the blast radius, and incidentally the context, since fewer tools means fewer definitions loaded. `CLAUDE_CODE_SUBAGENT_MODEL` sets the default for spawned agents when a prompt does not name one, which is useful when you want the whole worker layer on a cheaper tier without editing every definition. --- ## 9. Where the money actually goes Published figures for Claude Code across enterprise deployments: around 13 dollars per developer per active day, 150 to 250 dollars per developer per month, and under 30 dollars per active day for 90 percent of users. Those are single-developer figures, not fleet figures. For a fleet, the multipliers on top are what matter: * **Each parallel session is a full multiplier.** Ten background sessions consume quota about ten times as fast as one. There is no sharing. * **Coordinated teammates cost more than that.** Agent teams are documented at roughly 7x the tokens of a standard session when teammates run in plan mode, because every teammate carries its own context window. * **Long sessions cost more than short ones, even when idle.** The full conversation is sent with every request. A one-line question in a session that has been open all day still carries the whole day. * **Cache misses are a cliff, not a slope.** The cache lifetime is one hour on a subscription and five minutes on an API key or a cloud provider by default. The first message after a longer gap reprocesses the entire context. `ENABLE_PROMPT_CACHING_1H=1` keeps the longer lifetime when drawing on usage credits. * **Anything that wakes an idle session costs a full turn.** Scheduled tasks, inbound messages from other sessions, and goal check-ins all start a turn that sends the whole context. In a fleet that ticks on a timer, this can quietly become the largest line item. The last two are why a fleet's bill often looks nothing like the sum of its work. ### Controls that follow from that 1. Keep charters short and identical where they can be identical, so the cacheable prefix is as long as possible. 2. Clear or close sessions between unrelated tasks rather than letting one run all day. 3. Prefer event-driven wakeups over timers. See section 6. 4. Shut teammates down when their work is finished. An active teammate keeps consuming until it exits or the session ends. 5. Keep the shared instruction file small. Under 200 lines is the published guidance, and it is loaded into every agent's context at startup. 6. Move long, situational instructions out of the always-loaded file and into something that loads only when it is needed. --- ## 10. Isolation for parallel writers Two agents editing the same file will overwrite each other. There is no merge fairy. * **Background sessions** are moved into their own git worktree automatically before editing. * **Subagents** can be given one per agent by setting `isolation: worktree` in the definition. * **Teammates in a team are not isolated.** Partition the work by file so each teammate owns a different set, or accept the conflicts. Worktree isolation costs a little setup time and disk per agent, so use it where agents write concurrently and skip it where they only read. --- ## 11. The kill switch Know it before you need it, and run it once on a quiet day. * **Stop one subagent or background task**: the task list in the session lets you check on, attach to, or stop each running item. * **Stop one teammate**: select it in the agent panel and press the stop key, or ask the lead to send it a shutdown request. A teammate finishes its current tool call first, so shutdown is not instant. * **Stop one background session**: attach to it and exit, or stop it from the agent view. * **Stop the scheduler**: `CLAUDE_CODE_DISABLE_CRON=1` disables scheduled tasks entirely and stops anything already scheduled from firing. * **The panic button**: `Ctrl+X Ctrl+K`, pressed twice within three seconds, stops every running background subagent in the current session. It also turns off artifact auto-replies for the rest of the session. This is the one to memorise. * **From the shell**: `claude stop`. * **In agent view**: `Ctrl+X` stops the selected session, and a second `Ctrl+X` within two seconds deletes it. The second press works even when the stop attempt failed, which is what you want when the background service is the thing that is wedged. * **Stop the whole thing**: end the supervisor process that hosts background sessions. Know how to find it on your platform before you need to. Two things to check while you are testing it: 1. How long it actually takes. An agent mid-tool-call does not stop instantly. 2. What state it leaves behind. Half-finished worktrees, a board that says in flight for work that is not, and scheduled tasks that survive. --- ## 11a. Three telemetry traps that produce a confident, wrong dashboard If you export OpenTelemetry to build a fleet view, know these before you trust a chart. **The per-agent token field is not a total.** `claude_code.subagent_completed` carries `total_tokens`, and it covers only the final request, roughly the subagent's context size when it finished. Summing it across agents yields a number that is neither total spend nor peak context, and it looks perfectly reasonable. For rollups use the token counter and the cost counter filtered to `query_source` of `"subagent"` instead. **Your own agent names are redacted.** Built-in agents and agents from official-marketplace plugins export their names verbatim. **Every other agent name exports as the literal string `"custom"`** unless `OTEL_LOG_TOOL_DETAILS=1` is set. So a default dashboard cannot tell your verification lead from your mechanical IC. The same applies to user-authored workflow names. `agent.source` and `parent_agent_id` do come through, so you can still rebuild the tree shape even when the labels are useless. **Watching the fleet is billed.** Agent view's end-of-turn summary and each mid-turn rewrite are one short Haiku-class request each, through your normal provider. The 15-second updates in between are free because they reuse existing output. On a third-party provider or gateway with no Haiku-class model configured, those requests use **the session's main model**, so idly watching frontier-tier sessions generates frontier-tier summary traffic. Set `ANTHROPIC_DEFAULT_HAIKU_MODEL` if you route through a gateway. ## 11b. The task list may not exist The agent-teams design describes a shared task list as the coordination substrate. From Claude Code v2.1.233, `TaskCreate`, `TaskGet`, `TaskList`, `TaskUpdate` and `TodoWrite` are **not provided on Opus 4.8, Sonnet 5, Fable 5, Mythos 5 or later members of those families** unless you opt in, because those models track multi-step work without a written checklist and the tool definitions cost context. So on the current flagship models, in an interactive session, that substrate is absent by default and agents fall back to messaging. Opt back in with `CLAUDE_CODE_ENABLE_TODO_TOOLS=1` before launching, or by naming a tool in `--allowedTools`. Background sessions and Claude Code on the web provide them on every model regardless, so the same prompt behaves differently depending on where you ran it. None of this matters if your state lives in the board file, which is the entire argument for putting it there. ## 12. Durability traps in the scheduling layer If your watchdog is built on session-scoped scheduling, read this twice. * **Recurring in-session tasks expire after 7 days.** The task fires one final time and deletes itself. A mutual health check built this way stops silently on day seven, and everything looks fine right up until it does not. * **Tasks only fire while the session is running and idle.** Close the terminal and they stop. Backgrounding the session carries them over. * **There is no catch-up.** A task whose time passes while the agent is busy fires once when it goes idle, not once per missed interval. * **Starting a fresh conversation clears them.** Resuming brings back unexpired ones. * **Fire times are jittered.** Recurring tasks can fire up to 30 minutes after the scheduled time. If exact timing matters, do not schedule on the hour. * **Idle background sessions are stopped after about an hour** unless pinned. A fleet meant to run for days must pin the sessions that need to survive. For anything that has to outlive a session, use durable scheduling: a cloud routine, a desktop scheduled task, or CI. Cloud routines have a one-hour minimum interval and no access to local files, which is usually fine for a health check and not fine for the work itself. --- ## 13. Provider gaps that will surprise you * **Cross-session messaging is not available on Amazon Bedrock, Claude Platform on AWS, Google Cloud's Agent Platform, or Microsoft Foundry.** If your organization runs Claude Code through one of those, the multi-session pattern in this kit does not work as written, and you need a different coordination substrate. * **A session inside WSL 2 and a native Windows session on the same computer cannot reach each other.** They register under different home directories and listen on different socket types. * **A container has its own filesystem**, so a session inside it and a session on the host cannot see each other. Two sessions inside the same container can. * **Turning off feature-flag fetching turns off messaging.** Several privacy and telemetry environment variables have that side effect. If messaging is silently absent, check those before anything else. --- ## 14. The pre-flight checklist Before you let a fleet run unattended for the first time: * [ ] Deny rules cover reads of secrets, not only writes. * [ ] The allowlist covers everything you approved more than twice this week. * [ ] Every seat has a `model` and a `maxTurns`. * [ ] Every seat that writes concurrently has worktree isolation. * [ ] The artifact gate is wired, and you have watched it refuse a completion. * [ ] The board freshness check is wired, and you have watched it flag a stale board. * [ ] The health check is event-driven, not a timer into an idle session. * [ ] Nothing safety-relevant depends on a recurring in-session task that expires in 7 days. * [ ] Sessions that must survive the day are pinned. * [ ] You have run the kill switch once and timed it. * [ ] Spend reporting arrives somewhere a human reads, daily. * [ ] You know what happens to inbound messages in every session: accept, hold, or refuse. * [ ] Both leads are in the same permission class, or `crossSessionInbound` is set explicitly on each. Never leave this to the default. * [ ] You have pressed `Ctrl+X Ctrl+K` once, on purpose, on a quiet day. * [ ] If you built a telemetry dashboard, you are not summing `total_tokens` across agents, and you have decided whether to set `OTEL_LOG_TOOL_DETAILS=1` so your agent names are readable. --- Companion files at https://jwatte.com/downloads/ * `agent-fleet-playbook.md` * `agent-fleet-role-charters.md` * `agent-fleet-operating-contracts.md` * `agent-fleet-smb-quickstart.md` Written by J.A. Watte. https://jwatte.com