# Dispatch Envelope — What Actually Works

## Purpose

This document is the canonical reference for what actually works when the PA dispatches subagents through the Claude Code Agent tool: which subagent types can spawn children, how deep nesting goes, what tools children inherit, what isolation is real vs illusory, and what fails silently or loudly. It exists because the PA's operating model — especially the Project Lead orchestrator pattern — makes architectural bets on these mechanics; those bets have now been verified empirically (2026-07-05) rather than assumed.

The scope is the dispatch *mechanism*, not the briefing content. For brief-writing standards see `@stravica-ai/engineering-pa-core/docs/standards/briefing-contract.md`; this doc is upstream of that — the constraints below shape what a brief can and cannot ask a worker to do.

All findings were gathered by a live diagnostic run on 2026-07-05 in an operator instance codebase. Re-verify if the harness version shifts materially.

---

## Capability Matrix

| subagent_type | Dispatchable? | Has Agent tool? | Can spawn children? | Full MCP inventory? | Notes |
|---|---|---|---|---|---|
| `general-purpose` | yes | yes | yes | yes | Default choice for orchestrators. Tool inventory `*`. |
| `claude` | yes | yes | yes | yes | Equivalent to general-purpose for dispatch purposes. Tool inventory `*`. |
| `Explore` | yes | **no** | **no** | (moot — no Agent) | Read-only search agent. Excludes Agent, Artifact, ExitPlanMode, Edit, Write, NotebookEdit. |
| `Plan` | yes | **no** | **no** | (moot — no Agent) | Architect/planner. Same exclusions as Explore. |
| `claude-code-guide` | yes | **no** | **no** | (moot — no Agent) | Tools limited to Bash, Read, WebFetch, WebSearch. |
| `statusline-setup` | yes | **no** | **no** | (moot — no Agent) | Tools limited to Read, Edit. |

**Rule:** any orchestrator that must dispatch its own workers MUST be dispatched as `general-purpose` or `claude`. Dispatching an orchestrator role as any other type is a silent architectural bug — the runtime tool inventory won't include Agent, so the nested dispatch will fail the moment the orchestrator tries to spawn a worker.

Source for tool inventories: the harness's own `Available agent types` system reminder, corroborated by nested-dispatch runtime tests.

---

## Depth Findings

Nested dispatch works cleanly to at least **depth 4** with no permission prompts, no runtime errors, and modest per-level overhead.

**Confirmed chain from root to leaf:**

```
Parent (root)
  └─ Child (general-purpose)                     ~1.2s spawn overhead
       └─ Grandchild (general-purpose)           returned "DEPTH-3-ECHO"
            └─ Great-grandchild (general-purpose) returned "DEPTH-4-ECHO", ~2.7s
```

Root parent thread observed:
- Depth-3 grandchild sentence verbatim, echoed through the child's report.
- Depth-4 great-grandchild sentence verbatim, echoed through the child's report of the grandchild's report.

Per-level cost (zero-tool child, echo only):
- Depth-2 → depth-3 hop: ~1.2s (`duration_ms: 1184`)
- Depth-3 → depth-4 hop: ~2.7s (`duration_ms: 2746`)
- Wrapping orchestrator wall-clock: ~15-30s including its own reasoning
- The overhead is dominated by the orchestrator's model turn, not the spawn itself

No signs of a depth cap up to depth-4. Depth-5+ was not tested and remains open; assume it works but verify before betting on it.

**Observability trade-off scales linearly with depth.** Each nesting level is a summarisation checkpoint — the root only sees what the level-1 orchestrator reports, which only saw what the level-2 orchestrator reported, and so on. Depth-4 means the root's view of the leaf is triply summarised. For debugging or high-fidelity output the shallowest possible dispatch shape is preferable.

---

## Foreground vs Background

### Foreground (`run_in_background: false`)

- Returns the child's final assistant message as plain text directly in the Agent tool result.
- Blocks the parent turn until the child completes.
- Tail of the result includes: `agentId: a...-... (use SendMessage with to: 'a...-...' to continue this agent)` — the agentId is available for resumption even from a foreground call.
- No streaming, no partial progress visible during execution.
- **Use for:** short critical-path work where the parent literally cannot proceed without the result (e.g. a design decision, a lookup that gates the next dispatch).

### Background (`run_in_background: true` — the default)

