Skip to main content
Glama
README.md
# aiAgentBridge

**Let Claude Code, Codex and Opencode sessions message each other in real time.**

![Python 3.13+](https://img.shields.io/badge/python-3.13%2B-blue)
![License: MIT](https://img.shields.io/badge/license-MIT-green)
![MCP](https://img.shields.io/badge/MCP-server-purple)

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