Skip to content

@ahpd/agent-acp ​

npmCIlicense MITnode >=22Agent Host Protocol 0.9.0

An Agent Client Protocol backend for @ahpd/sdk, and a plugin for the @ahpd/server daemon.

Any program that speaks ACP over its stdio is one configured command, not a package of its own, so copilot --acp, codex-acp, gemini --acp and @deepseek-ai/dsh-acp all run through this one package.

Part of ahpd. The source is in packages/agent-acp.

Install ​

bash
ahpd plugin install @ahpd/agent-acp
ahpd restart

Update, remove and one-run flags such as --plugin are in Plugins.

What it needs ​

  • Each agent's own command on the daemon's PATH. A command that is missing fails that provider's turns with a message and leaves the daemon running.
  • codex-acp for codex. The codex CLI has no ACP mode of its own; codex-acp comes from npm i -g @agentclientprotocol/codex-acp.
  • cursor-agent for cursor, which runs cursor-agent acp, the name Cursor's installer puts on the host and its part ships.

Options ​

Add one entry to plugins in the daemon's config.json, and one key per ACP server under its presets:

json
{
  "plugins": [
    {
      "name": "@ahpd/agent-acp",
      "options": {
        "presets": {
          "copilot": {},
          "codex": {},
          "my-agent": { "name": "My agent", "command": "my-agent", "args": ["--acp"], "env": { "MY_AGENT_KEY": { "$secret": "host:my-agent" } } }
        },
        "hostTools": true,
        "toolsChanged": "notify"
      }
    }
  ]
}

Each key registers an agent of its own, under that key as the provider id, so copilot, codex and my-agent are three entries in the picker out of one load.

The shipped presets are codex, gemini, copilot, opencode, kilo, goose, pi, dsh, devin, cursor, amp and qwen. A key that names none of them writes a command of its own, and a key that names one takes its command, its arguments and its environment.

Every option below is a key under options in the plugin's entry, and the daemon checks each value against the schema before apply runs.

OptionDefaultWhat it does
presetsrequiredThe agents this load registers, by the id clients name. A key that names a shipped preset takes it; a key that names none writes a command of its own
hostToolstrueOffer the host's own tools to each session as an MCP server. A preset may set its own
toolsChangednotifyHow a session's agent hears that the tools it listed have moved: notify sends notifications/tools/list_changed down a stream the host holds open, list sends nothing. A preset may set its own

presets.<id> ​

OptionDefaultWhat it does
basethe key, when it names a shipped presetThe shipped preset this one takes, for a key that is not itself one
namethe shipped preset's name, then the keyWhat a client reads instead of the id
commandthe shipped preset'sThe program to spawn as the ACP server. Required for a key that names no shipped preset and no base
argsthe shipped preset'sThe arguments to give it, replacing the preset's own
envnoneEnvironment variables merged over the daemon's own for the child, by variable name. A value written { "$secret": "host:<name>" } is read from the vault when the daemon loads
cwdthe session's working directoryThe directory the server runs in
descriptionnoneOne line about what this agent is
modelthe server's ownThe model a session that names none runs on
authenticatenone{ "methodId": "<id>" }, the sign-in to send after the handshake, for a server that refuses a session until one has happened
hostToolsthe plugin-wide hostToolsWhether this agent's sessions are offered the host's own tools
honoursTrustthe shipped preset's, else falseWhether this agent asks before it loads a project's own settings and hooks. Absent, a session in a folder the host did not vouch for is refused, because ACP carries no trust field
toolsChangedthe plugin-wide toolsChangedHow this agent hears that the tools it listed have moved
machinethe shipped preset'sWhat a machine needs to run this agent, laid over the shipped preset's by key

presets.<id>.machine ​

