contenox
Browse docs/

Blueprint: Contenox as an ACP Hub — the client-side engine capability

Contenox sits on both sides of the Agent Client Protocol. Upward, acpsvc is an ACP agent: editors, beam, and any conformant ACP client drive it, over stdio (contenox acp / contenox acpx) today and over /acp transports as they land. Downward, the Go runtime is an ACP client: the engine opens sessions on other ACP agents and drives them the same way Zed drives contenox.

The downward direction is not an app and not a beam feature. It is a capability of the Go runtime itself, reachable from two places, both sanctioned, neither prioritized over the other:

  • (a) ACP as a taskengine step — a chain task that opens and drives a session on an external ACP agent.
  • (b) ACP as a modelprovider implementation — an external ACP agent registered so it is selectable anywhere a model goes.

Both are thin adapters over one shared prerequisite: a client-side connection core in libacp. This document specifies the hub architecture, the two shapes, the invariant that makes either of them safe, and the registry that names the agents on the other end.

The ladder: models, tools, agents

The taskengine already composes two kinds of capability into a chain. ACP adds a third:

PrimitiveExampleStatefulnessSide effectsReached via
Model (provider)a cloud or local LLMstateless per callnone — text in, text outmodelrepo.Provider / llmrepo.ModelRepo
Toollocal_shell, local_fs, an MCP serverstateless per call, gated per-callyes, individually HITL-gatedlocaltools / MCP worker
Agent (ACP)an external coding/ops agentstateful — a session with turns, plans, and its own tool callsyes, but bundled: a whole session’s worth of tool calls behind one driven interactionlibacp client core (this document)

Models and tools are both already first-class taskengine primitives; an agent is qualitatively different because a single interaction with it can contain an arbitrary number of the driving agent’s own tool calls and permission requests. That difference is why shape (b) needs an explicit honesty rule (below) and why shape (a) is the structurally safer default.

Shape (a): ACP as a taskengine step

A chain task type that opens a session on a configured external agent, prompts it, and surfaces the session’s protocol events — plans, tool calls, session/request_permission — into chain semantics rather than swallowing them into one opaque result.

This is protocol-honest by construction: taskengine’s step-sequencing already threads one step’s output into the next (ExecEnv’s loop in runtime/taskengine/taskenv.go assigns the prior step’s output as taskInput before invoking the next task), and Transition.Branches already picks the next task by evaluating that output. An ACP step’s final turn is just another step output subject to the same branching — so a guardrail step can sit between two agent actions exactly the way it sits between any other two tasks today. Fan-out (running the same ACP step against several agents, or several sessions) follows the same pattern any other task type uses; nothing about agents requires new fan-out machinery.

Concretely, this is a new TaskHandler (see runtime/taskengine/tasktype.go for the existing closed enum — raise_error, route, chat_completion, execute_tool_calls, noop, tools) and a new case in the taskexec.go switch, not a plugin/registry layer. That matches the taskengine’s existing architecture rather than inventing indirection ACP does not need.

Shape (b): ACP as a modelprovider implementation

Register an external ACP agent so it is selectable anywhere a model is selectable today — chat, routing, config options — by implementing the provider client interfaces over an ACP session.

The provider honesty rule

modelrepo’s actual client interfaces are literally stateless:

type LLMChatClient interface {
    Chat(ctx, messages []Message, args ...ChatArgument) (ChatResult, error)
}
type LLMPromptExecClient interface {
    Prompt(ctx, systemInstruction string, temperature float32, prompt string) (string, error)
}

(runtime/modelrepo/modelprovidertypes.go). Implementing Chat over an ACP agent means, inside one call: open or resume a session, send the prompt, absorb every tool_call, every plan update, and every session/request_permission round-trip the agent produces, wait for stopReason, and return the final message text as if it were a stateless completion. Everything that happened in between — files edited, commands run, services restarted — is invisible to the chain that called Chat.

That is semantically leaky by default, and it is leaky in exactly the way acp-chat-workspace.md’s downward-repair doctrine exists to prevent, aimed in the opposite direction: a chain step is supposed to be a reviewable unit whose guardrail evaluates its complete output before anything proceeds (see that document’s §C.0 on why blocking whole-message output is the architecturally honest default). A provider call that silently performed side effects defeats that guarantee — the guardrail reviews text, not the actions that produced it.

Rule: agent-as-provider is only valid under a permissions policy of deny/read-only. Used this way it is “a frontier agent as a pure reasoning model” — no file writes, no command execution, no side effects — and the provider abstraction stays honest. Side effects must never hide inside a provider call. An ACP agent registered as a provider with any looser policy is a defect, not a feature; if the work genuinely needs side effects, it belongs in shape (a), where the chain sees every action as it happens.

Permission routing — the load-bearing seam

Both shapes eventually produce the same reverse call: the driven agent’s session/request_permission. How that gets answered is the first design decision of the client core, and it is load-bearing:

A sub-agent’s session/request_permission must be answered by contenox’s own HITL policy machinery — the same policy evaluator that gates contenox’s own tools when contenox is the agent (runtime/hitlservice/policy.go: Policy{DefaultAction, Rules}, first-match Rule{Tools, Tool, When []Condition, Action} with condition operators including glob, command_blacklist, command_ask_always). The policy answers on the human’s behalf where the rule allows it, and escalates to the human through contenox’s own approval flow otherwise — never a separate, ad hoc path bolted onto the client core.