- Returns immediately with a metadata blob containing `agentId`, an `output_file` symlink pointing at the child's JSONL transcript, and a warning explicitly forbidding reading it (context-overflow risk).
- Completion arrives asynchronously as a `<task-notification>` message with `<status>completed</status>` and a `<result>...</result>` block containing the child's final message.
- Notification includes: `A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.`
- Multiple background dispatches from a single message run **in parallel** — confirmed with 10 concurrent dispatches completing within a tight 3-second window.
- Notifications from many parallel completions arrive in bursts of 2-4 rather than one-per-line.
- **Use for:** anything that isn't gating the very next turn — which is most things.

### SendMessage / resume semantics

- SendMessage to an existing agentId resumes it in the background with full transcript context. The response says `resumed from transcript in the background with your message`.
- The resumed agent is the **same** agent — same agentId, same conversation history, notification fires again on next stop.
- Recipient MUST be an agentId (format `a...`) or a live teammate name. Referring to a peer by `subagent_type` fails — e.g. `SendMessage({to: "general-purpose"})` returns `"No agent named 'general-purpose' is reachable"`. There is no group addressing by role.
- SendMessage supersedes dispatching a fresh Agent when continuing prior work is cheaper than briefing a new one. Use it to hand a follow-up task to a child that already has the context loaded.
- `to: "main"` is available from a background subagent to talk back to the parent — but the parent still receives it as a normal message, not a special channel.

### Return-shape parity across foreground and background

| Aspect | Foreground | Background |
|---|---|---|
| What returns | child's final assistant message text | metadata + async notification with final message |
| agentId available | yes (in tail) | yes (up front) |
| Intermediate tool events visible | no | no |
| Transcript file on disk | yes | yes (as symlink, do not read) |
| Resumable via SendMessage | yes | yes |

The transcript is always on disk as JSONL under `/private/tmp/claude-<uid>/<session>/tasks/{agentId}.output` (symlink) → `~/.claude/projects/<session>/subagents/agent-{agentId}.jsonl`. The harness warns not to read these directly — they include full tool traces and will blow the parent's context. If per-child transcript inspection matters, do it out-of-band after the session ends, not inline.

---

## MCP Tool Availability

**Finding: MCP tools do inherit into subagents.** The parent's full deferred-tool inventory is visible in every child spawned as `general-purpose` or `claude`. Confirmed by direct load-and-call diagnostics from a child agent.

Verified in a subagent's tool inventory:
- `WebFetch` — loads and returns real fetched content
- `WebSearch` — available
- `mcp__rcf-tools__rcf_status` — loads and returns valid JSON response
- `mcp__playwright__browser_navigate` — loads and successfully navigates
- `mcp__claude_ai_Gmail__authenticate` — listed as available
- `mcp__claude_ai_Atlassian__authenticate` — listed as available
- `SendMessage`, `Monitor` — listed as available
- `ToolSearch` itself — available (subagents must use it to load deferred schemas, same as the parent)

**Important shared-state caveat — Playwright browser session is shared with the parent.** When a subagent called `browser_navigate`, the response showed a pre-existing second tab open from a prior parent-thread run. The Playwright MCP browser context is **not** per-subagent; it's session-scoped. Implication:

- If the parent has a Playwright session in flight and dispatches a child that also uses Playwright, they share the same browser instance and can step on each other's tabs.
- OAuth-gated MCP tools (Google Drive, Gmail, Calendar, Atlassian, Canva) also share the parent's auth state — a child does not need to re-authenticate.
- For MCP tools like Playwright where state matters, only one agent at a time should be driving the browser. Serialise.

The subagent-type restrictions apply here too: `Explore`, `Plan`, `claude-code-guide`, and `statusline-setup` have restricted tool inventories that exclude MCP tools by default. Only `general-purpose` and `claude` inherit the full inventory.

---

## Concurrency and Limits

- **Documented cap:** none. The Agent tool description mentions parallel dispatch (`When you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently`) but specifies no numeric ceiling, queue, or throttle.
- **Observed cap:** none up to N=10. Ten concurrent background subagents launched from a single message all started immediately, ran in parallel, and completed within a tight ~3-second spread. No queuing, no refusal, no throttling.
- **Untested:** N > 10. If the harness has a hard ceiling it's above 10; do not rely on unbounded parallelism without re-testing.
- **Notification batching:** completion notifications arrive in bursts of 2-4 messages when many finish close together. This is a UX detail — it doesn't change semantics, and the ordering per-agent is preserved.

**Practical guidance:** dispatch as many parallel workers as make sense for the problem. Don't artificially cap at 3 or 5 — the harness accepts real parallelism. But note that each concurrent dispatch is a real token cost against the account, so parallelism is expensive even when it's fast.

