@open-everywhere/provider-slack 0.5.0 → 0.6.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 (37) hide show
  1. package/agent/app-manifest.ts +37 -1
  2. package/agent/channels/slack-events.ts +13 -0
  3. package/agent/channels/slack-front.ts +75 -1
  4. package/agent/channels/slack-home.ts +40 -5
  5. package/agent/channels/slack-side-panel.ts +104 -0
  6. package/agent/docs-generated.ts +1 -1
  7. package/agent/ops.ts +3 -0
  8. package/agent/register.ts +6 -0
  9. package/dist/agent/app-manifest.d.ts +12 -0
  10. package/dist/agent/app-manifest.d.ts.map +1 -1
  11. package/dist/agent/app-manifest.js +25 -1
  12. package/dist/agent/app-manifest.js.map +1 -1
  13. package/dist/agent/channels/slack-events.d.ts +9 -0
  14. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  15. package/dist/agent/channels/slack-events.js +5 -0
  16. package/dist/agent/channels/slack-events.js.map +1 -1
  17. package/dist/agent/channels/slack-front.d.ts +14 -1
  18. package/dist/agent/channels/slack-front.d.ts.map +1 -1
  19. package/dist/agent/channels/slack-front.js +54 -1
  20. package/dist/agent/channels/slack-front.js.map +1 -1
  21. package/dist/agent/channels/slack-home.d.ts +9 -1
  22. package/dist/agent/channels/slack-home.d.ts.map +1 -1
  23. package/dist/agent/channels/slack-home.js +33 -5
  24. package/dist/agent/channels/slack-home.js.map +1 -1
  25. package/dist/agent/channels/slack-side-panel.d.ts +41 -0
  26. package/dist/agent/channels/slack-side-panel.d.ts.map +1 -0
  27. package/dist/agent/channels/slack-side-panel.js +78 -0
  28. package/dist/agent/channels/slack-side-panel.js.map +1 -0
  29. package/dist/agent/docs-generated.js +1 -1
  30. package/dist/agent/docs-generated.js.map +1 -1
  31. package/dist/agent/ops.d.ts.map +1 -1
  32. package/dist/agent/ops.js +3 -1
  33. package/dist/agent/ops.js.map +1 -1
  34. package/dist/agent/register.d.ts.map +1 -1
  35. package/dist/agent/register.js +6 -1
  36. package/dist/agent/register.js.map +1 -1
  37. package/package.json +9 -3
