Skip to content

Latest commit

 

History

History
1119 lines (937 loc) · 81.4 KB

File metadata and controls

1119 lines (937 loc) · 81.4 KB

AgentPrism Workflows — Design Notes & Protocol Reference

The deep design reference behind ../README.md. It records what the system is, which libraries it uses, and what each library actually supports — with concrete, package-specific API references (field/method names, file:line, versions). For installation and usage, start with the README; read this when you need the protocol-level mechanics (ACP lifecycle, the structured-output crux, model/permission/usage wiring, and execution-engine internals).

Reference/design doc, not a roadmap or a tutorial. The implementation now lives in ten @automatalabs/* packages — see §2. The Pi src/… citations throughout are provenance for the lifted engine, not paths in this repo.


1. Goal

Rebuild the dynamic-workflow orchestrator so the engine has no dependency on Pi while Pi is available as an isolated, first-class ACP leaf:

  • The workflow tool is exposed by a stdio MCP server (instead of a Pi extension's registerTool). Any MCP-capable host (Claude Code, Zed, etc.) can call it.
  • Each agent() call inside a workflow script is backed by an ACP agent server (claude-agent-acp for Claude, codex-acp for Codex, opencode acp for OpenCode, pi-acp for pi) over the Agent Client Protocol (instead of Pi's in-process createAgentSession).

The deterministic orchestration engine (the JS vm realm, parallel/pipeline, the journal/resume machinery, and git-worktree isolation) is reused essentially unchanged from pi-dynamic-workflows — only the leaf (how one subagent runs) and the shell (how the tool is exposed) change.

This is built as a new, standalone codebase that lifts the reused pieces (copy + adapt the source) rather than modifying the Pi extension; the engine never imports Pi at runtime. The first-class Pi integration remains behind the spawned pi-acp process boundary. Three core layers (shared-types, workflow-engine, acp-agents) stay independently usable, while the SDK facade, MCP shell, and optional OTel leaf compose them for hosts — see §2 for the package layout.

The core inversion

The orchestrator process plays two protocol roles at once:

   MCP host (Claude Code / Zed / …)
        │  calls tool "workflow" or "repl"  (MCP, stdio)
        ▼
┌────────────────────────────────────────────────────────────┐
│  workflow-orchestrator process                             │
│   • MCP SERVER  → exposes the `workflow` and `repl` tools   │
│   • ACP CLIENT  → drives agent servers (both tools)         │
│   • `workflow` → the deterministic engine runs the script  │
│   • `repl`     → a per-project QuickJS-in-WASM broker runs  │
│                  the interactive REPL workspace            │
└────────────────────────────────────────────────────────────┘
        │  session/new, session/prompt … (ACP, JSON-RPC over stdio)
        ▼
   claude-agent-acp / codex-acp / opencode acp / pi-acp  (one or more long-lived subprocesses)
        │  → real Claude / Codex / OpenCode / pi agents, each in its own session

ACP and MCP are sibling JSON-RPC protocols from the same design space (ACP = host↔agent, MCP = agent↔tools), so this is a clean composition, not a hack.


2. Codebase & module structure

This is a new, greenfield codebase — not a fork, a patch, or a runtime dependency of the Pi extension. We lift the specific pieces of pi-dynamic-workflows we need (copy + adapt the source) and write the rest fresh. The engine imports no Pi code; acp-agents reaches Pi only by spawning the exact-pinned @automatalabs/pi-acp package as an ACP server.

The code is organized as ten packages, all released to npm, with a one-way dependency direction. The lower layers remain independently usable — in particular, the ACP agent logic and workflow engine both work with no MCP server at all — while the facade and integration leaves stay thin.

 ┌──────────────────────────────┐          ┌──────────────────────────┐
 │ mcp-server                   │          │ agentprism-otel          │
 │ stdio tools: workflow + repl │          │ observes manager events  │
 └──────────────┬───────────────┘          └────────────┬─────────────┘
                │ registers `repl` over → depends on      │ structural attach
                │ (also → workflows + shared-types,       ▼
                │  annotated below)              (attaches to a WorkflowManager)
                ▼
 ┌──────────────────────────────┐
 │ repl-engine                  │
 │ persistent JS REPL in a      │
 │ QuickJS-in-WASM VM + broker  │
 └──────────────┬───────────────┘
                │ depends on workflows
                │ (and acp-agents + shared-types, annotated below)
                ▼
 ┌─────────────────────────────┐◄── mcp-server, repl-engine
 │ workflows — public SDK      │
 │ facade + ACP event bridge   │
 └────────┬───────────────┬────┘
          ▼               ▼
 ┌──────────────────┐  ┌────────────────────────────┐
 │ workflow-engine  │  │ acp-agents                 │◄── repl-engine
 │ vm, journal,     │  │ pooled built-in ACP agents │
 │ resume           │  │ + custom ACP, auth, sessions│
 └────────┬─────────┘  └──────────────┬─────────────┘
          └──────────────┬────────────┘
                         ▼
            shared-types — AgentRunner seam  ◄── repl-engine, mcp-server

The REPL engine (roadmap repl-orchestrator) is not a leaf outside that chain — it composes it:

 ┌──────────────────────────┐
 │ repl-engine              │   persistent JS REPL in a QuickJS-in-WASM VM.
 │ REPL VM layer            │   Depends directly on workflows (the shared
 │ (roadmap: repl-orchestrator)│   per-project key), acp-agents (subagents are
 └──────────────────────────┘   ACP sessions), and shared-types. Its `repl`
                                MCP tool is registered in mcp-server (phase E —
                                implemented; the package is published independently).

@automatalabs/acp-server is a separate composition root over acp-agents. Each stdio, Streamable HTTP, or WebSocket connection acts as an ACP agent toward one extension-aware client and as an ACP client toward the selected backend. A discovery connection probes all configured backends; an operational connection pins one backend during initialize and then forwards ACP traffic without rewriting session IDs. The official TypeScript SDK owns the network transport's connection/SSE correlation; it does not add an AgentPrism session-routing table.

workflow-engine and acp-agents are siblings: neither imports the other. They meet only at the AgentRunner interface (run(prompt, opts) → result), injected at composition time. The engine never names a concrete backend; the agents module never knows it's inside a workflow.

acp-agentsthe internal ACP backend (an AgentRunner), not the public SDK

All the logic for actually using the ACP agents: opening and holding ACP client connections to claude-agent-acp / codex-acp / opencode acp / pi-acp, the ClaudeBackend / CodexBackend / OpenCodeBackend / PiBackend / CustomAcpBackend, model selection (§5.4), permission allow/deny (§5.5), usage extraction (§5.6), cancellation (§5.7), auth, session lifecycle, and structured-output vendor wiring (§6). It implements the one-method AgentRunner seam (run(prompt, opts)) and adds host-facing event, auth, and interactive/reattach APIs. Its runtime deps are @agentclientprotocol/sdk, @agentclientprotocol/claude-agent-acp, @automatalabs/codex-acp, @automatalabs/pi-acp, @modelcontextprotocol/sdk, typebox, and @automatalabs/shared-types; OpenCode is resolved from the host and deliberately is not bundled.

The Codex backend drives the workspace package @automatalabs/codex-acp (packages/codex-acp) — our fork of @agentclientprotocol/codex-acp, imported with its full history as a non-squashed subtree (#282), which bakes the turn-level outputSchema forward (§6.3) into its built dist. It is consumed as workspace:* (pnpm materializes an exact version at publish), so Codex ships on a clean git clone && pnpm install && pnpm build — no pnpm patch, no patches/ file, no vendored tree.

The Pi backend depends on the workspace @automatalabs/pi-acp (workspace:*, rewritten to the exact lockstep version at publish) and resolves its dist/index.js bin under process.execPath. Its complete fallback ladder is AGENTPRISM_PI_ACP_CMD/AGENTPRISM_PI_ACP_ARGS → installed package bin → npx -y @automatalabs/pi-acp; it never relies on a pi-acp PATH executable.

It is its own module (it imports neither the engine nor the MCP server), but it is an internal building block — not the importable public SDK. The canonical, importable SDK is @automatalabs/workflows, which re-exports createAcpRunner (and the rest of this backend's public surface), so callers never depend on @automatalabs/acp-agents directly. Run a single agent with no workflow and no MCP server through that facade:

import { createAcpRunner } from "@automatalabs/workflows";   // the canonical SDK entry point
const runner = createAcpRunner();                            // AgentRunner backed by the ACP pool
const result = await runner.run("Summarize repo X", { schema: MY_SCHEMA, cwd, model: "opus" });
await runner.dispose();

workflow-engine — the lifted Pi engine

runWorkflow (the vm realm + determinism prelude; the agent/parallel/pipeline/phase/log globals), the journal/resume, the limiter, the run manager + persistence, and the worktree helper. It depends on an injected AgentRunnernot on acp-agents — so it runs against a real ACP runner, a mock, or any other backend (exactly how the Pi tests drive it today via options.agent). The seam: runWorkflow requires options.agent: AgentRunner and only ever calls agentRunner.run(prompt, opts) (today Pick<WorkflowAgent,"run">, src/workflow.ts:59, bound at :283, called at :465).

mcp-server — the shell / composition root

Owns the workflow tool definition (input schema + handler) and the repl tool (registered over a per-project QuickJS VM through @automatalabs/repl-engine), plus the stdio MCP transport; streams progress via MCP notifications/progress; and owns the strict same-ID MCP continuation contract. It registers no auth tools — backend auth stays with the agents' own credential stores. It depends on @automatalabs/workflows, @automatalabs/repl-engine, and @automatalabs/shared-types, constructs the ACP runner, and injects it into the facade manager. It is just one consumer — the engine + agents can equally be driven by a CLI, a test harness, or another server, with no MCP involved.

workflows — the public SDK facade

The canonical programmatic entry point. It composes workflow-engine and acp-agents, re-exports the supported host surface, adds runDynamicWorkflow, validation/folder helpers, and bridges the runner's live ACP events onto WorkflowManager.agentEvent.

agentprism-otel — optional observability leaf

Attaches structurally to a WorkflowManager and maps workflow/agent/tool events to OpenTelemetry spans plus token, cost, count, and duration metrics. It peer-depends on @opentelemetry/api and is outside the engine/runner dependency chain.

Packaging (as implemented): a pnpm monorepo of ten published packages — @automatalabs/shared-types (the seam), @automatalabs/workflow-engine, @automatalabs/acp-agents, @automatalabs/acp-server (the connection-pinned ACP proxy), @automatalabs/mcp-server (the bin), @automatalabs/workflows (the importable SDK facade), @automatalabs/agentprism-otel (the optional telemetry bridge), @automatalabs/pi-acp (the standalone in-process pi ACP server), @automatalabs/codex-acp (the Codex ACP fork adding turn-level outputSchema forwarding, pulled in by acp-agents), and @automatalabs/repl-engine (the published REPL orchestrator QuickJS-in-WASM VM layer; its repl MCP tool is registered in mcp-server — roadmap phase E, implemented). The dependency direction and the AgentRunner seam are the contract.

Lifted from pi-dynamic-workflowsworkflow-engine (copied/adapted, mostly unchanged)

Concern Source (pi-dynamic-workflows) Notes
Script execution src/workflow.tsrunWorkflow, vm.createContext/vm.Script (:835,:866) Node vm realm; globals agent/parallel/pipeline/phase/log injected
Determinism src/workflow.ts DETERMINISM_PRELUDE (:227), parse blocklist (:212,:890) neuters Date.now/Math.random/new Date() for resume reproducibility
Fan-out parallel (:555, barrier), pipeline (:579, no inter-stage barrier — but still Promise.all-joins all items at :588, so don't drop that on a port), createLimiter (:1013) concurrency gate
Journal / resume src/run-persistence.ts, journal in workflow.ts (hashAgentCall :1045, firstMiss longest-unchanged-prefix :407) crash recovery + resume
Worktree isolation src/worktree.tsgit worktree add per agent engine creates it (deterministic name) and passes cwd to agent.run({cwd})
Model tiering logic src/model-routing.ts, src/model-tier-config.ts pure logic; resolution target becomes an ACP session config option (§5.4)
Schema validate/extract src/agent.ts resolveStructuredOutput (:113), extractValidated (:47) lifted into acp-agents (not the engine) as the schema guard (§6)

Written fresh in the new codebase

Module Piece Replaces (Pi) New
acp-agents Leaf — run one subagent WorkflowAgent in src/agent.ts (createAgentSession, ModelRegistry, createCodingTools) AcpAgentRunner.run() (via createAcpRunner()) — drives Claude, Codex, OpenCode, pi, or custom ACP agents
acp-server ACP composition root — aggregate backend servers no Pi equivalent stdio, Streamable HTTP, and WebSocket listeners over negotiated discovery connections plus connection-pinned transparent ACP V1 proxying
workflows Facade — compose + validate no Pi equivalent public SDK, one-shot helper, workflow folders/validator, manager ACP-event bridge
mcp-server Shell — expose tools extensions/workflow.ts + createWorkflowTool defineTool + TUI (display.ts, task-panel.ts, workflow-ui.ts) stdio MCP server registering the workflow and repl tools (no auth tools); progress via MCP notifications
agentprism-otel Observability no Pi equivalent OTel trace/metric mapping over manager events
acp-agents Structured output injected structured_output tool (src/structured-output.ts) Claude/Codex schema channels plus client-hosted StructuredOutput MCP capture for Pi, OpenCode, and eligible custom ACP backends (§6)

3. Libraries & packages

All versions below were re-verified from the installed workspace dependency graph on 2026-09-02.

Tool exposure (MCP server)

  • @modelcontextprotocol/{client,server,node}@2.0.0 — split TypeScript MCP SDK used by the MCP shell. The daemon retains its stateful 2025-era transport beside the SDK's strict modern createMcpHandler, and in-process stdio uses serveStdio, so one tool implementation serves legacy clients and 2026-07-28 clients. ACP-side embedded MCP clients remain independently on @modelcontextprotocol/sdk@1.30.0 across process/wire boundaries. @modelcontextprotocol/ext-apps@1.7.5 remains a browser-build dependency only; the server-side Apps registration/catalog is v2-native and passes no SDK object across the v1/v2 package boundary. Ref: https://github.com/modelcontextprotocol/typescript-sdk · https://modelcontextprotocol.io

Agent backends (ACP)

  • @agentclientprotocol/sdk@1.4.0 — the ACP protocol SDK (JSON-RPC-over-stdio types + client/connection helpers). This is what your orchestrator uses to speak ACP as a client. Ref: https://agentclientprotocol.com · https://github.com/agentclientprotocol

  • @agentclientprotocol/claude-agent-acp@0.75.1 — ACP server wrapping Claude. Bin: claude-agent-acp (npx @agentclientprotocol/claude-agent-acp). Author: Zed Industries. Resolves @anthropic-ai/claude-agent-sdk@0.3.265 through the workspace override — the adapter itself exact-pins 0.3.257, so the override lifts the runtime to npm latest. Adapter 0.71–0.73 adds model-aware modes, per-model usage metadata, native subagent/task reporting, message-specific forks, and session titles. AgentPrism gives engine-owned Claude sessions a stable label-derived SDK title so the adapter does not launch its otherwise-unobserved background title-generation model call; interactive sessions retain generated titles. SDK 0.3.259 adds batched user-message correlation and an opt-in no-prompt permission policy, plus Claude Code parity. AgentPrism's host permission broker retains the default host policy, and the structured-output, terminal-result, and usage surfaces integrated below remain compatible. Adapter 0.74 adds opt-in subscription restrictions under --hide-claude-auth and validates supplied gateway payloads; AgentPrism does not set that flag and supplies the required gateway URL/headers. SDK 0.3.261 adds opt-in plugin delivery over stdin and fixes disposal in older VM contexts; neither changes the existing integration surface. Adapter 0.75.1 reports compaction through ordinary ACP tool lifecycle events and fixes message-specific forks; resumed sessions restore their model from local transcript data without blocking on a context-usage control request. Its new connection-scoped authStatus notification remains an upstream capability; the runner's public auth snapshot still reflects its own bookkeeping. SDK 0.3.265 expands user-message correlation and preserves an agent's shell cwd across turns. Structured-output, terminal-result, and usage contracts remain compatible. Drop the override once the adapter catches up (CONTRIBUTING "When the dependency gate blocks"). Ref: https://github.com/agentclientprotocol/claude-agent-acp

    Naming note: the canonical package is claude-agent-acp, not "claude-acp".

  • @automatalabs/codex-acp (workspace, packages/codex-acp) — ACP server wrapping OpenAI Codex (TypeScript rewrite over the Codex App Server). Bin: codex-acp. This is a published fork of @agentclientprotocol/codex-acp that bakes the outputSchema forward (§6.3) into its shipped dist; it is the package acp-agents exact-pins and consumes. Ref: packages/codex-acp (workspace fork, full imported history) · https://github.com/agentclientprotocol/codex-acp (upstream)

    The Rust zed-industries/codex-acp is the deprecated predecessor; development moved to the agentclientprotocol/codex-acp TypeScript package (which this fork tracks).

  • @automatalabs/pi-acp — ACP server wrapping the Pi coding agent. Bin: pi-acp (dist/index.js). It serves stdio/Streamable HTTP/SSE MCP, advertises HTTP/SSE, sampling/roots/ elicitation, configured model/thinking options, and six unconditional authentication methods. acp-agents exact-pins and spawns it as the first-class pi backend.

Engine support (lifted from pi-dynamic-workflows; no Pi runtime needed)

  • acorn — parse the workflow script + extract/validate the meta literal.
  • node:vm, node:crypto — script realm + journal hashing.
  • A JSON-Schema lib (typebox today, or zod — note claude-agent-acp itself uses zod ^4) for the agent({schema}) contract and client-side validation.
  • git — worktree isolation (git worktree add/remove).

4. The MCP side — exposing the workflow tool

The workflow tool grew from Pi's single-form input (src/workflow-tool.ts:61) into a strict action unionconfig, run, resume, status, result, permissions-response, and stop — exposed via the MCP server instead of defineTool. Tool discovery publishes a draft-2020-12 oneOf with one top-level branch per canonical action, literal required discriminators, branch-local properties, and additionalProperties:false. Run nests exact inline/path variants; stop nests whole-run/targeted variants. The same Zod union performs runtime validation. There is no omitted-action default, retired action alias, pre-validation normalizer, deprecated field, or hidden acceptance path. Both the legacy 2025 transport and modern 2026-07-28 transport publish and execute this same lifecycle:

  • Run — supply exactly one of script or scriptPath (a raw JS string with no Markdown fences, or an absolute server-side path read once at admission; the first statement must be export const meta = { name, description, phases? }), plus projectDir — the absolute project directory selecting the run store and default cwd, required on the shared daemon and defaulting to the server's own project under --in-process. Agent-less deterministic scripts are valid; the validator warns when a script has neither agent() nor checkpoint(). Other run fields: args, maxAgents (default 1000), concurrency (clamped to 16), agentRetries (clamped to ≤3), and background.
  • Resume — supply the exact runId plus optional runtime bounds, a strict-JSON checkpointReplies answer, and background. The server continues that same identity under its run lease using its immutable persisted script, args, cwd, approved script backends, canonical agent configuration, journal, event stream, cumulative usage, and checkpoint history. It accepts no replacement script, args, replay policy, source ID, or project path and never allocates a child execution. An old record without the required canonical admission metadata remains inspectable where naturally supported, but continuation fails clearly and requires a fresh Run.
  • Status / result / permissions-response / stop — take a runId and never execution fields. status accepts the existing lastN / labelGlob / logLines projection bounds and always returns one immediate snapshot; it has no wait control or wait metadata. result reads the authoritative completed value from persistence and returns at most 16,384 exact UTF-8 JSON bytes plus endOffset/hasMore; boundaries never split a code point and interior offsets fail closed. Status projects live ACP permission requests; permissions-response names the opaque request id and returns an exact advertised optionId or cancelled outcome. Whole-run stop is location-independent across daemon generations: the successor persists an idempotent intent, routes signed control to the lease owner, and may return a nonterminal pending-control acknowledgement before final settlement. forceOwner:true explicitly authorizes terminating a superseded owner after identity revalidation. stop with callIndex instead synchronously routes cancellation to one live in-flight agent (its slot settles to null with AGENT_CANCELLED); force is forbidden and cancellation is never reconstructed after owner loss. Stop accepts the same projection bounds.
  • Bounds clamp, don't reject: accept concurrency/agentRetries as plain numbers in the tool schema — not Zod .max(), which rejects out-of-range input with InvalidParams. The engine already clamps them (normalizeConcurrencyMAX_CONCURRENCY 16, normalizeAgentRetriesMAX_AGENT_RETRIES 3), so defer to it and keep the "clamped" semantics above (matches Pi). The status projection bounds (lastN/logLines), by contrast, are wire-contract limits rejected at the Zod boundary.

Unbounded agent execution with explicit cancellation. Model-facing agent work has no elapsed-time budget or idle watchdog. An attempt remains live until it completes, fails, or the host explicitly cancels its call or run. Protocol startup, cancellation-grace, cleanup, lease, and transport bounds remain fixed implementation safety controls; they are not configurable agent work budgets.

Configuration elicitation fills unresolved models and is canonical. Before a new run is admitted, mock execution resolves per-call, agent-definition, tier, phase, and meta models. A form-capable client is asked only about observed calls whose effective model remains unresolved. Explicit and inherited model/mode/config values are preserved, including backend-only model specs; optional mode/config omissions use backend defaults and do not trigger a form. Each unresolved occurrence shows its phase title/detail, label, and a bounded credential-redacted task/prompt preview. Accepted form values are validated against the live catalog and combined with authored configurations into the complete canonical effective snapshot described below; private form scaffolding is not persisted.

Exact result discovery is separate from observability. Completed runs with a persisted JSON value expose workflow://runs/{runId}/result, distinct from the immutable /script resource and the bounded/redacted /events stream. Every admitted durable-log run and later status/terminal response identifies /events through eventsUri and a labelled resource link. Status additionally reduces durable progress to bounded per-call latestActivity; the linked event stream remains the detailed cursor/transcript authority. Foreground and status identify the exact-result URI and link with an explicit result label; script and events links are labelled separately. Exact JSON up to 4,096 UTF-8 bytes is also copied into foreground/status text for content-first hosts. Larger results stay out of summary text and can be read as an unbounded resource or reconstructed from bounded action:"result" pages. All paths read the existing persisted snapshot, add no engine format, and fail closed for runs without a completed authored value. Events remain observability and are never promoted into a result reconstruction format.

Background execution, not just synchronous. Pi's "return immediately, deliver the result into a later turn" affordance (installResultDelivery) has no MCP equivalent, so a foreground run (the default, background: false) normally executes to completion, streams progress via MCP notifications/progress, and returns the final result — bound to the request and its timeout. If an ACP permission blocks the turn first, foreground returns the still-running run and its pending request rather than stranding the tool call; that run is then operated like a background run. But background support was not dropped. Runs execute in a shared per-user workflow daemon (the stdio entry is a thin shim that auto-starts it), so background: true acknowledges after durable admission with a runId and the run outlives the request — observed later with bounded status snapshots, and durable across client disconnects, shim kills, and session eviction. Version succession moves the family front door without moving live VM/ACP state: a predecessor keeps its run lease while the successor joins that lease to the predecessor's PID/instance record and forwards control over a user-key HMAC endpoint. A pre-control busy predecessor is temporarily retained for the first rolling upgrade. Owner exit, or the single client-owned process exiting under --in-process, can interrupt work; no timeout steals a live lease. Resume is explicit and same-ID: action:"resume" continues the supplied run and does not create a model-visible execution attempt.

The run-monitor UI is also resilient to hosts replacing an existing MCP App panel. Every surviving panel is a bounded multi-run dashboard: it defaults to the tool call's run and uses a capability-gated, app-only workflow-runs tool to navigate active and recent project runs. The listing comes from the authoritative manager/store, remains outside the model's tool loop, and is bounded independently of the detailed app-only event poller.

The shipped server registers the workflow and repl tools — and no auth tool. Backend auth belongs to the agents' own CLI credential stores, and the server deliberately exposes no auth state for a host to inspect: agents that self-authenticate from disk are invisible to any host-side auth bookkeeping, so an auth-status surface could only report "unauthenticated" on fully logged-in machines — an LLM host reads that as a blocker. This also bounds MCP automatic default discovery: a no-prompt session/new/config probe can rule out definite failures but cannot prove universal first-prompt authentication. When AGENTPRISM_DEFAULT_BACKEND is absent and a mock routing pass reaches a model-less call, the MCP composition root probes configured backends, excludes failures and explicitly empty built-in catalogs, prefers positive session-open evidence (Codex's auth check; Pi's credential-filtered model catalog), then falls back to the first session-ready unknown. It pins that backend-only spec into engine validation, call identity, persistence, and continuation. A later AUTH_REQUIRED pauses on that backend; there is no mid-run provider fallback. AUTH_REQUIRED pauses a run with the non-secret authContext; the recovery sequence is an out-of-band CLI login, then re-call workflow with action:"resume" and the same runId. Programmatic credential injection stays in the SDK's auth-capable runner APIs for embedding hosts.

MCP resume is one exact-run operation. Admission persists a versioned canonical host-owned effective agent-configuration snapshot atomically with the initial state. The snapshot contains the resolved provider/model/mode/config values by stable occurrence ordinal, not raw form fields. Continuation inherits it without re-elicitation and fails closed if a live occurrence was not covered or if an old record lacks required metadata. If the run paused inside a root agent turn on usage/auth, the manager projects its persisted call/session join into a continuation candidate. The resumed live occurrence reopens and continues that session when every identity, input, cwd, backend, and capability gate holds; otherwise it opens a fresh session. This channel adds no MCP input.

Live ACP permission requests are a different, execution-affine human gate. The MCP runner installs a resolver that parks the original session/request_permission promise and records a bounded live projection keyed by run/call/permission id. The projection omits the private ACP session id, redacts credential-shaped diagnostics, bounds scalars and structure, and preserves every exact ordered option id inside a 64 KiB envelope; an unrepresentable option set is cancelled rather than partially shown. The form renders the already-sanitized available tool-call raw input, content, and locations under strict field and total bounds, alongside run ID, phase, agent label, backend, tool title/kind, and the exact meaning/scope of each option. Status exposes the exact ordered backend options; legacy elicitation-capable clients receive a form immediately, modern clients use an integrity-bound inputRequired retry, and non-elicitation clients call permissions-response. Responses validate the selected option against the parked request and route through signed daemon control to the process that owns the run lease. Public responses forbid _meta, so provider effects come only from the selected advertised option id. This is running-but-waiting state, not the engine's durable paused status: owner loss invalidates the ACP request and it is never reconstructed cold. Explicit tool allow/deny lists settle before the MCP resolver; otherwise AgentPrism does not infer a provider decision from option labels, kind, or response metadata.

Human-in-the-loop checkpoints: checkpoint() relied on Pi's ui.confirm. Over MCP, elicitation-capable clients provide the live channel. Without elicitation, the authored headless mode applies: "default" takes default ?? true, "abort" aborts, and opt-in "pause" persists a checkpoint_required pause. The host resumes that pause with action:"resume", its runId, and a decision in checkpointReplies; its checkpointContext supplies the call index and hash used to journal it. Under the run lease, the first strict-JSON answer is durable before continuation. Repeating the same answer is idempotent; later conflicts are ignored in favor of that first answer, which reconstruction replays forever.


5. The ACP side — driving agent servers

AgentPrism's backend subprocess connections use JSON-RPC 2.0 over stdio, newline-delimited (messages MUST NOT contain embedded newlines; stdout = protocol, stderr = free for logs). The aggregation server accepts that same ACP V1 message protocol over stdio, Streamable HTTP, and WebSocket; its HTTP/WebSocket listener uses the official TypeScript SDK transport implementation. Protocol version is 1. Spec: https://agentclientprotocol.com/protocol/v1/transports · https://agentclientprotocol.com/rfds/streamable-http-websocket-transport

5.1 Lifecycle / a single turn

initialize                         → capability handshake (protocolVersion, clientCapabilities, authMethods)
session/new   { cwd, mcpServers }  → returns sessionId (+ configOptions)             [fresh path]
  OR session/resume { sessionId } / session/load { sessionId }                      [eligible pause resume]
session/prompt { sessionId, prompt }  (request)
  ↳ session/update  notifications  → agent_message_chunk, tool_call, tool_call_update,
                                      plan, usage_update, …  (streaming, agent→client)
session/prompt response            → { stopReason, usage? }
session/cancel { sessionId }       (notification, client→agent)

The continuation acquire waits for the current pooled connection's initialize handshake, prefers advertised session/resume, and falls back to session/load. A missing/rejected reopen falls through to session/new with the original prompt. Once reopen resolves, the runner sends a fixed continue-the-interrupted-task prompt and does not restart fresh after later turn/setup failures.

5.2 Sessions & concurrency — supported

A single agent-server process hosts many concurrent sessions (each keyed by sessionId). Both servers implement a real sessionId → session map:

  • claude-agent-acp: sessions map; prompts on different sessions run concurrently; prompts within one session are queued (promptQueueing). (src/acp-agent.ts)
  • codex-acp: private readonly sessions: Map<string, SessionState> with per-session prompt state + generation fencing. (src/CodexAcpServer.ts)

Efficient fan-out: run one (or a few) long-lived server processes and open N sessions; the engine's createLimiter caps real concurrency. You're bound by API rate limits and per-session memory, not by the protocol. The one deliberate exception is the client-hosted StructuredOutput lane: concurrent injected runs reserve separate processes to isolate agents with process-global MCP registries, while non-injected sessions continue to multiplex (§6.6). Ref: https://agentclientprotocol.com/protocol/v1/session-setup

5.3 Working directory / worktree isolation — supported, clean

cwd is a required, per-session, absolute field on session/new (independent per session in one process); optional additionalDirectories expands the root set. So worktree isolation maps directly: createWorktree()session/new({ cwd: worktree.cwd }). Both servers store cwd per session. Ref: https://agentclientprotocol.com/protocol/v1/session-setup#working-directory

5.4 Model selection — supported, via Session Config Options (not session/new, not initialize)

The client picks the model per session (switchable per turn) from the catalog the agent advertises:

  • Mechanism: agent returns configOptions (in the session/new result, updatable later) including { id:"model", category:"model", type:"select", currentValue, options[] }; client switches with session/set_config_option { configId:"model", value }. Categories also include model_config (context/speed/quality) and thought_level. Refs: https://agentclientprotocol.com/protocol/v1/session-config-options · https://agentclientprotocol.com/rfds/model-config-category
  • claude-agent-acp: model option → query.setModel(...); accepts aliases (opus/sonnet); initial precedence ANTHROPIC_MODEL env → settings.model → SDK default. The client selects per-session models through session/set_config_option.
  • codex-acp: model encoded as "model[effort]" (e.g. gpt-5.2[high]) + separate reasoning_effort select; switch via session/set_config_option (the wire method; setConfigOption is just the ACP SDK's JS accessor for it).
  • opencode acp: model values are OpenCode catalog ids like provider/model (for example zai/glm-5.2) under a model select. The public routing prefix is stripped at the first slash: opencode/zai/glm-5.2[high] routes to the OpenCode backend and selects zai/glm-5.2[high] verbatim.
  • pi-acp: the public pi/ prefix is stripped once and the remainder is sent verbatim to Pi's model select. Pi then interprets <provider>/<model-id> by splitting the first slash, so pi/openrouter/vendor/model-id selects provider openrouter and model id vendor/model-id.

The catalog belongs to the server (Claude models on claude-agent-acp, Codex models on codex-acp, OpenCode models on opencode acp, Pi models on pi-acp), so cross-provider routing = choosing which server; within a provider, per-call tiering works. This is what the engine's tier: small/medium/big maps onto.

5.5 Permissions and explicit session modes — supported

The agent requests approval per gated tool call via session/request_permission (agent→client) and supplies the complete ordered decision set. The selected optionId is the sole decision contract: labels, kind, and _meta.permission are presentation, never a source from which the client reconstructs provider effects. SDK runners without a resolver retain the ACP-permitted auto-policy; MCP workflows park unresolved requests for the live permission broker described in §4. Explicitly authored tool allow/deny lists remain binding before that broker.

When a call omits mode, AgentPrism explicitly applies the first-class default rather than inheriting ambient harness settings: Claude auto, Codex agent (Codex Auto-review), OpenCode build, and no mode for Pi. Custom backends retain their own current mode. Config discovery returns the raw mode id, name, description, and _meta, plus AgentPrism's default id; both authored and built-in defaults are validated against the live advertised catalog before a prompt. No local replacement descriptions are maintained.

Trusted autonomous implementation/review workflows explicitly select Claude bypassPermissions or Codex agent. Claude auto is a model-classifier mode that may still request permission; it is not described as fully autonomous. Permission elicitation remains a transparent human gate for other modes, not a substitute for choosing the backend's real full-access mode.

Ref: https://agentclientprotocol.com/protocol/v1/tool-calls#requesting-permission · https://learn.chatgpt.com/docs/sandboxing/auto-review.md

5.6 Usage / token accounting — supported

  • usage_update session/update notification: used + size (token counts), optional cost { amount, currency }.
  • Per-turn usage object on the session/prompt response (still a Draft RFD, but both servers already emit it).
  • claude-agent-acp reports tokens + dollar cost (cost = total_cost_usd, USD); response usage { inputTokens, outputTokens, cachedReadTokens, cachedWriteTokens, totalTokens }.
  • codex-acp reports tokens/quota only (no dollar cost).
  • OpenCode reports per-turn PromptResponse.usage plus cumulative usage_update cost/context; the existing accumulator combines the latest cumulative cost with the per-turn token split.
  • Pi reports per-turn PromptResponse.usage and can emit cumulative usage_update notifications; the runner applies the same ACP usage accumulator.

This maps onto the engine's onUsage / token accounting; no need for the chars/4 estimator fallback in the normal case. Refs: https://agentclientprotocol.com/protocol/v1/prompt-turn · https://agentclientprotocol.com/rfds/session-usage

5.7 Cancellation — supported

session/cancel { sessionId } is a fire-and-forget notification; the agent aborts model+tool work and then resolves the original session/prompt with stopReason: "cancelled". A session/close (when advertised) also frees the session. Maps onto the engine's AbortController/signal. Ref: https://agentclientprotocol.com/protocol/v1/cancellation

5.8 Giving agents extra tools — mcpServers on session/new

The client passes MCP server configs (stdio mandatory; http/sse optional per capability) in session/new; the agent connects to them. This is the only client-side tool-injection path in ACP (the client does not hand the agent a tool object directly).

  • claude-agent-acp: ACP mcpServers → SDK McpServerConfig, merged with user options (src/acp-agent.ts:7003-7141).
  • codex-acp: supports stdio + http (rejects acp/sse).
  • opencode acp: advertises http + sse; this is what enables the runner-hosted StructuredOutput MCP tool for schema runs.
  • pi-acp: serves stdio, Streamable HTTP, and SSE and advertises HTTP/SSE, enabling the runner-hosted StructuredOutput MCP tool plus user-configured remote servers. Ref: https://agentclientprotocol.com/protocol/v1/session-setup#mcp-servers

5.9 Custom backends & the generic _meta passthrough

ACP is a unified protocol — nothing about the runner is backend-specific except the built-in Backend strategies for Claude, Codex, OpenCode, and Pi. Two additive surfaces open the seam to any ACP agent:

  • The backend registry (acp-agents/src/registry.ts): named spawn configs ({ command, args?, env?, sessionMeta?, structuredOutputTool? }), registered programmatically (createAcpRunner({ backends })) or via AGENTPRISM_BACKENDS (JSON env). Routing matches registered names FIRST (model: "browser" or "browser/<inner-model>" — the name is routing; the part after the slash is selected via Session Config Options), then the built-in heuristics. AGENTPRISM_DEFAULT_BACKEND may name a registry entry. The runner's historical unset fallback remains Claude; MCP's separate composition-root policy may inject a discovered backend-only defaultModel before the runner sees an otherwise omitted model. "claude"/"codex"/"opencode"/"pi" are reserved. A custom backend speaks the repo's published generic dialect: schema IN as turn-level _meta.outputSchema (plain JSON Schema, not OpenAI-strict), optionally a client-hosted StructuredOutput MCP tool when HTTP MCP is negotiated, and result OUT as captured tool args or final-text JSON — with the client-side validate/re-prompt ladder (§6) as the repair path for agents that ignore the schema channel.
  • Script-declared backends (meta.backendsExecOptions.scriptBackendsRunOptions.backends): a script can declare the backends it needs, making workflows self-contained (and letting agent-authored workflows bring their own ACP servers). This crosses a TRUST BOUNDARY — a spawn config is arbitrary code execution — so the layering is secure-by-default at every seam: the ENGINE parses/validates meta.backends but never acts on it; only a COMPOSITION ROOT that obtained approval threads it (SDK: allowScriptBackends true/callback, throwing on unapproved declarations; MCP server: an elicitation per unique spawn config for capable clients — approvals session-sticky, an elicitation failure is a DENY, and non-eliciting clients get a tool error naming the AGENTPRISM_ALLOW_SCRIPT_BACKENDS env opt-in). The runner re-validates run-scoped entries (reserved names rejected) and layers them UNDER the host registry — host names win. The pool keys connections by Backend.poolKey (id + spawn-config hash for custom backends) so two runs declaring the same NAME with different COMMANDS never share a process, and the one-time initialize handshake has a deadline (AGENTPRISM_ACP_INIT_TIMEOUT_MS, default 60s) so a command that is not an ACP server fails legibly instead of hanging — fail-fast hygiene, NOT a security gate (the process has already been spawned by then).
  • Generic _meta passthrough (RunOptions.meta / RunOptions.promptMeta, script-level agent(p, { meta, promptMeta })): the protocol reserves _meta for custom extension properties, so workflows can drive any agent's extension surface without a code change here. Session/new _meta layers lowest→highest: registry sessionMeta defaults → per-call meta → backend protocol-critical keys (Claude claudeCode schema channel, Codex base/developer-instruction forwards) → the engine runId stamp. Turn _meta layers per-call promptMeta under backend-computed keys (for example Codex outputSchema). Both are ADDITIVE run inputs — like mcpServers, they never enter hashAgentCall, so resume keys are stable across meta changes.

6. Structured output (the crux)

Claude and Codex use agent-specific schema channels; Pi and OpenCode use the standard injected MCP tool. ACP core models no structured-result field, so the runner owns negotiation, capture, validation, and common fallback.

6.1 ACP core (@agentclientprotocol/sdk@1.4.0) — no native structured output

Verified by exhaustive grep (zero matches for outputSchema|structuredContent|json_schema|…).

// dist/schema/types.gen.d.ts:5017
export type PromptRequest = {
  sessionId: SessionId;
  prompt: Array<ContentBlock>;
  _meta?: { [key: string]: unknown } | null;   // the ONLY extension point
};
// :2943  PromptResponse = { stopReason, usage?, _meta }   — no result payload
// :213   ToolCallContent = Content | Diff | Terminal      — no structuredContent

6.2 Claude — @agentclientprotocol/claude-agent-acp@0.75.1@anthropic-ai/claude-agent-sdk@0.3.265

Supported, session-scoped, via the _meta.claudeCode vendor extension.

(a) Set the schema — IN. The SDK's Options.outputFormat is the native lever:

// claude-agent-sdk 0.3.265  sdk.d.ts:1821
/** Output format configuration for structured responses.
 *  When specified, the agent will return structured data matching the schema. */
