contenox
Browse docs/

Blueprint: Fleet consolidation — closing the loop before widening it

Owner: runtime

Status: PROPOSED (2026-07-20). Slices C1–C8 are specified to be executed; technical decisions inside them are marked EXECUTED and are mine to make under delegation. The four questions in “Reserved” are product decisions and are the maintainer’s.

Absorbs the reporting and inbox repairs (C1, C2) from an earlier supervision-bus draft (since retired; its remaining scope folded into the slices here). Continues ../beam/fleet-manager.md.

Purpose

The agentinstance kernel was designed against requirements and use cases, and it shows — attach-bound control with the reasoning for rejecting a time lease, a documented port map from its reference implementation, an enumerated list of where ACP forced divergence. Everything downstream of it accreted slice by slice without that step, and it shows there too.

The result is a subsystem with more open loops than closed ones. Each slice shipped was individually verified; none completed a journey. Dispatch creates work that cannot be watched. The board renders state and affords no operation that reaches the session. Missions are stored and rendered nowhere. Approval requests reach beam and are discarded unread. The fleet has no CLI in a product whose identity is that the shell is the operating surface.

That is not a backlog of missing features. It is a correctness problem, and there is direct evidence: the InstanceStatus.SessionIDs defect shipped, survived a second slice that consumed it, and surfaced only when adopt became the first code to use it end to end. A primitive cannot be validated until a loop closes over it. Building more primitives first compounds that.

This blueprint closes one loop — dispatch an agent, see it ask for something, answer it, watch it finish — and repairs what is actively broken along the way. Method: state the requirement, then fix to it, which is the kernel’s method and the reason the kernel is the good part.

Verified defects

Established by reading the code (2026-07-20), file:line cited. Not a wish list — every item is a shipped path that misbehaves or a declared capability that cannot run.

#DefectEvidence
D1Headless approval hangs forever. The fallback blocks on a channel nothing can write to; its comment claims an approval API that does not exist.runtime/contenoxcli/serve_cmd.go:279-284, comment :277-278, repeated runtime/acpsvc/router.go:13-16
D2hitlservice.Respond is dead code — zero callers repo-wide — and would drop answers if wired: select with default: on an unbuffered channel.runtime/hitlservice/hitlservice.go:170, :177-182
D3Pending approvals are an in-process map; a restart loses every in-flight approval.runtime/hitlservice/hitlservice.go:43-44
D4Beam parses approval_requested and discards it: taskEvents.ts reduces it into state.pendingApproval; its only consumer never reads it.packages/harness/src/lib/taskEvents.ts:113-121, packages/harness/src/pages/admin/prompt/ExecPromptPage.tsx:27
D5No pending-approval surface exists on any channel — no REST route, no CLI verb, no beam page.grep -i approv over runtime/internal/ returns nothing outside tests
D6Enabled had two independently drifted implementations, not one gap. CORRECTED 2026-07-20: an earlier draft of this row claimed the chat path “never checks” — that was wrong, drawn from a truncated grep. Both spawn paths did check; they had diverged in message text, neither offered the remedy, and neither had test coverage. Consolidated in C5 to one shared judgment.runtime/fleetservice/fleetservice.go, and acpsvc’s external resolve (present at HEAD before this work)
D7Beam’s chain editor declares task handlers that no longer exist in Go (hook, prompt_to_string, prompt_to_int).packages/harness/src/lib/types.ts:1042-1061 vs runtime/taskengine/tasktype.go:15-24
D8adopt works but is reachable only by hand-crafting _meta — no button, no verb.runtime/acpsvc/adopt.go; no consumer in packages/harness or runtime/contenoxcli
D9The fleet has no CLI.only serve_cmd.go and fleet_telemetry.go mention fleet in runtime/contenoxcli/
D10The board cannot tell a dispatched instance from the chat you have open — fleet reports downstream ACP session ids, beam routes on upstream contenox ids, and the KV mapping is one-directional.runtime/acpsvc/session.go:426-427
D11No e2e coverage for agents, fleet, missions, or sessions.apitests/ covers backends, health, hitl_policies, mcp_servers, model_registry, taskchains, tools