Invariant: if permission routing degrades into auto-approve — “skip HITL, it’s just a sub-agent” — the entire governance value of driving that agent is gone. The same failure mode acp-chat-workspace.md polices for inbound approvals (a permission request rendered honestly, with the rule that gated it nameable) applies outbound: an operator must always be able to name which policy gated the sub-agent’s last action.

The shared prerequisite: a client-side connection core in libacp

Both shapes live on this core. libacp — the runtime’s own ACP implementation — speaks both sides: AgentSideConnection (libacp/conn.go) and the Agent / AgentFactory contract it drives (libacp/agent.go), which is exactly what acpsvc wraps (acpsvc.New(deps) libacp.AgentFactory, runtime/acpsvc/transport.go), and ClientSideConnection (libacp/clientconn.go) with the Client / ClientFactory contract (libacp/client.go) — session lifecycle (new/load/resume/prompt/cancel), the pending-request map, and the permission callback, shared machinery the two shapes sit on top of as thin adapters.

Why one shared core matters: runtime/vscodeagent hand-rolls its own pending-request bookkeeping (clientReqPending map[string]chan clientResponse, runtime/vscodeagent/server.go:42) to answer reverse calls on its own bespoke stdio bridge protocol — the same shape (a request-id-keyed map, resolved when the peer’s response arrives) that the client core provides for session/request_permission, fs/read_text_file, and terminal/create. Solving it again for real external ACP agents, bespoke and non-reusable, would repeat that mistake; a future /acp outbound WebSocket dial (mirroring the inbound transport acp-chat-workspace.md specifies) likewise reuses the core rather than adding a third implementation.

Symmetry worth noting: acpsvc’s own local_shell handling already expects capable peers to run commands on the peer’s own machine — it routes through the client’s terminal/create when the connected client advertises the Terminal capability, falling back to server-side exec only when the peer cannot (runtime/acpsvc/commandrunner.go:35, if !t.getClientCaps().Terminal { ... fallback ... }). The client core built here is the mirror image of that same capability negotiation, generalized to contenox acting as the capable peer for a sub-agent it drives.

The agent registry

External ACP agents are registered the same way external MCP servers already are — a persisted, name-keyed spawn spec, not a bespoke config surface. The existing pattern is runtimetypes.MCPServer (runtime/runtimetypes/mcp.go): ID, Name, Transport, Command, Args, plus connection/auth fields (URL, AuthType, AuthToken, AuthEnvKey, OAuthClientID, OAuthClientSecretEnv, ConnectTimeoutSeconds, Headers, InjectParams), with CRUD (CreateMCPServer, UpsertMCPServerByName, GetMCPServer, ListMCPServers, …) already built. An agent registry table should follow the same persistence shape: name-keyed upsert, CRUD, a spawn/connect spec.

One nuance worth carrying over precisely rather than assumed: runtimetypes. MCPServer represents environment injection narrowly (AuthEnvKey names a single env var to read a secret from), not as a general env map. libacp’s own wire-level MCP type (libacp/mcp.go, McpServer{Type, Name, Command, Args, Env []EnvVariable, URL, Headers}) already carries the ACP-spec-literal {name, command, args, env[], url, headers} shape, because relaying MCP servers to a peer routinely needs several environment variables at once. An agent registry — spawning an external ACP agent process, which just as routinely needs multiple env vars (API keys, config paths, credentials) — should follow libacp.McpServer’s fuller Env list rather than replicate the narrower AuthEnvKey shorthand.

Contenox driving contenox — remote nodes as a first-class composition

The client core does not assume the peer is a third-party agent. The most important instance of shape (a) may be contenox driving another instance of itself: an engine on a laptop opening a session on a contenox acp/acpx process running on a different host, exactly as it would open a session on Zed’s or Claude Code’s agent. Nothing about the client core, the permission routing invariant, or the registry pattern is special-cased for “another contenox” — that uniformity is the point. This is the composition the operator console (../opsclient/operator-console.md) is built on: a device-owning host drives a remote, operated host’s contenox acp over the same client core that drives any other registered agent, with the same permission-routing invariant applying to the remote host’s own HITL policy.

Invariants (anti-patterns to reject in review)

  • An agent-as-provider that executes side effects. Shape (b) is only honest under a deny/read-only permission policy; anything looser belongs in shape (a), where the chain sees the actions.
  • Auto-approve as a default or fallback permission policy for a driven agent. Permission routing must reach contenox’s HITL policy machinery, never a bypass justified by “it’s just a sub-agent.”
  • A second (or third — counting acp-web-client’s official-SDK-based browser client) hand-rolled ACP client RPC layer outside libacp. Replicating vscodeagent’s bespoke pending-request map instead of extending the shared core repeats a mistake already made once.
  • A chain step or provider that cannot name which sub-agent, session, or policy backs it. The operator must always be able to answer “what is this and what rule gated its last action,” outbound as well as inbound.
  • Treating shape (a) and shape (b) as competing rather than as two thin adapters over the same client core. Divergent session or permission handling between them — one honoring the HITL policy machinery, the other rolling its own — is a defect, not a design choice.
  • A registry entry with no invocable spawn spec — an agent listed without a {name, command, args, env}-shaped way to actually reach it duplicates the MCP registry’s own early mistakes instead of reusing the pattern.

Esc to close