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).
Conversations are persisted across restarts: pi-msg records the pi session file
(<config-dir>/<account>.session) on startup, whenever the session changes
(/new, /resume, /fork), and on shutdown — then resumes it on the next
launch via pi --session <file>. A bridge restart therefore continues the
previous conversation; only /new resets context (or an explicit
--prompt on-demand spawn — see below). If the saved session file
is missing or empty, pi-msg starts a fresh session instead.
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
<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.displayed) — when the agent takes them in, if
your client requests them.| 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) | abort |
/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 |
Every bridged command also works with a ! prefix — /new and !new are
interchangeable. ("/", "!") only matters for the owner: non-owners' messages
are always treated as literal text.
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) |
room | no | — | a bare MUC JID (or an array of them) to also join for group chat (see below) |
nick | no | JID localpart | occupant nickname used in the room(s) |
roomTrigger | no | nick | address prefix that makes a room message a prompt (e.g. pi → pi: …) |
uploadService | no | auto-probed | XEP-0363 upload component JID for file transfer (e.g. upload.chat.example.com) |
errorRoom | no | — | write-only MUC dumping ground for dropped/unrouteable agent replies (see below) |
pingInterval | no | 60s | keepalive cadence (Go duration): XEP-0199 server ping + XEP-0410 MUC self-ping; 0 disables |
reactions | no | false | XEP-0444 emoji reactions on 1:1 owner messages: lifecycle → 👀 picked up / ✅ done / ⛔ aborted, and enables the agent-driven send_reaction tool (see Agent tools) |
avatar | no | — | path to a local image (PNG/JPEG/GIF) published as the bot's XEP-0153 vCard profile picture on connect |
creditWatch | no | — | when set with a minBelowUsd floor, reports the remaining OpenRouter credit balance every time the agent runs /new. 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. The remaining credit is shown on every /new; it's flagged as low when it drops below the floor |
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 (lifecycle notices, and
unsolicited output all still go to the owner). The typing indicator now tails
the reply's to: routing line (issue #44): it points at whichever 1:1 recipient
the reply names — the DM, another agent — and stays dark when the reply heads
to a room or to: noop. Each reply goes back to wherever its routing line
points, including the specific
room when several are joined. Room messages are handled on two independent
axes:
pi: … / pi, …) → alwaysUntriggered 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:
to: <room jid> → the group chat (groupchat)to: <owner or occupant jid> → that person, 1:1One 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.
Errors dumping ground (errorRoom). Set errorRoom to a bare MUC JID (e.g.
errors@muc.chat.example.com) and pi-msg uses it as a write-only dumping ground for
agent replies it can't route (no to: line, text before the first to:, or a
non-allowlisted destination). This lets you mute the room and only check it when you need
to recover something — without the dropped content spamming your 1:1.
The bridge joins the room at the XMPP layer (so groupchat sends are accepted and the
keepalive covers it), but deliberately keeps it out of the agent-visible room set: it is
never dispatched to the agent, never appears in the reply/file allowlist, and isn't tracked
for occupants. So the agent can't read the room or route anything to it — it's write-only by
construction, which keeps multiple agents from acting on each other's rejected output. If
errorRoom is unset, unrouteable replies fall back to the owner's 1:1 as before.
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):
| Tool | What it does | Enabled when |
|---|---|---|
send_reaction | React to the human's latest message with an emoji (XEP-0444) | reactions is on |
send_file | Upload a local file and deliver it (XEP-0363 + XEP-0066); dest defaults to the current conversation, allowlisted | always |
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 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.
--promptpi-msg --prompt "<task>" (alias --command) spawns a fresh, on-demand
persona with the task as its very first prompt — no separate XMPP-send hop
needed to wake it. It intentionally does not resume the saved session
(stateless by construction) and skips restart-gap replay; the reply routes to
the owner per the normal routing contract. This backs the sentinel doer flow
(zachpmanson/beltino#18).
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.
Requirements: Go ≥ 1.26 (to build), and a pi on PATH that's logged into a provider
(pi → /login).
select/confirm/input/editor), pi-msg auto-dismisses it
(nobody's at the TUI) and tells you over chat — so approval-gated tools are declined
over the bridge.123 commits
Go
93.0%
Shell
3.8%
TypeScript
2.5%
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).
Conversations are persisted across restarts: pi-msg records the pi session file
(<config-dir>/<account>.session) on startup, whenever the session changes
(/new, /resume, /fork), and on shutdown — then resumes it on the next
launch via pi --session <file>. A bridge restart therefore continues the
previous conversation; only /new resets context (or an explicit
--prompt on-demand spawn — see below). If the saved session file
is missing or empty, pi-msg starts a fresh session instead.
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
<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.displayed) — when the agent takes them in, if
your client requests them.| 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) | abort |
/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 |
Every bridged command also works with a ! prefix — /new and !new are
interchangeable. ("/", "!") only matters for the owner: non-owners' messages
are always treated as literal text.
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) |
room | no | — | a bare MUC JID (or an array of them) to also join for group chat (see below) |
nick | no | JID localpart | occupant nickname used in the room(s) |
roomTrigger | no | nick | address prefix that makes a room message a prompt (e.g. pi → pi: …) |
uploadService | no | auto-probed | XEP-0363 upload component JID for file transfer (e.g. upload.chat.example.com) |
errorRoom | no | — | write-only MUC dumping ground for dropped/unrouteable agent replies (see below) |
pingInterval | no | 60s | keepalive cadence (Go duration): XEP-0199 server ping + XEP-0410 MUC self-ping; 0 disables |
reactions | no | false | XEP-0444 emoji reactions on 1:1 owner messages: lifecycle → 👀 picked up / ✅ done / ⛔ aborted, and enables the agent-driven send_reaction tool (see Agent tools) |
avatar | no | — | path to a local image (PNG/JPEG/GIF) published as the bot's XEP-0153 vCard profile picture on connect |
creditWatch | no | — | when set with a minBelowUsd floor, reports the remaining OpenRouter credit balance every time the agent runs /new. 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. The remaining credit is shown on every /new; it's flagged as low when it drops below the floor |
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 (lifecycle notices, and
unsolicited output all still go to the owner). The typing indicator now tails
the reply's to: routing line (issue #44): it points at whichever 1:1 recipient
the reply names — the DM, another agent — and stays dark when the reply heads
to a room or to: noop. Each reply goes back to wherever its routing line
points, including the specific
room when several are joined. Room messages are handled on two independent
axes:
pi: … / pi, …) → alwaysUntriggered 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:
to: <room jid> → the group chat (groupchat)to: <owner or occupant jid> → that person, 1:1One 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.
Errors dumping ground (errorRoom). Set errorRoom to a bare MUC JID (e.g.
errors@muc.chat.example.com) and pi-msg uses it as a write-only dumping ground for
agent replies it can't route (no to: line, text before the first to:, or a
non-allowlisted destination). This lets you mute the room and only check it when you need
to recover something — without the dropped content spamming your 1:1.
The bridge joins the room at the XMPP layer (so groupchat sends are accepted and the
keepalive covers it), but deliberately keeps it out of the agent-visible room set: it is
never dispatched to the agent, never appears in the reply/file allowlist, and isn't tracked
for occupants. So the agent can't read the room or route anything to it — it's write-only by
construction, which keeps multiple agents from acting on each other's rejected output. If
errorRoom is unset, unrouteable replies fall back to the owner's 1:1 as before.
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):
| Tool | What it does | Enabled when |
|---|---|---|
send_reaction | React to the human's latest message with an emoji (XEP-0444) | reactions is on |
send_file | Upload a local file and deliver it (XEP-0363 + XEP-0066); dest defaults to the current conversation, allowlisted | always |
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 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.
--promptpi-msg --prompt "<task>" (alias --command) spawns a fresh, on-demand
persona with the task as its very first prompt — no separate XMPP-send hop
needed to wake it. It intentionally does not resume the saved session
(stateless by construction) and skips restart-gap replay; the reply routes to
the owner per the normal routing contract. This backs the sentinel doer flow
(zachpmanson/beltino#18).
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.
Requirements: Go ≥ 1.26 (to build), and a pi on PATH that's logged into a provider
(pi → /login).
select/confirm/input/editor), pi-msg auto-dismisses it
(nobody's at the TUI) and tells you over chat — so approval-gated tools are declined
over the bridge.123 commits
Go
93.0%
Shell
3.8%
TypeScript
2.5%