Invariants

  • The kernel stays policy-free. agentinstance resolves and spawns what it is told; judgments (Enabled, envelopes, approval routing) live in the service layer above it. A fix that adds policy to the kernel is mis-placed.
  • No second mechanism. Approvals extend hitlservice; fleet verbs extend fleetservice; events extend TaskEvent. A parallel store, bus, or registry is reject-in-review.
  • An ask is never silently lost. It is answered, expired by an explicit policy, or visible as pending. Silence is the one outcome that is a defect.
  • The tracker stays passive and content-free; nothing here changes it.
  • Every new seam takes context.Context and returns error.

Slices

C1 — Durable asks (repairs D1, D2, D3)

Requirement. An agent working with no human attached must be able to request permission, have that request survive a restart, be answerable afterwards from a surface that is not a live session, and expire by an explicit policy rather than hang.

EXECUTED decisions. The store is a table, not KV records — asks are listed and filtered by state, which is precisely the criterion the fleet-manager blueprint used to choose a table over KV. It is owned by hitlservice, which already owns policy evaluation, TimeoutS, and OnTimeout (runtime/hitlservice/policy.go:86-91); a separate askservice would split one lifecycle across two packages. The answer channel becomes buffered (capacity 1) with the answer also persisted, so a Respond arriving while the requester is not parked is recorded rather than dropped — the default: arm at hitlservice.go:177-182 is the bug, not the mechanism.

Shape. RequestApproval writes a durable pending row (id, tool, args summary, diff, policy name, matched rule, requested-at, expires-at, state), publishes approval_requested as it does today, then blocks on the wake-up. Respond(id, approved) transitions the row and wakes any parked requester; answering an already-resolved or expired ask is an explicit, non-panicking outcome. A sweeper expires rows past their deadline applying the rule’s OnTimeout (default deny, per runtime/localtools/hitl.go:151-159).

Acceptance. An approval requested with no bound ACP session survives a contenox serve restart and is answerable afterwards; an unanswered ask expires by its rule rather than hanging; Respond has callers and tests; the serve_cmd.go:279-284 fallback provably terminates.

C2 — The inbox (repairs D4, D5)

Requirement. The operator must find and answer pending asks from any surface without attaching to the session that raised them, and must be able to name the policy rule that escalated each one.

Shape. GET /api/approvals (pending, newest first) and POST /api/approvals/{id} (answer) over C1’s store; contenox approvals list|answer; a beam page consuming the same routes. Each row shows agent, tool, args summary, diff when present, and the matched rule — the acp-client-engine.md:127-130 requirement that an operator can always name which policy gated an action.

Acceptance. A dispatched agent’s permission request reaches a human who never attached to its session and is answerable from both CLI and beam; a fleet with nothing pending renders empty.

This is the slice that closes the loop. With C1 and C2, dispatch → ask → answer → finish works end to end for the first time.

C3 — contenox fleet (repairs D9)

Requirement. The fleet must be operable from the shell, because the shell is this product’s operating surface. Output must compose: stdout is data, diagnostics are stderr, --json is the machine contract, exit codes are meaningful.

EXECUTED decision. Unlike contenox state / session, which open the SQLite DB directly, the fleet lives in contenox serve’s memory and is reachable only over HTTP. This slice therefore introduces the first serve-API client in contenoxcli (address + token resolution), which later verbs reuse.

Scope. list, show, stop, cancel, dispatch over the existing routes. attach is deliberately not here — see C4.

C4 — Attach, on both surfaces (repairs D8)

Requirement. A running unit must be observable without hand-crafting protocol: a button on the board, and a tmux attach-shaped verb in the shell.

EXECUTED decision. Both go through the existing ACP adopt verb rather than a new REST journal-tail route. Attach is ACP-native: beam already holds an app-wide ACP connection, and libacp.ClientSideConnection plus the /acp WebSocket shim already exist for the CLI side. A REST tail would be a second mechanism for something already built.

Shape. beam: a board action per session row → adopt → navigate to the returned upstream session. CLI: contenox fleet attach <instance> [session] → journal replay as scrollback, then live tail. Beam’s adopt returns the upstream session id, which also resolves D10’s click-through as a side effect.

C5 — Enforce Enabled where agents spawn (repairs D6)

Requirement. A disabled agent must not spawn, on any path.

EXECUTED decision. Not in Manager.Start — that would put policy in the kernel, against the invariant above. Instead a single shared resolve-declared-agent-for-spawn helper in the service layer, used by both fleetservice.Dispatch and acpsvc.bringUpExternal, so the judgment has one implementation and cannot drift between the two spawn paths.

C6 — Chain-handler drift (repairs D7)

