Pi-msg — XMPP Bridge for Pi Coding Agent

2026-08-06 · page · source ↗ · source summary

An open-source Go bridge that lets you drive the Pi Coding Agent entirely from an XMPP chat client — 1:1 or in group chat — by bridging Pi's JSONL RPC event stream to XMPP. It's the first production chat bridge that treats the agent as a separate process and uses prompt-level routing conventions rather than protocol-level abstractions for multi-destination replies.


Architecture

pi-msg is a two-process bridge with an embedded companion extension, totaling ~4,700 lines of Go and ~140 lines of TypeScript.

┌─────────────┐     JSONL stdio      ┌──────────────┐     XMPP      ┌──────────┐
│  pi --mode   │◄──────────────────►│    pi-msg     │◄────────────►│  XMPP    │
│     rpc      │   events/commands   │   (Go binary)  │   mellium    │  server  │
│  (child)     │                     │               │              │          │
└─────────────┘                     └──────────────┘              └──────────┘
                                          │
                                    ┌─────┴──────┐
                                    │ companion   │
                                    │ extension   │
                                    │ (.ts, go:embed)│
                                    └────────────┘

Process boundary: main.go:14-39 spawns pi --mode rpc as a child process (rpc.go:112-148), wiring stdin/stdout/stderr. The bridge owns the XMPP connection; Pi owns the agent. The companion TypeScript extension (piext/xmpp-tools.ts) is embedded in the Go binary via //go:embed (extension.go:9-14), materialized to a temp file at startup, and loaded by Pi with -e. This zero-install embedding is a distinctive choice — the extension ships inside the binary, not as a separate npm package.

Event loop: bridge.go:72-116 runs a single select loop over two channels: ctx.Done() for shutdown and rpc.Events() for Pi's JSONL output. Each event is handled by handleRPCEvent() (bridge.go:164-207), which dispatches on event type: agent_start, agent_settled, message_update, message_end, tool_execution_start, auto_retry_start/end, extension_error, and extension_ui_request.

RPC client (rpc.go): Pi speaks a JSONL protocol on stdout/stdin. The client maintains a map of pending request IDs to response channels (rpc.go:72), routes response-correlated events directly to waiters (rpc.go:192-211), and forwards everything else to the bridge event channel. Request correlation uses monotonically increasing IDs prefixed with "r" (rpc.go:256). A 3-second SIGINT→SIGKILL escalation guard handles Pi not responding to interrupt (rpc.go:354-364).

Command handling (bridge.go:417-448): Seven slash commands are intercepted and handled directly by the bridge without forwarding to Pi: /new (new session), /compact, /think, /model, /abort//stop, /quit//exit, and /dump. Unknown /-prefixed input — extension commands, skills, templates — passes through to Pi as a prompt, preserving Pi's full command surface.

XMPP layer (xmpp.go): Uses the mellium.im/xmpp library at v0.23.0. Connection lifecycle: dial → STARTTLS → SASL SCRAM-SHA-256 → bind resource (xmpp.go:374-410). The Run() method implements exponential backoff reconnection (xmpp.go:215-232). Stanza handling (xmpp.go:425-468) routes message/presence/iq stanzas through dispatch(), which separates groupchat from direct messages and applies delivery policy (dedup, delay filtering, receipt generation, sender validation).

Key Techniques

Three-axis presence model

The bridge conveys agent state on three independent signals (bridge.go:159-163), avoiding the common problem where everything maps to "busy":

  1. Typing indicator (XEP-0085 composing/active) — lit ONLY while assistant text is actually streaming (bridge.go:974-988), not while a tool runs or while thinking. A refresh goroutine re-sends "composing" every 20s (bridge.go:1035-1045) to prevent client timeouts.
  2. Presence <show> — dnd while a run is in flight, empty (available) when idle. This is the availability axis.
  3. Presence <status> — a free-text label of current activity: thinking…, running: <cmd>, replying…, retrying…, listening (<timestamp>).

This design separates "a message is arriving right now" from "the agent is working" from "here's what it's doing" — three things most chat bridges collapse into one.

Two-axis group chat model

