@open-everywhere/provider-discord 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/agent/approvals/discord-admin.ts +121 -0
  2. package/agent/approvals/discord-flow.ts +18 -6
  3. package/agent/channels/discord-dispatch-target.ts +36 -1
  4. package/agent/channels/discord-events.ts +3 -2
  5. package/agent/channels/discord-gateway.ts +85 -30
  6. package/agent/channels/discord-invite.ts +46 -0
  7. package/agent/channels/discord-origin-codec.ts +11 -0
  8. package/agent/commands/discord-commands.ts +53 -1
  9. package/agent/docs-generated.ts +506 -2
  10. package/agent/register.ts +8 -1
  11. package/agent/surface-parity.ts +3 -0
  12. package/dist/agent/approvals/discord-admin.d.ts +31 -0
  13. package/dist/agent/approvals/discord-admin.d.ts.map +1 -1
  14. package/dist/agent/approvals/discord-admin.js +107 -0
  15. package/dist/agent/approvals/discord-admin.js.map +1 -1
  16. package/dist/agent/approvals/discord-flow.d.ts.map +1 -1
  17. package/dist/agent/approvals/discord-flow.js +18 -7
  18. package/dist/agent/approvals/discord-flow.js.map +1 -1
  19. package/dist/agent/channels/discord-dispatch-target.d.ts +17 -1
  20. package/dist/agent/channels/discord-dispatch-target.d.ts.map +1 -1
  21. package/dist/agent/channels/discord-dispatch-target.js +35 -1
  22. package/dist/agent/channels/discord-dispatch-target.js.map +1 -1
  23. package/dist/agent/channels/discord-events.d.ts +3 -2
  24. package/dist/agent/channels/discord-events.d.ts.map +1 -1
  25. package/dist/agent/channels/discord-events.js.map +1 -1
  26. package/dist/agent/channels/discord-gateway.d.ts +6 -0
  27. package/dist/agent/channels/discord-gateway.d.ts.map +1 -1
  28. package/dist/agent/channels/discord-gateway.js +58 -6
  29. package/dist/agent/channels/discord-gateway.js.map +1 -1
  30. package/dist/agent/channels/discord-invite.d.ts +10 -0
  31. package/dist/agent/channels/discord-invite.d.ts.map +1 -0
  32. package/dist/agent/channels/discord-invite.js +42 -0
  33. package/dist/agent/channels/discord-invite.js.map +1 -0
  34. package/dist/agent/channels/discord-origin-codec.d.ts.map +1 -1
  35. package/dist/agent/channels/discord-origin-codec.js +12 -0
  36. package/dist/agent/channels/discord-origin-codec.js.map +1 -1
  37. package/dist/agent/commands/discord-commands.d.ts.map +1 -1
  38. package/dist/agent/commands/discord-commands.js +52 -2
  39. package/dist/agent/commands/discord-commands.js.map +1 -1
  40. package/dist/agent/docs-generated.d.ts.map +1 -1
  41. package/dist/agent/docs-generated.js +506 -2
  42. package/dist/agent/docs-generated.js.map +1 -1
  43. package/dist/agent/register.d.ts.map +1 -1
  44. package/dist/agent/register.js +8 -2
  45. package/dist/agent/register.js.map +1 -1
  46. package/dist/agent/surface-parity.d.ts.map +1 -1
  47. package/dist/agent/surface-parity.js +3 -0
  48. package/dist/agent/surface-parity.js.map +1 -1
  49. package/package.json +6 -2
@@ -4,10 +4,514 @@
4
4
  // manual is compiled in at all (docs/ does not exist on disk in a hosted deployment).