Delete the three dead handlers from beam’s types and any UI that offers them; align with taskengine/tasktype.go’s closed set. Small, mechanical, and it stops the chain editor from writing chains the engine rejects.

C7 — e2e for agents and fleet (repairs D11)

Extend apitests/ to cover declaring an agent, dispatching it, listing the fleet, cancelling, stopping, and answering an approval through C2’s routes. Nothing outside Go unit tests currently proves any of this works against a running server.

C8 — Board truthfulness (repairs D10)

With C4 landed, the upstream/downstream mapping exists. Use it: link a session row to its chat, and make the Stop confirm state whether the instance backs an open chat instead of admitting it cannot tell.

C9 — Chains as agent templates (resolves the chain kind)

DECIDED (maintainer, 2026-07-20): implement it. The kind was deliberately deferred until the ACP proxy path was validated; adopt plus the instance kernel now validate it. The intended usage model, in the maintainer’s words: you declare multiple chains as agent templates and launch agent-units for the fleet from that chain.

Requirement. An operator authors chains as files today. Those chains must be declarable as agents, so a fleet unit can be launched from one — many units from one template, and many templates side by side.

Why it matters beyond closing an ambiguity. It makes fleet units cheap. An external_acp unit is a subprocess with its own model and its own credentials; a chain is the native primitive the operator already writes and reviews. Fleet-of-many stops requiring subprocess-of-many, which is what makes the fleet coherent on one workstation with one GPU slot — and it is the unit the later JS orchestration layer will loop over.

EXECUTED design — the kernel stays ACP-generic. agentinstance Layer A depends only on libacp + agenthost by construction (doc.go:20-33), and a chain-backed instance must NOT teach it about taskengine. Instead a chain-kind agent resolves to an agenthost.Agent that connects to an in-process contenox ACP agent bound to that chain — a real ACP connection over an in-memory pipe, no subprocess. This is exactly the “contenox driving contenox” composition acp-client-engine.md:190-202 already names as first-class, and it is what validating the proxy path unblocked. The kernel sees an ACP peer; journal, viewers, controller promotion, and permission routing all work unmodified.

Shape. A chain-kind ConfigJSON naming the chain (sibling of ExternalACPConfig), validated by agentregistryservice — which today hard- rejects the kind (:143-144). Manager.Start’s process-less stub (manager.go:284-285) becomes a real spawner, and OpenSession / Prompt stop erroring for native instances because a connection now exists.

EXECUTED — how a chain becomes selectable: convention, not declaration. (Maintainer, 2026-07-20.) Requiring an operator to hand-register every chain would be ceremony over a file that already exists. Instead:

  • The agentic chains shipped by contenox init are eligible by default.
  • Any chain named agent-* in the directories chain discovery already walks is eligible automatically. Naming the file is the declaration.
  • contenox mission fire --chain <path> overrides both, firing an ad-hoc chain that was never registered at all.

Seed rather than fork the resolution path. Discovery upserts each eligible chain as an Agent row of kind chain, and everything downstream — the Enabled gate, ResolveForSpawn, Dispatch, beam’s picker — keeps working unchanged with one resolution path and one source of truth for “what can I fire.” The record already has the field for this: Source is system-managed provenance, today "registry" or "manual" (runtimetypes/agents.go:106-112), so a discovered chain is simply a third source. Do NOT teach ResolveForSpawn to consult a second lookup; that is how two drifting implementations of one judgement get born, and this blueprint exists partly to repair exactly that.

SEQUENCING CONSTRAINT — do not ship discovery before the kind runs. The service currently rejects kind chain and the kernel’s branch for it is a process-less stub. Seeding chain agents into the registry while that is true would put entries in the picker that fail at spawn with “unsupported kind” — manufacturing precisely the half-built surface this blueprint was written to eliminate. The runnable half and the discovery half land together, or discovery stays gated until the kind works.

Acceptance. Two chain files declared as two agents; units dispatched from each; both appear on the board, are adoptable, cancellable, and stoppable, and route permissions through the same HITL path as external agents — with no subprocess spawned.

Open sub-question for review: whether each chain-backed instance gets its own acpsvc.Transport (simplest, mirrors one-per-connection, costs a little per unit) or shares one across units. I lean per-instance for isolation.

CORRECTED as landed (2026-07-20). The “EXECUTED design” above chose an in-process ACP peer over an in-memory pipe. What shipped is a self-spawn instead: a chain-kind agent resolves to an ordinary agenthost.ExternalACPAgent whose command is this binary with argv ["acp"] and CONTENOX_ACP_CHAIN_PATH naming the chain file. Both readings of “contenox driving contenox” are legitimate; the self-spawn was chosen because agenthost/e2e_loopback_test.go already demonstrates that exact path working end to end — registry row → resolve → spawn → initialize → session/new → session/prompt → chain execution → streamed reply → teardown — with nothing mocked, so the kind landed on a proven mechanism rather than a new one. It also keeps the invariant the in-process design was chosen to protect (the kernel learns nothing about taskengine; it builds one more ExternalACPAgent) and keeps the two kinds on one bringUp, so journal, viewers, controller promotion, adopt, cancel, stop and restart are identical by construction rather than by parallel implementation.

Consequences of the correction:

  • The acceptance clause “with no subprocess spawned” does not hold. A chain unit IS a subprocess — of this same binary, inheriting this process’s environment so it shares the one global database, with only the chain-path variable added. The property that clause was reaching for — a fleet unit costing a chain rather than a second vendor’s agent with its own model and credentials — does hold.
  • The open sub-question is moot. There is no in-process acpsvc.Transport to share or isolate; the spawned runtime constructs its own, one per unit.
  • Kind chain is no longer reserved: runtimetypes.ChainConfig ({path, chainId}) is its config shape, agentregistryservice.validate accepts and validates it, and Manager.StartResolved’s chain branch builds the self-spawn. Manager gained one option, WithSelfExecutable, defaulting to os.Executable(), so a test can point the spawn at a built fixture binary.
  • Discovery landed with it (the sequencing constraint holds): runtime/chainagents reuses the existing chain walker (taskchainservice.List over localfileservice) across the workspace .contenox/ and ~/.contenox-harness/, seeds rows with Source = "discovered", and is run once at contenox serve startup. A chain file that vanishes DISABLES its row rather than deleting it, so the refusal goes through ResolveForSpawn’s one message instead of a new one, and mission/telemetry references keep resolving.
  • Still open after this slice: the ACP chat path (contenox.agent picker, acpsvc.resolveExternalAgent) refuses a chain-kind agent with a kind-mismatch error. It refuses rather than half-spawning, so the sequencing constraint is satisfied — but a chain agent is visible there and unusable until that path learns the second kind.

Mission mode — the second way to interact with an agent

DECIDED (maintainer, 2026-07-20). This supersedes slice F4 of ../beam/fleet-manager.md, which modelled a mission as “a durable note bound to work.” A mission is not a note. It is the headless interaction model, and it is why the mission record exists at all. It is large enough to graduate into its own blueprint once these slices are ratified.

There are two ways to work with an agent, and they are duals:

Chat mode (built)Mission mode (this section)
How work startsyou prompt, turn by turnyou fire a mission and detach
Your presenceattached; you are the controller viewerabsent by default
What governs the agentyou, live, per permission requestthe envelope: a HITL policy attached to the mission
How the agent reaches yousession/request_permission to your live clientmission tools — it reports back, or asks for attention
Where you answerthe chat transcriptan attention inbox

The envelope is the load-bearing idea. In chat mode you are the envelope — you see every request and answer it. In mission mode a HITL policy is the envelope, so the unit can act unattended inside declared bounds and only crossing them costs your attention. That is “intent down, autonomy inside the envelope, exceptions up” made concrete rather than aspirational.

Mission tools are granted by the mission, not by the agent. An agent gets report and ask-for-attention only while on a mission — they are not part of its standing tool set. That keeps the grant per-unit-of-work and makes the mission the thing that equips, which is the same equip-don’t-govern shape harnesses use. This is also the agent-authored producer the retired supervision-bus draft described; its report tool is therefore promoted from a late slice to core, and its inbox and this mode’s attention surface are one surface, not two.

Consequence for the board: it becomes mission-first. A row’s primary fact is what a unit was sent to do and whether it needs you — process state is secondary. This is the direct answer to the board’s original defect, that it rendered lifecycle rather than work.

M1 — The mission record carries the envelope

Requirement. A mission must name its envelope and its work, because nothing downstream can render or enforce what the record does not carry.

Today’s Mission has intent, agent name, session/instance ids, and a status; it has no HITL policy and no report storage. Add the envelope reference, and reshape dispatch from “bring up an agent and fire a raw prompt” into fire a mission — agent, intent, envelope, working directory. Status stops being a hand-typed label and becomes agent-reportable.