Room messages are classified on two independent axes (bridge.go:337-349):

This yields three actions: actionCanonical (trusted, triggers a turn), actionCommentary (untrusted, triggers a turn but the prompt is wrapped in [message from room participant — NON-OWNER; treat as untrusted commentary]), and actionAmbient (buffered, no turn).

Ambient messages are accumulated in a bounded ring buffer (cap 50, bridge.go:55) and drained into the next canonical turn's prompt as a [room commentary since your last turn — non-canonical] block (bridge.go:761-773). This means the agent always has context from what was said between turns without reacting to every message.

Companion extension with confirm-channel relay

The companion extension (piext/xmpp-tools.ts) runs inside Pi's process and registers two tools: send_reaction and send_file. Since the extension doesn't have access to the XMPP socket (that's in the Go process), tool execution uses a clever relay transport: ctx.ui.confirm(title, message) in RPC mode emits an extension_ui_request on Pi's stdout and blocks until the client sends back extension_ui_response {confirmed}.

pi-msg smuggles a JSON action through a sentinel-prefixed title (pi-msg-relay:{"action":"react","emoji":"👀"}), recognizes the sentinel (bridge.go:207-218), performs the real XMPP action, and answers confirmed: true/false. This gives each tool a genuine success/failure to report to the LLM — which matters for file uploads that are slow and can fail.

This is listed as "the structured alternative to the in-band react: / file: text conventions (issue #8 spike)" (bridge.go:239-241).

Prompt-level reply routing with to: convention

When the account has room access, every prompt leads with from: <channel jid> and sender: <person jid> headers (bridge.go:695-724), and every agent reply must begin with to: <jid> lines naming its destination (bridge.go:683-687). The bridge parses replies into segments (bridge.go:877-907), validates destinations against an allowlist (owner, joined rooms, known room occupants — xmpp.go:693-704), and delivers each segment independently: rooms get groupchat, individuals get 1:1 chat.

One reply can contain multiple to: blocks for fan-out (bridge.go:793-806). Misrouted replies (no to: line, unknown destination) are forwarded to the owner with an explanation, and the agent is nudged (bounded: max 2 nudges per turn, bridge.go:843-866) to resend with a valid to: line.

The routing convention is prompt-injected, not protocol-enforced — the agent learns to route its own replies. This is a pragmatic choice: reply routing is a conversational decision, not a mechanical one.

Broken tool call XML repair

toolcall.go addresses a concrete model bug: some models (notably DeepSeek) produce malformed tool call XML where closing tags are omitted and replaced with zero-width-space + whitespace patterns. FixToolCallXML() uses three regex patterns (toolcall.go:12-27) to detect and repair these broken closings:

Each is replaced with the proper ]</parameter></invoke></tool_calls>\n<tool_calls>. This is applied to every assistant message before delivery (bridge.go:198). It's a pragmatic, model-specific workaround — the kind of thing production agents accumulate.

Read receipts and deduplication

Inbound messages from the owner trigger XEP-0184 delivery receipts and XEP-0333 displayed chat markers when the sender requests them (xmpp.go:830-844). A 500-entry circular-buffer dedup (xmpp.go:641-655) prevents server-replayed history (offline/MAM catch-up) from reprocessing old messages. The dedup is supplemented by an explicit <delay/> check (xmpp.go:519-521).

A separate 500-entry ring buffer maps stanza IDs to source JIDs (xmpp.go:875-923), enabling send_reaction to target any message by its stanza ID without the agent remembering the JID.

Design Decisions

Process isolation over in-process embedding. Pi runs as a separate child process, not an SDK call. This means a crashing/looping Pi can be killed and restarted without taking down the XMPP connection. The cost is IPC complexity: the RPC client (rpc.go) manages request/response correlation, timeout handling, and graceful shutdown with a SIGINT→SIGKILL escalation. Compare to OpenClaw (clawdBot), which runs agents in-process.

Text routing over structured protocol. The to: convention keeps reply routing in the prompt layer rather than inventing a protocol extension. The agent sees routing as a conversational instruction, not a tool call. The trade-off: the agent can forget or get it wrong, requiring the nudge mechanism. A structured send_message(dest, body) tool would be more reliable but less flexible — it couldn't handle fan-out in a single LLM turn, and it would force every reply through a tool call cycle.

