diff --git a/CHANGELOG.md b/CHANGELOG.md
index e65cb5d..3085161 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,19 @@ All notable changes to postbag are recorded here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
uses [Semantic Versioning](https://semver.org/).
+## [2.2.2] - 2026-10-02
+
+Documentation release. No runtime change beyond the version number.
+
+### Changed
+- The README is a short product page about Codex and Claude Code talking to
+ each other: a one-line pitch, the demo, install and setup for both hosts,
+ a two-prompt quick start, the five MCP tools, and three security notes.
+- The demo shows two panes, Claude Code and Codex, each with its own MCP
+ calls and the letters it receives, from scripted calls to two real servers
+ with stand-in native doors.
+- A 1280x640 social preview image is in `docs/assets/social-preview.png`.
+
## [2.2.1] - 2026-10-02
Documentation and repository layout release. No runtime change beyond the
@@ -403,7 +416,8 @@ Four commits from "bridge" to "postbag" on the day the idea was born:
the ledger became the only state, `open` became human-only, and every
refusal learned to say stop.
-[Unreleased]: https://github.com/parasxos/postbag/compare/v2.2.1...HEAD
+[Unreleased]: https://github.com/parasxos/postbag/compare/v2.2.2...HEAD
+[2.2.2]: https://github.com/parasxos/postbag/releases/tag/v2.2.2
[2.2.1]: https://github.com/parasxos/postbag/releases/tag/v2.2.1
[2.2.0]: https://github.com/parasxos/postbag/releases/tag/v2.2.0
[2.1.0]: https://github.com/parasxos/postbag/releases/tag/v2.1.0
diff --git a/Dockerfile b/Dockerfile
index fb679fb..b8caef8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,6 +3,6 @@
# letters through a host session's native door, which a container does not have,
# so native delivery from this image is unsupported and unverified.
FROM python:3.12-slim
-ARG POSTBAG_VERSION=2.2.1
+ARG POSTBAG_VERSION=2.2.2
RUN pip install --no-cache-dir "postbag[mcp]==${POSTBAG_VERSION}"
ENTRYPOINT ["postbag-mcp"]
diff --git a/README.md b/README.md
index 1c9fcf0..54c0a33 100644
--- a/README.md
+++ b/README.md
@@ -1,113 +1,97 @@
-

+

# postbag
-**Two agents, one bag of letters.**
+**Let Codex and Claude Code talk to each other.**
-Your Claude Code and Codex sessions write to each other. Ask one to review
-the other's diff, split a task, or get a second opinion, without copying
-text between windows.
+A local MCP server. Ask Codex to review what Claude Code just wrote, or the
+other way round. The answer arrives as a new turn. No copying text between
+windows.
[](https://github.com/parasxos/postbag/actions/workflows/ci.yml)
[](https://pypi.org/project/postbag/)
[](https://glama.ai/mcp/servers/parasxos/postbag)
-[](https://github.com/parasxos/postbag/blob/v2.2.1/LICENSE)
+[](https://github.com/parasxos/postbag/blob/v2.2.2/LICENSE)
-
-
-Scripted calls to two real postbag MCP servers, with fake native doors.
-[Demo source](https://github.com/parasxos/postbag/blob/v2.2.1/docs/demo_mcp.py).
-
-## What it does
-
-- **Native delivery.** A letter arrives in the recipient as a new user turn,
- through its own input: Claude Code's inbox or `codex queue`. postbag adds
- no delivery daemon, polling or hooks.
-- **One shared record.** Every join, letter and leave goes into one
- append-only ledger per bag, which both agents and you can read.
-- **Set up by the agents.** Joining a bag creates it. Nothing to run in a
- separate terminal.
-- **The session is the sender.** Identity comes from the host, never from
- tool arguments.
+
## Install
+Needs Python 3.10 or later, Claude Code and a recent Codex CLI with
+`codex queue`. Run both sessions on one machine with the same postbag version.
+Delivery is tested on macOS.
+
```sh
pipx install 'postbag[mcp]'
+claude mcp add --scope user postbag -- "$(command -v postbag-mcp)"
+codex mcp add postbag -- "$(command -v postbag-mcp)"
```
-Python 3.10 or later, on macOS or Linux. Then register the server once in
-each host.
+Start a new session in each app, then run `/mcp`. postbag should list five
+tools. For uvx and other setups, see
+[MCP setup](https://github.com/parasxos/postbag/blob/v2.2.2/docs/mcp.md).
-**Claude Code**
+Or ask your agent: *Install postbag using
+[llms-install.md](https://github.com/parasxos/postbag/blob/v2.2.2/llms-install.md).*
-```sh
-claude mcp add --transport stdio --scope user postbag -- "$(command -v postbag-mcp)"
-```
+## Quick start
-**Codex**, in `~/.codex/config.toml`, with the path that `command -v postbag-mcp` prints:
+A bag is a named conversation. Both agents join the same one. Open Codex and
+Claude Code in the same repository, so both see the same code.
-```toml
-[mcp_servers.postbag]
-command = "/Users/you/.local/bin/postbag-mcp"
-tool_timeout_sec = 60
-```
+1. In Codex:
+ > Join postbag bag "review" as codex.
+2. In Claude Code:
+ > Join postbag bag "review" as claude, then ask codex to review my last commit.
-Reconnect or restart each host's MCP server, then check that the five
-postbag tools are listed, for example with `/mcp` in Claude Code.
-Codex delivery uses the `codex` binary's `queue` command, 0.149 or later.
-Set `POSTBAG_CODEX` if it is neither on `PATH` nor in the ChatGPT app.
-To run without installing, as listed in the official MCP registry:
-`uvx --with 'postbag[mcp]==2.2.1' postbag@2.2.1 mcp`.
-Agents can follow [llms-install.md](https://github.com/parasxos/postbag/blob/v2.2.1/llms-install.md).
+Codex joins first so Claude Code has someone to write to. Approve the postbag
+tools when asked. Codex gets the request as a new turn, and its reply lands in
+Claude Code the same way. Either side can end with a final letter, which asks
+for no reply.
-## Use it
+## What to use it for
-Open a Codex session and a Claude Code session in the same repository.
+- **Cross review.** Claude Code finishes a change and asks Codex to review the diff before you commit.
+- **Split work.** Codex writes the tests while Claude Code writes the implementation. They compare notes by letter.
+- **Second opinion.** When one agent is stuck on a bug, it asks the other for a fresh read.
-1. In Codex: *"Join the postbag bag review as bob and wait for letters."*
-2. In Claude Code: *"Join bag review as ada and ask bob to review my last commit."*
+## Tools
-Each letter ends with how to reply, so neither agent needs instructions.
-Watch the conversation with `postbag --bag review read`. When the work is
-done, ada can send a final letter, which asks for no reply, and either side
-can leave the bag.
-
-## The five tools
+Your agents call these. You just ask in plain words.
| Tool | What it does |
|---|---|
| `postbag_join` | Registers this session under a name in a bag, creating the bag if needed. |
| `postbag_send` | Sends a letter to a name in the bag. `final: true` asks for no reply. |
| `postbag_read` | Pages through the bag's history. |
-| `postbag_bags` | Lists local bags with their letter counts and registered names. |
+| `postbag_bags` | Lists local bags with letter counts and registered names. |
| `postbag_leave` | Withdraws this session from the bag. History stays. |
-The CLI has the same verbs: `postbag join`, `send`, `read`, `bags` and `leave`.
-
-## Good to know
-
-- **No brake.** postbag sets no letter limit. Your hosts' tool approvals and
- the agents' judgement decide how long a conversation runs.
-- **A letter is a user turn.** Connect only sessions you trust with the
- task. The ledger holds each Claude session's inbox token. It is created
- with mode `0600`, so keep it out of git and logs.
-- **Same machine, same version.** Two sessions are the supported use. After
- an upgrade, upgrade both and reconnect both MCP servers.
-- **Restarted session.** If a session comes back with a new identity and
- should keep corresponding, ask it to join the bag again under the same name.
-- **Submitted, not read.** A send returns once the recipient's door took the
- letter, not when the agent read it.
-
-[Concept](https://github.com/parasxos/postbag/blob/v2.2.1/CONCEPT.md) ·
-[MCP setup and limits](https://github.com/parasxos/postbag/blob/v2.2.1/docs/mcp.md) ·
-[Security](https://github.com/parasxos/postbag/blob/v2.2.1/.github/SECURITY.md) ·
-[Changelog](https://github.com/parasxos/postbag/blob/v2.2.1/CHANGELOG.md) ·
-[Contributing](https://github.com/parasxos/postbag/blob/v2.2.1/.github/CONTRIBUTING.md) ·
-[Tools that do more](https://github.com/parasxos/postbag/blob/v2.2.1/docs/readme-research.md) ·
-[MIT](https://github.com/parasxos/postbag/blob/v2.2.1/LICENSE)
+## How it works
+
+- **Native delivery.** Letters arrive as a new turn in the other session,
+ through its own input. postbag adds no delivery daemon, polling or hooks.
+- **No setup prompts.** Every letter ends with how to reply, or asks for no reply.
+- **One shared history.** Each bag keeps a local log that either agent can page
+ through with `postbag_read`.
+
+## Security
+
+- A delivered letter becomes a user turn in the other agent. Connect only
+ sessions you trust with the task.
+- There is no letter limit. Keep tool approvals on to check each send before it goes.
+- The ledgers under `~/.postbag/` hold each Claude Code session's inbox token,
+ with file mode `0600`. Never commit or share them.
+
+See [SECURITY.md](https://github.com/parasxos/postbag/blob/v2.2.2/.github/SECURITY.md).
+
+[MCP setup](https://github.com/parasxos/postbag/blob/v2.2.2/docs/mcp.md) ·
+[Concept](https://github.com/parasxos/postbag/blob/v2.2.2/CONCEPT.md) ·
+[Changelog](https://github.com/parasxos/postbag/blob/v2.2.2/CHANGELOG.md) ·
+[Contributing](https://github.com/parasxos/postbag/blob/v2.2.2/.github/CONTRIBUTING.md) ·
+[MIT](https://github.com/parasxos/postbag/blob/v2.2.2/LICENSE)
diff --git a/docs/assets/demo.gif b/docs/assets/demo.gif
index 3b4fe40..77540b2 100644
Binary files a/docs/assets/demo.gif and b/docs/assets/demo.gif differ
diff --git a/docs/assets/social-preview.png b/docs/assets/social-preview.png
new file mode 100644
index 0000000..3451557
Binary files /dev/null and b/docs/assets/social-preview.png differ
diff --git a/docs/demo.tape b/docs/demo.tape
index 97edc29..d91ca88 100644
--- a/docs/demo.tape
+++ b/docs/demo.tape
@@ -1,16 +1,18 @@
# vhs docs/demo.tape (https://github.com/charmbracelet/vhs)
-# Real postbag MCP tool calls between a Claude Code identity and a Codex
-# identity, through fake native doors. See docs/demo_mcp.py.
+# Claude Code on the left, Codex on the right: scripted calls to two real
+# postbag MCP servers, with stand-in native doors. See docs/demo_mcp.py.
Output docs/assets/demo.gif
Set Shell bash
-Set FontSize 16
-Set Width 1260
-Set Height 800
-Set Padding 20
+Set FontSize 20
+Set Width 1280
+Set Height 730
+Set Padding 24
+Set LetterSpacing 0
Set Theme "Catppuccin Mocha"
Hide
Type "clear; ${PYTHON:-python3} docs/demo_mcp.py"
Enter
+Sleep 1.5s
Show
-Sleep 36s
+Sleep 17s
diff --git a/docs/demo_mcp.py b/docs/demo_mcp.py
index e16eef7..afdf322 100644
--- a/docs/demo_mcp.py
+++ b/docs/demo_mcp.py
@@ -1,32 +1,32 @@
"""Render the README demo: scripted calls to two real postbag MCP servers.
-One `postbag mcp` server runs with a Claude Code identity (ada), the other
-with a Codex identity (bob). Their native doors are fakes: a throwaway Unix
-socket stands in for Claude Code's inbox and a small script stands in for
-`codex queue`, so the demo shows the exact envelope each door receives.
-No model authors these calls, and nothing touches a real session or your
-own bags.
+Two panes, Claude Code on the left and Codex on the right. Each runs its own
+`postbag mcp` server with that host's identity, shows its own tool calls, and
+shows the letters its door receives. The doors are stand-ins: a throwaway Unix
+socket for Claude Code's inbox and a small script for `codex queue`, so the demo
+shows the exact envelope each side would be handed. No model authors these
+calls, and nothing touches a real session or your own bags.
vhs docs/demo.tape # records docs/assets/demo.gif
- python docs/demo_mcp.py # the same transcript in your terminal
+ python docs/demo_mcp.py # the same demo in a terminal of at least 100x28
"""
import json
import os
import pathlib
import queue
+import shutil
import socket
import subprocess
import sys
import tempfile
+import textwrap
import threading
import time
ROOT = pathlib.Path(__file__).resolve().parents[1]
-THREAD = "019a0000-0000-7000-8000-00000000b0b0" # bob's Codex thread, fixed for a stable recording
+THREAD = "019a0000-0000-7000-8000-00000000b0b0" # the Codex thread, fixed for a stable recording
DEADLINE = 30
-ADA, BOB, DIM, BOLD, OK, END = "\033[38;5;215m", "\033[38;5;114m", "\033[2m", "\033[1m", "\033[38;5;151m", "\033[0m"
-
class Server:
"""One agent's postbag MCP server, spoken to with plain JSON-RPC lines."""
@@ -101,12 +101,12 @@ def close(self):
class Doors:
- """Fake native doors that keep what each recipient would be handed."""
+ """Fake native doors that keep what each recipient would be handed: an inbox socket for Claude Code, a queue script for Codex."""
def __init__(self, temp):
- self.to_ada, self.to_bob = [], []
- self.socket_path = str(temp / "ada.sock")
- self.queue_log = temp / "bob.jsonl"
+ self.to_claude, self.to_codex = [], []
+ self.socket_path = str(temp / "claude.sock")
+ self.queue_log = temp / "codex.jsonl"
self.queue = temp / "codex"
self.queue.write_text(
f"#!{sys.executable}\nimport json, sys\n"
@@ -129,89 +129,176 @@ def inbox(self, listener):
for line in data.decode().splitlines():
message = json.loads(line)
if message.get("type") == "user":
- self.to_ada.append(message["message"]["content"])
+ self.to_claude.append(message["message"]["content"])
def wait(self, inbox, count):
for _ in range(DEADLINE * 20):
- if inbox is self.to_bob and self.queue_log.exists():
- self.to_bob[:] = [json.loads(line) for line in self.queue_log.read_text().splitlines()]
+ if inbox is self.to_codex and self.queue_log.exists():
+ self.to_codex[:] = [json.loads(line) for line in self.queue_log.read_text().splitlines()]
if len(inbox) >= count:
return inbox[count - 1]
time.sleep(0.05)
raise TimeoutError("a submitted letter never reached its fake door")
-def pause(seconds):
- sys.stdout.flush()
- time.sleep(seconds)
-
-def show_call(who, colour, tool, **arguments):
- shown = " ".join(f"{key}={json.dumps(value)}" for key, value in arguments.items())
- sys.stdout.write(f"{colour}{who:<19}{END}")
- for char in f"{BOLD}{tool}{END} {shown}":
- sys.stdout.write(char)
- sys.stdout.flush()
- time.sleep(0.012)
- sys.stdout.write("\n")
+W, G = 47, 6 # pane width and gutter, in columns: 100 columns in all
+TOP, FOOT = 4, 27 # first content row, footer row
+CSI = "\033["
+END = CSI + "0m"
+CLAUDE = CSI + "38;2;217;119;87m" # coral
+CODEX = CSI + "38;2;137;180;250m" # blue
+OK = CSI + "38;2;166;227;161m" # green
+DIM = CSI + "38;2;127;132;156m" # grey
+TEXT = CSI + "38;2;205;214;244m" # body text
+BOLD = CSI + "1m"
-def show_result(text):
- print(f"{' ' * 19}{OK}✓ {text}{END}")
+def at(row, col):
+ return f"{CSI}{row};{col}H"
-def show_envelope(who, colour, envelope, keep):
- print(f"{colour}envelope captured at {who}'s door:{END}")
+def flush(seconds=0.0):
+ sys.stdout.flush()
+ if seconds:
+ time.sleep(seconds)
+
+
+class Pane:
+ def __init__(self, col, colour, title):
+ self.col, self.colour, self.row = col, colour, TOP
+ sys.stdout.write(at(1, col) + BOLD + colour + title + END + DIM + " postbag MCP" + END)
+ sys.stdout.write(at(2, col) + colour + "─" * W + END)
+
+ def line(self, parts, typed=0.0):
+ """Write one row of (style, text) parts at this pane's next row; typed>0 types it."""
+ assert self.row < FOOT - 1, "pane overflow"
+ assert sum(len(t) for _, t in parts) <= W, parts
+ sys.stdout.write(at(self.row, self.col))
+ for style, text in parts:
+ sys.stdout.write(style)
+ if typed:
+ for char in text:
+ sys.stdout.write(char)
+ flush(typed)
+ else:
+ sys.stdout.write(text)
+ sys.stdout.write(END)
+ self.row += 1
+ flush()
+
+ def gap(self, rows=1):
+ self.row += rows
+
+ def call(self, tool, args, body=None, typed=0.010):
+ """Show a tool call; return the row it started on (the arrow row for a send)."""
+ start = self.row
+ self.line([(BOLD + TEXT, tool), (TEXT, " " + " ".join(f"{k}={v}" for k, v in args))], typed)
+ if body is not None:
+ lines = body.splitlines()
+ for i, text in enumerate(lines):
+ q0, q1 = ('"' if i == 0 else " "), ('"' if i == len(lines) - 1 else "")
+ self.line([(TEXT, f" {q0}{text}{q1}")], typed)
+ return start
+
+ def ok(self, text):
+ self.line([(OK, "✓ " + text)])
+
+
+def excerpt(envelope, tail):
+ """Verbatim lines of the delivered envelope: header split before 'via', body, then one tail line."""
lines = envelope.splitlines()
- for line in lines[:keep]:
- line = line if len(line) <= 92 else line[:91] + "…"
- print(f"{DIM} │ {line}{END}" if line else f"{DIM} │{END}")
- if len(lines) > keep:
- print(f"{DIM} │ …{END}")
-
-
-def first_sentence(outcome):
- return outcome["message"].split(". ")[0].rstrip(".")
-
-
-def transcript(ada, bob, doors):
- print("\033[2J\033[H", end="")
- print(f"{DIM}postbag demo: scripted calls to two real postbag MCP servers, one with a Codex identity")
- print(f"(bob) and one with a Claude Code identity (ada). The native doors are fakes.{END}\n")
- pause(1.2)
- # The recipient joins first, so the first letter never meets an unregistered name.
- show_call("bob Codex", BOB, "postbag_join", bag="review", name="bob")
- show_result(first_sentence(bob.call("postbag_join", bag="review", name="bob")))
- pause(0.8)
- show_call("ada Claude Code", ADA, "postbag_join", bag="review", name="ada")
- show_result(first_sentence(ada.call("postbag_join", bag="review", name="ada")))
- pause(0.8)
- ask = "Review parser.py, top three findings please."
- show_call("ada Claude Code", ADA, "postbag_send", bag="review", to="bob", body=ask)
- show_result(first_sentence(ada.call("postbag_send", bag="review", to="bob", body=ask)))
- pause(0.6)
- show_envelope("bob", BOB, doors.wait(doors.to_bob, 1), keep=6)
- pause(2.4)
- answer = "Empty input, swallowed date errors, no field bound."
- show_call("bob Codex", BOB, "postbag_send", bag="review", to="ada", body=answer)
- show_result(first_sentence(bob.call("postbag_send", bag="review", to="ada", body=answer)))
- pause(0.6)
- show_envelope("ada", ADA, doors.wait(doors.to_ada, 1), keep=3)
- pause(1.8)
- done = "Fixed all three, merging."
- show_call("ada Claude Code", ADA, "postbag_send", bag="review", to="bob", final=True, body=done)
- show_result(first_sentence(ada.call("postbag_send", bag="review", to="bob", final=True, body=done)))
- pause(0.6)
- show_envelope("bob", BOB, doors.wait(doors.to_bob, 2), keep=5)
- pause(2.4)
- show_call("bob Codex", BOB, "postbag_leave", bag="review")
- show_result(first_sentence(bob.call("postbag_leave", bag="review")))
- pause(0.8)
- show_call("ada Claude Code", ADA, "postbag_read", bag="review", limit=3)
- state = ada.call("postbag_read", bag="review", limit=3)["data"]
- peers = ", ".join(f"@{peer['name']} ({peer['vendor']})" for peer in state["peers"])
- show_result(f"{state['letters']} letters, registered now: {peers}")
- pause(4)
+ head = lines[0]
+ cut = head.index(" via ")
+ blank = [i for i, text in enumerate(lines) if text == ""]
+ body = lines[blank[0] + 1: blank[1]]
+ footer = lines[blank[1] + 1:]
+ rows = [("head", head[:cut]), ("dim", head[cut + 1:])]
+ rows += [("text", text) for text in body]
+ if tail:
+ line = next(text for text in footer if tail in text)
+ line = line if line.startswith(tail) else "… " + line[line.index(tail):]
+ rows += [("dim", part) for part in textwrap.wrap(line, 36 if line.startswith("…") else W - 2)]
+ elif footer:
+ rows.append(("dim", "…")) # the elided reply instructions are marked, never silently dropped
+ return rows
+
+
+def arrive(pane, sender, rows, arrow_row, direction):
+ """Draw the arrow on the sender's call row, then the letter box in the recipient pane."""
+ gutter = W + 1 # first gutter column: panes start at 1 and W+G+1
+ arrow = " ───▶ " if direction == ">" else " ◀─── "
+ frames = [arrow[:i] for i in range(1, 7)] if direction == ">" else [arrow[-i:].rjust(6) for i in range(1, 7)]
+ for frame in frames:
+ sys.stdout.write(at(arrow_row, gutter) + sender.colour + frame + END)
+ flush(0.04)
+ pane.row = max(pane.row, arrow_row)
+ styles = {"head": BOLD + sender.colour, "dim": DIM, "text": TEXT}
+ for kind, text in rows:
+ pane.line([(sender.colour, "▌ "), (styles[kind], text)])
+ flush(0.06)
+
+
+def transcript(claude_server, codex_server, doors):
+ cols, rows = shutil.get_terminal_size()
+ if cols < 2 * W + G or rows < FOOT + 1:
+ raise SystemExit(f"demo needs {2 * W + G}x{FOOT + 1}, terminal is {cols}x{rows}")
+ sys.stdout.write(CSI + "?25l" + CSI + "2J" + CSI + "H")
+ left = Pane(1, CLAUDE, "Claude Code")
+ right = Pane(W + G + 1, CODEX, "Codex")
+ sys.stdout.write(at(FOOT, 1) + DIM + "Scripted tool calls to two real postbag MCP servers. "
+ "The native inputs are stand-ins." + END)
+ flush(1.0)
+
+ # Beat 1: both sessions join the same bag (the recipient's server is called first).
+ left.call("postbag_join", [("bag", '"review"'), ("name", '"claude"')], typed=0.006)
+ right.call("postbag_join", [("bag", '"review"'), ("name", '"codex"')], typed=0.006)
+ codex_server.call("postbag_join", bag="review", name="codex")
+ claude_server.call("postbag_join", bag="review", name="claude")
+ left.ok("joined bag review")
+ right.ok("joined bag review")
+ left.gap(), right.gap()
+ flush(0.8)
+
+ # Beat 2: Claude Code asks Codex for a review; it lands in Codex as a user turn.
+ ask = "Review my last commit. Top three issues?"
+ row = left.call("postbag_send", [("to", '"codex"')], ask)
+ claude_server.call("postbag_send", bag="review", to="codex", body=ask)
+ left.ok("letter 1 submitted")
+ flush(0.3)
+ arrive(right, left, excerpt(doors.wait(doors.to_codex, 1), "call postbag_send"), row, ">")
+ right.gap()
+ flush(1.8)
+
+ # Beat 3: Codex answers from its own session.
+ answer = "1. empty input crashes parse()\n2. date errors are swallowed\n3. no size limit on fields"
+ row = right.call("postbag_send", [("to", '"claude"')], answer, typed=0.007)
+ codex_server.call("postbag_send", bag="review", to="claude", body=answer)
+ right.ok("letter 2 submitted")
+ flush(0.3)
+ left.gap()
+ arrive(left, right, excerpt(doors.wait(doors.to_claude, 1), None), row, "<")
+ left.gap()
+ flush(1.8)
+
+ # Beat 4: Claude Code closes with a final letter.
+ done = "Fixed all three. Merging."
+ row = left.call("postbag_send", [("to", '"codex"'), ("final", "true")], done)
+ claude_server.call("postbag_send", bag="review", to="codex", final=True, body=done)
+ left.ok("letter 3 submitted")
+ flush(0.3)
+ right.gap()
+ arrive(right, left, excerpt(doors.wait(doors.to_codex, 2), "Final letter"), row, ">")
+ flush(1.2)
+
+ # Beat 5: one shared record.
+ left.gap()
+ left.call("postbag_read", [("bag", '"review"')], typed=0.006)
+ state = claude_server.call("postbag_read", bag="review", limit=3)["data"]
+ names = " and ".join(f"@{p['name']}" for p in state["peers"])
+ left.ok(f"{state['letters']} letters, {names} registered")
+ sys.stdout.write(at(FOOT + 1, 1))
+ flush(6) # the tape stops recording during this hold, so no shell prompt is filmed
def main():
@@ -221,20 +308,22 @@ def main():
home.mkdir()
doors = Doors(temp)
base = {"HOME": str(home), "PATH": os.environ.get("PATH", "/usr/bin:/bin"),
- "POSTBAG_CODEX": str(doors.queue), "PYTHONPATH": str(ROOT)}
+ "POSTBAG_CODEX": str(doors.queue), "PYTHONPATH": str(ROOT),
+ "PYTHONDONTWRITEBYTECODE": "1"}
servers = []
try:
- bob = Server(base, temp / "bob.stderr", meta={"threadId": THREAD})
- servers.append(bob)
- ada = Server({**base, "CLAUDECODE": "1", "CLAUDE_CODE_MESSAGING_SOCKET": doors.socket_path,
- "CLAUDE_CODE_MESSAGING_TOKEN": "demo-token"}, temp / "ada.stderr")
- servers.append(ada)
- transcript(ada, bob, doors)
+ codex = Server(base, temp / "codex.stderr", meta={"threadId": THREAD})
+ servers.append(codex)
+ claude = Server({**base, "CLAUDECODE": "1", "CLAUDE_CODE_MESSAGING_SOCKET": doors.socket_path,
+ "CLAUDE_CODE_MESSAGING_TOKEN": "demo-token"}, temp / "claude.stderr")
+ servers.append(claude)
+ transcript(claude, codex, doors)
except BaseException:
for server in servers:
sys.stderr.write(server.errors.read_text())
raise
finally:
+ sys.stdout.write(CSI + "?25h")
for server in servers:
server.close()
diff --git a/docs/mcp.md b/docs/mcp.md
index 9907736..10e2882 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -55,14 +55,14 @@ installation. The MCP extra and command select the same version:
```toml
[mcp_servers.postbag]
command = "/absolute/path/to/uvx"
-args = ["--with", "postbag[mcp]==2.2.1", "postbag@2.2.1", "mcp"]
+args = ["--with", "postbag[mcp]==2.2.2", "postbag@2.2.2", "mcp"]
tool_timeout_sec = 60
```
For Claude Code:
```sh
-claude mcp add --transport stdio --scope user postbag -- /absolute/path/to/uvx --with 'postbag[mcp]==2.2.1' postbag@2.2.1 mcp
+claude mcp add --transport stdio --scope user postbag -- /absolute/path/to/uvx --with 'postbag[mcp]==2.2.2' postbag@2.2.2 mcp
```
Generic stdio configuration uses the same command and arguments:
@@ -72,7 +72,7 @@ Generic stdio configuration uses the same command and arguments:
"mcpServers": {
"postbag": {
"command": "/absolute/path/to/uvx",
- "args": ["--with", "postbag[mcp]==2.2.1", "postbag@2.2.1", "mcp"]
+ "args": ["--with", "postbag[mcp]==2.2.2", "postbag@2.2.2", "mcp"]
}
}
}
diff --git a/docs/native-compatibility.md b/docs/native-compatibility.md
index ef4b3cc..70c6f46 100644
--- a/docs/native-compatibility.md
+++ b/docs/native-compatibility.md
@@ -1,5 +1,56 @@
# Native compatibility checks
+## 2 October 2026: installed 2.2.2 release candidate
+
+An independently built wheel from a clean archive of
+`94ad0853ff1a80014954d0972fce2adc4aa00eb2` passed the full native gate on its
+first execution. Both fresh hosts used the installed `postbag mcp` launcher
+from one isolated venv. Package and runtime versions were `2.2.2`. Installed
+modules matched the source and wheel and stayed unchanged. Both launchers
+exposed identical five-tool catalogs with current and legacy SDK clients,
+and all four checks returned an empty private inventory.
+
+The run used macOS 27.0.1, build 26A434, Python 3.14.6 and MCP SDK 2.2.0.
+Codex CLI **0.159.0-alpha.12.1** reported `gpt-6.1-sol`. Claude Code
+**2.1.287** reported `claude-opus-5-5`. The unwrapped native `codex queue`
+used default discovery in a private `CODEX_HOME`, without `--remote`, and
+reached a disposable persisted thread. Personal daemon routing and native
+Linux delivery were not tested.
+
+| Check | Observed result |
+|---|---|
+| Ordinary delivery | Both sessions received the initial letter and reply. The two incoming reply turns were created by the native queue, separate from direct harness prompts. |
+| Final letter | After recipient receipt and turn completion, 30.0605 seconds elapsed with no send attempt or ledger change. Both hosts stayed alive. |
+| Leave and refusal | MCP leave withdrew the recipient. The next send returned `refused` and `not_submitted`, included the departure time and preserved the ledger bytes. Its body was not received. |
+| Deliberate rejoin | An explicit later harness instruction caused rejoining. A later ordinary letter and reply were both received. |
+| Accounting | Ten model-authored calls produced three joins, one leave and five submitted letters. One additional send attempt was refused. Nine records and five letter ordinals were contiguous. |
+| Cleanup | Both clients and the private server exited 0 without forced shutdown. Native endpoints closed and the private authentication alias was removed. |
+
+An independent audit checked 52 assertions against raw tool events,
+recipient turns, timestamps, ledger records and installed files. Claude's
+inbound envelopes were not exposed by its event stream. Its exact-body
+stdout receipt markers followed by completed peer-origin turns corroborated
+receipt. Those markers were harness instrumentation, not Postbag replies.
+The final window is evidence for this run, not general loop prevention or
+an isolated test of the footer. Leave did not test recall of queued letters.
+
+The command sandbox reported read-only access, disabled network access and
+`approvalPolicy: never`. Claude was launched with `--permission-mode manual`
+and reported `permissionMode: default`. Preauthorized fixture tools required
+no human prompt. `crossSessionInbound` was unset with empty Claude setting
+sources. Native own-child classification was unobserved. No personal
+configuration or installation changed. Claude used its existing login and
+could write its usual caches or telemetry.
+
+Candidate provenance:
+
+```text
+wheel fd98de343c5d4089d27e9b39a80621085e224ffe4b8bebd55ab512cb5706c9d7
+postbag.py 18d76fb0643504e381c4bf880bc9b177b8b2e98beff88c2c0d74132f86cc780c
+postbag_mcp.py fe354cca37c92c6ce1e5ceaba71a37f0218542834124b1b31ee7528b7f4113c8
+harness bc30165fca08928a4f959ee22465d0beab5ac8c1ccd82e4676d9f3bcaa9fcc71
+```
+
## 2 October 2026: installed 2.2.1 release candidate
A clean wheel from `87777fc2c24f97cd4fdbbf77734a3065491930ef` passed the
diff --git a/llms-install.md b/llms-install.md
index 940d287..62f003c 100644
--- a/llms-install.md
+++ b/llms-install.md
@@ -35,7 +35,7 @@ The `postbag mcp` launcher and uvx examples below require 2.2.0 or later.
```
Alternatively, use uvx as the stdio command, with arguments
- `["--with", "postbag[mcp]==2.2.1", "postbag@2.2.1", "mcp"]`.
+ `["--with", "postbag[mcp]==2.2.2", "postbag@2.2.2", "mcp"]`.
Keep both version pins equal. This does not put the CLI on shell `PATH`.
Do not register both launch methods under different names.
@@ -80,5 +80,5 @@ an inbox share one peer, so a subagent can withdraw the parent's registration.
- Keep raw ledgers out of logs and repositories. They contain letter bodies
and native endpoint credentials. Use the redacted read and inventory tools.
-See [MCP setup and outcomes](https://github.com/parasxos/postbag/blob/v2.2.1/docs/mcp.md) and
-[native compatibility evidence](https://github.com/parasxos/postbag/blob/v2.2.1/docs/native-compatibility.md).
+See [MCP setup and outcomes](https://github.com/parasxos/postbag/blob/v2.2.2/docs/mcp.md) and
+[native compatibility evidence](https://github.com/parasxos/postbag/blob/v2.2.2/docs/native-compatibility.md).
diff --git a/postbag.py b/postbag.py
index 13bf883..49c8c76 100644
--- a/postbag.py
+++ b/postbag.py
@@ -25,7 +25,7 @@
from datetime import datetime
from pathlib import Path
-__version__ = "2.2.1"
+__version__ = "2.2.2"
PEERS = {"claude", "codex"} # supported vendors; registered peer names come from the ledger
NAME = re.compile(r"[a-z][a-z0-9-]{0,15}")
diff --git a/server.json b/server.json
index c2051ef..a5a9c0b 100644
--- a/server.json
+++ b/server.json
@@ -6,12 +6,12 @@
"url": "https://github.com/parasxos/postbag",
"source": "github"
},
- "version": "2.2.1",
+ "version": "2.2.2",
"packages": [
{
"registryType": "pypi",
"identifier": "postbag",
- "version": "2.2.1",
+ "version": "2.2.2",
"transport": {
"type": "stdio"
},
@@ -20,7 +20,7 @@
{
"type": "named",
"name": "--with",
- "value": "postbag[mcp]==2.2.1",
+ "value": "postbag[mcp]==2.2.2",
"description": "The MCP server is the optional mcp extra of the same version."
}
],