M2 — Mission UI

Requirement. Firing a mission, seeing what is running and why, and reading what came back must all be possible from beam.

Fire-a-mission form (agent or chain template, intent, envelope, cwd); mission list; mission detail showing reports, asks, and outcome; mission intent on board rows. This is the plumbing that ends missions’ zero-consumer status.

M3 — Mission tools and the attention inbox

Requirement. A unit on a mission must be able to report progress and ask for attention — and its reports must reach whoever fired it, which is not always the operator.

The report / ask tools, granted per-mission and forwarded at session setup (via mcpServers for external ACP agents, via the tools allowlist for chain templates per C9); durable storage of reports against the mission; and the attention inbox — which is C2’s approval inbox, extended, not a second surface.

The supervision edge (structural, and currently missing). A mission fired from a chat session by /mission (M4) is supervised by the calling agent’s session, not by the operator directly. The fired unit reports back into that session so the caller can act on the result, and may talk back to it. That is the recursion this whole direction rests on: supervisor is a role, filled by a human at beam, by a tail in a shell, or by another agent — and the mechanism must not care which.

Today’s Mission record cannot express this. It carries SessionID and InstanceID — the session and instance the mission spawned — and nothing identifying who fired it. Add a parent reference (the calling upstream session, absent when fired by an operator directly), and route a report to it when set, falling back to the operator inbox when not.

Note this is the same gap C2 surfaced from the other side: an approval row carries no agent, session, instance, or mission identity, so the inbox cannot name who is asking. One missing edge, two symptoms. Fix it once, in the record, and both surfaces gain attribution.

Deliberately open until first use: whether talk-back is asynchronous (reports land in the parent’s transcript; the parent reads them on its next turn) or synchronous (the sub-unit blocks awaiting the parent’s answer, i.e. an ask). Async is strictly simpler and is the floor; synchronous talk-back is the ask path already being built for humans, pointed at an agent instead. Build async first and let real use decide whether blocking is wanted.

C10 — The fleet is every session, not just the ones serve owns

The observation (maintainer, 2026-07-20). An editor wired to contenox over ACP launches contenox acp as its own subprocess — one per window or project. Those processes share the one global database. So the runtime already knows about that work; the fleet board simply refuses to look at it.

Why this is a correctness claim, not a feature request. The product’s position is that the runtime is the centre and editors are viewers. A fleet view that enumerates only what contenox serve happens to hold in memory is lying by omission: an operator with three editor windows open has three live agent sessions the board reports as nothing at all. Manager.List is a config+runtime join over one process’s memory; “the fleet” should mean every unit this runtime is running, whichever process is hosting it.

What already exists. Session enumeration across the whole database is built and shipped: deriveNamespace and contenox session workspaces (contenoxcli/session_inspect_cmd.go:17,58) list every workspace and namespace in the DB, and the namespace is the origin (zed, jetbrainsgoland, default). The CLI reference already documents recovering a session an editor abandoned. Nothing new is needed to find these sessions.

What is missing: liveness. The database records that a session exists, never that its process is alive. A row outlives the editor that made it, so naive enumeration would render closed sessions as running units — precisely the failure the mission record’s LastHeartbeat was added to prevent, one level up. This needs a process/session registration with a heartbeat and a staleness sweep, and it should reuse that same shape rather than invent a second one.

Control across the process boundary is already possible. The production bus backend is SQLite-backed, and bus_events / bus_requests / bus_replies live in the same shared database every contenox process opens. So the bus is cross-process by construction and its request-reply half can carry stop, cancel, and attach to a session another process owns. Foreign sessions do not have to be read-only. That is a far better answer than either giving up on control or inventing IPC.

Why this matters more than its size suggests. It supplies the fleet with a real N>1 population today, with no isolation primitive required — editor projects are already separate working directories, so the units are naturally isolated by construction. The plurality that mission mode’s surfaces assume, and that the sandboxing gap otherwise blocks, already exists on the maintainer’s machine and is merely invisible.

Open: whether a heartbeat is written by every contenox process unconditionally, or only by those hosting a live ACP session; and whether a stale entry is swept or merely rendered as stale. Prefer rendering staleness over deleting evidence.

M5 — Unattended permissions reach the inbox (prerequisite for everything else)