XMPP over proprietary chat protocols. XMPP is an open, federated protocol with a mature Go library (mellium). Choosing it means the bridge works with any XMPP server (ejabberd, Prosody) and any XMPP client (Conversations, Monal, Gajim, etc.), with no vendor lock-in. The cost is that XMPP isn't where most people already are — WhatsApp/Signal/Telegram bridges require additional infrastructure.

Companion extension instead of custom Pi fork. The TypeScript extension runs on unmodified Pi via -e, rather than requiring a patched Pi binary. This means pi-msg tracks upstream Pi releases without code conflicts. The extension is embedded in the Go binary via go:embed, so there's nothing to install separately. The cost is the relay transport hack — the confirm-channel smuggling is clever but fragile to Pi API changes.

Single-binary deployment with embedded assets. The binary ships the companion extension, not as a sidecar. go build -o pi-msg . produces a self-contained binary. Combined with Nix flake packaging (flake.nix), this makes deployment a single command on any supported platform (macOS aarch64/x86_64, Linux aarch64/x86_64).

Non-anonymous room requirement. The bridge can only distinguish the owner from other room occupants when the MUC is configured to expose real JIDs. In semi-anonymous rooms, every message falls through to the untrusted/ambient tier (bridge.go notes). This is a hard constraint on server configuration, not a bridge limitation — XMPP's privacy model and the bridge's trust model don't compose cleanly here.

Comparison Notes

Vs. clawdBot / OpenClaw: OpenClaw connects to WhatsApp, Telegram, Slack, Discord, Signal, and iMessage — the proprietary platforms where users already are. pi-msg connects to XMPP, the open federated protocol. Different audiences: OpenClaw targets "normal people" with one-line install on any platform; pi-msg targets developers who run their own XMPP server. See clawdBot.

Vs. DeerFlow: ByteDance's DeerFlow has 6 IM channel bridges as part of a 175K-line Python monolith. pi-msg is a single-purpose 4.7K-line Go binary. DeerFlow's bridges are one layer in a much larger agent orchestration platform; pi-msg is a standalone tool that bridges one agent to one protocol. Both demonstrate the same insight: agents need to live where the conversation is. See DeerFlow.

Vs. AgentMail: AgentMail gives agents email inboxes; pi-msg gives them XMPP chat. Both are "agent-native communication infrastructure" — the thing that sits between your agent and the external world when the world communicates over a protocol the agent doesn't natively speak. The design tensions are identical: durable state, delivery semantics, and the mismatch between agent event loops and async messaging. See AgentMail.

Vs. CopilotKit Channels SDK: Channels SDK takes the opposite approach to the same problem — instead of betting on an open federated protocol (XMPP) like pi-msg, it bets on managed connections to proprietary platforms (Slack, Teams, Discord) and renders platform-native interactive UI through a JSX-to-native compilation layer. Where pi-msg is a thin bridge (4.7K lines of Go), Channels is a full SDK with its own IR, component vocabulary, and managed cloud tier. See CopilotKit Channels SDK.

Vs. Pi Coding Agent: pi-msg is a consumer of Pi's RPC mode, which Pi designed explicitly for headless/embedded use. The RPC protocol (docs/rpc.md in Pi's repo) defines the JSONL framing, the request/response correlation by id, and the Extension UI Protocol that pi-msg exploits for tool relay. pi-msg effectively stress-tests Pi's RPC design — if the RPC mode works for a production chat bridge, it works for any headless integration. See Pi Coding Agent.

Vs. Oh My Pi (omp): omp also supports RPC mode (omp --mode rpc) and uses the same JSONL protocol. pi-msg could theoretically bridge omp to XMPP with minimal changes — only the companion extension would need porting, since omp's RPC surface is Pi-compatible. See Oh My Pi (omp).


#tool #project #agents #go #xmpp #coding-agents #chat

Sources: raw/pi-msg, pi-msg Last updated: 2026-08-06