outputFormat?: OutputFormat;
// :2290  OutputFormat = JsonSchemaOutputFormat
// :968   JsonSchemaOutputFormat = { type: 'json_schema'; schema: Record<string, unknown> }
// :2292  OutputFormatType = 'json_schema'

The adapter spreads the client-supplied options straight into the SDK query, so a client sets it via _meta.claudeCode.options.outputFormat at session/new:

// claude-agent-acp  src/acp-agent.ts:7003
const userProvidedOptions = sessionMeta?.claudeCode?.options;   // = params._meta.claudeCode.options
// :7123
const options: Options = {
  systemPrompt,
  settingSources: ["user", "project", "local"],
  ...(thinking !== undefined && { thinking }),
  ...userProvidedOptions,   // ← :7127  carries outputFormat straight into the SDK query
  // ACP-managed overrides AFTER the spread (cwd, mcpServers, permissionMode, tools,
  // canUseTool, hooks, env, …) do NOT touch outputFormat
};

Source (claude-agent-acp): acp-agent.ts:7003, :7123, :7127.

Client session/new payload:

{
  "cwd": "/abs/path/to/worktree",
  "_meta": {
    "claudeCode": {
      "options": {
        "outputFormat": { "type": "json_schema", "schema": { /* your JSON Schema */ } }
      },
      "emitRawSDKMessages": true          // required to READ the result (see (c))
    }
  }
}

