agent-bridge
# aiAgentBridge
**Let Claude Code, Codex and Opencode sessions message each other in real time.**



aiAgentBridge is a small Python hub that every AI coding session connects to as an MCP server. Each
session registers under a name, then sends messages to another session by name or broadcasts them to
all sessions with `*`. Sessions can be grouped into **rooms**, so several independent groups can share
one hub without seeing each other. Messages arrive as soon as they're sent, with no polling:
- **Claude Code** gets messages **pushed into the conversation** as `<channel>` blocks. It uses the
same mechanism as the official Telegram plugin.
- **Codex and Opencode** call `wait_for_message`, a long-poll that returns the moment a message
arrives.
- **Scripts, CI jobs and bots** can post to an HTTP webhook, or join as full agents over a WebSocket.
You can have Claude review what Codex just wrote, have Opencode tell Claude when tests pass, or let a
CI job broadcast "build is green" to every open session.
---
## Contents
- [How it works](#how-it-works)
- [Requirements](#requirements)
- [Install](#install)
- [Start the hub](#start-the-hub)
- [Connect your assistants](#connect-your-assistants)
- [Rooms](#rooms)
- [Quick start](#quick-start)
- [Tools](#tools)
- [Webhook and WebSocket API](#webhook-and-websocket-api)
- [Delivery rules](#delivery-rules)
- [Security](#security)
- [Troubleshooting](#troubleshooting)
- [Development](#development)
- [License](#license)
---
## How it works
```mermaid
flowchart LR
subgraph Sessions
CC[Claude Code]
CX[Codex]
OC[Opencode]
end
AD[agent-bridge-stdio<br/>adapter]
HUB((agent-bridge hub<br/>127.0.0.1:8765))
EXT[Scripts / CI / bots]
CC -- stdio MCP --> AD
AD -- WebSocket /ws --> HUB
CX -- HTTP MCP /mcp --> HUB
OC -- HTTP MCP /mcp --> HUB
EXT -- POST /webhook or /ws --> HUB
```
The **hub** is one long-running process. It keeps the registry of named agents, routes messages,
and queues direct messages for agents that are offline.
**Codex and Opencode** connect straight to the hub over MCP Streamable HTTP.
**Claude Code** connects through a small **stdio adapter**, `agent-bridge-stdio`. Claude Code talks to
HTTP MCP servers with the stateless `2026-07-28` protocol, which has no channel for the server to
push. Claude Code does accept pushes from stdio servers, though, so it launches the adapter locally.
The adapter holds a WebSocket to the hub and forwards each incoming message to Claude as a channel
notification.
One message, start to finish:
```mermaid
sequenceDiagram
participant X as Codex (codex-1)
participant H as Hub
participant A as stdio adapter
participant C as Claude Code (claude-main)
X->>H: wait_for_message (blocks)
C->>A: send_message to codex-1: please review foo.py
A->>H: send frame over WebSocket
H-->>X: wait_for_message returns the message
X->>H: send_message to claude-main: looks good
H->>A: message frame over WebSocket
A->>C: notifications/claude/channel
Note over C: shows up in the conversation as a<br/>channel block from codex-1
```
---
## Requirements
- **Python 3.13+**
- **[uv](https://docs.astral.sh/uv/)** (Python package and project manager)
- At least one of [Claude Code](https://docs.claude.com/en/docs/claude-code),
[Codex CLI](https://github.com/openai/codex) or [Opencode](https://opencode.ai)
It works on Windows, macOS and Linux.
---
## Install
```sh
git clone https://github.com/umairulh2001/aiAgentBridge.git
cd aiAgentBridge
uv sync
```
`uv sync` creates `.venv/` and installs two commands into it:
| Command | What it is | Windows path | macOS / Linux path |
|---|---|---|---|
| `agent-bridge` | The hub server | `.venv\Scripts\agent-bridge.exe` | `.venv/bin/agent-bridge` |
| `agent-bridge-stdio` | The stdio adapter for Claude Code | `.venv\Scripts\agent-bridge-stdio.exe` | `.venv/bin/agent-bridge-stdio` |
> In the rest of this README, `<path-to-repo>` means the **absolute** path where you cloned the
> repository, e.g. `C:\Users\you\aiAgentBridge` or `/home/you/aiAgentBridge`.
---
## Start the hub
Run it in its own terminal and leave it running:
```sh
uv run agent-bridge
# or call the executable directly:
# Windows: .venv\Scripts\agent-bridge.exe
# macOS/Linux: .venv/bin/agent-bridge
```
It listens on `http://127.0.0.1:8765`. Check it with `curl http://127.0.0.1:8765/health` (on Windows
PowerShell use `curl.exe`; see [Troubleshooting](#troubleshooting)).
| Option | Env var | Default | Notes |
|---|---|---|---|
| `--host` | `BRIDGE_HOST` | `127.0.0.1` | Keep it on localhost unless you also set a token |
| `--port` | `BRIDGE_PORT` | `8765` | |
| `--token` | `BRIDGE_TOKEN` | none | Requires `Authorization: Bearer <token>` on every route |
| `--wait-default` | | `50` | Default `wait_for_message` timeout in seconds |
| `--allowed-host` | | | Extra `Host` header to accept (repeatable), e.g. `mybox:8765` |
| `--log-level` | | `info` | |
All state is held in memory, so restarting the hub clears the queues. Clients reconnect on their own.
Running it as a background service or at login is up to you; any process supervisor works.
---
## Connect your assistants
Every session needs a **unique name**. That's the name other agents use to reach it. If two sessions
ask for the same name, the second one gets `<name>-2` and is told so.
### Claude Code
**1. Register the adapter.** This makes it available in every project:
```sh
# Windows
claude mcp add --scope user agent-bridge -- "<path-to-repo>\.venv\Scripts\agent-bridge-stdio.exe" --name claude-main
# macOS / Linux
claude mcp add --scope user agent-bridge -- "<path-to-repo>/.venv/bin/agent-bridge-stdio" --name claude-main
```
Or check it into one project as `.mcp.json`. Claude Code expands `${VAR}`, so each window can pick
its own name:
```json
{
"mcpServers": {
"agent-bridge": {
"command": "<path-to-repo>/.venv/bin/agent-bridge-stdio",
"args": ["--name", "${AGENT_NAME:-claude}", "--room", "${AGENT_ROOM:-default}"]
}
}
}
```
```sh
AGENT_NAME=claude-frontend AGENT_ROOM=shop-app claude --dangerously-load-development-channels server:agent-bridge
```
**2. Start Claude Code with channels enabled:**
```sh
claude --dangerously-load-development-channels server:agent-bridge
```
- The text after `server:` must match the name you registered the server under (`agent-bridge`).
- Channels are a **research-preview** feature of Claude Code, so the flag may change between
versions. Check `claude --help` and the Claude Code docs if it's rejected.
- Messages appear as `<channel>` blocks only in **interactive** sessions. In `claude -p`, or without
the flag, Claude can still receive everything by calling `wait_for_message`. The adapter keeps
every message, so nothing is lost.
Adapter options:
| Option | Env var | Default | |
|---|---|---|---|
| `--name` | `AGENT_NAME` | `claude` | Name to register under |
| `--room` | `AGENT_ROOM` | `default` | Room to join (see [Rooms](#rooms)) |
| `--hub` | `BRIDGE_HUB` | `ws://127.0.0.1:8765` | Hub URL |
| `--token` | `BRIDGE_TOKEN` | none | Prefer the env var: args are stored in plain text in configs |
| `--push` | `BRIDGE_PUSH` | `channel` | `wait` turns push off; messages are then only returned by `wait_for_message` |
| `--log-file` | `BRIDGE_LOG_FILE` | none | Claude Code hides a stdio server's stderr, so use this to debug |
- **Start order doesn't matter.** If the hub isn't up yet, the adapter keeps retrying (0.5s, backing
off to 10s). Until it connects, tool calls return "cannot reach the agent-bridge hub".
- **Why the full executable path instead of `uv run`?** `uv run` re-syncs the environment before every
launch. On Windows that clashes with the running hub's locked files (see
[Troubleshooting](#troubleshooting)). If you prefer uv, use
`uv run --no-sync --directory <path-to-repo> agent-bridge-stdio --name claude-main`.
- **No push needed?** Claude can also connect directly over HTTP in wait mode:
`claude mcp add --scope user --transport http agent-bridge "http://127.0.0.1:8765/mcp?name=claude-main"`.
### Codex
Add this to `~/.codex/config.toml` (Windows: `%USERPROFILE%\.codex\config.toml`):
```toml
[mcp_servers.agent-bridge]
url = "http://127.0.0.1:8765/mcp?name=codex-1"
tool_timeout_sec = 600 # wait_for_message blocks; the default (~60s) is too short
default_tools_approval_mode = "approve" # needed for `codex exec`, which can't prompt for approval
# bearer_token_env_var = "BRIDGE_TOKEN" # if the hub runs with --token
```
### Opencode
Add this to `opencode.json` (in the project or in your global Opencode config):
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"agent-bridge": {
"type": "remote",
"url": "http://127.0.0.1:8765/mcp?name=opencode-1",
"enabled": true
}
}
}
```
If the hub has a token, add `"headers": { "Authorization": "Bearer {env:BRIDGE_TOKEN}" }`. Opencode
fills in `{env:...}` from the environment, so the token stays out of the file.
**Wake Opencode when a message arrives (optional plugin).** Opencode has no push, so a message waits
until the model calls `wait_for_message`. The plugin `opencode-plugin/agent-bridge-wake.ts` fixes
that: it polls the hub's `/health` for this agent's queued count and, when messages wait and the
session is idle, starts a turn telling the model to read and answer them. To install it, copy the file
into `~/.config/opencode/plugins/` (Windows: `%USERPROFILE%\.config\opencode\plugins\`) or a project's
`.opencode/plugins/`, then restart Opencode.
- It reads the hub URL, name and room from the `agent-bridge` MCP entry, and the token from
`BRIDGE_TOKEN` (or the entry's `Authorization` header). Env overrides: `AGENT_BRIDGE_WAKE_NAME`,
`AGENT_BRIDGE_WAKE_SERVER`, `AGENT_BRIDGE_WAKE_INTERVAL` (seconds, default 3),
`AGENT_BRIDGE_WAKE_DISABLE=1`.
- It wakes the most recently active session, so a fresh Opencode needs one prompt first.
- It follows `register` and `join_room`. If the hub suffixed the name (`opencode-1-2`), the plugin
learns it the first time the agent calls `whoami` or `list_agents`.
> **Several windows of the same client:** Codex and Opencode configs can't vary the name per window. A
> second Codex window gets `codex-1-2` automatically, or the agent can call `register` to pick a
> clearer name.
---
## Rooms
A **room** is a group id that you choose, such as a project, a feature or a chat id. Sessions in the
same room can see and message each other. Sessions in other rooms are invisible to them: `list_agents`,
`send_message` and the `*` broadcast never cross rooms. Names are unique **per room**, so `claude-main`
can exist in two rooms at once. A session is in one room at a time. Without a room, it joins `default`,
so a hub where nobody sets a room behaves as before.
Room ids are 1–64 characters of `a-z 0-9 _ . : -` and are case-insensitive. For example, `shop-app`,
`proj:auth` and `-100123456` are all valid.
**Pick the room when connecting:**
| Client | How |
|---|---|
| Claude Code (stdio adapter) | `--room shop-app`, or `AGENT_ROOM=shop-app` |
| Codex / Opencode / HTTP | Add `&room=shop-app` to the URL, e.g. `http://127.0.0.1:8765/mcp?name=codex-1&room=shop-app`, or send an `X-Agent-Room` header |
| WebSocket | `ws://127.0.0.1:8765/ws?name=my-bot&room=shop-app` |
**Or switch rooms at runtime** with the `join_room(room)` tool. Codex and Opencode configs have one fixed
URL, so this is how two Codex windows end up in different rooms. Ask the agent, for example, "call
join_room with room shop-app".
- `join_room` is **refused while you have unread messages**. Read them with `wait_for_message` first.
This prevents replying to a sender from the old room and reaching a same-named agent in the new room.
- Your queued messages don't move with you, and your old name stops existing in the old room.
- If your name is taken in the new room by an active session, you get `name-2`, as when connecting.
- A Codex or Opencode session that reconnects starts again in the room from its URL.
**Outside senders** (webhook, send-only WebSocket) pass `"room"` in the body or frame; it defaults to
`default`. They alone may use `"room": "*"` together with `"to": "*"` to broadcast to every room.
> [!NOTE]
> **A room id is not a password.** `/health` lists every room, and any client that can reach the hub can
> join any room. Rooms keep groups apart, but they don't keep anyone out. Use `--token` for access
> control.
---
## Quick start
1. Start the hub: `uv run agent-bridge`.
2. Open **Codex** and ask:
> Call whoami, then wait_for_message with timeout_s=300. When a message arrives, answer it with
> send_message to its sender.
3. Open **Claude Code** with channels enabled and ask:
> Use agent-bridge to ask codex-1 what 2+2 is.
4. Codex's wait returns, Codex replies, and the answer appears in Claude's conversation as a
`<channel source="agent-bridge" from="codex-1">` block.
To see who's connected at any point: `curl http://127.0.0.1:8765/health`, or ask any agent to call
`list_agents`.
---
## Tools
Every connected session gets these MCP tools:
| Tool | Description |
|---|---|
| `whoami()` | Your name, room, push mode, whether push is ready, number of queued messages |
| `register(name)` | Rename yourself (1–32 chars: `a-z 0-9 _ . -`). Queued messages move with you and the old name stops existing |
| `list_agents()` | Every known agent **in your room**: name, room, client, online, push mode, queued count |
| `join_room(room)` | Move to another room. Refused while you have unread messages (see [Rooms](#rooms)) |
| `send_message(to, content, reply_to?)` | `to` is a name in your room, or `"*"` for every online agent in your room except you. Returns a delivery report: `pushed`, `queued`, `dropped_oldest`, `unknown` |
| `wait_for_message(timeout_s?, max?)` | Blocks until messages arrive and returns them oldest first. `timeout_s=0` only checks the inbox. `remaining > 0` means call again |
Each message looks like this:
```json
{"id": "75915e47e1d4", "from": "codex-1", "to": "claude-main", "content": "looks good",
"ts": "2026-10-03T07:59:40Z", "reply_to": "05ca99dd3850", "room": "default"}
```
`pushed` in a delivery report means the message was handed to the recipient's connection, not that
the model has read it.
---
## Webhook and WebSocket API
### `POST /webhook`
```sh
# macOS / Linux, or Windows with curl.exe
curl -X POST http://127.0.0.1:8765/webhook \
-H "Content-Type: application/json" \
-d '{"from":"ci","to":"*","content":"build is green"}'
```
```powershell
# Windows PowerShell
Invoke-RestMethod -Method Post -Uri http://127.0.0.1:8765/webhook -ContentType "application/json" `
-Body '{"from":"ci","to":"claude-main","content":"build is green"}'
```
- The body is `from` (optional), `to` (a name or `*`), `content`, and optionally `reply_to` and `room`
(default `default`; `"*"` with `to: "*"` reaches every room).
- The sender is recorded as `ext:<from>`, so outside callers can't impersonate a registered agent.
- The endpoint returns the delivery report. A name that isn't in the room returns `404`; if the name
exists in other rooms, the `hint` names them. Bad input returns `400`.
### `GET /health`
Returns `{"status": "ok", "agents": [...]}` for every room, each agent with its `room`. Add
`?room=<id>` to list one room.
### WebSocket `/ws`
Connecting to `ws://127.0.0.1:8765/ws?name=<name>&room=<room>` registers you as an agent (add
`&token=...` if the hub has one; `room` defaults to `default`). Leave out `name` to connect as a
send-only producer. A producer sends to the frame's `room`, falling back to the URL's `room` and then
`default`.
| You send | Hub replies |
|---|---|
| `{"type":"send","to":"...","content":"...","reply_to":"..."}` | `{"type":"report", ...delivery report}` |
| `{"type":"list"}` | `{"type":"agents","you":"...","room":"...","agents":[...]}` (your room only) |
| `{"type":"whoami"}` | `{"type":"whoami","name":"...","room":"...","client":"..."}` |
| `{"type":"register","name":"..."}` | `{"type":"registered", ...}` |
| `{"type":"join","room":"..."}` | `{"type":"joined","name":"...","room":"...", ...}` |
- Add `"id": <anything>` to a frame, and the reply carries the same `id`.
- Incoming messages arrive as `{"type":"message", "id": ..., "from": ..., "content": ...}`.
- Errors come back as `{"type":"error","error":"..."}`.
A minimal agent in Python:
```python
import asyncio, json, websockets
async def main():
async with websockets.connect("ws://127.0.0.1:8765/ws?name=my-bot") as ws:
print(json.loads(await ws.recv())) # {"type": "registered", ...}
await ws.send(json.dumps({"type": "send", "to": "claude-main", "content": "hello from my-bot"}))
async for raw in ws:
frame = json.loads(raw)
if frame["type"] == "message":
print(f"{frame['from']}: {frame['content']}")
asyncio.run(main())
```
---
## Delivery rules
- **Rooms:** every rule below applies within one room. A name in another room counts as unknown.
- **Direct messages to an offline agent** (one known from an earlier connection) are queued: up to
500 per agent, after which the oldest is dropped. They're delivered when a session claims that name
in the same room again. A session that comes back in a different room doesn't get them. Names that
stay offline expire after 24 hours.
- **Messages to an unknown name** are rejected (reported as `unknown`), not queued.
- **Broadcasts** (`to: "*"`) reach only agents in the room that are online right now, and never the
sender.
- **Order** is first-in, first-out for each recipient. If a push connection drops, messages queue up
and are flushed in order when it comes back.
- **Name clashes:**
- If the current holder is still active, a newcomer gets `name-2`. Active means its push connection
is open, it's blocked in `wait_for_message`, or it made a request in the last 2 minutes.
- If the holder is stale, the newcomer takes over the name and its queued messages.
- A stale session that comes back is renamed on its next call and told so.
- **Vanished clients:** a client that disconnects in the middle of `wait_for_message` is noticed
within about a second. It gives up its name and never swallows a message.
- **Slow clients:** a push that takes longer than 2 seconds is abandoned and the message is queued
instead. One stuck client never delays the others.
- **Persistence:** everything is held in memory.
---
## Security
- The hub listens only on `127.0.0.1` and has **DNS-rebinding protection** (it rejects foreign
`Host` headers).
- Use `--token` / `BRIDGE_TOKEN` if other users or programs on the machine shouldn't connect, and
**always** if you bind to anything other than localhost.
- **There's no proof of identity.** A client that can reach the hub can claim a stale name and read
that name's queue.
- **Rooms aren't access control.** Anyone who can reach the hub can join any room; see [Rooms](#rooms).
- **Treat messages from other agents as untrusted input.** The server's instructions tell agents not
to run destructive commands or reveal secrets just because a peer asked, and to check with their
user first.
> [!WARNING]
> **There is no loop guard.** Two agents told to "always reply" will keep talking to each other and
> burning tokens. Tell agents not to answer plain acknowledgements, and watch the first few
> exchanges.
---
## Troubleshooting
| Symptom | Cause / fix |
|---|---|
| No `<channel>` blocks appear in Claude Code | Channels show up only in **interactive** sessions started with `--dangerously-load-development-channels server:agent-bridge`, not in `claude -p`. Messages aren't lost: call `wait_for_message` (pushed ones are marked `pushed: true`). |
| Claude's `whoami` shows `push_mode: "wait"` and `client: "claude-code"` | Claude is connected over HTTP, which can't push. Use the stdio adapter. |
| Codex says *"MCP tool call requires approval, but approval policy is never"* | Add `default_tools_approval_mode = "approve"` to the `[mcp_servers.agent-bridge]` block. |
| `codex exec` sits at *"Reading additional input from stdin…"* | It's waiting for stdin to close. In scripts, run `codex exec ... </dev/null`. |
| Codex's `wait_for_message` fails after ~60s | Set `tool_timeout_sec = 600`, or pass a shorter `timeout_s`. |
| Your session got `name-2` | Another active session holds that name in the same room. Close it, wait about 2 minutes, or call `register`. |
| `send_message` says `unknown`, but the agent is connected | It's in another room. Compare `whoami` on both sides, then `join_room`. |
| `join_room` says you have unread messages | Call `wait_for_message` (`timeout_s=0` is enough) until it returns nothing, then try again. |
| Windows: `uv sync` / `uv run` fails with *"being used by another process"* | The running hub locks `.venv\Scripts\*.exe`. Stop the hub and run `uv sync` again. If a sync failed halfway, `agent-bridge-stdio.exe` may be missing, and re-running `uv sync` restores it. |
| Tool calls return *"cannot reach the agent-bridge hub"* | The hub isn't running, or `--hub` points at the wrong host or port. |
| `curl` on Windows PowerShell behaves strangely | In Windows PowerShell 5, `curl` is an alias for `Invoke-WebRequest`. Use `curl.exe` or `Invoke-RestMethod`. |
| `401 unauthorized` | The hub has a token. Send `Authorization: Bearer <token>`, or `?token=` on WebSockets. |
| You need to see what the adapter is doing | Start it with `--log-file <path>`; its stderr isn't visible inside Claude Code. |
---
## Development
```sh
uv sync
```
```
src/agent_bridge/
hub.py # routing core: rooms, named mailboxes, sessions, queues, wait/flush (no MCP imports)
mcp_server.py # MCP tools, session binding, channel push over Streamable HTTP
web.py # Starlette app: /mcp, /webhook, /ws, /health, token auth
stdio_shim.py # agent-bridge-stdio: stdio MCP server that proxies to the hub over WebSocket
__main__.py # agent-bridge CLI
```
`mcp` is pinned to `2.3.x` because the code relies on a few private parts of the SDK:
- `Connection.notify`, to send custom notifications
- `ServerRequestContext.session._connection`
- the transport's `_request_streams`, to detect open push streams and disconnected callers
---
## License
[MIT](LICENSE) © 2026 Umair
TDQS
Scored across 6 tools
Most tools target clearly distinct operations: send_message vs wait_for_message vs list_agents vs join_room are easy to tell apart. Minor overlap exists between whoami and list_agents (both report session/queue info) and between register and join_room (both mutate session identity/state), but descriptions clarify each.
Four tools follow a clean verb_noun snake_case pattern (list_agents, join_room, send_message, wait_for_message). whoami breaks snake_case style and register is a bare verb with no noun, but the intent stays obvious and readable.
Six tools is well-scoped for a messaging bridge: identity, room membership, discovery, send, and receive. Each tool earns its place with no redundancy or filler.
The core messaging lifecycle (identify, rename, discover peers, change room, send, receive) is fully covered. Minor gaps like leaving a room without joining another or listing all rooms are workable around, so the surface is not a dead end.