Data model¶
The Task payload is fully relational: every routing field plus the message body lives in its own typed column. The only JSON TEXT blob is agents.agent_card_json, which stores an AgentCard-shaped document.
The model is predominantly relational: every queried field on tasks is a typed column, and the only opaque payload is the agent_card_json document on agents.
Schema management is handled by Alembic; the runtime engine is SQLAlchemy 2.x with the synchronous pysqlite driver. The schema is created by a single initial migration; operators apply it once via cafleet db init before starting the server.
SQL Schema¶
All tables key on INTEGER columns. The three minted-id tables (fleets, agents, tasks) use INTEGER PRIMARY KEY AUTOINCREMENT. AUTOINCREMENT creates a per-table sqlite_sequence row that tracks the high-water mark, so ids are never reused — even after the highest row is deleted. The first AUTOINCREMENT value is 1, so real ids are always >= 1; this leaves 0 free as a sentinel (see tasks.to_agent_id). The other tables — agent_placements, monitor_config, and monitor_runtime — are the integer PKs without AUTOINCREMENT: each reuses a parent id (agents.agent_id for the first two, fleets.fleet_id for monitor_runtime) as a 1:1 PK rather than minting a fresh sequence.
fleets¶
| Column | Type | Constraints | Notes |
|---|---|---|---|
fleet_id |
INTEGER |
PRIMARY KEY AUTOINCREMENT |
DB-assigned integer (first value 1, monotonically increasing). |
label |
TEXT |
nullable | Optional free-form text for human bookkeeping (e.g. "PR-42 review"). |
created_at |
TEXT |
NOT NULL |
ISO-8601 timestamp. |
deleted_at |
TEXT |
nullable | NULL = active; non-NULL ISO-8601 timestamp = soft-deleted. Written on fleet delete; never cleared. |
director_agent_id |
INTEGER |
nullable (DB), app-enforced NOT NULL after bootstrap; REFERENCES agents(agent_id) ON DELETE RESTRICT |
Points at the fleet's root Director (the agent auto-registered by cafleet fleet create). DB-nullable so the bootstrap transaction can insert the fleet row before the Director's agents row exists; post-bootstrap every non-deleted fleet has a non-NULL value. |
Fleet deletion is a soft-delete keyed on deleted_at. See cli-options.md fleet delete for the observable delete behavior.
Root Director bootstrap¶
cafleet fleet create writes the fleet row, the root Director (and its placement), the director_agent_id back-reference, and the built-in Administrator in one all-or-nothing transaction. See cli-options.md fleet create for the observable bootstrap behavior.
agents¶
| Column | Type | Constraints | Notes |
|---|---|---|---|
agent_id |
INTEGER |
PRIMARY KEY AUTOINCREMENT |
DB-assigned integer. |
fleet_id |
INTEGER |
NOT NULL, REFERENCES fleets(fleet_id) ON DELETE RESTRICT |
The owning fleet. SQLite enforces the FK once PRAGMA foreign_keys=ON is set. |
name |
TEXT |
NOT NULL |
|
description |
TEXT |
NOT NULL |
|
status |
TEXT |
NOT NULL |
'active' or 'deregistered'. |
registered_at |
TEXT |
NOT NULL |
ISO-8601 timestamp. |
deregistered_at |
TEXT |
nullable | ISO-8601 timestamp; populated on soft-delete. |
agent_card_json |
TEXT |
NOT NULL |
AgentCard-shaped blob (CAFleet-defined internal schema). |
Indexes:
| Name | Columns | Purpose |
|---|---|---|
idx_agents_fleet_status |
(fleet_id, status) |
List active agents in a fleet; covers the WHERE fleet_id = ? AND status = 'active' predicate. |
Deregistration is a soft-delete: status='deregistered' plus deregistered_at is set in a single statement. There is no row delete and no background cleanup loop. Active query paths filter status='active' so dead rows are invisible to normal traffic.
Built-in Administrator agent¶
Each fleet owns exactly one built-in Administrator agent, distinguished by a flag inside agent_card_json (cafleet.kind == "builtin-administrator") rather than a separate table or column:
{
"name": "Administrator",
"description": "Built-in administrator agent for fleet <fleet_id>",
"skills": [],
"cafleet": {
"kind": "builtin-administrator"
}
}
The cafleet.* namespace inside agent_card_json is reserved for broker-owned flags; callers cannot set cafleet.kind through any public path. The Administrator row is written as the final operation of the fleet-create bootstrap transaction, and its registered_at matches fleets.created_at. No migration seeds it — the single initial migration is schema-only.
Invariant: Every fleet has exactly one active Administrator agent. The WebUI surfaces a derived kind field ("builtin-administrator" | "user") so it can locate the Administrator without matching on the name.
Protection: The Administrator is a write-only identity — it is used as the WebUI's implicit sender but never receives messages or a tmux pane. It cannot be deregistered (Administrator cannot be deregistered) and cannot be made a Director (Administrator cannot be a director). It is excluded from broadcast recipients, though it may itself be the broadcast sender.
tasks¶
| Column | Type | Constraints | Notes |
|---|---|---|---|
task_id |
INTEGER |
PRIMARY KEY AUTOINCREMENT |
DB-assigned integer. Identifies a single delivery (unicast row, broadcast delivery row, or broadcast summary row). |
context_id |
INTEGER |
NOT NULL, REFERENCES agents(agent_id) ON DELETE RESTRICT |
The recipient agent for unicast/broadcast deliveries; the broadcaster for broadcast_summary; the preserved original context_id for ACK/cancel. Always a registered agent_id. |
from_agent_id |
INTEGER |
NOT NULL |
Sender agent. Not a foreign key — historical tasks may outlive their sender. |
to_agent_id |
INTEGER |
NOT NULL |
Recipient agent. 0 for broadcast_summary rows (the "no single recipient" sentinel; real ids are >= 1 so 0 never collides). |
type |
TEXT |
NOT NULL |
'unicast' or 'broadcast_summary'. |
created_at |
TEXT |
NOT NULL |
ISO-8601 timestamp; set at insert time, never updated. |
status_state |
TEXT |
NOT NULL |
input_required, completed, or canceled. |
status_timestamp |
TEXT |
NOT NULL |
ISO-8601 timestamp; updated on every state change. Used for ORDER BY DESC. |
origin_task_id |
INTEGER |
nullable | Broadcast grouping link. NULL on unicast deliveries. On broadcast delivery rows, holds the summary task's task_id, shared across every delivery row in the same broadcast. On the broadcast summary row itself, holds its own task_id (self-reference) so the delivery rows and the summary row all share a single grouping value. Not a foreign key — it is a nullable self-link. |
text |
TEXT |
NOT NULL |
Message body. For broadcast_summary rows, the broker writes the human-readable summary "Broadcast sent to N recipients" at insert time. |
Indexes:
| Name | Columns | Purpose |
|---|---|---|
idx_tasks_context_status_ts |
(context_id, status_timestamp DESC) |
Inbox listing: WHERE context_id = ? ORDER BY status_timestamp DESC. |
idx_tasks_from_agent_status_ts |
(from_agent_id, status_timestamp DESC) |
WebUI sender outbox: WHERE from_agent_id = ? ORDER BY status_timestamp DESC. |
agent_placements¶
| Column | Type | Constraints | Notes |
|---|---|---|---|
agent_id |
INTEGER |
PRIMARY KEY (no AUTOINCREMENT), REFERENCES agents(agent_id) ON DELETE CASCADE |
The member agent. This is the parent agents.agent_id value reused as a 1:1 PK — it is not a freshly minted sequence, so AUTOINCREMENT is deliberately excluded. CASCADE ensures hard-delete of an agent (if any future path adds one) also removes the placement. |
director_agent_id |
INTEGER |
nullable, REFERENCES agents(agent_id) ON DELETE RESTRICT |
The fleet's root Director — the single Director that owns every member. RESTRICT prevents hard-deleting a Director with live placements. For every member placement this always equals fleets.director_agent_id (the fleet root); it is NULL only for the root Director's own placement (it has no parent), set at bootstrap time. Nested teams are forbidden — member registration rejects any placement whose director_agent_id is not the fleet root. |
tmux_session |
TEXT |
NOT NULL |
e.g. 'main', from tmux display-message '#{session_name}'. |
tmux_window_id |
TEXT |
NOT NULL |
e.g. '@3', from #{window_id}. |
tmux_pane_id |
TEXT |
nullable | e.g. '%7'. NULL = pending (row inserted at register time, pane not yet spawned). Set after the pane is spawned. |
coding_agent |
TEXT |
NOT NULL, DEFAULT 'claude' |
Free-form coding-agent identifier. Current known values are "claude" (the default for normal registrations) and "codex" / "opencode" when chosen at create time. The default 'claude' applies when a placement is inserted without an explicit value. |
created_at |
TEXT |
NOT NULL |
ISO-8601 timestamp, set server-side to match agents.registered_at. |
Indexes:
| Name | Columns | Purpose |
|---|---|---|
idx_placements_director |
(director_agent_id) |
List the fleet's members; every member placement's director_agent_id is the fleet root. |
Placement rows are hard-deleted (not soft-deleted) when the agent is deregistered through any path. They have no historical value and must not outlive the agent they describe.
If a user kills a pane manually without going through cafleet member delete, the placement row stays until the next member delete resolves it; the "pane already gone" case is handled gracefully.
monitor_config¶
Per-agent monitoring schedule, one row per pane-bound agent. The cafleet monitor process reads this table each tick to decide which agents are due (see Monitoring).
| Column | Type | Constraints | Notes |
|---|---|---|---|
agent_id |
INTEGER |
PRIMARY KEY (no AUTOINCREMENT), REFERENCES agents(agent_id) ON DELETE CASCADE |
The enrolled agent. This is the parent agents.agent_id value reused as a 1:1 PK (mirrors agent_placements.agent_id) — not a freshly minted sequence, so AUTOINCREMENT is deliberately excluded. |
interval_seconds |
INTEGER |
NOT NULL, DEFAULT 60 |
Ping cadence for this agent. |
last_ping_at |
TEXT |
nullable | ISO-8601 of the last ping the monitor dispatched to this agent. NULL = never pinged ⇒ due immediately. Persisted (not in-memory) so a monitor restart resumes cadence and monitor status can display it. |
enabled |
INTEGER |
NOT NULL, DEFAULT 1 |
Boolean stored as SQLite 0/1. 0 = the monitor skips this agent while preserving its interval for re-enable. Every broker read casts it to a Python bool at the read boundary, so the integer representation never leaks past the broker (the CLI and the WebUI/JSON contract both see enabled: bool). |
A row is inserted automatically at registration for every agent that has a tmux pane — the root Director and every member. The write-only Administrator and card-only agent register calls (no placement) are not enrolled: only an agent with a pane can be pinged. There is no fleet_id column — fleet scoping is reached through the monitor_config.agent_id → agents.agent_id → agents.fleet_id join (the same pattern agent_placements uses), and director-vs-member is derived at scan time (agent_id == fleets.director_agent_id), never denormalized.
The row is hard-deleted (not soft-deleted) when the agent is deregistered, alongside its agent_placements row — runtime config with no historical value, on the same lifecycle as the placement.
monitor_runtime¶
Per-fleet process and heartbeat state, one row per fleet. A dedicated single-row-per-fleet table (rather than columns on fleets) keeps high-write heartbeat telemetry off the slow-changing fleet identity row, models "no monitor" cleanly as "no row", and lets single-instance claims and teardown operate independently of the fleets lifecycle.
| Column | Type | Constraints | Notes |
|---|---|---|---|
fleet_id |
INTEGER |
PRIMARY KEY (no AUTOINCREMENT), REFERENCES fleets(fleet_id) ON DELETE RESTRICT |
One row per fleet; reuses fleets.fleet_id as a 1:1 PK. |
pid |
INTEGER |
nullable | OS PID of the running monitor loop. NULL after a clean stop. The single-instance claim records it; the ownership-checked heartbeat/clear match on it; _is_live corroborates liveness with os.kill(pid, 0). |
started_at |
TEXT |
nullable | ISO-8601 when the current worker claimed the runtime. |
last_tick_at |
TEXT |
nullable | ISO-8601 heartbeat, rewritten every tick. The authority for status liveness — a process that died silently stops updating it, so it reads as stale. |
tick_seconds |
INTEGER |
NOT NULL, DEFAULT 5 |
Scan-tick cadence the running monitor uses, so status can report it. |
The monitor_runtime row is deleted explicitly inside the fleet delete transaction (along with the fleet's monitor_config rows). Because agents are soft-deregistered and fleets soft-deleted, neither the CASCADE off agents nor the RESTRICT off fleets fires on the normal delete paths — the rows are cleaned explicitly, mirroring how agent_placements is explicitly deleted today.
Foreign key enforcement¶
SQLite ignores foreign key declarations unless PRAGMA foreign_keys=ON is issued on every connection. A SQLAlchemy engine connect event listener runs the PRAGMA on every new connection.
The foreign keys on agents.fleet_id, tasks.context_id, fleets.director_agent_id, agent_placements.director_agent_id, and monitor_runtime.fleet_id all use ON DELETE RESTRICT (agents are only soft-deregistered today, so RESTRICT is the safest default). agent_placements.agent_id → agents.agent_id and monitor_config.agent_id → agents.agent_id both use ON DELETE CASCADE so a hard-deleted agent (if any future path adds one) cannot leave a dangling placement or config row. Fleet deletion uses a soft-delete (deleted_at) — it never physically removes rows, so the RESTRICTs are not triggered by the normal delete path; the monitor_config and monitor_runtime rows are instead removed explicitly inside the fleet delete transaction.
Task Visibility Rules¶
Read access is fleet-scoped; per-agent identity is enforced only on state transitions:
| Operation | Enforcement |
|---|---|
message poll |
Returns the input_required deliveries whose context_id equals the passed --agent-id. The CLI gates only that --agent-id belongs to --fleet-id, so any in-fleet caller can poll any in-fleet agent's inbox by id — there is no per-caller snoop guard. |
message show |
Returns the task iff at least one of its endpoints (from_agent_id / to_agent_id) belongs to --fleet-id. No per-caller check; cross-fleet lookups return "not found". |
message ack |
Only the recipient may ACK — the caller's agent_id must equal the task's context_id, otherwise Only the recipient can ACK a task. |
message cancel |
Only the sender may cancel — the caller's agent_id must equal the task's from_agent_id, otherwise Only the sender can cancel a task. |
The only cross-fleet boundary is fleet membership; within a fleet, reads are not partitioned per agent. Recipient/sender identity is enforced only when transitioning a task's state (ack / cancel).
Broadcast Grouping¶
A broadcast produces N+1 rows — one delivery task per active recipient plus one broadcast_summary task — and the admin WebUI timeline presents all of them as a single entry. The tasks.origin_task_id column is the grouping link:
| Row kind | origin_task_id value |
|---|---|
| Unicast delivery | NULL |
| Broadcast delivery row (one per recipient) | The summary task's task_id (shared across all N delivery rows in the same broadcast) |
| Broadcast summary row | Its own task_id (self-reference) |
Because ids are DB-assigned, the summary row is inserted first with origin_task_id temporarily NULL; the broker reads back its DB-assigned task_id, updates that row's origin_task_id to itself (self-reference), then inserts each delivery row with origin_task_id = summary_task_id. Unicast deliveries are inserted with origin_task_id left NULL.
The grouping predicate on the wire is origin_task_id IS NOT NULL, which cleanly partitions the timeline into "standalone unicast entry" vs "part of a broadcast group".
ACK timestamp inference¶
The timeline reads each broadcast's per-recipient ACK time from the status_timestamp of the matching completed delivery row, which is valid because a delivery task makes exactly one state transition over its lifetime (input_required → completed on ACK). If a future change adds a second transition that rewrites status_timestamp, a dedicated acknowledged_at column must be added.
Deregistered Agents¶
Deregistration is a soft-delete only. There is no background cleanup loop and no physical removal of agent or task rows. Deregistered agents continue to exist as rows with status='deregistered' indefinitely; their inbox tasks remain readable by the WebUI (the only consumer that surfaces deregistered agents). Active query paths filter status='active' so dead rows are invisible to normal traffic.