@open-everywhere/provider-slack 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/agent/app-manifest.ts +1 -0
  2. package/agent/channels/slack-events.ts +22 -19
  3. package/agent/channels/slack-front.ts +10 -87
  4. package/agent/channels/slack-post-arm.ts +1 -42
  5. package/agent/channels/slack-wake-backscroll.ts +3 -2
  6. package/agent/channels/slack.ts +2 -2
  7. package/agent/docs-generated.ts +1 -1
  8. package/agent/setup/claim.ts +16 -1
  9. package/agent/setup/page.ts +68 -6
  10. package/agent/surface-parity.ts +0 -6
  11. package/dist/agent/app-manifest.d.ts.map +1 -1
  12. package/dist/agent/app-manifest.js +1 -0
  13. package/dist/agent/app-manifest.js.map +1 -1
  14. package/dist/agent/channels/slack-events.d.ts +5 -5
  15. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  16. package/dist/agent/channels/slack-events.js +20 -17
  17. package/dist/agent/channels/slack-events.js.map +1 -1
  18. package/dist/agent/channels/slack-front.d.ts +0 -14
  19. package/dist/agent/channels/slack-front.d.ts.map +1 -1
  20. package/dist/agent/channels/slack-front.js +11 -70
  21. package/dist/agent/channels/slack-front.js.map +1 -1
  22. package/dist/agent/channels/slack-post-arm.d.ts +1 -8
  23. package/dist/agent/channels/slack-post-arm.d.ts.map +1 -1
  24. package/dist/agent/channels/slack-post-arm.js +1 -40
  25. package/dist/agent/channels/slack-post-arm.js.map +1 -1
  26. package/dist/agent/channels/slack-wake-backscroll.d.ts +2 -2
  27. package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -1
  28. package/dist/agent/channels/slack-wake-backscroll.js +3 -3
  29. package/dist/agent/channels/slack-wake-backscroll.js.map +1 -1
  30. package/dist/agent/channels/slack.js +2 -2
  31. package/dist/agent/channels/slack.js.map +1 -1
  32. package/dist/agent/docs-generated.js +1 -1
  33. package/dist/agent/docs-generated.js.map +1 -1
  34. package/dist/agent/setup/claim.d.ts.map +1 -1
  35. package/dist/agent/setup/claim.js +15 -2
  36. package/dist/agent/setup/claim.js.map +1 -1
  37. package/dist/agent/setup/page.d.ts +2 -0
  38. package/dist/agent/setup/page.d.ts.map +1 -1
  39. package/dist/agent/setup/page.js +60 -6
  40. package/dist/agent/setup/page.js.map +1 -1
  41. package/dist/agent/surface-parity.d.ts.map +1 -1
  42. package/dist/agent/surface-parity.js +0 -6
  43. package/dist/agent/surface-parity.js.map +1 -1
  44. package/package.json +2 -7
  45. package/agent/channels/slack-approval-card.ts +0 -275
@@ -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## 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`. This is the page's only\n 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 configuration token is used once and never stored.\n3. You click **Install to Slack**. The OAuth callback stores the bot token.\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## 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.\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 > **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 - `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 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 The generated manifest sets both (`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 - 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## 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 > **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 - `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 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 The generated manifest sets both (`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 - 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## 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",
14
14
  },
15
15
  ];
@@ -58,6 +58,7 @@
58
58
 
59
59
  import {
60
60
  encryptedKvVault,
61
+ pageBaseUrl,
61
62
  type EncryptedKvVault,
62
63
  } from "@open-everywhere/core/agent/adapter-api.js";
63
64
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
@@ -228,6 +229,20 @@ export function resetSlackSetupLockCache(): void {
228
229
  lockCache = null;
229
230
  }
230
231
 
