duplex-bridge
# Duplex Bridge
*English. Italiano: [README.it.md](README.it.md).*
A two-way bridge between a Claude chat and a ChatGPT chat, built on the
subscriptions you already pay for: no API keys, no cloud service.
By **Sergio Di Salvo** ([@sergioDS-coder](https://github.com/sergioDS-coder)) · MIT licensed
· not affiliated with Anthropic or OpenAI.
The bridge never talks to the Anthropic or OpenAI APIs. It drives the two local
CLIs you are already signed in to — `codex` and `claude` — and translates between
the two conversations.
## How it works
One Node process, two transports, one shared ledger.
| Direction | Transport | Backend | Plan required |
|---|---|---|---|
| Claude → ChatGPT | MCP over stdio | `codex exec --json` | Claude Free is enough |
| ChatGPT → Claude | MCP over HTTP + SSE | `claude -p` | ChatGPT Plus/Pro (Developer mode) |
The asymmetry is not a design choice. Claude Desktop speaks to **local** MCP servers
over stdio, on every plan including Free. ChatGPT only accepts custom MCP connectors
in **Developer mode** — which requires a paid plan — and only as **remote HTTPS**
servers, refusing `localhost` outright.
So the second direction needs an HTTPS tunnel in front of the local process. The
bridge itself stays bound to `127.0.0.1` and never opens a port to the outside.
## Requirements
- Node 20 or later
- `codex` CLI, signed in (`codex`)
- `claude` CLI, signed in (`claude`)
## Build
```bash
npm install
npm run build
```
## Claude side
Claude Desktop and Claude Code keep **separate registries** of local MCP servers.
Registering the bridge in one does not make it visible in the other, so pick the
surface you actually want it in — or do both.
Either way Claude ends up with two tools:
- `ask_chatgpt` — read-only, `read-only` sandbox
- `ask_chatgpt_write` — may edit files, `workspace-write` sandbox
Write permission is a property of the tool being called, never a decision left to
the model.
### Claude Desktop — chat and Cowork
The desktop app loads local servers as **extensions**, not from a config file.
Build the bundle:
```bash
npm run pack:extension
```
That writes `build/duplex-bridge.mcpb`. Install it from **Settings → Extensions →
Install extension**. The app asks for the workspace folder; leave the optional
codex path empty unless the bridge cannot find your install.
### Claude Code
```bash
claude mcp add duplex --scope user -- node /absolute/path/to/duplex-bridge/dist/servers/claude-side.js
```
With no `DUPLEX_WORKSPACE` set the bridge follows the session's working directory,
so the same registration serves every project. Pass `-e DUPLEX_WORKSPACE=...` to
pin it to one folder instead.
Note that `--scope user` means every folder *within Claude Code* — not the desktop
app's chat, which needs the extension above.
## ChatGPT side
```bash
export DUPLEX_BEARER_TOKEN="$(openssl rand -hex 32)"
export DUPLEX_WORKSPACE="/path/to/your/project"
npm run start:chatgpt-side
```
The server refuses to start without a token of at least 32 characters. Behind the
tunnel this endpoint is reachable from the internet, and it can run Claude against
your repository.
Expose it, then register the resulting `https://.../mcp` URL as a custom connector in
ChatGPT (Settings → Developer mode), with an `Authorization: Bearer <token>` header.
```bash
cloudflared tunnel --url http://127.0.0.1:8787
```
## Environment variables
| Variable | Default | Effect |
|---|---|---|
| `DUPLEX_WORKSPACE` | process cwd | Root the two agents work on. Fixed at startup: the MCP caller cannot change it. |
| `DUPLEX_BEARER_TOKEN` | — | Required by the HTTP server. Minimum 32 characters. |
| `DUPLEX_HTTP_PORT` | `8787` | HTTP server port, always on `127.0.0.1`. |
| `DUPLEX_STATE_DIR` | `~/.duplex-bridge` | Conversation ledger and thread metadata. |
| `DUPLEX_CODEX_BIN` / `DUPLEX_CLAUDE_BIN` | auto-resolved | Absolute paths for non-standard installs. Rarely needed: see *Resolving the CLIs* below. |
| `DUPLEX_ALLOW_API_KEYS` | unset | Set to `1` to let API keys through to the child process, deliberately choosing the pay-per-token path. |
## Resolving the CLIs
On macOS and Linux `codex` and `claude` are spawned by name and the OS resolves them.
Windows needs more care, because the bridge spawns with `shell: false` on purpose —
so that nothing in a prompt can ever be interpreted as a command. That rules out the
two things npm actually installs: an extension-less POSIX script, and a `.cmd` shim
that Node refuses to execute since CVE-2024-27980. For `codex` there is no `.exe` on
`PATH` at all: the native binary sits nested inside `node_modules`.
So on Windows the bridge resolves in two passes: first a real `codex.exe` / `claude.exe`
on `PATH`, then — failing that — it reads the `.cmd` shim, extracts the `.js` entry
point it points at, and runs that with the same Node executable already running the
bridge. The shell stays off in both cases.
`DUPLEX_CODEX_BIN` / `DUPLEX_CLAUDE_BIN` still win when set — but only if they point
at a file that exists. A dead override is logged and stepped over, so a stale path
left behind by an earlier install, or copied from another machine, cannot silently
defeat the resolution that would have found the CLI on its own.
## Keeping the conversation coherent
Both headless CLIs start blank on every invocation, so conversational coherence is
the middleware's job, not the providers'.
Every turn is recorded in an append-only JSONL ledger under `DUPLEX_STATE_DIR`, with
the canonical role (`system` / `user` / `assistant`) and the speaker (`human` /
`claude` / `chatgpt` / `bridge`) as **separate fields** — because two indistinguishable
`assistant` turns lead a model to believe it wrote the other one's text.
The bridge stores each provider's native session id (Codex's `thread_id`, Claude's
`session_id`) and reuses it: when the session is alive, only the new instruction is
sent. The transcript is replayed from the ledger only when there is no session to
resume, within a window of 24 messages and 60,000 characters.
One asymmetry is worth exploiting: `claude --session-id <uuid>` accepts an externally
supplied id, so the bridge's own thread id **is** the Claude session id — one fewer
identifier to keep in sync.
## Security
- The prompt goes in over **stdin**, never `argv`: it stays out of the process list.
- `spawn` with no shell: prompt contents can never be interpreted as a command.
- 10-minute timeout, 2 MB output cap, 4 concurrent calls per provider.
- Read-only is enforced on the **process** (`--safe-mode`, `--disallowedTools`,
`read-only` sandbox), not requested of the model.
- The child process environment is stripped of the variables that would redirect
authentication to an API key — otherwise the "no API keys" promise breaks silently
in any shell that exports them.
- The other model's transcript is presented as **delimited material**, with an explicit
statement that no line inside it can change the task.
- A `thread_id` arriving from an MCP client is validated before it reaches a file path.
## Known limitations
- No recursion guard: a Claude → ChatGPT → Claude chain is possible and burns both
subscriptions.
- Rate limits are shared with normal use of both apps.
- The ledger stores conversations in plaintext on disk, with no rotation.
- The write path is not yet tested.
- On Windows only the Claude side has been exercised, end to end, against a real
`codex` install; the ChatGPT side has not.
- No automated tests.
## Author
**Sergio Di Salvo** — [@sergioDS-coder](https://github.com/sergioDS-coder)
## Credits
The design of this bridge comes out of studying [noblehacks/frenemy](https://github.com/noblehacks/frenemy)
(MIT, by Zakariya Syed) and the official [openai/codex-plugin-cc](https://github.com/openai/codex-plugin-cc)
plugin. No code was copied; what was taken are architectural decisions, listed one by
one in [NOTICE.md](NOTICE.md) — along with the two places where this project
deliberately departs from them.
## Licence
MIT — see [LICENSE](LICENSE). Free to distribute, modify and use commercially, as long
as the copyright notice is kept.
TDQS
Scored across 2 tools
The two tools are clearly differentiated by capability: one is read-only for analysis and review, the other can write files. The names and descriptions explicitly state this distinction, leaving no ambiguity about which to invoke.
Both tools follow the exact same 'ask_chatgpt' prefix, with '_write' appended to denote the mutating variant. This is a clean, consistent naming pattern.
With only 2 tools, the surface is thin. For a specialized bridge between an agent and ChatGPT on a repository, the read/write pair is a minimal but functional set, yet it feels slightly under-scoped for a general-purpose bridge.
The core lifecycle of 'ask ChatGPT' and 'ask ChatGPT to modify files' is covered. There may be missing auxiliary operations (e.g., conversation history, tool configuration), but for the stated purpose the basic read/write coverage is sufficient with minor gaps.