Drive the Pi coding agent entirely from an XMPP chat client — 1:1 or in a group chat (MUC).
pi-msg launches pi --mode rpc , then bridges Pi's JSONL event stream to XMPP (via mellium.im/xmpp ): the assistant's replies are relayed to you as chat messages, and your chat messages drive the agent — plain prompts and slash commands, exactly as if you'd typed them into Pi locally.
Because it runs Pi in RPC mode, commands like /new work over chat (an earlier in-process-extension version couldn't do this — sendUserMessage can't invoke Pi's command layer).
sequenceDiagram participant You as You (XMPP client) participant Bridge as pi-msg participant Pi as pi --mode rpc You->>Bridge: "fix the build" Bridge->>Pi: prompt Pi-->>Bridge: message_end event Bridge-->>You: assistant text You->>Bridge: "/new" Bridge->>Pi: {type:"new_session"} Note over Pi: fresh session Loading Each finished assistant message → sent to you as chat. Agent state shows on three independent signals (1:1): a typing indicator while a reply is actually being written, presence <show> ( dnd while busy, available when idle), and a presence status label of the current activity ( thinking… , running: <cmd> , replying… , retrying… , listening ). When a run settles with no text you get a ✅ done (no reply) — your turn nudge. Messages you send are acknowledged with read receipts — XEP-0184 delivery receipts and XEP-0333 chat markers ( displayed ) — when the agent takes them in, if your client requests them. Your chat messages → routed to Pi: You send Becomes plain text a prompt to the agent /skill:name … , /template … , any extension command a prompt (Pi expands/runs it) /new new_session (fresh session; connection stays up) /compact [instructions] compact /model <provider/id> or /model <search> set_model /think <off|low|medium|high|…> set_thinking_level /abort (or /stop ) abort /dump (or /dump pretty ) send the session transcript to the owner — raw JSONL, or pretty for indented per-record JSON (no LLM turn) /quit (or /exit ) shut down the bridge and Pi Configuration Create ~/.config/pi-msg/config.json (override the path with PI_MSG_CONFIG ), then chmod 600 it:
{ "accounts" : { "default" : { "jid" : " pi@chat.example.com " , "password" : " super-secret " , "owner" : " you@chat.example.com " , "model" : " anthropic/claude-sonnet-latest " , "workdir" : " /path/to/your/project " } } } Per-account fields:
Multiple accounts: add more keys under accounts ; default is used unless you set PI_MSG_ACCOUNT=<name> . In 1:1 mode only the owner JID may drive the agent.
Set room on an account (a single MUC JID, or an array of them) and pi-msg also joins each. The owner's 1:1 stays the primary channel — joining a room is purely additive and doesn't change 1:1 behaviour (typing indicator, lifecycle notices, and unsolicited output all still go to the owner). Each reply goes back to whichever channel the message arrived on, including the specific room when several are joined. Room messages are handled on two independent axes :
Untriggered messages are buffered and, on the next turn, prepended to the prompt as a clearly-labeled "room commentary — non-canonical" block, then the buffer clears.
Reply routing (explicit from: / to: ). When an account has room access, routing is fully explicit — no guessing. Each prompt the agent receives leads with a header naming the message's origin:
from: <channel jid> # the room (group msg) or the owner (DM) — reply here to answer in place sender: <person jid> # room messages only, when the real JID is known — reply here to DM them <message body> And every agent reply must begin with a to: <jid> line naming its destination:
One reply may contain several to: blocks — each to: line starts a new message, so the agent can fan a single turn out to multiple destinations:
to: team@muc.chat.zachmanson.com Deploying now — back in 5. to: zach@chat.zachmanson.com (privately: the staging creds are stale, heads up) Destinations are allowlisted : the owner, joined room(s), and real JIDs currently seen in a room. A reply whose to: is missing or points anywhere else is sent to the owner, so nothing is silently lost — the agent can't message arbitrary users. In a pure 1:1 account (no room) there are no prefixes; replies just go to the owner.
File transfer. The agent sends files with the send_file tool (a structured tool call, not in-band text — see Agent tools below): pi-msg uploads the file via XEP-0363 HTTP Upload and sends the resulting URL as an XEP-0066 out-of-band message, so the recipient's client shows a downloadable file. The destination is allowlisted (owner, joined rooms, known occupants) exactly like a to: reply. The upload component is discovered automatically ( upload.<domain> / httpupload.<domain> ) or set explicitly via the uploadService config field.
The room must be non-anonymous (ejabberd: "Present real Jabber IDs to → anyone" , optionally members-only ). The owner is recognized by real JID; in a semi-anonymous room real JIDs are hidden, so the owner can't be distinguished and every message falls through to the untrusted/ambient tiers.
Beyond reply text, the agent gets structured tools (registered by a small companion extension that pi-msg loads into pi --mode rpc , which relays each call back to pi-msg to perform the XMPP action):
Reply routing ( to: ) stays an in-band text convention (above); only these discrete side-effect actions are tools.
go build -o pi-msg . && ./pi-msg # from the repo Nix nix run github:zachpmanson/pi-msg # run the bridge nix build github:zachpmanson/pi-msg # build the package (bin: pi-msg) Dev shell (Go + gopls) via nix develop , or automatically with direnv — the repo ships a .envrc ( use flake ); run direnv allow once.
Set PI_MSG_DEBUG=1 to print connection/status/stderr diagnostics. On startup the bot simply comes online in your roster (presence listening ); on shutdown or a pi crash it goes offline with a <status> describing why and when — pi-msg no longer posts chat banners for these lifecycle events.
Requirements: Go ≥ 1.26 (to build), and a pi on PATH that's logged into a provider ( pi → /login ).
Hacker News
news.ycombinator.com