Agent Architecture¶
How Taskbase dispatches tasks to a local AI agent, how the agent executes them in ephemeral Claude Code sessions, and how the resulting trace is streamed back. This document covers the three-tier split (brain · executor · worker), the dispatch protocol, the runtime on the Mac Mini, and the authentication model.
See ADR 0004 (now superseded by ADR 0005) for the decision record. The original 2026-04-02 design used a persistent local MCP server with pull-based dispatch (get_next_task etc.). It was revised on 2026-04-18 to the push-based design described here: per-task subprocesses spawned by a thin executor, where each subprocess resumes the assigned agent instance's persistent Claude session. Note: this document still describes the ADR-0004 design; ADR-0005 supersedes it with a simpler model (per-org agent personas + priority dispatch, no instances or sessions). This component doc needs a follow-up update.
Three-Tier Architecture¶
The system is split across three concerns, each with a distinct responsibility:
| Tier | Where it runs | Responsibility |
|---|---|---|
| Brain | Taskbase API (Kubernetes) | Prioritization, scheduling, dependencies, retries, full state and trace; owns the agent fleet (types + instances) |
| Executor | Mac Mini M4 (FastAPI) | Thin, stateless dispatch — receives a task, spawns a worker resuming the agent's session, relays output |
| Worker | Per-task claude -p subprocess |
Does the actual task; OS process is fresh each time, but the conversation persists across tasks via --resume <session_id> |
The executor holds no business logic and no state. All decisions about what to run, when, and in what order live in the brain. All execution happens in the worker. The executor exists only to bridge the two.
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ TASKBASE — Brain │ │ MAC MINI — Executor (thin) │
│ │ POST │ │
│ Trigger → Engine → Queue ───┼────────►│ FastAPI → Spawner → Relay │
│ ▲ │ /tasks │ │ │
│ │ │ │ │ spawn │
│ Store ◄───────────────┼─────────┤ ▼ │
│ │ POST │ ┌─────────────────────────┐ │
│ │ /events │ │ CLAUDE CODE — Worker │ │
│ │ │ │ (ephemeral, per task) │ │
│ │ │ │ │ │
│ │ │ │ CLI · Skills · MCP │ │
│ │ │ │ · Tools · Chrome │ │
│ │ │ └────────────┬────────────┘ │
└──────────────────────────────┘ └───────────────┼──────────────┘
│ inference
▼
┌────────────────┐
│ Anthropic │
│ Max 20x OAuth │
└────────────────┘
Agents: Types and Instances¶
Agents are first-class in Taskbase. The model splits what kind of agent it is (template) from which concrete agent it is (worker with persistent memory).
Agent type (template)¶
A reusable definition: system prompt, allowed skills/tools/MCP servers, default permission mode and timeout. Lives in the Taskbase repo at taskbase/agents/<slug>/:
taskbase/agents/
├── frontend-dev/
│ ├── agent.yaml # name, allowed_skills, allowed_tools, allowed_mcp, defaults
│ └── system-prompt.md # the system prompt
├── gitea-ops/
│ ├── agent.yaml
│ └── system-prompt.md
└── browser-nav/
├── agent.yaml
└── system-prompt.md
agent.yaml shape:
name: Frontend Developer
description: Builds and maintains React/Vue UIs
allowed_skills: [taskbase-api, kodexet-site]
allowed_tools: [Read, Edit, Bash, WebFetch]
allowed_mcp: [gitea, taskbase-mcp, openbao]
default_permission_mode: acceptEdits
default_timeout_seconds: 1800
A startup-time sync upserts each folder into the agent_types table so the API and UI don't read the filesystem at request time.
Agent instance (worker)¶
A concrete worker bound to a scope (an organization, a project, or "free"). Each instance has its own Claude session-id, so it accumulates memory of the work it has done in that scope. Stored in agent_instances:
| Column | Purpose |
|---|---|
id |
Primary key — used as tasks.assignee when assignee_type = 2 |
agent_type_id |
FK to the template |
name, slug |
Display name and URL slug |
scope_type, scope_id |
Scope binding: organization/project/free |
session_id |
Claude Code session id; rotates on reset, NULL means fresh |
status |
idle / working / paused / offline |
current_task_id |
What the instance is working on right now |
last_active_at |
For "is this instance alive?" checks |
total_tasks_completed, total_tokens_used |
Lifetime stats for the fleet view |
Examples:
| Type | Instance | Scope |
|---|---|---|
frontend-dev |
kodexet-frontend |
project: Kodexet/ServiceSite |
frontend-dev |
skatzi-ui-frontend |
project: Skatzi/Taskbase UI |
gitea-ops |
platform-gitea-ops |
organization: Skatzi |
browser-nav |
kodexet-browser |
organization: Kodexet |
Same skill preset (one type) → many memories (many instances), one per repo/scope.
Fleet view in the UI¶
The Taskbase UI surfaces agent instances as a fleet:
- Overview — cards for every instance: status badge, current task title (link), tasks-done counter, tokens-used counter, last-active timestamp
- Per-agent detail — full task history, token usage over time, current session-id age (how close to context limit), reset button
- Filters — by type, by scope, by status
This means non-engineer operators can see at a glance "is something working", "who is doing what", and "which agent finishes the most tasks".
Resetting an instance¶
When an instance's conversation gets stale or poisoned (context window filling up, drifted into bad patterns), the operator (or an auto-trigger at a token threshold) resets it:
The next dispatched task spawns without --resume. The first init event in the resulting stream-json carries the new session-id, which the executor relays back to the brain to populate session_id on the instance.
Task Lifecycle¶
A task moves through the following states, modelled using done (boolean) and group_id (kanban column). State transitions are driven by the brain (in response to executor callbacks), not by the agent itself.
┌─────────┐ brain dispatches ┌─────────────┐ worker exits 0 ┌──────────┐
│ Backlog │ ─────────────────► │ In Progress │ ───────────────► │ Done │
│ │ │ │ │ │
└─────────┘ └──────┬──────┘ └──────────┘
│ worker exits non-0 / timeout
▼
┌─────────────┐
│ Blocked │
│ (paused w/ │
│ trace) │
└─────────────┘
| Kanban Column | Meaning |
|---|---|
| Backlog | Task is queued; the engine has not yet dispatched it |
| In Progress | The executor has spawned a worker; the worker is streaming events |
| Done | Worker exited cleanly; brain marked done = true based on the final stream-json message |
| Blocked | Worker exited non-zero, hit the timeout, or reported a checkpoint |
| Review | Work delivered; waiting for human review |
Who drives the transition¶
| Transition | Driven by | Mechanism |
|---|---|---|
| Backlog → In Progress | Brain | Engine pops from Queue, POSTs to executor /tasks; on 202 Accepted, brain updates group_id |
| In Progress → Done | Brain | Final stream-json message has subtype=success; relay posts it back; brain marks done=true |
| In Progress → Blocked | Brain | Worker exits non-zero, hits timeout, or final message indicates checkpoint |
| Any → Backlog (re-queue) | Brain | Engine retry policy (configurable per task) |
The worker does not call the Taskbase API directly to manage its own task state. State changes flow through the executor → brain so the trace stays authoritative and ordered.
Dispatch Protocol (Brain ↔ Executor)¶
The brain pushes tasks to the executor over HTTP. The executor exposes three endpoints and nothing else.
POST /tasks¶
Brain → Executor. Hands off one task to be executed by a specific agent instance.
{
"task_id": 1234,
"agent_instance_id": 7,
"prompt": "...task description from Taskbase...",
"system_prompt": "...from agent_type.system_prompt...",
"session_id": "abc-123-…",
"tool_allowlist": ["Read", "Edit", "Bash", "WebFetch"],
"permission_mode": "acceptEdits",
"timeout_seconds": 1800,
"callback_url": "https://taskbase.skatzi.com/api/tasks/1234/events"
}
The brain resolves the agent type's settings server-side and inlines them into the payload, so the executor doesn't need to read agent config — it just spawns. session_id is null for a fresh or freshly-reset instance.
Response: 202 Accepted on successful spawn, 429 Too Many Requests if the executor is at capacity, 503 Service Unavailable if Anthropic OAuth is unhealthy.
The executor maps these fields directly onto claude -p flags:
| Field | Maps to |
|---|---|
prompt |
positional argument to claude -p |
session_id |
--resume <session_id> (omitted if null) |
system_prompt |
--append-system-prompt (skills also auto-load via CLAUDE.md) |
tool_allowlist |
--allowedTools |
permission_mode |
--permission-mode |
timeout_seconds |
wall-clock kill of the subprocess |
callback_url |
where the relay POSTs each event |
GET /tasks/{id}/stream (SSE)¶
Executor → Brain (or UI). Live stream of events for an in-flight task. Each SSE event is one line of stream-json from the underlying claude -p process: thoughts, tool_use, tool_result, tokens, cost, final_message.
GET /health¶
Executor → anyone. Returns OAuth status, current concurrent task count, and capacity.
POST /tasks/{id}/events (callback)¶
Relay → Brain. Each stream-json line is forwarded as-is to the brain's events endpoint. The brain stores the full trace per task. Live mode (one POST per event) or batched mode (one POST per N events or per second) is a deployment choice; default is batched at 500ms.
The brain inspects events for two side-effects on the agent instance:
initevent — captures the newsession_id(set onagent_instancesif currentlyNULL)final_message/ process exit — incrementstotal_tasks_completedandtotal_tokens_used, setscurrent_task_id = NULL, setsstatus = idle
Worker — Per-Task Subprocess Resuming an Agent's Session¶
Each task spawns a fresh claude -p subprocess, but that subprocess resumes the assigned agent instance's persistent conversation via --resume <session_id>. So the OS process is ephemeral; the conversation is not.
What is fresh per task vs. what persists¶
| Aspect | Per task (fresh) | Per instance (persistent) |
|---|---|---|
| OS subprocess | ✅ new each time | — |
| Tool state (open files, shell env) | ✅ clean each time | — |
| Anthropic conversation | — | ✅ resumed via session_id |
| Accumulated repo / domain knowledge | — | ✅ carried across tasks |
| MCP server connections | ✅ established at spawn | — |
| Browser tab | ✅ new per task | (Chrome profile is shared across all instances) |
Why this split¶
- Process isolation — a runaway tool call, stuck shell command, or OOM in one task cannot bleed into the next; the OS does the cleanup
- Persistent memory — the agent doesn't re-learn the repo every task; pricing context, recent decisions, code conventions all carry over
- Cheap retries — a failed task is just "spawn the same subprocess again with the same session"
- Cheap resets — when context drift becomes a problem, set
session_id = NULLand the next task starts a new conversation; the type/identity of the agent is unchanged
The cost is one cold-start per task (~1–2 s for the subprocess; the --resume itself is cheap because Anthropic keeps the prompt cache warm for recently-used sessions).
What the worker has access to¶
| Surface | Purpose |
|---|---|
Skills auto-loaded via ~/.claude/CLAUDE.md |
Domain knowledge — taskbase-api, kodexet-site, gitea-ops, browser-nav |
| MCP servers registered globally | gitea, token-tracker, taskbase-mcp, openbao |
Built-in tools (per --allowedTools) |
Read, Edit, Bash, WebFetch, WebSearch |
| Claude in Chrome | Browser automation against logged-in sites; uses persistent Chrome on the Mac Mini |
The worker is given a focused prompt and the skills/tools it needs for that task — tool_allowlist and skill_hints keep the surface minimal per task.
Why MCP is on the worker side, not the executor side¶
In the original ADR design, MCP was the dispatch protocol between Cowork and a local server (get_next_task, log_progress, etc.). In the new design, MCP servers are worker-side integrations with external systems (Gitea, OpenBao, Taskbase itself for read-only context, token tracking). Dispatch moved from MCP to plain HTTP because:
- The brain pushes work; it doesn't wait for a worker to ask
- Stream-json carries far richer telemetry than discrete
report_tokenscalls - A thin FastAPI layer is easier to operate than a long-lived MCP server process
Authentication Model¶
Worker ↔ Anthropic¶
The worker authenticates to Anthropic using the Max 20x plan via OAuth, not an API key with credits:
- OAuth credentials live in the standard Claude Code location on the Mac Mini (
~/.claude/) - Each
claude -psubprocess inherits the OAuth session - No per-token billing — the Max plan covers usage; the executor enforces concurrency to stay within rate limits
/healthreports OAuth status so the brain can stop dispatching when the session needs re-auth
Implication: the executor cannot run elsewhere without re-authenticating. The OAuth session is tied to the Mac Mini.
Brain ↔ Executor¶
A shared secret on POST /tasks and POST /tasks/{id}/events:
X-Executor-Token: <random>— issued at executor install time- Stored in OpenBao at
secret/tools/taskbase/executor-token - Injected into the brain as
EXECUTOR_TOKENvia External Secrets
The executor only accepts POST /tasks from the brain's outbound IP (or any IP if behind a private network). Public exposure of the executor is not required.
Worker ↔ External services¶
When a worker task needs credentials (Gitea PAT, OpenBao secret, kubeconfig), it goes through the appropriate MCP server, which holds the credential:
| Service | Credential location | How worker accesses |
|---|---|---|
| Gitea | OpenBao secret/tools/taskbase-agent/gitea-token |
gitea MCP server |
| OpenBao itself | AppRole on the Mac Mini | openbao MCP server |
| Kubernetes | kubeconfig at ~/.kube/config |
direct kubectl via Bash tool |
| Anthropic | OAuth session (above) | inherited from subprocess env |
Principle: credentials are never embedded in task prompts and never returned in stream-json. MCP servers wrap them.
Browser automation (Claude-in-Chrome)¶
A persistent Chrome instance runs on the Mac Mini in headed mode. Login state (cookies, OAuth sessions) lives in the Chrome profile and survives across tasks. For initial login or CAPTCHA challenges the operator connects via VNC, completes the flow once, and Chrome retains the session.
The worker's Chrome tool attaches to this Chrome via native messaging — each task gets a fresh tab, but shares the underlying profile (and therefore login state) with all other tasks.
Runtime (Mac Mini)¶
Process layout¶
launchd ──► taskbase-executor (FastAPI on :8765)
│
└─ on POST /tasks ──► spawn `claude -p ...` subprocess
│
└─ stream-json stdout ──► relay ──► brain
The FastAPI executor runs as a launchd service. It is the only persistent process. Workers come and go.
Service definitions¶
~/.config/taskbase-executor/
├── config.yaml # listen addr, auth tokens, brain URL, concurrency limits
└── taskbase-executor.plist # launchd service (symlinked to ~/Library/LaunchAgents/)
config.yaml schema:
listen: 127.0.0.1:8765
brain_base_url: https://taskbase.skatzi.com/api
executor_token: <shared secret>
max_concurrent_tasks: 3
default_timeout_seconds: 1800
claude_binary: /usr/local/bin/claude
log_dir: ~/Library/Logs/taskbase-executor
Why launchd¶
Same reasoning as the original ADR: native macOS, starts on boot, restarts on crash, logs to system journal. The change is what runs under launchd — the FastAPI executor instead of an MCP server.
Logs¶
| Source | Location |
|---|---|
| FastAPI executor (dispatch decisions, errors) | ~/Library/Logs/taskbase-executor/executor.log |
| Per-task stream-json | ~/Library/Logs/taskbase-executor/tasks/<task_id>.jsonl (kept for N days) |
| Worker stdout/stderr | captured into the per-task jsonl |
| Brain (Taskbase API pod) | Loki via Grafana |
Per-task jsonl is the local copy of what was streamed to the brain — useful for post-mortem when the network glitched.
Failure Modes and Observability¶
| Failure | Detection | Recovery |
|---|---|---|
| Worker subprocess crashes mid-task | Non-zero exit code captured by relay | Brain marks task Blocked; engine retry policy decides whether to re-dispatch |
Worker hits timeout_seconds |
Executor SIGKILLs; relay sends a synthetic timeout event | Brain marks Blocked with timeout reason |
| Anthropic OAuth expired | /health returns OAuth=expired |
Brain stops dispatching; operator re-auths via VNC; brain resumes |
| Executor unreachable | Brain's POST /tasks times out / returns connection refused |
Engine backs off and retries; tasks remain in Backlog |
| Brain unreachable from executor | Relay's POST /events fails |
Relay buffers events to per-task jsonl; flushes when brain is reachable |
| Network partition mid-stream | SSE consumer disconnects | Brain reads the persisted trace from Store; no events lost (relay buffer) |
| Two concurrent tasks need same Chrome login | Chrome profile is shared | Tasks run sequentially via Chrome tool's per-tab semantics; no extra coordination needed |
| Token rate limit hit (Max plan) | Anthropic returns 429 in stream | Worker reports the error; brain backs off and re-dispatches with delay |
| Agent instance context window full | Worker reports context_full (or token-usage event passes a configured threshold) |
Brain auto-resets session_id; next task starts a fresh conversation for that instance |
| Agent instance "poisoned" (drifted into bad patterns) | Operator notices via fleet UI / output quality | Operator clicks Reset → session_id = NULL; only that instance is affected, other instances of the same type are untouched |
--resume references a session Anthropic no longer has |
Worker reports session-not-found at startup | Executor falls back to fresh spawn, captures new session-id from init event, brain updates the instance |
Alerts (v1 — manual)¶
Same as before: no automated alerts in v1. The operator watches the Taskbase UI and the executor's /health. Adding a Grafana panel for executor /health + per-task duration is a future addition.