@open-everywhere/provider-discord 0.2.1 → 0.4.0

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 (33) hide show
  1. package/agent/channels/discord-backscroll.ts +5 -4
  2. package/agent/channels/discord-front.ts +5 -50
  3. package/agent/channels/discord-gateway.ts +10 -12
  4. package/agent/channels/discord-post-arm.ts +0 -34
  5. package/agent/channels/discord-post.ts +1 -9
  6. package/agent/docs-generated.ts +1 -1
  7. package/agent/surface-parity.ts +0 -6
  8. package/dist/agent/channels/discord-backscroll.d.ts +4 -4
  9. package/dist/agent/channels/discord-backscroll.d.ts.map +1 -1
  10. package/dist/agent/channels/discord-backscroll.js +5 -5
  11. package/dist/agent/channels/discord-backscroll.js.map +1 -1
  12. package/dist/agent/channels/discord-front.d.ts +0 -18
  13. package/dist/agent/channels/discord-front.d.ts.map +1 -1
  14. package/dist/agent/channels/discord-front.js +5 -35
  15. package/dist/agent/channels/discord-front.js.map +1 -1
  16. package/dist/agent/channels/discord-gateway.d.ts +1 -1
  17. package/dist/agent/channels/discord-gateway.d.ts.map +1 -1
  18. package/dist/agent/channels/discord-gateway.js +10 -12
  19. package/dist/agent/channels/discord-gateway.js.map +1 -1
  20. package/dist/agent/channels/discord-post-arm.d.ts.map +1 -1
  21. package/dist/agent/channels/discord-post-arm.js +0 -34
  22. package/dist/agent/channels/discord-post-arm.js.map +1 -1
  23. package/dist/agent/channels/discord-post.d.ts +2 -10
  24. package/dist/agent/channels/discord-post.d.ts.map +1 -1
  25. package/dist/agent/channels/discord-post.js +2 -9
  26. package/dist/agent/channels/discord-post.js.map +1 -1
  27. package/dist/agent/docs-generated.js +1 -1
  28. package/dist/agent/docs-generated.js.map +1 -1
  29. package/dist/agent/surface-parity.d.ts.map +1 -1
  30. package/dist/agent/surface-parity.js +0 -6
  31. package/dist/agent/surface-parity.js.map +1 -1
  32. package/package.json +2 -6
  33. package/agent/channels/discord-approval-card.ts +0 -373
@@ -76,6 +76,7 @@ import {
76
76
  type WakeMessageInput,
77
77
  type BindableContext,
78
78
  type AuthorTrustQuery,
79
+ SUPERVISOR_BACKSCROLL_REACHBACK_HOURS,
79
80
  } from "@open-everywhere/core/agent/adapter-api.js";
80
81
  import { discordAuthorRoles } from "../approvals/discord-admin.js";
81
82
 