232
+ /**
233
+ * The ABSOLUTE `/setup` URL the boot notice names (OPE-2397), from the same base URL the page writes
234
+ * into the Slack manifest. A bare "/setup" left the operator to guess the host — and on a deployment
235
+ * with several hosts (a custom domain, the production alias, a per-deployment URL) the guess decides
236
+ * whether the page is reachable at all. With no discoverable base, the line says how to set one.
237
+ *
238
+ * The path is a literal because `page.ts` imports this module; `slack-setup-test.ts` pins it to
239
+ * `SLACK_SETUP_PATH`.
240
+ */
241
+ function setupPageUrl(deps: SlackClaimDeps): string {
242
+ const base = pageBaseUrl(deps.env ?? process.env);
243
+ return base === undefined ? "/setup on this deployment's public URL (set OE_PUBLIC_BASE_URL to name it)" : `${base}/setup`;
244
+ }
245
+
231
246
  /**
232
247
  * The boot notice (core `boot/notices.ts`): log the setup code ONCE per process, while the page is
233
248
  * open. Silent whenever {@link slackSetupOpen} is false — an install is recorded, OR this deployment
@@ -243,7 +258,7 @@ export async function announceSlackSetupCode(deps: SlackClaimDeps = {}): Promise
243
258
  if (!(await slackSetupOpen(deps))) return;
244
259
  const code = await slackSetupCode(deps);
245
260
  console.log(
246
- `[open-everywhere:slack-setup] Slack is not installed on this deployment. Open /setup and enter this one-time setup code: ${code}\n` +
261
+ `[open-everywhere:slack-setup] Slack is not installed on this deployment. Open ${setupPageUrl(deps)} and enter this one-time setup code: ${code}\n` +
247
262
  `[open-everywhere:slack-setup] The code stops being logged once the Slack install completes, and /setup locks at that point.`,
248
263
  );
249
264
  }
@@ -128,6 +128,8 @@ export interface SlackSetupPageDeps extends SlackClaimDeps {
128
128
  readonly createApp?: typeof createSlackApp;
129
129
  readonly exchangeCode?: typeof exchangeSlackOauthCode;
130
130
  readonly keyArmed?: () => boolean;
131
+ /** The login-wall probe's fetch (OPE-2397). Defaults to the global `fetch`. */
132
+ readonly probeFetch?: (url: string, init: RequestInit) => Promise<Response>;
131
133
  }
132
134
 
