Metadata-Version: 2.4
Name: a2a-bridge
Version: 0.1.1
Summary: Connect LibreChat, or any OpenAI-compatible chat client, to any A2A agent.
Author: Scott Klein
License: MIT
Project-URL: Homepage, https://github.com/kleinmatic/a2a-bridge
Project-URL: Repository, https://github.com/kleinmatic/a2a-bridge
Project-URL: Issues, https://github.com/kleinmatic/a2a-bridge/issues
Project-URL: Changelog, https://github.com/kleinmatic/a2a-bridge/releases
Keywords: a2a,agent2agent,librechat,chat-completions,agent,jsonrpc
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Communications :: Chat
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: fastapi>=0.115
Requires-Dist: uvicorn[standard]>=0.30
Requires-Dist: httpx>=0.27
Requires-Dist: pyyaml>=6.0
Provides-Extra: mongo
Requires-Dist: pymongo>=4.8; extra == "mongo"
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
Requires-Dist: respx>=0.21; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Dynamic: license-file

# a2a-bridge

[![CI](https://github.com/kleinmatic/a2a-bridge/actions/workflows/ci.yml/badge.svg)](https://github.com/kleinmatic/a2a-bridge/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/a2a-bridge)](https://pypi.org/project/a2a-bridge/)

**Connect LibreChat — or any OpenAI-compatible chat client — to any [A2A](https://a2a-protocol.org) agent.**

A2A agents speak JSON-RPC. Chat clients speak the OpenAI chat-completions API. This is the
adapter in between: each configured A2A agent shows up as a selectable model, and the agent's
answer lands on screen unchanged.

```
┌─────────────┐   POST /v1/chat/completions   ┌────────────┐   JSON-RPC message/send   ┌───────────┐
│ chat client │ ────────────────────────────► │ a2a-bridge │ ────────────────────────► │ A2A agent │
│ (LibreChat) │ ◄──────────────────────────── │            │ ◄──────────────────────── │           │
└─────────────┘        assistant message      └────────────┘        Task + artifacts    └───────────┘
```

Adding an agent is a config block, not a code change.

## Why not MCP?

MCP exposes an agent as a *tool*, which means a model sits between the agent and the user and
paraphrases whatever comes back. Fine for data lookups, destructive for anything where the
agent's own voice, formatting, or cross-agent attribution matters — multi-agent responses that
label which agent said what get flattened into a summary.

A2A treats the far side as a peer, not a function call. This bridge keeps that property: there
is **no model in the path**. The user's text goes to the agent, and the agent's text is what
renders.

---

## Quickstart

```bash
pip install a2a-bridge                 # or from a checkout: pip install -e .
cp examples/agents.example.yml agents.yml
$EDITOR agents.yml                     # set card_url to your agent
A2A_BRIDGE_CONFIG=agents.yml python -m a2a_bridge.server
```

Verify without a chat client in the loop:

```bash
curl -s localhost:8600/healthz
curl -s localhost:8600/v1/models

curl -s localhost:8600/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -H 'X-Conversation-Id: test-1' \
  -d '{"model":"myagent","messages":[{"role":"user","content":"hello"}]}'
```

Then send a **second** request with the same `X-Conversation-Id` and a follow-up that depends on
the first answer. If the agent remembers, sessions work — that is the part most likely to be
subtly broken, and the part hardest to notice later.

### Docker

```bash
docker build -t a2a-bridge .
docker run -p 8600:8600 \
  -v "$PWD/agents.yml:/app/agents.yml:ro" \
  -v a2a_data:/data \
  a2a-bridge
```

Keep `/data` on a volume. It holds the conversation → contextId map, and losing it means every
user starts over — including losing whatever the agent had decided about them.

---

## Configure

```yaml
store: "sqlite:///data/context.db"     # memory:// | sqlite:///path | mongodb://...
api_keys_env: A2A_BRIDGE_API_KEYS      # comma-separated; omit to leave the bridge open

agents:
  - id: myagent                                    # becomes the model name
    card_url: https://agent.example.org/api/agent/
    conversation_id_header: X-Conversation-Id      # see "Sessions" below
```

Everything else — the JSON-RPC endpoint, protocol version, streaming support — is read from the
agent card. See [`examples/agents.example.yml`](examples/agents.example.yml) for every option.

| Route | Purpose |
|---|---|
| `POST /v1/chat/completions` | blocking and streaming |
| `GET /v1/models` | one entry per configured agent, so clients self-populate |
| `GET /healthz` | liveness |

---

## Using it with LibreChat

Short version:

```yaml
endpoints:
  custom:
    - name: "My Agent"
      apiKey: "${A2A_BRIDGE_API_KEY}"
      baseURL: "http://a2a-bridge:8600/v1"
      models: { default: ["myagent"], fetch: false }
      headers:
        X-Conversation-Id: "{{LIBRECHAT_BODY_CONVERSATIONID}}"
      titleEndpoint: "bedrock"     # anything BUT this endpoint
      maxContextTokens: 200000
```

**→ Full guide, including the `@mention` setup and a list of traps that do not announce
themselves: [docs/librechat.md](docs/librechat.md).**

Read the traps section before debugging anything. Several of them fail silently — a wrong trailing
slash, a title model that does not exist, a config file whose inode changed — and each one looks
like a different problem than it is.

---

## What the agent needs to support

Minimum for a working integration:

- An **agent card**, at `/.well-known/agent-card.json` or served from the endpoint itself.
- **`message/send`** (JSON-RPC 2.0), returning a Task whose text lives in
  `result.artifacts[].parts[].text`.
- A **server-minted `contextId`** returned on the first response and honoured on later ones.

Optional, and worth having:

- **`message/stream`** — mainly for working-state notes, which turn a long blank wait into visible
  progress. Streaming does not imply incremental text; many agents send a whole artifact at once.
- **Working-state `status.message`** copy — "Searching…", "Handing off to X…" — forwarded to the
  user as it arrives.

Not used: Task lifecycle management, polling, push notifications. An agent needing those is not
yet a fit for a synchronous chat UI.

---

## Sessions

The single most important thing to get right.

The bridge omits `contextId` on the first turn, lets the server mint one, stores it against the
client's conversation id, and echoes it afterwards. Agents commonly bind session state to that
value — history, entitlement, subscription — so a rotated `contextId` can silently send a user
back to the beginning.

That is why `conversation_id_header` matters. Without it the bridge falls back to hashing the
first user message, which breaks the moment anyone edits or regenerates it.

---

## Design notes

Behaviours that took real debugging to establish, in case they look arbitrary:

**Only the newest user turn is sent.** A2A agents are stateful per `contextId` and keep their own
transcript. Replaying the client's history would duplicate their context every turn and inflate
their token spend.

**Parts within an artifact concatenate with nothing between them; separate artifacts get the
separator.** A streaming agent emits one part per chunk of a single string. Using a separator for
both splits words and breaks markdown mid-token.

**Two failure layers.** JSON-RPC errors arrive as HTTP 200 with an `error` object. Rate limiting
arrives as a bare HTTP 429 with an **empty body**, from middleware above the JSON-RPC app — no
envelope, nothing to parse. Code that only inspects JSON-RPC errors mistakes one for the other.

**429 is surfaced, never retried.** A throttled request is information the operator wants, not
something to hide in a retry loop.

**Redirects are not followed.** A `307` on a POST loses its body in most clients. Usually a missing
trailing slash, so it is reported as the configuration error it is.

**Streaming is always available to the client.** Chat clients request `stream: true` by default and
break on a plain JSON body, so a blocking agent's answer is emitted as a single delta.

**Failures render in-chat by default.** A non-2xx becomes a contextless red banner in most chat
UIs. Set `on_error: http_error` per agent for programmatic callers.

**Per-turn ids are recorded even though nothing reads them.** The agent's task id is emitted once
and cannot be reconstructed later; without it, "which answer was this about?" is unanswerable for
feedback, cost or audit.

---

## Forwarding caller identity

Agents that rate-limit per IP see every user of a server-side bridge as one caller, so one busy
user throttles everyone. If the agent supports it, forward a stable per-user id:

```yaml
    caller:
      id_header: X-Caller-Id
      auth_header: X-Caller-Auth
      secret_env: MY_SHARED_SECRET
```

The bridge sends the id plus an HMAC-SHA256 of it under a shared secret, so the agent can verify
rather than trust. **An unsigned identity header is a rate-limit bypass** waiting to be found, and
the secret must stay server-side.

Use a secret scoped to *this purpose*. If the agent's operator offers you a key that also signs
sessions or authorises billing, ask for a separate one — proving "this caller id came from me"
needs far less authority than that.

---

## Development

```bash
pip install -e '.[dev]'
pytest
ruff check src tests
```

Contract tests live in `tests/`. Recorded wire responses go in `tests/fixtures/` — see the README
there. Recording rather than hand-writing them is the point: the tests should fail when a peer
changes its wire shape, which only works if the fixtures came off the wire.

The fixtures shipped here came off the wire from a live multi-agent publisher, and cover a
paywall gate, a cross-agent handoff, both JSON-RPC error shapes, and streaming with progress
notes. The envelopes are untouched; the prose inside them was rewritten to a fictional
publisher, so nothing here reproduces a real organization's copy.

---

### Releasing

**The git tag is the version.** There is no number to edit in any file.

1. Pick the number. Semver, still `0.x`: bump the **middle** for a breaking
   change or a new feature, the **last** for a fix. `0.1.0` → `0.2.0` → `0.2.1`.
2. Publish a GitHub Release tagged `vX.Y.Z`.

The release workflow does the rest: builds, runs the tests, refuses to continue
if the built version and the tag disagree, and uploads to PyPI over OIDC. There
is no PyPI token anywhere in the repo or in anyone's shell.

Going to `1.0.0` is a promise that `agents.yml` and the caller headers have
stopped moving. Not yet.

## Status

Early, and deliberately small. Blocking `message/send`, optional `message/stream`, card discovery,
one request/response turn. No Task lifecycle management, no push notifications.

## License

MIT
