NOETIC
Framework

ACP Agent Steps

Run any coding agent that speaks the Agent Client Protocol — Claude Code, Codex, Gemini CLI — as a Noetic step.

Quick Example

An ACP step delegates a turn to an external coding agent, exactly the way callModel delegates a turn to a language model. One builder covers every agent; the agent itself is an adapter you pass in:

import { AgentHarness, type ContextData, step } from '@noetic-tools/core';
import { claudeCode } from '@noetic-tools/acp';

const review = step.acpAgent<ContextData, string, string>({
  id: 'review',
  agent: claudeCode(),
  prompt: 'Review the working tree and summarize the risks',
  permissions: { default: 'deny', allow: [{ kind: 'read' }] },
});

// `run` returns the step's output; `execute` starts a run and reports through
// the harness streams instead.
const harness = new AgentHarness({ name: 'reviewer', params: {} });
const summary = await harness.run(review, 'go', harness.createContext());

The integration is the Agent Client Protocol (ACP), a JSON-RPC 2.0 standard in which Noetic is the Client and the coding agent is the Agent.

Because every ACP agent is a uniform protocol peer, there is no vendor SDK per agent and no closed list of supported agents.

Coming from @noetic-tools/sub-harness-*? Those packages are deprecated — see Migrating from Sub-Harnesses.

Why a protocol instead of an SDK per agent

ACP inverts the usual control flow. The agent does not reach for the machine itself — it asks the client to read files, write files, and run terminals, and it asks permission before running a tool.

That means a sub-agent's file and shell access goes through Noetic's own adapters instead of around them through a vendor SDK — so a host that supplies an in-memory or virtual FsAdapter genuinely constrains what the agent can reach, and the client becomes the one place a boundary can be enforced at all. Noetic enforces one by default: see Filesystem confinement.

It also brings capabilities a hand-rolled adapter has no way to express: permission requests, plans, session modes, slash commands, MCP server passthrough, multimodal prompts, typed stop reasons, and real cancellation.

Agents

@noetic-tools/acp ships presets for the agents that speak ACP today. Each is only a launch recipe — which binary to run, with which flags:

import { claudeCode, codex, gemini, opencode, pi, customAcpAgent } from '@noetic-tools/acp';

claudeCode();                       // npx @agentclientprotocol/claude-agent-acp
codex();                            // npx @agentclientprotocol/codex-acp
gemini();                           // gemini --experimental-acp
opencode();                         // opencode acp  (native ACP)
pi();                               // npx pi-acp@0.0.32 (community adapter)

customAcpAgent({                    // any other ACP-speaking binary
  agentId: 'my-agent',
  command: 'my-acp-agent',
  args: ['--stdio'],
});

Every preset accepts command, args, and env overrides, a machine to run the agent somewhere other than this machine (see Where the agent runs), and a transport to attach to an agent someone else started.

Filesystem confinement

ACP puts boundary enforcement on the client: the agent asks for a path, and the client decides whether it may have it. Nothing in the protocol constrains what it asks for.

By default an agent reaches the session working directory and nothing else. Absolute paths outside it, .. traversal out of it, and relative paths (which the spec forbids on the wire) are all refused with a JSON-RPC invalid-params error before they reach your FsAdapter.

import { step } from '@noetic-tools/core';
import { claudeCode } from '@noetic-tools/acp';

step.acpAgent({
  id: 'review',
  agent: claudeCode(),
  prompt: 'Review the diff',
  cwd: '/srv/project',
  clientCapabilities: {
    // Reachable in addition to cwd — a monorepo sibling, a shared cache.
    additionalDirectories: ['/srv/shared-cache'],
  },
});

Set allowAnyPath: true to lift it entirely, when you genuinely want an unconfined agent.

A permissions policy does not do this job. It answers session/request_permission, which covers the agent's own tool calls. fs/read_text_file, fs/write_text_file, and terminal/* are client methods the agent invokes on you directly — an agent that simply never asks is never gated by a policy, however strict. Path confinement and clientCapabilities are the controls that bind.

Seeing what the agent touched

