ACP
Editors like Zed speak ACP: a stdio JSON-RPC protocol that lets a TUI or editor drive an external coding agent — streaming its text, rendering its file edits as diffs, and prompting the user to approve sensitive tool calls. Reach for this capability when you want a Pydantic AI Agent to appear as a first-class agent inside one of those editors, without implementing the ACP server side yourself.
To plug a Pydantic AI agent into an ACP editor you would otherwise have to implement the ACP server side by hand — chunking streamed text under the wire limit, rendering tool calls as diffs, mapping the protocol’s permission requests onto the agent’s tools, and managing per-workspace sessions.
run_acp_stdio serves any Pydantic AI Agent as an ACP agent over stdin/stdout. The editor launches your script as a subprocess and talks to it; the adapter translates between ACP and the agent’s run loop:
| ACP needs | The adapter provides |
|---|---|
| Streamed assistant text and reasoning | Agent text/thinking deltas, chunked under the wire limit |
Rich tool calls (kind, file locations, diffs) | A presenter that recognizes FileSystem/Shell tool calls |
| Human-in-the-loop tool approval | Maps ACP permission requests to Pydantic AI’s deferred-approval tools |
| Per-workspace sessions | A session_config hook to root tools at the client’s working directory |
| Cancellation, multi-turn history, session close | Handled per session |
uv add "pydantic-ai-harness[acp]"
This pulls in the agent-client-protocol SDK. The rest of the harness does not depend on it — only pydantic_ai_harness.experimental.acp does.
Write a script that builds your agent and serves it:
# my_acp_agent.py
from pydantic_ai import Agent
from pydantic_ai_harness.experimental.acp import run_acp_stdio_sync
def build_agent() -> Agent[None, str]:
return Agent('anthropic:claude-sonnet-4-6', instructions='You are a coding assistant.')
if __name__ == '__main__':
run_acp_stdio_sync(build_agent())
run_acp_stdio_sync blocks for the lifetime of the connection — it is the main() of an agent the editor launches. Inside an existing event loop, use the async run_acp_stdio instead.
ACP clients launch the agent as a subprocess. In Zed, register it as an external agent in settings.json:
{
"agent_servers": {
"My Pydantic AI Agent": {
"type": "custom",
"command": "python",
"args": ["/absolute/path/to/my_acp_agent.py"],
"env": { "ANTHROPIC_API_KEY": "..." }
}
}
}
Any ACP-compatible client works the same way — point it at python my_acp_agent.py.
The provider environment must be available to the launched subprocess. GUI editors and SDK-based test wrappers may not source your interactive shell startup files. If a real-model agent exits before initialize or fails provider auth, first verify that the command’s process can see variables such as ANTHROPIC_API_KEY.
A coding agent should read and write files in the workspace the editor opened, not wherever the subprocess started. ACP gives each session a working directory (cwd); a session_config factory turns that into per-session tools:
from pydantic_ai import Agent
from pydantic_ai_harness.experimental.acp import AcpSession, AcpSessionConfig, run_acp_stdio_sync
from pydantic_ai_harness.filesystem import FileSystem
from pydantic_ai_harness.shell import Shell
agent = Agent('anthropic:claude-sonnet-4-6')
def session_config(session: AcpSession) -> AcpSessionConfig[None]:
# Root file and shell tools at the workspace the client opened.
return AcpSessionConfig(
deps=None,
toolsets=[
FileSystem[None](root_dir=session.cwd).get_toolset(),
Shell[None](cwd=session.cwd).get_toolset(),
],
)
if __name__ == '__main__':
run_acp_stdio_sync(agent, session_config=session_config)
The factory runs once per session with the client’s AcpSession setup (its cwd, mcp_servers, and capabilities) and returns an AcpSessionConfig whose deps and toolsets apply to every run in that session. This is correct across multiple concurrent sessions in one process, where a single static FileSystem could not be.
The local FileSystem and Shell above operate on the agent process’s own disk and subprocesses. An editor’s source of truth is different: unsaved buffers, its own idea of the workspace layout, and — for a remote or containerized editor — the machine the code actually lives on. When the client advertises support, acp_filesystem and acp_terminal give the agent read_file/write_file/run_command tools that route through the client, so it acts where the user is:
from pydantic_ai_harness.experimental.acp import AcpSession, AcpSessionConfig, acp_filesystem, acp_terminal
from pydantic_ai_harness.filesystem import FileSystem
from pydantic_ai_harness.shell import Shell
def session_config(session: AcpSession) -> AcpSessionConfig[None]:
# Use the editor's filesystem/terminal when offered; otherwise fall back to local.
fs = acp_filesystem(session) or FileSystem[None](root_dir=session.cwd).get_toolset()
shell = acp_terminal(session) or Shell[None](cwd=session.cwd).get_toolset()
return AcpSessionConfig(deps=None, toolsets=[fs, shell])
Each helper returns None when the client did not advertise the capability, so the or falls back to local and the agent works either way. The tool names match the local FileSystem/Shell, so rich rendering stays identical.
Mark a tool to require approval and ACP relays the decision to the client, which shows the user an approve/reject prompt:
@agent.tool_plain(requires_approval=True)
def delete_file(path: str) -> str:
...
The lifecycle the client sees is pending (awaiting approval) -> in_progress (granted, running) -> completed/failed, so an unapproved action is never shown as already running. “Always allow”/“always reject” decisions are remembered for the session, scoped by default to the exact call (tool name plus arguments) so approving one call never silently approves a different one. Pass permission_policy to widen or narrow that scope.
By default the adapter recognizes the harness FileSystem and Shell tool calls by name and annotates them with an ACP kind (read/edit/search/execute), the file locations they touch, and an inline diff for edits — so the editor renders click-to-file links and diff views instead of opaque JSON. Pass tool_presenter to add rendering for your own tools (optionally with chain_presenters ahead of the default default_coding_presenter), or lambda _call: None to disable it.
An ACP client may offer MCP servers during session setup. This adapter does not connect them itself; a session_config is the place to turn session.mcp_servers into Pydantic AI toolsets (for example with pydantic_ai.mcp.MCPServerStdio). If a client sends MCP servers and no session_config is installed to consume them, the session request is rejected rather than silently ignoring them. A spec-following client only sends HTTP/SSE MCP servers when the agent advertises support during initialize; when your session_config connects them, say so:
from acp import schema
from pydantic_ai_harness.experimental.acp import PydanticAIACPAgent
PydanticAIACPAgent(
agent,
session_config=connect_mcp_servers,
mcp_capabilities=schema.McpCapabilities(http=True, sse=True),
)
The agent advertises which prompt content it accepts. The default is text only, so a client is not invited to send blocks a text model cannot handle. Enable the kinds your model supports:
from acp import schema
run_acp_stdio_sync(agent, prompt_capabilities=schema.PromptCapabilities(image=True, embedded_context=True))
Pass a session_store to let a client reopen a past conversation with session/load. Each committed turn is persisted as two parts — the model’s message history and the client-visible transcript — and reopening restores the history into the agent and replays the transcript to the client, so its UI is rebuilt as the user last saw it. Without a store, session/load is advertised as unsupported.
from pydantic_ai_harness.experimental.acp import InMemorySessionStore
run_acp_stdio_sync(agent, session_store=InMemorySessionStore())
InMemorySessionStore keeps sessions for the lifetime of the process. Implement the SessionStore protocol (save/load a StoredSession) over a file or database to make them survive a restart — the stored values are Pydantic models, so they serialize with Pydantic. Session persistence is for reopening a conversation; it is orthogonal to per-run durability. To also make individual turns crash-resilient, add a step-durability capability — each ACP turn is one agent run, so the two layers compose with no glue.
Pass models to advertise a stable ACP session config option named model (using Pydantic AI model names). The first is each session’s default. A selection is applied as a per-run override — the shared agent is never mutated — and is persisted with the session when a session_store is set.
run_acp_stdio_sync(agent, models=['anthropic:claude-sonnet-4-6', 'anthropic:claude-opus-4-8', 'openai:gpt-4o'])
A model id is any string a Pydantic AI model accepts, so newer models not yet in KnownModelName work too. Pass models='all' to offer every model Pydantic AI knows. To advertise ids infer_model does not understand (OAuth or subscription models), pass model_resolver to map the selected id to a prebuilt Model.
- Cancellation.
session/cancelandsession/closecancel the in-flight turn; close waits for it to unwind before returning. Cooperative async tools stop promptly. A synchronous tool already running in a worker thread cannot be force-stopped, so prefer async tools for cancellation-sensitive work. - Approval detection. Tools that require approval are recognized when they live in a
FunctionToolset(which the harnessFileSystem/Shelland@agent.toolall use). A tool whose approval requirement is decided dynamically per call (by raisingApprovalRequiredfrom its body) starts asin_progress, and any side effects it ran before raising have already happened — use anApprovalRequiredToolsetfor actions that must not partially execute before approval. - Overwrite diffs.
write_filerenders an overwrite as if creating a new file, so the diff understates what it replaced. - Live terminal panes.
acp_terminalreturns a command’s captured output; it does not embed a live terminal pane in the tool call. - Images. Prompt image blocks are off by default and must be enabled via
prompt_capabilitieswith a model that accepts them. - Slash commands. The adapter does not yet advertise any commands, so no slash commands appear in the client. Planned.
run_acp_stdio( # async; serve until the client disconnects
agent,
*,
deps=None,
name=None, # advertised name; defaults to the agent's name
version='0.1.0',
session_config=None, # per-session deps/toolsets from the client's setup
permission_policy=None, # scope of remembered "always" approval decisions
prompt_capabilities=None, # defaults to text-only
mcp_capabilities=None, # MCP transports to advertise; needs a session_config to connect them
tool_presenter=None, # defaults to the FileSystem/Shell presenter
session_store=None, # enables session/load by persisting each session
models=None, # models offered as the `model` config option ('all' for every known model)
model_resolver=None, # maps an advertised model id to the Model used for the run
usage_limits=None, # per-run request/token ceilings
)
run_acp_stdio_sync(...) # synchronous wrapper, same arguments
PydanticAIACPAgent(agent, *, ...) # the ACP agent object, to embed in a custom server
The module also exports the session types (AcpSession, AcpSessionConfig, McpServer), the store types (SessionStore, StoredSession, InMemorySessionStore), the client toolsets (AcpFileSystemToolset, AcpTerminalToolset, acp_filesystem, acp_terminal), the permission types (ToolCallPermission, default_permission_scope), and the presentation helpers (ToolCallPresentation, chain_presenters, default_coding_presenter).
Source: pydantic_ai_harness/experimental/acp/.
- Agent Client Protocol — protocol specification
- Zed external agents — editor-side configuration
- Human-in-the-loop tool approval (Pydantic AI)
- Pydantic AI capabilities