Letterbox
๐ Built for internal production use. Architecture proven across months of daily AI development. Open-sourced as a reference implementation.
In plain terms: If you use AI coding assistants in the terminal, you normally work with one at a time โ and getting two of them to collaborate means copy-pasting messages between windows yourself. Letterbox lets two assistants (say, Claude and Gemini, or Gemini and Mistral's Vibe) talk directly to each other and work a task together, hands-free.
The result: one agent can plan while another reviews, or the two can split the work between them โ collaborating on their own while you watch, instead of relaying every message by hand.
A small file-based comms protocol that lets two AI agents in separate terminals talk to each other in real time.
Letterbox lets two terminal coding agents โ Claude Code, Gemini CLI, Antigravity, or Mistral's Vibe โ hold a real-time conversation by passing message files through a shared directory. When one agent speaks, a ๐ฌ notification is injected into the other's terminal and wakes it to read and reply. No network, no server, no shared memory: just JSON files in a folder and the OS's atomic-rename. It's the messaging layer that was built for an internal planning loop, extracted into a standalone, versioned tool in 2026. If you've ever wanted two CLI agents to collaborate on a task without you copy-pasting between windows, this is for you. It gets the occasional update at the author's whim (the launcher tells you when a newer one is out) โ but it's unsupported: no roadmap, no feature requests, not a community project.
The bridge is genuinely cross-harness: Claude on one side, Gemini on the other, talking through the same channel, has been verified live. The one wrinkle is setup โ Claude wires itself automatically, while Gemini and Antigravity load letterbox from their own settings. The Setup section walks through both.
Why it exists
I work with two AI collaborators every day โ Claude and Gemini โ each living in whatever terminal harness it runs in (Claude Code, Gemini CLI, Antigravity CLI, and now Mistral's Vibe). Letterbox is how I get them talking to each other instead of through me.
That happens in two modes. Sometimes it's manual: we're brainstorming and I want to loop the other model into the conversation. Sometimes it's automated โ in the planning loop, Claude drafts a plan and each plan is routed to Gemini for review as a built-in stage. Letterbox carries both the same way.
It's harness-agnostic by design โ Claude Code โ Gemini CLI โ Antigravity CLI โ Vibe in any combination โ and same-model pairs work just as well: two Claude tabs, or two Gemini tabs, talking over one channel.
What it is
Each letterbox <harness> launch runs two coordinated processes inside one terminal:
letterbox claude --channel demo --as alice
โ
โโ PTY-Parent (the foreground letterbox process)
โ โข spawns the harness CLI as a PTY child
โ โข watches the channel directory for peer writes
โ โข injects ๐ฌ notifications into the PTY on arrival
โ
โโ the harness spawns:
โโ letterbox mcp (stdio MCP server, agent-spawned)
โข send_message / check_messages / acknowledge
โข check_latest_message / channel_info / list_channels
Both sides coordinate ONLY through the filesystem:
~/.letterbox/channels/demo/
msg-*.json โ one file per message
.read/alice.json โ per-agent read markers
.read/bob.json
There is no daemon, no IPC, no background service. The filesystem is the coordination medium โ the PTY-Parent's watcher sees a new msg-*.json appear and renders a notification; the channel directory is durable, inspectable, and cat-able. Crash recovery is trivial because nothing valuable lives in memory.
How the agent gets the letterbox tools differs per harness, and it's the one thing you configure once:
- Claude Code takes a launch flag, so letterbox wires it automatically โ it generates a temporary MCP config and passes
--mcp-configtoclaude. Nothing for you to set up. - Gemini CLI and Antigravity don't take that flag; they load MCP servers from their own settings file. You add a one-line, channel-agnostic
letterboxentry there once, and the launcher hands each session its channel and identity through environment variables at launch โ so you never edit settings per channel. - Vibe loads MCP servers from
~/.vibe/config.toml. Its MCP subprocess inherits only a trimmed environment, so a one-time bridge script is needed to relayLETTERBOX_CHANNEL/LETTERBOX_SENDERfrom Vibe's own process env. Once that's in place, any channel works exactly like Gemini. See the Vibe setup section.
Who it's for
- People running terminal coding agents who want autonomous AIโAI dialogue on one machine, without babysitting copy-paste between windows.
- People who value files as the source of truth โ auditable, greppable, no opaque protocol, no magic.
Who it's NOT for
- Anyone wanting a hosted or networked chat service โ the message protocol is filesystem-local and never touches the network. (The launcher makes one optional, best-effort version check at startup; disable it with
LETTERBOX_NO_UPDATE_CHECK=1.) - Anyone wanting a multi-user platform โ it's a point-to-point bridge between agents on one machine, not a many-user hub (see Built for two).
- Windows-native users โ v1 is POSIX-only (see What we don't support).
- Anyone wanting a supported product โ letterbox is versioned and gets the occasional update at the author's whim (the launcher tells you when a newer one is out), but there's no roadmap, no SLA, and no commitment to take feature requests or keep maintaining it. Use it as-is; pull a newer version if it helps.
Built for two
Letterbox is a two-way bridge at heart โ one peer talking to one peer is what it's designed and tuned for. Three or more agents can share a channel: directed addressing (send_message(to="<label>")) and the participants list make it workable, and same-channel broadcasts reach everyone. But a shared channel is a broadcast bus โ every message wakes every participant. Without orchestration (turn-taking, a designated coordinator, or rules about who speaks when), an N-way room becomes a notification storm that can chew through a model's message/usage limits surprisingly fast. If you want three or more, bring your own conductor. The substrate is honest about who's in the room; the etiquette is on you.
Install
Letterbox is installed from source (a wheel is buildable; it is not currently published to PyPI). From the repo root:
pip install -e . # or: pip install -e ".[dev]" for the test extras
This puts the letterbox command on your PATH. The command must resolve by name โ each agent spawns letterbox mcp itself โ so this is load-bearing. Confirm it:
which letterbox # note this absolute path; Gemini/Antigravity setup needs it
You also need the harness you're launching (claude, gemini, antigravity, or vibe) installed, on your PATH, and logged in. Letterbox launches it for you.
Updating
Letterbox is versioned (letterbox.__version__, the single source of truth); since there's no PyPI release, the git main HEAD is the release. On a human-facing launch the CLI makes one best-effort check (at most once a day, cached under ~/.cache/letterbox/) and prints a one-line notice if a newer version exists. To update:
pip install --upgrade "git+https://github.com/dovahkiin-v/letterbox"
This is the only network call letterbox ever makes โ the messaging protocol stays fully local. It runs with a tight timeout and is fully fail-silent: if it can't reach GitHub it simply prints nothing and never delays your launch. It is never run for letterbox mcp (the agent's stdio server). Disable it entirely with LETTERBOX_NO_UPDATE_CHECK=1.
Setup per harness
You only do this once per harness. Skip the harnesses you won't use.
Claude Code โ nothing to do
Letterbox wires Claude automatically: at launch it writes a temporary MCP config (mode 0600) and passes --mcp-config <path> to claude. The letterbox tools appear in that session and nowhere else. There is no settings file to edit.
Gemini CLI โ two one-time steps
1. Register the MCP server in ~/.gemini/settings.json (create the file if it doesn't exist). Use the absolute path to your installed letterbox (from which letterbox above), and pass only ["mcp"] โ no channel, no identity:
{
"mcpServers": {
"letterbox": {
"command": "/absolute/path/to/letterbox",
"args": ["mcp"]
}
}
}
This entry is channel-agnostic on purpose. The launcher exports LETTERBOX_CHANNEL, LETTERBOX_SENDER, and LETTERBOX_INSTANCE_ID into Gemini's environment at launch, and the MCP server reads them โ so the same single entry serves every channel and you never edit it again. (This mirrors how the Forge orchestrators pass a channel via an env var.)
2. Trust the folder you launch from. Gemini refuses to run in an untrusted directory without an interactive "do you trust this folder?" prompt โ and a blocking TUI prompt would stall the automation. Pre-trust the launch directory (or a parent) in ~/.gemini/trustedFolders.json:
{
"/home/you/projects": "TRUST_PARENT"
}
TRUST_FOLDER trusts exactly that directory; TRUST_PARENT trusts it and everything beneath, so one entry covers all your project folders. (Tip: don't reach for Gemini's --skip-trust flag to dodge this โ it forces a workspace-system-prompt lookup that crashes even in already-trusted directories. Trust the folder instead.)
Antigravity (agy)
Launch it as letterbox agy โฆ (the long form letterbox antigravity โฆ also works โ agy is just an alias matching the binary name). Antigravity receives its per-launch channel and identity through the same environment variables as Gemini; what differs is how you register the MCP server. agy loads MCP servers from plugins, so you install letterbox as a tiny local plugin (a directory with two JSON files):
# 1. Build the plugin (one directory, two files). Use the absolute
# `letterbox` path from `which letterbox`.
mkdir -p ~/.letterbox/agy-plugin/letterbox
cat > ~/.letterbox/agy-plugin/letterbox/plugin.json <<'JSON'
{ "name": "letterbox", "version": "1.0.0",
"description": "Letterbox file-based AI-to-AI comms bridge." }
JSON
cat > ~/.letterbox/agy-plugin/letterbox/mcp_config.json <<'JSON'
{ "mcpServers": { "letterbox": {
"command": "/absolute/path/to/letterbox", "args": ["mcp"] } } }
JSON
# 2. Install it (and confirm).
agy plugin install ~/.letterbox/agy-plugin/letterbox
agy plugin list
The mcp_config.json is channel-agnostic for the same reason Gemini's settings entry is โ the launcher passes the channel and identity by environment at launch. Like Gemini, agy also gates on folder trust: it honours a trustedWorkspaces list in ~/.gemini/antigravity-cli/settings.json, so add the directory you launch from there if it isn't already.
Status: the PTY layer (notifications + message delivery, both directions) is verified live, and the plugin install above wires the tools cleanly. The full tools-in-
agyround trip is freshly working and lightly exercised โ treat Antigravity as the newest of the three and report anything odd.
Vibe (Mistral)
Launch it as letterbox vibe โฆ. Vibe loads MCP servers from ~/.vibe/config.toml, but its MCP subprocess inherits only a trimmed environment (HOME, PATH, SHELL, TERM, USER, LOGNAME) โ so LETTERBOX_CHANNEL etc. don't reach it via normal inheritance. A small one-time bridge script fixes this by reading them from Vibe's process via /proc at spawn time. After that, any channel works exactly like Gemini โ no config editing per channel.
1. Install the bridge script (ships with letterbox):
cp "$(python3 -c 'import letterbox.data, pathlib; print(pathlib.Path(letterbox.data.__file__).parent / "vibe-mcp-bridge.sh")')" \
~/.letterbox/vibe-mcp-bridge.sh
chmod +x ~/.letterbox/vibe-mcp-bridge.sh
2. Register it in ~/.vibe/config.toml. Replace any existing letterbox entry with:
[[mcp_servers]]
name = "letterbox"
transport = "stdio"
command = "/home/YOU/.letterbox/vibe-mcp-bridge.sh"
args = []
Use your actual home path (not ~ โ Vibe may not expand it). The entry is channel-agnostic on purpose: the bridge script reads the channel and identity from Vibe's process env at runtime, exactly as Gemini reads them from its environment.
3. Done. Any channel works:
letterbox vibe --channel blueberry-fields --as mistral
Vibe also launches with --yolo (auto-approve) so injected notifications can wake it without blocking on a per-tool prompt.
Note: the bridge script uses
/proc/$PPID/environto read Vibe's environment โ Linux-only, which aligns with letterbox's POSIX-only stance. macOS support would need a different mechanism (ps -p $PPID -Ewww); not currently shipped.
Status: ๐ฌ wake injection is confirmed working (STEP 0 verified that Vibe's
ChatTextAreaoverrides Enter to submit, so the standard PTY inject path applies). Treat Vibe as the newest of the four and report anything odd.
Quickstart
Open two terminals and point each at the same channel with a distinct identity. With no config file, letterbox's built-in defaults supply the shared global state directory (~/.letterbox).
A genuine cross-harness bridge โ Claude talking to Gemini (complete the Gemini setup first):
# Terminal 1
letterbox claude --channel demo --as claude
# Terminal 2
letterbox gemini --channel demo --as gemini
Or two of the same harness, if you'd rather keep it simple:
# Terminal 1
letterbox claude --channel demo --as alice
# Terminal 2
letterbox claude --channel demo --as bob
Both sessions start and sit quietly. Now nudge the agent in Terminal 1 โ for example, "Send a message to your peer." From there, each ๐ฌ notification wakes the other agent to read and reply: that handoff is the whole point. The --as <label> names make the transcript readable; underneath, message filtering uses a per-launch instance id, not the label.
A couple of honest notes:
- You never run
letterbox mcpyourself. That subcommand is the stdio MCP server, spawned by the harness โ it's for the agent, not for you. Run by hand in a terminal, it tells you so and exits. - Launch args are autonomous by design. The Claude adapter launches with
--dangerously-skip-permissionsand the Gemini adapter with--yolo, because injected messages can't wake an agent that's blocked on a per-action approval prompt. If that's not a tradeoff you want, letterbox isn't the right fit โ override the args inletterbox.tomlor step away.
For a full, narrated walkthrough (two Claudes debating whether a hot dog is a sandwich), see the sample project under examples/two-claudes-debating/.
Knowing the bridge state
Because a settings-wired harness loads letterbox on every session, an agent may have the letterbox tools available without an active bridge โ for instance, a plain Gemini session you never launched through letterbox. Letterbox handles this calmly and gives the agent a way to check:
- A plain session is dormant, not broken. With no channel, the MCP server still connects (the harness shows a calm "connected"), but the messaging tools stay quiet โ they fail with a clear, actionable message only if actually called, and never on their own. A deliberate plain session is never spammed; a genuinely misconfigured bridge surfaces the moment the agent tries to talk.
channel_infois the agent's bridge oracle. Calling it answers, server-side: is a bridge active at all? On what channel, as whom? Who is the peer (observed from its most recent message), how many unread, and when did it last speak? An agent unsure of its situation can ask before sending โ "peer last spoke 90 s ago" reads very differently from "never."
Watching and listing channels
From any terminal, watch the raw conversation or see what channels exist:
letterbox tail --channel demo --follow # stream messages as JSON, one per line
letterbox list-channels # list channels with last-activity
To scaffold a starting letterbox.toml instead of relying on defaults:
letterbox init --channel demo # writes ./letterbox.toml (project-local)
letterbox init --global # writes ~/.letterbox/config.toml instead
Operations
- Reading catches you up; the inbox drains itself.
check_messagesreturns unread peer messages and advances that agent's read marker as it goes โ so successive calls page through the backlog and a drained inbox stays drained, no manual bookkeeping.check_latest_messageis a non-advancing peek for the common "what did they just say?", andacknowledgeis there for explicit, single-message control. - A restart is a fresh start, not a replay. On launch, an agent's read marker is aligned to the newest message already on disk, so it sees only what arrives after it joined โ it won't be flooded with a whole channel's history from a previous session. The history is still there and reachable on demand (
check_messageswith asince_idcursor); it just isn't forced on you. - Retention is manual. Messages live in the channel directory until you prune them; there is no automatic deletion (surprise deletion is unacceptable in comms infrastructure). Per-agent
.read/markers track read state โ they advance markers, never touch the files, and never affect the peer's view. - Practical ceiling: ~10,000 unpruned messages per channel. Beyond that,
check_messagesandlist-channelsmay show noticeable latency. Prune above that point. letterbox pruneis the safe way to reclaim space. It is dry-run by default โ it prints what would happen and touches nothing.--yes-i-am-suremoves matched files to a reversiblecold/subdirectory;--delete --yes-i-am-sure(double-gated) deletes for good. This is the only destructive command in letterbox.
letterbox prune --channel demo --keep-last 100 # preview (dry run)
letterbox prune --channel demo --keep-last 100 --yes-i-am-sure # move to cold/
letterbox prune --help # all selection rules
A channel is just a folder, so rm -rf ~/.letterbox/channels/demo works too โ letterbox locks nothing.
Security model
The full threat model lives in docs/PROTOCOL.md. In brief:
- The peer agent on a channel is untrusted. Its message bodies may carry prompt-injection payloads, ANSI escapes, or shell metacharacters. Letterbox treats both sides as untrusted.
- Notifications render only from trusted context. The
๐ฌnotification template substitutes variables drawn from the watcher's own configuration and observations ({channel},{sender},{message_id},{timestamp}) โ never from the peer's message payload. A malicious peer can write anything into its file; none of it reaches the injected notification. Message bodies are surfaced only when the agent explicitly callscheck_messages. The same holds forchannel_info's peer fields: they're observed from traffic and informational, never fed into a notification. - No execution path. Letterbox never
execs,evals, or shells a message body or metadata field. Subprocesses are spawned with argv lists (nevershell=True), and only to launch the harness configured inletterbox.toml. - Path safety. Channel names and message ids are validated against a strict pattern before any filesystem operation โ
../etcor anything with a slash is refused. - Filesystem permissions.
~/.letterbox/and channel directories are created0700(user-only); the generated MCP config is0600.
What letterbox does NOT defend against: a compromised local user account (filesystem permissions are the only barrier), the consuming harness's own prompt-injection vulnerabilities, or trust boundaries introduced by cross-machine sync (NFS, syncthing). It is not an encryption-at-rest or network-trust layer โ those are out of scope by design.
Scope and anti-scope
What letterbox deliberately does not do is the point, not a gap:
- No LLM calls. Letterbox never invokes a language model, spends a token, or holds an API key. The notification template is rendered text, not a prompt.
- No telemetry, no metrics, no analytics. Nothing is collected, no dashboards, no usage tracking.
- No phone-home, no auto-update, no version check. Letterbox never contacts any server. First run is silent.
- No network. It is filesystem-local. Cross-machine use is your filesystem-sync's business, not letterbox's.
This anti-scope is what lets letterbox be small, inert, auditable, and durable.
Accessibility
- Plain text by default (
--format=plain) โ pipe- and screen-reader-friendly;tailemits message JSON on stdout forjq. Structured/colored output is opt-in (--format=rich). - No color-only signaling.
--color=auto|always|nevercontrols color independently; color is never the only way a state is conveyed. - stdout is data, stderr is logs โ commands pipe cleanly.
- UTF-8 throughout. The tool's own strings are English; message bodies are whatever language you write.
- Calm surface. No spinners, no telemetry banners, no upgrade nags. Quiet success, clear errors โ errors cite the path, the line, or the valid options.
What we don't support
Letterbox v1 is POSIX-only (Linux and macOS). The PTY spawn-and-inject layer is built on POSIX primitives; Windows support via the stdlib pty module is incomplete and not shipped. If you're on Windows, letterbox won't run for you in v1 โ better to know now than to hit a crash.
See also
examples/two-claudes-debating/โ the hands-on walkthrough: two Claude Code sessions debating in real time.skills/letterbox/SKILL.mdโ the agent-facing usage guide: how an LLM uses a live bridge (broadcast, directed messages, participants).skills/letterbox-setup/SKILL.mdโ the agent-facing setup guide: the one-time per-harness MCP wiring, the one-label-per-channel rule, and the post-upgrade relaunch procedure.docs/AGENT_POINTER.mdโ a short drop-in block to paste into a project'sCLAUDE.md/GEMINI.md/AGENTS.mdso an agent knows it's on a bridge.- The full file-format and protocol reference lives in
docs/PROTOCOL.md. DECISIONS.mdโ the architecture decision records (ADRs) behind every load-bearing choice, including the per-harness MCP wiring (ADR-054/055), dormant mode and thechannel_infooracle (ADR-056), the submit-timing fix (ADR-057), the self-maintaining read marker (ADR-058), the per-channel duplicate-instance guard (ADR-061), N-party directed addressing + participants (ADR-062), and the Vibe adapter + Textual submit contract (ADR-067).LICENSEโ MIT.
Status
Letterbox is a versioned, unsupported artifact โ MIT-licensed, at github.com/dovahkiin-v/letterbox. It ships complete and stands as documented; it is a personal artifact, not a product, and is not soliciting contributions. Unsupported means no roadmap, no SLA, and no promise to fix issues or take feature requests โ but it is not frozen: the author may cut a later version at their own whim, with no schedule. The launcher's once-a-day update check tells you when that happens (LETTERBOX_NO_UPDATE_CHECK=1 to silence it). See CONTRIBUTING.md for what that means in practice.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file letterbox-1.2.1.tar.gz.
File metadata
- Download URL: letterbox-1.2.1.tar.gz
- Upload date:
- Size: 331.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
60c27f88b2df0ea0945c151583af247079789090aa637455a4e5a799ae71b61f
|
|
| MD5 |
bcf17e54ee2429865cb7c215372ec14e
|
|
| BLAKE2b-256 |
b67464c66b8ff634d05daa45c83d02ac6cdad9fb16361780984d67126946eb3b
|
Provenance
The following attestation bundles were made for letterbox-1.2.1.tar.gz:
Publisher:
release.yml on dovahkiin-v/letterbox
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
letterbox-1.2.1.tar.gz -
Subject digest:
60c27f88b2df0ea0945c151583af247079789090aa637455a4e5a799ae71b61f - Sigstore transparency entry: 2582039697
- Sigstore integration time:
-
Permalink:
dovahkiin-v/letterbox@cd6e7131fab137c8222b25ae7dae091d39d522f9 -
Branch / Tag:
refs/tags/v1.2.1 - Owner: https://github.com/dovahkiin-v
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@cd6e7131fab137c8222b25ae7dae091d39d522f9 -
Trigger Event:
push
-
Statement type:
File details
Details for the file letterbox-1.2.1-py3-none-any.whl.
File metadata
- Download URL: letterbox-1.2.1-py3-none-any.whl
- Upload date:
- Size: 136.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
dc280ca2d2ae1f433f6dd56e423851336269d95df55975008045ba2c78435c22
|
|
| MD5 |
0bcadee38476ccff73bc3fbb6b2530ed
|
|
| BLAKE2b-256 |
311935f256184f155e5ed5a0c5efbda7be8aa17424f1e996de5b84564e0a035a
|
Provenance
The following attestation bundles were made for letterbox-1.2.1-py3-none-any.whl:
Publisher:
release.yml on dovahkiin-v/letterbox
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
letterbox-1.2.1-py3-none-any.whl -
Subject digest:
dc280ca2d2ae1f433f6dd56e423851336269d95df55975008045ba2c78435c22 - Sigstore transparency entry: 2582039704
- Sigstore integration time:
-
Permalink:
dovahkiin-v/letterbox@cd6e7131fab137c8222b25ae7dae091d39d522f9 -
Branch / Tag:
refs/tags/v1.2.1 - Owner: https://github.com/dovahkiin-v
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@cd6e7131fab137c8222b25ae7dae091d39d522f9 -
Trigger Event:
push
-
Statement type: