Requires BlueBubbles running on a Mac with iMessage access
A Rust daemon that bridges iMessage (via BlueBubbles) with Codex. When someone texts the assistant's iMessage account, the daemon injects commands into an interactive Codex session (running in tmux), captures the response, and sends it back via iMessage.
Instead of spawning a new subprocess for each message, Sink injects commands directly into a persistent Codex interactive session. This approach offers several advantages:
✅ Real-time Visibility: Watch what Codex is doing in real time—see tool usage, file access, command execution ✅ Persistent Context: Codex maintains session state across messages, enabling more sophisticated workflows ✅ No Subprocess Overhead: Much faster response times with minimal resource usage ✅ Guaranteed Output Format: Commands are wrapped with unique IDs—Codex responds with matching tags, ensuring correct reply extraction ✅ Debuggable: You can manually interact with Codex in the same terminal for testing and debugging
- Setup: Sink runs Codex in an interactive tmux window (e.g.,
tmux new-session -s main -n "sink MASTER") - Message Arrives: Daemon receives iMessage from BlueBubbles
- Tag Wrapping: Daemon wraps the message in structured tags with a unique ID:
[CMD-a1b2]What files are in this directory?[/CMD-a1b2] - Injection: Daemon sends the wrapped command to the tmux window via
tmux send-keys - Codex Processes: Codex reads the command and executes it in real time (you see it happening!)
- Response Tags: Codex responds with matching ID tags:
[REPLY-a1b2]There are 42 files in the current directory[/REPLY-a1b2] - Extraction & Send: Daemon extracts the reply and sends it back as an iMessage
- iMessage Bridge: Receives messages via BlueBubbles API, sends responses back
- Interactive Codex: Commands execute in a real Codex session (not subprocess spawning)
- Unique ID Tagging: Each command gets a UUID—responses are matched by ID, never by buffer position
- Late Reply Recovery: A persistent outbox checks every five minutes for undelivered tmux replies, with no expiration
Group Chat Filtering: Uses Gemini to detect if messages are directed at Claude - Web Admin Panel: Monitor messages, transcripts, and costs at port 1111
- Context Awareness: Maintains conversation history for better responses
- Visible Execution: Watch all tool use, file operations, and command execution in real time
- BlueBubbles server running on macOS with iMessage access
- Codex CLI installed and authenticated
- Rust toolchain (for building)
- tmux (for running the persistent Codex session)
- (Optional) Gemini API key for group chat filtering
Codex reads AGENTS.md, so link an existing CLAUDE.md when both agents should
share the same project guidance:
ln -s ~/m/CLAUDE.md ~/m/AGENTS.mdCreate the named window once. Sink respawns its pane with the configured command when the daemon starts, unless outstanding replies require preserving the existing agent and its scrollback:
tmux new-session -s main -n "sink MASTER"git clone https://github.com/xeb/sink.git
cd sink
cargo build --releasesudo mkdir -p /etc/sink
sudo cp config.example.toml /etc/sink/config.toml
sudo chown $USER:$USER /etc/sink/config.toml
chmod 600 /etc/sink/config.toml$EDITOR /etc/sink/config.tomlKey settings:
[bluebubbles]
host = "YOUR_BB_HOST"
port = 1234
password = "YOUR_BB_PASSWORD"
[tmux]
window = "sink MASTER" # Your tmux window name
restart_command = "agy --dangerously-skip-permissions" # Loaded through interactive Bash
prompt = ">" # agy's prompt character
timeout_secs = 300 # Send a progress notice after this wait
extended_timeout_secs = 600 # Extra foreground wait before background recovery takes over
capture_interval_ms = 200 # Poll frequency
[polling]
interval_secs = 5 # How often to check for new messages
batch_window_secs = 2 # Wait 2s for message batching
[database]
path = "/var/lib/sink/messages.db"
[context]
message_history_count = 20 # Messages to send as contextmake installOr manually:
cargo build --release
cp target/release/sink ~/.local/bin/sink
systemctl --user enable sink
systemctl --user start sinkCheck the daemon status and logs:
# Check status
systemctl --user status sink
# Watch logs in real time
journalctl --user -u sink -f
# Restart
systemctl --user restart sinkiMessage arrives
↓
BlueBubbles API polls daemon
↓
Daemon generates unique ID (e.g., "a1b2")
↓
Wraps message: [CMD-a1b2]message[/CMD-a1b2]
↓
Injects into tmux window via send-keys
↓
Claude Code reads and executes in real time
(YOU CAN WATCH IT IN THE TERMINAL!)
↓
Claude responds with: [REPLY-a1b2]answer[/REPLY-a1b2]
↓
Daemon extracts answer and sends via BlueBubbles
↓
User receives response as iMessage
Without unique IDs, the daemon might extract an old response from the tmux buffer. With IDs:
Old message: [CMD-xyz1]Old question[/CMD-xyz1]
[REPLY-xyz1]Old answer[/REPLY-xyz1] ← Daemon ignores this
New message: [CMD-a1b2]New question[/CMD-a1b2]
[REPLY-a1b2]New answer[/REPLY-a1b2] ← Daemon extracts THIS
Access the web admin panel at http://localhost:1111.
It is a single page: a flat, newest-first list of every inbound message with its outcome
(replied / failed / waiting / ignored). Click any row to expand it in place and read
Claude's reply along with the round-trip time; failed messages show the recorded reason instead.
Filter by outcome or search the message text.
The panel exposes one endpoint, GET /api/messages?limit&offset&status&q, and reads only
messages.db.
The panel has no authentication of its own — put it behind a reverse proxy or an identity-aware proxy before exposing it beyond your machine.
| Setting | Description | Default |
|---|---|---|
tmux.window |
tmux window name | sink MASTER |
tmux.restart_command |
Respawn at startup unless outstanding replies require preserving the existing pane; recreate a missing window on each message poll | unset |
tmux.startup_command |
Command sent after the primary agent UI is ready | unset |
tmux.fallback_command |
Replacement agent command when the primary usage quota is exhausted | unset |
tmux.prompt |
Interactive agent prompt character | ❯ |
tmux.timeout_secs |
Primary wait for response | 90 |
tmux.extended_timeout_secs |
Extra foreground wait before five-minute background recovery takes over | 600 |
tmux.capture_interval_ms |
Poll frequency | 200 |
polling.interval_secs |
Check messages every N seconds | 5 |
polling.batch_window_secs |
Wait for message batching | 2 |
context.message_history_count |
Messages to send as context | 20 |
| Path | Content |
|---|---|
/var/lib/sink/messages.db |
Message history, status, and durable tmux_replies outbox |
/var/lib/sink/transcripts.db |
Full session transcripts (if enabled) |
/var/lib/sink/followups.db |
Scheduled notifications |
Sink waits for the matching [/REPLY-id] tag before delivering replies from
agy, Codex, or an unknown agent. Spinner text is not a completion signal for
these agents. Only an explicitly configured Claude command uses the legacy
idle fallback for a missing closing tag.
Sink saves each tmux command ID and its batched message GUIDs before sending the
command. After the foreground waits end, undelivered messages remain
awaiting_reply (shown under waiting in the panel). A separate task checks
requests older than five minutes at startup and every five minutes thereafter,
even while another command is running. There is no age or retry limit.
Recovery reads all retained tmux scrollback for a complete matching
[REPLY-id]…[/REPLY-id] pair and saves the answer before attempting delivery.
If using Codex instead of agy, start it with codex --yolo --no-alt-screen
so replies are retained in tmux scrollback.
In alternate-screen mode, a reply taller than the pane can hide its opening
tag from both foreground capture and recovery, even with a large history limit.
Failed sends retry from the saved answer, including after daemon restarts. A
successful API receipt records the outbound message and marks every input in
the batch replied in one database transaction. The foreground sender and
recovery task share a delivery lock to prevent duplicate sends during a run.
Keep the tmux pane and its history available until an answer is captured; recovery
cannot reconstruct output erased before a scan or an answer the agent never
produced. An ambiguous network failure or a crash after BlueBubbles accepts a
send but before Sink records its receipt can still cause a duplicate on retry;
retries reuse the same tempGuid but do not assume server-side deduplication.
tmux list-windows -t main
# Should show: 0: sink MASTER (attached)# Attach to the window
tmux attach-session -t main:sink\ MASTERjournalctl --user -u sink -f
# Look for:
# - "TMUX: Sending wrapped command with ID: ..."
# - "TMUX: Found [REPLY-...] tags after X polls"
# - "Successfully processed and responded to message"# Inject a command directly
tmux send-keys -t main:sink\ MASTER -l "[CMD-test]echo hello[/CMD-test]"
tmux send-keys -t main:sink\ MASTER Enter- Visibility First: You can see what Claude is doing at all times
- Simplicity: No complex subprocess management or transcript parsing
- Reliability: Unique ID tagging ensures correct response extraction every time
- Real-time: Commands execute immediately in a persistent session
- Debuggable: Manual testing and inspection is straightforward
MIT