---

## Worktree Isolation

Passing `isolation: "worktree"` to the Agent tool cuts a git worktree for the child. What that actually isolates:

**Isolated (git-only sandbox):**
- Working tree — each worktree gets its own filesystem view of the repo at `.claude/worktrees/agent-{agentId}/` under the parent repo root.
- Git branch — auto-named `worktree-agent-{agentId}`, cut from the parent's current HEAD.
- Repo-relative operations — file edits, staged changes, `git status` are per-worktree.
- The parent's working tree is untouched by child edits inside the worktree.

**NOT isolated (shared with parent and other agents):**
- `/tmp` — writes visible to parent and to sibling worktree-isolated agents.
- `$HOME` — shared user config.
- Network, credentials, MCP session state (see Playwright caveat above).
- Any absolute path outside the repo root.
- Environment variables (unless explicitly overridden).

**Cleanup:** manual when the child made changes. The Agent tool description states worktrees are auto-cleaned only when the agent made no changes. Both dirty test worktrees persisted at `.claude/worktrees/agent-{agentId}/` after completion. Prior sessions accumulate stale worktrees under `{OPERATOR_REPO_ROOT}/.claude/worktrees/` — worth a periodic `git worktree prune` + directory sweep.

**Concurrency safety:** two parallel worktree-isolated agents do not conflict on repo files — each has its own branch and its own working tree. But they DO share `/tmp` and any other absolute paths outside the repo, so treat worktree isolation as "git-safe parallel edits" not "sandboxed process".

---

## Long-Running Health

**Signals available to the parent while a child is running:**

- Presence of the child's task output file at `/private/tmp/claude-<uid>/<session>/tasks/{agentId}.output` (symlink to the JSONL transcript). The symlink exists as soon as the child starts.
- Absence of a task-notification for that agentId means "still running (or crashed silently — rare)".
- No documented API for peeking at partial progress, and the harness explicitly warns not to read the transcript file directly (context overflow risk).

**Detecting a stalled vs slow child:**

- There is no built-in health signal beyond notification-vs-silence.
- A child that runs quietly for a long time and never notifies is indistinguishable from a slow child until it finishes.
- Mitigation for genuinely long dispatches: brief the child to send periodic `SendMessage` updates to `main` at natural checkpoints. This makes progress observable without reading the transcript.
- For jobs whose duration is genuinely unknown (multi-hour scrapes, model runs), the harness is not the right container — kick them off via `schedule` (cloud routine) or a scheduled task instead.

**Concurrency test data point:** a background child running for ~78s wall-clock produced zero partial signals. Only the final `<task-notification>` fired at completion. Assume the same for anything below the notification threshold.

---

## Brief-Writing Implications

Rules derived from the findings above. Fold into `briefing-contract.md` when it's next updated.

**DO:**

1. **Dispatch orchestrators as `general-purpose` or `claude`.** Any Project Lead, task-group orchestrator, or worker that will itself spawn children MUST be one of these two types. State the subagent_type constraint explicitly in the brief so future refactors don't accidentally downgrade the type.
2. **Prefer background dispatch by default.** Foreground blocks the parent turn and is only justified when the result gates the very next action. All fan-out and parallelisable work goes background.
3. **Design for depth-1 or depth-2 orchestration whenever possible.** The observability tax on each nesting level (final-message-only rollup) makes depth-3+ progressively harder to reason about. Use nested orchestrators only when the problem genuinely needs a multi-role team.
4. **Require structured returns from orchestrators.** Since the parent only sees the orchestrator's final message, the brief must specify the exact return shape covering every child's outcome (name/role, agentId, status, key artefacts). Otherwise child work becomes opaque.
5. **Brief periodic SendMessage checkpoints for long-running children.** For any child expected to run >5 minutes, the brief should require it to `SendMessage` progress to `main` at natural checkpoints. Otherwise a stalled child is indistinguishable from a slow child.
6. **Explicitly name MCP shared-state hazards.** If a child will use Playwright, Gmail, or any MCP with session state, the brief must acknowledge the shared browser/OAuth session and instruct the child to serialise with any other agent using the same tool.
7. **Address SendMessage recipients by agentId, not by type.** Any brief that instructs a child to message a peer must include the peer's agentId. Referring to peers by role or subagent_type will fail at runtime.

**DON'T:**

