Skip to content

Latest commit

 

History

History
298 lines (233 loc) · 14.6 KB

File metadata and controls

298 lines (233 loc) · 14.6 KB

AgentBase — Design

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.


1. Vision & staged roadmap

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.


2. v1 architecture (tmux-native)

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 claude running in its own tmux window. Driven by tmux send-keys (input) and read via tmux capture-pane (output).
  • State store: ~/.agentbase/ — JSON files, trivially inspectable, no DB.
  • Two scripts (§3): agentbase-watch (listening) and agentbase (talking).
  • Manager (v2): a Claude session in window 0 with agentbase on 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.


3. The two scripts (v1)

Script 1 — agentbase-watch (the listening half)

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; UserPromptSubmitworking (a turn began); Stopidle; Notification(idle_prompt)idle (no downgrade if already idle); Notification(permission_prompt)needs-approval (+ records the pending request); Pre/PostToolUsetool-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.

Script 2 — agentbase (the talking half)

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.


4. Session registry & state model

~/.agentbase/sessions/<id>.json:

{
  "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": ""
}

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.


5. Signal → hook mapping

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:

  1. PostToolUse carries no tool output — read results from the transcript JSONL (lossless, structured) rather than scraping the pane.
  2. Hooks feed data back via return JSON, not arbitrary injection: PreToolUsepermissionDecision / updatedInput / additionalContext; PostToolUseupdatedToolOutput / additionalContext; StopadditionalContext or decision:"block" (force continue).

6. The manager — a headless command (v2, implemented)

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:

  1. Gathers the live fleet state (agentbase_sessions_json[{id,label,task,state, last_summary}]).
  2. Asks Claude (claude -p, model AGENTBASE_MANAGER_MODEL, default haiku) to return a single JSON decision object.
  3. Parses and executes it via the existing agentbase primitives.

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
}
  • sendagentbase send <target> <message> (continue/route to an existing session)
  • spawnagentbase 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.


7. Permissions (unattended workers)

  • Pre-allow safe tools (Read, scoped Bash) 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 in needs-approval until someone acts in its pane.
  • Never blanket-skip permissions on a fleet that can run shell.

8. Security

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).

9. Future evolution

  • v3 — local service + remote (implemented: agentbase-server.js): a thin Node daemon over the ~/.agentbase/ store exposing GET /sessions, GET /sessions/:id, POST /spawn, POST /sessions/{id}/message, POST /manager, and a live SSE /events stream. Optional AGENTBASE_TOKEN bearer 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, canUseTool for 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.

Appendix A — Why tmux for v1 (and when to switch to the SDK)

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.

Appendix B — watch-pane.sh

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.