The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Intercom listing page.
Two-way communication bridge between Claude Code sessions using the Channels API.
Let two Claude Code instances on different machines talk to each other in real-time. One sends a message, the other receives it instantly as a channel notification and can reply back.
Claude Code has native cross-session messaging now. Here's where Intercom is still the answer:
send_message reports sent, delivered-to-process and answered as three separate states, plus a check_message tool. You find out when the other side never picked it up, instead of planning around an answer that was never coming.If you're on macOS or Linux and you just want your own sessions talking to each other, native is simpler — use that. Everything else above is what this is for.
If you have a backend developer and a frontend developer each running Claude Code on separate machines, they currently have to relay questions through Slack/Discord/copy-paste. Claude Intercom creates a direct hotline between the two AI sessions — one Claude can ask the other about endpoints, schemas, or implementation details and get answers from the actual codebase.
Both instances run the same intercom.ts file. Each listens for HTTP messages and pushes them into its local Claude Code session via the Channels API. Each also exposes send_message and check_message tools that Claude can call.
Paste this into a Claude Code session on each machine and it will walk you through the whole thing. Run it on machine A first, keep the secret it gives you, then run it on machine B.
Prefer to do it by hand? The manual steps are below.
Nothing to install. npx fetches it on first run, on both machines.
You do need Bun on your PATH — the server uses Bun.serve for its HTTP listener, and the npx entry point hands off to it.
Then use "command": "bun" with "args": ["/path/to/claude-intercom/intercom.ts"] in the config below instead of the npx form.
Copy the example config into your project's .mcp.json.
First generate a secret, and use the same value on both machines:
Then export it in the shell you launch Claude Code from, rather than typing it into the config:
.mcp.json is a project file, and project files get committed. Claude Code expands ${VAR} in .mcp.json — in command, args, env, url and headers — so the config below can be checked in and shared with your teammate while the secret itself never leaves your environment.
Machine A (e.g. backend — static IP or VPS):
Machine B (e.g. frontend — can be behind NAT):
On both machines:
In either Claude Code session:
"Send a message to the other developer asking what API endpoints are available for the dashboard."
Claude will use the send_message tool to POST the message to the other machine. The other Claude receives it as a channel notification and responds.
Use Tailscale. Install it on both machines and they get stable private addresses on your own tailnet. Then point each side at the other's tailnet address:
This is strictly better than exposing a port to the internet: no public listener, no port forwarding, the address doesn't change when your ISP reassigns your IP, and device identity is enforced by Tailscale rather than resting entirely on a shared string. Set INTERCOM_HOST to your tailnet address — or to 127.0.0.1 if you are also fronting it with a tunnel — to be certain nothing outside can reach it at all.
If you can't use Tailscale, ngrok still works:
ngrok hostnames are detected and switched to HTTPS automatically. For any other tunnel — Cloudflare, Caddy, a reverse proxy of your own — write the scheme into REMOTE_HOST explicitly (https://your-host), or the secret goes out over cleartext HTTP.
Note this does put a publicly reachable endpoint in front of your Claude session, gated only by the shared secret — pick a strong one, and consider INTERCOM_HOST=127.0.0.1 so only the tunnel can reach the listener.
send_messageSends a message and returns immediately with an id. It does not wait for an answer — the reply arrives later as its own inbound message.
| Argument | Required | Description |
|---|---|---|
message | Yes | The text to send |
replyTo | No | The id of the incoming message this answers, so the sender can correlate it |
expectReplyWithin | No | How long a reply should reasonably take: "30s", "5m", "2h" |
The acknowledgement deliberately does not claim the other developer received it. An HTTP 200 proves the remote process accepted the message, not that the other Claude ever read it.
check_messageAnswers the question a fire-and-forget channel otherwise can't: is this reply slow, or is it never coming? Pass an id, or omit it to list everything outstanding.
Every message sits in one of three states:
| State | Meaning |
|---|---|
sent | We tried, but the remote process never acked it. The other machine is probably unreachable |
delivered-to-process | Their intercom took it. Their Claude may or may not have read it — that session could be idle, closed, or out of usage |
answered | A reply came back carrying replyTo for this id |
expectReplyWithin is what makes "overdue" mean anything, and it's per message rather than one global timeout — so a quick endpoint lookup and a long investigation aren't judged on the same clock.
| Environment Variable | Required | Default | Description |
|---|---|---|---|
MY_ROLE | Yes | developer-a | Label for this instance (appears in message tags) |
REMOTE_HOST | Yes | localhost:8789 | Address of the other machine (host:port or tunnel URL). Include https:// for any TLS tunnel that isn't ngrok |
INTERCOM_SECRET | Yes | none | Shared secret — must match on both sides. There is no default: unset, left as a docs placeholder, or an unexpanded ${VAR}, and the intercom refuses to pair |
INTERCOM_PORT | No | 8788 | Port to listen on for incoming messages |
INTERCOM_HOST | No | 0.0.0.0 | Interface to bind the listener to. Use 127.0.0.1 when a tunnel fronts it |
INTERCOM_SEND_TIMEOUT_MS | No | 10000 | How long an outbound POST may hang before giving up |
intercom.ts as a subprocess (MCP server over stdio)claude/channel capability — this registers it as a Channelmcp.notification() with method: 'notifications/claude/channel'<channel> tag, carrying the message idsend_message with replyTo set to that id, which POSTs to the remote machinereplyTo against its own outbound record and marks that message answeredThe stdio leg is not an implementation detail you can swap for HTTP. It is what attaches the server to a specific live Claude Code session — the claude/channel capability is registered over that connection, and it's the reason mcp.notification() lands in a conversation at all. Host this remotely and the notification goes to whatever MCP client connected instead.
POST /message requires an X-Intercom-Secret header matching the configured secret, compared in constant time. Anything else gets a 401 Unauthorized. GET /health is deliberately not authenticated, so you can verify a tunnel end to end — it reports this instance's role and version to anyone who asks, so treat a reachable intercom as discoverable.id and a timestamp, but neither is checked for freshness or reuse. Someone who captures a single authenticated request on a cleartext link can resend it verbatim, as often as they like.INTERCOM_SECRET has no fallback value. Leave it unset, leave a docs placeholder in place, or reference a ${VAR} you never exported, and the intercom starts unpaired — it binds no port and send_message refuses, explaining why. The unexpanded-${VAR} case matters because Claude Code passes a missing variable through as literal text, which would otherwise give both machines the same guessable secret.0.0.0.0 for cross-machine access. Set INTERCOM_HOST=127.0.0.1 when a tunnel is doing the reaching, so only the tunnel can connect.Warning: Those instructions are a default, not a boundary. You cannot fix prompt injection with prompt instructions — anyone holding your secret and address can put text into your Claude session, and the only real limits are that session's own permission prompts. Native cross-session messaging enforces this properly with hold/accept/refuse inbound controls; this does not. Use a strong secret, keep it off the public internet, and don't pair with a peer you wouldn't hand a terminal to.
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /health | No | Returns {"status":"ok","role":"...","version":"..."} |
POST | /message | X-Intercom-Secret header | Pushes message into Claude's session |
POST /message body:
id and replyTo are optional — a message without them still delivers, it just can't be correlated.
The body is schema-validated before anything reaches your session. content is required and capped at 32,000 characters; id and replyTo must look like machine ids ([A-Za-z0-9_-], ≤64); role and timestamp may not contain < > " ' or any Unicode control, zero-width, or bidi-override character, because those are what a sender would use to forge the <channel …> wrapper the message is rendered inside. Anything else gets a 400, and a body over 64 KB gets a 413.
MIT