The defect. viewerHub.requestPermission (runtime/agentinstance/viewer.go:239-258) routes a downstream permission request to the session’s controller viewer, and when there is no controller it auto-denies — returning cancelled, with the onUnsupervisedDeny hook explicitly documented as recording the decision and “never changing the outcome.” The kernel has no HITL path whatsoever (grep for approval/hitl across runtime/agentinstance finds nothing).

A mission runs with no viewer attached by design. So today, the first permission-gated action any unattended unit attempts is refused, C1’s durable ask store never sees it, and the inbox has nothing to show. Mission mode is broken for precisely the case it exists for, and no amount of UI on top will reveal it — the ask dies below the surface that would render it.

Note the asymmetry this leaves: native chain sessions already reach hitlservice through serve’s AskApproval fallback, so C1 fixed their headless case. External-agent sessions, which are the ones missions actually fire, never get there.

EXECUTED design — the kernel stays policy-free. Do not teach agentinstance about approvals. Inject a fallback permission answerer, defaulting to the current deny behavior, in the shape of the existing WithEventSink option: the kernel calls it when there is no controller and knows nothing about what it does. serve wires that fallback to hitlservice, turning an unsupervised request into a durable ask with the mission’s envelope as its policy. The auto-deny stops being the default and becomes the timeout outcome.

Acceptance. A dispatched unit with no viewer requests permission; the ask appears in the inbox naming the policy that escalated it; answering it unblocks the unit; leaving it unanswered expires by policy rather than hanging or silently denying.

Sequencing. This lands before M3 and before any live end-to-end run. Without it an e2e proves only that missions fail quietly.

M4 — Mission CLI and documentation

Requirement. Mission mode must be operable and learnable from the shell. A headless mode you can only reach from a browser contradicts the product’s own centre of gravity, and an operator cannot adopt a second interaction model they have to reverse-engineer from route definitions.

EXECUTED decision — mission owns firing, fleet does not. The two verb families split along the two nouns, not along the routes that happen to back them:

  • contenox fleet operates on units — list, show, stop, cancel, attach. The process-management verbs.
  • contenox mission operates on work — fire, list, show (with its reports), and status transitions.

Firing therefore lives at contenox mission fire, even though it is served by POST /fleet/dispatch. The route is an implementation detail; the verb should read as what the operator is doing. Consequence: C3 drops dispatch from its scope — a fleet dispatch alongside mission fire would be two names for one act, and the first thing to rot.

Shape. mission fire --agent <name> --intent <line> --policy <envelope> [--cwd <dir>]; mission list; mission show <id> rendering the mission plus its reports newest-first. Same output discipline as every other verb: stdout is data, diagnostics are stderr, --json is the machine contract, exit codes are meaningful.

The /mission slash command — the surface an operator actually reaches for. You are already in a chat when you realise something should be a mission. acpsvc/commands.go:23 already advertises contenox’s own commands (/model, /provider, /max-tokens, /compact) upward to every ACP client, so this is one more entry plus a handler, dispatching through fleetservice.Dispatch rather than reimplementing anything.

  • /mission <intent> — fires the default mission agent from config.
  • /mission <agent-name> <intent> — fires that named agent.

Grammar ambiguity to resolve before coding: those two forms are not distinguishable by shape. Resolving the first token against the declared-agent registry and treating a hit as the agent name is the obvious rule, but it means an intent whose first word happens to match an agent name silently changes meaning. Alternatives: require a flag-ish prefix for the named form, or accept the registry-lookup heuristic and make the echoed confirmation state plainly which agent was chosen so a misread is immediately visible. Decide deliberately; do not let the parser pick.

For external-agent sessions the command must be injected into the downstream agent’s advertised menu and intercepted before forwarding — contenox answers /mission, never the downstream. Precedent exists: the kernel already merges synthetic entries into a downstream’s surface for modes and models (agentinstance/drive.go:26-32). On collision, contenox wins and the downstream’s same-named command is shadowed; a command that sometimes fires a mission and sometimes does not is worse than either behavior consistently.

Config, and the CRUD it needs. default-mission-agent and default-mission-policy join the existing validConfigKeys map (contenoxcli/config_cmd.go), which carries them to the CLI, GET|PUT /api/cli-config, and beam’s settings page for free. That is what makes /mission <intent> work with no flags. Beyond the defaults, declared agents still have no write surface at all outside the CLI — no REST create/update/ delete, no beam management UI — so setting up “the agent that runs missions, with its config and prompt” is currently a terminal-only act. That surface is in scope here; it is the difference between mission mode being configurable and being hardcoded.

