A system for running and supervising multiple Claude Code instances on a machine (laptop now, VPS later), with a conversational manager that is itself a Claude session, and — eventually — native Apple apps (desktop + mobile, Swift) for status, listening, and voice control.
Verified against the official Claude Code hook/SDK docs on 2026-06-28. Status: v1 (tmux-native scripts) is specced below and ready to build.
The end state is Apple apps where each Claude session looks like a recording in the Voice Memos app: tap to read or hear its latest output (TTS), press-and-hold to speak a command (STT). A bottom "manager" button is the orchestrator you talk to — "spawn a session and do X", "what's the status of Y?"
We get there in stages. Each stage is independently useful.
| Stage | What | Driver |
|---|---|---|
| v1 | Two shell scripts: watch all sessions; find/create/route to sessions | tmux + hooks |
| v2 | The manager — a headless agentbase manager "<request>" command that wraps a Claude reasoning step over the v1 tools to classify and route requests |
tmux + hooks + Claude |
| v3 | A small local HTTP/JSON + SSE daemon (agentbase-server.js) over the fleet; remote access later |
Node over Tailscale |
| v4 | Native Apple apps (Swift), desktop + mobile, with TTS/STT | Swift ↔ API |
| (alt) | Swap tmux-driven workers for Agent SDK sessions where headless/programmatic control is wanted | Agent SDK |
This document specs v1 and v2 concretely and sketches the rest. The SDK approach (originally the leading design) is kept as a documented alternative in §9 — it's the right driver if/when we want truly headless instances, but tmux gets us a working fleet far faster and keeps everything visible and inspectable.
Everything lives in one tmux session (e.g. agentbase):
tmux session "agentbase"
├── window 0 "manager" ← a Claude session (v2); the orchestrator you talk to
├── window 1 "task-auth" ← a worker Claude instance
├── window 2 "task-docs" ← a worker Claude instance
└── window N … ← spawned on demand
│
│ each worker has global hooks configured in settings.json
▼
agentbase-watch hook (Stop / Notification / *ToolUse events)
│
▼
~/.agentbase/ state store (plain JSON)
├── sessions/<id>.json one record per session
└── notifications.jsonl append-only "needs attention" feed
▲ │
│ reads/writes │ live feed
┌────┴─────┐ ┌─────▼──────┐
│ fleet │ (script 2) │ agentbase-watch│ (script 1)
│ dispatch │ list/spawn/send/read │ feed │ watch all sessions
└──────────┘ └────────────┘
- Workers: each a
clauderunning in its own tmux window. Driven bytmux send-keys(input) and read viatmux capture-pane(output). - State store:
~/.agentbase/— JSON files, trivially inspectable, no DB. - Two scripts (§3):
agentbase-watch(listening) andagentbase(talking). - Manager (v2): a Claude session in window 0 with
agentbaseon its PATH.
Why tmux at the center: it makes worker output directly observable (the
watch-pane.sh / capture-pane work becomes load-bearing), needs no SDK or
server to start, and lets a human jump into any pane to take over.
Watch all of the Claude sessions.
Knows the state of every session and surfaces the ones needing attention.
Mechanism: hook-driven (push). Every worker has a single generic hook
(installed once in settings.json) that pipes the hook's JSON to
agentbase-watch hook. That updates the session's record and, for attention-worthy
events, appends to the notification feed and refreshes a summary.
Subcommands:
| Command | Does |
|---|---|
agentbase-watch hook |
Hook entrypoint. Reads hook JSON on stdin, identifies the session (via $TMUX_PANE + session_id), updates its record. On SessionStart links the Claude session_id ↔ pane ↔ fleet id. On SessionStart/resume → idle; UserPromptSubmit → working (a turn began); Stop → idle; Notification(idle_prompt) → idle (no downgrade if already idle); Notification(permission_prompt) → needs-approval (+ records the pending request); Pre/PostToolUse → tool-running/working. For attention events, appends to notifications.jsonl and runs a short Haiku summary into last_summary (summarising from that point using the transcript). |
agentbase-watch feed |
Tail notifications.jsonl and print a live "needs attention / needs approval / ready for review" stream. This is what a human watches now and the app subscribes to later. |
agentbase-watch status |
One-shot table of all sessions: id, label, state, last summary. |
Find existing sessions, create new ones, and input data into them.
The mechanical toolbox for acting on the fleet. In v1 you (or a test harness) call it directly; in v2 the manager Claude calls these as its tools.
| Command | Does |
|---|---|
agentbase list |
Print the registry: id, label, task, state, last summary. (What the manager reads to find the right session.) |
agentbase spawn "<label>" "<task>" |
Create a new window in the agentbase tmux session, launch claude, register a record, and send-keys the initial task into it. Prints the new id. |
agentbase send <id|label> "<text>" |
tmux send-keys the text + Enter into that session's pane. (Input data into an existing session.) |
agentbase read <id|label> [lines] |
tmux capture-pane of that session for detail. |
agentbase resume <id|label> |
Reopen a session whose pane is gone: new window running claude --resume <session_id> in the stored cwd. Same session id, so the conversation continues and hooks keep mapping. |
agentbase prune [state] |
Drop records in a state (default closed), killing any window. |
agentbase and agentbase-watch share the ~/.agentbase/ store, so dispatch and
monitoring stay consistent.
~/.agentbase/sessions/<id>.json:
State machine (hook events are the transitions):
SessionStart / resume → idle (just opened, your move)
UserPromptSubmit → working ⇄ tool-running (a turn begins; Pre/PostToolUse)
working/tool-running → needs-approval (Notification permission_prompt)
any → idle (Stop, or Notification idle_prompt)
any → closed (SessionEnd)
UserPromptSubmit (not SessionStart) is the "a turn started" signal — that's
why a resumed session, which opens but submits nothing, correctly shows idle
rather than getting stuck on working.
Three buckets a human cares about: active (working/tool-running),
needs you (needs-approval — blocked on a yes/no), and idle (idle —
finished a turn / sitting at the prompt, your move). Stop and the idle_prompt
notification are the same condition, so both map to idle; the idle nudge
never downgrades an already-idle session. closed = the process exited.
Identity linking: spawn records the pane + our fleet id; the worker's
SessionStart hook later reports Claude's own session_id, which
agentbase-watch hook joins to the record via $TMUX_PANE. Pane ids can move, so
routing resolves tmux_target → live pane at send time.
Every hook receives session_id, transcript_path, cwd, hook_event_name
on stdin. One generic hook script (agentbase-watch hook) handles them all.
| Meaning | Hook | Detail |
|---|---|---|
| Finished — ready for review | Stop |
Main agent stopped. |
| Subagent finished | SubagentStop |
agent_id, agent_type. |
| About to run a tool | PreToolUse |
Has tool_name, tool_input; can block / route approval. |
| Tool finished | PostToolUse |
Has tool_name. No output on stdin — read it from transcript_path. |
| Waiting for input | Notification idle_prompt |
The "needs your attention" signal. |
| Waiting on a decision | Notification permission_prompt |
The "needs approval" signal. |
| Started / resumed | SessionStart |
Link session_id ↔ pane. |
| Ended | SessionEnd |
Close the record. |
Two verified facts that shape the build:
PostToolUsecarries no tool output — read results from the transcript JSONL (lossless, structured) rather than scraping the pane.- Hooks feed data back via return JSON, not arbitrary injection:
PreToolUse→permissionDecision/updatedInput/additionalContext;PostToolUse→updatedToolOutput/additionalContext;Stop→additionalContextordecision:"block"(force continue).
The manager is not a Claude session living in a tmux pane. It's a stateless
command, agentbase manager "<request>", that wraps a single Claude reasoning
step over the fleet's tools. This form is what survives into the Swift app: the
app's eventual POST /manager {text} endpoint just runs this same command — only
the tmux-pane transport (talked to via send-keys) would have been throwaway.
Classification and session-matching are the model's own reasoning — not hardcoded keyword matching. On each request the command:
- Gathers the live fleet state (
agentbase_sessions_json→[{id,label,task,state, last_summary}]). - Asks Claude (
claude -p, modelAGENTBASE_MANAGER_MODEL, defaulthaiku) to return a single JSON decision object. - Parses and executes it via the existing
agentbaseprimitives.
Decision schema:
{
"action": "spawn" | "send" | "answer" | "clarify",
"target": "<id>", // send: which session
"label": "<label>", // spawn: new session label
"task": "<text>", // spawn: initial prompt
"message": "<text>", // send: text to deliver
"reply": "<text>", // answer/clarify: shown to the user
"reason": "<one line>" // always
}- send →
agentbase send <target> <message>(continue/route to an existing session) - spawn →
agentbase spawn <label> <task>(new, separate task) - answer → reply from
state+last_summary(status questions) - clarify → ask a brief question when the target is ambiguous
--dry-run prints the decision without executing. Why stateless beats a
persistent pane: the source of truth is the live session state, not the manager's
chat history — "the session we already have for X" is re-resolved from the fleet
on every call, which is more robust than relying on conversational memory and
maps cleanly onto a request/response API.
- Pre-allow safe tools (
Read, scopedBash) so routine work never blocks. - Route the rest to approval:
Notification(permission_prompt)→needs-approval+ a notification; the human (later, the app) approves. - The blocking callback timeout (~60s) is too short for human approval; for
v3+ use the SDK
defer+resume()pattern. In v1/v2 a permission prompt simply parks the worker inneeds-approvaluntil someone acts in its pane. - Never blanket-skip permissions on a fleet that can run shell.
The control surface can spawn agents that execute code. Treat as privileged.
- Local-first (v1/v2 run on your machine). For remote (v3): TLS + bearer auth, bound to localhost and reachable only over Tailscale / WireGuard — never public.
- Hard-deny destructive commands even under broad allow modes.
- Audit every hook event and approval. Cap concurrent instances (token + CPU).
- v3 — local service + remote (implemented:
agentbase-server.js): a thin Node daemon over the~/.agentbase/store exposingGET /sessions,GET /sessions/:id,POST /spawn,POST /sessions/{id}/message,POST /manager, and a live SSE/eventsstream. OptionalAGENTBASE_TOKENbearer auth; localhost-bound, reached over Tailscale for remote. (POST /approvals/{id}still to come with v5 permission routing.) - v4 — Apple apps (Swift): memo-style list, tap-to-read/listen (TTS), hold-to-speak (STT), the bottom manager button. Talks to the v3 API.
- alt — Agent SDK driver: replace tmux-driven workers with SDK sessions
(
resume()/fork(), native hook callbacks,canUseToolfor approval routing) where headless/programmatic control beats visible tmux panes. The transcript JSONL is the source of truth in that world. See Appendix A for the SDK-vs-CLI/tmux trade-offs.
| Need | tmux (v1) | Agent SDK (alt) |
|---|---|---|
| Time to first working fleet | fast — no SDK/server | slower — code + types |
| Human can take over a session | yes (jump into pane) | no (headless) |
| Drive a worker | send-keys |
resume() |
| Read output | capture-pane / transcript |
typed messages / transcript |
| Permission routing | park in needs-approval |
canUseTool callback |
| Headless / many on a VPS | clunky | designed for it |
Start with tmux for visibility and speed; adopt the SDK for the headless, at-scale phase.
The repo's watch-pane.sh polls a tmux pane with capture-pane, pipes the
snapshot to claude -p --model haiku, and prints a rolling summary. It's the
prototype of the agentbase read + summariser idea. Its prompt is hardened to treat
pane contents as data (anti prompt-injection), focus on the newest output, and
ignore TUI chrome. Keep it as a dev tool.
{ "id": "f3a9", // our stable short id "label": "task-auth", // human name "task": "refactor the auth module",// the initial task "tmux_target": "agentbase:1", // session:window (resolved to pane at use) "pane_id": "%42", // current tmux pane id (may change) "claude_session_id": "…", // filled in by the SessionStart hook "state": "working", // see state machine below "last_event": "PostToolUse", "last_summary": "Edited auth.ts; running tests.", "pending_approval": { "request_id": "…", "tool": "Bash", "input": { } } | null, "created_at": "…", "updated_at": "…" }