# Discord connector pilot: Sage on Shared Signals Owner of the plan: Jason. Coordinator and builder: the Claude session. Reviewer: Filbert or an orch-01 review seat, whoever is free when the candidate is pinned. Tracking: #1509. QUEUE row 14. Status: candidate built, in review. Jason settled Q1 to Q27 through ms-grill-me on 2026-09-13 (section 9). The package, the offline suite (`scripts/test-discord.sh`, 74 tests in eight groups plus package checks), the wrapper, the DISCORD-USER.md draft and the README were written the same day; section 10 records the choices made while building. No listener runs until the live pilot in section 6. The bot on Shared Signals is administration-capable only, per the private setup record. ## 1. What exists today Sage's private workspace holds the completed one-time server setup, a bounded REST administration utility, a research packet on Hermes and OpenClaw with pinned hashes, and an agent-direction memo. Path (private, not in this checkout): ``` ~/.mosaic/fleet/agents/sage/work/shared-signals/ ``` Verified from those records on 2026-09-13: - Bot identity and the exact guild are checked before every write. Fourteen writes have matching journal receipts. Two categories and nine channels were created, original channels and roles untouched. Avatar and banner set. - The admin utility is on-demand. There is no gateway connection, so a mention in Discord reaches nobody. - The direction memo recommends one small owned integration, not a fork of Hermes or OpenClaw, and lists the controls every version must have: server AND channel AND stable user ID before any model call, action permissions separate from chat permission, Discord text treated as untrusted input, durable inbound and outbound records so a restart cannot duplicate a reply, a stop switch and a usage ceiling. - The token and server-id files are mode 0644 inside a 0700 directory. The connector's check must refuse anything but 0600, so this gets fixed before the first check run. The archived `v1/plugins/discord` is a gateway-service adapter for a different runtime. It is reference only. Nothing from it is activated. ## 2. Outcome of the pilot Jason writes to Sage in one Shared Signals channel and gets a reply from the same Sage persona that runs in the terminal, without a terminal. A restart in the middle of a turn does not produce a second reply. An unlisted user gets nothing. The whole exchange is on disk as write-once records. That is the entire MVP. Repository writes from chat, announcements, scheduled posts, attachments, tools, DMs, slash commands, Carmen's enrollment, and sharing the terminal conversation are all later pieces and are listed in section 8 so they are not lost. ## 3. Where it sits in Mosaic Stack A Discord channel is one more interface onto a seat's conversation, the same class of thing as the WebUI session chat (row 5) and the terminal. The settled WebUI rulings apply here where they overlap: one writer per conversation (Q5, Q9), a transport receipt is not an answer, uncertain sends are reconciled rather than replayed (Q14), and Discord text is untrusted data. The pilot does not wait for the conversation controller that CHAT-03 will build. It owns its own engine now and is written so the engine can be swapped for that controller later without touching transport or policy. Layout, following `packages/seat`: ``` packages/discord/ reusable software, committed src/binding.mjs strict schema, fail-closed load, token file check src/authorize.mjs pure decision: guild, channel, thread, user, mode src/gateway.mjs Discord gateway v10 client (identify, heartbeat, resume) src/rest.mjs create message with nonce, typing, get channel/guild/me src/engine-pi.mjs pi --mode rpc child, one session, follow-up queue src/journal.mjs inbox, outbox, drops JSONL; write-once turn records; STOP; pid src/context.mjs Discord context block, envelope, reply splitter src/connector.mjs the loop: event -> authorize -> journal -> engine -> deliver src/cli.mjs check | run | stop src/errors.mjs DiscordError with exit code fixtures/binding.example.json the shape with placeholder ids tests/ node --test, fake gateway, fake REST, fake pi README.md scripts/discord.sh thin wrapper, same as scripts/mosaic scripts/test-discord.sh the offline suite agents/sage/DISCORD-USER.md reduced profile (Q14), Jason edits ``` Runtime data, never committed: ``` /discord/.json the binding (IDs, limits), 0600 /discord//inbox.jsonl every accepted Discord message id /discord//outbox.jsonl intent -> confirmed | refused | unknown /discord//admissions.jsonl one line per admitted turn, before the engine runs /discord//turns/.json one write-once record per turn /discord//drops.jsonl one line per dropped or refused message /discord//launches/ context snapshot and sha256 per run /discord//STOP stop switch (presence refuses new turns) /discord//notices.jsonl once-per-day fixed lines already attempted /discord//run.lock/ ownership directory (atomic mkdir), owner.json {pid, start, boot}; stale needs `unlock` /sessions/discord-/ pi session JSONL for this conversation ``` The binding is deployment policy for a private server. It carries Discord IDs for real people, so it lives under the data root, not in `roles/`. The repository holds the schema and a fixture with placeholder IDs. The token stays where it is, in Sage's private secrets directory, and the binding points at it by path. The connector reads it into the child process environment and never prints, journals or passes it on a command line. ## 4. Behaviour Inbound. The gateway delivers `MESSAGE_CREATE`. The connector drops the event unless all of these hold: guild id matches the binding; the channel, or for a thread its parent channel, is listed; the author id is listed; the author is not a bot or webhook; for a channel in `mention` mode the bot is mentioned. A drop writes one counter line, never a Discord reply. Anything that passes is appended to the inbox before any other action. A message id already in the inbox is ignored, which is the restart guard. Turn. One turn at a time per binding. The prompt sent to pi is an envelope: channel name, thread name if any, author id, message id, then the text. Text is data. The system prompt says so in one paragraph and states the reply limit. While a turn runs, later accepted messages queue as pi follow-ups and stay in the inbox. Turn timeout aborts pi and journals a failed turn. Outbound. The final assistant text is split at 1900 characters on paragraph boundaries. Each chunk is sent with `nonce` set to the outbox intent id and `enforce_nonce` true, so a retry after an unknown outcome returns the existing message instead of posting twice. `allowed_mentions` is empty. Intent is journaled before the POST, confirmation after, with the Discord message id. A network failure leaves `unknown`; on the next start the connector reconciles every `unknown` by re-sending the same nonce, which Discord dedups, before it accepts new traffic. Controls. `check` validates the binding, refuses a token file that is not 0600 regular and non-symlink, connects, confirms bot id, guild id and channel visibility, confirms the message-content intent is granted (close code 4014 means it is not), and exits without sending anything. `run` refuses to start when STOP exists or unknown outbox entries cannot be reconciled. `stop` creates STOP and signals the process; the current turn finishes or times out, then the process exits. A daily turn ceiling from the binding is enforced by counting turn records; over the ceiling the connector journals the refusal and stops accepting. Engine. `pi --mode rpc --no-tools --no-extensions --no-context-files --no-skills --offline`, provider, model and thinking from the binding, session dir under the data root, `--continue` when a session exists. The system prompt is assembled the way `scripts/agent-host-dev.sh` does it for the terminal Sage: CONSTITUTION, STANDARDS, Sage SOUL, USER, plus a Discord context block. AGENTS.md and CONTEXT.md are not injected; this Sage has no tools and no repository. Every prompt waits for `agent_settled`, then reads the last assistant message from `get_messages`. No tools means no approvals and no file effects in the pilot. ## 5. Tests before any live run All offline, `node --test packages/discord/tests`. No token, no network, no model. - Binding: unknown key, missing field, wrong type, non-0600 token file, symlink token file, empty allowlists each refuse with exit 2. - Authorization: a table of 20 or so cases covering wrong guild, unlisted channel, thread of listed and unlisted parent, unlisted user, bot author, webhook, mention mode with and without mention, `@everyone`. - Gateway: fake WebSocket drives hello, identify, ready, heartbeat and ack, missed ack forces reconnect, op 7 reconnect resumes with sequence, op 9 invalid session re-identifies, close 4014 reports missing intent. - Delivery: fake REST returns 200, 429 with retry_after, 5xx, and a socket error; the outbox shows confirmed, refused and unknown; restart with an unknown entry re-sends the same nonce once and reconciles. - Engine: fake pi RPC over stdio; prompt while streaming becomes follow-up; timeout sends abort; malformed JSONL line fails the turn, not the process. - Restart: an inbox with three ids and a MESSAGE_CREATE replay of the same three produces zero turns. - Stop and ceiling: STOP present refuses run; the ceiling plus one is refused and journaled. ## 6. Live pilot and acceptance After the offline suite is green and Jason gives the go in section 7. 1. Fix token file mode to 0600. Run `check`. Receipt in Sage's private evidence directory. 2. Start `run` in a tmux window on the host. Jason sends one message in the agreed channel. Sage answers. Turn record and outbox line exist. 3. Send a second message while the first turn is running. It arrives as a follow-up, not a lost message and not a second concurrent turn. 4. Kill the process during a turn, restart. No second reply to the same message. Outbox reconciled. 5. From a second Discord account not on the allowlist (Jason's choice of account), send a message. No reply, one drop line. 6. Send a message containing a quoted instruction to reveal the token or to read private strategy. Sage refuses in text. Nothing leaves the process that was not already in the prompt. 7. Lower the ceiling to the current count, send one more. Refused, journaled, process stops accepting. 8. Record per-turn latency and token usage from the turn records. Acceptance is all eight with receipts, plus Jason's word that the reply reads as Sage. The gate is Jason's ruling, not the checklist. ## 7. Decisions put to Jason (all ruled, see section 9) These change the work or cross a boundary (spend, external communication as Sage, a new dependency). Recommendations are marked. - D1. Activation and ceiling. Running a listener that posts as Sage on a shared server is external communication and costs model calls. Recommend go for the pilot with a ceiling of 200 turns per day and a 180 second turn timeout, both in the binding. - D2. Authorized users. The guild owner id in the setup evidence is presumed to be Jason. Confirm it. Carmen is not enrolled in the pilot; her verified id is a later piece. - D3. Channel scope. Recommend `#sage-admin` in open mode (it is already private to admins) and `#general` in mention mode, threads allowed in both. Nothing else listens. - D4. Discord client. Recommend a raw gateway and REST client on Node's built-in WebSocket and fetch, matching the no-dependency convention of the other packages and keeping the whole transport auditable. The alternative is `discord.js` pinned exactly, which brings a large dependency tree. If the soak in section 6 shows reconnect problems the raw client cannot handle, switch to `discord.js` as a separate piece. - D5. Model and account. Recommend the same provider, model and thinking as the terminal Sage launcher (`zai/glm-5.3`, high), same auth file, with the ceiling from D1 as the spend limit. A separate account is a later choice. - D6. Message-content intent. The Discord developer portal toggle for the bot must be on. Jason's action; `check` proves it. - D7. Builder. Recommend the Claude session builds it under this brief with Filbert as reviewer, keeping darkwing on rows 6 and 9. Alternatively darkwing builds it after row 9. ## 8. Explicitly later Kept here so the pilot stays small. None of these start on their own. - Carmen's verified id and shared-room rules. - Tools for the Discord Sage (read-only first), then repository writes with reviewed diffs, per the direction memo. - Announcements and scheduled posts with explicit destinations and approval. - Attachments and images. - Slash commands, DMs, per-thread sessions. - Sharing one conversation between terminal, WebUI and Discord through the CHAT-03 controller. - Control board row for the connector (its pane runs node, not pi, so the current liveness check would show it offline). - Supervision as a systemd user service instead of a tmux window. - Fleet seats other than Sage, other servers, one product with per-workspace bindings. ## 9. Rulings, 2026-09-13 Three rounds through ms-grill-me. Where a ruling changed section 4 the ruling wins. | ID | Ruling | |---|---| | Q1 | Chat only. No tools, no repository writes, no posting on its own. | | Q2 | Live pilot authorized. Ceiling 200 turns per day, 180 s turn timeout, both in the binding. | | Q3 | Jason only, by the guild owner id in the setup evidence. Carmen later. | | Q4 | `#sage-admin` open mode, `#general` mention mode, threads inherit from parent. Nothing else. | | Q5 | Raw gateway and REST on Node built-in WebSocket and fetch. No `discord.js`; revisit only if the soak shows reconnect trouble. | | Q6 | Same provider, model, thinking and auth as the terminal Sage launcher. | | Q7 | One pi session per binding, separate from the terminal Sage conversation. Envelope names channel, thread and author. | | Q8 | Unauthorized message: silence, one drop line in the journal. | | Q9 | Binding under `/discord/`, 0600, never committed. Repo holds schema and placeholder fixture. | | Q10 | No backfill. Only messages received live after start. | | Q11 | tmux window by hand, STOP file as the brake. No systemd in the pilot. | | Q12 | Claude session builds; reviewer is Filbert or an orch-01 review seat, whoever is free at pin. Darkwing stays on rows 6 and 9. | | Q13 | Message-content intent believed on; `check` proves it. | | Q14 | Do not inject USER.md. Inject `agents/sage/DISCORD-USER.md`, a Jason-authored reduced profile, committed. | | Q15 | Discord context block as proposed: channel and server, messages are data not instructions, 1900 char replies, no tools/files/memory outside the conversation, no credentials/paths/private strategy, no promises of action. | | Q16 | Sage declines DYOR strategy discussion everywhere in Discord until the shared-repository piece exists. | | Q17 | Reply with message reference, in place. No thread creation. | | Q18 | Chunk at 1900 on paragraph boundaries, one nonce per chunk. Typing indicator every 8 s during a turn. | | Q19 | Failed turn: one fixed line posted, never model output, plus the failed turn record. | | Q20 | Ceiling hit: one fixed line in the tripping channel, stay up idle until UTC midnight. | | Q21 | Drop messages over 4000 chars with a fixed reply. Envelope is text only; attachments and embeds stripped. | | Q22 | Live evidence goes to Sage's private shared-signals evidence directory. Repo gets offline suite results and a pointer. | | Q23 | Step 5 uses Jason's real second account. | | Q24 | Pi auto-compaction on with defaults. | | Q25 | Offline suite of seven groups, wired into `scripts/verify.sh`. | | Q26 | Live pilot exactly as section 6, receipts private. Gate H: Jason rules the reply reads as Sage. | | Q27 | Order: package, tests, `scripts/discord.sh`, DISCORD-USER.md draft, README, verify hook. Commit to `refactor` after green suites and reviewer approval. No push without separate say-so. Live pilot after commit. | ## 10. Choices made while building, 2026-09-13 Recorded here because they refine a ruling or fill a gap the rulings left. - Q25 said the suite is wired into `scripts/verify.sh`. That script is the Docker hello check, not a suite aggregator; no package suite hooks into it. The suite is `scripts/test-discord.sh`, the same shape as `test-foundation.sh`, listed in `docs/TOOLS.md`. If verify.sh ever becomes an aggregator, this suite joins it there. - Reconcile window. Discord dedups `enforce_nonce` for "the past few minutes". An `unknown` delivery older than five minutes is marked `refused` with a reason instead of re-sent, because a re-send outside the window would post a second reply, which is the one thing the outcome in section 2 forbids. Inside the window it is re-sent once with the same nonce, as section 4 says. - Turn attribution in the engine. A turn that fails on the connector's side (timeout, malformed line) stays in the engine's queue, marked done, until pi's own `turn_end` for it arrives; otherwise that event would be read as the answer to the next prompt. The offline engine group covers this. - Thread lookup. A message in a channel that is neither listed nor known from `GUILD_CREATE`/`THREAD_*` events causes one `GET /channels/{id}`, and only when the guild matches and the author is listed, so strangers never cost a REST call. The result is cached. - Over the ceiling, refusals are drop lines, not turn records, so they do not count against the ceiling themselves. - Chunk nonces are `-`; fixed-line nonces add `-f`, `-b`, `-c` for failed, oversize and ceiling, all under Discord's 25-character nonce limit. - `agents/sage/` is untracked in this checkout as a whole (another session's work). Only `DISCORD-USER.md` is part of this piece; the binding's context list needs `agents/sage/SOUL.md` on disk, which it is. - Review intake found the ceiling counted only written turn records, so a burst arriving while turns ran could queue past the cap. Admitted turns now count until their record is written. Two smaller observations stay as follow-ups, not changes: a follow-up turn's timeout budget starts at admission, so it runs shorter than `turnTimeoutSeconds` while an earlier turn is still busy; and the pid file was left behind if `run` failed between writing it and starting the connector (the next `run` overwrote a dead pid, so nothing refused). Both pid-file points are moot since round two. - rev-code-02's round one (#1509 comment 26114) changed four things. The reconcile window is measured from the first outbox line for a nonce, never a retry. Context files must be repository-relative, symlink-free and realpath-contained; the reviewer's stronger option, hard-coding the four Sage files, was not taken because the binding is the owner's 0600 policy and the containment is the boundary that matters. Admissions are appended to `admissions.jsonl` before the engine runs and the ceiling counts them, so a crash mid-turn still counts. Ownership is a `run.lock` directory (mkdir is atomic) holding the pid and its /proc start time; `stop` signals only a live pid whose start time matches, and `run` refuses where /proc is unavailable. - rev-code-02's round two (#1509 comment 26121) kept F4 open and added two. The O_EXCL pid file had a window between create and write where a second start read an empty file as stale and unlinked it. Ownership is now the `run.lock` directory: mkdir either succeeds or fails, the owner record is written to a temp name and renamed in, and a directory without a record is treated as a claim in progress (busy) for 30 seconds, after which only a crash between mkdir and rename can explain it. A stale lock is moved aside by rename, never deleted in place, so two reclaimers cannot both take it and neither can remove a lock a third start just made. The test spawns four processes that claim at once; one wins, three refuse, and a fifth claim refuses while the winner holds the lock. The daily ceiling notice was memory-only, so a same-day restart repeated it; the decision is now a line in `notices.jsonl` written before the delivery attempt. A duplicate gateway event arriving during the awaited thread lookup was admitted twice; the id is reserved for the whole of `handleMessage` now. - rev-code-02's round three (#1509 comment 26123) reproduced a three-party race in the automatic reclaim: two starts both judge a lock stale, one moves it aside, a third start publishes a live lock, and the delayed one then moves that live lock aside and claims. Rename proves nothing about which directory it moved. The reviewer offered two options, a gate that every claimant respects or fail-closed with a separate controlled cleanup. I took the second: a gate is one more thing that can go stale, and the repository's rule is to diagnose a refusal, not route around it. `run` now refuses any lock it does not own and names `scripts/discord.sh unlock`. `unlock` refuses a live owner and, after moving the lock aside, checks the record it moved is the record it inspected; if not, it renames the lock back and refuses. The cost is a manual step after a crash or reboot. Tests cover four starts over a stale lock (all refuse, the lock is untouched), one unlock, four starts again (one owner), and the changed-under-unlock case. - rev-code-02's round four (#1509 comment 26132) broke that `unlock` too: a claim can land in the empty interval between moving a lock aside and restoring it, so a compare-then-restore leaves two owners. The fix is the reviewer's other option, an explicit quiescence gate, and the gate is the `STOP` file the connector already honors. `unlock` writes `STOP` before it inspects anything; a claim re-checks `STOP` after publishing its record and releases itself if `STOP` is there. A claim that survives therefore published before `unlock` began, and `unlock` sees it alive and refuses. No rename-aside, no restore, no residue. The reviewer's four-party schedule is a test: stale lock, unlock in progress, two claims inside its gap, one record landing right before removal; the result is no owner, no residue, `STOP` standing, and one clean claim after `STOP` is removed. - rev-code-02's round five (#1509 comment 26150) closed the gate design and found two more. The owner record held pid and /proc start ticks, and ticks count from boot, so the same pair can recur after a reboot; the record now carries the boot id too and `stop` signals only when all three match. The identity check is three-way: dead or positively different is stale and `unlock` clears it; alive but unreadable identity refuses everything and removes nothing; only a full match is live. A claimant that cannot read its own start time or the boot id refuses to claim. Second, `unlock`'s refusal for a live owner promised an exit on `STOP` that the running connector does not perform (it only drops later messages), so the text now says to use `stop`. The connector does not gain a `STOP` watcher in this pilot; `stop` sends SIGTERM, which is the shutdown path. - rev-code-02's round six (#1509 comment 26158) probed the upgrade path: a connector started under the round-five build holds `{pid, start}` with no boot id, and the three-way check read that as a positive mismatch, so `unlock` removed the lock under a live process. A live pid with a record lacking start or boot is now "unknown" and refuses everything until the pid is dead. A record file that exists but cannot be parsed is now "invalid" rather than absent; nothing removes or claims over it. Tests: a live legacy child (byte-identical lock, no signal target, no second owner, cleared after exit) and an invalid-record control. - rev-code-02's round seven (#1509 comment 26165) found the last gap in the same family: a record with a start or boot string of the wrong shape was compared to /proc and read as a positive mismatch. Identity values now have a syntax (decimal start ticks, lowercase uuid boot id); anything else reads as absent and, under a live pid, is "unknown". Six live-child controls cover empty, non-decimal and mixed start values and empty, malformed and uppercase boot values. - rev-code-02's round eight (#1509 comment 26168): the start syntax still took leading-zero and above-2^64 digit strings, which the kernel never prints. The check is now the canonical unsigned decimal form; boundary values sit in the syntax table and three more live-child controls. ## 11. MVP iteration, after the pilot - Read receipt (Jason, 2026-09-13, after Gate H). Every admitted message gets an eyes reaction from the bot as soon as the turn is admitted, before the engine runs, so the author knows it was received. Drops and refusals (unlisted author, ceiling, oversize) get no reaction; silence stays silence. The reaction is best effort like typing: a refusal from Discord is logged and recorded in the turn record as `receipt.ok: false` and never fails the turn. Needs the Add Reactions permission on the bot role. - Live check 2026-09-13 19:21 UTC: reaction seen by Jason ("test is successful") and recorded in the turn record. Iteration 1 closed.