pi-chaos-relay0.17.68
Bridge the pi coding agent to a CHAOS relay server — drive and answer your pi agent over Telegram and email.
Untested on omp
This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.
Reports 0
No reports yet.
Readme
pi-chaos-relay
A pi coding-agent extension that bridges your
pi agent to a CHAOS relay server
(packages/server),
so you can drive your agent from — and have it reply to — Telegram, Discord,
email, and inbound webhooks.
External channels (a Telegram/Discord bot, an email address, a webhook URL) deliver messages to the relay. This extension makes pi the client: it receives new messages over a WebSocket (with a polling fallback), surfaces them to the agent, and sends the agent's answers back to the original thread. It is the same poll-and-reply pattern the CHAOS Chrome extension uses.
Telegram / Discord / Email / Webhook ──> CHAOS relay ──/ws push (poll fallback)──> pi agent
^ │
└──────────────── POST /reply ──────────────┘
Quick start
pi install pi-chaos-relay
Then, inside pi:
/chaos-relay setup— connect to the relay (registers a session for you)./chaos-relay add— pick a channel (Telegram / Discord / email / webhook) and follow the prompts.- Message that channel — it reaches the agent, and the agent replies back.
That's it. /chaos-relay status shows state; /chaos-relay approvals <off|writes|all>
gates risky tools behind a yes/no over the channel. Prefer talking? You can also just
ask the agent: "register a Telegram bot, the token is 123:ABC."
What it does
- Registers a relay session with an ECDSA P-256 keypair (the relay's real identity model) and stores the credentials + key locally.
- Signs every authenticated request (
X-Timestamp/X-Nonce/X-Signature) so the relay can verify it really came from this client. - Registers Telegram, Discord, email, and inbound webhook channels via the relay.
- Receives new messages over a WebSocket push (background safety poll as a fallback) and injects them into the pi agent (also exposed as an on-demand tool).
- Replies to a channel via the relay; optional tool approvals let you gate risky tools from the channel.
Install
Published on npm as pi-chaos-relay:
pi install pi-chaos-relay
# or pin a version:
pi install pi-chaos-relay@latest
# or from git / a local checkout:
pi install git:github.com/paulkinlan/pi-chaos-relay
pi install ./pi-chaos-relay
pi list should then show pi-chaos-relay. Remove with pi remove ....
Configuration
Configuration is read with this precedence: environment variable > saved config
file > default. The saved config file is ~/.pi/chaos-relay.json (written with
0600 permissions; it holds your API key, so it is never committed).
| Env var | Default | Meaning |
|---|---|---|
CHAOS_RELAY_URL |
https://chaos-relay.com |
Relay base URL |
CHAOS_RELAY_API_KEY |
— | Bearer API key from POST /auth/register (secret) |
CHAOS_RELAY_AGENT_ID |
pi |
Connection/session label channels are tagged with |
CHAOS_RELAY_APPROVAL_MODE |
writes |
Tool-approval policy: off / writes / all (see Tool approvals) |
CHAOS_RELAY_ALLOW_INSECURE_HTTP |
unset | Allow a plaintext http:// relay on a NON-loopback host (see Relay URL and transport) |
CHAOS_RELAY_WS_AUTH |
query |
How the WebSocket upgrade authenticates: query (default — the legacy ?token= the relay reads today, so the API key is visible in URL logs) / header (the API key travels in Authorization: Bearer and stays out of the URL; use this once your relay reads an upgrade header) / both |
CHAOS_RELAY_WS_MAX_BACKOFF_MS |
30000 |
Cap on the WebSocket reconnect backoff (the loop sleeps min(cap, 1000 * 2**min(attempts, 5)), so the exponent is bounded at 5 and any cap above 32000 behaves like 32000). An operator knob on a flaky network, and the seam that lets the test suite reach the auth-recovery path in milliseconds instead of sleeping through 2s + 4s of real backoff. Unset, empty, non-numeric or below 1 keeps the client default |
CHAOS_RELAY_PROFILE |
default |
Names a separate config file (~/.pi/chaos-relay.<profile>.json) — see Multiple instances |
CHAOS_RELAY_CONFIG |
— | Absolute path to the config file (overrides CHAOS_RELAY_PROFILE) |
Multiple instances / sessions
Each config file is a separate identity — its own ECDSA keypair → userId →
message queue. By default every pi instance on a machine shares
~/.pi/chaos-relay.json, so they'd share one connection and all receive the
same messages. To run two instances you can talk to independently, give each its
own profile:
CHAOS_RELAY_PROFILE=work pi # ~/.pi/chaos-relay.work.json
CHAOS_RELAY_PROFILE=home pi # ~/.pi/chaos-relay.home.json
Then register a separate channel per instance (e.g. a different Telegram bot,
or a different email address) so messaging that channel reaches that specific
instance. /chaos-relay status shows the active config file so you can tell
which is which. (CHAOS_RELAY_CONFIG=/abs/path.json sets the file explicitly.)
Manage profiles from inside pi. You don't have to relaunch to create or switch a profile — just ask the agent ("switch to my work connection", "make a new profile called staging") or use the command:
/chaos-relay profile # list profiles, mark the active one
/chaos-relay profile work # switch to "work" (creates + provisions it if new)
Creating a profile writes a new ~/.pi/chaos-relay.<name>.json (a fresh
identity + keypair). To keep that bounded, the extension caps on-disk profile
files at 100; a switch that would create a new one beyond that is refused
with a clear message and nothing is written. Reuse an existing profile
(/chaos-relay profile <name>) or remove unused chaos-relay*.json files to
create another.
The cap is a resource guard, not a security boundary, and it is enforced
per-process: two separate pi processes that both start at 99 profiles could each
pass the check before either writes, ending one or two over. That is an accepted
residual (pi-chaos-relay-icj) — a single process cannot race itself, reaching it
needs a deliberate collision at exactly the limit, and the worst case is a
handful of extra files in ~/.pi. The next creation attempt sees the true count.
Switching re-points this pi instance at that profile's identity (one active
connection at a time). To have two connections live simultaneously, launch
two instances with CHAOS_RELAY_PROFILE=<name> as above.
Profiles are bound to the pi session. Each session remembers the profile it
was using (in ~/.pi/chaos-relay-sessions.json, keyed by pi's session id), so
resuming a session reconnects as the same identity — not a machine-global
guess. The profile a session connects as is chosen, in order:
- Explicit env —
CHAOS_RELAY_CONFIG/CHAOS_RELAY_PROFILE(and it pins: the session is recorded as that profile) - The session's recorded profile — set by a previous switch / launch (covers resume and reload)
- Inherit — a
new/forksession adopts the profile of the session it came from - Default —
chaos-relay.json
So: switch a session to work, quit, resume that session → back on work.
A brand-new session with no env → default (or inherits its parent). Two
instances stay independent because each is a different session.
The collision: a held profile is refused, never replaced
If the profile a session picks is already held by another live pi process, the extension refuses to connect rather than switching identity:
Relay profile "default" is already held by another live pi session (PID 12345).
Lock file: /home/you/.pi/chaos-relay-default.lock. This session will not switch
to a new identity. Resolve it by closing that session, or give this one its own
profile: launch with CHAOS_RELAY_PROFILE=<name> pi, or run /chaos-relay profile
<name> here.
You get that as a ctx.ui.notify at session_start and in
~/.pi/agent/logs/chaos-relay.log. The session is left unbound — on the
profile it was already using, or with the relay idle if it had none — so the
collision is obvious instead of quietly becoming someone else.
Why not auto-create. Until v0.17.3 the extension dodged the collision by
minting a new profile named hostname-pid (a base36 process id). The name
looked random (omarchy-2if) but was derived from the pid, and every collision
minted a new identity — a config file, an ECDSA keypair, a relay session. One
machine on this project accumulated ~3,000 profile files before anyone
noticed, with sessions connecting as whichever identity they happened to land on.
To run two sessions at once, name them yourself — that is the supported path, and it is one env var (or one command from inside pi):
CHAOS_RELAY_PROFILE=work pi # ~/.pi/chaos-relay.work.json
CHAOS_RELAY_PROFILE=home pi # ~/.pi/chaos-relay.home.json
Check what you actually have, and which one is live:
ls -lt ~/.pi/chaos-relay*.json | head # newest first, with times
/chaos-relay profile # inside pi: lists profiles, active marked
# or, as a tool: relay_list_profiles
The pattern: one profile owns the channels, everything else is silent
Pick the one session that answers Telegram/email, and point every other session at a profile whose config has no channels — it registers, polls, and receives nothing, so it never races the channel owner.
no-relay is a convention, not a feature: nothing in the code treats that
name specially. configPathFor() only slugifies whatever you pass in
CHAOS_RELAY_PROFILE (config.ts:33), and "silent" simply means the profile
file has no channels array. The name is still worth using because it is
greppable.
# the ONE session that answers Telegram/email
CHAOS_RELAY_PROFILE=default pi
# every other session: silent, and unable to collide
export CHAOS_RELAY_PROFILE="no-relay-$$" # per-shell name — see below
pi
Or keep the intent in a launcher so it cannot be forgotten:
pi-silent() { CHAOS_RELAY_PROFILE="no-relay-$$" pi "$@"; }
Why $$, and the trade-off. A silent profile is a single lock-holding
profile like any other: two concurrent sessions pinned to the same no-relay
name collide exactly as two default sessions do, and the second one is
refused (no minted identity, no relay delivery) — the hole this pattern
exists to close. A
per-shell name closes it (no-relay-$$), at the cost of one inert profile file
per shell: it owns no channels, so it is safe to delete, but it does
accumulate. The alternative is a small fixed set of per-lane names
(no-relay-a, no-relay-b) that you reuse — which is silent only while one
session uses each name.
Either way, a sweep is cheap and safe for profiles that own no channels:
for f in ~/.pi/chaos-relay*.json; do
printf '%s: ' "$f"
node -e "const d=require(process.argv[1]);console.log((d.channels||[]).length+' channel(s)')" "$f"
done
Do not sweep the profile that owns your channels. That file is the
identity the channels are bound to: deleting it loses the keypair, and with it
the relay session that receives your Telegram/email. Legacy auto-created names
(omarchy-…, minted before v0.17.3) and per-shell no-relay-… names are the
ones that can go; a profile you named yourself — or that reports channels above
— deserves a look first.
The ECDSA private key is part of your identity and is deliberately not
configurable via an env var — it lives only in the 0600 config file. Setup
generates the keypair, sends only the public key to the relay, and persists
the pair locally. The Bearer API key you see in the config is just a session
token auto-issued from that keypair — you never enter or manage it, and it's
re-issued automatically if it expires.
The quickest start is zero-config — just tell the agent what you want:
"connect my Telegram"
The agent registers a relay session for you on first use (no setup step needed) and walks you through linking the channel. Or run the interactive setup:
/chaos-relay setup
This asks no questions in the common case: it connects to the hosted relay,
auto-registers your private session, starts the background poller, and offers to
link your first channel. Add more any time with /chaos-relay add, and check
state with /chaos-relay status.
Self-hosting / custom relay? Use /chaos-relay setup --advanced to enter a
custom relay URL, agent id, or paste an existing API key (or set the
CHAOS_RELAY_URL env var).
Troubleshooting
If setup or a request fails with Failed to parse URL from /… (or any
"relay error" during onboarding), the saved relay URL is malformed — usually
a command was accidentally pasted into the URL field. Two ways to recover:
/chaos-relay doctor— runs a diagnostics check-list (config validity, credentials, reachability, transport, channels) and points at the fix./chaos-relay reset— non-interactively clears the badrelayUrlbut keeps your credentials and channels. Then run/chaos-relay setupto re-enter the URL. Use/chaos-relay reset allfor a full wipe.
Default setup is zero-config and never asks for a URL, so this only affects
older configs or a bad CHAOS_RELAY_URL / --advanced entry. A URL must be an
absolute http(s)://… and use TLS unless it is loopback: https:// is
accepted anywhere, http:// only for localhost / 127.0.0.0/8 / [::1].
Anything else would put the bearer API key on the wire in the clear — in every
HTTP request and in the WebSocket upgrade — so a plaintext URL to
an external host is refused, and the refusal says so instead of being reported as
a malformed URL. Set CHAOS_RELAY_ALLOW_INSECURE_HTTP=1 to accept it on purpose
(a LAN relay with no TLS): it is off by default, never implied, and every connect
over such a URL logs a warning.
The push WebSocket can authenticate with a header instead of a URL
parameter: with CHAOS_RELAY_WS_AUTH=header the upgrade request carries
Authorization: Bearer <apiKey>, so the key cannot be picked up by a proxy log,
an access log, or an error string that quotes the URL.
The default is still the relay's ?token= parameter, because that is what
the relay's /ws handler reads today — it completes the upgrade only when the
query token is present, and a session that switched to a header-only upgrade
before the relay learned to read one would lose push delivery (leaving the 120s
safety poll as the only inbound path). While the default is in use, each
connection logs one warning naming the exposure and pointing at
CHAOS_RELAY_WS_AUTH=header; once your relay accepts an upgrade
Authorization header, that is the setting to use. CHAOS_RELAY_WS_AUTH=both
sends both, for a relay mid-migration. An unrecognised value is reported and
then the default is used — never silently taken as a valid mode — and
/chaos-relay status reports which mode is in use and whether the key is in
the URL.
The effective URL is the first valid value in precedence order (env → saved
file → default), so a malformed or refused value is skipped rather than silently
discarding a valid lower-precedence one. If no URL is configured anywhere,
auto-provisioning targets the hosted relay (with a warning); a set-but-invalid or
plaintext-external CHAOS_RELAY_URL with no saved URL is refused, and so is a
saved plaintext-external URL — a bound session refuses to connect rather than
following the default relay with an identity that was meant for your own.
The config file (and the <config>.state side-car holding the message cursor
and de-dup log) are written atomically (temp file + rename), so a concurrent
reader — e.g. a second pi session sharing the same profile —
never sees a half-written file. A crash between the temp write and the rename
leaves an inert .tmp.* orphan beside the file while the previous complete
file survives, if one existed (a first-ever write has no previous file).
Backups of a profile must include BOTH the config file and
its .state side-car: the de-dup log lives only in the side-car, so a
backup that restores the config without it resets de-dup (recent messages may
be re-delivered — the extension warns loudly when it detects this).
A damaged config never crashes the bridge: an
empty or whitespace-only file self-heals silently, and anything else that
fails to read or parse (truncated JSON, a hand-edit, non-object content) is
ignored with one warning naming the file and the error, falling back to
defaults. /chaos-relay reset (or reset all) clears a file you don't want
to repair by hand.
A hard kill can re-deliver a message
The cursor and de-dup log are written at the end of a batch (or within a couple of seconds), not on every inbound message, so a burst costs one small write instead of one per message. A clean shutdown flushes synchronously, a batch handed back for a replacement session is written immediately, and a profile switch flushes before it moves the active config.
If the process is killed hard (SIGKILL, power loss), the last window may not have
reached disk, so the relay's replay can deliver a message the agent already saw. A
kill during a slow delivery has a second, smaller window: the batch has been accepted
(and may be persisted by the safety interval) but not yet handed to the agent, so
that batch can be dropped rather than re-delivered. Both windows are bounded by the
same few seconds, and dropping one batch there is the same behaviour as before the
debounce, when the cursor was persisted as soon as a batch was accepted.
"The reply was accepted" does not mean it was delivered
relay_reply returning ok: true means the relay resolved and stored the
reply. It does not mean Telegram or email delivered it — delivery happens
server-side and is not observable from this client. Two guarantees back this up
(since relay v0.17.0 / a journal-xk4 server):
- Unknown channels are refused by name, before anything is stored. If the
channelIdmatches no channel registered to your session — or yourchannelTypecontradicts the channel's type — the relay answersREFUSEDnaming the channel (and lists your registered channels). A mistyped id can no longer round-trip as a confirmation; nothing is stored, nothing is sent. - Acceptance names the channel the relay actually resolved. The success
confirmation quotes
type / label / idfrom the relay's own answer, not from your request. Against an older relay that names nothing, the tool says so — the confirmation then "only echoes the requested id and is NOT proof of delivery."
The extension logs the same distinction when the ack arrives:
relay_reply: WS ack ok responseId=… NOTE: ack means the relay RESOLVED + STORED the reply —
actual Telegram/email delivery happens server-side and is logged there.
So when inbound messages arrive fine and your replies vanish: a REFUSED is
this client or the relay telling you the target is wrong (fix the id); an
acceptance means ask whether the relay stored them and then failed to forward
— the channel's own state (e.g. can the bot post in that chat? is the email
address still verified?) — rather than retrying the send.
Commands
| Command | Description |
|---|---|
/chaos-relay setup |
Zero-config connect (auto-registers your session) + start polling, then offers to link a channel. --advanced for a custom relay URL / agent id / pasted key |
/chaos-relay connect <token|email|webhook> |
One-shot: paste a Telegram/Discord bot token, an email, or webhook and it sets up the relay + registers the channel in a single step |
/chaos-relay profile [name] |
List connection profiles, or switch to / create one (each is a separate identity). No arg lists them |
/chaos-relay add |
Interactive wizard to add a channel (Telegram / Discord / email / webhook) |
/chaos-relay status |
Show config, poller state, and live relay health |
/chaos-relay poll |
Poll once now and deliver any new messages |
/chaos-relay stop |
Stop the background poller |
/chaos-relay approvals <off|writes|all> |
Set/show the tool-approval policy |
/chaos-relay doctor |
Diagnostics: config validity, credentials, relay reachability, transport, channels |
/chaos-relay reset [all] |
Clear a corrupted relayUrl (keeps creds/channels); reset all wipes the config file |
/chaos-relay help |
Show the full command reference (also shown on an unknown subcommand) |
Tools (LLM-callable)
| Tool | Description |
|---|---|
relay_connect |
One-shot: give it a bot token / email / webhook and it sets up the relay (auto-registering your session) and the channel in one step. Lets you just paste a token and say "connect this" |
relay_list_profiles |
List connection profiles and the active one |
relay_switch_profile |
Switch to (or create) a connection profile — "switch to my work connection" |
relay_check_messages |
Pull pending inbound Telegram/email messages and securely materialize attached images/files |
relay_reply |
Reply to a channel message (channelType, channelId, content, optional replyTo, optional files — absolute paths to attach; images render inline on Telegram, email gets real attachments; max 3 files, 5MB each, passed through and never stored). An unknown/mismatched channelId is REFUSED by name — nothing is stored or sent. ok: true means the relay resolved + stored the reply (naming the resolved channel), not that the channel delivered it — see Troubleshooting |
relay_register_telegram |
Register a Telegram bot channel |
relay_register_discord |
Register a Discord bot channel |
relay_register_email |
Register an email channel |
relay_register_webhook |
Register an inbound (one-way) webhook URL |
Tool approvals
pi has no built-in per-tool permission prompts — it runs tools with your account's permissions. For turns driven from a channel you can require approval over that channel before risky tools run:
| Mode | Behaviour |
|---|---|
writes (default) |
Ask before bash, edit, and write; reads/searches run freely. relay_reply is gated when it ships a file attachment, and a text-only relay_reply is gated once the session has inspected local content (read/grep/bash) — this closes the read → text-reply exfiltration path without prompting on every read. The control-plane relay tools — relay_connect, relay_register_telegram/_discord/_email/_webhook, relay_switch_profile — are gated too, and any future relay_* name is gated by default. Only the read-only plumbing (relay_check_messages, relay_list_profiles) stays open. |
all |
Ask before every tool except the read-only relay plumbing (relay_check_messages, relay_list_profiles). |
off |
Fully autonomous — run every tool. Best paired with a sandbox/container. |
The writes read guard is session-scoped: once the session has inspected
local content (from a channel turn or a terminal/local turn), every later
channel-driven text-only relay_reply requires approval until the session ends.
Inspection means any read or grep of a local file and any bash
execution — a shell command can print arbitrary local content (cat, grep,
env, …), so a successful bash run taints the session exactly like a read does.
The taint is cleared only at session start/shutdown, not per turn — the LLM
conversation context persists across turns, so a secret read in turn 1 cannot be
text-replied out in turn 2 without approval. It does not gate the reads or
the bash execution themselves (in writes mode bash is gated for being a
write-class tool, and an approved run taints the session). For the strongest
posture — where a channel-driven turn cannot inspect local content at all
without approval — use all (which also gates every read/search and every
reply).
writes is the default so a channel-driven session is NOT ungated out of the
box; choose off (or /chaos-relay approvals off) for the old fully-autonomous
posture. Set with /chaos-relay approvals <off|writes|all> or the
CHAOS_RELAY_APPROVAL_MODE env var.
The control-plane gate matters even when you never touch relay_connect
yourself: relay_register_* would otherwise let a channel-borne prompt injection
register the attacker's own channel, and relay_switch_profile would let it
move the session to a profile whose approval mode is off. Either one takes the
session over and self-approves the gate, because the next approval question is
then delivered to the attacker's channel. In writes and all modes those
calls pause for your approval like any other gated tool.
When a tool is gated, the agent pauses and sends an approval request to the active
channel; reply yes <code> to allow or no <code> to deny — the prompt shows a
short code, and only the originating sender's reply counts (auto-denies after 5 minutes).
The prompt describes the tool without echoing its payload: a relay_reply shows only
its channel type, a short channel fingerprint, and a character/byte count (plus the
name and size of each attachment); a bash
approval shows a REDACTED command string (secret-shaped values are masked) so the
operator can judge benign vs destructive; write/edit show the target path and
size; a control-plane call shows the parsed channel kind, a short fingerprint for
channelId and for any name the caller chose (never the name itself), an email address
because that is the routing target a verification link would go to, and only a length
for credential fields (botToken, password, secret, relay_connect's token) — the
question adds a line saying the call changes where the session connects or who may drive
it, so deny it if you did not ask for it. Caller-supplied names are deliberately drawn
as a fingerprint rather than echoed, because the agent chooses them: a channel-borne
relay_switch_profile {name: …} must not be able to copy local file contents into a
question that goes back out over the relay. Other tools show field sizes or path
fingerprints — never their contents.
Terminal/local turns are never gated.
Telegram setup — end to end
- In Telegram, talk to @BotFather, create a bot, and copy the bot token.
- In pi, make sure the relay is configured (
/chaos-relay setup). - Ask the agent to register Telegram, or call the tool directly with the bot
token. The extension calls
POST /channels/telegram/register, which validates the token, sets the Telegram webhook, and returns a channelId, bot username, and a pairing code. - Open Telegram, message your bot, and send it the pairing code to link your chat.
- Done. Messages, photos, and files you send the bot now arrive at the agent
(auto-injected by the poller). The agent replies with
relay_replyand they appear in your Telegram thread.
Email setup — end to end
The relay must be running with
CHAOS_EMAIL_DOMAINconfigured (and an email provider such as Resend wired up). See the relay's self-hosting docs.
- Make sure the relay is configured (
/chaos-relay setup). - Ask the agent to register email, or call the tool with your email address.
The extension calls
POST /channels/email/registerand returns a channelId and an inboundAddress (e.g.ch_abc123@your-relay-domain). - Check your inbox for a verification link and click it to activate the channel.
- Done. Email text and attachments sent to the inbound address reach the agent;
replies go back to the sender via
relay_reply.
What happens to a broken or oversized message
The relay forwards channel payloads as-is, so every inbound message is checked
before the agent sees it. A message that is missing a required field, carries a
field of the wrong type, or has a timestamp that is not ISO-8601 (which would
corrupt the persisted resume cursor) is dropped and reported in the log as
WARN: dropped an inbound message: <why>. It is never half-delivered, and
because its id is remembered the replay that follows (the relay resends until
the cursor passes it) is silent and never reaches the agent.
Reporting is capped so a misbehaving relay cannot fill the log: the first three occurrences of each kind of problem are logged, the fourth adds "further occurrences of this are not logged", and after that the kind is silent for the rest of the session. A different kind still reports. If you are chasing a message that never arrived, that message's kind may already have used its three lines — the relay's own logs are then the next place to look.
A message that is merely too big is delivered in a repaired form, with a warning in the log and a note in what the agent sees, because a repair the agent cannot see is a repair it cannot ask you about. The notes are carried beside the message, never inside the sender's text: an approval answer is matched against what the sender actually wrote, so a reply that itself carried an unusable attachment still answers its request, and a sender cannot type a note-shaped block to have their own later words ignored.
- content over 256 KiB is truncated and the agent sees
[chaos-relay: content truncated at 262144 bytes, from <n> bytes]— such a message is never treated as an answer to an approval, because the rest of what the sender wrote is unknown; - only the first 3 attachments are fetched, and unusable or over-limit ones
add a
[chaos-relay: <n> attachments not delivered (…)]note beside the message; - an inbound WebSocket frame over 5 MiB is dropped before it is parsed — the
same bound the HTTP transport already applies to a whole
/messagesresponse, so the push path never refuses something the poll path would have delivered.
A missing timestamp is delivered, but it cannot advance the resume cursor, and the cursor is compared as an instant (so a timestamp with fractional seconds or a UTC offset orders correctly).
How inbound delivery works
While a pi session is active, the extension holds a WebSocket to the relay
and receives messages the instant they arrive. A slow background safety poll
(every ~120s) runs only as a backstop in case a push is missed between
reconnects. Reconnects back off exponentially (2s→4s→…→30s), and the fast
first retry is earned, not given: the backoff only resets once a connection
has stayed open for 30 seconds, so a relay that accepts the WebSocket and
immediately drops it gets the full exponential curve instead of hammering a
2-second reconnect floor forever. New messages are de-duplicated by id — and
that de-dup log is
persisted (in a small side-car <config>.state file next to the config,
written once per delivery batch), so the relay's on-connect
replay (a 5-minute lookback it sends every time the WebSocket connects) never
re-processes a message already handled before a restart. Fresh messages are
injected into the agent as a user message that includes each message's id,
channelType, channelId, sender, and content — everything the agent needs to
call relay_reply. Each message's block is fenced by an identical
--- chaos-relay message <random-token> --- line before and after it, and the
envelope fields on the line after the opening fence are JSON-quoted: a sender
controls both their display name and their content, so the per-delivery random
boundary (plus escaping) is what stops either from forging a second envelope or
a different sender in the agent's prompt. When a message was sent as a reply (Telegram
reply-threading, e.g. tapping reply on a question and answering "Drop"), the
replied-to message travels with it as an
[In reply to message id="…" from "…": "…quoted text…"] line before the
content, so a terse answer can be resolved to the question it answered; channels
that give only the id (or only the quoted text) still get
[In reply to message id="…"]. Inbound Telegram/email attachment descriptors
are downloaded through an ECDSA-signed, user/message-scoped relay endpoint.
Files are written to
private 0700 directories with mode 0600; supported PNG/JPEG/GIF/WebP images
are also injected directly into the Pi image context after magic-byte checks.
Per-file failures are shown without dropping the text message. Stale local files
are removed after 24 hours. You can force an immediate pull with
relay_check_messages or /chaos-relay poll.
Security
- ECDSA P-256 request signing is the default identity path. At registration
the client generates a P-256 keypair, sends only the public key to the relay
(
POST /auth/registerwithpublicKey), and the relay binds the session to it. Every authenticated request after that is signed: a base64 ECDSA-SHA256X-Signatureover{timestamp}|{nonce}|{path}|{bodyHash}(path = pathname only,bodyHash= SHA-256 hex of the body, empty body for GET), plusX-Timestamp(ISO 8601, ±5 min) andX-Nonce(16 random bytes hex, replay protected). This matches the canonical CHAOS extension/server implementation. - The private key never leaves the machine — it is stored only in
~/.pi/chaos-relay.json(0600) and never sent to the relay or committed. The API key lives in the same file (or theCHAOS_RELAY_API_KEYenv var)..gitignoreblocks straychaos-relay.json/.envfiles. - Bearer-only mode is kept as a fallback for legacy sessions registered without a public key (e.g. a pasted API key). The relay still accepts unsigned requests for those; signed is preferred and automatic when a keypair exists.
- The relay's public key is pinned (TOFU). Registration returns the relay's
public key and the client persists it (
serverPublicKey). A later re-registration that returns a DIFFERENT key is refused — the client fails closed rather than silently adopting a new key — which closes the "impersonate the relay after first contact" path at registration time. The pin does NOT yet authenticate the content of WebSocket frames or HTTP responses: that requires server-side response/frame signing, which is not implemented (see Known limitations). - Bot tokens are sent only to the relay's register endpoint over HTTPS; the relay encrypts them at rest. They are not persisted by this extension.
- The durable relay log (
~/.pi/agent/logs/chaos-relay.log) is kept owner-only (directory0700, file0600), and pairing codes, channel ids, bot usernames, email addresses and webhook URLs are withheld from it. The interactive tool output still shows the fresh pairing code / verification link the operator needs to finish linking a channel. - Inbound attachments are capped at 3 files per message and 5MB each. The relay
stores only bounded descriptors and private provider references—not bytes,
credentials, or Resend signed URLs. Attachment retrieval requires request
signing even for otherwise legacy bearer-only sessions and returns
no-store.
Development
npm test # node --test unit tests (relay client, poller, config); runs the version check first
npx tsc --noEmit -p tsconfig.json # type-check against pi types
npm run check:version -- --base origin/master # package.json/lockfile agreement (required by the gate)
npm run test:fast # implementer iteration: the two checks above + only the affected tests
npm run test:fast is for iteration, not for landing. It always runs the
whole-tree type-check and the version gate, then narrows the tests using the
explicit reverse-dependency map in scripts/fast-gate.ts (a mapped module means
every test file that transitively imports it). It is a unit tier: the
integration suite test/index.test.ts is not run by it, because the entry point
imports every module and including it would make every change the full suite. It
falls back to the full suite for the entry point, the manifests/lockfile,
tsconfig.json, anything under test/, any path not in the map, a change list it
cannot read, and a checkout with no resolvable default branch (--base <ref>
selects a base and must resolve). Landing — and the fleet merger — still run
npx tsc --noEmit && npm test on the merged tree; see AGENTS.md.
npm test runs the version check first (via pretest), so a tree whose
package.json and package-lock.json disagree fails the gate instead of shipping —
that half needs no base and always applies. The check also refuses a version that
has moved backwards: it compares against the default branch (read as the fully
qualified refs/remotes/origin/master, so a local branch named origin/master
cannot shadow it), and in a shallow, single-branch, offline or remote-less checkout
it falls back to HEAD and says which half it could not enforce and how to
enforce it explicitly. The strict check takes a required --base, refuses an
ambiguous bare name rather than resolving it, and fails closed on an unresolvable
ref.
npm ci (or npm install) must run before the npx tsc check, which needs the
typescript devDependency — without it npx instead runs the unrelated
tsc@2.0.4 package and reports "This is not the tsc command you are looking
for"; npm test needs no install.
Integration testing against a local relay: run the CHAOS relay server
(deno task start in packages/server with --unstable-kv) and point
CHAOS_RELAY_URL=http://localhost:8787 (loopback, so plaintext needs no opt-in).
A relay on another machine over plaintext http:// needs
CHAOS_RELAY_ALLOW_INSECURE_HTTP=1; prefer https:// (a self-signed cert on a
private network still keeps the API key out of the clear).
Known gaps / future work
- A locked profile leaves the session without relay delivery. A collision is
now refused instead of auto-replaced (see The collision: a held profile is
refused, never replaced), so the losing session has no push connection until
you close the holder or give it its own
CHAOS_RELAY_PROFILE. That trade is deliberate — an inert session you can see beats a new identity you never chose — and the check still runs atsession_startonly, so a collision that only appears later (a second instance that starts while this one is still provisioning) is not re-checked until the nextsession_start. - The reply-ack note is WS-only. The WebSocket path logs that
okmeans stored, not delivered (index.ts:879); the HTTP fallback logs a bareok=(index.ts:900) and the tool text says "will forward it". One shared sentence in all three places would stop the client from implying more than it knows. - Server response signing. The client enforces the TOFU pin on the relay's public key (a re-registration returning a different key fails closed), but it does not yet verify server signatures on the content of WebSocket frames or HTTP responses — the relay does not sign them. Authenticating frame/response content requires a server-side change (sign frames/responses with the pinned server key). Outbound request signing (the key threat: someone spending your API key) is fully implemented.
- Email registration depends on relay-side
CHAOS_EMAIL_DOMAIN+ provider config.
License
MIT — see LICENSE.