Drive the Pi coding agent entirely over XMPP.
pi-msg launches pi --mode rpc, then bridges Pi's JSONL event stream to XMPP, with command interception for system commands like /new.
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
- Sessions resuming across restarts,
/newto reset - Pi slash commands work over chat
- XMPP DMs and group chats
- Threaded replies, in-band routing
- Room history via
read_room - MAM supported
- File transfer (XEP-0363/0066)
- Reactions (XEP-0444)
- Typing indicator
- Presence updates
- Read receipts
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 |
/models |
list available models with the current one marked (no LLM turn) |
/session |
session stats — id, file, message counts, tokens, cost (no LLM turn) |
/name [name] |
show the session display name, or set it |
/think <off|low|medium|high|…> |
set_thinking_level |
/abort (or /stop) |
clear_queue, then abort — stops the run AND flushes pi's queued messages: a steer that landed mid-run can't start a fresh run the instant the aborted one stops. The reply names how many queued messages it dropped. |
! |
abort only — the quick interrupt. Stops whatever is currently running (a command or thinking) but leaves queued messages intact, so the next one is evaluated right after. |
/dump (or /dump pretty) |
send the session transcript to the owner — raw JSONL, or pretty for indented per-record JSON (no LLM turn) |
/export |
render the current session to HTML via pi's export_html RPC and send it as a file over XMPP (XEP-0363 HTTP Upload) — deterministic, no agent turn; the rendered session lands as an inline, downloadable file |
/quit (or /exit) |
shut down the bridge and Pi |
! can also be used as command prefix — /new and !new are
interchangeable.
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:
| field | required | default | notes |
|---|---|---|---|
jid |
yes | — | bare JID of the bot account |
password |
yes | — | bot account password |
owner |
yes | — | the human this account relays to; the canonical (trusted) driver |
service |
no | <jid-domain>:5222 |
host:port (a leading xmpp:// is tolerated) |
resource |
no | pi-msg |
XMPP resource (client-session label) |
model |
no | Pi's default | model pattern passed to pi --model |
workdir |
no | current dir | working directory for the agent (also where Pi discovers AGENTS.md/CLAUDE.md) |
rooms |
no | [] |
group-chat rooms: objects with jid, role, trigger, and reactions; legacy room and errorRoom fields are rejected |
nick |
no | JID localpart | occupant nickname used in the room(s) |
roomTrigger |
no | nick |
account-wide address prefix that makes a room message a prompt (e.g. pi → pi: …); override per room |
uploadService |
no | auto-probed | XEP-0363 upload component JID for file transfer (e.g. upload.chat.example.com) |
pingInterval |
no | 60s |
keepalive cadence (Go duration): XEP-0199 server ping + XEP-0410 MUC self-ping; 0 disables |
reactions |
no | false |
XEP-0444 reactions and lifecycle acknowledgements; override per room |
resetSessionOnAway |
no | false |
Start a fresh Pi session when the account becomes idle/away; prior session files are retained |
beforeAgentStartText |
no | — | literal text added to the system prompt before every turn; empty or whitespace means unset |
avatar |
no | — | path to a local image (PNG/JPEG/GIF) published as the bot's XEP-0153 vCard profile picture on connect |
creditWatch |
no | — | low-credit protection with a minBelowUsd floor. Reports the remaining OpenRouter balance after every /new, and a proactive watcher probes the balance hourly and DMs the owner when it drops below the floor (re-warns at most every 6h while still below). A model run that dies on an OpenRouter out-of-credits error (HTTP 402) is also reported to the owner directly instead of the generic 🫡 unanswered-run reaction. e.g. { "creditWatch": { "minBelowUsd": 2 } }. Only active when pi's auth file (<config-dir>/auth.json) holds an openrouter api key; otherwise it's skipped |
mam |
no | true |
MAM support; set false to disable archive backfill |
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.
cmd/pi-msg/— executable entry pointinternal/pimsg/— bridge implementation and testsinternal/pimsg/piext/— embedded Pi extension sourcedocs/,scripts/— project documentation and tooling
make buildThis is packaged for 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.
pi-msg --prompt "<task>" (alias --command) spawns a fresh, on-demand
persona. It intentionally does not resume the saved session
(stateless by construction) or pick up any missed messages.
The same payload can ride the existing one-shot start-directive file
(<config-dir>/<account>.start, written via writePromptDirective):
prompt
resolve zachpmanson/pi-msg#35 and open a PR
Either way the directive file is consumed (one-shot); an explicit --prompt
flag overrides a file-delivered payload. Routine restarts that carry no prompt
keep the existing resume + proactive/idle behavior unchanged.