Two-way message relay between Claude Code instances on different machines. Send handovers, task updates, and coordination messages between any number of machines through a Cloudflare Worker.
If you use Claude Code on multiple machines (e.g. a Mac laptop and a Windows desktop), there's no built-in way for agents to communicate across machines. Photon bridges that gap — agents can leave messages for each other, hand off work context, and coordinate tasks across your setup.
Use cases:
- Cross-machine handovers — finish work on your laptop, send context to your desktop agent
- Task coordination — direct agents on different machines from one place
- Multi-agent messaging — tag and filter messages by project, machine, or topic
- Session continuity — leave notes for your next session on any machine
Machine A (Claude Code) Machine B (Claude Code)
| |
MCP server (stdio) MCP server (stdio)
| |
+-----------> Cloudflare Worker <-------------+
(KV message store)
Both machines run the same MCP server. Each identifies itself via BRIDGE_MACHINE_ID env var. Messages are stored in Cloudflare KV and accessed over HTTPS with a shared API key.
- A Cloudflare account (free tier works fine)
- Node.js (for deploying the Worker)
- Python 3.10+ (for the MCP server)
- Claude Code installed on your machines
git clone https://github.com/zamabama/photon.git
cd photonThe Worker is the message relay that both machines talk to. Free tier is more than enough.
cd worker
npm install
# Create a KV namespace for message storage
npx wrangler kv namespace create MESSAGES
# This outputs a namespace ID — copy it into wrangler.toml
# Set your shared API key (any random string — both machines need the same one)
npx wrangler secret put BRIDGE_API_KEY
# Deploy
npx wrangler deployAfter deploying, note your Worker URL (e.g. https://photon.<your-account>.workers.dev).
pip install mcp httpxOn each machine, add Photon to your project's .mcp.json (or create one):
{
"mcpServers": {
"photon": {
"command": "python3",
"args": ["/path/to/photon/mcp_server.py"],
"env": {
"BRIDGE_WORKER_URL": "https://photon.<your-account>.workers.dev",
"BRIDGE_API_KEY": "<your-shared-secret>",
"BRIDGE_MACHINE_ID": "mac"
}
}
}
}Configuration:
| Variable | Description |
|---|---|
BRIDGE_WORKER_URL |
Your deployed Worker URL |
BRIDGE_API_KEY |
Shared secret (same on all machines) |
BRIDGE_MACHINE_ID |
Unique identifier for this machine (e.g. "mac", "pc", "work-laptop") |
BRIDGE_PROJECT |
(Optional) Project name for filtering messages across projects |
PHOTON_WORKSTREAM |
(Optional) Default workstream sub-identity for this agent (see Workstreams). Usually set at runtime via set_identity instead. |
Windows note: Use "python" instead of "python3" for the command.
Multi-project setup: You can add Photon to multiple projects on the same machine. Set different BRIDGE_PROJECT values to filter messages per project, or leave it empty for global messages.
Add this to your project's CLAUDE.md so agents know to check messages:
## Photon — Message Bridge
Check photon at the start of every session:
- Use `check_messages` to see unread count
- Use `read_messages(unread_only=true)` to read pending messages
- Act on any task direction or handover notes
- When finishing a session, send a handover summary via `send_message`Once configured, Claude Code gets these tools:
| Tool | Description |
|---|---|
check_messages |
Is anything unread for me? Returns all unread addressed to you, any age (no recency window). |
read_messages |
Read messages addressed to you. Defaults to all unread + auto-marks read. as_recipient= / include_all_lanes= look beyond your lane only when the user directs you to. |
send_message |
Send a message with optional tags. Refuses cross-project sends without target=, and holds sends to a #workstream no live agent is using (allow_unregistered=true to force). |
set_identity |
Claim a workstream sub-identity (e.g. "website") so this agent gets its own independent read cursor and a roster entry. |
list_identities |
List the agents/workstreams currently active on a project (the roster). Call before set_identity or before targeting a specific lane. |
resolve_project |
Scan ~/dev and ~/Documents for sibling Photon projects. Call before any cross-project send. |
mark_read |
Mark a specific message as read by ID. |
clear_messages |
Delete all messages (requires confirm=true safety check). |
check_messages and read_messages return all of your unread mail regardless of age. There is no "fresh" cutoff. This matters for async dispatch: an orchestrator hands an agent a task, then 20–40 minutes pass before the agent checks — the assignment must still surface, not be hidden as "stale." Pass max_age_minutes= or since= only if you explicitly want to narrow the window.
photon_watch.py is a lightweight, non-LLM poller you run through Claude Code's Monitor tool. It checks the Worker for unread mail addressed to your identity every ~30s and prints a line only when a new message arrives — which Monitor delivers as an async event that wakes the agent. So the model is invoked only when there's actually mail; empty polls cost nothing but a tiny KV read (well within the free tier even with many agents).
An agent arms it once per session (the MCP server instructs this automatically):
Monitor(command="python3 /path/to/photon/photon_watch.py", persistent=true, description="photon inbox")
It self-configures with no secrets passed through the model: the MCP server writes ~/.photon/session-<CLAUDE_CODE_SESSION_ID>.json (chmod 600) on boot, and the watcher reads it by session id to get the Worker URL/key and its identity (including any restored workstream). It suppresses the existing backlog on arm (no re-notifying mail you already had) and only announces new arrivals. Re-arm after an IDE reload or a set_identity lane change. For stronger automation, add a SessionStart hook that nudges the agent to arm it.
If you send a message tagged with another project's name but forget target=, the server refuses the send (since the message would be silently scoped to your own project and the other agent would never see it). Call resolve_project(hint='<project>') first — it returns the correct composite identity to use as target=, and warns if the same identity is shared by multiple project directories (the "everyone is mac/global" trap).
By default, every agent opened on the same project root shares one identity (<machine>/<project>) and one mailbox. That's fine for a single agent, but if you run several chats/agents on the same project, the first one to read a broadcast marks it read for everyone else.
Workstreams fix this. Each agent claims a sub-identity — <machine>/<project>#<workstream>, e.g. mac/primogen#website — that has its own independent read cursor. One agent reading a message never clears it for the others, and a broadcast is seen fresh by every lane.
# At session start, when several agents share a project:
list_identities() # see who's already active / what's taken
set_identity("website", description="landing redesign") # claim a free lane
- Read independence —
#websitereading mail never affects#crypto's unread state. - Roster —
set_identityregisters you; agents refresh on activity and drop off after 15 min idle.list_identities(project="X")shows the live lanes for your own or another project. - Collision-safe — claiming a
#workstreamanother live agent already holds is refused, so two agents can't silently land on the same cursor. (Base identities without#stay shareable — the original single-mailbox behaviour.) - Targeting a lane —
send_message(target="mac/primogen#website")reaches just that lane. Cross-project too:list_identities(project="cocobun")then target the identity it returns. - Backward compatible — no workstream means the plain project mailbox, exactly as before.
An agent only ever receives mail addressed to it — its own identity, or its project's broadcasts. This is enforced server-side: the Worker never transmits another lane's message to you, so an agent can't read, surface, or act on work meant for a different workstream. check_messages and read_messages show your mail and nothing else. This keeps a master → N-workers fan-out clean: each worker sees only its own task.
To give one specific agent a task, target it —
send_message(target="mac/primogen#website"). Don't broadcast per-agent tasks; a broadcast is seen by every lane on purpose.
Sometimes you do need to reach across lanes — e.g. a message was mis-sent to the wrong one. These are deliberate, user-directed escape hatches; agents should not use them on their own initiative:
- Prevent it up front —
send_messageto a#workstreamno live agent is using is held, not sent, and returns the roster so you can pick the right lane. Override withallow_unregistered=true. - Read one specific lane —
read_messages(as_recipient="mac/primogen#crypto")reads that lane's mail without changing your own identity (noset_identity-there-and-back). Consumes that lane's copy by default;keep_unread=trueto peek. - Search every lane —
read_messages(include_all_lanes=true)returns all lanes in your project, read-only, for when "there should be a message for me but I can't find it." Check each message'stobefore acting.
You: "Check photon for any messages from my PC"
Agent: [calls check_messages] → "2 unread messages from pc"
Agent: [calls read_messages(unread_only=true)] → shows messages
You: "Send a handover to my PC about where we left off"
Agent: [calls send_message with context summary]
All endpoints require Authorization: Bearer <key> header except /health.
| Method | Path | Description |
|---|---|---|
GET |
/health |
Health check (no auth required) |
POST |
/messages |
Send a message |
GET |
/messages |
List messages |
POST |
/messages/:id/read |
Mark message as read (scoped to ?identity=) |
DELETE |
/messages/:id |
Delete a message |
DELETE |
/messages?confirm=true |
Delete all messages |
POST |
/presence |
Register / refresh an identity in the roster |
GET |
/presence?project= |
List active identities for a project |
| Parameter | Description |
|---|---|
unread |
"true" to return only unread messages |
identity |
Caller's identity — scopes both visibility (only mail addressed to this identity or its project's broadcasts is returned) and unread/mark_read (per-recipient read state). Falls back to legacy global behaviour if omitted. |
all_lanes |
"true" to bypass the visibility filter and return every lane's mail (for a user-directed cross-lane search). |
mark_read |
"true" to mark the returned messages read for identity in the same call |
from |
Filter by sender machine ID |
project |
Filter by project name |
tag |
Filter by tag |
limit |
Max messages to return (default 50) |
since |
ISO timestamp — only messages after this time |
{
"id": "uuid",
"from": "mac/my-project#website",
"to": null,
"project": "my-project",
"timestamp": "2026-02-28T12:00:00.000Z",
"content": "Finished the auth refactor. Tests passing. Ready for review.",
"tags": ["handover", "auth"],
"readBy": ["mac/my-project#website"]
}readBy lists the identities that have read the message — read state is per-recipient, not a single global flag, so each workstream clears its own copy independently. (to is the target identity for a directed send, or null for a project broadcast. Messages from before this change used a single read: true/false boolean; those are treated as read-by-everyone.)
photon/
├── mcp_server.py ← MCP server (Python, stdio transport)
├── photon_watch.py ← inbox watcher for auto-notify (run via the Monitor tool)
├── requirements.txt ← Python dependencies (mcp, httpx)
├── README.md
├── REPORT_subproject_mailboxes.md ← design notes: workstreams, roster, mis-addressed mail
└── worker/
├── wrangler.toml ← Cloudflare Worker config
├── package.json
└── src/
└── worker.js ← Cloudflare Worker (message relay)
Cloudflare Workers free tier includes 100,000 requests/day and 1GB KV storage. For typical Claude Code usage (a few hundred messages per day at most), you'll never come close to these limits. Photon costs nothing to run.
MIT