@@ -10,6 +10,6 @@ export const PROVIDER_DOCS: readonly DocEntry[] = [
10
10
  path: "docs/deploy/surfaces/slack.md",
11
11
  title: "Slack",
12
12
  summary: "Tag or DM Open Everywhere in your Slack workspace and it acts as an autonomous teammate — it reads the thread, does the work, posts back, and remembers what it learned, scoped correctly per channel.",
13
- markdown: "# Slack\n\nTag or DM Open Everywhere in your Slack workspace and it acts as an autonomous teammate — it reads the thread, does the work, posts back, and remembers what it learned, scoped correctly per channel.\n\nThis page is the Slack-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 workspace and channel, see [Memory](../../capabilities/memory.md). For how Slack's feature set compares to the other surfaces, see the [surface support matrix](./support-matrix.md) — Slack is the reference surface, where everything lands first.\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-workspace tier stack. Slack is a clean fit for that model — a channel or DM is a\n**surface**, and a Slack team is the natural `workspace` identity key many channels' surface\nkeys share, but nothing pools their memory automatically: a channel gets shared memory only\nvia an explicit `domain` listing it, or the `org` pseudo-domain fallback.\n\n| Open Everywhere concept | Slack thing |\n| --- | --- |\n| `org` (tenant) | your deployment (`deploymentIdentity.org` in `agent/identity/surfaces.ts`, one org per deploy today) |\n| `workspace` (identity key) | the Slack workspace / team (`team_id`) |\n| `surface` (partition key) | the individual channel or DM (`channel_id`) |\n| `mode` | `channel` (public/private channel), `dm` (1:1 IM), `mpim` (group DM) |\n| `visibility` | `public` only for a confirmed public channel; everything else `private` |\n| `trust` | `internal` for a verified in-workspace channel/DM partner; `external` for Slack Connect / shared channels or anything unverifiable — **unless the workspace is vouched**, which raises the unverifiable cases (see below) |\n\n**Visibility isn't the memory gate — trust is.** `visibility` (public vs. private) is tracked\nper channel but plays no role in whether memory shares: a **private** channel with verified\ninternal membership shares its domain's (or the org's) brain exactly like a public one does.\nWhat actually gates it is **trust** (`slackTrust`): `is_ext_shared` / `is_org_shared` /\n`is_shared` (a Slack Connect channel spanning orgs) binds `external` — read-only `public`\nmemory at most, never a domain or the org brain — and so does any channel Slack's\n`conversations.info` couldn't positively check. A confirmed in-workspace channel, private or\npublic, binds `internal`. An `app_mention` envelope carries neither bit, so the channel\nenriches both from `conversations.info`; if that call fails, the channel binds `external`\n(fail-closed) and its own visibility falls back to `private`.\n\n### The workspace vouch, and what it vouches\n\nThis deployment declares its own Slack **workspace** `internal` in\n[`agent/identity/surfaces.ts`](../../how-it-works/config-as-code.md) — one tenant-grain line, not a\nlist of rooms. A declaration counts at any rung, so the line vouches every room under the workspace:\neach channel, and each **group DM**, whose conversation id no config can name. Declare your own\nworkspace the same way, or name single channels instead if you want a narrower claim.\n\nThe vouch is a **promotion**, so it sits below Slack's own verdict and can only raise a room that\nwould otherwise be unproven:\n\n- A **Slack Connect** room (`is_ext_shared`) stays `external` — a channel and a group DM alike. The\n vouch never re-promotes a room the platform positively reports as shared.\n- A channel `conversations.info` never confirmed stays `external` too. The vouch is not a substitute\n for the check.\n- A channel you want out of the claim is declared `public` at **room grain**. The narrower entry\n wins, and on a channel `public` is a demotion that sits *above* the platform verdict. (`external`\n is not a declarable value — it is the fail-closed default of an unlisted surface.)\n\n🔴 **A group DM is demoted by evidence, never by its absence.** That is the one asymmetry with a\nchannel. An `is_ext_shared` group DM binds `external`, but a group DM Slack never confirmed binds\n`internal` where an unconfirmed channel would fail closed. So on a group DM your declaration decides\nwhenever Slack gave no positive refutation — and the room-grain carve-out cannot narrow it back,\nbecause that demotion applies to channels and no config can name an mpim's conversation id.\n\n🔴 **The vouch reaches this workspace's DMs.** A DM reads `trust` as \"is the partner internal?\", and\na workspace line matches a DM key too. A partner Slack reports as external still binds `external`.\nWhat changes is the partner with **no verdict** — nobody looked, or the `users.info` call failed (a\nmissing `users:read` scope, a rate limit, a network error) on someone who is in fact a guest or a\nConnect stranger. That case used to bind `external` and now binds `internal`. Vouch a workspace only\nif you mean every DM partner in it; name single channels instead if you do not.\n\nA DM partner that is an **app** follows the same rule as a person: your own workspace's app binds\n`internal`, an app from another workspace binds `external` unless you declared that workspace.\n\n### Enterprise Grid: declare the organisation once\n\nAn Enterprise Grid holds several workspaces, and a Slack surface key names only the workspace. So a\nGrid gets its own rung, written as one line at the enterprise id:\n\n```ts\ndefineSurface({ key: \"slack/E0GRIDID\", trust: \"internal\" }),\n```\n\nThat line vouches the workspaces under the Grid, so you do not list them one by one. A workspace you\ndid not declare and that no Grid line covers stays undeclared, and Open Everywhere reads an\nundeclared workspace as an outside one.\n\n**Every door carries the rung.** The Grid line reaches every path that decides who is a MEMBER (see\nbelow), and every path that vouches a ROOM: a channel mention, a DM, a group DM, a thread reply, a\nchannel join and a **shortcut** all resolve the same enterprise line. So you declare the Grid once\nand list no workspace under it.\n\nOpen Everywhere reads the enterprise id wherever Slack puts it on that route:\n\n- the **event envelope** (`enterprise_id` beside `team_id`) on every Events API path;\n- on the **interactivity** route, which has no envelope: the payload's top-level `enterprise`\n object, or `team.enterprise_id`. Slack sends the first for an org-wide install and the second for\n a per-workspace install on a Grid, so both are read;\n- an `enterprise_id` **form field** on the `/oe` slash-command route.\n\nThe `/oe` command is narrower than the wake doors, and only here: it carries the rung into your\ndeclaration, but it cannot make a **channel** command's room internal, because a slash-command\npayload carries no `conversations.info` verdict and that check fails closed. Nothing about a\nmention, a DM or a shortcut in the same room changes.\n\n`preflight` may still report a Grid line as a **dead declaration**. That is the check's own data,\nnot a gap in the rung: it folds traffic from audit rows, and a row names a workspace and a room with\nno rung above them, so a working Grid line matches none of them. Do not delete the line on that\nadvice — the check says so itself.\n\nThe enterprise is the **lowest** rung, so anything narrower wins. `slack/E0GRIDID` internal plus\n`slack/T0SALES` public keeps that one workspace's **rooms** out; a room-grain `public` entry keeps\none room out. A `public` enterprise line does not demote a workspace you declared internal beneath\nit.\n\n🔴 **The carve-out does not reach a workspace's own members.** The member half answers \"is this the\nworkspace the message arrived in?\" before it reads your declarations, so a Sales person writing in a\nSales room is a member however you declared `slack/T0SALES`. The `public` line demotes that person\nonly where they write in **another** workspace, and it demotes every Sales room on both halves. To\nkeep a workspace's people out of the member set, do not declare the Grid above them.\n\n🔴 **It fixes who counts as a member, and that is the point of the rung.** On a Grid a person's home\n`team_id` is their own workspace, not the workspace the message sits in. Without the enterprise line\nevery Grid colleague reads as an outside author: their message marks the turn a non-member turn, their\nwords render inside the untrusted fence, and the member plane refuses their personal credential. With\nthe line they read as members. A workspace under a **different** Grid, or under no declaration, is\nstill an outside author.\n\nThe member half needs no extra scope and no configuration beyond the line above: Open Everywhere\nreads the author's own workspace and Grid from the `users.info` lookup it already pays for, so it\nholds on every ingress, the channel-mention path included. A single-workspace install has no\nenterprise id, declares nothing at that rung, and behaves exactly as before.\n\n**What this arms, and what stays off.** The [member plane](../configuration-reference.md)\n(`connectors.memberPlane`) refuses any room the operator never declared internal. It is consulted at\none place — the mint of a member's own connector credential on a non-DM surface — so without this\nline that mint would refuse in every Slack room, while ordinary Slack wakes carried on unaffected.\nThe plane itself **ships dark on this deployment**: the setting keeps its shipped default of `false`,\nand the gate refuses on that first. Declaring the workspace removes the config blocker; it does not\narm the plane.\n\n### Admin keys on a Grid\n\nAn admin key on Slack names the workspace and the person: `slack/T…/U…`. On a Grid, you can write\nthe Grid id in the workspace slot instead (OPE-2400):\n\n```ts\ndefineDomain({ name: \"org\", match: \"org\", memory: \"org-brain\", admins: [\"slack/E0GRIDID/U0EXUSER002\"] }),\n```\n\nThat one key gives the person admin standing in every workspace under the Grid. Slack issues one\nmember id for the whole Grid, so the key names one person. Slack's own admin pages show the `E…` id,\nso you do not need to find the person's `T…` workspace id.\n\nThe rung works as it does for trust. Open Everywhere reads the Grid id from the delivery itself, in\nthe same places listed above. It checks the workspace key first and the Grid key last. A Grid key\nmatches only where the delivery named that Grid. A delivery from a workspace under another Grid, or\nunder no Grid, does not match it. A workspace key still never reaches a sibling workspace. An admin\nroster only grants standing, so no narrower line can take standing away: a workspace key and a Grid\nkey for one person both grant, and either one is enough.\n\nThe Grid key reaches the admin tools, the approval-card click (an admin-tier card), the admin\nbypass of a spend cap, the wake trust tier on the Slack event doors, and the admin memory reads.\n🔴 **Three readers do not use the Grid key.** There, a Grid key grants nothing, which is the safe\ndirection:\n\n- the governance-channel floor, which needs every member of the room to be an admin. It holds only\n a channel key, and a channel key names no Grid, so it counts a Grid admin as a plain member and\n the floor stays unarmed;\n- the ✋ stop reaction on work that another person started. The reaction door reads the Grid id for\n its tenant fence, but this change does not pass it on to the stop;\n- the trust grade of quoted messages in backscroll (the bootstrap read, the catch-up read and the\n name-drop read). Each one grades a Grid-only admin as a member, so all three agree.\n\nFor a person who needs one of these, add the workspace key `slack/T…/U…` as well.\n\n## Prerequisites\n\n- **Admin** (or app-install rights) on the target Slack workspace.\n- A reachable Open Everywhere instance — deployed (public HTTPS URL) or local with a tunnel. See the [docs index](../../README.md) to get the project and the [setup runbook](../setup-runbook.md) for the deployment steps (env, access config, safety rules).\n- Your instance's public base URL. The Slack webhook route is **`/eve/v1/slack`**, so Slack calls `https://<your-deploy-url>/eve/v1/slack`.\n\n## The short way: `/setup` on the deployment\n\n**If your deployment is already up, open `https://<your-deploy-url>/setup` and skip Steps 1 and 2.**\nThat page does the whole install for you (OPE-2231):\n\n1. It asks for a **one-time setup code**. The deployment logs the code on every start while Slack is\n not installed — read it with `vercel logs` or `docker logs`. The log line names the absolute page\n URL, `https://<host>/setup`, on the host the page will write into the Slack app. This is the\n page's only authentication, so whoever can read the deployment's logs can run setup.\n2. You paste **one Slack app configuration token** (<https://api.slack.com/apps> → *Your App\n Configuration Tokens*; it needs `app_configurations:write` and lasts about 12 hours). The page\n calls `apps.manifest.create` with the manifest below and your deployment's own host, so no URL is\n typed by hand. The screen names that host. It also asks the host for its own `/setup` with no\n login. On a 401, a 403 or a redirect, the screen warns that Slack cannot reach the host and\n names `OE_PUBLIC_BASE_URL`, because Slack sends its requests with no login. The configuration\n token is used once and never stored.\n3. You click **Install to Slack**. The OAuth callback stores the bot token. Slack's manifest has no icon field, so set the app icon by hand: **Basic Information** → **Display Information** → **App icon**, and upload [`brand/png/mark-square-1024.png`](../../../brand/png/mark-square-1024.png). 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.\n4. The last screen gives you the **two config edits**, already filled in with your workspace and user\n ids — the vouch in `agent/identity/surfaces.ts` and `admins` in\n `agent/memory/config/domains/org.ts`. DM the bot and ask it to commit them, or make them by hand.\n\nEvery credential Slack *issues* goes **browser → Slack → your deployment's encrypted credential\nstore**: the client secret, the signing secret and the bot token are never seen by a human and never\nreach a clipboard or a hosting dashboard. The one value you do paste is the short-lived app\nconfiguration token, which `/setup` uses once and never stores. `SLACK_BOT_TOKEN` and\n`SLACK_SIGNING_SECRET` do not need to be set at all — Step 2 below becomes unnecessary.\n\n🔴 **The page locks for good at the first successful install**, and answers `404` from then on. An org\nadmin reopens it with the `reopen_slack_setup` control call (see\n[the control plane](../../security/control-plane.md)); a reopen keeps every stored credential, so the\nagent stays on Slack while you walk the page again.\n\n🔴 **If `SLACK_BOT_TOKEN` or `SLACK_SIGNING_SECRET` is set in the environment, `/setup` answers 404.**\nThe resolver is env-first — deliberately, so that landing this page cannot change which Slack app a\nworking deployment speaks as — which means a run on such a deployment would store credentials nothing\nwould ever read. Rather than let you complete a run that changes nothing, the page refuses. To move an\nexisting deployment onto the credential store, unset both variables and redeploy; `/setup` then opens\nand the deployment logs a setup code on that start.\n\nThis is also why an existing deployment does **not** get an open setup page when it upgrades: it is\nconfigured by environment variable, so the page is closed from the start.\n\nThe rest of this page is the **manual path** — still fully supported, and the one to use before the\nfirst deploy (the page needs a running deployment to create an app that points at it).\n\n## Public exposure on a custom domain\n\nA deployment on a public host exposes two things to any caller that knows the host. Know both, and\nclose what you do not need.\n\n**Vercel Standard Protection leaves a production custom domain public.** It puts a Vercel login in\nfront of every other domain of the project. Check the protection your project's dashboard shows.\nWith Standard Protection, the status route and `/setup` answer anyone on a production custom domain.\nThe reverse case breaks setup: when the host that `/setup` writes into the Slack app sits behind the\nlogin, Slack's requests never reach the deployment. `/setup` warns about that on screen 2. The host\ncomes from `OE_PUBLIC_BASE_URL`, else `VERCEL_PROJECT_PRODUCTION_URL`, else `VERCEL_URL`. Set\n`OE_PUBLIC_BASE_URL` to the public custom domain to choose it.\n\n**1. The status route, `GET /eve/v1/oe/status`.** It is public with no setting to turn it off. It\nnames the exact core and eve versions and the contract verdicts, and its Slack `installed` field\nreads `no` while `/setup` can be open. It carries no secret, id or content. To hide it, exclude\n`channel/oe-status` in `roster.config.ts`, or deny every path that starts with it in the Vercel\nFirewall. The [deployment contract](../contract.md#hiding-the-status-route) gives both recipes.\n\n**2. The `/setup` window.** The page is open from the first start with a valid\n`OE_CREDENTIAL_ENCRYPTION_KEY` until the first Slack install, unless `SLACK_BOT_TOKEN` or\n`SLACK_SIGNING_SECRET` is set in the environment. It answers 404 while the key is unset. In that\nwindow, anyone who can read the deployment's logs can claim the page: every member of the Vercel\nteam, and every reader of a log drain. The code is 128 bits, so a guess does not work. A claim lets\nthe holder create a Slack app in their own workspace that points at this deployment, and lock\n`/setup`. That workspace is not declared in `agent/identity/surfaces.ts`, so the agent treats it as\nan outside workspace. Recover with the `reopen_slack_setup` control call. To keep the window short:\n\n- Set `OE_CREDENTIAL_ENCRYPTION_KEY` on the day you install Slack, if the deployment needs no stored\n credential before then. Then run `/setup` in the same sitting.\n- If the deployment will never use Slack, exclude `channel/slack-setup-page` under `providers.slack`\n in `roster.config.ts` (then `npx oe build --accept`), or deny every path that starts with `/setup`\n at the edge. That prefix covers `/setup/` and `/setup/callback` too.\n- After the install, `/setup` answers 404 for good, until a `reopen_slack_setup`.\n\n## Step 1 — Create the Slack app\n\n1. Go to <https://api.slack.com/apps> → **Create New App** → **From scratch**. Name it (e.g. `Open Everywhere`) and pick the target workspace. Under **Basic Information** → **Display Information** → **App icon**, upload [`brand/png/mark-square-1024.png`](../../../brand/png/mark-square-1024.png), the project mark. 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. **OAuth & Permissions** → **Bot Token Scopes**, add:\n - `app_mentions:read` — receive `@mention` events.\n - `chat:write` — post replies, and edit the agent's own posts (`edit_reply` uses `chat.update`,\n which needs no scope beyond this one).\n - `chat:write.customize` (optional) — vary the bot's display NAME per message (`chat.postMessage`'s\n `username` param). Powers the worker-reply TIER signal: a worker that posts into the room\n itself shows as \"\\<agent name\\> [task]\" instead of the bare agent name, so its reply is\n visually distinct from the ambient supervisor's (see\n [Agent & loop](../../how-it-works/agent-and-loop.md)). **Nothing renders it today** — the\n bracket is keyed on the `task` grain, and no shipped route mints that grain: a worker reports\n back through the seat and posts nothing of its own. Grant this scope only if you run a\n deployment whose workers post directly. **Scope optional, graceful fallback:**\n omit it and every reply still posts, under the bot's default name — the agent detects a\n rejected `username` and retries the SAME post without it, so a missing scope costs only the\n tier signal, never the reply itself.\n - `files:write` — upload file attachments (the `post_file` tool — charts, CSVs, documents a\n delegated worker posts into the thread). Omit this and every file post fails with Slack's\n `missing_scope`; the agent reports the blocker instead of delivering the file.\n - `files:read` — download user-uploaded attachments (the `describe_image` tool fetches an\n image's `url_private`, which Slack only serves to an app with this scope). Omit it and\n image reads fail; text replies are unaffected.\n - `channels:history` — read public-channel messages in the thread it's working.\n - `channels:read` — **positively confirm a public channel's identity** (`conversations.info`:\n shared-ness, member count, name/topic; also backs `conversations.members`). This is the\n channel half of the trust gate: every non-DM message enriches its trust/visibility from\n `conversations.info`, and a failed call binds the channel `external` (fail-closed) —\n silently, with no error naming the missing scope. Also needed for channel-join intros and\n the governance-channel roster.\n - `groups:read` — the same as `channels:read`, for **private** channels — needed for\n `conversations.info`/`conversations.members` on a private channel exactly the way\n `channels:read` covers a public one.\n - `im:history` — read DM messages (required for DM support).\n - `im:read` — **the DM half of `channels:read`.** `conversations.info` / `conversations.members`\n on a `D…` (DM) conversation need this scope; `channels:read` and `groups:read` do **not** cover\n an `im`. Two paths ask that question about a DM: the **approval clicker floor** (a member-tier\n approve/reject click resolves the room's roster before it executes) and **card disclosure**\n (the same roster question at propose time, before a card is posted). Omit it and both Slack\n calls fail with `missing_scope`, the roster comes back unresolved, and the clicker floor's\n beneficiary leg — the one guarding actions whose result is handed to the clicker, e.g. minting\n a webhook signing secret — **refuses the click**, blaming an unreadable roster. Ordinary DM\n chat still works; it is approvals *in DMs* that break.\n - `mpim:read` — the same, for **group DMs**, and it carries one more job than its 1:1 twin.\n `conversations.info`/`conversations.members` on an `mpim` need their own scope, and an approval\n card or a click can land in a group DM exactly as it can in a 1:1. Beyond that, an `app_mention`\n event carries no channel type, so this is the **only** way the agent can tell that an\n `@mention` landed in a group DM rather than a channel — and that is what routes the mention into\n the group DM's own standing session. Without the scope, `@mention`s in group DMs still get\n answered, but from a separate per-thread session that cannot be woken when delegated work\n finishes; plain messages in the group DM are unaffected.\n - `im:write` — **open a DM the agent starts.** Two features need it. The governance DM-delivery\n arm: on a workspace with no governance channel, an org-scope admin read asked in an ordinary\n room is delivered to the requesting admin's DM instead of rendering there (see\n [Access & scoping](../../how-it-works/access-and-scoping.md)). Omit it and that delivery fails\n closed — the agent refuses and posts nothing; it never falls back to answering in the room.\n And **global shortcuts** (Step 9 below): a global shortcut names no conversation, so the agent\n opens the invoker's own DM to wake in. Omit the scope and a global shortcut acks and wakes\n nothing, with the reason in the deploy log. Message shortcuts do not need it.\n - `commands` — **register the `/oe` slash command and the shortcuts.** Slack's own description\n is *\"Add shortcuts and slash commands that people can use\"*. It grants no read access; it is\n what lets the app declare those entry points at all. Omit it and both the shortcuts (Step 9\n below) and the `/oe` command ([Slash commands](#slash-commands-oe-zero-inference-control-plane),\n set up in its own section further down) are unavailable to install.\n - `groups:history` — read private-channel messages (if you'll use it in private channels).\n - `reactions:write` — set the 👀 (working) / 🛑 (blocked) status reaction on the thread root, and the `react` tool's model-chosen reactions.\n - `reactions:read` — **ingest inbound reactions.** Lets the agent receive `reaction_added` / `reaction_removed` events (Step 1.7) so a 👍 / 👎 on one of *its own* replies is recorded as lightweight human feedback. Omit this and reaction ingestion is silently off.\n - `pins:read`, `pins:write` — read pinned standing instructions on first onboard and maintain the pinned in-flight status message.\n - `mpim:history` — read group-DM messages (only if you'll use group DMs).\n - `users:read` — resolve a Slack user id to their profile (`users.info`). Backs the bot's own\n display name and the name-drop wake's bot-name check, and — the security-relevant one — the\n **DM-partner trust check**: every DM enriches its trust from `users.info` (is the partner a\n full member, a guest, a Connect stranger?), and a failed call leaves it unverified, which the\n binding treats as `external` (fail-closed). Omit this and **every DM binds `external`**\n (read-only `public` memory at most) on a fresh install, with no error pointing at the cause —\n `users.info` just fails quietly and the binding falls back to its safe default. 🔴 **A vouched\n workspace inverts that fallback**: the deploy's own declaration then supplies `internal` for the\n partner nobody could check, so the missing scope opens the shared read layer instead of closing\n it (see [the workspace vouch](#the-workspace-vouch-and-what-it-vouches)). The same scope\n also backs the workspace roster (`users.list`) that [identity\n linking](../../how-it-works/identity.md) searches to name a person's other account; without it\n that search finds nothing and linking falls back to its code flow, which still works.\n - `canvases:write` — **the domain memory canvas.** The agent shows each domain's memory as the\n channel canvas of the domain's internal channels, and rewrites it when that memory changes (see\n [Memory you can see](../../capabilities/memory.md#memory-you-can-see)). Omit it and each refresh\n fails with `missing_scope`, which the deploy log and a `memory.canvas_refresh` audit row record\n per channel. Memory itself is unaffected. A workspace on Slack's free plan has no channel\n canvases, and the refresh fails there in the same way.\n > **Already installed?** Adding a scope to the app does **not** retrofit an existing\n > installation — an app installed before this list grew still holds only the scopes it was\n > granted at install time, and its `xoxb-` token keeps working, so nothing looks broken. After\n > adding a scope you must **reinstall the app to the workspace** (OAuth & Permissions →\n > *Reinstall to Workspace*) and re-approve, then re-copy the Bot User OAuth Token if it changed.\n > Until you do, approvals in DMs keep failing on the unreadable roster described above.\n\n3. **Install to Workspace** (top of OAuth & Permissions). Approve the scopes.\n4. Copy the **Bot User OAuth Token** (`xoxb-…`) — this is `SLACK_BOT_TOKEN`.\n5. **Basic Information** → **App Credentials** → copy the **Signing Secret** — this is `SLACK_SIGNING_SECRET` (used to verify every inbound webhook).\n6. **Event Subscriptions** → toggle **On**. Set the **Request URL** to:\n\n ```\n https://<your-deploy-url>/eve/v1/slack\n ```\n\n Slack sends a one-time `url_verification` challenge; your instance must be running and reachable so it can echo the challenge back (see [Gotchas](#gotchas)).\n7. Under **Subscribe to bot events**, add:\n - `app_mention` — wake on `@Open Everywhere …` in a channel.\n - `message.im` — wake on a direct message.\n - `message.channels` — **reply-without-re-tagging in public channels.** After you `@mention`\n the agent in a thread it stays *engaged* and picks up your follow-up replies without a new\n mention (until you ask it to stop — see *Stopping it*, below). Omit this and the agent only ever responds\n to explicit mentions — the passive engagement feature is silently off. Engaged replies are\n **judged, not automatic**: the agent reads every reply in the thread but stays silent when\n people are talking among themselves — it responds only when a message is addressed to it or\n moves its task forward. An `@mention` always guarantees a response. The model has two ways to\n go quiet on a judged turn: the legacy `NO_REPLY` text convention, or the `no_reply` tool (a\n closed reason, audited — prefer it; both still work). In a busy thread it can also choose a\n single emoji `react` instead of a text reply — on a message that did **not** address it, a\n react can be the whole response. A message that **did** address it (an `@mention` or a DM) is\n always owed one short reply; there the react only acknowledges.\n - `message.groups` — the same for **private** channels (needs `groups:history`).\n - `message.mpim` — **group DMs** (needs `mpim:history`). Not the same as the two above: a group\n DM is treated as DM-family, so **every** message in it wakes the agent, with no engagement or\n name-drop gate in front — the same rule as a 1:1 DM. It still *judges* whether to answer (a\n group DM holds other people, who talk to each other), and it stays silent when nothing was\n asked of it. Omit this event and the agent only hears `@mention`s in group DMs.\n - `member_joined_channel` — **intro on invite.** When someone `/invite`s the agent into a\n channel it introduces itself once, fitted to the channel (see\n [Channel-join intros](#channel-join-intros-default-on-fail-closed)). Omit this and the\n agent stays silent until its first mention, which then carries the intro instead.\n The same event, for a PERSON joining, re-runs the domain memory canvas gate: when the newcomer\n is a guest, the agent replaces its own canvas in that channel with a notice that holds no memory.\n - `reaction_added`, `reaction_removed` — **reaction ingestion (feedback signal).** A 👍 / 👎\n on one of the agent's own replies is recorded as lightweight human feedback (and every\n reaction emits anonymous volume telemetry); reactions on *other* messages are counted but\n not stored. Needs the `reactions:read` scope from Step 2. Omit these and the feature is\n silently off — nothing else changes.\n - `app_home_opened` — **the Home tab page.** When a person opens the agent's Home tab, the agent\n publishes their private page: what it remembers about them, with Edit, Forget and Export, and\n what their domains know (see [Memory you can see](../../capabilities/memory.md#memory-you-can-see)).\n The event needs no scope. Omit it and the Home tab shows Slack's empty placeholder.\n - `channel_shared` — **take a memory canvas down when its channel is shared.** When a channel\n becomes Slack Connect shared, the agent re-runs the memory canvas gate there at once and replaces\n its own canvas with a notice that holds no memory. Needs `channels:read` and `groups:read` from\n Step 2. Omit it and the canvas comes down at the next memory write to the domain instead.\n - `user_change` — **automatic offboarding.** When a workspace admin deactivates a member or a\n guest, OE confirms it with `users.info` and revokes that person's link-set and stored OAuth\n credentials (see [Identity](../../how-it-works/identity.md), *Automatic offboarding*). Needs\n the `users:read` scope from Step 2. Omit it and offboarding stays the admin's manual\n `revoke_link_set`.\n The generated manifest (`/setup`, `surface_manifest`, `npx @open-everywhere/create`) subscribes\n every event in this list and requests each event's scope.\n Then **App Home** → **Show Tabs** → turn the **Messages Tab** on, and tick *Allow users to send\n Slash commands and messages from the messages tab*. Without it, a person who opens a DM with the\n agent sees *\"Sending messages to this app has been turned off\"*, and `message.im` never fires.\n Turn the **Home Tab** on too, for the page above. The generated manifest sets all three\n (`features.app_home`).\n8. **Interactivity & Shortcuts** → toggle **On**. Set the **Request URL** to the same\n `https://<your-deploy-url>/eve/v1/slack` — the same route Event Subscriptions uses. This is\n what makes the agent's **clickable buttons work**: Eve's own tool-approval prompts and\n clarifying-question options render as interactive Block Kit messages, **and** the\n admin-mutation approval cards (spend caps, connector revokes, forgetting a memory bullet —\n see [In-Slack approvals](../../security/control-plane.md#in-slack-approvals)) post their\n approve/reject buttons the same way. Both ride this one Request URL; Slack delivers every\n button click to it, and the front classifies which handler a click belongs to. Without it,\n clicks fail with *\"the app is not configured to handle interactive responses\"* while\n everything else appears to work.\n9. **Shortcuts** (same **Interactivity & Shortcuts** page, below the Request URL) → **Create New\n Shortcut**. A shortcut is a **wake**: it starts a turn the same way an `@mention` does. Add\n either or both — they answer different needs.\n - **On messages** (a *message shortcut*) — appears in a message's `…` menu. The agent wakes on\n **that message**: its text is the wake's content, and any files on it come too. Use it to hand\n the agent something somebody else wrote without re-typing or re-tagging it. In a **channel**\n the reply threads under the message; in a **DM** it lands flat in the conversation, which is\n where every DM answer lands.\n\n The message is somebody else's, and the agent is told so: the wake is attributed to **you**,\n the person who invoked the shortcut, and the message's body is wrapped as untrusted content.\n Handing a message over does not vouch for it — but it does run the turn with **your** access,\n not the message author's.\n\n Slack delivers a `message_action` even for a conversation the app is not a member of, so the\n shortcut has to answer for itself what the room is. It asks `conversations.info`, and three\n outcomes follow.\n\n - **A room the app cannot see at all** — a private channel, DM or group DM it is not in —\n answers `channel_not_found`. The shortcut then wakes nothing, because the agent must not\n route a message whose room it could not identify. For a private channel this is measured. For\n a DM or a group DM it is an INFERENCE from Slack's documentation, and the code says so\n (`appCanReadConversation` in `slack-identity.ts`): no offline tier can drive a live\n `conversations.info` against a DM the app is not in, so the answer there is unmeasured. If the\n inference is wrong, such a DM reaches the turn with its files.\n - **A public channel the app has not joined** answers, and says `is_member: false`. The\n message's **text** crosses into the turn, and that is the ruled trade: you are the\n principal, and the body is fenced as untrusted. Its **files do not** — the references are\n dropped from the wake and never registered, so the bot token downloads nothing from a room\n the agent has not joined, and the agent is told the text rather than that there is a file it\n cannot open.\n - **A room the app is in** carries the message and its files both. A shortcut on a screenshot\n surfaces the screenshot here, and only here.\n - **Global** — appears in the composer's ⚡ menu and in search. It names no conversation, so the\n agent wakes in **your own DM** with it, with the shortcut's name as the whole content. This\n one needs the `im:write` scope from Step 2 (the agent opens the DM with\n `conversations.open`); without it the shortcut acks and wakes nothing, and the deploy log says\n so.\n\n Slack asks for a **Name**, a **Short Description** and a **Callback ID** for each. The callback\n ID is yours to choose — the agent owns every shortcut this app receives, so the name you give it\n is simply the trigger name the agent is told about. Shortcuts ride the **`commands`** scope from\n Step 2, which Slack documents as *\"Add shortcuts and slash commands that people can use\"* — the\n same scope the `/oe` command uses.\n\n > **A message shortcut in a channel needs the single window.** A shortcut is *addressed*, so it\n > never goes through the engagement or name-drop judge — it takes the same road a\n > single-window `@mention` takes. On a domain that does **not** run the ambient supervisor\n > (`ambientSupervisor` off) a channel has no such road, so a message shortcut used there wakes\n > nothing and logs a warning. Message shortcuts in DMs and group DMs, and global shortcuts, work\n > on every deployment.\n >\n > A message shortcut also needs the agent to know **which kind of room** it was invoked in. If\n > `conversations.info` fails — a missing scope, or a transient Slack error — the shortcut wakes\n > nothing and logs a warning, rather than routing a DM's content into a channel session. Retry\n > the shortcut; if it keeps failing, check the `channels:read`, `groups:read`, `im:read` and\n > `mpim:read` scopes from Step 2.\n10. **Save Changes.** If Slack prompts to reinstall the app (scopes changed), do it.\n\n> **Verify the wake path after deploy.** `@mention` the agent in a public channel, let it reply,\n> then post a follow-up in that thread **without** tagging it. If it responds, engagement is live.\n> If only the mention worked, re-check that `message.channels` is subscribed and `channels:history`\n> is granted. (The engagement events all land on the same `/eve/v1/slack` Request URL.)\n\n### Optional: respond to name-drops (per domain, off by default)\n\nBy default the agent **never** joins a conversation uninvited: it responds to `@mentions`, DMs,\nand threads it's already engaged in — a top-level message that merely *says* its name is ignored.\nIf you want it to (judged, carefully) answer messages like *\"could Ada look at this?\"*, opt a\n**memory domain** in:\n\n```ts\n// agent/memory/config/domains/org.ts — one file per domain; list each in domains/index.ts\nexport const domain = defineDomain({ name: \"org\", match: \"org\", memory: \"org-brain\", nameDropWake: true }) // workspace-wide\n\n// agent/memory/config/domains/support.ts\nexport const domain = defineDomain({ name: \"support\", reach: [\"slack/T…/C…\"], nameDropWake: true, /* … */ }) // just these channels\n```\n\nAn explicit domain's setting always wins for its channels (no fall-through to `org`). The gate is\ndeliberately conservative even when enabled: **top-level** human messages in channels/private\ngroups only (a group DM never needs it — it's DM-family, so every message there wakes the agent\nanyway), the message must say one of the agent's names — the wake trigger's token set (same rule\non Discord) is:\n\n- the bot's **Slack display name** — the agent's public face on Slack, from one cached\n `users.info` lookup; rename the app and the trigger follows, nothing to configure;\n- the deployment's canonical `identity.name` setting, when set — the same name the agent\n introduces itself by on every surface;\n- any token in `wake.nameDrop.include` (configuration-reference.md), added by the operator;\n\nminus any token in `wake.nameDrop.exclude`, which wins over the other three. Matching is\ncase-insensitive and word-boundary, same as an `@mention`'s name check. The\nchannel must be positively confirmed as **not externally shared** (Slack Connect and\nunverifiable channels never trigger it), and the agent then *judges* whether a reply is actually\nwarranted — when in doubt it stays silent. If it does reply, it starts a thread on the message and\nfollows that thread from then on, exactly as if it had been mentioned.\n\nUninvited turns also **fail quietly**: until the agent has actually posted a reply, an error\n(model outage, tool failure) on a name-drop turn is logged and audited (`surface.quiet_fail`)\ninstead of posted — a turn that would have judged \"stay silent\" never spams the conversation\nwith a stack trace instead. Once it has replied it's a participant, and failures go loud like\nany engaged thread's. Every wake decision is one log line in your host's runtime logs, and the\nname-drop road writes **two audit rows of its own**:\n\n- `surface.name_drop_woke` — the road admitted the message and handed on a wake. It names the\n room (`container`), the message (`ts`), which session took the wake (`road`: the room's standing\n seat, or a fresh per-thread session) and who decided (`via`: the built-in policy, or a drop-in\n [wake arm](../../extending/drop-in-arms.md)).\n- `surface.name_drop_silent` — somebody named the agent and the road refused. `reason` is the\n refusing gate's own words: the domain never enabled `nameDropWake`, or the channel is not\n confirmed internal.\n\nA message that names nobody writes neither row — unless a drop-in wake arm adds the wake, and that\nrow carries `via: \"arm\"`. The pair is what tells a **judged** silence from a **dead** road: no row\nat all means the road never ran. Read a `surface.name_drop_woke` row with no reply as \"the road\nadmitted the wake\", not yet as \"the seat judged silence\": gates below the road can still refuse the\nturn — the tenant fence, the spend cap, and on a seated channel the seat's own stood-down marker —\nand each of those writes its own refusal row. Slack itself writes no per-turn silence row — the\ndeployment's posture is actions-only — so the silent turn's own record is the always-on\n`reply.no_reply_observed` row plus the [session\ntranscript](../../security/audit-and-provenance.md). A reply is audited as `reply.post`, the one\ncanonical row every reply route writes, whether the model called the `reply` tool or the turn's\nending text was the answer.\n\n### Channel-join intros (default on, fail-closed)\n\nBeing `/invite`d into a channel **is** an invitation — so, unlike name-drops, this needs no\nopt-in. When the bot is added to a channel it **reads up before it introduces itself**: it\nimmediately acks (\"👋 Just joined — reading up on this channel; back in a moment.\"), fetches the\nchannel's recent history (up to 50 messages) and the inviter's name, then posts an introduction\n**in a thread under the \"joined\" notice** grounded in what it read: a teammate's personal channel\ngets a person-focused hello fitted to what they've actually been working on, a team channel gets\nan \"I've read the room\" line plus 2–3 concrete offers drawn from things really discussed there,\nand a large announce-style channel (~500+ members) skips the ack and the read entirely and gets\none low-key line (\"I'll stay in the background — @mention me if I can help\") and then silence.\nAn empty or unreadable channel degrades to an intro from the name and topic alone. The fetched\nhistory is framed as untrusted channel data — something *in* the backscroll asking the bot to act\nis chatter, never a task. The intro is still a *judged* turn — the agent can choose to say\nnothing at all if even that would be noise.\n\n> **What it deliberately does *not* read:** other channels. Claude Tag's join intro searches the\n> whole workspace via per-user tokens; Open Everywhere's access model is bot-principal only, so\n> cross-channel context comes from the channel's [domain memory](../../capabilities/memory.md) —\n> if the channel is in a domain, the intro turn may take one look at that shared memory too.\n\nTwo hard limits:\n\n- **Never on externally-shared channels.** The same fail-closed gate as name-drops: the channel\n must be positively confirmed as not externally shared (Slack Connect, org-shared, and\n unverifiable channels never get an intro).\n- **Once per channel.** The join intro shares its once-only marker with the first-mention\n onboarding (Step 3 below) — whichever happens first claims the channel, so you never get both,\n and a remove/re-invite doesn't re-intro.\n\nRequires the `member_joined_channel` bot event from Step 1.7. Without it the feature is silently\noff and the intro rides the first `@mention` instead.\n\n## Step 2 — Configure the environment\n\nSet these on your instance:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-… # Bot User OAuth Token from Step 1.4\nSLACK_SIGNING_SECRET=… # Signing Secret from Step 1.5\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 Slack channel reads `team_id`, `channel_id`, `channel_type`, and `is_private` straight off each inbound event into the session auth context, and `bindSlack` derives workspace = the Slack workspace, surface = the channel, plus mode and visibility. The `deploymentIdentity.workspace` / `.surface` declaration in `agent/identity/surfaces.ts` is only the `eve dev` (no-surface HTTP) gap-filler — once Slack is wired it is ignored.\n\n## Step 3 — Invoke it\n\nOpen Everywhere wakes on an `@mention` in a channel, or on any message in a DM.\n\n1. In any channel, invite the bot: `/invite @Open Everywhere`.\n2. Mention it: `@Open Everywhere summarise the last 20 messages and post the action items`.\n3. Expected behaviour:\n - It reacts to the thread root with 👀 (seen it, working on it) and shows a rotating status verb as its typing indicator (`Tinkering…`). eve's own `Thinking...` status comes first, while the mention is admitted.\n - **Live step labels.** While a tool runs, the status line says what the agent does, in plain\n words: `Searching Notion…`, `Reading the PR…`, `Reading the channel…`. Between tool steps it\n shows the status verb again. The label is code, not model text: it comes from the tool's name\n and, for a connector, from a fixed list of vendor names. It never shows what the agent searches\n for, a page title, a URL, or anything a tool returned, so a label in a shared channel cannot\n carry another conversation's words. Only a step that needs no approval by default gets a label\n (a read, a search, running code in the sandbox, a hand-off to a declared subagent): a write\n (a pull request, a ticket, a Notion change) shows the status verb, because the label shows\n before an approval or a check decides the write. A connector op counts as a read only when\n its name says so (`list_…`, `search_…`). An operator can still require approval for such an op,\n and its label then shows while the card waits. A tool with no label shows the status verb.\n The status line is Slack's native assistant status, so it posts nothing in the thread. The reply still\n posts once, after every check. A label does tell the room which kind of tool runs, and for a\n connector, which vendor.\n - A turn that starts a task also posts eve's **task card**: one message per turn, one row per\n task, updated in place. Outside a 1:1 DM a row shows the task's name only (`Researcher`), never\n the brief the agent wrote for it, because a brief can carry content from another conversation.\n Outside a 1:1 DM, a row that waits on a person says only what it waits for (`Waiting for approval`).\n In a 1:1 DM with you, a row shows the brief. In no room does a row show the task's result: the\n answer arrives as the agent's reply.\n - On the **first** mention in a channel it posts a short intro and adopts any pinned messages as standing instructions for that surface.\n - It does the work, replies in-thread, and **removes** the 👀 — absence is the done signal; the posted reply is the result. On a failure it flips to 🛑 (blocked) instead. (✅ is deliberately left to humans — react ✅ yourself to mark something done.)\n - **Every channel reply lands in a thread.** A message that is already in a thread is answered\n in that thread. A **top-level** channel message — an `@mention` or an ambient answer alike —\n is answered in a new thread opened on that message, so a busy channel keeps one root per\n topic and the answer sits under the question. Only a post that answers no message at all (a\n [routine](../../capabilities/proactivity-and-routines.md) fire, a webhook post-back) goes to\n the channel root. A **DM** is deliberately different: its replies stay flat, because\n threading a 1:1 shatters the conversation.\n - That status reaction is code-driven and narrow by design; the model can ALSO choose its own\n reaction via the `react` tool — a bare emoji ack (e.g. 👍) instead of a low-content reply in a\n busy thread. It's a genuine alternative response, not a status indicator, and is audited like\n any other tool call.\n4. Test a DM: open a direct message with the bot and send a plain message (no `@mention` needed in a DM). It responds the same way. The DM surface's memory is isolated and `private`.\n\n### What it can read on Slack\n\n**The message that woke it carries more than the typed text.** Three parts reach the agent — four\nwhen the wake is an edit, which also carries the text as it read before the edit:\n\n- The author's own text.\n- Any file the message carries. The agent sees the reference — the id, the name, the mimetype\n and the file's Slack `url_private` — not the bytes. It reads an image with `describe_image`\n and a text file with `read_file_upload`.\n- **A quoted Slack message: a forward, a share, or a message permalink unfurl.** Slack puts\n this content beside the text, not in it. The agent sees it appended under the author's text,\n so a person can forward a message to the agent and ask about it. A quoted field the author's\n own text already carries is dropped. The unit is one whole field, so a two-line quote of which\n the author typed one line is kept intact — the repeated line stays.\n\n A plain **link unfurl** — the title and blurb Slack scrapes from a web page — is **not**\n merged on any path Open Everywhere's own parser handles. Those paths read quoted Slack\n messages only, and they are: an ambient channel message, an engaged thread, a name-drop, a\n **DM**, a **group DM**, and a mention that the unified window or a group DM owns.\n\n> ⚠️ **One `@mention` route still merges a link unfurl, and in practice it almost always does.**\n> A mention that Open Everywhere's own parser does not own is forwarded to the `eve` framework's\n> native inbound path, and that path reads every attachment — including the text Slack scrapes\n> from a linked page. Measured against our pinned eve: the scraped text is merged whenever the\n> message carries Block Kit `blocks` whose flattened text contains what the person typed — the\n> normal case for a message composed in a Slack client. Otherwise eve falls back to a length\n> comparison, which can discard the scraped text. Since you cannot tell from the outside which\n> case a given message hits, treat a linked page's title and blurb on that route as reaching the\n> model inside the member's unfenced body.\n> Open Everywhere does not control that leg today (tracked as OPE-1380).\n\n> ⚠️ **Quoted content carries the forwarder's standing, not its own.** Open Everywhere merges the\n> quoted text into the forwarder's message body. The fence keys on the **author** of that body, so\n> a forward from a vouched member arrives with the member's trust tier and is delivered unfenced —\n> the same as anything that member types. The agent is not told which words the member wrote and\n> which came from the quoted message.\n>\n> A member can therefore put another Slack author's words in front of the model under their own\n> standing. Treat the ability to forward into a channel the agent watches as equal to the ability\n> to talk to the agent. This is the same handling a direct `@mention` has always had.\n\nTwo **wake** decisions read only what the person typed: the check that drops a message as the\nduplicate of an `@mention`, and the name-drop trigger. So forwarding a message that says the\nagent's name does not wake it by name. The agent does *act on* forwarded content once it is\nawake — that is the point of the feature.\n\nOne caveat on the first of those, and it is unmeasured. We do not know whether Slack raises an\n`app_mention` for an `@mention` that sits inside a forwarded message rather than in the text the\nperson typed. We assume it does not, and Open Everywhere is built for that case: the forward\nstill reaches the agent **on any route that was already going to wake it** — a DM, a group DM,\nan engaged thread, or an ambient channel. In a plain channel with no ambient mode and no\nname-drop, a forward whose only mention sits inside the quote wakes nothing, the same as any\nother message nobody addressed to the agent. If Slack *does* raise an `app_mention`, the forward\nreaches the agent twice instead — once by the mention route and once by the wake route. A\nduplicate answer is the deliberate trade; the alternative was a forward the agent never saw at\nall on the routes that do wake.\n\nBeyond the message that woke it, the agent has three reads on Slack, all open to any member of\nthe room (membership *is* the read tier here — there is no separate grant to hand out):\n\n- `read_channel` — recent history of **the channel this turn arrived in**, pulled into the\n conversation that asked. It takes no target: the channel comes from the turn's own binding, so a\n channel the bot has not been invited to is not addressable, not merely refused.\n- `read_thread` — the same, scoped to the thread this turn arrived in.\n\n Both are fenced to the session's [memory domain](../../capabilities/memory.md) and to the\n window's own sources, and nothing else. A refused read writes a `read.refused` audit row naming\n the channel, the caller's domain and the verdict (ids only, never message content) —\n `GET /control/v1/audit?since=<iso>&actions=read.refused`.\n- `read_file_upload` — decodes a **text** attachment already posted in the room (an image\n goes to `describe_image` instead). The fetch uses the bot token and is host-pinned to\n Slack, allowlisted by file type and capped by size, and the decoded content is ingested as\n untrusted external data like any other surface text.\n\n### Personal credentials in a Slack room (the member plane)\n\nThe [member plane](../../security/credentials-and-planes.md) is the one way a person's own connector\ncredential resolves outside a 1:1 DM. Two Slack facts decide whether a room admits it.\n\n**Your declaration vouches the room, at any rung you wrote it on.** One\n`defineSurface({ key: \"slack/<team>\", trust: \"internal\" })` line covers every room in that workspace.\nA room you did not declare vouches for nobody, and the plane refuses there.\n\n**A group DM is a channel here.** It is a shared room with several people in it, so it reaches the\nsame gate a channel reaches. Nobody declares a group DM's own key, so its vouch always comes from a\nrung above it — the workspace line does the job.\n\n**Slack Connect demotes the room, whatever you declared.** A channel or a group DM that Slack reports\nas externally shared (`is_ext_shared`) stays out of the plane. A workspace-wide declaration cannot\nre-promote a room that spans another organisation.\n\n> ⚠️ **The group-DM half of that needs `mpim:read`.** Open Everywhere reads the sharing flag from\n> `conversations.info`, and on a group DM that call needs the `mpim:read` scope. Without the scope the\n> call answers nothing, Open Everywhere demotes nothing, and a Slack Connect **group DM** stays vouched\n> under your workspace declaration. Absence of an answer never demotes a room — only Slack saying so\n> does. Grant `mpim:read` (Step 1) if you use group DMs and rely on this demotion. A **channel** does\n> not have this gap: an unconfirmed channel fails closed on its own.\n\n**A guest or an outside person who WRITES marks that turn.** Open Everywhere probes the author of the\nmessage that woke it (`users.info`). A positively refused author makes the turn a non-member turn, and\nthe plane refuses a personal credential on it. **Sitting in the room marks nothing** — the mark keys\non whose words entered the conversation, not on who is present. Open Everywhere reads no roster for\nthis, and an unreadable probe refuses nobody.\n\n**The author probe walks your declaration rungs.** An author from the workspace the message sits in\nis a member. An author from a **different** workspace is a member only if you declared that\nworkspace, or the [Grid](#enterprise-grid-declare-the-organisation-once) it belongs to. This is what\nadmits an Enterprise Grid colleague, whose home workspace is a sibling of the one they are writing\nin. A guest grade still refuses on either path: `is_restricted`, `is_ultra_restricted`, `is_stranger`\nand a deleted account all mark the turn, whatever workspace the person sits in.\n\n**An app is judged by its workspace, like a person.** A bot that writes in a room is a member when it\nis your own workspace's app, and an outside author when it belongs to another workspace you have not\ndeclared. There is no separate rule for bots.\n\n## Search Slack as you\n\nThe bot sees the channels it is in, and nothing else. **\"Search Slack as you\"** lets a person let the\nagent see what THEY see: the channels and conversations the bot is not in. It can also post in their\nname. It runs on Slack's own MCP server (`https://mcp.slack.com/mcp`) with the person's own Slack\nsign-in. It is off until an admin turns it on, and the bot stays the normal route.\n\n### What a person sees\n\n- **Only in their own Slack DM with the agent.** A channel, a group DM and a DM on another platform\n never get it. Two walls hold this: the derived grant resolves in a Slack 1:1 DM only, and the\n connection itself refuses every call from anywhere else before any card or sign-in. A refusal\n writes a `connector.refused` audit row with reason `personal_surface`.\n- **Bot first, offered in the moment.** The agent answers from the bot's own Slack access first.\n When the bot cannot see what was asked, it says so and offers: \"I can only see channels I am in.\n Want me to see what you see?\" Nothing offers the connection at onboarding.\n- **The person signs in once.** Their first call posts the \"Connect my account\" card. They consent\n at Slack to the user scopes below, and their token reaches only what their own Slack account sees.\n- **Every write as them takes a card.** A post or a reply in their name posts an approval card first,\n and nothing is sent until they approve it. Reads run without a card. On a routine's fire, nobody\n is there to approve a card, so a write as them is refused: a routine permission does not lift it.\n- **It is labelled as theirs.** The connection is `slack-as-you`, described as \"search Slack as you\",\n and the agent says which answers came from their account.\n\nIt rides the host-side connector plane, never the sandbox egress plane: the DM's sandbox already holds\nthe bot's Slack key, and the firewall stamps one credential per host per session. The person's token\nlives in the OAuth vault and goes to `mcp.slack.com` alone.\n\n### Turn it on\n\n1. **Declare it** in `agent/access/mcp-servers.ts`, with your Slack app's public Client ID (Basic\n Information → App Credentials). It reuses the app the bot already runs on. Slack opens its MCP\n server to an internal app (your own Open Everywhere app is one) through that app's own OAuth\n client, with no dynamic client registration.\n\n ```ts\n import { defineMcpServers } from \"@open-everywhere/core/agent/connections/mcp-servers.js\";\n import { slackAsYou } from \"@open-everywhere/provider-slack/agent/slack-as-you.js\";\n\n export const MCP_SERVERS = defineMcpServers([slackAsYou({ clientId: \"<your Slack app's Client ID>\" })]);\n ```\n\n `tools: [\"search\", \"read\"]` makes it read-only (no `chat:write`). `people: { groups: [...] }`\n narrows who gets it, as in any declared server. Run `npx oe build` and commit the diff.\n2. **Store the Client Secret** (Basic Information → App Credentials) in the credential store:\n `npx oe secret set db:slack:oauth-client-secret`. The declaration names that ref, never the value.\n A `/setup` install already holds the secret under its own reserved `slack-app:` ref, which no\n outbound path may read, so store this copy once by hand.\n3. **Change the Slack app.** Fetch the manifest again (`surface_manifest { surface: \"slack\" }`, or the\n `/setup` page). Once the app declares the entry, it carries the `mcp:connect` bot scope, the user\n scopes below and the redirect URL `https://<host>/oauth/v1/callback`. Paste it (App Manifest → Save\n Changes) and reinstall the app if Slack asks. Then turn on **the MCP toggle in the app config's\n Agents section** — the manifest has no field for it, so it stays a click.\n\nThe user scopes, by tool group:\n\n| Tool group | User scopes |\n|---|---|\n| `search` | `search:read.public`, `search:read.private`, `search:read.mpim`, `search:read.im`, `search:read.files`, `search:read.users`, `files:read` |\n| `read` | `channels:history`, `groups:history`, `mpim:history`, `im:history`, `canvases:read`, `users:read` |\n| `write` | `chat:write` |\n\nRate limits are the Web API's own tiers.\n\n> ⚠️ **Not confirmed yet:** whether a Slack plan restricts the MCP server, whether an org admin\n> must approve the internal app before a person can consent, the exact shape of the\n> `oauth.v2.user.access` answer (a top-level `access_token` is assumed), and whether Slack accepts\n> the `scope` parameter on a token refresh. The offline tests prove the Open\n> Everywhere side with a stubbed MCP server. The live e2e scenario `S-SLACK-AS-YOU` proves the rest\n> once your test app has the toggle and the scopes.\n\n## Slash commands: `/oe` (zero-inference control-plane)\n\n`/oe` is the control-plane escape hatch (backlog item 23): pure logic, code-rendered responses,\n**no model turn** — so it answers even while a channel is over its spend cap or the model/gateway\nitself is down. Unlike `@mention`/DM, it rides its **own dedicated Request URL**\n(`/eve/v1/slack/commands`) — a brand-new route, separate from the Event Subscriptions /\nInteractivity URL (`/eve/v1/slack`) Steps 1.6–1.8 configure — so setting it up never touches the\nmention/DM/approval-card wiring above.\n\nCommands (typed as free text after `/oe`):\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` (or `/oe budget status`) | This channel's and workspace'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). | 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: resolves it from the durable turn-anchor registry and cancels it. **No model turn, nothing spent** — so it works while the channel is over its cap, which is when you are 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\nBare `/oe` (no text) defaults to help — the friendliest landing. `/oe budget` alone (no `raise`/\n`clear`) defaults to `budget status`; Discord's equivalent (`/oe`) requires picking a concrete\nsubcommand instead — see the [Discord guide](./discord.md#slash-commands-oe-zero-inference-control-plane)\nfor why that's a genuine, documented platform grammar difference, not an inconsistency.\n\n### Register the Slack app feature\n\n1. In your Slack app config, go to **Slash Commands** → **Create New Command**.\n2. **Command**: `/oe`.\n3. **Request URL**:\n\n ```\n https://<your-deploy-url>/eve/v1/slack/commands\n ```\n\n4. **Short Description**: `Open Everywhere control-plane commands (stop, status, budget) — no model turn.`\n5. **Usage Hint**: `stop | status | budget [raise <usd> | clear] | rate <n> | help`.\n6. **Save**, then reinstall the app if Slack prompts you to (a new command is a scope-adjacent change).\n\nNo extra env var is needed — the route reuses `SLACK_SIGNING_SECRET` from [Step 2](#step-2--configure-the-environment) to verify every request (the SAME `v0=` HMAC scheme Event Subscriptions uses), and `SLACK_BOT_TOKEN` to post the `raise`/`clear` approval card.\n\n## Stopping it\n\nTwo different things, two different mechanisms — and the split is not stylistic.\n\n**Stop the turn it is running now.** Either react **✋** on the thread, or run **`/oe stop`**. Both\nare decided at ingress, before any model call, so both are free and both keep working when the\nchannel is over its spend cap. The agent's host places the ✋ only where work is running, for example on the\nmessage that asked for a job, from the job's start to its settlement. So it is present and\nclickable exactly while a stop is meaningful, and never appears on a message the agent merely read. Inference cannot\ndo this job at all: the agent is the thing that is busy, so a message saying \"stop!\" queues behind\nthe very turn it would stop.\n\nBe clear-eyed about what a stop is: the turn's history rolls back to its last completed model call,\nbut **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 so, and\nnames what had already run.\n\n**Stop it following the thread.** Just tell it — \"you can stop following this\", \"we're good here,\nthanks\", whatever you'd say to a colleague. This is **judged, not pattern-matched**: the agent reads\nthe intent between turns and calls `stand_down`, which sets a durable marker on the thread. It stays\nin the channel and loses nothing; `@mention` it to pull it back in. There is deliberately no magic\nword — the regex that used to look for one disengaged on \"don't stop the deploy\" as readily as on\n\"stop\", which is the failure mode a pattern over unbounded phrasing always has.\n\nThis is a **channel-only** verb. A DM has no `@mention` to pull it back in, so `stand_down` refuses\nthere and the agent replies that it could not stop following — a marker nobody could ever clear\nwould trap the DM silent for good.\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\nexactly as well as in one with headroom. `/oe budget raise`/`clear` still go through the real\napproval-card machinery; a human's click, not the command, is what actually changes anything.\n\n## Gotchas\n\n- **Signing-secret verification.** Every inbound webhook is HMAC-verified against `SLACK_SIGNING_SECRET`. If it's wrong or unset, Slack requests are rejected and the bot stays silent — check this first if mentions do nothing.\n- **Request URL challenge handshake.** When you set or change the Event Subscriptions URL, Slack POSTs a `url_verification` challenge. Your instance must be live and reachable at that moment to echo it back, or Slack marks the URL unverified and won't deliver events. Running locally? Expose it with a tunnel (e.g. `ngrok`) and use that HTTPS URL.\n- **DMs need both pieces.** A DM only works if you subscribed to the `message.im` bot event **and** granted the `im:history` scope. Missing either means DMs are silently dropped.\n- **An approve/reject click in a DM needs `im:read` (group DM: `mpim:read`).** Both are separate\n from `im:history`, and neither is covered by `channels:read`/`groups:read`. Before a member-tier\n click executes, the approval **clicker floor** resolves the room's roster\n (`conversations.info` + `conversations.members`) to check who could see the card; in a DM that\n call is on a `D…` id. Without the scope both calls fail `missing_scope`, the roster is\n unresolved, and clicks on the kinds whose result is handed to the clicker (minting a webhook\n signing secret, say) are refused with a roster complaint — while chat in that same DM keeps\n working normally, which is what makes it confusing. If you added the scope to an app that was\n already installed, **reinstall it** — see the note in Step 1.\n- **A private channel is NOT memory-isolated by that alone — trust decides, not visibility.** A verified in-workspace private channel shares its domain's (or the org's) brain exactly like a public one; visibility (public/private) is tracked but doesn't gate sharing. What DOES gate it is `conversations.info` failing to positively confirm the channel — missing the relevant read scope means the channel binds `external` (fail-closed): read-only `public` memory at most, no domain or org brain, regardless of whether it's public or private. Grant `channels:read` (or `groups:read` for a private channel) so `conversations.info` succeeds and the channel resolves `internal` — `channels:history` is a different scope (it gates `conversations.history`, the message-read path) and granting it alone will not fix this.\n- **`/oe` has its own Request URL.** It's a separate Slack app feature (Slash Commands) from Event Subscriptions/Interactivity — pointing it at the wrong URL (or forgetting to set it at all) means `/oe` times out with Slack's generic \"dispatch_failed\" error while mentions/DMs keep working fine, since they're on a different route entirely.\n- **Slack Connect (shared) channels: it answers, with public-only memory.** A channel shared across workspaces/orgs (`is_shared` / `is_ext_shared` / `is_org_shared` on `conversations.info`) binds **external** trust: the bot still replies, but with read-only `public` memory at most — no domain brain, no org brain, no shared-memory writes (this differs from Claude's Slack agent, which refuses shared channels outright). One consequence to know: if *another* org in that shared channel runs its own agent on this codebase (or any bot), each install answers its own org's mentions independently — the bot-author drop prevents loops between them, but nothing deduplicates two humans mentioning two different bots. If you don't want the bot in shared channels at all, just don't invite it to them.\n",
13
+ markdown: "# Slack\n\nTag or DM Open Everywhere in your Slack workspace and it acts as an autonomous teammate — it reads the thread, does the work, posts back, and remembers what it learned, scoped correctly per channel.\n\nThis page is the Slack-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 workspace and channel, see [Memory](../../capabilities/memory.md). For how Slack's feature set compares to the other surfaces, see the [surface support matrix](./support-matrix.md) — Slack is the reference surface, where everything lands first.\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-workspace tier stack. Slack is a clean fit for that model — a channel or DM is a\n**surface**, and a Slack team is the natural `workspace` identity key many channels' surface\nkeys share, but nothing pools their memory automatically: a channel gets shared memory only\nvia an explicit `domain` listing it, or the `org` pseudo-domain fallback.\n\n| Open Everywhere concept | Slack thing |\n| --- | --- |\n| `org` (tenant) | your deployment (`deploymentIdentity.org` in `agent/identity/surfaces.ts`, one org per deploy today) |\n| `workspace` (identity key) | the Slack workspace / team (`team_id`) |\n| `surface` (partition key) | the individual channel or DM (`channel_id`) |\n| `mode` | `channel` (public/private channel), `dm` (1:1 IM), `mpim` (group DM) |\n| `visibility` | `public` only for a confirmed public channel; everything else `private` |\n| `trust` | `internal` for a verified in-workspace channel/DM partner; `external` for Slack Connect / shared channels or anything unverifiable — **unless the workspace is vouched**, which raises the unverifiable cases (see below) |\n\n**Visibility isn't the memory gate — trust is.** `visibility` (public vs. private) is tracked\nper channel but plays no role in whether memory shares: a **private** channel with verified\ninternal membership shares its domain's (or the org's) brain exactly like a public one does.\nWhat actually gates it is **trust** (`slackTrust`): `is_ext_shared` / `is_org_shared` /\n`is_shared` (a Slack Connect channel spanning orgs) binds `external` — read-only `public`\nmemory at most, never a domain or the org brain — and so does any channel Slack's\n`conversations.info` couldn't positively check. A confirmed in-workspace channel, private or\npublic, binds `internal`. An `app_mention` envelope carries neither bit, so the channel\nenriches both from `conversations.info`; if that call fails, the channel binds `external`\n(fail-closed) and its own visibility falls back to `private`.\n\n### The workspace vouch, and what it vouches\n\nThis deployment declares its own Slack **workspace** `internal` in\n[`agent/identity/surfaces.ts`](../../how-it-works/config-as-code.md) — one tenant-grain line, not a\nlist of rooms. A declaration counts at any rung, so the line vouches every room under the workspace:\neach channel, and each **group DM**, whose conversation id no config can name. Declare your own\nworkspace the same way, or name single channels instead if you want a narrower claim.\n\nThe vouch is a **promotion**, so it sits below Slack's own verdict and can only raise a room that\nwould otherwise be unproven:\n\n- A **Slack Connect** room (`is_ext_shared`) stays `external` — a channel and a group DM alike. The\n vouch never re-promotes a room the platform positively reports as shared.\n- A channel `conversations.info` never confirmed stays `external` too. The vouch is not a substitute\n for the check.\n- A channel you want out of the claim is declared `public` at **room grain**. The narrower entry\n wins, and on a channel `public` is a demotion that sits *above* the platform verdict. (`external`\n is not a declarable value — it is the fail-closed default of an unlisted surface.)\n\n🔴 **A group DM is demoted by evidence, never by its absence.** That is the one asymmetry with a\nchannel. An `is_ext_shared` group DM binds `external`, but a group DM Slack never confirmed binds\n`internal` where an unconfirmed channel would fail closed. So on a group DM your declaration decides\nwhenever Slack gave no positive refutation — and the room-grain carve-out cannot narrow it back,\nbecause that demotion applies to channels and no config can name an mpim's conversation id.\n\n🔴 **The vouch reaches this workspace's DMs.** A DM reads `trust` as \"is the partner internal?\", and\na workspace line matches a DM key too. A partner Slack reports as external still binds `external`.\nWhat changes is the partner with **no verdict** — nobody looked, or the `users.info` call failed (a\nmissing `users:read` scope, a rate limit, a network error) on someone who is in fact a guest or a\nConnect stranger. That case used to bind `external` and now binds `internal`. Vouch a workspace only\nif you mean every DM partner in it; name single channels instead if you do not.\n\nA DM partner that is an **app** follows the same rule as a person: your own workspace's app binds\n`internal`, an app from another workspace binds `external` unless you declared that workspace.\n\n### Enterprise Grid: declare the organisation once\n\nAn Enterprise Grid holds several workspaces, and a Slack surface key names only the workspace. So a\nGrid gets its own rung, written as one line at the enterprise id:\n\n```ts\ndefineSurface({ key: \"slack/E0GRIDID\", trust: \"internal\" }),\n```\n\nThat line vouches the workspaces under the Grid, so you do not list them one by one. A workspace you\ndid not declare and that no Grid line covers stays undeclared, and Open Everywhere reads an\nundeclared workspace as an outside one.\n\n**Every door carries the rung.** The Grid line reaches every path that decides who is a MEMBER (see\nbelow), and every path that vouches a ROOM: a channel mention, a DM, a group DM, a thread reply, a\nchannel join and a **shortcut** all resolve the same enterprise line. So you declare the Grid once\nand list no workspace under it.\n\nOpen Everywhere reads the enterprise id wherever Slack puts it on that route:\n\n- the **event envelope** (`enterprise_id` beside `team_id`) on every Events API path;\n- on the **interactivity** route, which has no envelope: the payload's top-level `enterprise`\n object, or `team.enterprise_id`. Slack sends the first for an org-wide install and the second for\n a per-workspace install on a Grid, so both are read;\n- an `enterprise_id` **form field** on the `/oe` slash-command route.\n\nThe `/oe` command is narrower than the wake doors, and only here: it carries the rung into your\ndeclaration, but it cannot make a **channel** command's room internal, because a slash-command\npayload carries no `conversations.info` verdict and that check fails closed. Nothing about a\nmention, a DM or a shortcut in the same room changes.\n\n`preflight` may still report a Grid line as a **dead declaration**. That is the check's own data,\nnot a gap in the rung: it folds traffic from audit rows, and a row names a workspace and a room with\nno rung above them, so a working Grid line matches none of them. Do not delete the line on that\nadvice — the check says so itself.\n\nThe enterprise is the **lowest** rung, so anything narrower wins. `slack/E0GRIDID` internal plus\n`slack/T0SALES` public keeps that one workspace's **rooms** out; a room-grain `public` entry keeps\none room out. A `public` enterprise line does not demote a workspace you declared internal beneath\nit.\n\n🔴 **The carve-out does not reach a workspace's own members.** The member half answers \"is this the\nworkspace the message arrived in?\" before it reads your declarations, so a Sales person writing in a\nSales room is a member however you declared `slack/T0SALES`. The `public` line demotes that person\nonly where they write in **another** workspace, and it demotes every Sales room on both halves. To\nkeep a workspace's people out of the member set, do not declare the Grid above them.\n\n🔴 **It fixes who counts as a member, and that is the point of the rung.** On a Grid a person's home\n`team_id` is their own workspace, not the workspace the message sits in. Without the enterprise line\nevery Grid colleague reads as an outside author: their message marks the turn a non-member turn, their\nwords render inside the untrusted fence, and the member plane refuses their personal credential. With\nthe line they read as members. A workspace under a **different** Grid, or under no declaration, is\nstill an outside author.\n\nThe member half needs no extra scope and no configuration beyond the line above: Open Everywhere\nreads the author's own workspace and Grid from the `users.info` lookup it already pays for, so it\nholds on every ingress, the channel-mention path included. A single-workspace install has no\nenterprise id, declares nothing at that rung, and behaves exactly as before.\n\n**What this arms, and what stays off.** The [member plane](../configuration-reference.md)\n(`connectors.memberPlane`) refuses any room the operator never declared internal. It is consulted at\none place — the mint of a member's own connector credential on a non-DM surface — so without this\nline that mint would refuse in every Slack room, while ordinary Slack wakes carried on unaffected.\nThe plane itself **ships dark on this deployment**: the setting keeps its shipped default of `false`,\nand the gate refuses on that first. Declaring the workspace removes the config blocker; it does not\narm the plane.\n\n### Admin keys on a Grid\n\nAn admin key on Slack names the workspace and the person: `slack/T…/U…`. On a Grid, you can write\nthe Grid id in the workspace slot instead (OPE-2400):\n\n```ts\ndefineDomain({ name: \"org\", match: \"org\", memory: \"org-brain\", admins: [\"slack/E0GRIDID/U0EXUSER002\"] }),\n```\n\nThat one key gives the person admin standing in every workspace under the Grid. Slack issues one\nmember id for the whole Grid, so the key names one person. Slack's own admin pages show the `E…` id,\nso you do not need to find the person's `T…` workspace id.\n\nThe rung works as it does for trust. Open Everywhere reads the Grid id from the delivery itself, in\nthe same places listed above. It checks the workspace key first and the Grid key last. A Grid key\nmatches only where the delivery named that Grid. A delivery from a workspace under another Grid, or\nunder no Grid, does not match it. A workspace key still never reaches a sibling workspace. An admin\nroster only grants standing, so no narrower line can take standing away: a workspace key and a Grid\nkey for one person both grant, and either one is enough.\n\nThe Grid key reaches the admin tools, the approval-card click (an admin-tier card), the admin\nbypass of a spend cap, the wake trust tier on the Slack event doors, and the admin memory reads.\n🔴 **Three readers do not use the Grid key.** There, a Grid key grants nothing, which is the safe\ndirection:\n\n- the governance-channel floor, which needs every member of the room to be an admin. It holds only\n a channel key, and a channel key names no Grid, so it counts a Grid admin as a plain member and\n the floor stays unarmed;\n- the ✋ stop reaction on work that another person started. The reaction door reads the Grid id for\n its tenant fence, but this change does not pass it on to the stop;\n- the trust grade of quoted messages in backscroll (the bootstrap read, the catch-up read and the\n name-drop read). Each one grades a Grid-only admin as a member, so all three agree.\n\nFor a person who needs one of these, add the workspace key `slack/T…/U…` as well.\n\n## Prerequisites\n\n- **Admin** (or app-install rights) on the target Slack workspace.\n- A reachable Open Everywhere instance — deployed (public HTTPS URL) or local with a tunnel. See the [docs index](../../README.md) to get the project and the [setup runbook](../setup-runbook.md) for the deployment steps (env, access config, safety rules).\n- Your instance's public base URL. The Slack webhook route is **`/eve/v1/slack`**, so Slack calls `https://<your-deploy-url>/eve/v1/slack`.\n\n## The short way: `/setup` on the deployment\n\n**If your deployment is already up, open `https://<your-deploy-url>/setup` and skip Steps 1 and 2.**\nThat page does the whole install for you (OPE-2231):\n\n1. It asks for a **one-time setup code**. The deployment logs the code on every start while Slack is\n not installed — read it with `vercel logs` or `docker logs`. The log line names the absolute page\n URL, `https://<host>/setup`, on the host the page will write into the Slack app. This is the\n page's only authentication, so whoever can read the deployment's logs can run setup.\n2. You paste **one Slack app configuration token** (<https://api.slack.com/apps> → *Your App\n Configuration Tokens*; it needs `app_configurations:write` and lasts about 12 hours). The page\n calls `apps.manifest.create` with the manifest below and your deployment's own host, so no URL is\n typed by hand. The screen names that host. It also asks the host for its own `/setup` with no\n login. On a 401, a 403 or a redirect, the screen warns that Slack cannot reach the host and\n names `OE_PUBLIC_BASE_URL`, because Slack sends its requests with no login. The configuration\n token is used once and never stored.\n3. You click **Install to Slack**. The OAuth callback stores the bot token. Slack's manifest has no icon field, so set the app icon by hand: **Basic Information** → **Display Information** → **App icon**, and upload [`brand/png/mark-square-1024.png`](../../../brand/png/mark-square-1024.png). 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.\n4. The last screen gives you the **two config edits**, already filled in with your workspace and user\n ids — the vouch in `agent/identity/surfaces.ts` and `admins` in\n `agent/memory/config/domains/org.ts`. DM the bot and ask it to commit them, or make them by hand.\n\nEvery credential Slack *issues* goes **browser → Slack → your deployment's encrypted credential\nstore**: the client secret, the signing secret and the bot token are never seen by a human and never\nreach a clipboard or a hosting dashboard. The one value you do paste is the short-lived app\nconfiguration token, which `/setup` uses once and never stores. `SLACK_BOT_TOKEN` and\n`SLACK_SIGNING_SECRET` do not need to be set at all — Step 2 below becomes unnecessary.\n\n🔴 **The page locks for good at the first successful install**, and answers `404` from then on. An org\nadmin reopens it with the `reopen_slack_setup` control call (see\n[the control plane](../../security/control-plane.md)); a reopen keeps every stored credential, so the\nagent stays on Slack while you walk the page again.\n\n🔴 **If `SLACK_BOT_TOKEN` or `SLACK_SIGNING_SECRET` is set in the environment, `/setup` answers 404.**\nThe resolver is env-first — deliberately, so that landing this page cannot change which Slack app a\nworking deployment speaks as — which means a run on such a deployment would store credentials nothing\nwould ever read. Rather than let you complete a run that changes nothing, the page refuses. To move an\nexisting deployment onto the credential store, unset both variables and redeploy; `/setup` then opens\nand the deployment logs a setup code on that start.\n\nThis is also why an existing deployment does **not** get an open setup page when it upgrades: it is\nconfigured by environment variable, so the page is closed from the start.\n\nThe rest of this page is the **manual path** — still fully supported, and the one to use before the\nfirst deploy (the page needs a running deployment to create an app that points at it).\n\n## Public exposure on a custom domain\n\nA deployment on a public host exposes two things to any caller that knows the host. Know both, and\nclose what you do not need.\n\n**Vercel Standard Protection leaves a production custom domain public.** It puts a Vercel login in\nfront of every other domain of the project. Check the protection your project's dashboard shows.\nWith Standard Protection, the status route and `/setup` answer anyone on a production custom domain.\nThe reverse case breaks setup: when the host that `/setup` writes into the Slack app sits behind the\nlogin, Slack's requests never reach the deployment. `/setup` warns about that on screen 2. The host\ncomes from `OE_PUBLIC_BASE_URL`, else `VERCEL_PROJECT_PRODUCTION_URL`, else `VERCEL_URL`. Set\n`OE_PUBLIC_BASE_URL` to the public custom domain to choose it.\n\n**1. The status route, `GET /eve/v1/oe/status`.** It is public with no setting to turn it off. It\nnames the exact core and eve versions and the contract verdicts, and its Slack `installed` field\nreads `no` while `/setup` can be open. It carries no secret, id or content. To hide it, exclude\n`channel/oe-status` in `roster.config.ts`, or deny every path that starts with it in the Vercel\nFirewall. The [deployment contract](../contract.md#hiding-the-status-route) gives both recipes.\n\n**2. The `/setup` window.** The page is open from the first start with a valid\n`OE_CREDENTIAL_ENCRYPTION_KEY` until the first Slack install, unless `SLACK_BOT_TOKEN` or\n`SLACK_SIGNING_SECRET` is set in the environment. It answers 404 while the key is unset. In that\nwindow, anyone who can read the deployment's logs can claim the page: every member of the Vercel\nteam, and every reader of a log drain. The code is 128 bits, so a guess does not work. A claim lets\nthe holder create a Slack app in their own workspace that points at this deployment, and lock\n`/setup`. That workspace is not declared in `agent/identity/surfaces.ts`, so the agent treats it as\nan outside workspace. Recover with the `reopen_slack_setup` control call. To keep the window short:\n\n- Set `OE_CREDENTIAL_ENCRYPTION_KEY` on the day you install Slack, if the deployment needs no stored\n credential before then. Then run `/setup` in the same sitting.\n- If the deployment will never use Slack, exclude `channel/slack-setup-page` under `providers.slack`\n in `roster.config.ts` (then `npx oe build --accept`), or deny every path that starts with `/setup`\n at the edge. That prefix covers `/setup/` and `/setup/callback` too.\n- After the install, `/setup` answers 404 for good, until a `reopen_slack_setup`.\n\n## Step 1 — Create the Slack app\n\n1. Go to <https://api.slack.com/apps> → **Create New App** → **From scratch**. Name it (e.g. `Open Everywhere`) and pick the target workspace. Under **Basic Information** → **Display Information** → **App icon**, upload [`brand/png/mark-square-1024.png`](../../../brand/png/mark-square-1024.png), the project mark. 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. **OAuth & Permissions** → **Bot Token Scopes**, add:\n - `app_mentions:read` — receive `@mention` events.\n - `chat:write` — post replies, and edit the agent's own posts (`edit_reply` uses `chat.update`,\n which needs no scope beyond this one).\n - `chat:write.customize` (optional) — vary the bot's display NAME per message (`chat.postMessage`'s\n `username` param). Powers the worker-reply TIER signal: a worker that posts into the room\n itself shows as \"\\<agent name\\> [task]\" instead of the bare agent name, so its reply is\n visually distinct from the ambient supervisor's (see\n [Agent & loop](../../how-it-works/agent-and-loop.md)). **Nothing renders it today** — the\n bracket is keyed on the `task` grain, and no shipped route mints that grain: a worker reports\n back through the seat and posts nothing of its own. Grant this scope only if you run a\n deployment whose workers post directly. **Scope optional, graceful fallback:**\n omit it and every reply still posts, under the bot's default name — the agent detects a\n rejected `username` and retries the SAME post without it, so a missing scope costs only the\n tier signal, never the reply itself.\n - `files:write` — upload file attachments (the `post_file` tool — charts, CSVs, documents a\n delegated worker posts into the thread). Omit this and every file post fails with Slack's\n `missing_scope`; the agent reports the blocker instead of delivering the file.\n - `files:read` — download user-uploaded attachments (the `describe_image` tool fetches an\n image's `url_private`, which Slack only serves to an app with this scope). Omit it and\n image reads fail; text replies are unaffected.\n - `channels:history` — read public-channel messages in the thread it's working.\n - `channels:read` — **positively confirm a public channel's identity** (`conversations.info`:\n shared-ness, member count, name/topic; also backs `conversations.members`). This is the\n channel half of the trust gate: every non-DM message enriches its trust/visibility from\n `conversations.info`, and a failed call binds the channel `external` (fail-closed) —\n silently, with no error naming the missing scope. Also needed for channel-join intros and\n the governance-channel roster.\n - `groups:read` — the same as `channels:read`, for **private** channels — needed for\n `conversations.info`/`conversations.members` on a private channel exactly the way\n `channels:read` covers a public one.\n - `im:history` — read DM messages (required for DM support).\n - `im:read` — **the DM half of `channels:read`.** `conversations.info` / `conversations.members`\n on a `D…` (DM) conversation need this scope; `channels:read` and `groups:read` do **not** cover\n an `im`. Two paths ask that question about a DM: the **approval clicker floor** (a member-tier\n approve/reject click resolves the room's roster before it executes) and **card disclosure**\n (the same roster question at propose time, before a card is posted). Omit it and both Slack\n calls fail with `missing_scope`, the roster comes back unresolved, and the clicker floor's\n beneficiary leg — the one guarding actions whose result is handed to the clicker, e.g. minting\n a webhook signing secret — **refuses the click**, blaming an unreadable roster. Ordinary DM\n chat still works; it is approvals *in DMs* that break.\n - `mpim:read` — the same, for **group DMs**, and it carries one more job than its 1:1 twin.\n `conversations.info`/`conversations.members` on an `mpim` need their own scope, and an approval\n card or a click can land in a group DM exactly as it can in a 1:1. Beyond that, an `app_mention`\n event carries no channel type, so this is the **only** way the agent can tell that an\n `@mention` landed in a group DM rather than a channel — and that is what routes the mention into\n the group DM's own standing session. Without the scope, `@mention`s in group DMs still get\n answered, but from a separate per-thread session that cannot be woken when delegated work\n finishes; plain messages in the group DM are unaffected.\n - `im:write` — **open a DM the agent starts.** Two features need it. The governance DM-delivery\n arm: on a workspace with no governance channel, an org-scope admin read asked in an ordinary\n room is delivered to the requesting admin's DM instead of rendering there (see\n [Access & scoping](../../how-it-works/access-and-scoping.md)). Omit it and that delivery fails\n closed — the agent refuses and posts nothing; it never falls back to answering in the room.\n And **global shortcuts** (Step 9 below): a global shortcut names no conversation, so the agent\n opens the invoker's own DM to wake in. Omit the scope and a global shortcut acks and wakes\n nothing, with the reason in the deploy log. Message shortcuts do not need it.\n - `commands` — **register the `/oe` slash command and the shortcuts.** Slack's own description\n is *\"Add shortcuts and slash commands that people can use\"*. It grants no read access; it is\n what lets the app declare those entry points at all. Omit it and both the shortcuts (Step 9\n below) and the `/oe` command ([Slash commands](#slash-commands-oe-zero-inference-control-plane),\n set up in its own section further down) are unavailable to install.\n - `groups:history` — read private-channel messages (if you'll use it in private channels).\n - `reactions:write` — set the 👀 (working) / 🛑 (blocked) status reaction on the thread root, and the `react` tool's model-chosen reactions.\n - `reactions:read` — **ingest inbound reactions.** Lets the agent receive `reaction_added` / `reaction_removed` events (Step 1.7) so a 👍 / 👎 on one of *its own* replies is recorded as lightweight human feedback. Omit this and reaction ingestion is silently off.\n - `pins:read`, `pins:write` — read pinned standing instructions on first onboard and maintain the pinned in-flight status message.\n - `mpim:history` — read group-DM messages (only if you'll use group DMs).\n - `users:read` — resolve a Slack user id to their profile (`users.info`). Backs the bot's own\n display name and the name-drop wake's bot-name check, and — the security-relevant one — the\n **DM-partner trust check**: every DM enriches its trust from `users.info` (is the partner a\n full member, a guest, a Connect stranger?), and a failed call leaves it unverified, which the\n binding treats as `external` (fail-closed). Omit this and **every DM binds `external`**\n (read-only `public` memory at most) on a fresh install, with no error pointing at the cause —\n `users.info` just fails quietly and the binding falls back to its safe default. 🔴 **A vouched\n workspace inverts that fallback**: the deploy's own declaration then supplies `internal` for the\n partner nobody could check, so the missing scope opens the shared read layer instead of closing\n it (see [the workspace vouch](#the-workspace-vouch-and-what-it-vouches)). The same scope\n also backs the workspace roster (`users.list`) that [identity\n linking](../../how-it-works/identity.md) searches to name a person's other account; without it\n that search finds nothing and linking falls back to its code flow, which still works.\n - `canvases:write` — **the domain memory canvas.** The agent shows each domain's memory as the\n channel canvas of the domain's internal channels, and rewrites it when that memory changes (see\n [Memory you can see](../../capabilities/memory.md#memory-you-can-see)). Omit it and each refresh\n fails with `missing_scope`, which the deploy log and a `memory.canvas_refresh` audit row record\n per channel. Memory itself is unaffected. A workspace on Slack's free plan has no channel\n canvases, and the refresh fails there in the same way.\n - `assistant:write` — **the side panel.** Slack requires it for the `app_context_changed` event\n (Step 7). That subscription is what makes Slack tell the agent which room a person is viewing\n when they write from the side panel (see [The side panel](#the-side-panel)). Omit it and the\n panel still works as a DM, without the viewed room.\n > **Already installed?** Adding a scope to the app does **not** retrofit an existing\n > installation — an app installed before this list grew still holds only the scopes it was\n > granted at install time, and its `xoxb-` token keeps working, so nothing looks broken. After\n > adding a scope you must **reinstall the app to the workspace** (OAuth & Permissions →\n > *Reinstall to Workspace*) and re-approve, then re-copy the Bot User OAuth Token if it changed.\n > Until you do, approvals in DMs keep failing on the unreadable roster described above.\n\n3. **Install to Workspace** (top of OAuth & Permissions). Approve the scopes.\n4. Copy the **Bot User OAuth Token** (`xoxb-…`) — this is `SLACK_BOT_TOKEN`.\n5. **Basic Information** → **App Credentials** → copy the **Signing Secret** — this is `SLACK_SIGNING_SECRET` (used to verify every inbound webhook).\n6. **Event Subscriptions** → toggle **On**. Set the **Request URL** to:\n\n ```\n https://<your-deploy-url>/eve/v1/slack\n ```\n\n Slack sends a one-time `url_verification` challenge; your instance must be running and reachable so it can echo the challenge back (see [Gotchas](#gotchas)).\n7. Under **Subscribe to bot events**, add:\n - `app_mention` — wake on `@Open Everywhere …` in a channel.\n - `message.im` — wake on a direct message.\n - `message.channels` — **reply-without-re-tagging in public channels.** After you `@mention`\n the agent in a thread it stays *engaged* and picks up your follow-up replies without a new\n mention (until you ask it to stop — see *Stopping it*, below). Omit this and the agent only ever responds\n to explicit mentions — the passive engagement feature is silently off. Engaged replies are\n **judged, not automatic**: the agent reads every reply in the thread but stays silent when\n people are talking among themselves — it responds only when a message is addressed to it or\n moves its task forward. An `@mention` always guarantees a response. The model has two ways to\n go quiet on a judged turn: the legacy `NO_REPLY` text convention, or the `no_reply` tool (a\n closed reason, audited — prefer it; both still work). In a busy thread it can also choose a\n single emoji `react` instead of a text reply — on a message that did **not** address it, a\n react can be the whole response. A message that **did** address it (an `@mention` or a DM) is\n always owed one short reply; there the react only acknowledges.\n - `message.groups` — the same for **private** channels (needs `groups:history`).\n - `message.mpim` — **group DMs** (needs `mpim:history`). Not the same as the two above: a group\n DM is treated as DM-family, so **every** message in it wakes the agent, with no engagement or\n name-drop gate in front — the same rule as a 1:1 DM. It still *judges* whether to answer (a\n group DM holds other people, who talk to each other), and it stays silent when nothing was\n asked of it. Omit this event and the agent only hears `@mention`s in group DMs.\n - `member_joined_channel` — **intro on invite.** When someone `/invite`s the agent into a\n channel it introduces itself once, fitted to the channel (see\n [Channel-join intros](#channel-join-intros-default-on-fail-closed)). Omit this and the\n agent stays silent until its first mention, which then carries the intro instead.\n The same event, for a PERSON joining, re-runs the domain memory canvas gate: when the newcomer\n is a guest, the agent replaces its own canvas in that channel with a notice that holds no memory.\n - `reaction_added`, `reaction_removed` — **reaction ingestion (feedback signal).** A 👍 / 👎\n on one of the agent's own replies is recorded as lightweight human feedback (and every\n reaction emits anonymous volume telemetry); reactions on *other* messages are counted but\n not stored. Needs the `reactions:read` scope from Step 2. Omit these and the feature is\n silently off — nothing else changes.\n - `app_home_opened` — **the Home tab page.** When a person opens the agent's Home tab, the agent\n publishes their private page: what it remembers about them, with Edit, Forget and Export, the\n rules they kept with \"Approve, and allow things like this\", with Remove (see\n [Pre-approval rules § 7](../../how-it-works/pre-approval-rules.md#7-approve-and-allow-things-like-this)),\n and what their domains know (see [Memory you can see](../../capabilities/memory.md#memory-you-can-see)).\n The event needs no scope. Omit it and the Home tab shows Slack's empty placeholder.\n - `channel_shared` — **take a memory canvas down when its channel is shared.** When a channel\n becomes Slack Connect shared, the agent re-runs the memory canvas gate there at once and replaces\n its own canvas with a notice that holds no memory. Needs `channels:read` and `groups:read` from\n Step 2. Omit it and the canvas comes down at the next memory write to the domain instead.\n - `user_change` — **automatic offboarding.** When a workspace admin deactivates a member or a\n guest, OE confirms it with `users.info` and revokes that person's link-set and stored OAuth\n credentials (see [Identity](../../how-it-works/identity.md), *Automatic offboarding*). Needs\n the `users:read` scope from Step 2. Omit it and offboarding stays the admin's manual\n `revoke_link_set`.\n - `app_context_changed` — **the side panel's viewed room.** Slack fires it when a person moves\n the side panel to another room, and, once the app subscribes to it, attaches the viewed room to\n each `message.im` sent from the panel. The agent acts on the room in the message and on nothing\n else: the event itself wakes nothing. Needs `assistant:write` from Step 2 and Slack's agent\n view (`features.agent_view` in the manifest). See [The side panel](#the-side-panel).\n The generated manifest (`/setup`, `surface_manifest`, `npx @open-everywhere/create`) subscribes\n every event in this list and requests each event's scope.\n Then **App Home** → **Show Tabs** → turn the **Messages Tab** on, and tick *Allow users to send\n Slash commands and messages from the messages tab*. Without it, a person who opens a DM with the\n agent sees *\"Sending messages to this app has been turned off\"*, and `message.im` never fires.\n Turn the **Home Tab** on too, for the page above. The generated manifest sets all three\n (`features.app_home`).\n8. **Interactivity & Shortcuts** → toggle **On**. Set the **Request URL** to the same\n `https://<your-deploy-url>/eve/v1/slack` — the same route Event Subscriptions uses. This is\n what makes the agent's **clickable buttons work**: Eve's own tool-approval prompts and\n clarifying-question options render as interactive Block Kit messages, **and** the\n admin-mutation approval cards (spend caps, connector revokes, forgetting a memory bullet —\n see [In-Slack approvals](../../security/control-plane.md#in-slack-approvals)) post their\n approve/reject buttons the same way. Both ride this one Request URL; Slack delivers every\n button click to it, and the front classifies which handler a click belongs to. Without it,\n clicks fail with *\"the app is not configured to handle interactive responses\"* while\n everything else appears to work.\n9. **Shortcuts** (same **Interactivity & Shortcuts** page, below the Request URL) → **Create New\n Shortcut**. A shortcut is a **wake**: it starts a turn the same way an `@mention` does. Add\n either or both — they answer different needs.\n - **On messages** (a *message shortcut*) — appears in a message's `…` menu. The agent wakes on\n **that message**: its text is the wake's content, and any files on it come too. Use it to hand\n the agent something somebody else wrote without re-typing or re-tagging it. In a **channel**\n the reply threads under the message; in a **DM** it lands flat in the conversation, which is\n where every DM answer lands.\n\n The message is somebody else's, and the agent is told so: the wake is attributed to **you**,\n the person who invoked the shortcut, and the message's body is wrapped as untrusted content.\n Handing a message over does not vouch for it — but it does run the turn with **your** access,\n not the message author's.\n\n Slack delivers a `message_action` even for a conversation the app is not a member of, so the\n shortcut has to answer for itself what the room is. It asks `conversations.info`, and three\n outcomes follow.\n\n - **A room the app cannot see at all** — a private channel, DM or group DM it is not in —\n answers `channel_not_found`. The shortcut then wakes nothing, because the agent must not\n route a message whose room it could not identify. For a private channel this is measured. For\n a DM or a group DM it is an INFERENCE from Slack's documentation, and the code says so\n (`appCanReadConversation` in `slack-identity.ts`): no offline tier can drive a live\n `conversations.info` against a DM the app is not in, so the answer there is unmeasured. If the\n inference is wrong, such a DM reaches the turn with its files.\n - **A public channel the app has not joined** answers, and says `is_member: false`. The\n message's **text** crosses into the turn, and that is the ruled trade: you are the\n principal, and the body is fenced as untrusted. Its **files do not** — the references are\n dropped from the wake and never registered, so the bot token downloads nothing from a room\n the agent has not joined, and the agent is told the text rather than that there is a file it\n cannot open.\n - **A room the app is in** carries the message and its files both. A shortcut on a screenshot\n surfaces the screenshot here, and only here.\n - **Global** — appears in the composer's ⚡ menu and in search. It names no conversation, so the\n agent wakes in **your own DM** with it, with the shortcut's name as the whole content. This\n one needs the `im:write` scope from Step 2 (the agent opens the DM with\n `conversations.open`); without it the shortcut acks and wakes nothing, and the deploy log says\n so.\n\n Slack asks for a **Name**, a **Short Description** and a **Callback ID** for each. The callback\n ID is yours to choose — the agent owns every shortcut this app receives, so the name you give it\n is simply the trigger name the agent is told about. Shortcuts ride the **`commands`** scope from\n Step 2, which Slack documents as *\"Add shortcuts and slash commands that people can use\"* — the\n same scope the `/oe` command uses.\n\n > **A message shortcut in a channel needs the single window.** A shortcut is *addressed*, so it\n > never goes through the engagement or name-drop judge — it takes the same road a\n > single-window `@mention` takes. On a domain that does **not** run the ambient supervisor\n > (`ambientSupervisor` off) a channel has no such road, so a message shortcut used there wakes\n > nothing and logs a warning. Message shortcuts in DMs and group DMs, and global shortcuts, work\n > on every deployment.\n >\n > A message shortcut also needs the agent to know **which kind of room** it was invoked in. If\n > `conversations.info` fails — a missing scope, or a transient Slack error — the shortcut wakes\n > nothing and logs a warning, rather than routing a DM's content into a channel session. Retry\n > the shortcut; if it keeps failing, check the `channels:read`, `groups:read`, `im:read` and\n > `mpim:read` scopes from Step 2.\n10. **Save Changes.** If Slack prompts to reinstall the app (scopes changed), do it.\n\n> **Verify the wake path after deploy.** `@mention` the agent in a public channel, let it reply,\n> then post a follow-up in that thread **without** tagging it. If it responds, engagement is live.\n> If only the mention worked, re-check that `message.channels` is subscribed and `channels:history`\n> is granted. (The engagement events all land on the same `/eve/v1/slack` Request URL.)\n\n### Optional: respond to name-drops (per domain, off by default)\n\nBy default the agent **never** joins a conversation uninvited: it responds to `@mentions`, DMs,\nand threads it's already engaged in — a top-level message that merely *says* its name is ignored.\nIf you want it to (judged, carefully) answer messages like *\"could Ada look at this?\"*, opt a\n**memory domain** in:\n\n```ts\n// agent/memory/config/domains/org.ts — one file per domain; list each in domains/index.ts\nexport const domain = defineDomain({ name: \"org\", match: \"org\", memory: \"org-brain\", nameDropWake: true }) // workspace-wide\n\n// agent/memory/config/domains/support.ts\nexport const domain = defineDomain({ name: \"support\", reach: [\"slack/T…/C…\"], nameDropWake: true, /* … */ }) // just these channels\n```\n\nAn explicit domain's setting always wins for its channels (no fall-through to `org`). The gate is\ndeliberately conservative even when enabled: **top-level** human messages in channels/private\ngroups only (a group DM never needs it — it's DM-family, so every message there wakes the agent\nanyway), the message must say one of the agent's names — the wake trigger's token set (same rule\non Discord) is:\n\n- the bot's **Slack display name** — the agent's public face on Slack, from one cached\n `users.info` lookup; rename the app and the trigger follows, nothing to configure;\n- the deployment's canonical `identity.name` setting, when set — the same name the agent\n introduces itself by on every surface;\n- any token in `wake.nameDrop.include` (configuration-reference.md), added by the operator;\n\nminus any token in `wake.nameDrop.exclude`, which wins over the other three. Matching is\ncase-insensitive and word-boundary, same as an `@mention`'s name check. The\nchannel must be positively confirmed as **not externally shared** (Slack Connect and\nunverifiable channels never trigger it), and the agent then *judges* whether a reply is actually\nwarranted — when in doubt it stays silent. If it does reply, it starts a thread on the message and\nfollows that thread from then on, exactly as if it had been mentioned.\n\nUninvited turns also **fail quietly**: until the agent has actually posted a reply, an error\n(model outage, tool failure) on a name-drop turn is logged and audited (`surface.quiet_fail`)\ninstead of posted — a turn that would have judged \"stay silent\" never spams the conversation\nwith a stack trace instead. Once it has replied it's a participant, and failures go loud like\nany engaged thread's. Every wake decision is one log line in your host's runtime logs, and the\nname-drop road writes **two audit rows of its own**:\n\n- `surface.name_drop_woke` — the road admitted the message and handed on a wake. It names the\n room (`container`), the message (`ts`), which session took the wake (`road`: the room's standing\n seat, or a fresh per-thread session) and who decided (`via`: the built-in policy, or a drop-in\n [wake arm](../../extending/drop-in-arms.md)).\n- `surface.name_drop_silent` — somebody named the agent and the road refused. `reason` is the\n refusing gate's own words: the domain never enabled `nameDropWake`, or the channel is not\n confirmed internal.\n\nA message that names nobody writes neither row — unless a drop-in wake arm adds the wake, and that\nrow carries `via: \"arm\"`. The pair is what tells a **judged** silence from a **dead** road: no row\nat all means the road never ran. Read a `surface.name_drop_woke` row with no reply as \"the road\nadmitted the wake\", not yet as \"the seat judged silence\": gates below the road can still refuse the\nturn — the tenant fence, the spend cap, and on a seated channel the seat's own stood-down marker —\nand each of those writes its own refusal row. Slack itself writes no per-turn silence row — the\ndeployment's posture is actions-only — so the silent turn's own record is the always-on\n`reply.no_reply_observed` row plus the [session\ntranscript](../../security/audit-and-provenance.md). A reply is audited as `reply.post`, the one\ncanonical row every reply route writes, whether the model called the `reply` tool or the turn's\nending text was the answer.\n\n### Channel-join intros (default on, fail-closed)\n\nBeing `/invite`d into a channel **is** an invitation — so, unlike name-drops, this needs no\nopt-in. When the bot is added to a channel it **reads up before it introduces itself**: it\nimmediately acks (\"👋 Just joined — reading up on this channel; back in a moment.\"), fetches the\nchannel's recent history (up to 50 messages) and the inviter's name, then posts an introduction\n**in a thread under the \"joined\" notice** grounded in what it read: a teammate's personal channel\ngets a person-focused hello fitted to what they've actually been working on, a team channel gets\nan \"I've read the room\" line plus 2–3 concrete offers drawn from things really discussed there,\nand a large announce-style channel (~500+ members) skips the ack and the read entirely and gets\none low-key line (\"I'll stay in the background — @mention me if I can help\") and then silence.\nAn empty or unreadable channel degrades to an intro from the name and topic alone. The fetched\nhistory is framed as untrusted channel data — something *in* the backscroll asking the bot to act\nis chatter, never a task. The intro is still a *judged* turn — the agent can choose to say\nnothing at all if even that would be noise.\n\n> **What it deliberately does *not* read:** other channels. Claude Tag's join intro searches the\n> whole workspace via per-user tokens; Open Everywhere's access model is bot-principal only, so\n> cross-channel context comes from the channel's [domain memory](../../capabilities/memory.md) —\n> if the channel is in a domain, the intro turn may take one look at that shared memory too.\n\nTwo hard limits:\n\n- **Never on externally-shared channels.** The same fail-closed gate as name-drops: the channel\n must be positively confirmed as not externally shared (Slack Connect, org-shared, and\n unverifiable channels never get an intro).\n- **Once per channel.** The join intro shares its once-only marker with the first-mention\n onboarding (Step 3 below) — whichever happens first claims the channel, so you never get both,\n and a remove/re-invite doesn't re-intro.\n\nRequires the `member_joined_channel` bot event from Step 1.7. Without it the feature is silently\noff and the intro rides the first `@mention` instead.\n\n## Step 2 — Configure the environment\n\nSet these on your instance:\n\n```bash\nSLACK_BOT_TOKEN=xoxb-… # Bot User OAuth Token from Step 1.4\nSLACK_SIGNING_SECRET=… # Signing Secret from Step 1.5\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 Slack channel reads `team_id`, `channel_id`, `channel_type`, and `is_private` straight off each inbound event into the session auth context, and `bindSlack` derives workspace = the Slack workspace, surface = the channel, plus mode and visibility. The `deploymentIdentity.workspace` / `.surface` declaration in `agent/identity/surfaces.ts` is only the `eve dev` (no-surface HTTP) gap-filler — once Slack is wired it is ignored.\n\n## Step 3 — Invoke it\n\nOpen Everywhere wakes on an `@mention` in a channel, or on any message in a DM.\n\n1. In any channel, invite the bot: `/invite @Open Everywhere`.\n2. Mention it: `@Open Everywhere summarise the last 20 messages and post the action items`.\n3. Expected behaviour:\n - It reacts to the thread root with 👀 (seen it, working on it) and shows a rotating status verb as its typing indicator (`Tinkering…`). eve's own `Thinking...` status comes first, while the mention is admitted.\n - **Live step labels.** While a tool runs, the status line says what the agent does, in plain\n words: `Searching Notion…`, `Reading the PR…`, `Reading the channel…`. Between tool steps it\n shows the status verb again. The label is code, not model text: it comes from the tool's name\n and, for a connector, from a fixed list of vendor names. It never shows what the agent searches\n for, a page title, a URL, or anything a tool returned, so a label in a shared channel cannot\n carry another conversation's words. Only a step that needs no approval by default gets a label\n (a read, a search, running code in the sandbox, a hand-off to a declared subagent): a write\n (a pull request, a ticket, a Notion change) shows the status verb, because the label shows\n before an approval or a check decides the write. A connector op counts as a read only when\n its name says so (`list_…`, `search_…`). An operator can still require approval for such an op,\n and its label then shows while the card waits. A tool with no label shows the status verb.\n The status line is Slack's native assistant status, so it posts nothing in the thread. The reply still\n posts once, after every check. A label does tell the room which kind of tool runs, and for a\n connector, which vendor.\n - A turn that starts a task also posts eve's **task card**: one message per turn, one row per\n task, updated in place. Outside a 1:1 DM a row shows the task's name only (`Researcher`), never\n the brief the agent wrote for it, because a brief can carry content from another conversation.\n Outside a 1:1 DM, a row that waits on a person says only what it waits for (`Waiting for approval`).\n In a 1:1 DM with you, a row shows the brief. In no room does a row show the task's result: the\n answer arrives as the agent's reply.\n - On the **first** mention in a channel it posts a short intro and adopts any pinned messages as standing instructions for that surface.\n - It does the work, replies in-thread, and **removes** the 👀 — absence is the done signal; the posted reply is the result. On a failure it flips to 🛑 (blocked) instead. (✅ is deliberately left to humans — react ✅ yourself to mark something done.)\n - **Every channel reply lands in a thread.** A message that is already in a thread is answered\n in that thread. A **top-level** channel message — an `@mention` or an ambient answer alike —\n is answered in a new thread opened on that message, so a busy channel keeps one root per\n topic and the answer sits under the question. Only a post that answers no message at all (a\n [routine](../../capabilities/proactivity-and-routines.md) fire, a webhook post-back) goes to\n the channel root. A **DM** is deliberately different: its replies stay flat, because\n threading a 1:1 shatters the conversation.\n - That status reaction is code-driven and narrow by design; the model can ALSO choose its own\n reaction via the `react` tool — a bare emoji ack (e.g. 👍) instead of a low-content reply in a\n busy thread. It's a genuine alternative response, not a status indicator, and is audited like\n any other tool call.\n4. Test a DM: open a direct message with the bot and send a plain message (no `@mention` needed in a DM). It responds the same way. The DM surface's memory is isolated and `private`.\n\n### What it can read on Slack\n\n**The message that woke it carries more than the typed text.** Three parts reach the agent — four\nwhen the wake is an edit, which also carries the text as it read before the edit:\n\n- The author's own text.\n- Any file the message carries. The agent sees the reference — the id, the name, the mimetype\n and the file's Slack `url_private` — not the bytes. It reads an image with `describe_image`\n and a text file with `read_file_upload`.\n- **A quoted Slack message: a forward, a share, or a message permalink unfurl.** Slack puts\n this content beside the text, not in it. The agent sees it appended under the author's text,\n so a person can forward a message to the agent and ask about it. A quoted field the author's\n own text already carries is dropped. The unit is one whole field, so a two-line quote of which\n the author typed one line is kept intact — the repeated line stays.\n\n A plain **link unfurl** — the title and blurb Slack scrapes from a web page — is **not**\n merged on any path Open Everywhere's own parser handles. Those paths read quoted Slack\n messages only, and they are: an ambient channel message, an engaged thread, a name-drop, a\n **DM**, a **group DM**, and a mention that the unified window or a group DM owns.\n\n> ⚠️ **One `@mention` route still merges a link unfurl, and in practice it almost always does.**\n> A mention that Open Everywhere's own parser does not own is forwarded to the `eve` framework's\n> native inbound path, and that path reads every attachment — including the text Slack scrapes\n> from a linked page. Measured against our pinned eve: the scraped text is merged whenever the\n> message carries Block Kit `blocks` whose flattened text contains what the person typed — the\n> normal case for a message composed in a Slack client. Otherwise eve falls back to a length\n> comparison, which can discard the scraped text. Since you cannot tell from the outside which\n> case a given message hits, treat a linked page's title and blurb on that route as reaching the\n> model inside the member's unfenced body.\n> Open Everywhere does not control that leg today (tracked as OPE-1380).\n\n> ⚠️ **Quoted content carries the forwarder's standing, not its own.** Open Everywhere merges the\n> quoted text into the forwarder's message body. The fence keys on the **author** of that body, so\n> a forward from a vouched member arrives with the member's trust tier and is delivered unfenced —\n> the same as anything that member types. The agent is not told which words the member wrote and\n> which came from the quoted message.\n>\n> A member can therefore put another Slack author's words in front of the model under their own\n> standing. Treat the ability to forward into a channel the agent watches as equal to the ability\n> to talk to the agent. This is the same handling a direct `@mention` has always had.\n\nTwo **wake** decisions read only what the person typed: the check that drops a message as the\nduplicate of an `@mention`, and the name-drop trigger. So forwarding a message that says the\nagent's name does not wake it by name. The agent does *act on* forwarded content once it is\nawake — that is the point of the feature.\n\nOne caveat on the first of those, and it is unmeasured. We do not know whether Slack raises an\n`app_mention` for an `@mention` that sits inside a forwarded message rather than in the text the\nperson typed. We assume it does not, and Open Everywhere is built for that case: the forward\nstill reaches the agent **on any route that was already going to wake it** — a DM, a group DM,\nan engaged thread, or an ambient channel. In a plain channel with no ambient mode and no\nname-drop, a forward whose only mention sits inside the quote wakes nothing, the same as any\nother message nobody addressed to the agent. If Slack *does* raise an `app_mention`, the forward\nreaches the agent twice instead — once by the mention route and once by the wake route. A\nduplicate answer is the deliberate trade; the alternative was a forward the agent never saw at\nall on the routes that do wake.\n\nBeyond the message that woke it, the agent has three reads on Slack, all open to any member of\nthe room (membership *is* the read tier here — there is no separate grant to hand out):\n\n- `read_channel` — recent history of **the channel this turn arrived in**, pulled into the\n conversation that asked. It takes no target: the channel comes from the turn's own binding, so a\n channel the bot has not been invited to is not addressable, not merely refused.\n- `read_thread` — the same, scoped to the thread this turn arrived in.\n\n Both are fenced to the session's [memory domain](../../capabilities/memory.md) and to the\n window's own sources, and nothing else. A refused read writes a `read.refused` audit row naming\n the channel, the caller's domain and the verdict (ids only, never message content) —\n `GET /control/v1/audit?since=<iso>&actions=read.refused`.\n- `read_file_upload` — decodes a **text** attachment already posted in the room (an image\n goes to `describe_image` instead). The fetch uses the bot token and is host-pinned to\n Slack, allowlisted by file type and capped by size, and the decoded content is ingested as\n untrusted external data like any other surface text.\n\n### Personal credentials in a Slack room (the member plane)\n\nThe [member plane](../../security/credentials-and-planes.md) is the one way a person's own connector\ncredential resolves outside a 1:1 DM. Two Slack facts decide whether a room admits it.\n\n**Your declaration vouches the room, at any rung you wrote it on.** One\n`defineSurface({ key: \"slack/<team>\", trust: \"internal\" })` line covers every room in that workspace.\nA room you did not declare vouches for nobody, and the plane refuses there.\n\n**A group DM is a channel here.** It is a shared room with several people in it, so it reaches the\nsame gate a channel reaches. Nobody declares a group DM's own key, so its vouch always comes from a\nrung above it — the workspace line does the job.\n\n**Slack Connect demotes the room, whatever you declared.** A channel or a group DM that Slack reports\nas externally shared (`is_ext_shared`) stays out of the plane. A workspace-wide declaration cannot\nre-promote a room that spans another organisation.\n\n> ⚠️ **The group-DM half of that needs `mpim:read`.** Open Everywhere reads the sharing flag from\n> `conversations.info`, and on a group DM that call needs the `mpim:read` scope. Without the scope the\n> call answers nothing, Open Everywhere demotes nothing, and a Slack Connect **group DM** stays vouched\n> under your workspace declaration. Absence of an answer never demotes a room — only Slack saying so\n> does. Grant `mpim:read` (Step 1) if you use group DMs and rely on this demotion. A **channel** does\n> not have this gap: an unconfirmed channel fails closed on its own.\n\n**A guest or an outside person who WRITES marks that turn.** Open Everywhere probes the author of the\nmessage that woke it (`users.info`). A positively refused author makes the turn a non-member turn, and\nthe plane refuses a personal credential on it. **Sitting in the room marks nothing** — the mark keys\non whose words entered the conversation, not on who is present. Open Everywhere reads no roster for\nthis, and an unreadable probe refuses nobody.\n\n**The author probe walks your declaration rungs.** An author from the workspace the message sits in\nis a member. An author from a **different** workspace is a member only if you declared that\nworkspace, or the [Grid](#enterprise-grid-declare-the-organisation-once) it belongs to. This is what\nadmits an Enterprise Grid colleague, whose home workspace is a sibling of the one they are writing\nin. A guest grade still refuses on either path: `is_restricted`, `is_ultra_restricted`, `is_stranger`\nand a deleted account all mark the turn, whatever workspace the person sits in.\n\n**An app is judged by its workspace, like a person.** A bot that writes in a room is a member when it\nis your own workspace's app, and an outside author when it belongs to another workspace you have not\ndeclared. There is no separate rule for bots.\n\n## Search Slack as you\n\nThe bot sees the channels it is in, and nothing else. **\"Search Slack as you\"** lets a person let the\nagent see what THEY see: the channels and conversations the bot is not in. It can also post in their\nname. It runs on Slack's own MCP server (`https://mcp.slack.com/mcp`) with the person's own Slack\nsign-in. It is off until an admin turns it on, and the bot stays the normal route.\n\n### What a person sees\n\n- **Only in their own Slack DM with the agent.** A channel, a group DM and a DM on another platform\n never get it. Two walls hold this: the derived grant resolves in a Slack 1:1 DM only, and the\n connection itself refuses every call from anywhere else before any card or sign-in. A refusal\n writes a `connector.refused` audit row with reason `personal_surface`.\n- **Bot first, offered in the moment.** The agent answers from the bot's own Slack access first.\n When the bot cannot see what was asked, it says so and offers: \"I can only see channels I am in.\n Want me to see what you see?\" Nothing offers the connection at onboarding.\n- **The person signs in once.** Their first call posts the \"Connect my account\" card. They consent\n at Slack to the user scopes below, and their token reaches only what their own Slack account sees.\n- **Every write as them takes a card.** A post or a reply in their name posts an approval card first,\n and nothing is sent until they approve it. Reads run without a card. On a routine's fire, nobody\n is there to approve a card, so a write as them is refused: a routine permission does not lift it.\n- **It is labelled as theirs.** The connection is `slack-as-you`, described as \"search Slack as you\",\n and the agent says which answers came from their account.\n\nIt rides the host-side connector plane, never the sandbox egress plane: the DM's sandbox already holds\nthe bot's Slack key, and the firewall stamps one credential per host per session. The person's token\nlives in the OAuth vault and goes to `mcp.slack.com` alone.\n\n### Turn it on\n\n1. **Declare it** in `agent/access/mcp-servers.ts`, with your Slack app's public Client ID (Basic\n Information → App Credentials). It reuses the app the bot already runs on. Slack opens its MCP\n server to an internal app (your own Open Everywhere app is one) through that app's own OAuth\n client, with no dynamic client registration.\n\n ```ts\n import { defineMcpServers } from \"@open-everywhere/core/agent/connections/mcp-servers.js\";\n import { slackAsYou } from \"@open-everywhere/provider-slack/agent/slack-as-you.js\";\n\n export const MCP_SERVERS = defineMcpServers([slackAsYou({ clientId: \"<your Slack app's Client ID>\" })]);\n ```\n\n `tools: [\"search\", \"read\"]` makes it read-only (no `chat:write`). `people: { groups: [...] }`\n narrows who gets it, as in any declared server. Run `npx oe build` and commit the diff.\n2. **Store the Client Secret** (Basic Information → App Credentials) in the credential store:\n `npx oe secret set db:slack:oauth-client-secret`. The declaration names that ref, never the value.\n A `/setup` install already holds the secret under its own reserved `slack-app:` ref, which no\n outbound path may read, so store this copy once by hand.\n3. **Change the Slack app.** Fetch the manifest again (`surface_manifest { surface: \"slack\" }`, or the\n `/setup` page). Once the app declares the entry, it carries the `mcp:connect` bot scope, the user\n scopes below and the redirect URL `https://<host>/oauth/v1/callback`. Paste it (App Manifest → Save\n Changes) and reinstall the app if Slack asks. Then turn on **the MCP toggle in the app config's\n Agents section** — the manifest has no field for it, so it stays a click.\n\nThe user scopes, by tool group:\n\n| Tool group | User scopes |\n|---|---|\n| `search` | `search:read.public`, `search:read.private`, `search:read.mpim`, `search:read.im`, `search:read.files`, `search:read.users`, `files:read` |\n| `read` | `channels:history`, `groups:history`, `mpim:history`, `im:history`, `canvases:read`, `users:read` |\n| `write` | `chat:write` |\n\nRate limits are the Web API's own tiers.\n\n> ⚠️ **Not confirmed yet:** whether a Slack plan restricts the MCP server, whether an org admin\n> must approve the internal app before a person can consent, the exact shape of the\n> `oauth.v2.user.access` answer (a top-level `access_token` is assumed), and whether Slack accepts\n> the `scope` parameter on a token refresh. The offline tests prove the Open\n> Everywhere side with a stubbed MCP server. The live e2e scenario `S-SLACK-AS-YOU` proves the rest\n> once your test app has the toggle and the scopes.\n\n## The side panel\n\nSlack's agent experience lets a person open the agent in a **side panel** beside any channel, and\nkeep one conversation with it while they read. The manifest turns it on with `features.agent_view`\n(Slack's current name; a new app cannot use the older `assistant_view`). The panel shows the app's\nMessages tab, so a message sent there is an ordinary `message.im` to the person's **own DM seat**.\nIt is the same seat, the same session and the same grants as a DM. The panel adds no seat type.\n\n**What the panel adds is one fact: the room the person is looking at.** With the `app_context_changed`\nsubscription and `assistant:write` (Step 1), Slack attaches the viewed room to each panel message.\nThe agent passes the room's id to the seat in a trusted host note, as context only. The seat gains\nnone of that room's reads: a read of that room follows the rules that already apply to the DM.\n\n**Guests and external rooms get the strictest default.** The room id rides the note only when all\nthree hold:\n\n- the person is a full member of the workspace (the DM-partner check answers `internal`);\n- the room belongs to the same workspace as the DM;\n- Slack confirms the room internal (`conversations.info`). A Slack Connect channel, or a private\n channel the app is not in, fails this check.\n\nOtherwise the person still gets an answer, but the seat does not learn the room.\n\n**Suggested prompts** come from the `slack.suggestedPrompts` setting (at most four). They are part of\nthe manifest, so a change reaches people only after the operator pastes the manifest again (App\nManifest → Save Changes). The shipped three name no product.\n\n**Requirements and limits, from Slack's documentation (re-read 2026-10-11):**\n\n- Slack says some AI features need a **paid plan**, though the settings appear on every plan. Its\n developer sandbox has them for free.\n- 🔴 **The switch is one-way for an app that already has `assistant_view`.** Slack cannot move such\n an app back, and its people need a hard refresh. An app made from this manifest before the side\n panel shipped had no `assistant_view`, so for it the paste only adds the panel.\n- The status line under a panel message rides the legacy `assistant.threads.setStatus`. Slack's\n compatibility bridge maps it onto the new session states: a status sets `processing`, an empty one\n sets `active`, which the agent sends at the end of every turn. OE does not yet subscribe to\n `agent_session_stopped`, so the panel shows no interactive Stop button; `/oe stop` and the ✋\n reaction still stop a turn.\n- **Microsoft Teams** has no equivalent of a panel beside any channel for a bot. The nearest is the\n bot's personal chat, which is the person's DM seat already.\n\n## Slash commands: `/oe` (zero-inference control-plane)\n\n`/oe` is the control-plane escape hatch (backlog item 23): pure logic, code-rendered responses,\n**no model turn** — so it answers even while a channel is over its spend cap or the model/gateway\nitself is down. Unlike `@mention`/DM, it rides its **own dedicated Request URL**\n(`/eve/v1/slack/commands`) — a brand-new route, separate from the Event Subscriptions /\nInteractivity URL (`/eve/v1/slack`) Steps 1.6–1.8 configure — so setting it up never touches the\nmention/DM/approval-card wiring above.\n\nCommands (typed as free text after `/oe`):\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` (or `/oe budget status`) | This channel's and workspace'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). | 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: resolves it from the durable turn-anchor registry and cancels it. **No model turn, nothing spent** — so it works while the channel is over its cap, which is when you are 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 optout on` / `/oe optout off` / `/oe optout` | Turns on (or off, or shows) **your own** opt-out of agent actions on your content: no memory is saved from your own turns (in a shared conversation, a later turn by somebody else can still save what you said, and a [domain question](../../capabilities/domain-questions.md) you ask is not covered yet), and the agent refuses when somebody else hands it one of your messages with a shortcut. Keyed to you alone — there is no argument that names another person. The Home tab has the same switch. See [Opting out](../../capabilities/memory.md#opting-out-of-agent-actions-on-your-content). | Ephemeral |\n| `/oe help` | Lists the commands. | Ephemeral |\n\nBare `/oe` (no text) defaults to help — the friendliest landing. `/oe budget` alone (no `raise`/\n`clear`) defaults to `budget status`; Discord's equivalent (`/oe`) requires picking a concrete\nsubcommand instead — see the [Discord guide](./discord.md#slash-commands-oe-zero-inference-control-plane)\nfor why that's a genuine, documented platform grammar difference, not an inconsistency.\n\n### Register the Slack app feature\n\n1. In your Slack app config, go to **Slash Commands** → **Create New Command**.\n2. **Command**: `/oe`.\n3. **Request URL**:\n\n ```\n https://<your-deploy-url>/eve/v1/slack/commands\n ```\n\n4. **Short Description**: `Open Everywhere control-plane commands (stop, status, budget) — no model turn.`\n5. **Usage Hint**: `stop | status | budget [raise <usd> | clear] | rate <n> | optout [on | off] | help`.\n6. **Save**, then reinstall the app if Slack prompts you to (a new command is a scope-adjacent change).\n\nNo extra env var is needed — the route reuses `SLACK_SIGNING_SECRET` from [Step 2](#step-2--configure-the-environment) to verify every request (the SAME `v0=` HMAC scheme Event Subscriptions uses), and `SLACK_BOT_TOKEN` to post the `raise`/`clear` approval card.\n\n## Stopping it\n\nTwo different things, two different mechanisms — and the split is not stylistic.\n\n**Stop the turn it is running now.** Either react **✋** on the thread, or run **`/oe stop`**. Both\nare decided at ingress, before any model call, so both are free and both keep working when the\nchannel is over its spend cap. The agent's host places the ✋ only where work is running, for example on the\nmessage that asked for a job, from the job's start to its settlement. So it is present and\nclickable exactly while a stop is meaningful, and never appears on a message the agent merely read. Inference cannot\ndo this job at all: the agent is the thing that is busy, so a message saying \"stop!\" queues behind\nthe very turn it would stop.\n\nBe clear-eyed about what a stop is: the turn's history rolls back to its last completed model call,\nbut **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 so, and\nnames what had already run.\n\n**Stop it following the thread.** Just tell it — \"you can stop following this\", \"we're good here,\nthanks\", whatever you'd say to a colleague. This is **judged, not pattern-matched**: the agent reads\nthe intent between turns and calls `stand_down`, which sets a durable marker on the thread. It stays\nin the channel and loses nothing; `@mention` it to pull it back in. There is deliberately no magic\nword — the regex that used to look for one disengaged on \"don't stop the deploy\" as readily as on\n\"stop\", which is the failure mode a pattern over unbounded phrasing always has.\n\nThis is a **channel-only** verb. A DM has no `@mention` to pull it back in, so `stand_down` refuses\nthere and the agent replies that it could not stop following — a marker nobody could ever clear\nwould trap the DM silent for good.\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\nexactly as well as in one with headroom. `/oe budget raise`/`clear` still go through the real\napproval-card machinery; a human's click, not the command, is what actually changes anything.\n\n## Gotchas\n\n- **Signing-secret verification.** Every inbound webhook is HMAC-verified against `SLACK_SIGNING_SECRET`. If it's wrong or unset, Slack requests are rejected and the bot stays silent — check this first if mentions do nothing.\n- **Request URL challenge handshake.** When you set or change the Event Subscriptions URL, Slack POSTs a `url_verification` challenge. Your instance must be live and reachable at that moment to echo it back, or Slack marks the URL unverified and won't deliver events. Running locally? Expose it with a tunnel (e.g. `ngrok`) and use that HTTPS URL.\n- **DMs need both pieces.** A DM only works if you subscribed to the `message.im` bot event **and** granted the `im:history` scope. Missing either means DMs are silently dropped.\n- **An approve/reject click in a DM needs `im:read` (group DM: `mpim:read`).** Both are separate\n from `im:history`, and neither is covered by `channels:read`/`groups:read`. Before a member-tier\n click executes, the approval **clicker floor** resolves the room's roster\n (`conversations.info` + `conversations.members`) to check who could see the card; in a DM that\n call is on a `D…` id. Without the scope both calls fail `missing_scope`, the roster is\n unresolved, and clicks on the kinds whose result is handed to the clicker (minting a webhook\n signing secret, say) are refused with a roster complaint — while chat in that same DM keeps\n working normally, which is what makes it confusing. If you added the scope to an app that was\n already installed, **reinstall it** — see the note in Step 1.\n- **A private channel is NOT memory-isolated by that alone — trust decides, not visibility.** A verified in-workspace private channel shares its domain's (or the org's) brain exactly like a public one; visibility (public/private) is tracked but doesn't gate sharing. What DOES gate it is `conversations.info` failing to positively confirm the channel — missing the relevant read scope means the channel binds `external` (fail-closed): read-only `public` memory at most, no domain or org brain, regardless of whether it's public or private. Grant `channels:read` (or `groups:read` for a private channel) so `conversations.info` succeeds and the channel resolves `internal` — `channels:history` is a different scope (it gates `conversations.history`, the message-read path) and granting it alone will not fix this.\n- **`/oe` has its own Request URL.** It's a separate Slack app feature (Slash Commands) from Event Subscriptions/Interactivity — pointing it at the wrong URL (or forgetting to set it at all) means `/oe` times out with Slack's generic \"dispatch_failed\" error while mentions/DMs keep working fine, since they're on a different route entirely.\n- **Slack Connect (shared) channels: it answers, with public-only memory.** A channel shared across workspaces/orgs (`is_shared` / `is_ext_shared` / `is_org_shared` on `conversations.info`) binds **external** trust: the bot still replies, but with read-only `public` memory at most — no domain brain, no org brain, no shared-memory writes (this differs from Claude's Slack agent, which refuses shared channels outright). One consequence to know: if *another* org in that shared channel runs its own agent on this codebase (or any bot), each install answers its own org's mentions independently — the bot-author drop prevents loops between them, but nothing deduplicates two humans mentioning two different bots. If you don't want the bot in shared channels at all, just don't invite it to them.\n",
14
14
  },