OptionDefaultWhat it does
envthe shipped preset'sVariables set only inside the machine: a string, { "fromEnv": "NAME" } read when the daemon loads, or { "$secret": "<scope>:<name>" } read when the machine is made. Laid over the preset's by variable
copynoneHost paths copied into the machine, each { "source", "target" }, with target an absolute path
partthe shipped preset'sThe part the CLI comes from
state/ahpd/<id> for a shipped presetThe absolute directory the agent keeps its configuration in, as a state volume
seedthe agent's own host filesThe host files state is seeded from, each { "source", "target", "keep", "drop" }

Every shipped preset brings a machine: its CLI's part, a state directory at /ahpd/<id> seeded from the agent's own host files and never its login file, and the variables that point the CLI there. So a preset in a machine runs from its part on any glibc image, and signs in with the key a person adds in machine.env. A vault-filled key reaches only this agent's commands in the machine. See ACP agents in a machine.

A per-agent option written at the top level fails the load and says where it goes now. A preset that cannot be resolved is skipped with one line naming it, and the rest register: a base naming no shipped preset, no command where one is needed, an authenticate with no methodId, a $secret the vault does not hold, or a machine that is wrongly written or reads a variable the daemon does not have. A row whose sign-in depends on a variable sends it only when the daemon's own environment or that preset's env has the variable, and for a session placed in a machine also when the preset's machine.env sets it.

ahpd vault set host:<name> keeps the credential a preset's env reads with { "$secret": "host:<name>" }, from standard input. See PLUGINS.md for a $secret.

In your own host ​

bash
pnpm add @ahpd/agent-acp @ahpd/sdk
ts
import { createHost, listen } from '@ahpd/sdk';
import { acpAgent } from '@ahpd/agent-acp';

const path = process.cwd();
const host = createHost({
  path,
  agents: [
    acpAgent({ provider: 'copilot', displayName: 'Copilot', command: 'copilot', args: ['--acp'] }),
  ],
});
await listen({ port: 9187 }, (peer) => host.accept(peer));

createHost takes a list of agents, so several ACP servers run beside each other and beside any other backend.

What it does ​

It spawns the command, completes the ACP handshake over its stdio, opens one session, and turns each session/update into the chat/* action a client already knows. A turn ends as the server's own stop reason says: chat/turnComplete for end_turn, chat/turnCancelled for cancelled, and chat/error carrying the reason for max_tokens, max_turn_requests and refusal, which are a turn that stopped early rather than an answer. cancel reaches the server as its notification, after everything it was still waiting on has been answered: cancelled for a permission, and cancel for a question, because nobody said no to one that stopped being asked.

The host's own tools, and with them the tools a session's clients provide, are offered to the agent as one HTTP MCP server named ahp, where hostTools is on, which it is by default. A call the agent makes to a client's tool is reported against the client that provides it and waits for that client's answer, which reaches the agent as the tool result with everything the client sent, images and resources included. A client that arrives after the agent listed its tools is heard of as toolsChanged says: under notify, the default, the host holds a stream open and sends notifications/tools/list_changed down it, and under list nothing is sent. Both serve the current list either way, so an agent that ignores the notification and never lists again is one that misses every tool a client announced after it looked.

A session this process never watched is loaded when a client reads it, where the server advertised loadSession; the read and the turn after it each load it, and what the server replays becomes the session's earlier turns. A replayed turn the server sent no user message for is kept, with no user text on it.

A session/request_permission is a confirmation offering the server's own options, approvals first, and the one the person picks is the optionId the server receives. An answer that picked none selects the server's once option of that kind, never an always.

An elicitation/create is a question, and it is asked on the chat as a chat/inputRequested with one question per field of its form, and the session/inputNeededSet entry a permission raises. The person's answer arrives as chat/inputCompleted and reaches the server as the elicitation's content, and the kind the client sent reaches it as itself: a decline as a decline and a cancel as a cancel. A page rather than a form carries its url on the request and is completed by the server saying so with elicitation/complete.

Documentation ​

PLUGINS.mdThe ACP options in the daemon, and what the server may ask the host for
AGENT.mdThe Agent and Session contracts this implements
AHP.mdWhich actions are served, which are refused, and why

License ​

MIT © Softov