@@ -307,12 +308,12 @@ export async function fetchDiscordBackscrollLines(
307
308
  // replies of threads hanging off it; that's the deliberate one-endpoint scope, matching the flat
308
309
  // path). FAIL-SOFT ABSOLUTELY throughout, exactly like {@link fetchDiscordBackscrollLines}.
309
310
 
310
- /** Reachback (hours) for the raw Discord window: how far back from the trigger we paginate. Shares
311
- * the SAME env + 7d default as the Slack adapter (slack-wake-backscroll.ts RAW_FETCH_REACHBACK_HOURS)
312
- * — it is one supervisor-backscroll concept, kept in sync by reading the same knob. Generous enough
311
+ /** Reachback (hours) for the raw Discord window: how far back from the trigger we paginate. It is
312
+ * the SAME core constant the Slack adapter takes (slack-wake-backscroll.ts RAW_FETCH_REACHBACK_HOURS)
313
+ * — one supervisor-backscroll concept, so one number. Generous enough
313
314
  * that the spine (the most-recent cluster, exempt from the 36h enrichment cap) still spans a
314
315
  * weekend/overnight gap, yet a hard floor bounding the fetch. */
315
- export const DISCORD_RAW_FETCH_REACHBACK_HOURS = Number(process.env.OE_SUPERVISOR_BACKSCROLL_REACHBACK_HOURS) || 168;
316
+ export const DISCORD_RAW_FETCH_REACHBACK_HOURS = SUPERVISOR_BACKSCROLL_REACHBACK_HOURS;
316
317
 
317
318
  /** `GET /channels/{id}/messages` page size (Discord's max is 100). */
318
319
  const DISCORD_RAW_FETCH_PAGE_LIMIT = 100;
@@ -33,11 +33,10 @@ import { POST, type Channel, type HttpRouteDefinition, type RouteHandlerArgs } f
33
33
  import { verifyDiscordSignature } from "eve/channels/discord";
34
34
  import { OE_DISCORD_APPROVAL_PREFIX } from "../approvals/discord-card.js";
35
35
  import { handleDiscordApprovalInteraction, type DiscordInteractionResponse } from "../approvals/discord-flow.js";
36
- import { LINK_CONSENT_CLICK_PREFIX, OE_GRANT_ACTION_PREFIX, OE_INPUT_CLICK_PREFIX } from "@open-everywhere/core/agent/adapter-api.js";
36
+ import { LINK_CONSENT_CLICK_PREFIX, OE_GRANT_ACTION_PREFIX } from "@open-everywhere/core/agent/adapter-api.js";
37
37
  import { handleDiscordLinkConsentInteraction } from "../identity/discord-link-consent.js";
38
38
  import { handleDiscordGrantCardInteraction } from "../access/discord-grant-click.js";
39
- import { handleDiscordApprovalCardInteraction, type ApprovalRespondFn } from "./discord-approval-card.js";
40
- import { approvalCardRespondVia, oeCommandName, type AttachSessionForCancel } from "@open-everywhere/core/agent/adapter-api.js";
39
+ import { oeCommandName, type AttachSessionForCancel } from "@open-everywhere/core/agent/adapter-api.js";
41
40
  import { handleDiscordOeCommandInteraction } from "../commands/discord-commands.js";
42
41
 
43
42
  /** An HTTP route handler — `RouteHandler` isn't a public eve export, so we mirror its shape
@@ -117,11 +116,6 @@ export type DiscordFrontDecision =
117
116
  /** An `oe_grant:` credential-grant card button click (core access/credential-grant-card.ts) — ours:
118
117
  * the OWNER answering a request to spend their credential (OPE-1260). */
119
118
  | { readonly kind: "grant-click"; readonly reason: string; readonly interaction: unknown }
120
- /** An `oe_input:` BRIDGED-APPROVAL card button click (core channels/approval-card.ts) — ours: the
121
- * room answering an eve `tool-approval` this deployment rendered onto a unified origin (OPE-1720).
122
- * eve's own route cannot resolve it — the parked request lives on the UNIFIED window, not on the
123
- * Discord channel's session — so forwarding it would drop the tap. */
124
- | { readonly kind: "input-click"; readonly reason: string; readonly interaction: unknown }
125
119
  | { readonly kind: "oe-command"; readonly reason: string; readonly interaction: unknown };
126
120
 
127
121
  /**
@@ -164,13 +158,11 @@ export function classifyDiscordInteraction(rawBody: string): DiscordFrontDecisio
164
158
  }
165
159
  const data = typeof p.data === "object" && p.data !== null ? (p.data as Record<string, unknown>) : undefined;
166
160
  const customId = typeof data?.custom_id === "string" ? data.custom_id : undefined;
167
- // Four OE prefixes now, and they are deliberately different strings so a log line names one
161
+ // Three OE prefixes, and they are deliberately different strings so a log line names one
168
162
  // feature: `oe-approval:` is the approvals plane, `oe-link:` is the identity-consent button
169
163
  // (OPE-622), `oe_grant:` is the credential-grant card (OPE-1260 — before it, that card's buttons
170
- // rendered here and fell through to eve, so the owner's tap was lost), `oe_input:` is the bridged
171
- // eve approval card on a unified origin (OPE-1720 — the same lost-tap shape, one plane along).
172
- // Anything else — eve's own `eve_input:` HITL buttons included — forwards untouched, and the two
173
- // spellings are one character apart on purpose: `oe_input:` is OURS and `eve_input:` is eve's.
164
+ // rendered here and fell through to eve, so the owner's tap was lost). Anything else — eve's own
165
+ // `eve_input:` HITL buttons included — forwards untouched.
174
166
  if (customId?.startsWith(OE_DISCORD_APPROVAL_PREFIX)) {
175
167
  return { kind: "approval-click", reason: "oe-approval: component click — ours, never forwarded", interaction: json };
176
168
  }
@@ -180,9 +172,6 @@ export function classifyDiscordInteraction(rawBody: string): DiscordFrontDecisio
180
172
  if (customId?.startsWith(OE_GRANT_ACTION_PREFIX)) {
181
173
  return { kind: "grant-click", reason: "oe_grant: component click — ours, never forwarded", interaction: json };
182
174
  }
183
- if (customId?.startsWith(OE_INPUT_CLICK_PREFIX)) {
184
- return { kind: "input-click", reason: "oe_input: component click — ours, never forwarded", interaction: json };
185
- }
186
175
  return { kind: "forward", reason: "component click — eve's HITL owns it" };
187
176
  }
188
177
 
@@ -211,16 +200,6 @@ export interface DiscordFrontOptions {
211
200
  raw: unknown,
212
201
  deps?: { waitUntil?: (p: Promise<unknown>) => void },
213
202
  ) => Promise<DiscordInteractionResponse | null>;
214
- /** Bridged-approval-card `oe_input:` tap handler (OPE-1720, defaults to
215
- * `./discord-approval-card.ts`). Injectable so the classification and the branch are testable
216
- * without a store, a bot token or eve's `respond` primitive. It takes the SETTLE delivery as its
217
- * second argument because `args.from` is the only place eve hands that primitive out, so it is
218
- * bound in the route and injected rather than reached for inside the handler. */
219
- readonly handleApprovalCardInteraction?: (
220
- raw: unknown,
221
- respond: ApprovalRespondFn,
222
- deps?: { waitUntil?: (p: Promise<unknown>) => void },
223
- ) => Promise<DiscordInteractionResponse | null>;
224
203
  /** "/oe" zero-inference command handler (defaults to the real commands/discord-commands.ts).
225
204
  * Injectable so tests can stub it without the spend/approval/memory machinery it may touch. */
226
205
  readonly handleOeCommand?: (raw: unknown, attachSession?: AttachSessionForCancel) => Promise<DiscordInteractionResponse | null>;
@@ -243,7 +222,6 @@ export function buildDiscordFrontHandler(innerHandler: RouteHandler, opts: Disco
243
222
  const handleClick = opts.handleApprovalInteraction ?? handleDiscordApprovalInteraction;
244
223
  const handleLinkConsent = opts.handleLinkConsentInteraction ?? handleDiscordLinkConsentInteraction;
245
224
  const handleGrantCard = opts.handleGrantCardInteraction ?? handleDiscordGrantCardInteraction;
246
- const handleApprovalCard = opts.handleApprovalCardInteraction ?? handleDiscordApprovalCardInteraction;
247
225
  const handleOeCommand = opts.handleOeCommand ?? handleDiscordOeCommandInteraction;
248
226
 
249
227
  return async (req: Request, args: RouteHandlerArgs): Promise<Response> => {
@@ -295,29 +273,6 @@ export function buildDiscordFrontHandler(innerHandler: RouteHandler, opts: Disco
295
273
  if (response) return interactionJson(response);
296
274
  }
297
275
 
298
- if (decision.kind === "input-click") {
299
- // Ours (core channels/approval-card.ts): somebody tapped a bridged eve approval this deployment
300
- // rendered onto a unified origin (OPE-1720). The handler resolves the ticket, admits the tap
301
- // through the SAME floor the typed answer takes, and delivers
302
- // `respond([{requestId, optionId}])` to the parked WINDOW session.
303
- //
304
- // 🔴 THE DELIVERY GOES BY SESSION ID (OPE-1863). `args.from` is CHANNEL-LOCAL — on this route
305
- // it addresses `discord:<token>`, while the parked session lives at `unified:<token>` — so the
306
- // settle threw "the target session was not found" and every tap left the approval parked.
307
- // `args.attachSession` dispatches by durable session id from any channel, and
308
- // `approvalCardRespondVia` is the one road every surface binds. Bound HERE and injected because
309
- // the route handler is the only place eve hands out either primitive.
310
- //
311
- // 🔴 `args.waitUntil` RIDES ALONG, for the grant branch's reason and one more of this door's
312
- // own: a settle RESUMES a parked turn, and Discord does not redeliver an interaction it never
313
- // saw answered — so a slow settle without the defer shows the tapper a red failure over an
314
- // approval that really did settle. A null means the payload carried our prefix but not a shape
315
- // the handler could read: forward rather than swallow, exactly as the three branches above do.
316
- const respondToApproval: ApprovalRespondFn = approvalCardRespondVia(args.attachSession);
317
- const response = await handleApprovalCard(decision.interaction, respondToApproval, { waitUntil: args.waitUntil });
318
- if (response) return interactionJson(response);
319
- }
320
-
321
276
  if (decision.kind === "oe-command") {
322
277
  // Ours: zero-inference — parsed, executed as pure logic (+ the approval-card path for
323
278
  // budget raise/clear), answered synchronously as an interaction response. NEVER reaches
@@ -36,9 +36,6 @@
36
36
  // dropped, and unresolvable payloads are rejected rather than guessed at.
37
37
  //
38
38
  // Env this surface reads at runtime:
39
- // OE_DM_SEAT_ROTATE_* (optional) the DM window's rotation thresholds (core channels/dm-seat.ts),
40
- // never the supervisor's. A DM window itself is unconditional since OPE-368
41
- // retired the OE_DM_SEAT_ROTATION master switch.
42
39
  // OE_RELAY_SECRET (required) HMAC-SHA256 key shared with the relay; unset → all 401.
43
40
  // DISCORD_BOT_TOKEN (required to reply) bot token for the REST create-message/thread calls.
44
41
  // discord.autoThread (optional, default ON) a mention in a base channel opens a thread and
@@ -158,7 +155,7 @@ import {
158
155
  consumeRepliedThisTurn,
159
156
  dropClosingProse,
160
157
  isAllowedDriver,
161
- parseDriverAllowlist,
158
+ configuredDriverAllowlist,
162
159
  // THE DM SEAT (OPE-356) — the same core mint/touch/flag every first-classed DM window uses; the
163
160
  // Slack front route reaches for exactly these three.
164
161
  getOrCreateDmSeat,
@@ -558,7 +555,7 @@ export function authForRelayMessage(msg: DiscordRelayMessage): OpenEverywhereDis
558
555
  // real agent. Fail-closed default-off (no env → unconditional drop, exactly as
559
556
  // before); every acceptance is logged so an armed seam is never invisible.
560
557
  if (!isAllowedDriver(msg.guildId, msg.author.id)) return null;
561
- log(`e2e driver accepted as user: bot ${msg.author.id} in guild ${msg.guildId} (OE_TEST_DRIVER_IDS)`);
558
+ log(`e2e driver accepted as user: bot ${msg.author.id} in guild ${msg.guildId} (wake.driverAllowlist)`);
562
559
  }
563
560
  return buildDiscordAuth({
564
561
  userId: msg.author.id,
@@ -575,7 +572,7 @@ export function authForRelayMessage(msg: DiscordRelayMessage): OpenEverywhereDis
575
572
 
576
573
  /**
577
574
  * Is this bot-authored relay message's author id a KNOWN e2e-driver id (OPE-647) — pinned to
578
- * SOME guild in `OE_TEST_DRIVER_IDS` — but NOT to this message's own guild? `authForRelayMessage`
575
+ * SOME guild in the `wake.driverAllowlist` setting — but NOT to this message's own guild? `authForRelayMessage`
579
576
  * already refuses the message either way: `isAllowedDriver` is guild-pinned by format
580
577
  * (`guildId:botUserId`), so a driver id posting outside its pinned guild never passes. This
581
578
  * classifier exists only to tell that refusal APART from routine "some unrelated real bot
@@ -586,7 +583,7 @@ export function authForRelayMessage(msg: DiscordRelayMessage): OpenEverywhereDis
586
583
  export function isMisplacedDriver(
587
584
  guildId: string | undefined,
588
585
  botUserId: string,
589
- allowlist: ReadonlySet<string> = parseDriverAllowlist(process.env.OE_TEST_DRIVER_IDS),
586
+ allowlist: ReadonlySet<string> = configuredDriverAllowlist(),
590
587
  ): boolean {
591
588
  if (!botUserId) return false;
592
589
  if (guildId !== undefined && allowlist.has(`${guildId}:${botUserId}`)) return false; // pinned HERE — ordinary admission, not a misplacement
@@ -653,8 +650,8 @@ export type DiscordExternalVerdict = "proceed" | "withheld-addressed" | "withhel
653
650
  * `getConfig()` call on this path sits BELOW the DM early-return, so a DM never touched the config
654
651
  * registry before this feature existed; making the policy the first thing that does would let an
655
652
  * unregistered config take DMs off the air. "Could not read the domain registry" means "no domain
656
- * override", which resolves to the shipped org default — today's behaviour, which is the only safe
657
- * answer. A genuinely broken config is still loud everywhere else it is read.
653
+ * override", which resolves to the shipped org default (strict since 2026-10-10), which is the only
654
+ * safe answer. A genuinely broken config is still loud everywhere else it is read.
658
655
  */
659
656
  function externalPolicyForRoom(roomKey: string): ExternalPolicy {
660
657
  try {
@@ -1539,7 +1536,8 @@ export function discordSingleWindowPrivacyOk(
1539
1536
  ): boolean {
1540
1537
  if (!input.guildId) return false; // DM/group-DM: NEVER, unconditionally (no tenant boundary)
1541
1538
  const vouched = deps.vouched ?? isVouchedInternal;
1542
- const trustEnv = "trustEnv" in deps ? deps.trustEnv : process.env.OE_TRUST;
1539
+ // The blanket is the `identity.blanketTrust` setting since OPE-2411 (was OE_TRUST).
1540
+ const trustEnv = "trustEnv" in deps ? deps.trustEnv : getSettings().identity.blanketTrust;
1543
1541
  if (trustEnv === "internal") return true; // the blanket internal-only-deploy switch
1544
1542
  if (vouched(input.workspaceKey, input.surfaceKey)) return true;
1545
1543
  // A thread inherits its parent channel's vouch (the domain-grain key — same widening the
@@ -2357,8 +2355,8 @@ export async function dispatchTurn(
2357
2355
  // nothing and show nothing — an affordance on a suppressed message tells the sender the agent is
2358
2356
  // reading them, which is the opposite of the intended behaviour.
2359
2357
  //
2360
- // Both grains are inert under their shipped values (`taint` / `engage`), so this whole block is a
2361
- // no-op on every deployment that has not opted in.
2358
+ // Both grains are inert only on the `taint` / `engage` opt-out, so this whole block is a no-op
2359
+ // only on a deployment that opted out. The shipped grains suppress here.
2362
2360
  // EXPLICIT ADDRESSING — one fact, read twice: the verdict owes a private notice on it, and the
2363
2361
  // OPE-885 ack keys on it. A DM is addressing by construction; `mentionsBot` is Discord's own
2364
2362
  // mention signal, which a NAME-DROP never sets.
@@ -102,34 +102,6 @@ async function liveDiscordPost(origin: DiscordOrigin, text: string): Promise<str
102
102
  return first;
103
103
  }
104
104
 
105
- /**
106
- * UNIFIED, WITH A COMPONENT PLANE (OPE-1720): the same single-window post as {@link liveDiscordPost},
107
- * carrying a row of tappable actions under it. Discord is the SECOND surface to implement this
108
- * member, and it may only do so because the tap lands: `discord-front.ts` classifies the `oe_input:`
109
- * custom_id and `discord-approval-card.ts` settles it.
110
- *
111
- * 🔴 NO AUTO-THREAD BRANCH, DELIBERATELY. `liveDiscordPost` may open a thread off the mention and
112
- * answer there. A governed approval card must not move: the people who can answer it are watching the
113
- * conversation it was asked in, and burying the buttons in a freshly-created thread is how a card
114
- * goes unanswered. The anchor still rides — the card hangs under the triggering message through the
115
- * same `message_reference` a plain reply uses — so it is placed and never moved.
116
- *
117
- * 🔴 IT FALLS BACK TO TEXT RATHER THAN SPLITTING. Discord caps a message at 2000 characters and a
118
- * button row cannot be split across messages: a row attached to the wrong part would sit under a
119
- * fragment. So an over-cap card posts through the ordinary splitting path and loses the SHORTCUT,
120
- * never the meaning — `DirectMessageAction`'s own rule, which the question bridge's text already
121
- * satisfies (it prints the prompt, the options, and the line saying a reply may name one).
122
- */
123
- async function liveDiscordPostWithActions(origin: DiscordOrigin, text: string, actions: readonly DirectMessageAction[]): Promise<string> {
124
- const buttons = actions.map((a) => ({ customId: a.id, label: a.label, intent: a.intent }));
125
- if (buttons.length === 0 || text.length > DISCORD_MESSAGE_MAX) return liveDiscordPost(origin, text);
126
- try {
127
- return await discordPostMessageWithButtons(origin.channelId, text, buttons, origin.messageId ? { replyToMessageId: origin.messageId } : undefined);
128
- } catch {
129
- throw new Error(`unified reply couldn't post to Discord — create-message with buttons failed on channel ${origin.channelId}`);
130
- }
131
- }
132
-
133
105
  /** EDIT (OPE-793): `PATCH /channels/{id}/messages/{mid}` through the same write seam the post path
134
106
  * uses. Ownership was already decided upstream against the own-post ledger; this is transport only. */
135
107
  async function editDiscordMessage(locus: EditLocus, messageId: string, text: string): Promise<void> {
@@ -199,12 +171,6 @@ export const discordPostArm: PostArm = {
199
171
  },
200
172
  dispatchReply: (target, text) => discordPostMessage((target as DiscordTarget).channelId, text),
201
173
  dispatchUnified: (origin, text, deps: UnifiedReplyDeps) => (deps.postDiscord ?? liveDiscordPost)(origin as DiscordOrigin, text),
202
- // OPE-1720: Discord declares the unified component plane because the tap route exists on it —
203
- // `discord-front.ts` classifies the `oe_input:` custom_id and `discord-approval-card.ts` settles
204
- // it. The injected `postDiscord` override is honoured VERBATIM and drops the buttons: a test that
205
- // scripted a text-only post must not start receiving a card it never asked for.
206
- dispatchUnifiedActions: (origin, text, actions, deps: UnifiedReplyDeps) =>
207
- deps.postDiscord ? deps.postDiscord(origin as DiscordOrigin, text) : liveDiscordPostWithActions(origin as DiscordOrigin, text, actions),
208
174
  describeRedriveTarget: (origin) => `discord channel ${(origin as DiscordOrigin).channelId}`,
209
175
  redriveContainerId: (origin) => (origin as DiscordOrigin).channelId,
210
176
  editMessage: editDiscordMessage,
@@ -213,13 +213,7 @@ export interface DiscordDmButton {
213
213
  * 🔴 NO SPLIT, AND THAT IS DELIBERATE. `discordPostMessage` splits an over-cap reply into several
214
214
  * messages; buttons cannot be split, and a row attached to the wrong part would sit under a
215
215
  * fragment. So this refuses past Discord's 2000-character cap rather than truncating or fanning out,
216
- * and both callers post core-owned copy: the identity consent ask, and the bridged approval card
217
- * (channels/discord-post-arm.ts), whose own caller falls back to the splitting path past the cap.
218
- *
219
- * `opts.replyToMessageId` hangs the message under the one it answers, through the same
220
- * `message_reference` the plain post path uses and with the same `fail_if_not_exists: false` degrade
221
- * when the trigger is gone (OPE-1720 — a card posted onto a unified origin must land where the
222
- * conversation is looking, exactly as its prose sibling does). ABSENT ⇒ a plain channel post.
216
+ * and its caller (the DM-delivery arm, channels/discord-post-arm.ts) posts core-owned copy.
223
217
  *
224
218
  * The bot token rides only the header, like every call in this module.
225
219
  */
@@ -227,7 +221,6 @@ export async function discordPostMessageWithButtons(
227
221
  channelId: string,
228
222
  content: string,
229
223
  buttons: readonly DiscordDmButton[],
230
- opts?: { readonly replyToMessageId?: string },
231
224
  ): Promise<string> {
232
225
  if (content.length > DISCORD_MESSAGE_MAX) {
233
226
  throw markNotApplied(
@@ -239,7 +232,6 @@ export async function discordPostMessageWithButtons(
239
232
  headers: botHeaders(botToken()),
240
233
  body: JSON.stringify({
241
234
  content,
242
- ...(opts?.replyToMessageId ? { message_reference: { message_id: opts.replyToMessageId, fail_if_not_exists: false } } : {}),
243
235
  components: [
244
236
  {
245
237
  type: COMPONENT_ACTION_ROW,
@@ -10,6 +10,6 @@ export const PROVIDER_DOCS: readonly DocEntry[] = [
10
10
  path: "docs/deploy/surfaces/discord.md",
11
11
  title: "Discord",
12
12
  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.",
13
- 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`OE_TRUST=internal` 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`).\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 `OE_TEST_DRIVER_IDS` allowlist 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` | Deploy sanity: the 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 compaction fires 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 (`OE_TRUST=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",
13
+ 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` | Deploy sanity: the 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 compaction fires 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",
14
14
  },
15
15
  ];
@@ -13,12 +13,6 @@ export const DISCORD_SURFACE_PARITY: SurfaceParityDeclaration = {
13
13
  "capability-manifest": "supported",
14
14
  "post-arm": "supported",
15
15
  "wake-origin-codec": "supported",
16
- // The arm renders a Discord action row (`discord-post-arm.ts`), the front routes the tap back
17
- // (`discord-front.ts` classifies the `oe_input:` custom_id, `discord-approval-card.ts` settles it),
18
- // and the settle DELIVERS through core's `approvalCardRespondVia` — all three halves, as the row
19
- // requires. The third is not a formality: this front bound its own `args.from(bareToken)` until
20
- // OPE-1863, and that address is channel-local, so every tap threw and the approval stayed parked.
21
- "unified-action-plane": "supported",
22
16
  // `messageId` is the `message_reference` anchor and sits outside `threadRef`, which keys on the
23
17
  // channel id alone — Slack's shape exactly. Both members are implemented; `autoThreadName` stays
24
18
  // OUT of the axis because opening a thread MOVES a post rather than placing one.
@@ -98,12 +98,12 @@ export declare function fetchDiscordBackscrollLines(input: DiscordBackscrollInpu
98
98
  readonly lines: ContextLine[];
99
99
  readonly source: string;
100
100
  } | undefined>;
101
- /** Reachback (hours) for the raw Discord window: how far back from the trigger we paginate. Shares
102
- * the SAME env + 7d default as the Slack adapter (slack-wake-backscroll.ts RAW_FETCH_REACHBACK_HOURS)
103
- * — it is one supervisor-backscroll concept, kept in sync by reading the same knob. Generous enough
101
+ /** Reachback (hours) for the raw Discord window: how far back from the trigger we paginate. It is
102
+ * the SAME core constant the Slack adapter takes (slack-wake-backscroll.ts RAW_FETCH_REACHBACK_HOURS)
103
+ * — one supervisor-backscroll concept, so one number. Generous enough
104
104
  * that the spine (the most-recent cluster, exempt from the 36h enrichment cap) still spans a
105
105
  * weekend/overnight gap, yet a hard floor bounding the fetch. */
106
- export declare const DISCORD_RAW_FETCH_REACHBACK_HOURS: number;
106
+ export declare const DISCORD_RAW_FETCH_REACHBACK_HOURS = 168;
107
107
  /**
108
108
  * The creation time (epoch ms) encoded in a Discord snowflake id: the top 42 bits are
109
109
  * `(unix_ms - DISCORD_EPOCH_MS)`. Robust — every message id carries it, so we can derive a time even
@@ -1 +1 @@
1
- {"version":3,"file":"discord-backscroll.d.ts","sourceRoot":"","sources":["../../../agent/channels/discord-backscroll.ts"],"names":[],"mappings":"AA+DA,OAAO,EAOL,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACtB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EAErB,MAAM,4CAA4C,CAAC;AAGpD,kEAAkE;AAClE,MAAM,WAAW,sBAAsB;IACrC;kGAC8F;IAC9F,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,mFAAmF;IACnF,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC;kGAC8F;IAC9F,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B;oGACgG;IAChG,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;+EAE+E;AAC/E,MAAM,MAAM,gBAAgB,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAEtG,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAC;CACpC;AA8BD;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,WAAW,EAAE,CAgBnH;AAED;;4FAE4F;AAC5F,MAAM,MAAM,gBAAgB,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC;AAE3G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,wBAAsB,wBAAwB,CAC5C,KAAK,EAAE,SAAS,WAAW,EAAE,EAC7B,GAAG,EAAE,eAAe,EACpB,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,SAAS,GAAE,gBAAqC,GAC/C,OAAO,CAAC,WAAW,EAAE,CAAC,CA8BxB;AAQD;;;;;;;;;;;;GAYG;AACH,wBAAsB,2BAA2B,CAC/C,KAAK,EAAE,sBAAsB,EAC7B,IAAI,GAAE,qBAAgC,GACrC,OAAO,CAAC;IAAE,QAAQ,CAAC,KAAK,EAAE,WAAW,EAAE,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAAC,CAqBjF;AAkBD;;;;kEAIkE;AAClE,eAAO,MAAM,iCAAiC,QAAsE,CAAC;AAYrH;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAQ5D;AAuBD;;;;;;;;;;;6CAW6C;AAC7C,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAMrE;AAiED;iFACiF;AACjF,MAAM,WAAW,4BAA4B;IAC3C,mGAAmG;IACnG,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;8DAC0D;IAC1D,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,kGAAkG;IAClG,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B;yFACqF;IACrF,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB;qDACiD;IACjD,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;GAIG;AACH,wBAAsB,4BAA4B,CAChD,KAAK,EAAE,4BAA4B,EACnC,IAAI,GAAE,qBAAgC,EACtC,WAAW,GAAE,MAAM,GAAG,OAAgB,GACrC,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAgC9B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,kCAAkC,CACtD,KAAK,EAAE,4BAA4B,EACnC,IAAI,GAAE,qBAAgC,EACtC,QAAQ,CAAC,EAAE,kBAAkB,GAC5B,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAQ7B;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,uBAAuB,CAAC,GAAG,EAAE,kBAAkB,EAAE,IAAI,GAAE,qBAAgC,GAAG,OAAO,CAAC,SAAS,iBAAiB,EAAE,CAAC,CAYpJ;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,8BAA8B,IAAI,CAAC;AAEhD;uEACuE;AACvE,eAAO,MAAM,uBAAuB,EAAE,gBAAwD,CAAC"}
1
+ {"version":3,"file":"discord-backscroll.d.ts","sourceRoot":"","sources":["../../../agent/channels/discord-backscroll.ts"],"names":[],"mappings":"AA+DA,OAAO,EAOL,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACtB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EAGrB,MAAM,4CAA4C,CAAC;AAGpD,kEAAkE;AAClE,MAAM,WAAW,sBAAsB;IACrC;kGAC8F;IAC9F,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,mFAAmF;IACnF,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC;kGAC8F;IAC9F,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B;oGACgG;IAChG,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;+EAE+E;AAC/E,MAAM,MAAM,gBAAgB,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAEtG,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAC;CACpC;AA8BD;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,WAAW,EAAE,CAgBnH;AAED;;4FAE4F;AAC5F,MAAM,MAAM,gBAAgB,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC;AAE3G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,wBAAsB,wBAAwB,CAC5C,KAAK,EAAE,SAAS,WAAW,EAAE,EAC7B,GAAG,EAAE,eAAe,EACpB,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,SAAS,GAAE,gBAAqC,GAC/C,OAAO,CAAC,WAAW,EAAE,CAAC,CA8BxB;AAQD;;;;;;;;;;;;GAYG;AACH,wBAAsB,2BAA2B,CAC/C,KAAK,EAAE,sBAAsB,EAC7B,IAAI,GAAE,qBAAgC,GACrC,OAAO,CAAC;IAAE,QAAQ,CAAC,KAAK,EAAE,WAAW,EAAE,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAAC,CAqBjF;AAkBD;;;;kEAIkE;AAClE,eAAO,MAAM,iCAAiC,MAAwC,CAAC;AAYvF;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAQ5D;AAuBD;;;;;;;;;;;6CAW6C;AAC7C,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAMrE;AAiED;iFACiF;AACjF,MAAM,WAAW,4BAA4B;IAC3C,mGAAmG;IACnG,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;8DAC0D;IAC1D,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,kGAAkG;IAClG,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B;yFACqF;IACrF,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB;qDACiD;IACjD,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;GAIG;AACH,wBAAsB,4BAA4B,CAChD,KAAK,EAAE,4BAA4B,EACnC,IAAI,GAAE,qBAAgC,EACtC,WAAW,GAAE,MAAM,GAAG,OAAgB,GACrC,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAgC9B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,kCAAkC,CACtD,KAAK,EAAE,4BAA4B,EACnC,IAAI,GAAE,qBAAgC,EACtC,QAAQ,CAAC,EAAE,kBAAkB,GAC5B,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAQ7B;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,uBAAuB,CAAC,GAAG,EAAE,kBAAkB,EAAE,IAAI,GAAE,qBAAgC,GAAG,OAAO,CAAC,SAAS,iBAAiB,EAAE,CAAC,CAYpJ;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,8BAA8B,IAAI,CAAC;AAEhD;uEACuE;AACvE,eAAO,MAAM,uBAAuB,EAAE,gBAAwD,CAAC"}
@@ -60,7 +60,7 @@
60
60
  // siblings (discord-gateway.ts `divertDiscordWakeToUnifiedWindow`). The BOOTSTRAP half — a fresh
61
61
  // generation's first wake — still carries the recorded tail rather than a platform fetch.
62
62
  // The flat path (1) continues to serve the mention/name-drop hydration on every Discord wake.
63
- import { sinceLastAgentReply, WAKE_BACKSCROLL_MAX, fetchedBackscroll, resolveWakeTrustFromContext, armedAuthorTrustTenants, catchUpSender, } from "@open-everywhere/core/agent/adapter-api.js";
63
+ import { sinceLastAgentReply, WAKE_BACKSCROLL_MAX, fetchedBackscroll, resolveWakeTrustFromContext, armedAuthorTrustTenants, catchUpSender, SUPERVISOR_BACKSCROLL_REACHBACK_HOURS, } from "@open-everywhere/core/agent/adapter-api.js";
64
64
  import { discordAuthorRoles } from "../approvals/discord-admin.js";
65
65
  // Direct bot-token GET to the Discord REST API. Mirrors discord-gateway.ts's own fetch calls
66
66
  // (`Bot <token>` header; the token is a SECRET — never logged). Fail-soft: no token / non-ok /
@@ -259,12 +259,12 @@ export async function fetchDiscordBackscrollLines(input, deps = liveDeps) {
259
259
  // bounded thread-reply fan-out to mirror (a base-channel window here does NOT reach into the
260
260
  // replies of threads hanging off it; that's the deliberate one-endpoint scope, matching the flat
261
261
  // path). FAIL-SOFT ABSOLUTELY throughout, exactly like {@link fetchDiscordBackscrollLines}.
262
- /** Reachback (hours) for the raw Discord window: how far back from the trigger we paginate. Shares
263
- * the SAME env + 7d default as the Slack adapter (slack-wake-backscroll.ts RAW_FETCH_REACHBACK_HOURS)
264
- * — it is one supervisor-backscroll concept, kept in sync by reading the same knob. Generous enough
262
+ /** Reachback (hours) for the raw Discord window: how far back from the trigger we paginate. It is
263
+ * the SAME core constant the Slack adapter takes (slack-wake-backscroll.ts RAW_FETCH_REACHBACK_HOURS)
264
+ * — one supervisor-backscroll concept, so one number. Generous enough
265
265
  * that the spine (the most-recent cluster, exempt from the 36h enrichment cap) still spans a
266
266
  * weekend/overnight gap, yet a hard floor bounding the fetch. */
267
- export const DISCORD_RAW_FETCH_REACHBACK_HOURS = Number(process.env.OE_SUPERVISOR_BACKSCROLL_REACHBACK_HOURS) || 168;
267
+ export const DISCORD_RAW_FETCH_REACHBACK_HOURS = SUPERVISOR_BACKSCROLL_REACHBACK_HOURS;
268
268
  /** `GET /channels/{id}/messages` page size (Discord's max is 100). */
269
269
  const DISCORD_RAW_FETCH_PAGE_LIMIT = 100;
270
270
  /** Max history pages to paginate — bounds a busy channel's fetch at ≤300 messages so a burst can't