(b) Constraint + retry — built in. The SDK validates the final message against the schema and retries; on exhaustion it ends with a terminal subtype:

// claude-agent-sdk 0.3.265  sdk.d.ts:4983  (SDKResultError.subtype)
'error_during_execution' | 'error_max_turns' | 'error_max_budget_usd'
  | 'error_max_structured_output_retries'

The adapter already handles that subtype (src/acp-agent.ts:4553, mapped to an internal error / max_turn_requests stop reason).

(c) Read the result — OUT (the one rough edge). The parsed object lands in:

// claude-agent-sdk 0.3.265  sdk.d.ts:5032  (SDKResultSuccess)
structured_output?: unknown;

…but ACP PromptResponse only carries { stopReason, usage }, and the adapter does not give structured_output a first-class ACP field. You read it by opting into raw SDK messages:

// claude-agent-acp  src/acp-agent.ts:736 (flag), :7492 (wired), :3269 (forwarded)
if (session.emitRawSDKMessages && shouldEmitRawMessage(session.emitRawSDKMessages, message)) {
  await this.client.extNotification("_claude/sdkMessage", {
    sessionId: params.sessionId,
    message: message as Record<string, unknown>,
  });
}

Source (claude-agent-acp): acp-agent.ts:736 (flag), :7492 (wired), :3269 (forwarded).