1. **Don't dispatch a Project Lead (or any orchestrator) as `Plan`, `Explore`, `claude-code-guide`, or `statusline-setup`.** Those types lack the Agent tool. The nested dispatch will fail — usually at the worst possible moment.
2. **Don't run two Playwright agents in parallel.** Browser session is shared. Serialise, or accept that they will clobber each other's tabs.
3. **Don't rely on worktree isolation as a security or sandbox boundary.** It isolates the git tree only. `/tmp`, `$HOME`, network, and everything absolute-path outside the repo are wide open.
4. **Don't put critical writes to shared paths (`/tmp`, `~/.claude/`) inside parallel workers.** They'll race. Use per-agent output directories or namespace files by agentId.
5. **Don't assume worktrees auto-clean.** If a worker makes changes and doesn't merge or discard them, the worktree persists. Include a cleanup instruction or accept the accumulation.
6. **Don't ask children to read each other's transcript files.** The `output_file` symlinks exist but reading them will overflow whoever tries. Route inter-worker communication through SendMessage or through the shared context store (task-group standard).
7. **Don't ship a brief that asks a child to "let me know when you're done at each step".** Streaming intermediate events isn't available. The child can `SendMessage("main", ...)` for progress, but that's model-driven and needs to be requested explicitly.

---

## Operator-presence stamp

Every session reads the SessionStart hook's attendance stamp as the first line of its workState context. The stamp is one of three shapes:

- `operator-present: true, grant: none, scope: attended`. The operator is at the terminal. Boot routine, session-wrap, relay poll, next-session resume, and any mutation of estate state proceed normally.
- `operator-present: false, grant: <dispatchId>, scope: dispatch-only`. Unattended session running under a signed PA_WORKER_GRANT minted by pa-worker-launcher. Operate within the dispatch brief only. Do not run the boot routine, do not inherit the main-thread work queue, do not resume anything from `next-session.md`.
- `operator-present: false, grant: none|rejected, scope: refusal, refusal: <reason>`. Unattended session with no grant (or a grant that failed HMAC verification). Do not run the boot routine, the session-wrap, the relay poll, or any next-session resume. Do not mutate work items, projects, library state, relay state, or the git tree. Emit a single banner line stating the session is unattended with no valid grant and halt.

The stamp is produced by `pa-operator-attendance session-start` (a Claude Code SessionStart hook, shipped in the scaffolded `.claude/settings.json`). The paired `pa-operator-attendance pre-tool-use` hook enforces the same reading at tool-call time: raw `claude <word>` launches from Bash are denied regardless of attendance, and estate-mutating Bash + Edit + Write + NotebookEdit calls are denied on an unattended session that carries no grant. Inspection-flag forms (`claude --help`, `claude --version`, `claude --mcp-list`) always pass.

A dispatch that genuinely needs its own child session goes through `pa-worker-launcher`, which mints the signed grant, strips the attended envelope, and spawns `claude --print` with `--setting-sources user` from a neutral cwd. Workers never invoke `claude` directly from Bash: the coordinator-hook deny plus the attendance pre-tool-use leg catch both.

Rollout is observe-mode by default for one point release: every would-deny lands as a JSONL line under `~/.local/share/pa/attendance-decisions.jsonl` (with Bash commands redacted so argument-borne tokens, Authorization headers and URL credentials never land in the log) but the tool call proceeds, so operators can inspect the gate's behaviour on real traffic before `PA_OPERATOR_ATTENDANCE_MODE=enforce` becomes the shipped default. Attendance detection is positive-proof: `CLAUDE_CODE_SESSION_ATTENDED === '1' && parentHasControllingTty && !PA_WORKER_GRANT`. The hook's own stdout is always a pipe (the client reads the JSON envelope), so the discriminator is the hook's PARENT process: in an attended session the parent is the claude client attached to the operator's terminal; in a `claude -p` worker or any Bash-tool child the parent is a bash shell with no controlling tty. We probe `ps -o tty= -p <ppid>` on POSIX and treat a non-empty, non-`?`/non-`??` result as a real tty; on win32 we fall back to the env var alone and record `parentTty: 'unavailable'` on the stamp. Any other combination is unattended, which is the fail-safe direction. A client bump that strips the env var defaults the gate to "unattended" and surfaces loudly rather than silently failing open.

