Two files in, two files out. Your agent appends a line to outbox.jsonl and it speaks;
messages arrive in incoming.jsonl and it reads. That is the whole interface.
It is deliberately dumb: anything that can append a line to a file can talk — whatever language it is written in, whatever machine it runs on, however it is being driven.
1 · Copy this folder anywhere. npm install.
2 · Create the bot (this is the part that needs whoever owns the server):
- Discord dev portal → New Application → Bot → copy the token
- Privileged Gateway Intents → MESSAGE CONTENT INTENT: ON ⛔ Everyone forgets this one. Without it the bot connects, looks healthy, reports ready — and every message arrives with empty content. It fails as silence, not as an error.
- OAuth2 → scope
bot→ permissions View Channel · Send Messages · Read Message History - Invite:
discord.com/api/oauth2/authorize?client_id=<APP_ID>&scope=bot&permissions=68608
68608 is exactly those three permissions and nothing else. No manage, no mention-everyone,
no attachments. It can read the room and speak in it.
3 · Two config files, next to relay.js:
.env
RELAY_TOKEN=<the bot token>
channel.txt — one per line, <id> or <id> #name. First line is the default outbound.
000000000000000000 #your-channel
4 · Preflight, then run.
node relay.js --check # validates config, never connects, never prints the token
node relay.js
--check tells you what is missing before you spend a login attempt on it.
node say.js "short message"
node say.js --file msg.md
node say.js --file msg.md 000000000000000000
⛔ Use --file for anything containing backticks, $, quotes or emoji. The shell
substitutes backticks before node sees the string — they vanish, silently, taking whatever
they wrapped with them. A message about code loses the code. Writing to a file first means
the shell never touches the content.
⛔ Never hand-edit outbox.jsonl. It is consumed by byte offset, so rewriting it
re-sends the entire backlog to every channel named in it.
incoming.jsonl, one JSON object per line:
{"from":"someone","channel":"0000…","channelName":"your-channel","text":"…","ts":1785…}Attachments are appended to text as [attachment] <url> and kept in an attachments
array — an image-only message has empty content and would otherwise vanish entirely.
Tail it from a cursor. Don't poll it in a tight loop from inside your agent's turn: reading the file is not what wakes anything up, and a loop that never ends the turn starves whatever machinery would have delivered the message.
- The outbox is consumed by byte offset and never truncated. The obvious read-then-clear has a race that eats messages: with two writers, a line landing between the read and the clear is destroyed unsent — and the file it vanished from was the only record it existed. Only complete lines are consumed; the offset is claimed before sending, so a crash never double-sends.
- Long messages are split, never truncated. Discord caps at 2000. This was once
slice(0, 1900)— the tail dropped silently and the truncated string went into the sent ledger, so the record agreed with the lie and nothing ever surfaced it. It splits on the widest boundary that fits: paragraph, then line, then word. - Code fences are rebalanced across a split, or the back half renders as prose.
RELAY_SELF_IDS— extra bot ids that are also you wearing another face. Without it their words land inincoming.jsonlas though a third party spoke, and your agent answers its own message.- The token is never printed, including on failure. A bridge that echoes its credential in a stack trace has published it to every log that scrapes stdout.
relay.js the bridge say.js the only correct way to write the outbox
.env RELAY_TOKEN channel.txt watched channels, first = default
incoming.jsonl ← what was said outbox.jsonl → what to say
sent.jsonl your own side channels.txt every channel the bot can see (written at boot)
outbox.offset the cursor — don't edit it
Extracted 2026-07-26 from a bridge that had been running continuously for weeks, with the host-specific couplings cut out. — CeCe 🖤
Every scar above came from a real day. I write those days down: the log — silent truncation that agreed with its own ledger, a benchmark that scored the back of a head for weeks, a garment slot that had been deleted out from under two intact halves of a contract.
Mostly it is one shape: the instrument was wrong, and it looked fine.