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 Pisrc/…citations throughout are provenance for the lifted engine, not paths in this repo.
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
workflowtool is exposed by a stdio MCP server (instead of a Pi extension'sregisterTool). 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-acpfor Claude,codex-acpfor Codex,opencode acpfor OpenCode,pi-acpfor pi) over the Agent Client Protocol (instead of Pi's in-processcreateAgentSession).
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 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.
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.
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();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 AgentRunner — not 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).
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.
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.
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-leveloutputSchemaforwarding, pulled in byacp-agents), and@automatalabs/repl-engine(the published REPL orchestrator QuickJS-in-WASM VM layer; itsreplMCP tool is registered inmcp-server— roadmap phase E, implemented). The dependency direction and theAgentRunnerseam are the contract.
| Concern | Source (pi-dynamic-workflows) |
Notes |
|---|---|---|
| Script execution | src/workflow.ts — runWorkflow, 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.ts — git 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) |
| 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) |
All versions below were re-verified from the installed workspace dependency graph on 2026-09-02.
@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 moderncreateMcpHandler, and in-process stdio usesserveStdio, so one tool implementation serves legacy clients and2026-07-28clients. ACP-side embedded MCP clients remain independently on@modelcontextprotocol/sdk@1.30.0across process/wire boundaries.@modelcontextprotocol/ext-apps@1.7.5remains 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
-
@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.265through the workspace override — the adapter itself exact-pins0.3.257, so the override lifts the runtime to npmlatest. 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 defaulthostpolicy, and the structured-output, terminal-result, and usage surfaces integrated below remain compatible. Adapter 0.74 adds opt-in subscription restrictions under--hide-claude-authand 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-scopedauthStatusnotification 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-acpNaming 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-acpthat bakes theoutputSchemaforward (§6.3) into its shipped dist; it is the packageacp-agentsexact-pins and consumes. Ref:packages/codex-acp(workspace fork, full imported history) · https://github.com/agentclientprotocol/codex-acp (upstream)The Rust
zed-industries/codex-acpis the deprecated predecessor; development moved to theagentclientprotocol/codex-acpTypeScript 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-agentsexact-pins and spawns it as the first-classpibackend.
acorn— parse the workflow script + extract/validate themetaliteral.node:vm,node:crypto— script realm + journal hashing.- A JSON-Schema lib (
typeboxtoday, orzod— noteclaude-agent-acpitself useszod ^4) for theagent({schema})contract and client-side validation. git— worktree isolation (git worktree add/remove).
The workflow tool grew from Pi's single-form input
(src/workflow-tool.ts:61) into a strict action union — config, 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
scriptorscriptPath(a raw JS string with no Markdown fences, or an absolute server-side path read once at admission; the first statement must beexport const meta = { name, description, phases? }), plusprojectDir— 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 neitheragent()norcheckpoint(). Other run fields:args,maxAgents(default 1000),concurrency(clamped to 16),agentRetries(clamped to ≤3), andbackground. - Resume — supply the exact
runIdplus optional runtime bounds, a strict-JSONcheckpointRepliesanswer, andbackground. 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
runIdand never execution fields.statusaccepts the existinglastN/labelGlob/logLinesprojection bounds and always returns one immediate snapshot; it has no wait control or wait metadata.resultreads the authoritative completed value from persistence and returns at most 16,384 exact UTF-8 JSON bytes plusendOffset/hasMore; boundaries never split a code point and interior offsets fail closed. Status projects live ACP permission requests;permissions-responsenames 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:trueexplicitly authorizes terminating a superseded owner after identity revalidation.stopwithcallIndexinstead synchronously routes cancellation to one live in-flight agent (its slot settles tonullwithAGENT_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/agentRetriesas plain numbers in the tool schema — not Zod.max(), which rejects out-of-range input withInvalidParams. The engine already clamps them (normalizeConcurrency→MAX_CONCURRENCY16,normalizeAgentRetries→MAX_AGENT_RETRIES3), 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.
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
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.
- Stop reasons (on the
session/promptresult):end_turn,max_tokens,max_turn_requests,refusal,cancelled. Ref: https://agentclientprotocol.com/protocol/v1/prompt-turn
A single agent-server process hosts many concurrent sessions (each keyed by sessionId).
Both servers implement a real sessionId → session map:
claude-agent-acp:sessionsmap; 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
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
The client picks the model per session (switchable per turn) from the catalog the agent advertises:
- Mechanism: agent returns
configOptions(in thesession/newresult, updatable later) including{ id:"model", category:"model", type:"select", currentValue, options[] }; client switches withsession/set_config_option{ configId:"model", value }. Categories also includemodel_config(context/speed/quality) andthought_level. Refs: https://agentclientprotocol.com/protocol/v1/session-config-options · https://agentclientprotocol.com/rfds/model-config-category claude-agent-acp:modeloption →query.setModel(...); accepts aliases (opus/sonnet); initial precedenceANTHROPIC_MODELenv →settings.model→ SDK default. The client selects per-session models throughsession/set_config_option.codex-acp: model encoded as"model[effort]"(e.g.gpt-5.2[high]) + separatereasoning_effortselect; switch viasession/set_config_option(the wire method;setConfigOptionis just the ACP SDK's JS accessor for it).opencode acp: model values are OpenCode catalog ids likeprovider/model(for examplezai/glm-5.2) under amodelselect. The public routing prefix is stripped at the first slash:opencode/zai/glm-5.2[high]routes to the OpenCode backend and selectszai/glm-5.2[high]verbatim.pi-acp: the publicpi/prefix is stripped once and the remainder is sent verbatim to Pi'smodelselect. Pi then interprets<provider>/<model-id>by splitting the first slash, sopi/openrouter/vendor/model-idselects provideropenrouterand model idvendor/model-id.
The catalog belongs to the server (Claude models on
claude-agent-acp, Codex models oncodex-acp, OpenCode models onopencode acp, Pi models onpi-acp), so cross-provider routing = choosing which server; within a provider, per-call tiering works. This is what the engine'stier: small/medium/bigmaps onto.
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
usage_updatesession/updatenotification:used+size(token counts), optionalcost { amount, currency }.- Per-turn
usageobject on thesession/promptresponse (still a Draft RFD, but both servers already emit it). claude-agent-acpreports tokens + dollar cost (cost = total_cost_usd, USD); responseusage { inputTokens, outputTokens, cachedReadTokens, cachedWriteTokens, totalTokens }.codex-acpreports tokens/quota only (no dollar cost).- OpenCode reports per-turn
PromptResponse.usageplus cumulativeusage_updatecost/context; the existing accumulator combines the latest cumulative cost with the per-turn token split. - Pi reports per-turn
PromptResponse.usageand can emit cumulativeusage_updatenotifications; 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
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
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: ACPmcpServers→ SDKMcpServerConfig, merged with user options (src/acp-agent.ts:7003-7141).codex-acp: supports stdio + http (rejectsacp/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
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 viaAGENTPRISM_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_BACKENDmay name a registry entry. The runner's historical unset fallback remains Claude; MCP's separate composition-root policy may inject a discovered backend-onlydefaultModelbefore 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.backends→ExecOptions.scriptBackends→RunOptions.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/validatesmeta.backendsbut never acts on it; only a COMPOSITION ROOT that obtained approval threads it (SDK:allowScriptBackendstrue/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 theAGENTPRISM_ALLOW_SCRIPT_BACKENDSenv 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 byBackend.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-timeinitializehandshake 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
_metapassthrough (RunOptions.meta/RunOptions.promptMeta, script-levelagent(p, { meta, promptMeta })): the protocol reserves_metafor custom extension properties, so workflows can drive any agent's extension surface without a code change here. Session/new_metalayers lowest→highest: registrysessionMetadefaults → per-callmeta→ backend protocol-critical keys (ClaudeclaudeCodeschema channel, Codex base/developer-instruction forwards) → the enginerunIdstamp. Turn_metalayers per-callpromptMetaunder backend-computed keys (for example CodexoutputSchema). Both are ADDITIVE run inputs — likemcpServers, they never enterhashAgentCall, so resume keys are stable across meta changes.
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.
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 structuredContentSupported, 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:
(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-scoped — outputFormat 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.
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 onlySource (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?: JsonValueSource (codex-acp):
Tool.ts:9,ToolOutputSchema.ts:6-10,CallToolResult.ts:9,McpToolCallResult.ts:6,McpServerToolCallResponse.ts:6.
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.
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.
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.
- 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, becausestructured_outputis typedunknownand 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-turnoutputSchemaforwarded 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). Samerun(prompt, { schema })interface above them.
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.
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.
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.
- 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.claudeCodechannel andemitRawSDKMessagesare vendor extensions, not standard ACP — pin versions and isolate behind the backend adapter. emitRawSDKMessagesis mandatory to readstructured_outputon the Claude path; filter the raw stream to just thetype:"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 atrust-v0.142.4) honorsturn/start.outputSchema, but the stock adapter never forwards it — the ~1-line_meta→runTurnforward (§6.3) is baked into the workspace package@automatalabs/codex-acp's dist, whichacp-agentsconsumes asworkspace:*— published as an exact version, so it travels to npm consumers (unlike a pnpmpatchedDependenciestransform).CodexBackendalso normalizes schemas to OpenAI strict rules. Output rides the normal message stream (noemitRawSDKMessagesneeded). - MCP turn semantics: no "deliver result into a later turn" — run the
workflowtool 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.
OpenCodeBackendresolvesAGENTPRISM_OPENCODE_ACP_CMD, then a host-installedopencode-ailauncher, thenopencodefrom PATH. The package is deliberately not a dependency because its platform binaries are large. - Pi is bundled as an exact pin.
PiBackendresolvesAGENTPRISM_PI_ACP_CMDand its optional args first, then the installed@automatalabs/pi-acpdist/index.jsunderprocess.execPath, thennpx -y @automatalabs/pi-acp. Authentication is surfaced as five provider env-key methods plus Pi's ambient~/.pi/agent/auth.jsonstore. - Concurrency is bound by provider API rate limits + per-session memory, not the protocol; intra-session prompts serialize.
- Per-turn token-usage breakdown on
PromptResponseis still a Draft ACP RFD (servers emit it ahead of stabilization).codex-acpreports tokens/quota but no dollar cost. codex-acpconfig options are our codex model/tier/effort routing channel (the model,reasoning_effort, and Fast-modeSessionConfigOptions, switched viasession/set_config_option). codex-acp disables them only when the connecting client is IntelliJ/JetBrains and itsversionstarts with2026.1(isJetBrains2026_1Client→isSessionConfigEnabledinCodexAcpServer.ts). Sinceacp-agentscontrols theclientInfoit sends atinitialize, just don't identify as JetBrains/IntelliJ2026.1and config options stay enabled — so the gate never affects us. It's independent of structured output, which rides the turn, not config options.
Packages (verified versions, 2026-09-02):
@modelcontextprotocol/{client,server,node}@2.0.0(dual-era MCP shell);@modelcontextprotocol/sdk@1.30.0remains on separate ACP embedded-client boundaries — https://github.com/modelcontextprotocol/typescript-sdk@agentclientprotocol/sdk@1.4.0— https://github.com/agentclientprotocol@agentclientprotocol/claude-agent-acp@0.75.1(workspace override resolves@anthropic-ai/claude-agent-sdk@0.3.265; adapter pin0.3.257) — https://github.com/agentclientprotocol/claude-agent-acp@automatalabs/codex-acp(workspace fork of@agentclientprotocol/codex-acpatpackages/codex-acp, patch baked into dist) — upstream: https://github.com/agentclientprotocol/codex-acp@automatalabs/pi-acp(Pi ACP server; workspace-lockstep built-in dependency, exact version stamped at publish) —packages/pi-acp- OpenCode (
opencode acp) — https://opencode.ai
ACP spec:
- Overview / transports — https://agentclientprotocol.com/protocol/v1/transports
- Initialization — https://agentclientprotocol.com/protocol/v1/initialization
- Session setup (cwd, mcpServers) — https://agentclientprotocol.com/protocol/v1/session-setup
- Prompt turn / stop reasons / usage — https://agentclientprotocol.com/protocol/v1/prompt-turn
- Tool calls / permissions — https://agentclientprotocol.com/protocol/v1/tool-calls
- Session config options (model) — https://agentclientprotocol.com/protocol/v1/session-config-options
- Cancellation — https://agentclientprotocol.com/protocol/v1/cancellation
- Extensibility (
_meta,_-methods) — https://agentclientprotocol.com/protocol/v1/extensibility
Reused engine (lifted from pi-dynamic-workflows):
src/workflow.ts— engine, vm, determinism, journal,agent/parallel/pipelinesrc/workflow-manager.ts— run lifecycle, persistence, resumesrc/run-persistence.ts— disk journal + leasessrc/worktree.ts— git-worktree isolationsrc/agent.ts— the leaf being replaced;resolveStructuredOutput/extractValidatedreused as the schema guard
{ "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)) } } }