So: set _meta.claudeCode.emitRawSDKMessages = true, then read structured_output off the _claude/sdkMessage notification carrying the type:"result", subtype:"success" message.

Scope: session-scopedoutputFormat is read at session/new; prompt() (src/acp-agent.ts:2189) reads no per-turn schema. With the engine's one-session-acquisition-per-occurrence model this is a non-issue: ordinary occurrences acquire a new session, while a continued occurrence reopens the exact session whose original turn already carried that schema.

6.3 Codex — @automatalabs/codex-acp (Codex App Server)

The Codex App Server natively enforces outputSchema AND the shipped binary honors it — but the stock codex-acp adapter never forwards a client schema, so Codex structured output needs a ~1-line adapter forward. We ship that forward in the published @automatalabs/codex-acp fork. (Verified end-to-end below.)

One field-verified nuance: although the parameter is documented as constraining the final assistant message, Codex applies the Responses-API constraint to every sampled assistant message in the turn — intermediate progress messages between tool calls come back schema-shaped too. CodexBackend therefore extracts the structured result from the turn's final assistant message only (StructuredSource.finalMessageText(), segmented at tool/thought/plan boundaries), never by scanning the whole turn's concatenated text — a first-JSON scan over the turn would return a progress object instead of the result.

Protocol declares it (turn-level outputSchema):