15
15
  ];
package/agent/ops.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  agentCommandName,
31
31
  configuredAgentName,
32
32
  declaredMcpServers,
33
+ getSettings,
33
34
  registerSurfaceAppManifest,
34
35
  registerSurfaceCredentialProbe,
35
36
  } from "@open-everywhere/core/agent/adapter-api-ops.js";
@@ -53,6 +54,8 @@ export function slackManifestOptionsFromSettings(opts: { readonly appName?: stri
53
54
  // "Search Slack as you" (OPE-2421): the manifest carries its scopes and redirect only when the app
54
55
  // declares it in agent/access/mcp-servers.ts.
55
56
  ...(declaredMcpServers().some((s) => s.name === SLACK_AS_YOU_NAME) ? { personalMcp: true } : {}),
57
+ // The side panel's suggested prompts (OPE-2457) — the `slack.suggestedPrompts` setting.
58
+ suggestedPrompts: getSettings().slack.suggestedPrompts,
56
59
  };
57
60
  }
58
61
 
package/agent/register.ts CHANGED
@@ -53,6 +53,7 @@ import {
53
53
  registerBootNotice,
54
54
  registerSurfaceStatusReporter,
55
55
  registerMemoryWriteObserver,
56
+ registerDomainMembershipProbe,
56
57
  } from "@open-everywhere/core/agent/adapter-api.js";
