Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## What this is

a2claude serves a coding agent over the [A2A](https://a2aprotocol.ai/) protocol. Other agents discover it through its agent card and delegate coding work; it drives a real coding-agent session and streams the structured work (tool calls, file diffs, permission requests, cost, session continuity) back — not just flattened text in / text out.
a2acode serves a coding agent over the [A2A](https://a2aprotocol.ai/) protocol. Other agents discover it through its agent card and delegate coding work; it drives a real coding-agent session and streams the structured work (tool calls, file diffs, permission requests, cost, session continuity) back — not just flattened text in / text out.

It is a **bridge between two interop standards**: it speaks Zed's [Agent Client Protocol](https://agentclientprotocol.com) (ACP) to the coding agent (Claude Code, Gemini CLI, Codex, OpenHands, ... — a launch-command choice) and A2A to the caller. The default `acp` backend makes the agent vendor-neutral; a `claude` backend (Claude Agent SDK, no subprocess) and an `echo` backend also ship.

Expand All @@ -26,8 +26,8 @@ CI (`.github/workflows/ci.yml`) runs lint, format-check, mypy, pytest, and build
Run the server end to end without an API key using the `echo` backend:

```bash
uv run a2claude serve --backend echo &
uv run a2claude call "fix the failing test"
uv run a2acode serve --backend echo &
uv run a2acode call "fix the failing test"
```

## Architecture
Expand All @@ -46,7 +46,7 @@ CLI / A2A caller
-> echo.py dependency-free mirror, for tests and offline wiring checks
```

The `acp` backend is the headline: ACP's `session/update` stream, diff content, and `session/request_permission` map almost one-to-one onto the event vocabulary below, so vendor-neutrality is a launch-command choice rather than a backend per agent. ACP itself targets human-driven editors; a2claude's value is exposing an ACP agent to *remote A2A callers* with the permission round-trip and cost preserved.
The `acp` backend is the headline: ACP's `session/update` stream, diff content, and `session/request_permission` map almost one-to-one onto the event vocabulary below, so vendor-neutrality is a launch-command choice rather than a backend per agent. ACP itself targets human-driven editors; a2acode's value is exposing an ACP agent to *remote A2A callers* with the permission round-trip and cost preserved.

### The event vocabulary (`backends/base.py`)

Expand Down Expand Up @@ -82,7 +82,7 @@ The server does **not** load the developer's personal Claude settings (`setting_
- **Keep the layering intact.** If you reach for `acp`/`claude_agent_sdk` outside their own backend module, or for `a2a.*` inside a backend, that's the wrong layer.
- **The translation functions are pure and side-effect free** — `events_from_message` (claude), `events_from_update` + `select_option` (acp), and `diff.py` — so the protocol mapping is unit-testable without a live agent. Keep them that way; stateful concerns (cost capture, permission parking) live in the backend's `Client`/`drive`, not the translator.
- Python 3.13+, full type hints, `from __future__ import annotations`. Ruff enforces `E, F, I, UP, B, SIM` at line length 88.
- The `acp` and `claude` backends are imported lazily (`make_backend`) so `echo` works without their runtime deps. The Claude SDK is an optional extra (`a2claude[claude]`). New optional backends should follow the same lazy pattern.
- The `acp` and `claude` backends are imported lazily (`make_backend`) so `echo` works without their runtime deps. The Claude SDK is an optional extra (`a2acode[claude]`). New optional backends should follow the same lazy pattern.

## Reference material

Expand Down
46 changes: 23 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
<img src="assets/mascot.png" alt="a2claude" width="150" align="right">
<img src="assets/mascot.png" alt="a2acode" width="150" align="right">

# a2claude
# a2acode

Serve a coding agent over the [A2A](https://a2aprotocol.ai/) protocol. Other agents call it over A2A; it drives a real coding-agent session in your project — Claude Code, or any agent that speaks Zed's [Agent Client Protocol](https://agentclientprotocol.com) (ACP): Gemini CLI, Codex, OpenHands, and more — and streams the work back as it happens.

[![CI](https://github.com/kanywst/a2claude/actions/workflows/ci.yml/badge.svg)](https://github.com/kanywst/a2claude/actions/workflows/ci.yml)
[![CI](https://github.com/kanywst/a2acode/actions/workflows/ci.yml/badge.svg)](https://github.com/kanywst/a2acode/actions/workflows/ci.yml)
[![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
[![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/)
[![Protocol: A2A 1.0](https://img.shields.io/badge/protocol-A2A%201.0-D97757.svg)](https://a2aprotocol.ai/)

![a2claude streaming a task, then pausing on a permission prompt](assets/demo.gif)
![a2acode streaming a task, then pausing on a permission prompt](assets/demo.gif)

Most adapters that put a coding agent behind A2A flatten everything to text in, text out. a2claude keeps the structure the agent produces: the tools it runs, the files it changes, what it costs, the approvals it needs, and how to continue on the next turn. It bridges two Linux Foundation interop standards — **ACP** (how editors and clients talk to coding agents) on the agent side, **A2A** (how agents delegate to each other) on the caller side — so any ACP agent becomes a peer any A2A orchestrator can call.
Most adapters that put a coding agent behind A2A flatten everything to text in, text out. a2acode keeps the structure the agent produces: the tools it runs, the files it changes, what it costs, the approvals it needs, and how to continue on the next turn. It bridges two Linux Foundation interop standards — **ACP** (how editors and clients talk to coding agents) on the agent side, **A2A** (how agents delegate to each other) on the caller side — so any ACP agent becomes a peer any A2A orchestrator can call.

## How it maps to A2A

Expand All @@ -30,13 +30,13 @@ The mapping is all in `executor.py`. Backends only emit normalized events; they

Anthropic now ships its own ways to run Claude Code beyond the terminal: Claude Code on the web, background agents, cloud-hosted Routines, and the Managed Agents API. These are the right choices when you want Anthropic to host the run and you live in their ecosystem, and they are typically tied to Anthropic infrastructure and a GitHub-centric flow.

a2claude solves a different problem: making any coding agent a first-class peer on a vendor-neutral [A2A](https://a2aprotocol.ai/) mesh. An orchestrator built on any framework discovers it through its agent card and delegates coding work the same way it would to any other A2A agent. The run happens on infrastructure you control, in a workspace you point it at. Reach for a2claude when:
a2acode solves a different problem: making any coding agent a first-class peer on a vendor-neutral [A2A](https://a2aprotocol.ai/) mesh. An orchestrator built on any framework discovers it through its agent card and delegates coding work the same way it would to any other A2A agent. The run happens on infrastructure you control, in a workspace you point it at. Reach for a2acode when:

- another agent (not a human at a prompt) is the caller, and it speaks A2A;
- you want the run on your own infrastructure and data boundary, not a vendor VM;
- you do not want to bet on one vendor's coding agent: ACP makes the backend a launch-command choice, so swapping Claude Code for Codex, Gemini CLI, or OpenHands does not touch the protocol surface your callers depend on.

ACP already standardizes the editor↔agent side and a dozen agents speak it; a2claude is the piece that exposes an ACP agent to *remote autonomous callers* over A2A, with permission round-trips and cost preserved as first-class protocol citizens — the part ACP leaves out because it assumes a human in an editor. The practical user is the platform team building that mesh, not the individual developer.
ACP already standardizes the editor↔agent side and a dozen agents speak it; a2acode is the piece that exposes an ACP agent to *remote autonomous callers* over A2A, with permission round-trips and cost preserved as first-class protocol citizens — the part ACP leaves out because it assumes a human in an editor. The practical user is the platform team building that mesh, not the individual developer.

## Requirements

Expand All @@ -55,9 +55,9 @@ uv sync
The `echo` backend needs no API key and no Claude install, so you can exercise the whole path offline first:

```bash
uv run a2claude serve --backend echo &
uv run a2acode serve --backend echo &
# once the "Uvicorn running" line appears:
uv run a2claude call "fix the failing test"
uv run a2acode call "fix the failing test"
```

```text
Expand All @@ -72,30 +72,30 @@ fix the failing test
Then point it at a real project. The default backend is `acp`, fronting Claude Code through its ACP adapter:

```bash
uv run a2claude serve --cwd /path/to/project # acp + claude by default
uv run a2claude call "add a /health endpoint" --url http://localhost:9100/
uv run a2acode serve --cwd /path/to/project # acp + claude by default
uv run a2acode call "add a /health endpoint" --url http://localhost:9100/
```

Swap the agent without touching anything else:

```bash
uv run a2claude serve --agent gemini --cwd /path/to/project
uv run a2claude serve --agent-command "npx -y some-other-acp-agent"
uv run a2acode serve --agent gemini --cwd /path/to/project
uv run a2acode serve --agent-command "npx -y some-other-acp-agent"
```

Continue the same conversation by passing the `context` from a previous turn:

```bash
uv run a2claude call "now add a test for it" --context <context-id>
uv run a2acode call "now add a test for it" --context <context-id>
```

## Commands

| Command | Description |
| -------------------- | -------------------------------------------- |
| `a2claude serve` | Start the A2A server |
| `a2claude call TEXT` | Send a message and print the streamed events |
| `a2claude card` | Fetch and print the agent card |
| `a2acode serve` | Start the A2A server |
| `a2acode call TEXT` | Send a message and print the streamed events |
| `a2acode card` | Fetch and print the agent card |

The agent card is served at `/.well-known/agent-card.json` and advertises Claude Code's abilities as discrete skills (generation, refactor, debug, review, test, explain).

Expand All @@ -118,7 +118,7 @@ Each agent authenticates the way its own tooling does, inherited from the server
A caller that discovers this server only has the agent card to go on. Sign it so the caller can confirm the card came from you and was not swapped in transit:

```bash
uv run a2claude serve --sign-key card-signing.pem --sign-kid my-key-1 --sign-alg ES256
uv run a2acode serve --sign-key card-signing.pem --sign-kid my-key-1 --sign-alg ES256
```

The card is then served with a JWS signature over its canonical form. `--sign-key` is a path to a file holding the key: a PEM private key for asymmetric algorithms (`ES256`, `RS256`), or a shared secret for `HS256`. `--sign-kid` is the key id a verifier uses to look up the matching public key. Unsigned is still the default.
Expand All @@ -128,7 +128,7 @@ The card is then served with a JWS signature over its canonical form. `--sign-ke
A signed card proves who the server is; this proves the caller is allowed in. Require a bearer token and the server rejects any task request that does not carry it:

```bash
uv run a2claude serve --auth-token-file caller-token.txt
uv run a2acode serve --auth-token-file caller-token.txt
```

When `--auth-token-file` is set, callers must send `Authorization: Bearer <token>`; a request without a valid token gets `401 Unauthorized`. The agent card stays public so a caller can still fetch it to discover the requirement, and the card advertises the bearer scheme in `securitySchemes`. Without the flag the server stays open, as before.
Expand All @@ -140,10 +140,10 @@ A2A keeps the credential at the HTTP layer, so this composes with whatever your
A tool that needs approval pauses the task in the A2A `input-required` state instead of being skipped. The caller answers with a follow-up message on the same task:

```bash
uv run a2claude call "sudo reboot"
uv run a2acode call "sudo reboot"
# ... [input-required] Permission requested for Bash: $ sudo reboot
# reply: a2claude call "allow" --task <id> --context <id>
uv run a2claude call "allow" --task <id> --context <id>
# reply: a2acode call "allow" --task <id> --context <id>
uv run a2acode call "allow" --task <id> --context <id>
```

`allow` (or `yes`, `approve`, `ok`) approves; anything else denies. The agent session stays alive across the pause, so it resumes exactly where it stopped. Over ACP this is the agent's `session/request_permission` call answered from the A2A caller's reply; with the `claude` backend it routes through the Claude SDK's `can_use_tool`.
Expand All @@ -156,7 +156,7 @@ The agent card advertises push notifications. A caller can register a webhook fo

## Observability

Debugging one agent is hard; debugging a chain of them without traces is worse. Because A2A runs over HTTP, it drops straight into OpenTelemetry: install the extra and the A2A SDK's instrumentation plus a per-task `a2claude.execute` span light up, with W3C trace context propagating across the call so client and server spans share one trace.
Debugging one agent is hard; debugging a chain of them without traces is worse. Because A2A runs over HTTP, it drops straight into OpenTelemetry: install the extra and the A2A SDK's instrumentation plus a per-task `a2acode.execute` span light up, with W3C trace context propagating across the call so client and server spans share one trace.

```bash
uv sync --extra telemetry
Expand Down
6 changes: 3 additions & 3 deletions assets/demo.tape
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,20 @@ Hide
Type 'cd "$(git rev-parse --show-toplevel)" && source .venv/bin/activate && export PS1="$ " _ZO_DOCTOR=0 && clear'
Enter
Sleep 1.5s
Type "a2claude serve --backend echo > /tmp/demo.log 2>&1 &"
Type "a2acode serve --backend echo > /tmp/demo.log 2>&1 &"
Enter
Sleep 2.5s
Type "clear"
Enter
Show

Type "a2claude call 'refactor the parser'"
Type "a2acode call 'refactor the parser'"
Enter
Sleep 3.5s

Type "# a tool that needs approval pauses the task:"
Enter
Type "a2claude call 'sudo reboot'"
Type "a2acode call 'sudo reboot'"
Enter
Sleep 4s
Sleep 1.5s
10 changes: 5 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[project]
name = "a2claude"
name = "a2acode"
version = "0.3.0"
description = "Serve Claude Code and other ACP coding agents over the A2A protocol."
readme = "README.md"
Expand All @@ -21,15 +21,15 @@ dependencies = [
# Claude Code too, so this is only needed for the SDK-native path.
claude = ["claude-agent-sdk>=0.2.101"]
# Distributed tracing. Pulls the A2A SDK's OpenTelemetry instrumentation plus
# the API a2claude's own spans import directly (declared rather than relied on
# transitively through the SDK). Install with `a2claude[telemetry]`.
# the API a2acode's own spans import directly (declared rather than relied on
# transitively through the SDK). Install with `a2acode[telemetry]`.
telemetry = ["a2a-sdk[telemetry]>=1.1,<2", "opentelemetry-api>=1.33"]

[project.scripts]
a2claude = "a2claude.cli:app"
a2acode = "a2acode.cli:app"

[project.urls]
Repository = "https://github.com/kanywst/a2claude"
Repository = "https://github.com/kanywst/a2acode"

[dependency-groups]
dev = [
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
2 changes: 1 addition & 1 deletion src/a2claude/card.py → src/a2acode/card.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
try:
# The agent card version tracks the package version, read from installed
# metadata so there is one source of truth (pyproject) and it cannot drift.
VERSION = _package_version("a2claude")
VERSION = _package_version("a2acode")
except PackageNotFoundError: # running from a source tree without an install
VERSION = "0.0.0"

Expand Down
10 changes: 5 additions & 5 deletions src/a2claude/cli.py → src/a2acode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

Three commands, enough to run the server and exercise it by hand:

a2claude serve start the A2A server
a2claude call TEXT send a message and print the streamed events
a2claude card fetch and print the agent card
a2acode serve start the A2A server
a2acode call TEXT send a message and print the streamed events
a2acode card fetch and print the agent card
"""

from __future__ import annotations
Expand Down Expand Up @@ -174,7 +174,7 @@ def serve(
auth_token=auth_token,
)
label = f"{backend}:{agent}" if backend == "acp" else backend
typer.echo(f"a2claude: backend={label} card={_local_url(host, port)}")
typer.echo(f"a2acode: backend={label} card={_local_url(host, port)}")
uvicorn.run(asgi_app, host=host, port=port, log_level="info")


Expand Down Expand Up @@ -270,7 +270,7 @@ async def _call(text: str, url: str, context: str | None, task: str | None) -> N
def _render_input_required(line: str, ids: dict[str, str], url: str) -> None:
typer.echo(f"[input-required] {line}")
follow = (
f'a2claude call "allow" --task {ids["task"]} '
f'a2acode call "allow" --task {ids["task"]} '
f"--context {ids['context']} --url {url}"
)
typer.echo(f" reply: {follow}")
Expand Down
6 changes: 3 additions & 3 deletions src/a2claude/executor.py → src/a2acode/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,11 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non
# span() drops None-valued attributes, so no fallbacks are needed; the
# ids are populated by the SDK before execute is called.
with span(
"a2claude.execute",
"a2acode.execute",
**{
"a2a.task_id": context.task_id,
"a2a.context_id": context.context_id,
"a2claude.backend": self._backend.name,
"a2acode.backend": self._backend.name,
},
):
await self._execute(context, event_queue)
Expand Down Expand Up @@ -293,7 +293,7 @@ async def _request_input(updater: TaskUpdater, event: PermissionRequest) -> None
message=updater.new_agent_message(
[Part(text=f"Permission requested for {event.tool_name}: {line}")],
metadata={
"a2claude_permission": {
"a2acode_permission": {
"request_id": event.request_id,
"tool": event.tool_name,
"input": event.tool_input,
Expand Down
File renamed without changes.
File renamed without changes.
6 changes: 3 additions & 3 deletions src/a2claude/tracing.py → src/a2acode/tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

A2A runs over HTTP, so it slots into standard OpenTelemetry tracing: the SDK
already instruments its own client/server/task paths, and this adds a span for
a2claude's own protocol-mapping layer so a trace shows where time went inside
a2acode's own protocol-mapping layer so a trace shows where time went inside
the executor, not just in the SDK.

OpenTelemetry is an optional dependency (install ``a2claude[telemetry]``). When
OpenTelemetry is an optional dependency (install ``a2acode[telemetry]``). When
it is absent, ``span`` is a no-op context manager, so the core install and the
hot path stay free of the dependency. Trace context propagation over HTTP
headers is handled by the standard OTel instrumentation, not here.
Expand All @@ -20,7 +20,7 @@
try:
from opentelemetry import trace

_tracer: Any | None = trace.get_tracer("a2claude")
_tracer: Any | None = trace.get_tracer("a2acode")
except ImportError: # opentelemetry not installed: tracing is a no-op
_tracer = None

Expand Down
4 changes: 2 additions & 2 deletions tests/test_acp.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@
from acp import schema as s
from acp import text_block, tool_diff_content

from a2claude.backends.acp import (
from a2acode.backends.acp import (
_BridgeClient,
events_from_update,
select_option,
)
from a2claude.backends.base import (
from a2acode.backends.base import (
FileChange,
PermissionDecision,
TextDelta,
Expand Down
Loading