// codex-acp  src/app-server/v2/TurnStartParams.ts:43-46  — the LIVE path is v2 `turn/start`
/** Optional JSON Schema used to constrain the final assistant message for this turn. */
outputSchema?: JsonValue | null;
// src/app-server/SendUserTurnParams.ts (v1) is DEAD CODE — the server speaks v2 turn/start only

Source (codex-acp): TurnStartParams.ts:43-46. These TS types are generated from the codex binary (codex app-server generate-ts).

The shipped binary honors it. codex-acp currently ships @openai/codex@^0.142.5. The forward was source-verified at tag rust-v0.142.4 (SHA d0fd966) and remains covered end-to-end: the App Server threads turn/start.outputSchema all the way into the OpenAI Responses API as a strict structured-output constraint:

turn/start.output_schema            app-server-protocol/.../v2/turn.rs:143
  → final_output_json_schema        app-server/.../turn_processor.rs:523   (the handler wires it in)
  → turn_context.final_output_json_schema   core/.../session/turn_context.rs:780
  → prompt.output_schema            core/.../session/turn.rs:1109
  → Responses API (strict)          core/.../client.rs:818-819  (&prompt.output_schema, _strict)

Source (openai/codex @ rust-v0.142.4): turn.rs:143, turn_processor.rs:523, turn_context.rs:780, turn.rs:1109, client.rs:818-819.

