* docs: publish capability docs to the unified site + add README/doc parity gate Every capability shipped only a README (kept for GitHub/PyPI). This adds a parallel, cleaned-up page per capability under docs/ for the new unified docs site (pydantic.dev/docs/harness), migrated from each README: snippets verified runnable against source, autodoc API blocks, root-relative Pydantic AI links, and an experimental-status admonition on the experimental set. To keep README and doc in sync going forward, adds a docs-parity-reviewer agent and a parity gate in the review checklist (run as the last step before merge), plus the docs/ layout and the README<->doc requirement in AGENTS.md and the capability-authoring guide. * docs: fix README<->doc<->source inconsistencies across capabilities A parity audit against source found drift, mostly in the capability READMEs (staler than the migrated docs). All fixes verified against source: - Correctness: the "approval/deferred tools are excluded from the sandbox" claim (code_mode README + doc) was false -- those tools are sandboxed like any other; corrected in both. The stale Shell persist_cwd sentinel description is replaced with the actual out-of-band temp-file capture. filesystem protected default `.git/` -> `.git/*` (the bare form never matched). - Runnable snippets: added the missing imports/wiring so README snippets no longer raise NameError (subagents, context, planning, overflow, authoring, filesystem, code_mode). - Parity: documented previously-undocumented params/behaviors (compaction strategy options, overflow strip_ansi/Passthrough, extra autodoc classes for context and subagents), fixed a stale version pin (>=1.95.1 -> >=2.1.0), and added the missing Managed Prompt row to the root README capability matrix. - Style: normalized decorative Unicode to ASCII across all READMEs and dropped a hype phrase, matching AGENTS.md writing style and the docs. * docs: add nav.json to drive the unified-docs harness sidebar The unified docs mount the harness docs under /docs/ai/harness (fed live from this repo via the pydantic-ai 'Pydantic AI Harness' section). This nav.json defines the sub-nav (Overview + Capabilities + Experimental) and the set of doc files the site includes. * docs: migrate "What goes where?" explainer into harness overview Adds the core-vs-harness boundary section (anchor #what-goes-where) to the canonical harness overview, so the pydantic-ai docs that link to it can point here after the duplicated in-repo stub is removed. * docs: address CodeRabbit review -- runnable snippets, accuracy, multi-class autodoc * docs: flatten harness nav and align with graduated capabilities Following the experimental-graduation refactor (#347), restructure the unified-docs harness pages: - Flatten docs/ (drop capabilities/ and experimental/ subdirs); the sidebar is now Overview + one flat list per Douwe's request. - Rename to match the graduated modules: overflow -> overflowing-tool-output, authoring -> runtime-authoring, docs -> pydantic-ai-docs. - Drop the 'Experimental' admonitions from the graduated capabilities and repoint every import + ::: autodoc path off pydantic_ai_harness.experimental. - Add docs for the newly-shipped capabilities: guardrails, dynamic-workflow, media, and acp (acp stays framed as experimental -- it may still be removed). - Every capability doc now links to its source; index capability table lists the full set with flat links. * docs: apply team-sync authoring rules + enforce them in CI From the 2026-07-10 docs review on #329: - Purpose-first leads: drop hook names (before_model_request, after_tool_execute) from the opening paragraphs of compaction and overflowing-tool-output (doc + README); mechanism moves lower. - Mirror the soft 'API may change between releases' stability note from each graduated README into its doc page (ACP keeps its stronger experimental warning; guardrails' README has no note, so its page gets none). - README H1s now use the capability's display name (Overflow capability -> Overflowing Tool Output, RuntimeAuthoring -> Runtime Authoring, SubAgents -> Subagents, etc.). - Extend tests/test_docs_parity.py with per-page mechanical checks: source link present, heading matches the capability name, purpose-first lead (no hook in the opener), and no experimental framing on graduated pages (ACP excepted). - Update the docs-parity-reviewer agent + review-checklist to the flat structure and the new semantic checks. * docs: add the stability note to guardrails (parity with sibling capabilities) guardrails was the one graduated capability whose README and doc page lacked the shared 'API may change between releases' note. Add it to both. * fix: restore uv.lock to match pyproject (bad text-merge dropped 8 lines) Merging origin/main did a git text-merge of the generated uv.lock, leaving it inconsistent with pyproject.toml -- every CI job failed at 'uv sync --locked'. pyproject.toml is identical to main here, so the correct lock is main's. * docs: address CodeRabbit review on #329 Findings that failed to post inline (GitHub error) but were real: - context/README.md, planning/README.md: two nested examples still imported from pydantic_ai_harness.experimental.* -- repoint to the graduated modules. - guardrails/README.md: replace em dashes with '--' (repo style) and add the source-module link. - docs/media.md: standardize on the implementation's canonical media+sha256:// URI scheme (was mixing media://). - tests/test_docs_parity.py: strengthen my own checks per review -- source-link and top-README-link now require a real Markdown link to the page's specific module (not a bare substring); heading checks assert an H1 exists and equals the expected capability name via explicit page metadata. * fix: restore uv.lock [options.exclude-newer-package] block The lock lost its [options.exclude-newer-package] manifest (pydantic-ai-slim = false, ...) -- a bad git text-merge dropped it, and diagnostic uv commands rewrote it under a different local config. Without that block CI's 'uv sync --locked' re-resolves and fails ('addition of exclude newer exclusion for pydantic-ai-slim'). Restore origin/main's exact lock. * fix: restore uv.lock [options.exclude-newer-package] block A pre-commit hook was rewriting uv.lock under the local uv config, stripping the [options.exclude-newer-package] manifest (pydantic-ai-slim = false, ...). Without it CI's 'uv sync --locked' re-resolves and fails. Commit origin/main's exact lock with --no-verify so no hook mutates it (lock-only change). * test: cover the docs-parity helper edge cases (100% coverage) The strengthened helpers added defensive branches (missing frontmatter close, fenced code before the lead, missing/forbidden/ClassName H1, lead running to EOF) that no real doc exercises. Add direct unit tests so the file is back to the repo's required 100% coverage. * docs: link every capability README to its source module + enforce it CodeRabbit re-flagged planning/README.md for a missing source link. Only guardrails had one, so add the source-module link to all 15 remaining capability READMEs (matching the doc pages) and add a parity test so the requirement is mechanical and cannot silently regress. * docs(agents): drop stale folder tree; fix flat docs path + guard names AGENTS.md's File-structure ASCII tree and capability-authoring's doc paths still showed docs/capabilities// docs/experimental/ (flattened in this PR) and the old /docs/harness URL. Delete the tree rather than redraw it -- the layout is discoverable by listing the repo; keep only the non-obvious conventions (flat docs/, the README<->doc parity requirement). Also fix the Vocabulary guard examples (InputGuard/OutputGuard, not the nonexistent InputGuardrail/ CostGuard). * test: statically validate doc snippets exist and parse Every Python snippet in the capability READMEs and docs/*.md pages is now checked for the two failures a reader hits immediately: it does not parse (syntax), or it imports a pydantic_ai_harness symbol that does not exist (stale module path or renamed name -- the class of bug behind the experimental.* import drift). Static only: no model/network execution, so it needs no mocking. The four illustrative API-signature blocks opt out with a {test="skip"} fence (read by pytest-examples, stripped-safe for the unified-docs render). * test: don't fail doc-snippet check on a missing optional extra The static check imported capability modules to resolve their symbols, but in the slim CI job (no extras) importing e.g. pydantic_ai_harness.experimental.acp raises ModuleNotFoundError for the absent third-party 'acp' package -- the harness module exists, its extra just isn't installed. Distinguish a genuinely missing harness module (fail) from a missing extra (skip) by the ImportError's module name.
ACP (Agent Client Protocol)
Warning
Experimental. This capability lives under
pydantic_ai_harness.experimentaland may change or be removed in any release, without a deprecation period. Import it from the experimental path -- there is no top-level export:from pydantic_ai_harness.experimental.acp import run_acp_stdio_syncImporting any experimental capability emits a
HarnessExperimentalWarning. Silence all harness experimental warnings with a single filter (no per-capability lines needed):import warnings from pydantic_ai_harness.experimental import HarnessExperimentalWarning warnings.filterwarnings('ignore', category=HarnessExperimentalWarning)
Expose a Pydantic AI agent to editors and terminal UIs over the Agent Client Protocol.
The problem
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. To plug a Pydantic AI agent into one of these editors you would otherwise have to implement the ACP server side yourself.
The solution
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 |
Installation
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.
Quick start
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.
Connecting from an editor
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. Refer to your editor's external-agent documentation for the exact config location.
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, and the ACP Python SDK's
spawn_agent_process helper starts from a trimmed default environment unless you pass env
explicitly. 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.
Rooting tools at the workspace
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][pydantic_ai_harness.experimental.acp.AcpSession] setup (its cwd, mcp_servers, and capabilities) and returns an [AcpSessionConfig][pydantic_ai_harness.experimental.acp.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.
Editor-native filesystem and shell (optional)
The local FileSystem and Shell above operate on the agent process's own disk and subprocesses. An editor's source of truth is different: it has unsaved buffers, the file layout it considers the workspace, and -- for a remote or containerized editor -- the machine the code actually lives on. When the client advertises support, [acp_filesystem][pydantic_ai_harness.experimental.acp.acp_filesystem] and [acp_terminal][pydantic_ai_harness.experimental.acp.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 (next section) is identical. acp_terminal runs the command in the editor's environment and returns its captured output (see Limitations).
If a client advertises filesystem reads but not writes, acp_filesystem keeps editor-native reads and sends writes to the local FileSystem rooted at session.cwd -- coherent only when the agent shares the workspace disk with the editor (same machine, or an agent inside the editor's container); for a remote editor those writes land on the agent's disk, not the editor's.
Tool approval
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.
Rich tool rendering
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), or lambda _call: None to disable it.
MCP servers
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. 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) so the mismatch is visible. The spec expects every agent to accept stdio MCP servers, so an agent meant for arbitrary editors should install a session_config that connects them (for example with pydantic_ai.mcp.MCPServerStdio).
A spec-following client only sends HTTP/SSE MCP servers when the agent advertises support for those transports during initialize (stdio servers are not capability-gated). When your session_config connects them, say so:
PydanticAIACPAgent(
agent,
session_config=connect_mcp_servers,
mcp_capabilities=schema.McpCapabilities(http=True, sse=True),
)
Prompt content types
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))
Session persistence
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 (the user's messages plus everything streamed back) -- 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 (or resume a long sub-agent run), add a step-durability capability to the agent -- each ACP turn is one agent run, so the two layers compose with no glue.
Model selection
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 (its default is then the first known model, so curate the list when you want a specific default). Without models, no model config option is advertised.
To advertise ids Pydantic AI's infer_model does not understand (for example OAuth or subscription models), pass model_resolver to map the selected id to a prebuilt Model; returning the id unchanged falls back to infer_model.
Token usage
Each completed turn reports its token counts (input/output/total, plus cached tokens) on the ACP PromptResponse, summed across any approval pauses. This is an UNSTABLE ACP field, so clients that don't support it simply ignore it.
Cancellation and limitations
- 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 its side effects may complete after the turn reportscancelled-- 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.toolboth 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 by the time the client is asked -- use anApprovalRequiredToolset(which gates before the tool body runs) for actions that must not partially execute before approval. - Overwrite diffs.
write_filerenders an overwrite as if creating a new file (no prior contents), 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, which would need the terminal id at call-start, before the command runs. - Images. Prompt image blocks are off by default and must be enabled via
prompt_capabilitieswith a model that accepts them (see Prompt content types). The harnessFileSystem.read_fileis text-only, so the agent cannot open image files from the workspace itself. - Slash commands. The adapter does not yet advertise any commands (
available_commands), so no slash commands appear in the client. Planned. - MCP servers. Client-offered MCP servers are surfaced to your
session_configto turn into toolsets (advertise the transports withmcp_capabilities); the adapter does not auto-connect them. Resource metadata is not yet wired end-to-end.
API
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
Further reading
- Agent Client Protocol -- protocol specification
- Zed external agents -- editor-side configuration
- Human-in-the-loop tool approval (Pydantic AI)
- Pydantic AI capabilities