Documentation is part of this slice, not a follow-up. Two audiences, two places: the verb reference in docs/reference/contenox-cli.md alongside the existing subcommand sections, and a concept page explaining the two interaction modes — when you prompt an agent turn by turn versus when you fire one at an intent and detach, and what the envelope is protecting you from. The second matters more; mission mode is a new way to work, not a new flag.

Sequencing. Depends on C2, which builds the first serve-API HTTP client in contenoxcli. The fleet lives in contenox serve’s memory, so unlike contenox state or session — which open the SQLite DB directly — these verbs must go over HTTP. Writing a pending approval’s row from another process would change its state without waking the goroutine parked in RequestApproval, which would then block to its ceiling. One client, built once, reused by C3 and M4.

Documentation is written after the commands exist and against their real behavior. Documenting unwritten verbs is how a repo ends up with comments claiming APIs that were never built — a defect this blueprint already exists to repair (D1).

Primitives weighed and deliberately deferred

Design positions taken 2026-07-20, recorded so they are not re-litigated. Each stands on its own reasoning.

DEFERRED: durable suspend/resume. A step that could yield — persisting its position and being resumed later by an external stimulus — would collapse “waiting for a human”, “waiting for a sub-agent”, and “backing off before a retry” into one mechanism. That is attractive, and we are still not building it now, for a reason specific to our units: a mission’s unit is an external ACP subprocess, and Manager.Close kills every subprocess at shutdown (agentinstance/manager.go:522-547). Suspending our side of the conversation saves nothing when the agent’s own context dies with the restart regardless.

Revisit at C9, when chain-backed units become in-process and the run itself is finally worth preserving — or when missions must survive a deploy. Until then, durability lives in the mission record and the ask store, not in the execution.

ADOPTED: enqueue-then-nudge. Write the durable record inside the transaction first; only then publish a notification, and give that notification no payload — it says only that something changed. The notification becomes a latency optimization rather than a correctness dependency: losing one costs seconds, because a sweeper re-nudges anything that has gone stale. This is already C1’s shape, and it is why C1 persists the answer rather than only signalling it.

ADOPTED: the envelope is enforced at construction, not by a check. Privileged capabilities are bound into a unit’s session when that session is built; an unbound capability is absent, not refused. So mission tools are not “the agent calls report and we verify it is on a mission” — the mission’s session is constructed with those tools bound, and a unit not on a mission has nothing to call. That makes the envelope unforgeable from the agent’s side instead of policed, and removes a whole class of check-ordering bugs. M3 must implement it this way.

NOTED for the future scripting layer, not now: an embedded interpreter needs a watchdog able to interrupt runaway user code, since a single-threaded VM lets an unguarded loop pin a core indefinitely. And a step transition must return a value the engine dispatches rather than invoking the next step on the interpreter’s own call stack — the latter bounds total transitions by stack depth, which would cap precisely the long-running loop the scripting layer exists to enable.

Reserved — the maintainer’s calls

These are product decisions, not technical ones. Each is cheap if the answer is “remove”; each costs more than either resolution while it stays ambiguous.

  1. endpoint transport. Validates and persists with only a URL (runtimetypes/agents.go:72-75), then fails at connect with “not implemented yet” (agenthost/externalacp.go:67-68) — you can register an agent that can never run. Implement, or reject at validation? DECIDED (maintainer, 2026-07-20): restart stays off. Re-spawning loses the downstream conversation (agentinstance/doc.go:84-88), which makes it a change with real cost and no urgency; it is not taken lightly and not taken now. warning stays in the state vocabulary (it is reachable the moment restart is ever enabled) and the board keeps rendering reality. Consequence for C8: no surface may imply a restart action exists.

Acceptance

Consolidation is done when:

  1. Dispatch → ask → answer → finish works end to end, from CLI and from beam, with no hand-crafted protocol and no attached session required.
  2. Every declared capability either runs or is rejected at declaration time — no registry entry that cannot spawn, no state that cannot be reached.
  3. The fleet is operable from the shell with composable output.
  4. An operator can always name the policy that gated the last action.
  5. apitests exercises the agent and fleet paths against a running server.
  6. Nothing in this blueprint required a new mechanism — only completing, wiring, or deleting existing ones.

Esc to close