Skip to content

ADR-0004: Fleet Control Plane & Hot-Swappable Extension

  • Status: Accepted — 2026-06-01
  • Deciders: Josh (operator)
  • Related: ADR-0001, ADR-0002; protoAgent ADR 0001 (Extensibility & Plugin Architecture), ADR 0002 (Reusable Subagent Workflows), ADR 0005 (Tool Pollution & Progressive Disclosure); ORBIS config/delegates.yaml + /api/delegates
  • Tags: architecture, agent, plugins, registry, control-plane, observability, hot-reload

We want to extend and modify the fleet — add agents, wire in capabilities, change rituals — without rebuilding the image or restarting the container, and to see what the system is doing without tailing logs. Today most of that forces a restart, and the one extension surface meant for it (workspace TS plugins) is structurally broken. This ADR decides how protoWorkstacean becomes pluggable and observable: a fleet control plane built from hot-reloadable, file-backed registries with a write API + a dedicated management surface, where the units of extension are external agents (A2A), MCP servers, and declarative rituals — never hot-loaded in-process code — and where live state is durable and exposed through one read surface. It honors the load-bearing decisions already made (bus-is-the-contract, the read-only debug dashboard, no world-model, federate-don't-replace-the-bus).


1. Context & Problem

protoWorkstacean is the switchboard: trigger → router → dispatcher → executor over a typed bus. Extending it today is uneven. Some surfaces hot-reload; the ones that matter most for "add a new agent" do not, and the dynamic-code surface is broken.

What hot-reloads vs. what forces a restart (audited 2026-06-01):

SurfaceAdd / modify cost today
Ceremonies (workspace/ceremonies/*.yaml)✅ hot-reload (5 s watch) + CRUD API
Channels (workspace/channels.yaml)✅ hot-reload (5 s watch)
Crons (SchedulerPlugin)⚠️ runtime add/remove via bus command; no file-watch
A2A agent skills (remote card change)✅ auto re-register (ExecutorRegistry.unregister() already exists)
DeepAgents (workspace/agents/*.yaml)❌ boot-only → restart → ✅ hot-reload (P1 shipped, #714/#715)
A2A agent entries (workspace/agents.yaml + agents.d/)❌ boot-only → restart → ✅ control-plane register/remove, live (P3 day-4, #723/#725)
Workspace plugins (workspace/plugins/*.ts)❌ restart, Node module-cache pins the old coderetired (P4, ADR-0005) — replaced by out-of-process A2A / MCP

Two structural facts drive this ADR:

  1. Adding an agent requires a restart. AgentRuntimePlugin calls loadAgentDefinitions() exactly once at install(); SkillBrokerPlugin reads agents.yaml once at boot. There is no file-watch on either. Yet ExecutorRegistry already supports runtime register()/unregister() (it uses them for A2A card refresh) — the capability is half-built.

  2. The dynamic-code plugin surface is a dead end. workspace/plugins/*.ts loads via await import() (Node module cache → no safe reload) and, because the workspace is bind-mounted outside the app's module tree, cannot resolve app lib/ or node_modules (this is why feature-notifier had to move to lib/plugins/ — see the 2026-06-01 fix). In-process hot-reload of arbitrary TypeScript is unsafe (stale closures) and partially impossible here. We will stop pretending this surface works.

Observability is read-rich but ephemeral. /api/agents/runtime, /api/bus/topology, /api/bus/history, the /api/bus/subscribe WebSocket, cost/confidence summaries, and /api/ceremonies already expose live state without logs — but it is in-memory (the bus-history ring is a 30-min buffer; fleet-health is a 24 h in-memory rollup; restart wipes it). "See what's going on without logs" is ~80 % there for reads, but nothing survives a restart and there is no single pane that unifies the registries + their live health.

Prior art. ORBIS already solved the consumer side of this for its delegate layer: config/delegates.yaml is a hot-reloaded, file-backed registry with full CRUD (/api/delegates + a Settings UI), a health-probe loop, and SSE state — adding a delegate is a UI form, zero restart. protoAgent's ADR 0001 is the reference for the mature shape: four extension tiers (tools / skills / plugins / MCP), lazy manifest discovery, capability grants (operator-approved, off by default), and trust tiers (builtin / trusted / community → untrusted runs out-of-process via MCP). We are not inventing; we are generalizing ORBIS's proven pattern into the fleet host and borrowing protoAgent's trust model.


2. Constraints we must honor (already decided)

  1. The bus is the contract. Plugins communicate only through typed topics; no plugin holds a reference to another. The sole exemption is the startup registrar pattern writing to shared registry objects (ExecutorRegistry, ChannelRegistry). A control plane must mutate state through command.* topics + a registrar, not cross-plugin method calls.
  2. The debug dashboard is read-only. flow-dashboard.md deliberately makes the dashboard a read-only, in-memory, Tailnet-only observability pane — "not a control surface." We do not bolt write onto it. The control plane is a separate, auth-gated surface (Decision D1).
  3. No in-process hot-swap of arbitrary code. Node's module cache and the workspace-module-resolution wall make it unsafe and partly impossible. Code extension is out-of-process (A2A / MCP) or compiled-in (image deploy).
  4. Federate, don't replace the bus. Multi-node is a BusBridgePlugin to NATS/Redis, not a new bus. The control plane is local-first and bridges later.
  5. No world-model / planner. The dispatcher stays stateless routing. A control plane is configuration + observability, not agency — if tempted to add "goal" or "world state," a cron or webhook does the job.
  6. Greenfield-strict. When a surface is replaced, it is removed, not shimmed. The broken workspace/plugins/*.ts loader is retired, not kept "for compatibility."

3. Decision

Build a fleet control plane: a uniform layer of file-backed, hot-reloadable registries, mutated through a write API + command.* bus topics + a registrar, surfaced by a dedicated management UI, with durable + unified live state. The units of extension are external agents (A2A), MCP servers, and declarative rituals (ceremonies / workflows) — not hot-loaded in-process code.

The three forks, resolved:

  • D1 — Separate control surface. The control plane is its own auth-gated management surface (its own routes + UI section), distinct from the read-only debug dashboard. The dashboard stays read-only; this ADR does not amend that decision — it adds a sibling.
  • D2 — Extension = external + declarative, never runtime code. "Add a plugin via the UI" means: register an A2A agent, register an MCP server (new MCP-client tier), add a ceremony/declarative workflow, or define a DeepAgent (YAML). Arbitrary TypeScript plugins stay compiled-in (image deploy). The dynamic workspace/plugins/*.ts loader is retired.
  • D3 — Durable + unified state. Registries are file-backed (already the durable source of truth). The observability snapshots that matter (fleet-health rollups, skill outcomes, cron/ceremony run history) are persisted so they survive restart, and every registry + its live health is exposed through one control-plane read surface.

4. Architecture

4.1 The Registry abstraction (the spine)

Every extensible unit conforms to one shape — a file-backed, hot-reloaded registry:

workspace/<unit>.yaml ──(file watch, ~5s)──▶ Registry.reload()
        ▲                                          │
        │ atomic write                             ▼
  ControlPlaneRegistrar ◀── command.<unit>.{add,update,remove} ◀── write API (CRUD)

                                          ExecutorRegistry / scheduler / channels
                                          register() · unregister() · dispose()
  • Source of truth = the YAML file (durable, git-trackable, diffable). The DB never owns config.
  • Hot-reload = file watch, the pattern ChannelRegistry and CeremonyPlugin already prove (5 s poll, debounced, diff-and-apply). Generalize it; don't reinvent per-unit.
  • Mutation = write API → command.<unit>.* topic → registrar writes the YAML → the watcher applies it. The write path and the read path are separate. The registrar is the documented bus-contract exemption (it owns a shared registry object).

This is deliberately the ORBIS /api/delegates pattern, generalized to agents, A2A endpoints, ceremonies, crons, channels, and MCP servers.

4.2 Agent hot-reload (closes the biggest gap)

AgentRuntimePlugin and SkillBrokerPlugin gain a file-watch + diff-apply:

  • Added / changed agent → validate the YAML; construct the executor (DeepAgentExecutor for in-process, A2A registration for remote); ExecutorRegistry.register(skill, executor) for each declared skill.
  • Removed agentExecutorRegistry.unregister(skill, agentName) (already exists) + dispose the executor (new lifecycle hook) so in-flight work drains and resources free.
  • Concurrency: apply diffs on the bus turn, never mid-dispatch; a replaced executor finishes in-flight requests before disposal.

ExecutorRegistry is already most of the way here (it unregisters on A2A card refresh). The new work is executor lifecycle (construct/dispose) and the watch + diff.

4.3 Control plane = write API + command topics + registrar

  • REST (auth-gated, admin key): POST/PUT/DELETE /api/agents, /api/a2a-endpoints, /api/crons, /api/mcp-servers; /api/ceremonies already exists and is the template. Each validates, then publishes command.<unit>.{add,update,remove}.
  • command.<unit>.* bus topics carry the mutation. A single ControlPlaneRegistrar subscribes, validates against the registry schema, and performs the atomic YAML write. The file watcher (§4.1) then reloads. This keeps every mutation on the bus (auditable in bus-history) and the registrar as the only writer.
  • Test-before-save: POST /api/<unit>/test probes reachability + fetches the agent card (see §4.5) before persisting — ORBIS's pattern, so the UI never saves a dead endpoint.

4.4 Management UI (the separate control surface)

A dedicated, auth-gated Console surface (its own section, distinct from the read-only debug dashboard):

  • CRUD for each registry (agents, A2A endpoints, MCP servers, ceremonies, crons, channels) — add / edit / delete / test, mirroring ORBIS's DelegatesSettings.
  • A live fleet view: every registry + per-entry health (reachable / latency / last error) + recent run history — the "what's going on without logs" pane, fed by §4.6.
  • Capability discovery rendered read-only (§4.5).

It consumes the write API (§4.3) for mutations and the unified read surface (§4.6) for state. It may live in the same app as the dashboard but is a clearly-separated, write-capable surface — never the read-only debug pane.

4.5 Capability discovery (closes ORBIS's gap, powers the UI)

On add/test of an A2A agent or MCP server, fetch its descriptor (/.well-known/agent-card.json for A2A; the tool list for MCP), cache its skills/tools, and surface them read-only in the UI. SkillBrokerPlugin already fetches agent cards for skill registration — extend it to expose the cached card to the control plane. The operator picks from real capabilities, not a free-text guess (ORBIS's current weak spot), and the registry records what each agent actually does.

4.6 Durable + unified state

  • Registries are file-backed already — durable by construction.
  • Persist the snapshots that matter — fleet-health rollups, skill outcomes, and cron/ceremony run history — to the existing SQLite store, so they survive restart. The live bus ring stays in-memory (ephemeral by nature) but is backed by the durable event log for history.
  • One read surface — a control-plane state endpoint (and the Console pane) that renders every registry + live health + recent runs in one place. This is the explicit answer to "see what's going on without logging."

4.7 Extension tiers (what "pluggable" means here — borrowing protoAgent ADR 0001)

TierUnitAdd viaIsolationHot-swappable
Agent — remoteA2A servicecontrol plane (register endpoint)out-of-process
Agent — in-processDeepAgent YAMLcontrol plane / workspace/agents/in-process (trusted, first-party)✅ (after §4.2)
Capability/toolMCP servercontrol plane (register server + grants)out-of-process
Ritualceremony / declarative workflowcontrol plane / workspace/ceremonies/declarative
Code pluginTS in lib/plugins/image build + deployin-process❌ (by design)

Capability grants + trust tiers (protoAgent ADR 0001): external agents and MCP servers declare the capabilities they need (network egress, secrets, etc.); the operator approves them at add-time in the Console; a trust tier (builtin > trusted > community) gates what auto-enables. This is the safety boundary that lets us add untrusted external extensions without a rebuild.


5. Phased plan (impact → effort)

  1. P1 — Agent registry hot-reload (shipped — #714 watch+detect, #715 apply). WorkspaceWatcher (reusable poll-based file/dir diff) watches workspace/agents/; AgentRuntimePlugin reconciles the live registry via ExecutorRegistry.register/unregister + IExecutor.dispose?(). Add/edit/remove a DeepAgent YAML applies in ~5s, no restart; a parse error keeps the running agent; in-flight dispatch is never aborted.
  2. P2 — Control-plane write API + command.* + registrar (shipped — #717). POST/PUT/DELETE /api/agents + /api/agents/test validate, publish command.agent.*, and the sole ControlPlaneRegistrar performs the atomic YAML write (synchronous, so the API verifies + responds). Create/edit/remove an agent via API → persisted → live via P1, no restart.
  3. P3 — Management Console + capability discovery (shipped — #718 Console, #719 edit-in-place, #720 agent-card probe, #723/#725 A2A register/remove). Separate auth-gated Console (@protolabsai/ui): DeepAgent CRUD + test-before-save + agent-card capability probe; A2A endpoints register to workspace/agents.d/ (per-file, so the documented agents.yaml is never clobbered) and unregister live via SkillBroker. The debug dashboard stays read-only.
  4. P4 — MCP client tier + capability grants + trust tiers; retire the workspace/plugins/*.ts loader (shipped — see ADR-0005: registry, live client, Console, loader retired). MCP servers are a control-plane registry (workspace/mcp-servers.d/); McpClientPlugin connects each enabled server and registers its tools as executors live. Trust tiers gate auto-enable; grants are audit-only (v1). The dynamic TS-plugin loader is removed — extension is out-of-process (A2A/MCP) or compiled-in.
  5. P5 (cross-cutting) — Durable + unified state (shipped — #726 durable fleet-health, #727 unified read + doc). FleetStateRepository persists outcomes to knowledge.db; AgentFleetHealthPlugin rehydrates its 24h window on startup (survives restart). GET /api/control-plane/state is the one read view (fleet + durable-backed health), consumed by the Console; flow-dashboard.md documents the read/write split.

6. Week-1 sprint

Scope: land P1 + P2 + P3 and the durable/unified-state slice of P5, leaving P4 (MCP + grants + trust tiers) for a following sprint. One PR per day, each green + tested, each shippable on its own.

DayGoalDeliverable / PRAcceptance
1Registry abstraction + agent file-watch (detect only)Extract the shared file-watch/diff helper (from the Channel/Ceremony pattern); arm it on workspace/agents/ + agents.yaml; log computed add/change/remove diffs — no apply yet.Editing an agent YAML logs the correct diff within ~5 s; tests on the diff logic.
2P1 — apply the diff (hot-reload agents)Executor lifecycle (dispose()); AgentRuntimePlugin/SkillBroker apply diffs via ExecutorRegistry.register/unregister; drain in-flight before dispose.Add/edit/remove an agent YAML → live in /api/agents/runtime with no restart; in-flight dispatch isn't dropped; tests. Ship P1.
3P2 — control-plane write APIPOST/PUT/DELETE /api/agents + command.agent.* + ControlPlaneRegistrar (atomic YAML write) + /api/agents/test. Reuse the /api/ceremonies CRUD shape.✅ Shipped (#717). Create an agent via API → persisted → live (via P1) → no restart; auth-gated; mutations on the bus; tests + live prod smoke (10/10).
4P3 — Console surface + capability discoverySeparate auth-gated management pane: agent CRUD + test-before-save + agent-card capability probe surfaced read-only; A2A endpoint register/remove.✅ Shipped (#718/#719/#720/#723/#725). DeepAgent CRUD + agent-card probe; A2A register→agents.d/→live, remove via SkillBroker; debug dashboard stays read-only.
5P5 slice — durable + unified state + docsPersist fleet-health to SQLite; one control-plane read view; update flow-dashboard.md for the read/write split; sprint retro.✅ Shipped (#726/#727). Fleet-health rehydrates from knowledge.db on restart; GET /api/control-plane/state unifies fleet + health; doc + ADR updated.

Outcome: P1+P2+P3+P5 shipped (#714/#715/#717/#718/#719/#720/#723/#725/#726/#727), each green + tested, each shipped on its own. P4 (MCP client, capability grants, trust tiers, retiring the workspace-plugin loader) is the scoped follow-up sprint (#712) — it introduces a new external-tool tier and a trust/grant model and deserves its own ADR slice.


7. Consequences

Positive

  • Adding/modifying agents, A2A endpoints, crons, channels, and (later) MCP tools is UI-driven and hot-swappable — no rebuild, no restart.
  • One uniform registry abstraction replaces five ad-hoc loaders → less code, consistent behavior, easier to extend to the next unit.
  • Live state is durable + unified — "what's the fleet doing" is answerable from one pane, across restarts, without logs.
  • Untrusted extension is safe by construction (out-of-process A2A/MCP + capability grants), not by trusting hand-loaded code.
  • The bus stays the contract (mutations are command.* topics, auditable in bus-history); the debug dashboard stays read-only.

Costs / risks

  • Executor lifecycle + diff-apply must be careful about in-flight work and concurrent mutation (drain-before-dispose; apply on the bus turn).
  • A new write/control surface is new attack surface — strictly auth-gated, Tailnet-scoped like the rest.
  • Persisting observability adds a small storage + write-path cost (bounded; rollups + run-history, not the full firehose).
  • Retiring workspace/plugins/*.ts removes a (broken) surface — first-party plugins move to lib/plugins/ (already the working path; feature-notifier set the precedent).

8. Alternatives considered

  • A visual workflow / node-programming editor (the "N8N canvas" read of this ADR). Rejected — explicitly and firmly. This is a switchboard, not a workflow engine (see CLAUDE.md "What this app is NOT"). N8N-style data-flow programming — drawing node.output → node.input to author logic — is the rejected path, of a piece with the GOAP planner that was already ripped. Our model is choreography: triggers publish topics, subscribers react, and the intelligence lives in the agent nodes (they reason), not in a human-drawn graph. The Console's topology view (§4.4) may become interactive — wiring routing (who reacts to what) and watching live dispatches flow through the graph — but it never becomes a surface for authoring logic. If genuine multi-step orchestration is ever needed, the on-brand path is declarative workflows over subagents (protoAgent ADR 0002, YAML, no arbitrary code), optionally rendered on the canvas — not a node editor.

    Reframed by ADR-0008 (2026-06-07). The interactive, on-brand future this paragraph blessed — interactive topology, wiring authoring, rendered declarative workflows, live dispatch flow — is now the decided direction (a visual orchestration surface for the fleet, run from one command hub). The hard line stands: the canvas is for observability + wiring + rendering, never node.output → node.input data-flow logic authoring. "UX yes, logic-authoring no."

  • Hot-reload arbitrary in-process TS plugins. Rejected — Node module cache pins old code, stale closures are unsafe, and workspace plugins can't resolve app modules anyway. Out-of-process (A2A/MCP) is the correct isolation boundary.

  • Make the existing dashboard the control surface. Rejected — it's a deliberately read-only, in-memory debug pane; a write path there muddies the charter and the security posture. A separate, auth-gated Console keeps the split clean.

  • A config DB as source of truth. Rejected — YAML-in-workspace stays the source of truth (git-trackable, diffable, matches the fleet idiom); the DB only persists observability snapshots, never config.

  • Do nothing / restart-to-extend. Rejected — the whole point is to extend without rebuilds; and the workspace-plugin surface is already broken, so the status quo is worse than it looks.


  • ADR-0001 — Org→execution pipeline · ADR-0002 — protoMaker boundary
  • flow-dashboard.md — the read-only debug pane this ADR keeps read-only.
  • protoAgent ADR 0001 (Extensibility & Plugin Architecture — tiers, manifests, capability grants, trust tiers), ADR 0002 (Reusable Subagent Workflows), ADR 0005 (Tool Pollution & Progressive Disclosure — the tool-bloat lens already applied to Ava).
  • ORBIS config/delegates.yaml + /api/delegates + DelegatesSettings.tsx — the proven hot-reloadable-registry-with-CRUD-UI pattern this generalizes.
  • Code anchors: src/executor/executor-registry.ts (register/unregister), src/agent-runtime/agent-runtime-plugin.ts (boot-only load → add watch), src/plugins/CeremonyPlugin.ts + lib/channels/channel-registry.ts (the file-watch pattern to generalize), src/api/operations.ts (/api/ceremonies CRUD template).

protoWorkstacean — a switchboard, not an agent.