133
135
  function vaultOf(deps: SlackSetupPageDeps): EncryptedKvVault {
@@ -186,6 +188,64 @@ function manifestHost(base: string): string {
186
188
  return base.replace(/^https?:\/\//i, "").replace(/\/+$/, "");
187
189
  }
188
190
 
191
+ // --- the login-wall probe (OPE-2397) ----------------------------------------------------------------
192
+ //
193
+ // THE FAILURE IT CATCHES. The base URL can name a host that sits behind a login: Vercel's deployment
194
+ // protection covers the generated hosts by default, and `VERCEL_URL` is the last rung of the
195
+ // precedence. The operator passes that wall in a browser with a session cookie, so `/setup` renders
196
+ // and the run completes. Slack sends its requests with no cookie, so the app it creates never
197
+ // delivers an event, and nothing on the page says why.
198
+ //
199
+ // THE PROBE. One GET to this deployment's own `/setup` at the base URL, with no cookie and no
200
+ // redirect follow. `/setup` is the path to ask because it is mounted whenever this code runs, and it
201
+ // answers 200 to a cookie-less GET while setup is open. The status route is the wrong target: the
202
+ // deployment docs tell an operator to block it at the edge, and that block answers 403 too.
203
+ //
204
+ // WHAT COUNTS. A 401, a 403 or a 3xx is a login wall or a host Slack cannot use (Slack does not
205
+ // follow a redirect on a request URL). Anything else, a network failure or a timeout included, adds
206
+ // no warning: a deployment that cannot reach its own public host from inside is not proof of a wall,
207
+ // and a false warning would send the operator to change a correct URL.
208
+
209
+ /** How long the probe waits. Screen 2 renders after it, so it must stay short. */
210
+ const PROBE_TIMEOUT_MS = 3_000;
211
+
212
+ /** The probe's verdict: the HTTP status that marks a login wall, or `undefined` for no warning. */
213
+ async function loginWallStatus(base: string, deps: SlackSetupPageDeps): Promise<number | undefined> {
214
+ let res: Response;
215
+ try {
216
+ res = await (deps.probeFetch ?? fetch)(`${base}${SLACK_SETUP_PATH}`, {
217
+ method: "GET",
218
+ redirect: "manual",
219
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
220
+ });
221
+ } catch {
222
+ return undefined;
223
+ }
224
+ // Drain the body: an unread body holds the connection open.
225
+ await res.body?.cancel().catch(() => undefined);
226
+ const s = res.status;
227
+ return s === 401 || s === 403 || (s >= 300 && s < 400) ? s : undefined;
228
+ }
229
+
230
+ /** The screen-2 warning for a login wall. Names the host and the variable that fixes it. */
231
+ function loginWallWarning(host: string, status: number): string {
232
+ return (
233
+ `<p style="color:#b91c1c"><strong>Slack cannot reach <code>${escapeHtml(host)}</code>.</strong> This deployment ` +
234
+ `asked its own <code>${escapeHtml(SLACK_SETUP_PATH)}</code> at that host with no login, and the host answered ` +
235
+ `<code>${status}</code>. That is the answer of a login wall, such as Vercel deployment protection, or of a redirect. ` +
236
+ `Slack sends its requests with no login and follows no redirect, so an app created here would never receive an event.</p>` +
237
+ `<p>Set <code>OE_PUBLIC_BASE_URL</code> to a public host of this deployment (for example its custom domain) and ` +
238
+ `redeploy, or remove the protection from this host. Then reload this page.</p>`
239
+ );
240
+ }
241
+
242
+ /** Screen 2, with the probe's warning when the host is behind a login. */
243
+ async function createScreenFor(base: string, deps: SlackSetupPageDeps): Promise<Response> {
244
+ const host = manifestHost(base);
245
+ const wall = await loginWallStatus(base, deps);
246
+ return createScreen(host, wall === undefined ? "" : loginWallWarning(host, wall));
247
+ }
248
+
189
249
  // --- the four screens -----------------------------------------------------------------------------
190
250
 
191
251
  /** Screen 1 — ask for the setup code. `retry` re-renders after a wrong code, with an EMPTY field. */
@@ -205,15 +265,17 @@ function claimScreen(retry: boolean): Response {
205
265
  );
206
266
  }
207
267
 
208
- /** Screen 2 — ask for the app configuration token. */
209
- function createScreen(host: string): Response {
268
+ /** Screen 2 — ask for the app configuration token. `warning` is the login-wall probe's HTML, or "". */
269
+ function createScreen(host: string, warning: string): Response {
210
270
  return securePageResponse(
211
271
  200,
212
272
  "Create the Slack app",
213
- `<p>Open <code>api.slack.com/apps</code>, scroll to <strong>Your App Configuration Tokens</strong>, ` +
273
+ warning +
274
+ `<p>Open <code>api.slack.com/apps</code>, scroll to <strong>Your App Configuration Tokens</strong>, ` +
214
275
  `and generate one for the workspace that will host the agent. Paste it below. It needs the ` +
215
276
  `<code>app_configurations:write</code> scope and lasts about 12 hours.</p>` +
216
- `<p>This page will create the Slack app for <code>${escapeHtml(host)}</code> and keep its ` +
277
+ `<p>This page will create the Slack app for <code>${escapeHtml(host)}</code>: every request URL in the ` +
278
+ `app's manifest names that host, so Slack must be able to reach it with no login. It keeps the app's ` +
217
279
  `credentials in this deployment's own encrypted store. The configuration token is used once and never stored.</p>` +
218
280
  `<form method="post" action="${escapeHtml(SLACK_SETUP_PATH)}">` +
219
281
  `<input type="hidden" name="${FIELD_ACTION}" value="create">` +
@@ -347,7 +409,7 @@ export async function handleSlackSetupGet(req: Request, deps: SlackSetupPageDeps
347
409
 
348
410
  const pending = await readPending(deps);
349
411
  if (pending) return installScreen(authorizeUrlFor(pending, base, manifestBotScopes()));
350
- return createScreen(manifestHost(base));
412
+ return createScreenFor(base, deps);
351
413
  }
352
414
 
353
415
  // --- POST /setup ----------------------------------------------------------------------------------
@@ -389,7 +451,7 @@ export async function handleSlackSetupPost(req: Request, deps: SlackSetupPageDep
389
451
  if (base === undefined) return notAvailable();
390
452
 
391
453
  const configToken = (form.get(FIELD_CONFIG_TOKEN) ?? "").trim();
392
- if (configToken.length === 0) return createScreen(manifestHost(base));
454
+ if (configToken.length === 0) return createScreenFor(base, deps);
393
455
 
394
456
  const rawName = (form.get(FIELD_APP_NAME) ?? "").trim();
395
457
  if (rawName.length > 0) {
@@ -14,12 +14,6 @@ export const SLACK_SURFACE_PARITY: SurfaceParityDeclaration = {
14
14
  "capability-manifest": "supported",
15
15
  "post-arm": "supported",
16
16
  "wake-origin-codec": "supported",
17
- // The arm renders the buttons (`slack-post-arm.ts`), the front routes the tap back
18
- // (`slack-front.ts` classifies `oe_input:`, `slack-approval-card.ts` settles it), and the settle
19
- // DELIVERS through core's `approvalCardRespondVia` — all three halves, as the row requires. The
20
- // third is not a formality: this front bound its own `args.from(bareToken)` until OPE-1863, and
21
- // that address is channel-local, so every tap threw and the approval stayed parked.
22
- "unified-action-plane": "supported",
23
17
  // `replyAnchorTs` is placement only and sits outside `threadRef`, so two top-level wakes in one
24
18
  // channel are ONE conversation carrying two different anchors. Both members are implemented.
25
19
  "origin-placement-carry": "supported",
@@ -1 +1 @@
1
- {"version":3,"file":"app-manifest.d.ts","sourceRoot":"","sources":["../../agent/app-manifest.ts"],"names":[],"mappings":"AAaA;;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;CAC/B;AAED;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,CA2JnF;AAED,8CAA8C;AAC9C,eAAO,MAAM,qBAAqB,+BAA+B,CAAC;AAElE,kFAAkF;AAClF,eAAO,MAAM,wBAAwB,EAAE,SAAS,MAAM,EAOrD,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":"AAaA;;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;CAC/B;AAED;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,CA2JnF;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"}
@@ -212,6 +212,7 @@ export const SLACK_APP_MANIFEST_STEPS = [
212
212
  "Pick the workspace, choose JSON, and paste the manifest below.",
213
213
  "Next → Create.",
214
214
  "Install to Workspace, and approve the scopes Slack lists.",
215
+ "Unless you set identity.name and chose your own icon, give the app the project mark: Basic Information → Display Information → App icon, and upload https://raw.githubusercontent.com/open-everywhere/open-everywhere/main/brand/png/mark-square-1024.png. Slack's manifest has no icon field, so this step stays by hand.",
215
216
  "Before the first deploy, Slack cannot verify the Event Subscriptions URL: it answers only once a deploy that holds SLACK_SIGNING_SECRET is up. After the deploy, paste the manifest again with the real host (App Manifest → Save Changes), then retry the verification under Event Subscriptions.",
216
217
  ];
217
218
  /** What the operator brings back. The token and the secret are environment variables, not
@@ -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":"AAAA,qEAAqE;AACrE,EAAE;AACF,gFAAgF;AAChF,gGAAgG;AAChG,kGAAkG;AAClG,qGAAqG;AACrG,4CAA4C;AAC5C,EAAE;AACF,6FAA6F;AAC7F,mGAAmG;AACnG,iGAAiG;AACjG,mCAAmC;AAEnC;;mEAEmE;AACnE,MAAM,CAAC,MAAM,6BAA6B,GAAG,iBAAiB,CAAC;AAE/D;yFACyF;AACzF,MAAM,CAAC,MAAM,sBAAsB,GAAG,iBAAiB,CAAC;AAExD,iGAAiG;AACjG,MAAM,0BAA0B,GAAG,IAAI,CAAC;AAExC,4EAA4E;AAC5E,MAAM,CAAC,MAAM,uBAAuB,GAAG,EAAE,CAAC;AAE1C,oGAAoG;AACpG,MAAM,CAAC,MAAM,kBAAkB,GAAG,EAAE,CAAC;AAErC;0GAC0G;AAC1G,MAAM,UAAU,mBAAmB,CAAC,GAAW;IAC7C,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;IACxB,IAAI,IAAI,KAAK,EAAE;QAAE,OAAO,uBAAuB,CAAC;IAChD,IAAI,IAAI,CAAC,MAAM,GAAG,kBAAkB;QAAE,OAAO,mBAAmB,IAAI,CAAC,MAAM,sCAAsC,kBAAkB,EAAE,CAAC;IACtI,OAAO,SAAS,CAAC;AACnB,CAAC;AAmBD;iGACiG;AACjG,MAAM,UAAU,oBAAoB,CAAC,IAAI,GAA4B,EAAE;IACrE,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,EAAE,IAAI,EAAE,CAAC;IACtC,IAAI,QAAQ;QAAE,OAAO,QAAQ,CAAC;IAC9B,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,EAAE,IAAI,EAAE,CAAC;IAC1C,IAAI,UAAU;QAAE,OAAO,UAAU,CAAC,KAAK,CAAC,CAAC,EAAE,kBAAkB,CAAC,CAAC,IAAI,EAAE,CAAC;IACtE,OAAO,sBAAsB,CAAC;AAChC,CAAC;AAED,0FAA0F;AAC1F,SAAS,YAAY,CAAC,MAAc,EAAE,OAAe,EAAE,OAAe;IACpE,MAAM,KAAK,GAAG,GAAG,MAAM,IAAI,OAAO,EAAE,CAAC;IACrC,OAAO,KAAK,CAAC,MAAM,IAAI,uBAAuB,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC;AACnE,CAAC;AAED;+DAC+D;AAC/D,MAAM,UAAU,wBAAwB,CAAC,IAAI,GAA4B,EAAE;IACzE,MAAM,OAAO,GAAG,oBAAoB,CAAC,IAAI,CAAC,CAAC;IAC3C,MAAM,WAAW,GAAG,IAAI,CAAC,WAAW,EAAE,IAAI,EAAE,IAAI,0BAA0B,CAAC;IAC3E,OAAO,IAAI,CAAC,SAAS,CACnB;QACE,mBAAmB,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE;QACtC,QAAQ,EAAE;YACR,wFAAwF;YACxF,4FAA4F;YAC5F,yFAAyF;YACzF,kFAAkF;YAClF,QAAQ,EAAE,EAAE,gBAAgB,EAAE,KAAK,EAAE,oBAAoB,EAAE,IAAI,EAAE,8BAA8B,EAAE,KAAK,EAAE;YACxG,QAAQ,EAAE,EAAE,YAAY,EAAE,OAAO,EAAE,aAAa,EAAE,IAAI,EAAE;YACxD,0FAA0F;YAC1F,2FAA2F;YAC3F,0FAA0F;YAC1F,wFAAwF;YACxF,yFAAyF;YACzF,2DAA2D;YAC3D,cAAc,EAAE;gBACd;oBACE,iFAAiF;oBACjF,OAAO,EAAE,IAAI,WAAW,EAAE;oBAC1B,GAAG,EAAE,WAAW,6BAA6B,wBAAwB;oBACrE,WAAW,EAAE,GAAG,OAAO,iEAAiE;oBACxF,0FAA0F;oBAC1F,mEAAmE;oBACnE,UAAU,EAAE,gEAAgE;oBAC5E,aAAa,EAAE,KAAK;iBACrB;aACF;YACD,mFAAmF;YACnF,wFAAwF;YACxF,0FAA0F;YAC1F,uFAAuF;YACvF,wFAAwF;YACxF,qDAAqD;YACrD,EAAE;YACF,wFAAwF;YACxF,wFAAwF;YACxF,SAAS,EAAE;gBACT;oBACE,IAAI,EAAE,YAAY,CAAC,SAAS,EAAE,OAAO,EAAE,mBAAmB,CAAC;oBAC3D,IAAI,EAAE,SAAS;oBACf,WAAW,EAAE,qBAAqB;oBAClC,WAAW,EAAE,iCAAiC;iBAC/C;gBACD;oBACE,IAAI,EAAE,YAAY,CAAC,KAAK,EAAE,OAAO,EAAE,eAAe,CAAC;oBACnD,IAAI,EAAE,QAAQ;oBACd,WAAW,EAAE,oBAAoB;oBACjC,WAAW,EAAE,yBAAyB;iBACvC;aACF;SACF;QACD,YAAY,EAAE;YACZ,8FAA8F;YAC9F,0FAA0F;YAC1F,6FAA6F;YAC7F,8FAA8F;YAC9F,0FAA0F;YAC1F,wBAAwB;YACxB,aAAa,EAAE,CAAC,WAAW,6BAA6B,iBAAiB,CAAC;YAC1E,MAAM,EAAE;gBACN,GAAG,EAAE;oBACH,mBAAmB;oBACnB,YAAY;oBACZ,kBAAkB;oBAClB,sFAAsF;oBACtF,wFAAwF;oBACxF,iFAAiF;oBACjF,UAAU;oBACV,iFAAiF;oBACjF,+EAA+E;oBAC/E,iFAAiF;oBACjF,eAAe;oBACf,aAAa;oBACb,YAAY;oBACZ,wFAAwF;oBACxF,4EAA4E;oBAC5E,wFAAwF;oBACxF,wFAAwF;oBACxF,wFAAwF;oBACxF,oFAAoF;oBACpF,2EAA2E;oBAC3E,SAAS;oBACT,uFAAuF;oBACvF,sFAAsF;oBACtF,UAAU;oBACV,gBAAgB;oBAChB,sFAAsF;oBACtF,cAAc;oBACd,0FAA0F;oBAC1F,oFAAoF;oBACpF,iFAAiF;oBACjF,WAAW;oBACX,qFAAqF;oBACrF,yFAAyF;oBACzF,wBAAwB;oBACxB,aAAa;oBACb,6DAA6D;oBAC7D,0FAA0F;oBAC1F,uFAAuF;oBACvF,YAAY;oBACZ,iBAAiB;oBACjB,gBAAgB;oBAChB,WAAW;oBACX,YAAY;oBACZ,mFAAmF;oBACnF,wDAAwD;oBACxD,YAAY;iBACb;aACF;SACF;QACD,QAAQ,EAAE;YACR,mBAAmB,EAAE;gBACnB,WAAW,EAAE,WAAW,6BAA6B,eAAe;gBACpE,wFAAwF;gBACxF,+DAA+D;gBAC/D,8DAA8D;gBAC9D,2FAA2F;gBAC3F,oEAAoE;gBACpE,wFAAwF;gBACxF,2FAA2F;gBAC3F,0EAA0E;gBAC1E,wFAAwF;gBACxF,kCAAkC;gBAClC,UAAU,EAAE;oBACV,aAAa;oBACb,YAAY;oBACZ,kBAAkB;oBAClB,gBAAgB;oBAChB,cAAc;oBACd,uBAAuB;oBACvB,gBAAgB;oBAChB,kBAAkB;iBACnB;aACF;YACD,wFAAwF;YACxF,2FAA2F;YAC3F,oFAAoF;YACpF,yFAAyF;YACzF,+DAA+D;YAC/D,aAAa,EAAE;gBACb,UAAU,EAAE,IAAI;gBAChB,WAAW,EAAE,WAAW,6BAA6B,eAAe;aACrE;YACD,kBAAkB,EAAE,KAAK;YACzB,mBAAmB,EAAE,KAAK;YAC1B,sBAAsB,EAAE,KAAK;SAC9B;KACF,EACD,IAAI,EACJ,CAAC,CACF,CAAC;AACJ,CAAC;AAED,8CAA8C;AAC9C,MAAM,CAAC,MAAM,qBAAqB,GAAG,4BAA4B,CAAC;AAElE,kFAAkF;AAClF,MAAM,CAAC,MAAM,wBAAwB,GAAsB;IACzD,wFAAwF;IACxF,wCAAwC;IACxC,gEAAgE;IAChE,gBAAgB;IAChB,2DAA2D;IAC3D,4TAA4T;IAC5T,oSAAoS;CACrS,CAAC;AAEF;;8BAE8B;AAC9B,MAAM,CAAC,MAAM,0BAA0B,GAAsB;IAC3D,uEAAuE;IACvE,6EAA6E;IAC7E,sJAAsJ;IACtJ,4HAA4H;CAC7H,CAAC"}
@@ -598,7 +598,7 @@ export declare function auditDmBotAuthorDrop(ev: ParsedMessageEvent, dmKind: "im
598
598
  * DM leg in the pack exercised a synthetic envelope and a regression in Slack's own DM delivery to
599
599
  * us was invisible. This carve-out lets the driver in through the real front door.
600
600
  *
601
- * WHY IT IS NOT THE `OE_TEST_DRIVER_IDS` ALLOWLIST, which already lifts the same bot at the
601
+ * WHY IT IS NOT THE `wake.driverAllowlist` SETTING, which already lifts the same bot at the
602
602
  * engagement and mention doors. A DM seat holds a window token, delegation and memory, so admitting
603
603
  * a bot into a DM is a strictly larger grant than admitting one into a watched channel. Reusing the
604
604
  * channel allowlist would have widened the DM door for every deployment that already sets it, on
@@ -780,7 +780,7 @@ export interface NameDropRouteDeps {
780
780
  }) => Promise<WakeBackscrollLines | null>;
781
781
  /** The per-author membership probe the hydrated-context withhold uses (OPE-525,
782
782
  * {@link withholdNameDropContext}). Defaults to the live {@link slackAuthorIsMember}; injectable
783
- * so the withhold is testable without Slack. Never called on the shipped `taint` grain. */
783
+ * so the withhold is testable without Slack. Never called on the `taint` opt-out grain. */
784
784
  readonly authorIsMember?: SlackMemberProbe;
785
785
  /** Start the judged turn in a thread rooted at the triggering message. `context` (when present)
786
786
  * is the backscroll — ADDITIONAL history the channel appends before the delivery turn. */
@@ -1024,7 +1024,7 @@ export declare function applySlackExternalPolicy(ev: ParsedMessageEvent, p: Part
1024
1024
  * other in the plane. A `bot` line carries a bot id, and a machine author is never excluded, so only
1025
1025
  * `senderType: "user"` lines are asked about.
1026
1026
  *
1027
- * COSTS NOTHING ON THE SHIPPED GRAIN: `taint` returns the wake by reference before any probe runs.
1027
+ * COSTS NOTHING ON THE OPT-OUT GRAIN: `taint` returns the wake by reference before any probe runs.
1028
1028
  * On `withhold` it costs one TTL-cached `users.info` per DISTINCT hydrated author, resolved in
1029
1029
  * parallel, and the render budget caps the hydration at ten lines.
1030
1030
  */
@@ -1332,7 +1332,7 @@ export interface SupervisorRouteDeps {
1332
1332
  * {@link withholdNameDropContext}) — the same dep, spelled the same way, as the native route it
1333
1333
  * replaces ({@link NameDropRouteDeps.authorIsMember}). Defaults to the live
1334
1334
  * {@link slackAuthorIsMember}; injectable so the withhold is testable without Slack. Never called
1335
- * on the shipped `taint` grain, and never on a wake that carries no hydrated lines. */
1335
+ * on the `taint` opt-out grain, and never on a wake that carries no hydrated lines. */
1336
1336
  readonly authorIsMember?: SlackMemberProbe;
1337
1337
  /**
1338
1338
  * Start/continue the supervisor session under its reserved token. `window` is present ONLY on
@@ -1404,7 +1404,7 @@ export interface SupervisorRouteDeps {
1404
1404
  * Route one unowned message to the channel supervisor: pure disqualifiers, then the
1405
1405
  * fail-closed internal-channel confirmation (which also surfaces the member count for the
1406
1406
  * envelope), then create-or-fetch the supervisor session and deliver the judged wake. The
1407
- * caller (slack-front) only reaches here once the domain has opted into ambientSupervisor.
1407
+ * caller (slack-front) only reaches here once the domain has ambientSupervisor on (the default).
1408
1408
  *
1409
1409
  * `opts.directMention` (SINGLE-WINDOW @mention routing — the GitHub reference's
1410
1410
  * `treatMentionAsDirect`/`directMention` shape): the trigger is the `message.channels` SIBLING of