5
5
  export const PROVIDER_DOCS = [
6
6
  {
7
- path: "docs/deploy/surfaces/discord.md",
8
7
  title: "Discord",
8
+ path: "docs/deploy/surfaces/discord.md",
9
9
  summary: "Run a slash command in your Discord server and Open Everywhere acts as an autonomous teammate — it does the work, replies in the channel, and remembers what it learned, scoped correctly per channel.",
10
- markdown: "# Discord\n\nRun a slash command in your Discord server and Open Everywhere acts as an autonomous teammate — it does the work, replies in the channel, and remembers what it learned, scoped correctly per channel.\n\nThis page is the Discord-specific setup runbook. For the concept of a *surface* and how surfaces plug in, see [Surfaces](../../extending/surfaces.md). For how memory is scoped per server and channel, see [Memory](../../capabilities/memory.md). For how Discord's feature set compares to the other surfaces — including what needs the Gateway relay and why — see the [surface support matrix](./support-matrix.md).\n\n**Discord has two ingestion paths, and this page sets up both.** The HTTPS **interactions** endpoint is interaction-based: on it, the only way to invoke the agent is an explicitly-registered slash command, and a plain `@mention` does nothing. Ambient `@mention`s, DMs, name-drops and channel follow-ups arrive over the separate **Gateway** path instead, which needs the always-on relay ([`deploy/discord-relay/`](../../../deploy/discord-relay/README.md)) — Discord never delivers those to an HTTP endpoint. The [surface support matrix](./support-matrix.md) marks every Gateway-dependent row with footnote ¹.\n\n## How the silo model maps\n\nOpen Everywhere's memory is [opt-in domains and silos](../../capabilities/memory.md), not an\nautomatic per-guild tier stack. A Discord channel/thread/DM is a **surface**; the guild is the\nnatural `workspace` identity key its channels' surface keys share, but sharing memory across\nthem still needs an explicit `domain` or the `org` pseudo-domain fallback — and (see \"Trust\"\nbelow), by default no Discord surface reaches either.\n\n| Open Everywhere concept | Discord thing |\n| --- | --- |\n| `org` (tenant) | your deployment (`deploymentIdentity.org` in `agent/identity/surfaces.ts`, one org per deploy today) |\n| `workspace` (identity key) | the **guild** (server) — its id (`guild_id`) |\n| `surface` (partition key) | the individual **channel or thread** (`channel_id`) |\n| `mode` | `channel` (guild text channel/thread), `dm` (1:1 DM, channel type `1`), `mpim` (group DM, type `3`) |\n| `visibility` | `public` only for a confirmed @everyone-readable guild channel; everything else `private` |\n\n**Visibility is tracked but isn't the memory gate.** Discord interaction payloads carry *no*\nclean public/private bit — channel readability is governed by per-role `@everyone`\n`VIEW_CHANNEL` permission overwrites, which aren't in the payload — so guild channels default\nto **`private`** (`discordVisibility`) until a future enrichment positively confirms\n`@everyone` can view them. DMs (type `1`) and group DMs (type `3`) are inherently private.\nNone of this decides whether memory shares, though — see below.\n\n**Trust is unverified by default — every guild starts `external`.** Open Everywhere has no\nDiscord guest/`@everyone`-membership check yet, so `bindDiscord` leaves `trust` undefined,\nwhich **fails closed to `external`**: a fresh guild channel gets read-only `public` memory at\nmost — never an explicit domain's silo, never the `org` brain — public-looking channel or not.\nA public Discord server is full of strangers, so a guild existing is not proof of org\nmembership. To unlock domain/org memory, the operator explicitly **vouches** it: set\n`identity.blanketTrust: \"internal\"` in `agent/settings/settings.ts` for a single-tenant deployment where every guild you'll ever be in is\ntrusted, or vouch specific guilds via a `discord/<guild_id>` (or `discord/<channel_id>` for one\nchannel) entry in `agent/identity/surfaces.ts`. Unvouched, the bot still answers slash\ncommands (chat-only, no shared memory); vouched, normal domain/org resolution applies exactly\nas it does for any other internal channel.\n\n**DMs are personal universes.** A DM has no guild, so it keys to the **caller's own user id**\nas the workspace — fully isolated from the server brain and from other users' DMs. The\npersonal DB silo is always writable regardless of trust; only the *shared read layer* on top\nof it needs a vouch (the same DM-partner-trust gate every platform uses).\n\n## Auto-thread (mention conversations)\n\nWhen the bot is reachable over the **Gateway** ingestion surface (ambient `@mention` messages, as opposed to the slash-command interaction path above), a mention starts a **thread** and the whole conversation happens in it — the signature \"one task = one thread\" pattern Open Everywhere uses on Slack.\n\n| Where you mention it | What happens |\n| --- | --- |\n| A **base channel** (guild text or announcement) | The bot **opens a thread** hanging off your message and replies there. That thread is its own **conversation** — its own durable session and per-thread notes — while still counting as part of the parent channel for domains and spend caps (below). Keep talking in the thread — no need to re-mention. |\n| **Already inside a thread** | It replies **in place** (a thread is already its own conversation). |\n| A **DM or group DM** | It replies **in place** (Discord has no threads in DMs). |\n| A **forum** channel | It replies **in place** (a forum post is itself a thread — it doesn't nest). |\n\nEach auto-created thread is named from your message (the mention stripped, truncated to Discord's 100-char cap; an empty message falls back to `Conversation with the agent`). A **second** mention in the base channel starts a **second** thread — one task per thread, by design.\n\n**Threads belong to their channel.** Although a Discord thread is technically its own channel id, Open Everywhere treats a thread as part of its **parent channel** for everything scoped to channels — [memory domain](../../capabilities/memory.md) membership (silo, access bundle, model route), domain-admin standing, and **spend caps** (a cap on the channel accumulates and gates its threads' usage too, and a cap proposed *from* a thread targets the parent channel). This matches Slack, where threads share the channel. Only the *conversation* is per-thread: the durable session, engagement, and the thread's own surface-tier notes. Parent resolution is best-effort — if the one-time lookup fails, that thread simply behaves as an unaffiliated channel for the turn (never blocked).\n\n**Toggle — `discord.autoThread`** (default **on**). Set it to `false` to reply **inline in the base channel** instead of opening a thread. It is a typed setting, not an environment variable, so opting out is a reviewed diff in your own `agent/settings/settings.ts`:\n\n```ts\n// agent/settings/settings.ts\nexport default defineSettings({\n discord: { autoThread: false }, // opt out: reply inline in the base channel, no thread\n});\n```\n\n**Fails open.** If the bot lacks **Create Public Threads** / **Send Messages in Threads** (or Discord rate-limits the create), thread creation is skipped and the turn still runs as an **inline** reply in the base channel — degraded, never dropped. Grant both permissions in [Step 3](#step-3--invite-the-bot) to get threads.\n\n**DMs need no mention.** Over the Gateway surface a direct message (or group DM) to the bot always wakes it — a DM is by definition addressed to the bot, so just talk to it. In guild channels the usual rule applies: an `@mention` starts a conversation; after that the bot follows the thread without re-mentioning.\n\n**Name-drops (opt-in, per domain).** With `nameDropWake: true` on the channel's [memory domain](../../capabilities/memory.md) — same flag as Slack — a guild message that says one of the bot's names without @mentioning it gets a *judged* reply: the bot answers only when the message is clearly for it, and stays silent otherwise. The name-token set is the same rule as Slack's (see [that surface's doc](slack.md)): the bot's **display name** (its server nickname, if set) UNION the deployment's `identity.name`, extended by `wake.nameDrop.include` and narrowed by `wake.nameDrop.exclude` (configuration-reference.md) — exclude always wins. Works in channels *and* un-followed threads (a deliberate extension over Slack, where threads belong to the engagement path). Scope matters for what happens next: a name-drop **in a thread** puts the bot into follow mode for that thread (small scope — same grain as Slack, where a name-drop's reply thread is followed); a name-drop **in a base channel** does *not* — following a whole channel means a judged model turn on every subsequent message, so there you @mention it (or say its name again) to continue. When it does follow a thread off a name-drop, it starts as a **warm watcher** (see *Stopping it following* below). Off by default; requires a Gateway relay recent enough to forward the bot's identity (older relays simply never name-wake). The road is audited on Discord exactly as it is on Slack: an admitted name-drop writes `surface.name_drop_woke` (naming the channel, the message id and `road: \"native\"` — this surface hands off to no standing seat), and a name-drop the domain flag refuses writes `surface.name_drop_silent`. A message that names nobody writes neither row. A judged wake also arrives with a short, **untrusted-wrapped backscroll** of the surrounding conversation (recent messages, capped) so the model can tell whether the message is really for it — the same context an @mention gets.\n\n### Stopping it following\n\nEngagement has no hard timeout, but a lightly-latched surface **sheds itself** — the same model as Slack:\n\n- **Participating** surfaces (you @mentioned the bot, or it actually replied) are **sticky**: they keep following every message there (each one a judged model turn, so a followed surface has a real per-message cost) until you ask it to stop. Just say so, however you'd phrase it to a colleague — \"you can stop following this\", \"we're good here\". This is **judged, not pattern-matched**: the agent reads the intent and calls `stand_down`, which sets a durable marker on the surface. There is deliberately no magic word (the regex that used to look for one disengaged on \"don't stop the deploy\" as readily as on \"stop\"). Mention it again any time to re-engage — that clears the marker. This is a **channel-only** verb — a DM has no `@mention` to clear a marker with, so `stand_down` refuses there instead of trapping the conversation silent for good.\n- **Warm watchers** (a thread the bot entered off a *name-drop* but hasn't yet replied in) are **not** sticky: if it judges \"no reply\" on three consecutive messages, it quietly **disengages** itself — so a one-off name-drop that wasn't really for it never leaves it silently following forever. The moment it does reply (or you @mention it), the surface is promoted to participating and the count resets.\n\n### Stopping a turn that's already running\n\nDifferent problem, different mechanism. Standing down happens *between* turns; stopping a turn\nthat is **in flight** can't be inferred at all, because the bot is the thing that's busy and your\nmessage would queue behind the very turn it's meant to stop. So it's an affordance:\n\n- react **✋** on the message — the host adds it when a turn actually kicks work off (its first\n delegation) and removes it when the turn ends, so it's there to click exactly while a stop means\n something, and never appears on a message the bot merely read;\n- or run **`/oe stop`**, which needs no reaction permission and takes no arguments.\n\nBoth are decided at ingress with **no model call and nothing spent**, so they keep working while\nthe channel is over its spend cap. ⚠️ The ✋ route needs a Gateway relay recent enough to forward\nreactions — an older deployed relay never forwards them, and `/oe stop` covers the gap.\n\nWhat a stop does, precisely: the turn's history rolls back to its last completed model call, but\n**anything already done is done** — tool calls inside the interrupted step had real effects and\nare not undone, and a running sandbox command can't be killed. The acknowledgement says \"stood\ndown\", never \"stopped\", and names what had already run.\n\n### Working indicators (status UX)\n\nWhile a turn runs (often 30s+), the bot shows it's on the case using Discord's native affordances — no extra messages, no edits:\n\n- It reacts **👀** on your message the moment it picks it up (\"seen, working\") — on any direct address (a mention or a DM).\n- While it works, Discord's native **typing indicator** shows (\"*Open Everywhere is typing…*\"), re-triggered as the turn progresses. The answer then arrives as a fresh message.\n- **Judged wakes hold typing back.** On a followed (engaged) surface or a name-drop, the model judges each message and may decide no reply is warranted. Those turns show **no typing at all until the reply is actually being written** — so a judged pass is silent from the first second, instead of \"typing… for 10 seconds, then nothing\" (which reads as a crash). Direct addresses (mentions and DMs) always answer, so they show typing immediately.\n- **On an auto-threaded mention** the 👀 is **removed** when the answer lands in the thread — absence is the done signal, visible from the base channel without opening the thread. (The bot never plants a ✅ — that stays reserved for humans, same convention as Slack.) Elsewhere the 👀 simply stands as the ack; the answer arrives right underneath.\n\n**Toggle — `discord.statusUx`** (default **on**). Like `autoThread` it is a typed setting, not an environment variable: set `discord: { statusUx: false }` in your own `agent/settings/settings.ts` for plain replies with no reaction or typing. All indicators are best-effort: a failed reaction or typing call never delays or drops the answer.\n\n### Reaction ingestion (feedback signal)\n\nOver the **Gateway** relay the bot also *ingests* inbound reactions. A 👍 / 👎 (or any emoji) on one of the **bot's own replies** is recorded as a lightweight human **feedback** signal — durable, append-only, carrying only the reactor's id + the emoji, never any message text. Reactions on *other* messages emit anonymous volume telemetry but aren't stored, and the bot's own status reactions (👀) are never counted. This needs the **Guild Message Reactions** and **Direct Message Reactions** gateway intents, which the relay already requests (they're not privileged — no portal toggle). Older relays that predate this simply never forward reactions, so the feature is silently off until you redeploy the relay.\n\n### Spend caps\n\nGuild channels are subject to the same [monthly spend caps](../../security/spend-and-cost.md) as every surface: a channel (or guild) over its budget **declines** new mentions with a visible message rather than silently ignoring them. The decline message names the exact scope keys, so a follow-up DM proposal can target them.\n\n**Admins are never locked out.** If the sender is an admin over the scope — on the config roster (`Domain.admins`), or a Discord-verified administrator/owner of the guild — the turn proceeds past the cap with a visible `⚠️ … proceeding because you're an admin` line (throttled to once per channel per hour — subsequent bypassed turns proceed quietly) and an audit entry (`spend.cap_bypassed`) on every bypass. Sudo semantics: the cap binds the team's ambient usage; the budget owner steps over it visibly and can raise or clear it right there (\"set this channel's cap to $5\" → approval card).\n\nDMs are capped like any other surface. The escape hatch is the **admin bypass**: an org admin's own DM turn proceeds past the cap with the visible warning and an audit row, so they can still propose an override (passing the scope key from the decline message) and approve it on the card **in the DM** — a DM click is decided by checking the clicker's admin standing in the target guild over REST, since Discord DM interactions carry no member permissions. One default from a DM is still refused: a Discord DM belongs to no server, so a `\"workspace\"` cap defaulted there would target your personal DM bucket rather than the guild you meant — pass an explicit `discord/<serverId>` key. (`level: \"surface\"` from a DM is fine now: it targets that DM, which is a scope the gate really enforces.)\n\n## Prerequisites\n\n- A Discord **server you manage** with the **Manage Server** permission (needed to add the bot and, optionally, to register guild-scoped commands).\n- A reachable Open Everywhere instance — deployed with a **public HTTPS URL**. Discord delivers interactions as signed HTTPS POSTs from its own infrastructure and **cannot reach `localhost`**; for local dev expose your instance with a tunnel (e.g. `ngrok http 3000`) and use that HTTPS URL. See the [docs index](../../README.md) to get the project and the [setup runbook](../setup-runbook.md) for the deployment steps.\n- Your instance's public base URL. The Discord interactions route is **`/eve/v1/discord`**, so Discord calls `https://<your-deploy-url>/eve/v1/discord`.\n\n## Step 1 — Create the Discord application + bot\n\n1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) → **New Application**. Name it (e.g. `Open Everywhere`). Upload [`brand/png/mark-square-1024.png`](../../../brand/png/mark-square-1024.png), the project mark, as the **App Icon** on **General Information** and as the bot's **Icon** on the **Bot** tab. Skip this when you set [`identity.name`](../configuration-reference.md) and chose your own icon: with no name configured the agent presents as the project, so the project mark is its default avatar.\n2. On **General Information**, copy the **Application ID** — this is `DISCORD_APPLICATION_ID`.\n3. Still on **General Information**, copy the **Public Key** — this is `DISCORD_PUBLIC_KEY`. It verifies the Ed25519 signature on every inbound interaction POST.\n4. Go to the **Bot** tab → **Reset Token** → copy the token — this is `DISCORD_BOT_TOKEN`. (Treat it like a password; it's shown once.)\n5. Set the **Interactions Endpoint URL** (on **General Information**) to:\n\n ```\n https://<your-deploy-url>/eve/v1/discord\n ```\n\n When you save this, Discord immediately POSTs a `PING` (interaction type `1`) and expects a signed `PONG` back. **Your instance must already be deployed, running, and reachable with `DISCORD_PUBLIC_KEY` configured** (Step 4) or Discord rejects the URL. Set the env first, deploy, *then* save this field.\n\n > ⚠️ **Use your STABLE production domain here** (e.g. `your-app.vercel.app`), never a\n > deployment-specific URL (`your-app-<hash>-team.vercel.app`). Deployment URLs stay alive\n > forever pointing at that one old build — interactions (including approval-card button\n > clicks) would keep hitting stale code after every redeploy, failing in ways that look\n > like application bugs.\n\n6. *(Optional, recommended)* Go to the **Webhooks** tab → set the **Webhook Events URL** to:\n\n ```\n https://<your-deploy-url>/eve/v1/discord/events\n ```\n\n and subscribe to **`APPLICATION_AUTHORIZED`** and **`APPLICATION_DEAUTHORIZED`**. This is a\n **different URL** from the Interactions Endpoint above — Discord configures the two separately —\n but it is verified with the **same** `DISCORD_PUBLIC_KEY`, so there is nothing new to set.\n\n It gives OE an install/uninstall audit trail: each event writes a `surface.app_installed` /\n `surface.app_uninstalled` row (guild as the workspace, the authorizing user as the principal),\n queryable over the control plane's audit route. Without it OE has no signal that a server added\n or removed the app short of noticing silence.\n\n **Observability only** — this route gates nothing, wakes no session, and drops nothing. Leaving\n it unconfigured changes no behaviour. Note that Webhook Events deliberately do **not** carry chat\n messages: ambient channel content is Gateway-only and still needs the always-on relay (see the\n [surface support matrix](./support-matrix.md)).\n\n## Step 2 — Register the slash command\n\nThe channel handler does **not** filter on a specific command name — its `onCommand` hook accepts *any* registered application command, attaches the real Discord identity, and dispatches. So you register one command and pick its name. The command maps to the model prompt via `commandInteractionMessage`: it uses the value of a string option named **`message`** when present, otherwise it reconstructs `/command opt:value …`. **Register a command with a required `message` string option** so users type their request naturally.\n\nRegister with the Discord API (no helper script ships today). **Guild** commands appear **instantly** in that one server; **global** commands can take **up to an hour** to propagate — use a guild command while testing.\n\nGuild-scoped (instant, recommended for setup):\n\n```bash\ncurl -X POST \\\n \"https://discord.com/api/v10/applications/$DISCORD_APPLICATION_ID/guilds/$GUILD_ID/commands\" \\\n -H \"Authorization: Bot $DISCORD_BOT_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"ask\",\n \"type\": 1,\n \"description\": \"Ask Open Everywhere to do something\",\n \"options\": [\n { \"name\": \"message\", \"description\": \"What should it do?\", \"type\": 3, \"required\": true }\n ]\n }'\n```\n\nGlobal (drop `/guilds/$GUILD_ID`, propagation up to ~1h):\n\n```bash\ncurl -X POST \\\n \"https://discord.com/api/v10/applications/$DISCORD_APPLICATION_ID/commands\" \\\n -H \"Authorization: Bot $DISCORD_BOT_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{ \"name\": \"ask\", \"type\": 1, \"description\": \"Ask Open Everywhere to do something\",\n \"options\": [ { \"name\": \"message\", \"description\": \"What should it do?\", \"type\": 3, \"required\": true } ] }'\n```\n\n(`type: 1` = CHAT_INPUT slash command; option `type: 3` = STRING. Name it `openeverywhere` instead of `ask` if you prefer.)\n\n## Step 3 — Invite the bot\n\n1. In the Developer Portal → **OAuth2** → **URL Generator**.\n2. Under **Scopes**, tick **`applications.commands`** (required for slash commands) and **`bot`**.\n3. Under **Bot Permissions**, tick:\n - **Send Messages** — post replies (interaction responses and Gateway/REST posts).\n - **Create Public Threads** — open a thread per conversation (see [Auto-thread](#auto-thread-mention-conversations)).\n - **Send Messages in Threads** — reply inside those threads.\n\n Without the two thread permissions the bot **falls back to inline replies** (auto-thread fails open — it never drops a turn), so the minimum for slash commands alone is just **Send Messages**. The agent replies via the interaction token for slash commands, so it doesn't need broad permissions.\n4. Copy the generated URL, open it, pick your server, and **Authorize**. You need **Manage Server** on that server to complete the invite.\n\n## Step 4 — Configure the environment\n\nSet these on your instance, then deploy:\n\n```bash\nDISCORD_APPLICATION_ID=… # Application ID from Step 1.2\nDISCORD_PUBLIC_KEY=… # Public Key from Step 1.3 (verifies signed interaction POSTs)\nDISCORD_BOT_TOKEN=… # Bot token from Step 1.4\n```\n\nOptional, but relevant to the silo model:\n\n```bash\nOE_MEMORY_PROVIDER=git # memory backend — git | sqlite\nOE_MEMORY_DIR=/abs/path/to/store\n```\n\n**Identity binds automatically.** You do **not** set workspace, surface, mode, or visibility by hand. The Discord channel reads the guild/channel/user ids and the channel `type` straight off each inbound interaction into the session auth context (`buildDiscordAuth`), and `bindDiscord` derives workspace = the guild, surface = the channel, plus mode and visibility. The `deploymentIdentity.workspace` / `.surface` declaration in `agent/identity/surfaces.ts` is only the `eve dev` gap-filler — once Discord is wired it is ignored.\n\n## Step 5 — Invoke it\n\n1. In a guild channel, run `/ask summarise the last few messages and list the action items`.\n2. Expected behaviour:\n - Discord shows a **`Thinking…`** typing indicator while the agent works (the channel calls `startTyping()` on dispatch).\n - It **acks within 3 seconds with a deferred response**, then edits that original response in place with the answer once the agent finishes — so longer tasks don't time out.\n - The reply lands in the channel, scoped to that channel's surface silo.\n3. Test a DM: open a direct message with the bot and run `/ask …` there. It responds the same way; the DM surface's memory is isolated to you and `private`.\n\n## Rotating the bot token\n\n**Reset Token** in the Developer Portal (Step 1.4) invalidates the old token immediately. Two\n**deployed** stores hold that token. Each one needs its own update, and neither picks the new value\nup on its own.\n\n| Store | What holds the token | How the new value takes effect |\n| --- | --- | --- |\n| Vercel production | `DISCORD_BOT_TOKEN` on the deployment | an explicit redeploy |\n| Railway | `DISCORD_BOT_TOKEN` on the always-on relay ([`deploy/discord-relay/`](../../../deploy/discord-relay/README.md)) | a relay redeploy |\n\nA rotation that reaches only the deployment leaves the relay dead. That failure is quiet: the slash\ncommand still works, and every HTTPS route still answers.\n\nA local checkout keeps its own copy — `deploy/discord-relay/.env`, and the repo-root `.env`. Those\nare operator copies, not deployed stores. Update one the next time you run it locally.\n\n### The Vercel half\n\n`git push` does not deploy, and a running deployment keeps the value it booted with:\n\n```bash\nvercel env rm DISCORD_BOT_TOKEN production -y # the name exists already — remove, then re-add\nvercel env add DISCORD_BOT_TOKEN production\nvercel deploy --prod\n```\n\n`deploy:census` does **not** see this half. It reads credential rows out of the store, and this\ntoken is an environment variable. Prove it by hand instead: run `/ask …` in a guild channel\n(Step 5). A dead token fails every Discord API call the deployment makes.\n\n### The Railway half\n\n**The relay reads the token ONCE, at boot.** `deploy/discord-relay/index.js` reads\n`process.env.DISCORD_BOT_TOKEN` at module load, then logs in with that value. A variable change alone\nchanges nothing on a running relay. The process holds the dead token until it restarts.\n\n```bash\ncd deploy/discord-relay\nread -rs -p 'new DISCORD_BOT_TOKEN: ' NEW_TOKEN && \\\n railway variables --set DISCORD_BOT_TOKEN=\"$NEW_TOKEN\" --skip-deploys && unset NEW_TOKEN\nrailway redeploy -y\nrailway logs # expect: \"[oe-discord-relay] ready as <bot>#0000 | mode: FORWARD\"\n```\n\n`read -rs` keeps the token out of your **shell history** and off the **screen** — a token typed as a\nliteral argument stays in the history file long after the rotation. `unset` drops it from the\nenvironment as soon as Railway holds it.\n\n🔴 **It does NOT hide the token from the process table.** The shell expands `\"$NEW_TOKEN\"` before it\nruns `railway`, so the value is visible in that process's `ps` and `/proc/<pid>/cmdline` entry for\nthe life of the call. Rotate on a machine you trust, never on a shared box or a runner other\ntenants can read.\n\n`--skip-deploys` holds the restart back, so the redeploy is one explicit step you can watch. The\n`ready as` line is the proof — the relay holds the Gateway socket again, with the new token.\n\n### What a missed relay looks like\n\nDiscord closes the relay's Gateway socket at the reset hour. Ambient channel and DM messages stop\narriving, because Discord delivers them over that socket only. Slash commands keep working, so the\nsurface looks half-alive.\n\n`npm run deploy:census` catches it: `discord.heartbeat` goes red. Read that row correctly. The\n**relay** POSTs the heartbeat on its own timer; the **deployment** writes the row, once the signed\nPOST passes the relay gate. After a token rotation the cause is the stale relay, so the remedy is\nthe relay redeploy above. A red row under a LIVE relay points at the ingest side instead — an\n`OE_RELAY_SECRET` mismatch, or a route that refuses. Check `railway logs` for `ingest rejected`\nbefore you redeploy anything. Restart the relay, then run the census again.\n\nThe check needs Discord mounted, and it needs the census run's OWN `OE_RELAY_HEARTBEAT_MS` mirror\nnon-zero and matching the relay's configured interval. The census reads its own copy of that name,\nnever the relay's — see [rollback](../rollback.md).\n\n### `.env.e2e` does not hold this token\n\nThe e2e tier drives Discord with `E2E_DISCORD_DRIVER_TOKEN`, and that is a **different bot** — the\ntest driver, a second application the `wake.driverAllowlist` setting admits\n(`packages/core/scripts/e2e/env.ts`). Resetting the deployment's own bot token does not invalidate\nit. No code in this repository reads the deployment's bot token out of `.env.e2e`, so a rotation\nleaves that file alone.\n\n## What the agent calls itself here\n\nA Discord session's IDENTITY — the name it introduces itself by — comes from one place only: the\n[`identity.name`](../configuration-reference.md) setting. Set it and it **wins on every surface**,\nbyte-identically — the right answer for a deployment that spans Slack and Discord from one window:\none name, everywhere. Unset, the session renders the generic identity line (\"this team's agent\").\nIt never borrows a name from another surface, and never from this surface's own Discord account\nname either — that is a HANDLE, not an identity (OPE-610).\n\nSeparately, the session also carries a HANDLE line — \"your mention/handle here appears as X\" — so\nthe agent recognises its own mentions and backscroll lines under the bot account's real Discord\nname. The agent reads it once per instance from `GET /users/@me` (the same `DISCORD_BOT_TOKEN` this\nintegration already uses to post replies), preferring the account's display name (`global_name`)\nand falling back to its username. A rename on Discord takes effect on the next cold start. If the\nread fails (no token, a transport error), the session simply carries no handle line; the identity\nline above is unaffected either way.\n\n## Reading channel/thread history\n\nDiscord supports the generic `read_channel`/`read_thread` verbs (the same tools Slack and Teams\nuse — since OPE-1145 no per-surface reader stands beside them on any surface), backed by the plain\nDiscord REST API\n(`GET /channels/{id}/messages`) — the **same `DISCORD_BOT_TOKEN`** this integration already uses to\npost replies (Step 1.4/Step 4 above). The read adapter (`packages/providers/discord/agent/channels/discord-read.ts`'s\n`discordReadAdapter`, registered into core's read-adapter registry at boot) reuses that token verbatim, so **there is no extra Discord- or Azure-side\nconfiguration to grant** — unlike Teams, which needs a separate Graph token/permission because its\nBot Framework Connector token can't read history.\n\n- **A Discord thread is its own channel id.** Discord threads aren't a sub-resource of their parent\n channel the way Slack/Teams threads are — a thread IS a channel, with its own id. So\n `read_thread`'s thread identifier for Discord is that thread's channel id, and the adapter simply\n reads it with the same `GET /channels/{id}/messages` call `read_channel` uses against a base\n channel. Both ids come from the turn's own binding — neither verb takes a target.\n- **Bounded, single page.** Unlike Teams' paginated Graph reads, one GET returns up to 100 messages\n (Discord's own per-request cap) — a requested `limit` above 100 is silently clamped, never fanned\n out across pages.\n- **Scope.** The verbs read THE CONTAINER THE WAKE ARRIVED IN and take no target, so no other\n channel is addressable. The domain allowlist still runs inside the adapter as defence in depth.\n An out-of-scope channel is refused before any Discord GET, and every refusal writes a\n `read.refused` audit row naming the channel, the caller's domain and the verdict (ids only, never\n message content) — queryable with `GET /control/v1/audit?since=<iso>&actions=read.refused`.\n\n## Identity linking\n\n[Identity linking](../../how-it-works/identity.md) joins a person's Discord account to their\naccounts elsewhere. Discord supports **both halves** of the preferred *directory* transport, so a\nperson can name their other account instead of carrying a code between two surfaces.\n\n- **The account directory** searches the guilds your domains declare in their `reach` lists\n (`agent/memory/config/domains/<name>.ts`) — one `GET /guilds/{id}/members/search` per guild, so\n Discord itself does the matching. A guild nobody configured is never searched, and bots are\n dropped (a link is a statement about a human).\n- **The DM ask** opens the 1:1 conversation with `POST /users/@me/channels` and posts the consent\n request there. No extra permission: Discord delivers a bot DM when the recipient shares a server\n with the bot and has not turned off direct messages from server members.\n- **Two buttons** ride under the ask, and a tap answers it with no model turn. The tap returns to the\n same **Interactions Endpoint URL** the approval cards already use, so there is nothing extra to\n configure. The answer is **ephemeral** — only the person who tapped\n sees it. A tap from a server channel is refused with a pointer back to the DM.\n\n⚠️ **The directory needs the privileged `SERVER MEMBERS` intent.** Turn on **Privileged Gateway\nIntents → Server Members Intent** on your application's **Bot** page in the Discord developer\nportal. Without it Discord answers `403` to the member endpoints, the directory resolves empty, and\nlinking falls back to the **code** transport — which still works and says which capability was\nmissing. Directory rights are fine to require for the better path; they are never a precondition for\nlinking at all.\n\nBecause Discord can DM, a `link <code>` claim typed **in a channel** is now refused with a pointer to\nthe DM, and the code is not spent. A code is a bearer secret for its whole life, and a room has an\naudience.\n\n## Proactive routines\n\nScheduled [routines](../../capabilities/proactivity-and-routines.md) work on Discord: create one with a slash command (\"every hour, check …\") and the fired result posts back **into the same channel** via the REST create-message endpoint (the bot token from Step 1 — no Gateway websocket involved). The post-back target is captured automatically when the routine is created; nothing to configure. Spend caps apply to guild-channel routines — and to 1:1 DM routines too, same as Slack: a fire never gets the admin bypass, because there is no attending admin to bypass on its behalf.\n\n## Slash commands: `/oe` (zero-inference control-plane)\n\n`/oe` is a **second, separate** slash command from the model-dispatching one registered in [Step 2](#step-2--register-the-slash-command) (`/ask`, or whatever you named it). It's the control-plane escape hatch: pure logic, code-rendered responses, **no model turn** — so it answers even while a channel is over its spend cap, engagement is judging silence, or the model/gateway itself is down. It's intercepted at the **front route** (`channels/discord-front.ts`), before discordChannel's `onCommand` hook ever sees it, so it costs zero inference — unlike every other registered command name, which `onCommand` dispatches to the agent unconditionally.\n\nCommands:\n\n| Command | What it does | Visibility |\n| --- | --- | --- |\n| `/oe status` | Says the agent is running, then lists, under \"Details for your admin\", the build (deployment sha) and eve pin that identify this build, the app version when the deploy carries one, primary/utility model config, the context window and the token count it is summarised at, memory provider. | Ephemeral (only you see it) |\n| `/oe budget status` | This channel's and server's effective spend caps (tokens + USD) and current-month spend — the SAME figures the spend gate enforces. | Ephemeral |\n| `/oe budget raise <usd>` | Proposes a new monthly USD cap for this channel: posts the SAME public approval card `set_spend_limit` posts (Approve/Reject buttons); an admin decides. The ephemeral ack also shows **your own domain's recent spend** from the ledger — see [Spend & cost](../../security/spend-and-cost.md#the-domain-spend-scan-behind-oe-budget-raise). A Discord channel the operator has not declared internal is `external`, so no domain resolves there and the ack says so instead. | Card: public in-channel. Ack: ephemeral |\n| `/oe budget clear` | Proposes clearing this channel's spend-limit override back to the config-as-code cap: posts the SAME public approval card `clear_spend_limit` posts. | Card: public in-channel. Ack: ephemeral |\n| `/oe stop` | Stands down the turn currently running in this channel. **No model turn, nothing spent** — it works while the channel is over its cap, which is when you're most likely to want it. | Ephemeral |\n| `/oe rate <1-5>` | Records one human mark on the [model A/B test](../../extending/models-and-gateway.md#ab-testing-a-challenger-model): rates this channel's most recent champion/challenger turn, on the arm that turn actually ran. Answers \"nothing to rate\" — the normal reply — while the test is off. | Ephemeral |\n| `/oe help` | Lists the commands. | Ephemeral |\n\nDiscord requires picking a concrete subcommand — you can't invoke the bare group (`/oe budget` isn't itself invokable; type `/oe budget status`). Slack's equivalent free-text grammar (`/oe budget`) is more forgiving; see the [Slack guide](./slack.md#slash-commands-oe-zero-inference-control-plane).\n\n### Register `/oe` — the deployment does it itself\n\n**You register nothing.** The deployment reconciles `/oe` against Discord on its own, once per process\nstart: it reads the commands Discord holds for your application at each declared guild, compares them\nwith the command set the code declares, and writes only when they differ. Deploy a build that adds a\nsubcommand and the next cold start registers it.\n\nName the guilds in `agent/commands/discord-commands.ts`:\n\n```ts\nexport const DISCORD_COMMAND_GUILDS: readonly string[] = [\n \"1300000000000000100\", // your server's guild id\n];\n```\n\nA guild id is **tracked config**, not an environment variable — it lives in Git and changes by pull\nrequest, like every other room key ([Config-as-code](../../how-it-works/config-as-code.md)). The two\ncredentials stay environment: `DISCORD_APPLICATION_ID` (Step 1.2) says which application this\ndeployment speaks as, and `DISCORD_BOT_TOKEN` (Step 1.4) authenticates the call. Either one absent and\nthe reconcile reports `skipped` in the boot log and registers nothing — it never fails the boot, and\nneither does a Discord outage: the worst outcome is the command list you already had.\n\nWatch for one line per platform in the deployment's boot log:\n\n```\n[open-everywhere:command-registration] discord: unchanged — guild 152…: \"/oe\" already matches what this deployment declares\n```\n\n**The deployment's write is additive.** It uses Discord's per-command `POST` (create-or-update by\nname), so a command you registered yourself at the same scope — the `/ask`-style command from\n[Step 2](#step-2--register-the-slash-command) — is never touched.\n\nDeclare no guild and the deployment registers nothing and makes no Discord call at all.\n\n⚠️ **Every process holding both credentials reconciles — a local `eve dev`/`eve start` included, and\nit writes the command shape of the checkout that is running.** Do not run a branch that changes the\n`/oe` command set against the deployment's own `DISCORD_BOT_TOKEN`: your server will show that\nbranch's `/oe` until the deployment's next cold start writes it back. Develop such a branch against a\nDiscord application of your own, or with the bot token unset.\n\n#### The one-off CLI, for a global registration\n\n`npm run register:discord-commands` still ships, for the one thing the deployment does not do:\nregistering `/oe` **globally** (every server the app is in). A global command takes up to an hour to\npropagate, which no boot-time check can prove landed, so it stays a deliberate operator step. It\nreads:\n\n```bash\nDISCORD_APPLICATION_ID=… # from Step 1.2\nDISCORD_BOT_TOKEN=… # from Step 1.4\nDISCORD_GUILD_ID=… # optional: guild-scoped (instant) instead of global (~1h propagation)\n```\n\n⚠️ **The CLI is a bulk `PUT`, not an upsert** — unlike the deployment's own additive write above.\nDiscord's bulk endpoint *replaces every command registered at that scope* with exactly the array the\nscript sends (today: `[\"/oe\"]` alone). If you've also registered `/ask` (or anything else) at the\n**same scope** (same guild, or both global), running this script **deletes it** — re-register\neverything you want to keep in one pass.\n\n### Spend caps deliberately do NOT gate `/oe`\n\nThere is no model turn to decline, so `/oe` isn't a \"new spend\" origin — it works in a ⛔'d channel exactly as well as in one with headroom. `/oe budget raise`/`clear` still go through the real approval-card machinery; a human's click, not the command, is what actually changes anything.\n\n## Gotchas\n\n- **Slash command vs. mention.** The **interaction** channel documented here is slash-command-only — a plain `@mention` does nothing on that path. Ambient `@mention` messages (and the [auto-thread](#auto-thread-mention-conversations) behaviour) are served by the separate **Gateway** ingestion surface, which needs the always-on relay configured. If `/ask` doesn't appear, the command isn't registered for that scope, or a global command hasn't propagated yet.\n- **Public Key verification.** Every inbound interaction POST is Ed25519-verified against `DISCORD_PUBLIC_KEY`. If it's wrong or unset, Discord requests are rejected (including the initial PING), the **Interactions Endpoint URL** won't save, and the bot stays silent — check this first.\n- **PING handshake needs the endpoint live first.** Saving the Interactions Endpoint URL triggers an immediate `PING` (type `1`); your instance must already be running with the public key set so it can return the signed `PONG`. Set env → deploy → *then* save the URL.\n- **3-second ack / deferred response.** Discord requires an interaction acknowledgement within 3 seconds. Open Everywhere handles this for you (deferred ack, then edits the original response), so the agent can take as long as it needs — no extra wiring required.\n- **Global vs guild propagation.** Guild commands are instant; global commands can take up to an hour to appear. Register a guild command for setup and testing.\n- **No domain/org memory until the guild is vouched.** Every guild starts `external` (no membership check exists yet — see [Trust is unverified by default](#how-the-silo-model-maps) above), so a fresh guild channel's memory stays wherever the `public` pseudo-domain reaches at most, regardless of whether Discord could confirm the channel `@everyone`-readable. Vouch the guild (`identity.blanketTrust: \"internal\"` or `surfaces.ts`) to unlock an explicit domain or the org brain.\n- **Webhook Events *do* need a separate URL.** The one exception to \"Discord has only one public endpoint per app\": install/uninstall events (Step 1.6) are configured on the **Webhooks** tab with their own URL (`/eve/v1/discord/events`), not the Interactions Endpoint. They share `DISCORD_PUBLIC_KEY`, and they carry **no chat messages** — the feature name misleads; ambient content stays Gateway-only.\n- **`/oe` needs no separate URL.** It rides the SAME Interactions Endpoint URL as everything else in Step 1.5 — Discord has only the one public endpoint per app, so there's nothing extra to configure beyond registering the command itself ([above](#register-oe)).\n",
10
+ markdown: [
11
+ "# Discord",
12
+ "",
13
+ "Run a slash command in your Discord server and Open Everywhere acts as an autonomous teammate — it does the work, replies in the channel, and remembers what it learned, scoped correctly per channel.",
14
+ "",
15
+ "This page is the Discord-specific setup runbook. For the concept of a *surface* and how surfaces plug in, see [Surfaces](../../extending/surfaces.md). For how memory is scoped per server and channel, see [Memory](../../capabilities/memory.md). For how Discord's feature set compares to the other surfaces — including what needs the Gateway relay and why — see the [surface support matrix](./support-matrix.md).",
16
+ "",
17
+ "**Discord has two ingestion paths, and this page sets up both.** The HTTPS **interactions** endpoint is interaction-based: on it, the only way to invoke the agent is an explicitly-registered slash command, and a plain `@mention` does nothing. Ambient `@mention`s, DMs, name-drops and channel follow-ups arrive over the separate **Gateway** path instead, which needs the always-on relay ([`deploy/discord-relay/`](../../../deploy/discord-relay/README.md)) — Discord never delivers those to an HTTP endpoint. The [surface support matrix](./support-matrix.md) marks every Gateway-dependent row with footnote ¹.",
18
+ "",
19
+ "## How the silo model maps",
20
+ "",
21
+ "Open Everywhere's memory is [opt-in domains and silos](../../capabilities/memory.md), not an",
22
+ "automatic per-guild tier stack. A Discord channel/thread/DM is a **surface**; the guild is the",
23
+ "natural `workspace` identity key its channels' surface keys share, but sharing memory across",
24
+ "them still needs an explicit `domain` or the `org` pseudo-domain fallback — and (see \"Trust\"",
25
+ "below), by default no Discord surface reaches either.",
26
+ "",
27
+ "| Open Everywhere concept | Discord thing |",
28
+ "| --- | --- |",
29
+ "| `org` (tenant) | your deployment (`deploymentIdentity.org` in `agent/identity/surfaces.ts`, one org per deploy today) |",
30
+ "| `workspace` (identity key) | the **guild** (server) — its id (`guild_id`) |",
31
+ "| `surface` (partition key) | the individual **channel or thread** (`channel_id`) |",
32
+ "| `mode` | `channel` (guild text channel/thread), `dm` (1:1 DM, channel type `1`), `mpim` (group DM, type `3`) |",
33
+ "| `visibility` | `public` only for a confirmed @everyone-readable guild channel; everything else `private` |",
34
+ "",
35
+ "**Visibility is tracked but isn't the memory gate.** Discord interaction payloads carry *no*",
36
+ "clean public/private bit — channel readability is governed by per-role `@everyone`",
37
+ "`VIEW_CHANNEL` permission overwrites, which aren't in the payload — so guild channels default",
38
+ "to **`private`** (`discordVisibility`) until a future enrichment positively confirms",
39
+ "`@everyone` can view them. DMs (type `1`) and group DMs (type `3`) are inherently private.",
40
+ "None of this decides whether memory shares, though — see below.",
41
+ "",
42
+ "**Trust is unverified by default — every guild starts `external`.** Open Everywhere has no",
43
+ "Discord guest/`@everyone`-membership check yet, so `bindDiscord` leaves `trust` undefined,",
44
+ "which **fails closed to `external`**: a fresh guild channel gets read-only `public` memory at",
45
+ "most — never an explicit domain's silo, never the `org` domain's memory — public-looking channel or not.",
46
+ "A public Discord server is full of strangers, so a guild existing is not proof of org",
47
+ "membership. To unlock domain/org memory, the operator explicitly **vouches** it: set",
48
+ "`identity.blanketTrust: \"internal\"` in `agent/settings/settings.ts` for a single-tenant deployment where every guild you'll ever be in is",
49
+ "trusted, or vouch specific guilds via a `discord/<guild_id>` (or `discord/<channel_id>` for one",
50
+ "channel) entry in `agent/identity/surfaces.ts`. Unvouched, the bot still answers slash",
51
+ "commands (chat-only, no shared memory); vouched, normal domain/org resolution applies exactly",
52
+ "as it does for any other internal channel.",
53
+ "",
54
+ "**DMs are personal universes.** A DM has no guild, so it keys to the **caller's own user id**",
55
+ "as the workspace — fully isolated from the server's memory and from other users' DMs. The",
56
+ "personal DB silo is always writable regardless of trust; only the *shared read layer* on top",
57
+ "of it needs a vouch (the same DM-partner-trust gate every platform uses).",
58
+ "",
59
+ "## Auto-thread (mention conversations)",
60
+ "",
61
+ "When the bot is reachable over the **Gateway** ingestion surface (ambient `@mention` messages, as opposed to the slash-command interaction path above), a mention starts a **thread** and the whole conversation happens in it — the signature \"one task = one thread\" pattern Open Everywhere uses on Slack.",
62
+ "",
63
+ "| Where you mention it | What happens |",
64
+ "| --- | --- |",
65
+ "| A **base channel** (guild text or announcement) | The bot **opens a thread** hanging off your message and replies there. That thread is its own **conversation** — its own durable session and per-thread notes — while still counting as part of the parent channel for domains and spend caps (below). Keep talking in the thread — no need to re-mention. |",
66
+ "| **Already inside a thread** | It replies **in place** (a thread is already its own conversation). |",
67
+ "| A **DM or group DM** | It replies **in place** (Discord has no threads in DMs). |",
68
+ "| A **forum** channel | It replies **in place** (a forum post is itself a thread — it doesn't nest). |",
69
+ "",
70
+ "Each auto-created thread is named from your message (the mention stripped, truncated to Discord's 100-char cap; an empty message falls back to `Conversation with the agent`). A **second** mention in the base channel starts a **second** thread — one task per thread, by design.",
71
+ "",
72
+ "**Threads belong to their channel.** Although a Discord thread is technically its own channel id, Open Everywhere treats a thread as part of its **parent channel** for everything scoped to channels — [memory domain](../../capabilities/memory.md) membership (silo, access bundle, model route), domain-admin standing, and **spend caps** (a cap on the channel accumulates and gates its threads' usage too, and a cap proposed *from* a thread targets the parent channel). This matches Slack, where threads share the channel. Only the *conversation* is per-thread: the durable session, engagement, and the thread's own surface-tier notes. Parent resolution is best-effort — if the one-time lookup fails, that thread simply behaves as an unaffiliated channel for the turn (never blocked).",
73
+ "",
74
+ "**Toggle — `discord.autoThread`** (default **on**). Set it to `false` to reply **inline in the base channel** instead of opening a thread. It is a typed setting, not an environment variable, so opting out is a reviewed diff in your own `agent/settings/settings.ts`:",
75
+ "",
76
+ "```ts",
77
+ "// agent/settings/settings.ts",
78
+ "export default defineSettings({",
79
+ " discord: { autoThread: false }, // opt out: reply inline in the base channel, no thread",
80
+ "});",
81
+ "```",
82
+ "",
83
+ "**Fails open.** If the bot lacks **Create Public Threads** / **Send Messages in Threads** (or Discord rate-limits the create), thread creation is skipped and the turn still runs as an **inline** reply in the base channel — degraded, never dropped. Grant both permissions in [Step 3](#step-3--invite-the-bot) to get threads.",
84
+ "",
85
+ "**DMs need no mention.** Over the Gateway surface a direct message (or group DM) to the bot always wakes it — a DM is by definition addressed to the bot, so just talk to it. In guild channels the usual rule applies: an `@mention` starts a conversation; after that the bot follows the thread without re-mentioning.",
86
+ "",
87
+ "**Name-drops (opt-in, per domain).** With `nameDropWake: true` on the channel's [memory domain](../../capabilities/memory.md) — same flag as Slack — a guild message that says one of the bot's names without @mentioning it gets a *judged* reply: the bot answers only when the message is clearly for it, and stays silent otherwise. The name-token set is the same rule as Slack's (see [that surface's doc](slack.md)): the bot's **display name** (its server nickname, if set) UNION the deployment's `identity.name`, extended by `wake.nameDrop.include` and narrowed by `wake.nameDrop.exclude` (configuration-reference.md) — exclude always wins. Works in channels *and* un-followed threads (a deliberate extension over Slack, where threads belong to the engagement path). Scope matters for what happens next: a name-drop **in a thread** puts the bot into follow mode for that thread (small scope — same grain as Slack, where a name-drop's reply thread is followed); a name-drop **in a base channel** does *not* — following a whole channel means a judged model turn on every subsequent message, so there you @mention it (or say its name again) to continue. When it does follow a thread off a name-drop, it starts as a **warm watcher** (see *Stopping it following* below). Off by default; requires a Gateway relay recent enough to forward the bot's identity (older relays simply never name-wake). The road is audited on Discord exactly as it is on Slack: an admitted name-drop writes `surface.name_drop_woke` (naming the channel, the message id and `road: \"native\"` — this surface hands off to no standing seat), and a name-drop the domain flag refuses writes `surface.name_drop_silent`. A message that names nobody writes neither row. A judged wake also arrives with a short, **untrusted-wrapped backscroll** of the surrounding conversation (recent messages, capped) so the model can tell whether the message is really for it — the same context an @mention gets.",
88
+ "",
89
+ "### Stopping it following",
90
+ "",
91
+ "Engagement has no hard timeout, but a lightly-latched surface **sheds itself** — the same model as Slack:",
92
+ "",
93
+ "- **Participating** surfaces (you @mentioned the bot, or it actually replied) are **sticky**: they keep following every message there (each one a judged model turn, so a followed surface has a real per-message cost) until you ask it to stop. Just say so, however you'd phrase it to a colleague — \"you can stop following this\", \"we're good here\". This is **judged, not pattern-matched**: the agent reads the intent and calls `stand_down`, which sets a durable marker on the surface. There is deliberately no magic word (the regex that used to look for one disengaged on \"don't stop the deploy\" as readily as on \"stop\"). Mention it again any time to re-engage — that clears the marker. This is a **channel-only** verb — a DM has no `@mention` to clear a marker with, so `stand_down` refuses there instead of trapping the conversation silent for good.",
94
+ "- **Warm watchers** (a thread the bot entered off a *name-drop* but hasn't yet replied in) are **not** sticky: if it judges \"no reply\" on three consecutive messages, it quietly **disengages** itself — so a one-off name-drop that wasn't really for it never leaves it silently following forever. The moment it does reply (or you @mention it), the surface is promoted to participating and the count resets.",
95
+ "",
96
+ "### Stopping a turn that's already running",
97
+ "",
98
+ "Different problem, different mechanism. Standing down happens *between* turns; stopping a turn",
99
+ "that is **in flight** can't be inferred at all, because the bot is the thing that's busy and your",
100
+ "message would queue behind the very turn it's meant to stop. So it's an affordance:",
101
+ "",
102
+ "- react **✋** on the message — the host adds it when a turn actually kicks work off (its first",
103
+ " delegation) and removes it when the turn ends, so it's there to click exactly while a stop means",
104
+ " something, and never appears on a message the bot merely read;",
105
+ "- or run **`/oe stop`**, which needs no reaction permission and takes no arguments.",
106
+ "",
107
+ "Both are decided at ingress with **no model call and nothing spent**, so they keep working while",
108
+ "the channel is over its spend cap. ⚠️ The ✋ route needs a Gateway relay recent enough to forward",
109
+ "reactions — an older deployed relay never forwards them, and `/oe stop` covers the gap.",
110
+ "",
111
+ "What a stop does, precisely: the turn's history rolls back to its last completed model call, but",
112
+ "**anything already done is done** — tool calls inside the interrupted step had real effects and",
113
+ "are not undone, and a running sandbox command can't be killed. The acknowledgement says \"stood",
114
+ "down\", never \"stopped\", and names what had already run.",
115
+ "",
116
+ "### Working indicators (status UX)",
117
+ "",
118
+ "While a turn runs (often 30s+), the bot shows it's on the case using Discord's native affordances — no extra messages, no edits:",
119
+ "",
120
+ "- It reacts **👀** on your message the moment it picks it up (\"seen, working\") — on any direct address (a mention or a DM).",
121
+ "- While it works, Discord's native **typing indicator** shows (\"*Open Everywhere is typing…*\"), re-triggered as the turn progresses. The answer then arrives as a fresh message.",
122
+ "- **Judged wakes hold typing back.** On a followed (engaged) surface or a name-drop, the model judges each message and may decide no reply is warranted. Those turns show **no typing at all until the reply is actually being written** — so a judged pass is silent from the first second, instead of \"typing… for 10 seconds, then nothing\" (which reads as a crash). Direct addresses (mentions and DMs) always answer, so they show typing immediately.",
123
+ "- **On an auto-threaded mention** the 👀 is **removed** when the answer lands in the thread — absence is the done signal, visible from the base channel without opening the thread. (The bot never plants a ✅ — that stays reserved for humans, same convention as Slack.) Elsewhere the 👀 simply stands as the ack; the answer arrives right underneath.",
124
+ "",
125
+ "**Toggle — `discord.statusUx`** (default **on**). Like `autoThread` it is a typed setting, not an environment variable: set `discord: { statusUx: false }` in your own `agent/settings/settings.ts` for plain replies with no reaction or typing. All indicators are best-effort: a failed reaction or typing call never delays or drops the answer.",
126
+ "",
127
+ "### Reaction ingestion (feedback signal)",
128
+ "",
129
+ "Over the **Gateway** relay the bot also *ingests* inbound reactions. A 👍 / 👎 (or any emoji) on one of the **bot's own replies** is recorded as a lightweight human **feedback** signal — durable, append-only, carrying only the reactor's id + the emoji, never any message text. Reactions on *other* messages emit anonymous volume telemetry but aren't stored, and the bot's own status reactions (👀) are never counted. This needs the **Guild Message Reactions** and **Direct Message Reactions** gateway intents, which the relay already requests (they're not privileged — no portal toggle). Older relays that predate this simply never forward reactions, so the feature is silently off until you redeploy the relay.",
130
+ "",
131
+ "The relay also serves **voice meetings** (OPE-2473): it joins the voice channel a member approved, and forwards only that call's voice credentials to OE's meeting box. This needs the **Guild Voice States** gateway intent, which the relay requests (not privileged), and the **View Channel**, **Connect** and **Send Messages** permissions in the voice channel (OE posts a recording notice there before the bot records, and does not record when it cannot). See [Meetings § Discord voice](../../capabilities/meetings.md#discord-voice). An older relay has no voice sync, so a Discord meeting cannot start until you redeploy the relay; the box then reports that it got no credentials.",
132
+ "",
133
+ "### Spend caps",
134
+ "",
135
+ "Guild channels are subject to the same [monthly spend caps](../../security/spend-and-cost.md) as every surface: a channel (or guild) over its budget **declines** new mentions with a visible message rather than silently ignoring them. The decline message names the exact scope keys, so a follow-up DM proposal can target them.",
136
+ "",
137
+ "**Admins are never locked out.** If the sender is an admin over the scope — on the config roster (`Domain.admins`), or a Discord-verified administrator/owner of the guild — the turn proceeds past the cap with a visible `⚠️ … proceeding because you're an admin` line (throttled to once per channel per hour — subsequent bypassed turns proceed quietly) and an audit entry (`spend.cap_bypassed`) on every bypass. Sudo semantics: the cap binds the team's ambient usage; the budget owner steps over it visibly and can raise or clear it right there (\"set this channel's cap to $5\" → approval card).",
138
+ "",
139
+ "DMs are capped like any other surface. The escape hatch is the **admin bypass**: an org admin's own DM turn proceeds past the cap with the visible warning and an audit row, so they can still propose an override (passing the scope key from the decline message) and approve it on the card **in the DM** — a DM click is decided by checking the clicker's admin standing in the target guild over REST, since Discord DM interactions carry no member permissions. One default from a DM is still refused: a Discord DM belongs to no server, so a `\"workspace\"` cap defaulted there would target your personal DM bucket rather than the guild you meant — pass an explicit `discord/<serverId>` key. (`level: \"surface\"` from a DM is fine now: it targets that DM, which is a scope the gate really enforces.)",
140
+ "",
141
+ "## Prerequisites",
142
+ "",
143
+ "- A Discord **server you manage** with the **Manage Server** permission (needed to add the bot and, optionally, to register guild-scoped commands).",
144
+ "- A reachable Open Everywhere instance — deployed with a **public HTTPS URL**. Discord delivers interactions as signed HTTPS POSTs from its own infrastructure and **cannot reach `localhost`**; for local dev expose your instance with a tunnel (e.g. `ngrok http 3000`) and use that HTTPS URL. See the [docs index](../../README.md) to get the project and the [setup runbook](../setup-runbook.md) for the deployment steps.",
145
+ "- Your instance's public base URL. The Discord interactions route is **`/eve/v1/discord`**, so Discord calls `https://<your-deploy-url>/eve/v1/discord`.",
146
+ "",
147
+ "## Step 1 — Create the Discord application + bot",
148
+ "",
149
+ "1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) → **New Application**. Name it (e.g. `Open Everywhere`). Upload [`brand/png/mark-square-1024.png`](../../../brand/png/mark-square-1024.png), the project mark, as the **App Icon** on **General Information** and as the bot's **Icon** on the **Bot** tab. Skip this when you set [`identity.name`](../configuration-reference.md) and chose your own icon: with no name configured the agent presents as the project, so the project mark is its default avatar.",
150
+ "2. On **General Information**, copy the **Application ID** — this is `DISCORD_APPLICATION_ID`.",
151
+ "3. Still on **General Information**, copy the **Public Key** — this is `DISCORD_PUBLIC_KEY`. It verifies the Ed25519 signature on every inbound interaction POST.",
152
+ "4. Go to the **Bot** tab → **Reset Token** → copy the token — this is `DISCORD_BOT_TOKEN`. (Treat it like a password; it's shown once.)",
153
+ "5. Set the **Interactions Endpoint URL** (on **General Information**) to:",
154
+ "",
155
+ " ```",
156
+ " https://<your-deploy-url>/eve/v1/discord",
157
+ " ```",
158
+ "",
159
+ " When you save this, Discord immediately POSTs a `PING` (interaction type `1`) and expects a signed `PONG` back. **Your instance must already be deployed, running, and reachable with `DISCORD_PUBLIC_KEY` configured** (Step 4) or Discord rejects the URL. Set the env first, deploy, *then* save this field.",
160
+ "",
161
+ " > ⚠️ **Use your STABLE production domain here** (e.g. `your-app.vercel.app`), never a",
162
+ " > deployment-specific URL (`your-app-<hash>-team.vercel.app`). Deployment URLs stay alive",
163
+ " > forever pointing at that one old build — interactions (including approval-card button",
164
+ " > clicks) would keep hitting stale code after every redeploy, failing in ways that look",
165
+ " > like application bugs.",
166
+ "",
167
+ "6. *(Optional, recommended)* Go to the **Webhooks** tab → set the **Webhook Events URL** to:",
168
+ "",
169
+ " ```",
170
+ " https://<your-deploy-url>/eve/v1/discord/events",
171
+ " ```",
172
+ "",
173
+ " and subscribe to **`APPLICATION_AUTHORIZED`** and **`APPLICATION_DEAUTHORIZED`**. This is a",
174
+ " **different URL** from the Interactions Endpoint above — Discord configures the two separately —",
175
+ " but it is verified with the **same** `DISCORD_PUBLIC_KEY`, so there is nothing new to set.",
176
+ "",
177
+ " It gives OE an install/uninstall audit trail: each event writes a `surface.app_installed` /",
178
+ " `surface.app_uninstalled` row (guild as the workspace, the authorizing user as the principal),",
179
+ " queryable over the control plane's audit route. Without it OE has no signal that a server added",
180
+ " or removed the app short of noticing silence.",
181
+ "",
182
+ " **Observability only** — this route gates nothing, wakes no session, and drops nothing. Leaving",
183
+ " it unconfigured changes no behaviour. Note that Webhook Events deliberately do **not** carry chat",
184
+ " messages: ambient channel content is Gateway-only and still needs the always-on relay (see the",
185
+ " [surface support matrix](./support-matrix.md)).",
186
+ "",
187
+ "## Step 2 — Register the slash command",
188
+ "",
189
+ "The channel handler does **not** filter on a specific command name — its `onCommand` hook accepts *any* registered application command, attaches the real Discord identity, and dispatches. So you register one command and pick its name. The command maps to the model prompt via `commandInteractionMessage`: it uses the value of a string option named **`message`** when present, otherwise it reconstructs `/command opt:value …`. **Register a command with a required `message` string option** so users type their request naturally.",
190
+ "",
191
+ "Register with the Discord API (no helper script ships today). **Guild** commands appear **instantly** in that one server; **global** commands can take **up to an hour** to propagate — use a guild command while testing.",
192
+ "",
193
+ "Guild-scoped (instant, recommended for setup):",
194
+ "",
195
+ "```bash",
196
+ "curl -X POST \\",
197
+ " \"https://discord.com/api/v10/applications/$DISCORD_APPLICATION_ID/guilds/$GUILD_ID/commands\" \\",
198
+ " -H \"Authorization: Bot $DISCORD_BOT_TOKEN\" \\",
199
+ " -H \"Content-Type: application/json\" \\",
200
+ " -d '{",
201
+ " \"name\": \"ask\",",
202
+ " \"type\": 1,",
203
+ " \"description\": \"Ask Open Everywhere to do something\",",
204
+ " \"options\": [",
205
+ " { \"name\": \"message\", \"description\": \"What should it do?\", \"type\": 3, \"required\": true }",
206
+ " ]",
207
+ " }'",
208
+ "```",
209
+ "",
210
+ "Global (drop `/guilds/$GUILD_ID`, propagation up to ~1h):",
211
+ "",
212
+ "```bash",
213
+ "curl -X POST \\",
214
+ " \"https://discord.com/api/v10/applications/$DISCORD_APPLICATION_ID/commands\" \\",
215
+ " -H \"Authorization: Bot $DISCORD_BOT_TOKEN\" \\",
216
+ " -H \"Content-Type: application/json\" \\",
217
+ " -d '{ \"name\": \"ask\", \"type\": 1, \"description\": \"Ask Open Everywhere to do something\",",
218
+ " \"options\": [ { \"name\": \"message\", \"description\": \"What should it do?\", \"type\": 3, \"required\": true } ] }'",
219
+ "```",
220
+ "",
221
+ "(`type: 1` = CHAT_INPUT slash command; option `type: 3` = STRING. Name it `openeverywhere` instead of `ask` if you prefer.)",
222
+ "",
223
+ "## Step 3 — Invite the bot",
224
+ "",
225
+ "1. In the Developer Portal → **OAuth2** → **URL Generator**.",
226
+ "2. Under **Scopes**, tick **`applications.commands`** (required for slash commands) and **`bot`**.",
227
+ "3. Under **Bot Permissions**, tick:",
228
+ " - **Send Messages** — post replies (interaction responses and Gateway/REST posts).",
229
+ " - **Create Public Threads** — open a thread per conversation (see [Auto-thread](#auto-thread-mention-conversations)).",
230
+ " - **Send Messages in Threads** — reply inside those threads.",
231
+ "",
232
+ " Without the two thread permissions the bot **falls back to inline replies** (auto-thread fails open — it never drops a turn), so the minimum for slash commands alone is just **Send Messages**. The agent replies via the interaction token for slash commands, so it doesn't need broad permissions.",
233
+ "4. Copy the generated URL, open it, pick your server, and **Authorize**. You need **Manage Server** on that server to complete the invite.",
234
+ "",
235
+ "## Step 4 — Configure the environment",
236
+ "",
237
+ "Set these on your instance, then deploy:",
238
+ "",
239
+ "```bash",
240
+ "DISCORD_APPLICATION_ID=… # Application ID from Step 1.2",
241
+ "DISCORD_PUBLIC_KEY=… # Public Key from Step 1.3 (verifies signed interaction POSTs)",
242
+ "DISCORD_BOT_TOKEN=… # Bot token from Step 1.4",
243
+ "```",
244
+ "",
245
+ "Optional, but relevant to the silo model:",
246
+ "",
247
+ "```bash",
248
+ "OE_MEMORY_PROVIDER=git # memory backend — git | sqlite",
249
+ "OE_MEMORY_DIR=/abs/path/to/store",
250
+ "```",
251
+ "",
252
+ "**Identity binds automatically.** You do **not** set workspace, surface, mode, or visibility by hand. The Discord channel reads the guild/channel/user ids and the channel `type` straight off each inbound interaction into the session auth context (`buildDiscordAuth`), and `bindDiscord` derives workspace = the guild, surface = the channel, plus mode and visibility. The `deploymentIdentity.workspace` / `.surface` declaration in `agent/identity/surfaces.ts` is only the `eve dev` gap-filler — once Discord is wired it is ignored.",
253
+ "",
254
+ "## Step 5 — Invoke it",
255
+ "",
256
+ "1. In a guild channel, run `/ask summarise the last few messages and list the action items`.",
257
+ "2. Expected behaviour:",
258
+ " - Discord shows a **`Thinking…`** typing indicator while the agent works (the channel calls `startTyping()` on dispatch).",
259
+ " - It **acks within 3 seconds with a deferred response**, then edits that original response in place with the answer once the agent finishes — so longer tasks don't time out.",
260
+ " - The reply lands in the channel, scoped to that channel's surface silo.",
261
+ "3. Test a DM: open a direct message with the bot and run `/ask …` there. It responds the same way; the DM surface's memory is isolated to you and `private`.",
262
+ "",
263
+ "## Rotating the bot token",
264
+ "",
265
+ "**Reset Token** in the Developer Portal (Step 1.4) invalidates the old token immediately. Two",
266
+ "**deployed** stores hold that token. Each one needs its own update, and neither picks the new value",
267
+ "up on its own.",
268
+ "",
269
+ "| Store | What holds the token | How the new value takes effect |",
270
+ "| --- | --- | --- |",
271
+ "| Vercel production | `DISCORD_BOT_TOKEN` on the deployment | an explicit redeploy |",
272
+ "| Railway | `DISCORD_BOT_TOKEN` on the always-on relay ([`deploy/discord-relay/`](../../../deploy/discord-relay/README.md)) | a relay redeploy |",
273
+ "",
274
+ "A rotation that reaches only the deployment leaves the relay dead. That failure is quiet: the slash",
275
+ "command still works, and every HTTPS route still answers.",
276
+ "",
277
+ "A local checkout keeps its own copy — `deploy/discord-relay/.env`, and the repo-root `.env`. Those",
278
+ "are operator copies, not deployed stores. Update one the next time you run it locally.",
279
+ "",
280
+ "### The Vercel half",
281
+ "",
282
+ "`git push` does not deploy, and a running deployment keeps the value it booted with:",
283
+ "",
284
+ "```bash",
285
+ "vercel env rm DISCORD_BOT_TOKEN production -y # the name exists already — remove, then re-add",
286
+ "vercel env add DISCORD_BOT_TOKEN production",
287
+ "vercel deploy --prod",
288
+ "```",
289
+ "",
290
+ "`deploy:census` does **not** see this half. It reads credential rows out of the store, and this",
291
+ "token is an environment variable. Prove it by hand instead: run `/ask …` in a guild channel",
292
+ "(Step 5). A dead token fails every Discord API call the deployment makes.",
293
+ "",
294
+ "### The Railway half",
295
+ "",
296
+ "**The relay reads the token ONCE, at boot.** `deploy/discord-relay/index.js` reads",
297
+ "`process.env.DISCORD_BOT_TOKEN` at module load, then logs in with that value. A variable change alone",
298
+ "changes nothing on a running relay. The process holds the dead token until it restarts.",
299
+ "",
300
+ "```bash",
301
+ "cd deploy/discord-relay",
302
+ "read -rs -p 'new DISCORD_BOT_TOKEN: ' NEW_TOKEN && \\",
303
+ " railway variables --set DISCORD_BOT_TOKEN=\"$NEW_TOKEN\" --skip-deploys && unset NEW_TOKEN",
304
+ "railway redeploy -y",
305
+ "railway logs # expect: \"[oe-discord-relay] ready as <bot>#0000 | mode: FORWARD\"",
306
+ "```",
307
+ "",
308
+ "`read -rs` keeps the token out of your **shell history** and off the **screen** — a token typed as a",
309
+ "literal argument stays in the history file long after the rotation. `unset` drops it from the",
310
+ "environment as soon as Railway holds it.",
311
+ "",
312
+ "🔴 **It does NOT hide the token from the process table.** The shell expands `\"$NEW_TOKEN\"` before it",
313
+ "runs `railway`, so the value is visible in that process's `ps` and `/proc/<pid>/cmdline` entry for",
314
+ "the life of the call. Rotate on a machine you trust, never on a shared box or a runner other",
315
+ "tenants can read.",
316
+ "",
317
+ "`--skip-deploys` holds the restart back, so the redeploy is one explicit step you can watch. The",
318
+ "`ready as` line is the proof — the relay holds the Gateway socket again, with the new token.",
319
+ "",
320
+ "### What a missed relay looks like",
321
+ "",
322
+ "Discord closes the relay's Gateway socket at the reset hour. Ambient channel and DM messages stop",
323
+ "arriving, because Discord delivers them over that socket only. Slash commands keep working, so the",
324
+ "surface looks half-alive.",
325
+ "",
326
+ "`npm run deploy:census` catches it: `discord.heartbeat` goes red. Read that row correctly. The",
327
+ "**relay** POSTs the heartbeat on its own timer; the **deployment** writes the row, once the signed",
328
+ "POST passes the relay gate. After a token rotation the cause is the stale relay, so the remedy is",
329
+ "the relay redeploy above. A red row under a LIVE relay points at the ingest side instead — an",
330
+ "`OE_RELAY_SECRET` mismatch, or a route that refuses. Check `railway logs` for `ingest rejected`",
331
+ "before you redeploy anything. Restart the relay, then run the census again.",
332
+ "",
333
+ "The check needs Discord mounted, and it needs the census run's OWN `OE_RELAY_HEARTBEAT_MS` mirror",
334
+ "non-zero and matching the relay's configured interval. The census reads its own copy of that name,",
335
+ "never the relay's — see [rollback](../rollback.md).",
336
+ "",
337
+ "### `.env.e2e` does not hold this token",
338
+ "",
339
+ "The e2e tier drives Discord with `E2E_DISCORD_DRIVER_TOKEN`, and that is a **different bot** — the",
340
+ "test driver, a second application the `wake.driverAllowlist` setting admits",
341
+ "(`packages/core/scripts/e2e/env.ts`). Resetting the deployment's own bot token does not invalidate",
342
+ "it. No code in this repository reads the deployment's bot token out of `.env.e2e`, so a rotation",
343
+ "leaves that file alone.",
344
+ "",
345
+ "## What the agent calls itself here",
346
+ "",
347
+ "A Discord session's IDENTITY — the name it introduces itself by — comes from one place only: the",
348
+ "[`identity.name`](../configuration-reference.md) setting. Set it and it **wins on every surface**,",
349
+ "byte-identically — the right answer for a deployment that spans Slack and Discord from one window:",
350
+ "one name, everywhere. Unset, the session renders the generic identity line (\"this team's agent\").",
351
+ "It never borrows a name from another surface, and never from this surface's own Discord account",
352
+ "name either — that is a HANDLE, not an identity (OPE-610).",
353
+ "",
354
+ "Separately, the session also carries a HANDLE line — \"your mention/handle here appears as X\" — so",
355
+ "the agent recognises its own mentions and backscroll lines under the bot account's real Discord",
356
+ "name. The agent reads it once per instance from `GET /users/@me` (the same `DISCORD_BOT_TOKEN` this",
357
+ "integration already uses to post replies), preferring the account's display name (`global_name`)",
358
+ "and falling back to its username. A rename on Discord takes effect on the next cold start. If the",
359
+ "read fails (no token, a transport error), the session simply carries no handle line; the identity",
360
+ "line above is unaffected either way.",
361
+ "",
362
+ "## Reading channel/thread history",
363
+ "",
364
+ "Discord supports the generic `read_channel`/`read_thread` verbs (the same tools Slack and Teams",
365
+ "use — since OPE-1145 no per-surface reader stands beside them on any surface), backed by the plain",
366
+ "Discord REST API",
367
+ "(`GET /channels/{id}/messages`) — the **same `DISCORD_BOT_TOKEN`** this integration already uses to",
368
+ "post replies (Step 1.4/Step 4 above). The read adapter (`packages/providers/discord/agent/channels/discord-read.ts`'s",
369
+ "`discordReadAdapter`, registered into core's read-adapter registry at boot) reuses that token verbatim, so **there is no extra Discord- or Azure-side",
370
+ "configuration to grant** — unlike Teams, which needs a separate Graph token/permission because its",
371
+ "Bot Framework Connector token can't read history.",
372
+ "",
373
+ "- **A Discord thread is its own channel id.** Discord threads aren't a sub-resource of their parent",
374
+ " channel the way Slack/Teams threads are — a thread IS a channel, with its own id. So",
375
+ " `read_thread`'s thread identifier for Discord is that thread's channel id, and the adapter simply",
376
+ " reads it with the same `GET /channels/{id}/messages` call `read_channel` uses against a base",
377
+ " channel. Both ids come from the turn's own binding — neither verb takes a target.",
378
+ "- **Bounded, single page.** Unlike Teams' paginated Graph reads, one GET returns up to 100 messages",
379
+ " (Discord's own per-request cap) — a requested `limit` above 100 is silently clamped, never fanned",
380
+ " out across pages.",
381
+ "- **Scope.** The verbs read THE CONTAINER THE WAKE ARRIVED IN and take no target, so no other",
382
+ " channel is addressable. The domain allowlist still runs inside the adapter as defence in depth.",
383
+ " An out-of-scope channel is refused before any Discord GET, and every refusal writes a",
384
+ " `read.refused` audit row naming the channel, the caller's domain and the verdict (ids only, never",
385
+ " message content) — queryable with `GET /control/v1/audit?since=<iso>&actions=read.refused`.",
386
+ "",
387
+ "## Identity linking",
388
+ "",
389
+ "[Identity linking](../../how-it-works/identity.md) joins a person's Discord account to their",
390
+ "accounts elsewhere. Discord supports **both halves** of the preferred *directory* transport, so a",
391
+ "person can name their other account instead of carrying a code between two surfaces.",
392
+ "",
393
+ "- **The account directory** searches the guilds your domains declare in their `reach` lists",
394
+ " (`agent/memory/config/domains/<name>.ts`) — one `GET /guilds/{id}/members/search` per guild, so",
395
+ " Discord itself does the matching. A guild nobody configured is never searched, and bots are",
396
+ " dropped (a link is a statement about a human).",
397
+ "- **The DM ask** opens the 1:1 conversation with `POST /users/@me/channels` and posts the consent",
398
+ " request there. No extra permission: Discord delivers a bot DM when the recipient shares a server",
399
+ " with the bot and has not turned off direct messages from server members.",
400
+ "- **Two buttons** ride under the ask, and a tap answers it with no model turn. The tap returns to the",
401
+ " same **Interactions Endpoint URL** the approval cards already use, so there is nothing extra to",
402
+ " configure. The answer is **ephemeral** — only the person who tapped",
403
+ " sees it. A tap from a server channel is refused with a pointer back to the DM.",
404
+ "",
405
+ "⚠️ **The directory needs the privileged `SERVER MEMBERS` intent.** Turn on **Privileged Gateway",
406
+ "Intents → Server Members Intent** on your application's **Bot** page in the Discord developer",
407
+ "portal. Without it Discord answers `403` to the member endpoints, the directory resolves empty, and",
408
+ "linking falls back to the **code** transport — which still works and says which capability was",
409
+ "missing. Directory rights are fine to require for the better path; they are never a precondition for",
410
+ "linking at all.",
411
+ "",
412
+ "Because Discord can DM, a `link <code>` claim typed **in a channel** is now refused with a pointer to",
413
+ "the DM, and the code is not spent. A code is a bearer secret for its whole life, and a room has an",
414
+ "audience.",
415
+ "",
416
+ "## Proactive routines",
417
+ "",
418
+ "Scheduled [routines](../../capabilities/proactivity-and-routines.md) work on Discord: create one with a slash command (\"every hour, check …\") and the fired result posts back **into the same channel** via the REST create-message endpoint (the bot token from Step 1 — no Gateway websocket involved). The post-back target is captured automatically when the routine is created; nothing to configure. Spend caps apply to guild-channel routines — and to 1:1 DM routines too, same as Slack: a fire never gets the admin bypass, because there is no attending admin to bypass on its behalf.",
419
+ "",
420
+ "## Slash commands: `/oe` (zero-inference control-plane)",
421
+ "",
422
+ "`/oe` is a **second, separate** slash command from the model-dispatching one registered in [Step 2](#step-2--register-the-slash-command) (`/ask`, or whatever you named it). It's the control-plane escape hatch: pure logic, code-rendered responses, **no model turn** — so it answers even while a channel is over its spend cap, engagement is judging silence, or the model/gateway itself is down. It's intercepted at the **front route** (`channels/discord-front.ts`), before discordChannel's `onCommand` hook ever sees it, so it costs zero inference — unlike every other registered command name, which `onCommand` dispatches to the agent unconditionally.",
423
+ "",
424
+ "Commands:",
425
+ "",
426
+ "| Command | What it does | Visibility |",
427
+ "| --- | --- | --- |",
428
+ "| `/oe status` | Says the agent is running, then lists, under \"Details for your admin\", the build (deployment sha) and eve pin that identify this build, the app version when the deploy carries one, primary/utility model config, the context window and the token count it is summarised at, memory provider. | Ephemeral (only you see it) |",
429
+ "| `/oe budget status` | This channel's and server's effective spend caps (tokens + USD) and current-month spend — the SAME figures the spend gate enforces. | Ephemeral |",
430
+ "| `/oe budget raise <usd>` | Proposes a new monthly USD cap for this channel: posts the SAME public approval card `set_spend_limit` posts (Approve/Reject buttons); an admin decides. The card is the answer, so the only ephemeral line is **your own domain's recent spend** from the ledger — see [Spend & cost](../../security/spend-and-cost.md#the-domain-spend-scan-behind-oe-budget-raise). A Discord channel the operator has not declared internal is `external`, so no domain resolves there and the ack carries no figure. | Card: public in-channel. Ack: ephemeral |",
431
+ "| `/oe budget clear` | Proposes clearing this channel's spend-limit override back to its configured limit: posts the SAME public approval card `clear_spend_limit` posts, which names both figures (\"From $1/mo to $50/mo (the default)\"). | Card: public in-channel. Ack: a private 👍 only, because Discord must answer every command (OPE-2490) |",
432
+ "| `/oe stop` | Stands down the turn currently running in this channel. **No model turn, nothing spent** — it works while the channel is over its cap, which is when you're most likely to want it. | Ephemeral |",
433
+ "| `/oe rate <1-5>` | Records one human mark on the [model A/B test](../../extending/models-and-gateway.md#ab-testing-a-challenger-model): rates this channel's most recent champion/challenger turn, on the arm that turn actually ran. Answers \"nothing to rate\" — the normal reply — while the test is off. | Ephemeral |",
434
+ "| `/oe cover on` / `/oe cover off` / `/oe cover status` | Turns **your own** [out-of-office cover](../../capabilities/out-of-office-cover.md) on (from your DM with the agent, members only, with an optional `until` date as `YYYY-MM-DD`) or off (anywhere), or shows it (OPE-2534). While cover is on, a question that @mentions you in a guild channel on the cross-surface window gets an answer from what that channel already knows, labelled as cover. When cover ends you get one recap in your DM. The recap shows a question only from a channel Discord's own permissions let you read. `on` needs the DM to bind `internal`. A Discord DM binds `external` by default and `internal` under the operator's own trust declaration, for example `identity.blanketTrust: \"internal\"`, so without one `on` answers \"Cover is for members\". `on` runs in a DM. The deployment registers `/oe` only in the guilds it declares, and a guild command does not reach a DM. So the operator registers `/oe` **globally** once: `npm run register:discord-commands` with `DISCORD_GUILD_ID` unset. A global command takes up to an hour to appear. | Ephemeral |",
435
+ "| `/oe suggest off` / `/oe suggest on` / `/oe suggest status` | Stops (or allows, or shows) the agent's [routine suggestions](../../capabilities/proactivity-and-routines.md#shared-rooms) in **this channel or thread** (OPE-2550). Anybody in the channel may stop them. Only a member of the organisation may turn them back on. In a DM it changes nothing. | Ephemeral |",
436
+ "| `/oe help` | Lists the commands. | Ephemeral |",
437
+ "",
438
+ "Discord requires picking a concrete subcommand — you can't invoke the bare group (`/oe budget` isn't itself invokable; type `/oe budget status`). Slack's equivalent free-text grammar (`/oe budget`) is more forgiving; see the [Slack guide](./slack.md#slash-commands-oe-zero-inference-control-plane).",
439
+ "",
440
+ "### Register `/oe` — the deployment does it itself",
441
+ "",
442
+ "**You register nothing.** The deployment reconciles `/oe` against Discord on its own, once per process",
443
+ "start: it reads the commands Discord holds for your application at each declared guild, compares them",
444
+ "with the command set the code declares, and writes only when they differ. Deploy a build that adds a",
445
+ "subcommand and the next cold start registers it.",
446
+ "",
447
+ "Name the guilds in `agent/commands/discord-commands.ts`:",
448
+ "",
449
+ "```ts",
450
+ "export const DISCORD_COMMAND_GUILDS: readonly string[] = [",
451
+ " \"1300000000000000100\", // your server's guild id",
452
+ "];",
453
+ "```",
454
+ "",
455
+ "A guild id is **tracked config**, not an environment variable — it lives in Git and changes by pull",
456
+ "request, like every other room key ([Config-as-code](../../how-it-works/config-as-code.md)). The two",
457
+ "credentials stay environment: `DISCORD_APPLICATION_ID` (Step 1.2) says which application this",
458
+ "deployment speaks as, and `DISCORD_BOT_TOKEN` (Step 1.4) authenticates the call. Either one absent and",
459
+ "the reconcile reports `skipped` in the boot log and registers nothing — it never fails the boot, and",
460
+ "neither does a Discord outage: the worst outcome is the command list you already had.",
461
+ "",
462
+ "Watch for one line per platform in the deployment's boot log:",
463
+ "",
464
+ "```",
465
+ "[open-everywhere:command-registration] discord: unchanged — guild 152…: \"/oe\" already matches what this deployment declares",
466
+ "```",
467
+ "",
468
+ "**The deployment's write is additive.** It uses Discord's per-command `POST` (create-or-update by",
469
+ "name), so a command you registered yourself at the same scope — the `/ask`-style command from",
470
+ "[Step 2](#step-2--register-the-slash-command) — is never touched.",
471
+ "",
472
+ "Declare no guild and the deployment registers nothing and makes no Discord call at all.",
473
+ "",
474
+ "⚠️ **Every process holding both credentials reconciles — a local `eve dev`/`eve start` included, and",
475
+ "it writes the command shape of the checkout that is running.** Do not run a branch that changes the",
476
+ "`/oe` command set against the deployment's own `DISCORD_BOT_TOKEN`: your server will show that",
477
+ "branch's `/oe` until the deployment's next cold start writes it back. Develop such a branch against a",
478
+ "Discord application of your own, or with the bot token unset.",
479
+ "",
480
+ "#### The one-off CLI, for a global registration",
481
+ "",
482
+ "`npm run register:discord-commands` still ships, for the one thing the deployment does not do:",
483
+ "registering `/oe` **globally** (every server the app is in). A global command takes up to an hour to",
484
+ "propagate, which no boot-time check can prove landed, so it stays a deliberate operator step. It",
485
+ "reads:",
486
+ "",
487
+ "```bash",
488
+ "DISCORD_APPLICATION_ID=… # from Step 1.2",
489
+ "DISCORD_BOT_TOKEN=… # from Step 1.4",
490
+ "DISCORD_GUILD_ID=… # optional: guild-scoped (instant) instead of global (~1h propagation)",
491
+ "```",
492
+ "",
493
+ "⚠️ **The CLI is a bulk `PUT`, not an upsert** — unlike the deployment's own additive write above.",
494
+ "Discord's bulk endpoint *replaces every command registered at that scope* with exactly the array the",
495
+ "script sends (today: `[\"/oe\"]` alone). If you've also registered `/ask` (or anything else) at the",
496
+ "**same scope** (same guild, or both global), running this script **deletes it** — re-register",
497
+ "everything you want to keep in one pass.",
498
+ "",
499
+ "### Spend caps deliberately do NOT gate `/oe`",
500
+ "",
501
+ "There is no model turn to decline, so `/oe` isn't a \"new spend\" origin — it works in a ⛔'d channel exactly as well as in one with headroom. `/oe budget raise`/`clear` still go through the real approval-card machinery; a human's click, not the command, is what actually changes anything.",
502
+ "",
503
+ "## Gotchas",
504
+ "",
505
+ "- **Slash command vs. mention.** The **interaction** channel documented here is slash-command-only — a plain `@mention` does nothing on that path. Ambient `@mention` messages (and the [auto-thread](#auto-thread-mention-conversations) behaviour) are served by the separate **Gateway** ingestion surface, which needs the always-on relay configured. If `/ask` doesn't appear, the command isn't registered for that scope, or a global command hasn't propagated yet.",
506
+ "- **Public Key verification.** Every inbound interaction POST is Ed25519-verified against `DISCORD_PUBLIC_KEY`. If it's wrong or unset, Discord requests are rejected (including the initial PING), the **Interactions Endpoint URL** won't save, and the bot stays silent — check this first.",
507
+ "- **PING handshake needs the endpoint live first.** Saving the Interactions Endpoint URL triggers an immediate `PING` (type `1`); your instance must already be running with the public key set so it can return the signed `PONG`. Set env → deploy → *then* save the URL.",
508
+ "- **3-second ack / deferred response.** Discord requires an interaction acknowledgement within 3 seconds. Open Everywhere handles this for you (deferred ack, then edits the original response), so the agent can take as long as it needs — no extra wiring required.",
509
+ "- **Global vs guild propagation.** Guild commands are instant; global commands can take up to an hour to appear. Register a guild command for setup and testing.",
510
+ "- **No domain/org memory until the guild is vouched.** Every guild starts `external` (no membership check exists yet — see [Trust is unverified by default](#how-the-silo-model-maps) above), so a fresh guild channel's memory stays wherever the `public` pseudo-domain reaches at most, regardless of whether Discord could confirm the channel `@everyone`-readable. Vouch the guild (`identity.blanketTrust: \"internal\"` or `surfaces.ts`) to unlock an explicit domain's or the org's memory.",
511
+ "- **Webhook Events *do* need a separate URL.** The one exception to \"Discord has only one public endpoint per app\": install/uninstall events (Step 1.6) are configured on the **Webhooks** tab with their own URL (`/eve/v1/discord/events`), not the Interactions Endpoint. They share `DISCORD_PUBLIC_KEY`, and they carry **no chat messages** — the feature name misleads; ambient content stays Gateway-only.",
512
+ "- **`/oe` needs no separate URL.** It rides the SAME Interactions Endpoint URL as everything else in Step 1.5 — Discord has only the one public endpoint per app, so there's nothing extra to configure beyond registering the command itself ([above](#register-oe)).",
513
+ "",
514
+ ].join("\n"),
11
515
  },
12
516
  ];
13
517
  //# sourceMappingURL=docs-generated.js.map