AG-UI ↔ Claude Agent SDK protocol adapter
Project description
AG-UI Claude Adapter
A transport-agnostic protocol adapter bridging AG-UI (Agent-User Interaction Protocol) ↔ Claude Agent SDK (Python, drives the claude CLI as a subprocess).
frontend (AG-UI events) ⇄ Adapter (this package) ⇄ ClaudeSDKClient ⇄ claude CLI
Design principles
- Stateless. The
Adapterholds no cross-run mutable state — no client cache, no session manager, no generated IDs. Eachprocess_run()call is self-contained. - No opinion on session identity.
thread_id/run_idare taken verbatim from the caller; resume/session behavior is the caller's decision viasdk_options. - Converters are standalone, first-class citizens.
MessageConverter(AG-UI → SDK) andEventBuilder(SDK → AG-UI) can be used independently. - Transport-agnostic tool bridge. Frontend-defined tools are bridged through an in-process MCP server — no dependency on any web framework.
Install
pip install agui-claude-adapter # core converters + adapter
pip install agui-claude-adapter[tools] # + frontend-tool bridge (mcp)
Prerequisite: claude CLI on PATH (npm install -g @anthropic-ai/claude-code).
Usage paths
This package gives you three levels of control. Pick whichever matches your architecture.
Path 1 — Standalone converters (no Adapter needed)
You own the ClaudeSDKClient lifecycle. Call the two converters directly wherever you need them.
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions
from agui_claude_adapter import MessageConverter, EventBuilder
async def my_agent_turn(run_input):
client = ClaudeSDKClient(options=my_options)
await client.connect()
try:
# 1. AG-UI messages → SDK format
sdk_messages = MessageConverter.agui_to_sdk(run_input.messages)
await client.query(sdk_messages[0]["content"])
# 2. SDK output → AG-UI events
builder = EventBuilder()
async for event in builder.process(
client.receive_response(),
thread_id=run_input.thread_id,
run_id=run_input.run_id,
):
yield event # each is an ag_ui.core.Event subclass
finally:
await client.disconnect()
Use this path when:
- You already manage your own
ClaudeSDKClientpool / per-thread workers. - You need fine-grained control over
connect/query/disconnect(e.g. multi-turn with a persistent subprocess — see the demo for a worked example). - You want minimal overhead — no extra layers between you and the SDK.
Path 2 — Adapter.process_run() (batteries-included)
The Adapter is a stateless convenience: it creates a fresh ClaudeSDKClient per call, connects, sends, streams, and disconnects — all for you.
from agui_claude_adapter import Adapter
adapter = Adapter()
async for event in adapter.process_run(run_input):
print(event.type, event)
You can pass per-run sdk_options (resume, model, system prompt, …) — the adapter forwards them unchanged:
from claude_agent_sdk import ClaudeAgentOptions
opts = ClaudeAgentOptions(
include_partial_messages=True,
max_turns=10,
permission_mode="bypassPermissions",
resume="cli-session-id-from-last-turn", # business-layer decision
)
async for event in adapter.process_run(run_input, sdk_options=opts):
...
Use this path when:
- You want a single-turn, fire-and-forget agent call.
- You don't need persistent subprocesses across turns (each
process_runis isolated).
Path 3 — Business-layer orchestration (demo's pattern)
For multi-turn conversations with memory, you keep a long-lived ClaudeSDKClient (one CLI subprocess per thread) and drive the converters against it directly — just like Path 1, but with a persistent client.
The demo (demo/server.py) implements this with a _ThreadWorker per thread: the worker owns one ClaudeSDKClient inside a dedicated asyncio.Task, request tasks submit messages via a queue, and the two standalone converters do the rest. This pattern keeps the core adapter stateless while the business layer owns session identity and client lifecycle.
See
demo/server.pyfor the full implementation — it is extensively commented and serves as the reference for this pattern.
Working with multimodal messages (images, documents)
AG-UI represents multimodal content as an array of content parts. The converter maps them to Anthropic's native format automatically.
Frontend to AG-UI UserMessage
The frontend sends an AG-UI content-part array:
{
"message": [
{"type": "text", "text": "Look at this image"},
{"type": "image", "source": {"type": "data", "value": "<base64>", "mimeType": "image/png"}}
]
}
What the converter does
MessageConverter.agui_to_sdk maps each part:
| AG-UI input | Anthropic output |
|---|---|
{"type":"text","text":"..."} |
{"type":"text","text":"..."} |
{"type":"image","source":{"type":"data","value":"...","mimeType":"image/png"}} |
{"type":"image","source":{"type":"base64","media_type":"image/png","data":"..."}} |
{"type":"document","source":...} |
Same mapping (data→base64, mimeType→media_type, value→data) |
The SDK routes the converted content to the CLI, which delivers it to the model. Text-only paths are unchanged.
Single vs multiple messages
- One multimodal message: goes through the async-iterable path (the string fast-path is guarded so lists don't cause
TypeError). - Multiple messages (full history): already uses the async-iterable path — multimodal content works the same way.
Frontend-defined tools
When the model calls a tool that the frontend executes (not a built-in CLI tool), you need a bridge between the CLI's MCP handler and the frontend's result:
CLI calls tool → MCP call_tool → bridge.wait_for(name, args) → BLOCK
Frontend runs → POST /tool_result → bridge.resolve(name, args, result)
UNBLOCK → return to CLI
Using Adapter.process_run with tools
async for event in adapter.process_run(
run_input,
tools=run_input.tools, # list of ag_ui.core.Tool
):
...
When tools is provided the adapter wires up an in-process MCP server (FrontendToolBridge + FrontendToolRegistry + build_frontend_tool_mcp_server) internally and tears it down at run end.
Using the tool bridge directly (Path 1 / Path 3)
from agui_claude_adapter import (
FrontendToolBridge,
FrontendToolRegistry,
build_frontend_tool_mcp_server,
)
bridge = FrontendToolBridge()
registry = FrontendToolRegistry(agui_tools)
mcp_server = build_frontend_tool_mcp_server(bridge, registry)
sdk_options.mcp_servers = {
"frontend-tools": {
"type": "sdk",
"name": "frontend-tools",
"instance": mcp_server,
}
}
When the frontend returns a tool result, deliver it through the bridge:
# In your POST /api/tool_result handler (or WebSocket, or …)
matched = bridge.resolve(tool_name, arguments, result)
# matched == True → a waiting call_tool handler was unblocked
# matched == False → result buffered (arrived before call_tool), or stale POST
Important: The MCP call_tool handler never learns the Anthropic tool_use_id, so the bridge correlates by a (tool_name, arguments) fingerprint instead. The frontend must send tool_name + arguments (not just tool_call_id) when reporting a result. Both sides canonicalise with json.dumps(sort_keys=True) inside Python, so key order and JS/Python serialization differences don't cause mismatches.
Exception handling
Adapter.process_run classifies exceptions into typed AdapterError subclasses and emits a RunErrorEvent rather than raising:
| Exception | Error code |
|---|---|
AdapterConnectionError |
CONNECTION_ERROR |
AdapterAuthError |
AUTH_ERROR |
AdapterTimeoutError |
TIMEOUT_ERROR |
AdapterRateLimitError |
RATE_LIMIT_ERROR |
AdapterInvalidRequestError |
INVALID_REQUEST |
AdapterServerError |
SERVER_ERROR |
| Other / unknown | UNKNOWN_ERROR |
When using the standalone converters (Path 1), you handle SDK exceptions yourself — the converters only do format translation.
Package reference
| Component | Import | Description |
|---|---|---|
MessageConverter |
agui_claude_adapter |
Static methods: agui_to_sdk(messages) → list[dict], sdk_to_agui_message(sdk_msg) → AssistantMessage |
EventBuilder |
agui_claude_adapter |
process(sdk_stream, *, thread_id, run_id) → AsyncIterator[AGUIEvent] |
Adapter |
agui_claude_adapter |
process_run(input, *, sdk_options=None, tools=None) → AsyncIterator[AGUIEvent] |
FrontendToolBridge |
agui_claude_adapter |
wait_for(name, args, *, timeout), resolve(name, args, result) → bool, cancel_all() |
FrontendToolRegistry |
agui_claude_adapter |
replace(tools), snapshot(), names() |
build_frontend_tool_mcp_server |
agui_claude_adapter |
(bridge, registry, *, server_name, call_timeout) → McpServer |
ToolConverter |
agui_claude_adapter |
AG-UI Tool ↔ OpenAI/Anthropic schema conversion utilities |
Run the demo
# Backend (requires claude CLI)
uv run python demo/server.py # → http://127.0.0.1:8000
# Frontend
cd demo/frontend && npm run dev # → http://localhost:5173 (proxies /api → :8000)
The demo is a full-stack reference: multi-turn chat (persistent _ThreadWorker per thread), file upload with image → Anthropic conversion, and generative-UI tools (setBackgroundColor, showChoiceCard, flightBookingWizard).
Development
uv run pytest tests/ # full suite (102 tests)
uv run pytest tests/test_adapter.py -v # single file
asyncio_mode = "auto" is set — no @pytest.mark.asyncio needed on async tests.
Documentation
| Document | Purpose |
|---|---|
docs/KNOWN_ISSUES.md |
Prioritized defect log (all core-adapter issues fixed as of 2026-07-09) |
docs/stateless-adapter-plan.md |
Design rationale for the stateless refactor |
docs/tool-bridge-core-plan.md |
Transport-agnostic tool-bridge architecture |
docs/python-packaging-guide.md |
Step-by-step Python packaging & publishing tutorial |
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file agui_claude_adapter-0.1.1.tar.gz.
File metadata
- Download URL: agui_claude_adapter-0.1.1.tar.gz
- Upload date:
- Size: 39.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a87d9c9692efb11748df3020238857bfec47d9cff8d6b676e46f41c0efda19a1
|
|
| MD5 |
da68a5c770c5020e60d64a80656efb2e
|
|
| BLAKE2b-256 |
4d4d8d8338c7d6db4501c865c72add481e12b3231dfe9066553736d42e045e28
|
File details
Details for the file agui_claude_adapter-0.1.1-py3-none-any.whl.
File metadata
- Download URL: agui_claude_adapter-0.1.1-py3-none-any.whl
- Upload date:
- Size: 23.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
dd71c89f620bfdd4331dd5541c30e0a0dba71434ab8b4f1d58aded19721b1ae8
|
|
| MD5 |
a9e410534dc2a0037a390debf62b1fe4
|
|
| BLAKE2b-256 |
27e552d0cb88ecb715eef0b06ae1b3cae5c87a855acea9072446d523478d1462
|