The gap + the forward. The stock adapter's sendPrompt() builds the runTurn({…}) call but never sets outputSchema. The fork forwards it from the prompt's _meta (the adapter already reads request._meta nearby) — a ~1-line change in packages/codex-acp/src/CodexAcpClient.ts:

// inside sendPrompt() → the runTurn({ ... }) call
outputSchema: (request._meta as any)?.["outputSchema"] ?? null,

runTurn → turnStart → sendRequest({ method: "turn/start", params }) passes it through verbatim; TurnStartParams.outputSchema already exists, so it's type-clean.

Delivery. The forward is baked into the workspace package @automatalabs/codex-acp — its build compiles the change into dist/index.js, so npm consumers get it directly (unlike a pnpm patchedDependencies transform, which is a workspace-root install step that never travels in a published tarball). acp-agents consumes it as workspace:* (published as an exact version by pnpm), so the forward is present on a clean checkout with no vendoring and no postinstall hook. CodexBackend spawns the resolved package main (require.resolve("@automatalabs/codex-acp")) under the current node.

Output needs no patch. outputSchema constrains the FINAL assistant message, which already flows back over the normal session/update agent-message stream — CodexBackend reads the final text and JSON.parses it. (Cleaner than Claude, which needs emitRawSDKMessages.)

Strict-mode caveat. output_schema_strict is true for normal turns, so the schema is sent in strict mode — CodexBackend must normalize the engine's JSON Schema to OpenAI strict rules (every property required, additionalProperties:false, supported types/keywords only) before sending. Keep the validate→re-prompt guard regardless.

Tool-level structured output also exists, but it's the wrong lever for a client (see §6.5):