57
58
  import { PROVIDER_DOCS } from "./docs-generated.js";
58
59
  import { slackSurfaceStatus } from "./status.js";
@@ -78,6 +79,7 @@ import slackChannel from "./channels/slack.js";
78
79
  import { slackWebhookVerifier } from "./channels/slack-webhook-verifier.js";
79
80
  import { registerSlackOps } from "./ops.js";
80
81
  import { coalescedRefresh, liveMemoryCanvasDeps } from "./channels/slack-memory-canvas.js";
82
+ import { slackDomainMembershipProbe } from "./channels/slack-home.js";
81
83
  import { announceSlackSetupCode } from "./setup/claim.js";
82
84
  import { reopenSlackSetup, slackInstallState } from "./setup/install-state.js";
83
85
 
@@ -133,6 +135,10 @@ export function registerSlackProvider(): void {
133
135
  // Governance venue floor (OPE-110): the live Slack roster supplier. Core owns the seam + the
134
136
  // fail-closed default (no supplier ⇒ a governance channel never arms); v1 is Slack-only.
135
137
  registerSlackGovernanceMembers();
138
+ // DM questions (OPE-2452): which domains a person belongs to, through which INTERNAL channels — the
139
+ // Home tab's own trust gate (full member, Slack-confirmed internal channel). Core owns the seam and
140
+ // the fail-closed default: a platform with no probe cannot ask a domain from a DM.
141
+ registerDomainMembershipProbe("slack", slackDomainMembershipProbe());
136
142
  // The bundle→core bridge for the generic spine's slack branches (see file header).
137
143
  registerSlackBridge({
138
144
  form: slackForm,
@@ -34,7 +34,19 @@ export interface SlackAppManifestOptions {
34
34
  * The MCP toggle itself (the app config's Agents section) has no manifest field, so it stays a click.
35
35
  */
36
36
  readonly personalMcp?: boolean;
37
+ /**
38
+ * The side panel's suggested prompts (OPE-2457) — the `slack.suggestedPrompts` setting, which the
39
+ * caller reads (this module imports nothing). Absent or empty ⇒ the manifest offers none. The setup
40
+ * CLI passes none: it runs before the app's settings resolve, and the post-deploy re-paste from the
41
+ * ops tool or `/setup` carries them.
42
+ */
43
+ readonly suggestedPrompts?: readonly {
44
+ readonly title: string;
45
+ readonly message: string;
46
+ }[];
37
47
  }
48
+ /** Slack's limit on `features.agent_view.agent_description` (the manifest reference: 300). */
49
+ export declare const SLACK_AGENT_DESCRIPTION_MAX = 300;
38
50
  /** The path the person's OAuth sign-in returns to — core's `OE_OAUTH_CALLBACK_PATH`
39
51
  * (connections/oe-oauth-callback.ts). Spelled here because this module imports nothing from core;
40
52
  * `scripts/slack-as-you-test.ts` fails if the two drift. */
@@ -1 +1 @@
1
- {"version":3,"file":"app-manifest.d.ts","sourceRoot":"","sources":["../../agent/app-manifest.ts"],"names":[],"mappings":"AAeA;;mEAEmE;AACnE,eAAO,MAAM,6BAA6B,oBAAoB,CAAC;AAE/D;yFACyF;AACzF,eAAO,MAAM,sBAAsB,oBAAoB,CAAC;AAKxD,4EAA4E;AAC5E,eAAO,MAAM,uBAAuB,KAAK,CAAC;AAE1C,oGAAoG;AACpG,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC;0GAC0G;AAC1G,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAKnE;AAED;;kFAEkF;AAClF,MAAM,WAAW,uBAAuB;IACtC;;yBAEqB;IACrB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B;;0CAEsC;IACtC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;8CAC0C;IAC1C,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;CAChC;AAED;;6DAE6D;AAC7D,eAAO,MAAM,oCAAoC,uBAAuB,CAAC;AAEzE;iGACiG;AACjG,wBAAgB,oBAAoB,CAAC,IAAI,GAAE,uBAA4B,GAAG,MAAM,CAM/E;AAQD;+DAC+D;AAC/D,wBAAgB,wBAAwB,CAAC,IAAI,GAAE,uBAA4B,GAAG,MAAM,CAsLnF;AAED,8CAA8C;AAC9C,eAAO,MAAM,qBAAqB,+BAA+B,CAAC;AAElE,kFAAkF;AAClF,eAAO,MAAM,wBAAwB,EAAE,SAAS,MAAM,EAQrD,CAAC;AAEF;;8BAE8B;AAC9B,eAAO,MAAM,0BAA0B,EAAE,SAAS,MAAM,EAKvD,CAAC"}
1
+ {"version":3,"file":"app-manifest.d.ts","sourceRoot":"","sources":["../../agent/app-manifest.ts"],"names":[],"mappings":"AAeA;;mEAEmE;AACnE,eAAO,MAAM,6BAA6B,oBAAoB,CAAC;AAE/D;yFACyF;AACzF,eAAO,MAAM,sBAAsB,oBAAoB,CAAC;AAKxD,4EAA4E;AAC5E,eAAO,MAAM,uBAAuB,KAAK,CAAC;AAE1C,oGAAoG;AACpG,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC;0GAC0G;AAC1G,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAKnE;AAED;;kFAEkF;AAClF,MAAM,WAAW,uBAAuB;IACtC;;yBAEqB;IACrB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B;;0CAEsC;IACtC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;8CAC0C;IAC1C,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;IAC/B;;;;;OAKG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,SAAS;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC7F;AAED,8FAA8F;AAC9F,eAAO,MAAM,2BAA2B,MAAM,CAAC;AAU/C;;6DAE6D;AAC7D,eAAO,MAAM,oCAAoC,uBAAuB,CAAC;AAEzE;iGACiG;AACjG,wBAAgB,oBAAoB,CAAC,IAAI,GAAE,uBAA4B,GAAG,MAAM,CAM/E;AAQD;+DAC+D;AAC/D,wBAAgB,wBAAwB,CAAC,IAAI,GAAE,uBAA4B,GAAG,MAAM,CAwMnF;AAED,8CAA8C;AAC9C,eAAO,MAAM,qBAAqB,+BAA+B,CAAC;AAElE,kFAAkF;AAClF,eAAO,MAAM,wBAAwB,EAAE,SAAS,MAAM,EAQrD,CAAC;AAEF;;8BAE8B;AAC9B,eAAO,MAAM,0BAA0B,EAAE,SAAS,MAAM,EAKvD,CAAC"}
@@ -34,6 +34,12 @@ export function slackAppNameProblem(raw) {
34
34
  return `the app name is ${name.length} characters; Slack accepts at most ${SLACK_APP_NAME_MAX}`;
35
35
  return undefined;
36
36
  }
37
+ /** Slack's limit on `features.agent_view.agent_description` (the manifest reference: 300). */
38
+ export const SLACK_AGENT_DESCRIPTION_MAX = 300;
39
+ /** The side panel's one-line description. Names the configured agent, never the brand on its own. */
40
+ function slackAgentDescription(appName) {
41
+ return `Ask ${appName} beside anything you are reading in Slack. It is your own direct-message conversation, with the same rules as a DM.`.slice(0, SLACK_AGENT_DESCRIPTION_MAX);
42
+ }
37
43
  /** The path the person's OAuth sign-in returns to — core's `OE_OAUTH_CALLBACK_PATH`
38
44
  * (connections/oe-oauth-callback.ts). Spelled here because this module imports nothing from core;
39
45
  * `scripts/slack-as-you-test.ts` fails if the two drift. */
@@ -72,6 +78,19 @@ export function slackAppManifestTemplate(opts = {}) {
72
78
  // it delivers `app_home_opened` (below), and the front route publishes the page
73
79
  // (channels/slack-home.ts).
74
80
  app_home: { home_tab_enabled: true, messages_tab_enabled: true, messages_tab_read_only_enabled: false },
81
+ // THE SIDE PANEL (OPE-2457) — Slack's agent experience. With `agent_view` on, a person can
82
+ // open the app's Messages tab beside any channel, and what they send there is an ordinary
83
+ // `message.im` to their own DM seat (channels/slack-side-panel.ts). `agent_view`, not the
84
+ // older `assistant_view`: Slack's manifest reference says a NEW app can only use `agent_view`.
85
+ // 🔴 FOR AN EXISTING APP THE SWITCH IS ONE-WAY: Slack cannot move an app from `assistant_view`
86
+ // back, and its people need a hard refresh. An app that never had `assistant_view` (every
87
+ // install of this manifest before OPE-2457) has nothing to switch from.
88
+ agent_view: {
89
+ agent_description: slackAgentDescription(appName),
90
+ ...(opts.suggestedPrompts && opts.suggestedPrompts.length > 0
91
+ ? { suggested_prompts: opts.suggestedPrompts.map((p) => ({ title: p.title, message: p.message })) }
92
+ : {}),
93
+ },
75
94
  bot_user: { display_name: appName, always_online: true },
76
95
  // The "/oe" ZERO-INFERENCE control command (agent/commands/core.ts). Declared here rather
77
96
  // than left to a manual setup step because one of its subcommands is `/oe stop` (OPE-418),
@@ -87,7 +106,7 @@ export function slackAppManifestTemplate(opts = {}) {
87
106
  description: `${appName} control-plane commands (stop, status, budget) — no model turn.`,
88
107
  // Every subcommand `parseOeCommandTokens` accepts (packages/core/agent/commands/core.ts).
89
108
  // scripts/slack-app-manifest-test.ts fails if one is missing here.
90
- usage_hint: "stop | status | budget [raise <usd> | clear] | rate <n> | help",
109
+ usage_hint: "stop | status | budget [raise <usd> | clear] | rate <n> | optout [on | off] | help",
91
110
  should_escape: false,
92
111
  },
93
112
  ],
@@ -180,6 +199,9 @@ export function slackAppManifestTemplate(opts = {}) {
180
199
  // knows. Without it the canvas refresh fails with `missing_scope`, logged and audited
181
200
  // per channel, and memory itself is unaffected.
182
201
  "canvases:write",
202
+ // The side panel (OPE-2457): Slack requires it for `app_context_changed`, the event whose
203
+ // subscription makes Slack attach the viewed room to each side-panel `message.im`.
204
+ "assistant:write",
183
205
  // Slack requires this bot scope on an app whose people connect to Slack's MCP server
184
206
  // (OPE-2421). Declared only when the app declares "search Slack as you".
185
207
  ...(personalMcp ? [SLACK_MCP_BOT_SCOPE] : []),
@@ -204,6 +226,7 @@ export function slackAppManifestTemplate(opts = {}) {
204
226
  // app_home_opened → no scope (the Home tab page, OPE-2451)
205
227
  // channel_shared → channels:read, groups:read (re-run the memory canvas gate, OPE-2451)
206
228
  // user_change → users:read (automatic offboarding on a deactivated member, OPE-2423)
229
+ // app_context_changed → assistant:write (the side panel's viewed room, OPE-2457)
207
230
  // scripts/slack-app-manifest-test.ts derives the handled set from the code and fails if
208
231
  // this list drops one (OPE-2223).
209
232
  bot_events: [
@@ -218,6 +241,7 @@ export function slackAppManifestTemplate(opts = {}) {
218
241
  "app_home_opened",
219
242
  "channel_shared",
220
243
  "user_change",
244
+ "app_context_changed",
221
245
  ],
222
246
  },
223
247
  // The INTERACTIVITY Request URL — the SAME public route Event Subscriptions uses. Every
@@ -1 +1 @@
1
- {"version":3,"file":"app-manifest.js","sourceRoot":"","sources":["../../agent/app-manifest.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"app-manifest.js","sourceRoot":"","sources":["../../agent/app-manifest.ts"],"names":[],"mappings":"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"}
@@ -1,3 +1,4 @@
1
+ import { type SlackAppContextRoom } from "./slack-side-panel.js";
1
2
  import { resolvePartition, type AttachSessionForCancel, type SeatCatchUpRequest, type SeatCatchUpResult, type ExternalPolicy, type IngestedChatMessage, PREFER_NO_REPLY_TOOL, type Partition, type BindableContext, type SingleWindowMentionPolicy, type UnifiedWindowWake, type ReplyDecision, type EngagementRow, type RoomRoster, type ContextLine, type WakeAttachment, type WakeMessageInput, type WakeParticipant, type WakeTrust, type NormalizedWake, type WakeEngagementTier } from "@open-everywhere/core/agent/adapter-api.js";
2
3
  import { type SpendGateDecision } from "./slack-spend-gate.js";
3
4
  import { type WakeBackscrollLines } from "./slack-wake-backscroll.js";
@@ -100,6 +101,10 @@ export interface ParsedMessageEvent {
100
101
  * the wake envelope so the agent knows an image exists and can `describe_image` it. Empty/
101
102
  * absent when the message carries no files. Never the bytes — just id/name/mimetype/url. */
102
103
  readonly attachments?: readonly WakeAttachment[];
104
+ /** The room the person was viewing in the side panel (OPE-2457), from the event's `app_context`.
105
+ * UNVERIFIED — slack-side-panel.ts `sidePanelViewedRoom` gates it before any use. Absent on every
106
+ * event that carries no channel entity, which is every non-panel message. */
107
+ readonly appContext?: SlackAppContextRoom;
103
108
  }
104
109
  /** Parse Slack's `files[]` (on a message event's inner message) into attachment references.
105
110
  * Only well-formed entries survive; an image mimetype ⇒ kind "image" (describable), else
@@ -311,6 +316,10 @@ export interface SlackWakeOpts {
311
316
  * (channels/seat-catch-up.ts `catchUpUnavailableReason`). Rendered as a trusted `<system-note>`;
312
317
  * unset ⇒ no note, which is every Slack wake, because Slack registers a fetch. */
313
318
  readonly catchUpUnavailable?: string;
319
+ /** OE's own trusted note for this wake (`<system-note>`): fixed words around host-checked ids only.
320
+ * Set by the DM door alone — the side panel's viewed room and the person's opt-out
321
+ * (slack-side-panel.ts `slackDmSystemNote`). Unset ⇒ no note, byte-identical to before. */
322
+ readonly systemNote?: string;
314
323
  /**
315
324
  * THE NAME-DROP HYDRATION, ON THE SEAT ROAD (OPE-1725). The surrounding conversation
316
325
  * {@link routeNameDropEvent} fetches for a name-drop, as RAW {@link ContextLine}s — renderWake