Every fs/* and terminal/* call the agent makes is emitted as an acp_client_activity framework event — allowed and refused alike:

import type { AgentHarness } from '@noetic-tools/core';

declare const harness: AgentHarness;

for await (const ev of harness.getFullStream()) {
  if (ev.source === 'framework' && ev.type.endsWith(':acp_client_activity')) {
    console.log(ev.data.allowed ? 'ok ' : 'REFUSED', ev.data.method, ev.data.path ?? ev.data.command);
  }
}

This is an observed record, not the agent's account of itself. A tool_call update says what the agent claims it did; this says what it actually asked the client to do — including the reaches that were turned down, which is usually the more interesting line in the log.

It covers the client methods only. Work the agent does entirely inside its own process, and anything a terminal command does after it starts, is not visible here.

What confinement does not stop

  • Symlinks inside the workspace pointing out of it. The check is lexical and does not touch the filesystem. If that matters, supply an FsAdapter that resolves real paths and re-checks.
  • What a terminal command does once running. terminal/create confines the starting cwd, but a shell can cd anywhere the host user can. The only hard boundary for shell access is withdrawing it: clientCapabilities: { terminal: false }.
  • Anything, if you pass allowAnyPath: true.

Confinement narrows what a cooperative-but-careless agent reaches. It is not a security sandbox against a hostile one — for that, run the agent in a real sandbox (container, VM) and point a transport at it.

Permissions

ACP requires the client to answer session/request_permission before the agent runs a tool. Noetic resolves it in three tiers — the first decisive one wins:

  1. The step's permissions policy. Declarative rules matched on the ACP tool kind and title. deny is evaluated before allow, so an explicit refusal always beats a broad grant.
  2. Steering. The same beforeToolCall pipeline that governs first-party tool calls, so one rule set covers both. Steering acts as a veto: only a non-allow decision is acted on, because allow is also what an empty rule set returns.
  3. onPermissionRequest. An async handler — the human-in-the-loop hatch that a synchronous predicate cannot express.

When all three abstain, the policy default applies. That default is deny: an unattended agent should not gain blanket approval by omission.

import { step } from '@noetic-tools/core';
import { claudeCode } from '@noetic-tools/acp';

declare const askAHuman: (title: string) => Promise<boolean>;

step.acpAgent({
  id: 'guarded',
  agent: claudeCode(),
  prompt: 'Fix the failing test',
  permissions: {
    default: 'deny',
    allow: [{ kind: 'read' }, { kind: 'edit' }],
    deny: [{ title: 'rm -rf' }],
    persist: true, // prefer allow_always / reject_always when offered
  },
  async onPermissionRequest(request) {
    return { decision: (await askAHuman(request.toolCall.title ?? '')) ? 'allow' : 'deny' };
  },
});

title accepts a case-insensitive substring or a RegExp. The resolved decision is translated into one of the options the agent actually offered; if none matches, the request is cancelled rather than answered with an option meaning something else.

Client capabilities

Noetic serves the agent's requests from the execution context's adapters:

ACP client methodBacked by
fs/read_text_filectx.fs, honouring the spec's 1-indexed line / limit window
fs/write_text_filectx.fs, creating parent directories
terminal/create, output, wait_for_exit, kill, releasectx.shell, with an output byte cap and per-terminal cancellation
session/request_permissionThe resolver above

Withdraw one and the agent is told the method does not exist, so it never attempts the work:

import { step } from '@noetic-tools/core';
import { claudeCode } from '@noetic-tools/acp';

step.acpAgent({
  id: 'read-only-review',
  agent: claudeCode(),
  prompt: 'Review, do not change anything',
  clientCapabilities: { writeTextFile: false, terminal: false },
});

Prompt content

prompt carries plain text. content carries the full ACP content-block array — images, audio, resource links, embedded context — appended after it:

import { step } from '@noetic-tools/core';
import { gemini } from '@noetic-tools/acp';

declare const pngBase64: string;

step.acpAgent({
  id: 'describe',
  agent: gemini(),
  prompt: 'What is wrong with this screenshot?',
  content: [{ type: 'image', data: pngBase64, mimeType: 'image/png' }],
});

The specification requires clients to restrict content to what the agent advertised during initialize, so sending an image to an agent that did not advertise image support throws AcpCapabilityError before anything reaches the wire.

An empty prompt means "use the step's runtime input as the prompt".

Modes and models

import { step } from '@noetic-tools/core';
import { claudeCode } from '@noetic-tools/acp';

step.acpAgent({
  id: 'plan-first',
  agent: claudeCode(),
  prompt: 'Design the migration',
  mode: 'plan',                     // session/set_mode before the turn
  model: 'claude-opus-4-8',         // session/set_model before the turn
});

Each is checked against what the agent advertised. Asking for a mode from an agent that reports none throws AcpCapabilityError rather than failing opaquely mid-turn.

Giving the agent your tools

Pass the same tool() definitions a callModel step takes. The agent calls them over MCP, and each call runs the same path a model's tool call does: the beforeToolCall layers, input validation, needsApproval and the harness approver, execution, the result item, its tool_call_started / tool_call_completed events, and its tool.call span. The agent gets the exact string a model would.

import { callModel, step, tool } from '@noetic-tools/core';
import { claudeCode } from '@noetic-tools/acp';
import { z } from 'zod';

const lookup = tool({
  name: 'orders/lookup',
  description: 'Find an order by id.',
  input: z.object({ id: z.string() }),
  output: z.object({ status: z.string() }),
  execute: async ({ id }) => ({ status: id.startsWith('A') ? 'shipped' : 'pending' }),
});

callModel({ id: 'answer', model: 'openai/gpt-5', tools: [lookup] });
step.acpAgent({ id: 'fix', agent: claudeCode(), prompt: 'Fix the stuck order', tools: [lookup] });

Leave tools out and the agent gets every tool the run offers, connection tools included, as a callModel step's model does; tools: [] gives it none. An acpAgentTool is never served to the step it runs.

MCP servers

Give the agent an MCP server the way you give one to a callModel step: list the connection in tools. Noetic is the MCP client, so the agent reaches the server's tools through the bridge, by the same names (linear_get_issue), through the same gates, and the credentials never leave the host:

import { connectionTools, step } from '@noetic-tools/core';
import { claudeCode } from '@noetic-tools/acp';
import { mcp } from '@noetic-tools/mcp';

declare const token: string;
const LINEAR_MCP = 'https://mcp.linear.app/mcp';

step.acpAgent({
  id: 'fix',
  agent: claudeCode(),
  prompt: 'Fix ENG-1',
  tools: [mcp({ name: 'linear', type: 'http', url: LINEAR_MCP, headers: [{ name: 'Authorization', value: `Bearer ${token}` }] })],
});
// A harness or turn connection named 'linear', picked by name.
step.acpAgent({
  id: 'read',
  agent: claudeCode(),
  prompt: 'Summarize ENG-1',
  tools: [connectionTools('linear', { ops: ['get_*'] })],
});

The entry connects when the turn starts, like a callModel step's, and is released when the turn ends — or, for a session kept with keepAlive: 'run' or 'harness', when that session closes. A connection tool's idempotency key is <execution>:tool:<callId>, the agent's own call id, as it is the model's on callModel.

mcp() takes ACP's McpServer shape, so a server config written for an ACP agent goes into tools as it stands: mcp({ name, type: 'http', url, headers }), or mcp({ name, command, args, env }) for stdio. A stdio server that must run where the agent runs takes spawn: agent.spawn, so it starts on that machine — a remote runner included — with its stdio carried back to noetic. No server reaches the agent's own MCP client, so pi, whose adapter drops MCP servers, gets their tools too.

What the step does for you:

  • Serves the tools. The connection starts a loopback MCP server (127.0.0.1, random port and path, a bearer token per connection) and hands it to the agent as the MCP server noetic. The server closes with the connection.
  • Sets up each agent to match callModel. Out of the box, agents hide MCP tools behind a search step, cut large results, or run their own approval review. Each preset turns those off: Claude Code reads per-tool hints from the tool list, Codex and opencode get launch settings (CODEX_CONFIG, OPENCODE_CONFIG_CONTENT) merged into any you already set, and Gemini CLI needs none.
  • Gemini CLI needs a trusted folder. Gemini starts no MCP server, so serves none of your tools, in a folder it does not trust, and it cannot trust one server alone: trusting a folder also loads that folder's .gemini/settings.json (its MCP servers, hooks, and tool commands), GEMINI.md, and .env. noetic never trusts one for you. Pass gemini({ trustWorkspace: true }) to trust the session's folder for that process (GEMINI_CLI_TRUST_WORKSPACE=true); a folder you trusted with gemini trust takes the same option. Without it, a step that gives Gemini tools fails with ACP_AGENT_CANNOT_TAKE_TOOLS before the agent starts, rather than running with none of them. Leave it off, and give the step no tools, for a repository you did not write.
  • Asks permission once. Your tools are gated by steering and needsApproval, as in callModel. Claude Code's, Codex's, and Gemini CLI's own permission request for one of them is answered allow without consulting the step's permissions policy, which governs only the agent's built-in tools. The request is recognized by fields the agent's adapter sets, never by a title the model could have written. Other agents' requests go to the policy; opencode asks none by default.
  • Shows the agent your context layers. Before each turn the layers' recall runs, as before a model call, and leads the prompt as a <context> block — a tool's own context layer included. After the turn the afterModelCall hooks run (a guide sends one more turn with the guidance) and the store hooks see the result.
  • Records every call. The agent's tool_call report is replaced in the item log by the bridge's function_call / function_call_output pair, and a call still running when the agent ends its turn is waited for and recorded. Events, the approver, and the items all carry the agent's own call id, and the stream names calls by tool name.
  • Runs calls one at a time, like the callModel tool loop, even when the agent sends them together. Once a call's pipeline fails, no further call runs and the step fails.

Tool names reach the agent reduced to letters, digits, _, and - (orders/lookup becomes orders_lookup). Two tools that reduce to the same name, or a server tool such as openrouter:web_search, are configuration errors. A schema whose root is not a plain object (a scalar, nullable, or union) is wrapped as { value }, since MCP and Anthropic require one; the tool still receives the unwrapped value. A tool that returns nothing reaches the agent, like a model, as an empty string. A tool that runs a step on toolCtx.ctx — acpAgentTool does — works under a bare execute() as under harness.run.

ACP has no toolChoice: an agent decides for itself when to call a tool, so say so in the prompt.

Every preset takes tools (gemini() with trustWorkspace: true), on this machine or on a remote runner. pi has no MCP client and pi-acp drops MCP servers, so pi() adds the pi-noetic-tools extension, which registers each tool as a native pi tool with the same name, description, and schema and returns its result unchanged. pi() pins pi-acp@0.0.32: 0.0.34 hangs on every prompt with pi 0.73 (@mariozechner/pi-coding-agent), waiting for an event that pi never sends. pi 1.x (@earendil-works/pi-coding-agent) is untested; pass command and args to run another adapter. Two agents cannot take tools. gemini() without trustWorkspace: true fails with ACP_AGENT_CANNOT_TAKE_TOOLS (see above). The other is one attached through a custom transport: noetic did not start it, so it can neither carry the bridge to it nor apply its launch settings. Given tools, such an agent fails before anything spawns, with ACP_TRANSPORT_CANNOT_LAUNCH when its wiring has launch settings and ACP_TRANSPORT_CANNOT_REACH otherwise; with tools left out it simply gets none. acpAgentTool() and the acp-agent JSON node take tools too.

Conversation history

An ACP session owns its history on the agent side, but a freshly opened session knows nothing about the Noetic steps that ran before it. Before appending the turn's prompt, the runtime captures the prior items from ctx.itemLog and folds them into the first prompt of a fresh session as a transcript preamble — so a coding agent running after a chain of callModel steps understands what was already established. A reused session is not re-seeded.

Sessions and lifetime

A connection owns a live agent process, so keeping one is never inferred. session.keepAlive names the scope, and it defaults to closing with the step:

keepAliveThe connection is
'step' (default)closed when the step finishes
'run'kept for the rest of the root run, then closed for you
'harness'kept until you call harness.closeAcpSessions() — nothing closes it for you

session.reuse shares a kept connection under an id, so later steps take their turns against the same agent:

import { step } from '@noetic-tools/core';
import { claudeCode } from '@noetic-tools/acp';

const investigate = step.acpAgent({
  id: 'investigate',
  agent: claudeCode(),
  prompt: 'Find the root cause of the failing auth test. Do not change code yet.',
  permissions: { allow: [{ kind: 'read' }] },
  session: { reuse: 'bugfix', keepAlive: 'run' },
});

const fix = step.acpAgent({
  id: 'fix',
  agent: claudeCode(),
  // Same key → same session, so the agent still has its findings in context.
  prompt: 'Now apply the minimal fix for the root cause you found.',
  permissions: { allow: [{ kind: 'read' }, { kind: 'edit' }] },
  session: { reuse: 'bugfix', keepAlive: 'run' },
});

reuse requires keepAlive: 'run' or 'harness' — a connection closed at the end of its step has nothing left to share — so a reuse key without a scope throws ACP_REUSE_WITHOUT_KEEPALIVE rather than quietly extending the lifetime on your behalf.

Keeping an agent warm across runs

keepAlive: 'harness' opts out of automatic collection entirely, for a long-lived harness that wants one warm coding agent across several turns of a conversation:

import { AgentHarness, step } from '@noetic-tools/core';
import { claudeCode } from '@noetic-tools/acp';

declare const harness: AgentHarness;

// An empty `prompt` means "use whatever input the step was given", so each
// turn of the conversation reaches the same warm agent.
const assistant = step.acpAgent({
  id: 'assistant',
  agent: claudeCode(),
  prompt: '',
  session: { reuse: 'assistant', keepAlive: 'harness' },
});

try {
  // …many harness.execute() calls later:
} finally {
  await harness.closeAcpSessions();
}

Nothing closes a 'harness' session for you. An undisposed connection keeps its agent process running, which keeps your host process from exiting — so own it deliberately, ideally in a finally.

Two more things to know about sharing a session:

  • Each step gets its own policy. The permission policy, steering hook, handler, and event stream are rebound before every turn, so the pattern above really works: fix's broader permissions do apply, even though investigate opened the connection.
  • The agent and clientCapabilities are fixed per connection. ACP negotiates capabilities once, and a connection speaks to one agent. A step joining an existing session with a different agent or different clientCapabilities throws ACP_SESSION_AGENT_CONFLICT / ACP_SESSION_CAPABILITY_CONFLICT rather than silently getting something other than it asked for.

session.load resumes an existing ACP session id instead of creating a new one, for agents that advertise loadSession.

Stop reasons

Stop reasonResult
end_turnNormal return
max_tokens, max_turn_requestsNormal return; recorded on ctx.lastStepMeta
refusalThrows a NoeticError of kind model_refused
cancelledThrows a NoeticError of kind cancelled

Aborting the step's context sends session/cancel. Per the specification the agent still answers the original prompt with the cancelled stop reason, which becomes the typed error.

Streaming

Everything the agent emits reaches the harness's event surface, so ACP output streams exactly like a callModel step's:

import type { AgentHarness } from '@noetic-tools/core';

declare const harness: AgentHarness;

for await (const chunk of harness.getTextStream()) {
  process.stdout.write(chunk);
}
ACP updateSurfaces as
agent_message_chunkgetTextStream(), getFullStream()
agent_thought_chunkgetReasoningStream()
tool_callgetItemStream() as a function call
plan, available_commands_update, current_mode_update, tool_call_updategetFullStream() as acp.<update>

Every notification is also emitted raw as an acp_event framework event. A turn always closes its bracket: response.created on the way in, response.completed on the way out — with the stop reason on success, an error reason when the turn throws. An agent that returns a result without streaming has its output synthesized rather than silently swallowed. emit: false on the step suppresses all of it.

Structured output

import { step } from '@noetic-tools/core';
import { claudeCode } from '@noetic-tools/acp';
import { z } from 'zod';

const Review = z.object({ risks: z.array(z.string()), verdict: z.enum(['ship', 'hold']) });

const review = step.acpAgent({
  id: 'review',
  agent: claudeCode(),
  prompt: 'Review the diff. Reply with JSON matching the schema.',
  output: Review,
});

The assistant text is JSON-parsed and validated, raising model_parse_error on a mismatch — identical to callModel.

Letting the model delegate

A step decides for the model when the agent runs. acpAgentTool() hands that decision to the model instead, by wrapping the agent as a tool it can call:

import { acpAgentTool, callModel } from '@noetic-tools/core';
import { claudeCode } from '@noetic-tools/acp';

callModel({
  id: 'plan',
  model: 'anthropic/claude-sonnet-4-20250514',
  instructions: 'Plan the work. Delegate implementation to the coding agent.',
  tools: [
    acpAgentTool({
      agent: claudeCode(),
      permissions: { allow: [{ kind: 'read' }, { kind: 'edit' }] },
      // Lets the model hold a conversation with the agent across calls
      // instead of starting cold each time.
      session: { reuse: 'delegate', keepAlive: 'run' },
    }),
  ],
});

The tool takes { prompt } and returns { text }, and is named delegate_to_<agentId> unless you say otherwise. Override description to steer when the model delegates — that matters far more than the wording.

Underneath it runs the same step.acpAgent, so the delegated turn lands in the same item log, usage totals, and event stream as any other step.

Letting a human decide

A declarative policy can only answer what you told it in advance. When the decision belongs to a person, askUserForPermission() publishes the request on an external channel and waits for an answer:

import { step } from '@noetic-tools/core';
import { askUserForPermission, claudeCode } from '@noetic-tools/acp';

step.acpAgent({
  id: 'review',
  agent: claudeCode(),
  prompt: 'Fix the failing test',
  permissions: { default: 'deny' },            // policy abstains…
  onPermissionRequest: askUserForPermission(), // …so the human decides
});

Subscribe once per harness and answer:

import type { AgentHarness } from '@noetic-tools/core';
import {
  ACP_PERMISSION_SCOPE,
  acpPermissionDecisions,
  acpPermissionRequests,
  resolveAcpPermission,
} from '@noetic-tools/acp';

declare const harness: AgentHarness;
declare const showApprovalCard: (prompt: { title: string }) => Promise<string>;

const decisions = harness.getChannelHandle(acpPermissionDecisions, ACP_PERMISSION_SCOPE);

for await (const prompt of harness.getChannelStream(
  acpPermissionRequests,
  ACP_PERMISSION_SCOPE,
)) {
  // prompt.options are the agent's OWN options — present those, don't invent any.
  const optionId = await showApprovalCard(prompt);
  resolveAcpPermission(decisions, {
    requestId: prompt.requestId,
    decision: 'allow',
    optionId,
  });
}

The prompt carries what a reviewer needs: agentId, stepId, threadId, the tool title and kind, the raw input, and the agent's own options.

Subscribe once — requests are a queue, so a second subscriber would steal them. Decisions are a topic, broadcast to every waiting handler, each filtering for its own requestId. An unanswered prompt denies when it times out (5 minutes by default): waiting must never become approval. Override with askUserForPermission({ timeout, onTimeout }).

Inspecting and steering live sub-agents

The harness exposes the connections it is holding, so a UI can show what is running and act on it:

import type { AgentHarness } from '@noetic-tools/core';

declare const harness: AgentHarness;
declare const key: string;

harness.listAcpSessions();  // key, agentId, sessionId, mode, commands, keepAlive
harness.getAcpSession(key);
await harness.cancelAcpSession(key);  // session/cancel — the connection stays open
await harness.closeAcpSessions();     // release everything held

This is read-and-interrupt only, on purpose. Turns are driven by steps, so nothing here starts work behind the runtime's back — a turn run outside a step would bypass the item log, usage accounting, and the event bridge. Send a follow-up with a step sharing the same session.reuse key.

JSON workflows

The same agents are available in the JSON runtime as a single node kind. The agent field is a registry key, so supporting a new agent needs another registry entry, not a schema change:

{
  "kind": "acp-agent",
  "id": "review",
  "agent": "claude-code",
  "prompt": "Review the diff",
  "mode": "plan",
  "permissions": { "default": "deny", "allow": [{ "kind": "read" }] }
}
import { hydrateWorkflow, type ExecuteStepFn, type WorkflowDocument } from '@noetic-tools/core';
import { claudeCode, codex, createAcpAgentRegistry } from '@noetic-tools/acp';

declare const document: WorkflowDocument;
declare const executeStep: ExecuteStepFn;

const hydrated = hydrateWorkflow(document, {
  tools: new Map(),
  executeStep,
  acpAgents: createAcpAgentRegistry(claudeCode(), codex()),
});

A node naming an unregistered agent fails hydration with UNKNOWN_ACP_AGENT_REFERENCE.

A node's tools take what a callModel node's take — { "type" }, { "connection", "ops"? } and inline { "mcp": { "name", "type", "url", "headers"? } } or { "mcp": { "name", "command", "args"?, "env"? } } entries — served to the agent through the bridge exactly as a TypeScript step's are. Each { "mcp" } entry is built by the allowlist's provider; a stdio one starts where the node's agent runs, through its spawn.

A node decides what the agent can reach on your machine — clientCapabilities.terminal alone hands it a shell, and { "mcp" } entries launch commands and carry credentials. So a document you did not write — one a model generated with dynamicWorkflow, or one parseAndRunWorkflow loads — gets only what you grant: the acp allowlist caps each agent's permissions, mode, model, cwd, clientCapabilities and session (by default: deny-only permissions, no mode, any model, the harness's working directory, readTextFile only, no session.load; keepAlive: 'harness' never), and the mcp allowlist names the servers it may use: HTTP/SSE servers by URL pattern, stdio servers by exact command and args, header or environment values only as { "$secret": "<name>" } bound by the grant, and the grant's policy. Anything more fails with CONNECTION_NOT_ALLOWED before the agent starts, and the document's sessions never join yours. See What a document may ask of an agent and MCP servers.

Testing an ACP step

loopbackTransport stands an in-process agent on the far end of a real protocol connection — full handshake, sessions, notifications, and client callbacks, with no process to spawn:

import { defineAcpAgent, loopbackTransport } from '@noetic-tools/acp';

const fake = defineAcpAgent({
  agentId: 'fake',
  transport: loopbackTransport((conn) => ({
    async initialize(params) {
      return { protocolVersion: params.protocolVersion, agentCapabilities: {}, authMethods: [] };
    },
    async newSession() {
      return { sessionId: 'session-1' };
    },
    async authenticate() {
      return {};
    },
    async prompt(params) {
      await conn.sessionUpdate({
        sessionId: params.sessionId,
        update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'done' } },
      });
      return { stopReason: 'end_turn' };
    },
    async cancel() {},
  })),
});

Because the wire protocol really runs, this catches contract mistakes a hand-rolled stub would hide. An agent on a transport takes no tools, so to test a step with tools, stand the same agent up with loopbackMachine and give it a command, as a preset has:

import { claudeToolWiring, defineAcpAgent, loopbackMachine } from '@noetic-tools/acp';

declare const fakeAgent: Parameters<typeof loopbackMachine>[0];

const withTools = defineAcpAgent({
  agentId: 'fake',
  command: { command: 'fake-agent' },
  machine: loopbackMachine(fakeAgent),
  tools: claudeToolWiring,
});

Where the agent runs

A preset runs its command on a machine. The default is this one. Give it another and the same preset — command, tool settings, and all — runs there, and its tools still behave exactly as on callModel:

import { codex } from '@noetic-tools/acp';
import { commandMachine, webSocketMachine } from '@noetic-tools/acp/remote';

// A runner listening on a build host, over TLS.
const overWebSocket = codex({
  machine: webSocketMachine({ url: 'wss://build-host:7000', token: process.env.RUNNER_TOKEN ?? '' }),
});

// A runner started over ssh, or in a container with `docker exec -i`.
const overSsh = codex({
  machine: commandMachine({
    command: 'ssh',
    args: ['build-host', 'npx', '-y', '@noetic-tools/acp', 'serve', '--stdio'],
  }),
});

The far end is the remote runner, noetic-acp serve, from @noetic-tools/acp:

# Listen for hosts (127.0.0.1 unless given a certificate):
NOETIC_ACP_RUNNER_TOKEN=… npx @noetic-tools/acp serve --host 0.0.0.0 --port 7000 \
  --tls-cert cert.pem --tls-key key.pem --agent codex
# Or serve one host over stdio, for ssh and docker exec:
npx @noetic-tools/acp serve --stdio

Over its one channel the runner starts the agent with the preset's launch settings, starts MCP servers beside it, and serves your tools to it on its own loopback port, forwarding each request back to the tool bridge on your machine. The agent needs no route back to you, and every WebSocket upgrade must carry the runner's token. --cwd sets the working directory for a start that names none. The step's cwd must exist on the runner's machine.

A runner started without grants starts whatever its host asks. Grants limit it:

npx @noetic-tools/acp serve --stdio --agent claude-code \
  --spawn 'npx -y @modelcontextprotocol/server-github' \
  --env ANTHROPIC_API_KEY --env GITHUB_PERSONAL_ACCESS_TOKEN --cwd /srv/work

--agent <id> grants one preset's command with the environment names its tool settings set (PRESET_ENV). --spawn grants an MCP server's command line, word for word, so a step can start it with agent.spawn; without it a granted runner refuses every server. --env adds a name every grant may set, such as an API key you pass in a preset's env. With any grant, a start must match one exactly, may set only its names, so NODE_OPTIONS, LD_PRELOAD, PATH and the like are refused, and must run inside --cwd (the runner's own directory by default), with .. and symlinks resolved first. Settings that name a command are held to the grants too: each mcp_servers command in a CODEX_CONFIG must be a --spawn grant whose names cover its env, and notify is refused; for --agent pi the runner sets PI_ACP_PI_COMMAND and NOETIC_PI_COMMAND itself (its own launcher, and NOETIC_PI_COMMAND or pi from its own environment), whatever the host sends. Grants bound what the runner starts, not what the agent does once started: it still runs what your permission answers allow. A runner trusts whoever holds its token, or its stdio, and says so when it starts: they can start whatever the grants allow, as the runner's user.

A stdio MCP server that depends on the agent's machine — one that reads its workspace — runs there too: spawnOn(machine) starts it and hands back its stdio, so noetic stays its client. Every command agent carries one as agent.spawn; pass it to mcp({ name, command, spawn: agent.spawn }). It gets the environment the MCP SDK gives a local server — that machine's HOME, LOGNAME, PATH, SHELL, TERM and USER, plus its own env — not the agent's.

To attach to an agent someone else started, supply a transport — anything that yields a duplex byte stream:

import { defineAcpAgent } from '@noetic-tools/acp';

declare const readable: ReadableStream<Uint8Array>;
declare const writable: WritableStream<Uint8Array>;
declare const socket: { close: () => void };

const attached = defineAcpAgent({
  agentId: 'remote-agent',
  transport: async () => ({
    readable,
    writable,
    close: async () => {
      socket.close();
    },
  }),
});

An attached agent takes no tools: see Giving the agent your tools.

The Node stdio transport lives at @noetic-tools/acp/stdio. The main entry has no static node:* import and never loads child_process unless a stdio connection is opened — but that is a runtime property: the dynamic import('./stdio') uses a static specifier, so a bundler still pulls the module in. A browser build should alias or externalise @noetic-tools/acp/stdio.

API reference

Everything @noetic-tools/acp exports. Internal helpers — the item builders, turn accumulator, terminal registry, permission engine — are deliberately not exported; they are implementation detail and free to change.

Agents

ExportWhat it is
claudeCode(opts?)Preset for @agentclientprotocol/claude-agent-acp.
codex(opts?)Preset for @agentclientprotocol/codex-acp.
gemini(opts?)Preset for Gemini CLI's native ACP mode.
opencode(opts?)Preset for opencode's native opencode acp command.
pi(opts?)Preset for pi, via the community pi-acp adapter (pinned to 0.0.32).
customAcpAgent(opts)Any other ACP-speaking binary. Needs a command (run on its machine) or a transport.
AcpPresetOptionscommand / args / env / machine / transport overrides accepted by every preset.
PRESET_COMMANDSThe command each preset starts, by agent id.
PRESET_ENVThe environment names each preset's tool settings set, by agent id: what noetic-acp serve --agent <id> lets a host set.
GeminiOptionsgemini()'s options: every preset option plus trustWorkspace.
AcpProcessSpecHow to launch an agent as a local child process.
defineAcpAgent(opts)Build an AcpAgent from a command on a machine, or a transport — the constructor behind every preset.
DefineAcpAgentOptionsagentId, command, machine, transport, env, tools (default sessionToolWiring).
AcpToolWiringHow an agent takes Noetic tools: where the bridge is declared ('session' or 'launch') and the launch settings that make the agent match callModel.
claudeToolWiring, codexToolWiring, geminiToolWiring, opencodeToolWiring, piToolWiring, sessionToolWiringThe wiring each preset uses, for building your own agent with defineAcpAgent.
BridgedPermissionCheck, claudeBridgedPermission, codexBridgedPermission, geminiBridgedPermissionHow a wiring recognizes its agent's permission requests for bridged tools (AcpToolWiring.permits).
ToolBridgeAddress, ToolBridgeLaunchWhat AcpToolWiring.launchEnv receives.
createAcpAgentRegistry(...agents)Build the Map<string, AcpAgent> a JSON workflow hydrates against.
AcpAgentRegistryThat map's type.

Transports

ExportWhat it is
loopbackTransport(toAgent)Stands an in-process agent on the far end of a real protocol connection. The recommended way to test an ACP step.
loopbackMachine(toAgent)The same agent as a machine, so a test step can give it tools.
localMachine()This machine: the default for every preset. Lives at @noetic-tools/acp/stdio.
spawnOn(machine)A Spawn that starts a process on machine and returns its stdio; a command agent's agent.spawn.
webSocketMachine(opts), commandMachine(opts), runnerMachine(openLink)A remote runner over WebSocket, over a command's stdio, or over any FrameLink. Live at @noetic-tools/acp/remote.
listenAcpRunner(opts), serveAcpRunner(link), serveAcpRunnerOverStdio()The runner itself, as the noetic-acp serve bin uses it. Live at @noetic-tools/acp/remote.
AcpRunnerGrantOne entry of AcpRunnerOptions.allow: a command, its exact args, and the env names a host may set for it. Lives at @noetic-tools/acp/remote.
createAcpLoopbackPair()The two raw ends, when you want to drive both sides yourself.
AcpLoopbackPairThat pair's type.
stdioAcpTransport(spec)Node stdio over a child process. Lives at @noetic-tools/acp/stdio.

Human-in-the-loop permissions

ExportWhat it is
askUserForPermission(opts?)Builds an onPermissionRequest handler that asks a human over channels.
AskUserForPermissionOptionstimeout (default 5 min) and onTimeout (default deny).
acpPermissionRequestsQueue channel carrying prompts out. Subscribe once per harness.
acpPermissionDecisionsTopic channel carrying answers back, filtered by requestId.
resolveAcpPermission(handle, reply)Answer a prompt from outside the execution.
ACP_PERMISSION_SCOPEHarness-lifetime scope id for the subscription and write handle.
AcpPermissionPrompt / SchemaWhat a reviewer receives: agent, step, thread, tool title/kind, raw input, the agent's own options.
AcpPermissionReply / SchemaThe answer: decision, optional optionId and reason.

Path confinement

ExportWhat it is
isAbsolutePath(path)The spec requires absolute paths; this is the check the client applies.
normalizePath(path)Lexical normalisation — collapses ., .., duplicate separators.
isWithinRoots(path, roots)Whether a path is inside the allowed roots.

Exported so a host writing a constraining FsAdapter can apply the same rules the client does — including resolving symlinks, which the client's lexical check deliberately does not.

Advanced: building a client by hand

For embedding an ACP connection outside a Noetic step — a custom runtime, a bridge, a test harness. step.acpAgent is the supported path; these are the pieces underneath it.

ExportWhat it is
openAcpConnection(opts)Open a transport, negotiate initialize, return the connection.
OpenAcpConnectionOptionsagentId, transport, host, signal.
NoeticAcpClientThe ACP Client implementation, backed by an AcpClientHost.
NoeticAcpClientOptionshost plus the notification sink.

From @noetic-tools/core

ExportWhat it is
step.acpAgent(opts)The step.
acpAgentTool(opts)The same agent as a tool a model can call.
harness.listAcpSessions()Live sub-agents: handle, agent, session, mode, commands, keep-alive.
harness.getAcpSession(key)One live connection + session.
harness.cancelAcpSession(key)session/cancel — the connection stays open.
harness.closeAcpSessions()Release everything held. Idempotent.

The contract types (AcpAgent, AcpSession, AcpClientHost, AcpTransport, the permission and protocol types) live in @noetic-tools/types and are re-exported from core.

On this page