// src/app-server/Tool.ts:9            outputSchema?: JsonValue   (on the tool definition)
// src/app-server/ToolOutputSchema.ts:6-10   { properties?, required?: string[], type: string }
// src/app-server/CallToolResult.ts:9          structuredContent?: JsonValue
// src/app-server/v2/McpToolCallResult.ts:6    structuredContent: JsonValue | null
// src/app-server/v2/McpServerToolCallResponse.ts:6  structuredContent?: JsonValue

Source (codex-acp): Tool.ts:9, ToolOutputSchema.ts:6-10, CallToolResult.ts:9, McpToolCallResult.ts:6, McpServerToolCallResponse.ts:6.

6.4 Pi — @automatalabs/pi-acp

PiBackend enables prompt embedding and client-hosted StructuredOutput injection. Pi-acp advertises HTTP MCP, discovers the runner's tool through its production full-client bridge, and presents it as mcp__structured_output__StructuredOutput. The runner validates captured arguments. With no valid capture, the common prompt-embedded schema and validated last-text recovery ladder applies. Pi has no private capability namespace or backend-native structured hook.

6.5 Why tool-level structured output is the wrong lever for a client

For both backends, a tool's structuredContent flows back to the model, not to your orchestrator. The SDK's in-process tool() helper exposes no outputSchema (claude-agent-sdk sdk.d.ts:6506, :3683). The only client-capturable tool signal is the tool's inputSchema (the args the model passes when it calls a client-hosted tool). So schema-conformance for a subagent result should use the turn/session output format, not a tool.

6.6 Client-hosted StructuredOutput MCP tool for custom ACP backends

Pi, OpenCode, and custom ACP backends without an agent-specific result channel can inject a runner-hosted MCP server through session/new.mcpServers when all gates hold: RunOptions.schema is present, the custom backend's registry config did not set structuredOutputTool:false (default true; OpenCode always opts in), and the negotiated initialize response strictly advertises mcpCapabilities.http === true. Missing or false HTTP MCP support falls back to the existing prompt-embedded schema and final-text JSON path.

The injected server uses Streamable HTTP on 127.0.0.1 with an unguessable token path and is runner-scoped, lazy, and closed on runner disposal. Each run registers its own token slot and appends one MCP server after user-provided entries, named structured_output or the next free suffix. The server exposes exactly one tool, StructuredOutput; agents may display it namespaced by server name. Its inputSchema is the user's plain JSON Schema, and a valid call captures the arguments. Invalid calls return a tool error with TypeBox validation details and do not clobber a prior valid capture. The resolution ladder is captured tool args → native/final-text parse → prose JSON extraction → repair prompt.

Injected runs use process-exclusive elastic pooling rather than a per-connection FIFO. Selection synchronously reserves a process with no other injected run; if every usable process is reserved, the pool starts another process even past its configured size. The reservation remains held until the owning session.release() completes, which prevents process-global MCP registries such as OpenCode's from exposing a sibling injected registration while allowing Pi, OpenCode, and custom injecting backends to overlap uniformly. Non-injected sessions keep the normal multiplexing policy and may share a process with an injected run. Released surplus processes remain warm for an idle keep-alive and are then reaped back to size; pool disposal and force-kill retain them throughout that lifecycle.

6.7 What this means for us

  • Keep native channels primary where they exist. Claude constrains out-of-the-box via _meta; Codex constrains after the adapter patch (§6.3); Pi and OpenCode use the client-hosted MCP tool plus the common prompt/validated-last-text fallback (§6.4/§6.6).
  • Keep resolveStructuredOutput's validate-then-re-prompt (src/agent.ts:113) as a guard, because structured_output is typed unknown and the constraint can still fail (error_max_structured_output_retries) and tool arguments are still untrusted. Ladder: captured tool args → native constraint/final-text parse → client-side validate → re-prompt on failure.
  • Abstract behind a per-backend adapter — the three native paths genuinely differ (Claude: session-scoped vendor _meta.claudeCode + emitRawSDKMessages, read off the raw message stream; Codex: per-turn outputSchema forwarded by the forked adapter, read off the normal message stream, with strict-schema normalization; Pi/OpenCode: standard client-hosted HTTP MCP capture with common fallback). Same run(prompt, { schema }) interface above them.

7. The leaf interface: AcpAgentRunner.run(prompt, opts)

This lives in the acp-agents module (§2) and is usable on its own — no workflow-engine, no mcp-server. It drives claude-agent-acp, the @automatalabs/codex-acp fork (patch baked into its dist, §2, §6.3), opencode acp, and pi-acp as ACP server subprocesses. It implements the AgentRunner seam the engine injects against (today Pick<WorkflowAgent, "run">, src/workflow.ts:59). One method, backend strategies behind it:

run(prompt, { schema?, model?, tier?, cwd?, signal?, toolNames?, … }) →
  1. pick backend (Claude vs Codex vs OpenCode vs Pi/custom) by agentType/model
  2. acquire a pooled process:
       injected schema lane → synchronously reserve one process exclusively from injected peers,
                              elastically spawning past size when all are reserved
       non-injected lane    → idle → grow to size → multiplex least-loaded
  3. if continueFromSession is eligible:
       session/resume({ sessionId }) else session/load({ sessionId })
       on reopen failure → clean up and session/new({ cwd }) with the ORIGINAL prompt
     otherwise session/new({ cwd: worktree?.cwd }) // §5.3 worktree isolation
  4. select model via session config option         // §5.4
  5. apply schema:
       Claude → already set in session/new _meta.claudeCode.options.outputFormat (+ emitRawSDKMessages)
       Codex  → outputSchema on the turn params
       Pi/OpenCode → append a client-hosted HTTP StructuredOutput MCP tool and embed the schema
       custom → generic outputSchema plus optional StructuredOutput MCP tool
  6. session/prompt(continued ? CONTINUATION_INSTRUCTION : prompt); drain session/update:
       • agent_message_chunk → assistant text
       • tool_call / request_permission → exact option selection or live host wait (§5.5)
       • usage_update → token accounting (§5.6)
  7. on stopReason:
       schema set → extract structured result
                     (Claude: structured_output off _claude/sdkMessage; Codex: final text;
                      Pi/OpenCode/custom: HTTP tool capture, then the common final-text fallback),
                     then VALIDATE; re-prompt on failure (guard)
       no schema   → final assistant text (empty ⇒ recoverable retry)
  8. release the session; only after release completes, return any injected reservation
  9. signal.aborted → session/cancel (§5.7)

onSessionOpen fires exactly once for the acquisition that wins. Usage/auth pause failures release with keepOpen:true so the recorded session survives; a successful session/load snapshots usage after transcript replay and reports only the continuation-turn delta. Continuation attempt provenance is reported before post-open setup, and the engine turns it into a guarded audit notice plus a replay-neutral journal marker.

Everything above this method — parallel/pipeline, the journal, phases, and resume — is the unchanged engine.


8. Isolation mode — why replay fails closed

Isolation mode is a backend-neutral engine primitive exposed through @automatalabs/workflow-engine and ACP-defaulted by @automatalabs/workflows. It re-executes the recorded script with recorded args, serves every non-target terminal call from the manifest/journal, and delegates selected targets live. The implementation deliberately admits fewer recordings than a best-effort replay system: every accepted comparison must have a provable call correspondence and execution context.

Call paths and their honest boundary. Each agent() and checkpoint() captures a normalized V8 call-site path alongside its deterministic hash. The VM compile filename is sanitized, async frames are excluded, and the path/input format versions plus the full Node and V8 versions are persisted. A path is stable only inside that recorded runtime boundary; isolation preflight requires exact format/Node/V8 equality rather than claiming portability across engines. The target's separate input fingerprint covers behavior-shaping runner inputs omitted from the journal hash, and its resolved cwd is compared immediately before delegation. Git HEAD plus dirty-content identity (or an explicit non-Git environment key) gates filesystem comparability before any candidate spend.