Reference: `packages/cli/src/operator-attendance/cli.mjs`, `packages/cli/src/worker-launcher/cli.mjs`, `packages/core/src/attendance/grant.mjs`. Rationale and incidents: `w-2026-10-02-dave-016` (review), `w-2026-10-02-dave-018` (build), `w-2026-10-02-dave-044` (0.1.58 detection + pattern + redaction fix, from Dex's 0.1.57 review).

---

## Failure Modes

Catalogue of dispatches that will fail. Most of these fail silently or late — the point of listing them here is to catch them at brief-write time.

| Failure | Symptom | Root cause |
|---|---|---|
| Dispatch a `Plan`/`Explore`/`claude-code-guide`/`statusline-setup` orchestrator that needs to spawn workers | Orchestrator tries to call Agent, tool not in inventory, dispatch fails at runtime | Restricted tool inventories exclude Agent |
| SendMessage to a peer by subagent_type instead of agentId | Response: `No agent named 'general-purpose' is reachable` | No group addressing by role — must use agentId or teammate name |
| Two parallel Playwright agents both driving `browser_navigate` | Tabs interleave, screenshots capture the wrong page | Playwright MCP browser session is parent-scoped, not per-subagent |
| Parallel workers writing to the same `/tmp/foo.txt` or the same file inside a shared parent dir | Last-write-wins race | `/tmp` and any absolute path outside the worktree is shared |
| Worker reads its own `output_file` (JSONL transcript) to check progress | Context overflow | Transcript is full tool-trace JSONL, forbidden by the tool warning |
| Parent expects progress signals from a 30-minute background child | Silence until completion | No streaming; only final-message notification |
| Depth-N orchestrator returns "success" but the leaf actually errored | Root gets a rolled-up "done" without the failure detail | Each nesting level is a summarisation checkpoint; leaves are opaque unless the orchestrator brief requires child-outcome enumeration |
| Worktree-isolated worker changes `$HOME/.claude/settings.local.json` | Change persists to the parent's config | Only the git tree is isolated |
| Dispatch depends on unloaded MCP tool without ToolSearch first | `InputValidationError` | Deferred tools need schema loaded before invocation, same rule as the parent |
| OAuth-gated MCP tool called before auth is completed | Auth flow error | Auth state is inherited from parent — if parent hasn't authenticated, child can't either |
| Worker spawns `claude <word>` (or `claude -p ...`, `claude help foo`, `claude mcp list`) from Bash | Child boots a full PA session in the worker's cwd, loads the project CLAUDE.md and SessionStart hook, runs the boot routine under bypassPermissions | Any `claude <word>` form is a session launch, not a subcommand. The coordinator-hook deny (`^\s*claude(\s+[^-\s]|\s*$)`) and the attendance pre-tool-use leg both block it. Flag-only forms (`claude --help`, `claude --version`) pass. Child sessions a dispatch genuinely needs go through `pa-worker-launcher`; see the Operator-presence stamp section above. |

---

## Related

- **Project Lead role definition** — operator-side; documents the `subagent_type: "general-purpose"` dispatch constraint that this doc justifies. <!-- OPERATOR: this reference is operator-side; operators substitute their equivalent role path -->
- **Bucket 3 of the CLAUDE.md always-on rules** — `packages/scaffold/templates/CLAUDE.md.template`. Rule 6 ("Any multi-role project: dispatch a Project Lead") depends on the nested-dispatch capability confirmed here.
- **Briefing contract standard** — `packages/core/docs/standards/briefing-contract.md`. The Do/Don't rules above should be folded into the briefing template so orchestrator briefs pick up the constraints automatically.
- **Task group standard** — a task-group standard in operator-side knowledge governs the group shape a Project Lead assembles; the isolation and concurrency findings here govern what a group can safely do in parallel. <!-- OPERATOR: this reference is operator-side; operators substitute their equivalent standard path -->
- **Agent terminology** — operator-side; "orchestrator", "worker", "task group", and "run folder" as used in this doc. <!-- OPERATOR: this reference is operator-side -->

---

## Verification metadata

- **Investigated:** 2026-07-05
- **Harness:** Claude Code, Opus 4.7, an operator-instance project scope
- **Method:** live diagnostic dispatches from the main thread; each test topic recorded WHAT was tried, WHAT happened, and the implication. Full details preserved in the parent's transcript for the session.
- **Confidence:** high for all findings up to depth-4, N=10 concurrent, and the six subagent-type inventories. Untested and open: depth-5+, N>10, remote isolation mode, long-lived (>1 hour) child health signals.
- **Re-verify when:** Claude Code harness version changes materially, subagent-type registry gains new types, or MCP inventory grows (esp. new session-stateful tools).