Guarded terminal settlement. A logical call decides its terminal state once. The engine-owned manifest append happens first, followed by journal and terminal observers, each guarded separately. A throwing observer is logged and swallowed: it cannot retry, fail, or duplicate the call. The same settlement seal drops late usage, model, session, history, provenance, and manager events from timed out or floated work, so the manifest and sealed agentEnd event are the target report's only authority.

Record-time freezing. Agent results, checkpoint replies, usage/history/model/session telemetry, errors, arguments, journal entries, events, and persisted rows cross a strict-JSON snapshot boundary when captured and are deep-frozen. The VM receives an independent clone of strict-JSON args. This prevents caller, listener, or script mutation after the fact from changing identity, replay values, or persistence; values that cannot be represented faithfully are either rejected at the relevant result/reply boundary or explicitly marked unusable as a baseline for permissive input paths.

Serving algebra and the fatal latch. Target calls require exact (path, hash) identity plus an equal input fingerprint and cwd, then delegate with only the optional model rewritten. Non-targets serve by exact (kind, path, hash) identity; after a target changes downstream content, a row may serve by path only when that path has exactly one recorded candidate. Repeated identities, multi-candidate paths, new calls, nested calls, dependent targets, and target-context drift latch one typed fatal divergence. Once latched, every later arrival rethrows before serving or spending. This strict posture is what makes "held fixed" meaningful: propagation mode remains the correct tool for scripts that cannot prove isolated correspondence.

Settlement order and gate freedom. Current recordings retain dense settlement ordinals for deterministic ordering, but token usage is observational telemetry rather than an execution or replay gate. Historical budget fields are ignored on read and omitted from new artifacts. Baselines at the agent-limit boundary, with abort residue, or without complete required limits are refused before provider use. Concurrency reproduces the scheduling envelope, not timing; timeout and retry settings affect only the live target because served calls resolve at the replay seam.

Isolation artifacts carry an initial run-level executionMode marker, per-call provenance, and a persisted ReplayReport; they cannot be resumed or selected as later baselines. See api.md for the public surface and complete refusal vocabulary.


9. Durable run events — two authorities, one ordered observation stream

The typed live RunEvent contract and the append-only <runId>.events.jsonl sidecar make manager observations consumable after the initiating process/request is gone. The sidecar is deliberately an observability projection, not another workflow recovery format and not an ACP transcript.

Append before watermark. A publication mutates its managed state, projects and appends event sequence N, then advances PersistedRunState.eventSeq and performs any required snapshot save. That ordering prevents a concurrent reader from seeing a snapshot that claims an event which does not yet exist. It also defines snapshot-plus-tail consumption cleanly: load a snapshot at watermark N, pin its eventStreamId, then consume records strictly after N. A crash may leave a valid log ahead of a stale watermark, which is safe catch-up; a snapshot ahead of the valid log is an integrity failure and readers fail closed rather than inventing observations.

Generation pinning survives run-ID reuse. A new journaling run mints a random 32-character lowercase hexadecimal eventStreamId; resume retains it, while delete followed by recreation of the same runId mints another. Every record repeats the generation and a watcher pins the one it validated at construction. A reader racing lease-protected delete/recreate therefore reports a stream mismatch instead of stitching the old prefix to the replacement suffix. Sequence alone would not distinguish those two histories.

Snapshot and log have separate authority. The snapshot/journal is authoritative for resumable state, full agent results, session re-attach records, and the current run status. The event log is authoritative for the order and greatest valid sequence of bounded observations. A corrupt or incomplete event sidecar never blocks snapshot-based workflow recovery, but readEvents() and watchEvents() fail closed because they cannot honestly promise a gap-free tail. Conversely, the redacted event projection is never replayed as an agent result. Inline child workflows share the root sidecar with their own scope; they intentionally do not gain another snapshot or resume journal.

ACP transcript traffic stays relay-only. agentEvent and agentHistory are typed so live hosts can render or capture them, but message/thought chunks, tool payloads, permission inputs, raw vendor messages, and session traffic are high-frequency and content-heavy. Persisting them by default would quietly choose security, consent, volume, and retention policy for every embedder. The v1 sidecar therefore admits bounded lifecycle, call, usage, and authored-log observations only; a host that needs transcripts owns a separate store and policy.

Writer simplicity is intentional. Exactly one lease-owning writer may mutate a run. For each persisted event, the default writer performs one open/write/verify/close syscall sequence: open the sidecar in append mode, issue one synchronous write for the complete LF-terminated record, verify the byte count, and close before returning. There is no user-space buffer or per-event fsync/fdatasync. This is a deliberate simplicity-over-throughput choice sized for the lifecycle-only default persistence policy. Any future opt-in for high-frequency events must revisit batching, backpressure, durability, and failure boundaries rather than inherit this path unexamined.

Deletion follows the same ownership rule. The manager holds or reacquires the run lease, removes the sidecar before delegating snapshot deletion, removes the default lock last, and releases in finally. Detached callbacks lose durable publication authority when deletion wins, so they cannot resurrect a snapshot or sidecar after the run was removed.


10. Caveats / version pins / things to design around

  • Version-specific (Claude): the structured-output path is verified for claude-agent-acp@0.57.0 / @anthropic-ai/claude-agent-sdk@0.3.202. The _meta.claudeCode channel and emitRawSDKMessages are vendor extensions, not standard ACP — pin versions and isolate behind the backend adapter.
  • emitRawSDKMessages is mandatory to read structured_output on the Claude path; filter the raw stream to just the type:"result" message.
  • Schema scope (Claude) is per-session → spin up a fresh ACP session per agent() call (or per distinct schema). The engine already does one session per call.
  • Codex structured output needs a codex-acp forward: the shipped binary (@openai/codex@0.153.2; the field was source-verified at rust-v0.142.4) honors turn/start.outputSchema, but the stock adapter never forwards it — the ~1-line _metarunTurn forward (§6.3) is baked into the workspace package @automatalabs/codex-acp's dist, which acp-agents consumes as workspace:* — published as an exact version, so it travels to npm consumers (unlike a pnpm patchedDependencies transform). CodexBackend also normalizes schemas to OpenAI strict rules. Output rides the normal message stream (no emitRawSDKMessages needed).
  • MCP turn semantics: no "deliver result into a later turn" — run the workflow tool synchronously with progress notifications or admit it in the background; continuation keeps the exact input run ID.
  • Cross-provider routing = choose the server. Per-call model tiering works within a provider via config options; switching providers means routing to a different ACP server.
  • OpenCode is not bundled. OpenCodeBackend resolves AGENTPRISM_OPENCODE_ACP_CMD, then a host-installed opencode-ai launcher, then opencode from PATH. The package is deliberately not a dependency because its platform binaries are large.
  • Pi is bundled as an exact pin. PiBackend resolves AGENTPRISM_PI_ACP_CMD and its optional args first, then the installed @automatalabs/pi-acp dist/index.js under process.execPath, then npx -y @automatalabs/pi-acp. Authentication is surfaced as five provider env-key methods plus Pi's ambient ~/.pi/agent/auth.json store.
  • Concurrency is bound by provider API rate limits + per-session memory, not the protocol; intra-session prompts serialize.
  • Per-turn token-usage breakdown on PromptResponse is still a Draft ACP RFD (servers emit it ahead of stabilization). codex-acp reports tokens/quota but no dollar cost.
  • codex-acp config options are our codex model/tier/effort routing channel (the model, reasoning_effort, and Fast-mode SessionConfigOptions, switched via session/set_config_option). codex-acp disables them only when the connecting client is IntelliJ/JetBrains and its version starts with 2026.1 (isJetBrains2026_1ClientisSessionConfigEnabled in CodexAcpServer.ts). Since acp-agents controls the clientInfo it sends at initialize, just don't identify as JetBrains/IntelliJ 2026.1 and config options stay enabled — so the gate never affects us. It's independent of structured output, which rides the turn, not config options.

11. References

Packages (verified versions, 2026-09-02):

ACP spec:

Reused engine (lifted from pi-dynamic-workflows):