@open-everywhere/provider-slack 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/NOTICE +34 -4
  2. package/README.md +1 -1
  3. package/agent/app-manifest.ts +44 -9
  4. package/agent/channels/slack-commands.ts +9 -5
  5. package/agent/channels/slack-events.ts +73 -13
  6. package/agent/channels/slack-front.ts +25 -3
  7. package/agent/channels/slack-task-card.ts +182 -0
  8. package/agent/channels/slack.ts +518 -468
  9. package/agent/contributions.ts +1 -1
  10. package/agent/docs-generated.ts +1 -1
  11. package/agent/ops.ts +20 -2
  12. package/agent/setup/eve-credentials.ts +10 -2
  13. package/agent/setup/page.ts +12 -9
  14. package/agent/status.ts +28 -15
  15. package/dist/agent/app-manifest.d.ts +20 -4
  16. package/dist/agent/app-manifest.d.ts.map +1 -1
  17. package/dist/agent/app-manifest.js +29 -6
  18. package/dist/agent/app-manifest.js.map +1 -1
  19. package/dist/agent/channels/slack-commands.d.ts.map +1 -1
  20. package/dist/agent/channels/slack-commands.js +9 -6
  21. package/dist/agent/channels/slack-commands.js.map +1 -1
  22. package/dist/agent/channels/slack-events.d.ts +28 -6
  23. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  24. package/dist/agent/channels/slack-events.js +71 -14
  25. package/dist/agent/channels/slack-events.js.map +1 -1
  26. package/dist/agent/channels/slack-front.d.ts +4 -1
  27. package/dist/agent/channels/slack-front.d.ts.map +1 -1
  28. package/dist/agent/channels/slack-front.js +21 -3
  29. package/dist/agent/channels/slack-front.js.map +1 -1
  30. package/dist/agent/channels/slack-task-card.d.ts +38 -0
  31. package/dist/agent/channels/slack-task-card.d.ts.map +1 -0
  32. package/dist/agent/channels/slack-task-card.js +156 -0
  33. package/dist/agent/channels/slack-task-card.js.map +1 -0
  34. package/dist/agent/channels/slack.d.ts.map +1 -1
  35. package/dist/agent/channels/slack.js +516 -466
  36. package/dist/agent/channels/slack.js.map +1 -1
  37. package/dist/agent/contributions.d.ts +1 -1
  38. package/dist/agent/contributions.d.ts.map +1 -1
  39. package/dist/agent/contributions.js +1 -1
  40. package/dist/agent/contributions.js.map +1 -1
  41. package/dist/agent/docs-generated.js +1 -1
  42. package/dist/agent/docs-generated.js.map +1 -1
  43. package/dist/agent/ops.d.ts +5 -0
  44. package/dist/agent/ops.d.ts.map +1 -1
  45. package/dist/agent/ops.js +13 -2
  46. package/dist/agent/ops.js.map +1 -1
  47. package/dist/agent/setup/eve-credentials.d.ts +9 -2
  48. package/dist/agent/setup/eve-credentials.d.ts.map +1 -1
  49. package/dist/agent/setup/eve-credentials.js +9 -1
  50. package/dist/agent/setup/eve-credentials.js.map +1 -1
  51. package/dist/agent/setup/page.d.ts.map +1 -1
  52. package/dist/agent/setup/page.js +12 -10
  53. package/dist/agent/setup/page.js.map +1 -1
  54. package/dist/agent/status.d.ts.map +1 -1
  55. package/dist/agent/status.js +23 -15
  56. package/dist/agent/status.js.map +1 -1
  57. package/package.json +13 -5
@@ -26,7 +26,7 @@ export const CONTRIBUTIONS = {
26
26
  register: "agent/register.js#registerSlackProvider",
27
27
  contributions: [
28
28
  // ── channels ─────────────────────────────────────────────────────────────────────────────────
29
- { kind: "channel", name: "slack", module: "agent/channels/slack.js", summary: "The Slack surface. Wires Slack's webhook → an OpenEverywhere session, and — the point of the \"Slack-binding\" slice — populates the session auth context with the REAL Slack identity (team/channel/user…" },
29
+ { kind: "channel", name: "slack", module: "agent/channels/slack.js", summary: "The Slack surface. Wires Slack's webhook → an agent session, and — the point of the \"Slack-binding\" slice — populates the session auth context with the REAL Slack identity (team/channel/user…" },
30
30
  { kind: "channel", name: "slack-commands", module: "agent/channels/slack-commands.js", summary: "The Slack SLASH-COMMAND ingestion surface for \"/oe\" — the zero-inference control-plane escape hatch (backlog item 23): pure logic, instant code-rendered responses, NO model turn, so it keeps…" },
31
31
  { kind: "channel", name: "slack-setup-page", module: "agent/channels/slack-setup-page.js", summary: "The agent-led Slack setup page (OPE-2231) — `GET`/`POST /setup` create the Slack app from the shipped manifest with one pasted app configuration token, and `GET /setup/callback` takes the OAuth install's bot token. All three 404 until the credential vault is armed AND no install is recorded." },
32
32
  ],
@@ -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…`).\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 ✋ is placed by the agent's host when a turn actually kicks work\noff — its first delegation — and removed when the turn ends, so it is present and clickable exactly\nwhile 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## 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",
14
14
  },
15
15
  ];
package/agent/ops.ts CHANGED
@@ -26,15 +26,31 @@
26
26
  // unconfigured here. Every surface that says so says WHY, rather than claiming — as it did before —
27
27
  // that the credential store is not read at all.
28
28
 
29
- import { registerSurfaceAppManifest, registerSurfaceCredentialProbe } from "@open-everywhere/core/agent/adapter-api-ops.js";
29
+ import {
30
+ agentCommandName,
31
+ configuredAgentName,
32
+ registerSurfaceAppManifest,
33
+ registerSurfaceCredentialProbe,
34
+ } from "@open-everywhere/core/agent/adapter-api-ops.js";
30
35
  import {
31
36
  SLACK_APP_CONSOLE_URL,
32
37
  SLACK_APP_MANIFEST_COLLECT,
33
38
  SLACK_APP_MANIFEST_STEPS,
34
39
  SLACK_APP_NAME_MAX,
35
40
  slackAppManifestTemplate,
41
+ type SlackAppManifestOptions,
36
42
  } from "./app-manifest.js";
37
43
 
44
+ /** The manifest options for this deployment: the caller's explicit name over the settings. */
45
+ export function slackManifestOptionsFromSettings(opts: { readonly appName?: string } = {}): SlackAppManifestOptions {
46
+ const agentName = configuredAgentName();
47
+ return {
48
+ ...(opts.appName === undefined ? {} : { appName: opts.appName }),
49
+ ...(agentName === undefined ? {} : { agentName }),
50
+ commandName: agentCommandName(),
51
+ };
52
+ }
53
+
38
54
  /** Register the slack ops-plane arms (app manifest + telemetry presence probe). */
39
55
  export function registerSlackOps(): void {
40
56
  // THE PASTE-READY APP MANIFEST (OPE-1603). Minting the Slack app stays human, so the ops plane's
@@ -45,7 +61,9 @@ export function registerSlackOps(): void {
45
61
  consoleUrl: SLACK_APP_CONSOLE_URL,
46
62
  steps: SLACK_APP_MANIFEST_STEPS,
47
63
  collect: SLACK_APP_MANIFEST_COLLECT,
48
- template: (opts) => slackAppManifestTemplate(opts),
64
+ // RENDERED FROM THE SETTINGS on every call (OPE-2334): the configured `identity.name` names the
65
+ // app, and `identity.commandName` names the command. An explicit `appName` still wins.
66
+ template: (opts) => slackAppManifestTemplate(slackManifestOptionsFromSettings(opts)),
49
67
  appNameMax: SLACK_APP_NAME_MAX,
50
68
  });
51
69
  // THE PRESENCE PROBE IS ENV-ONLY, AND THAT IS A KNOWN GAP (OPE-2231). `registerSurfaceCredentialProbe`
@@ -33,14 +33,21 @@ const MAX_SKEW_SECONDS = 60 * 5;
33
33
 
34
34
  /**
35
35
  * Eve's inbound verifier. THROWS to reject — eve catches, logs, and answers 401, so a thrown error
36
- * here is the refusal. It must never return a verdict as a value.
36
+ * here is the refusal.
37
+ *
38
+ * 🔴 AN ACCEPT RETURNS `true`, NEVER NOTHING (OPE-2348). eve 0.71.0's Slack verifier
39
+ * (`@chat-adapter/slack` `verifySlackRequest`, inlined into eve's `slackChannel`) reads the verifier's
40
+ * RESULT: `if (!result) throw "Slack webhook verifier rejected the request"`. A string result
41
+ * replaces the body, and any other truthy value keeps the raw body. This function used to return
42
+ * `undefined` on success, so eve refused EVERY request OE forwarded to it — every native mention and
43
+ * every click on eve's own question buttons answered 401. `true` keeps eve on the raw body it read.
37
44
  *
38
45
  * The message names no secret and no header value: a verification failure is one of three things to
39
46
  * an attacker (no secret configured, bad signature, stale timestamp) and telling them which is free
40
47
  * information. The three are distinguished in the thrown text only to the extent an OPERATOR reading
41
48
  * their own logs needs, which is why it names the CONDITION and never the input.
42
49
  */
43
- export async function verifySlackInboundForEve(request: Request, body: string): Promise<void> {
50
+ export async function verifySlackInboundForEve(request: Request, body: string): Promise<true> {
44
51
  const secret = await slackSigningSecret();
45
52
  if (!secret) {
46
53
  throw new Error(
@@ -57,6 +64,7 @@ export async function verifySlackInboundForEve(request: Request, body: string):
57
64
  if (!Number.isFinite(skew) || skew > MAX_SKEW_SECONDS) throw new Error("slack inbound: the signed timestamp is outside the accepted window.");
58
65
 
59
66
  if (!slackSignatureMatches(secret, timestamp, body, signature)) throw new Error("slack inbound: the signature does not match.");
67
+ return true;
60
68
  }
61
69
 
62
70
  /**
@@ -45,6 +45,7 @@
45
45
  import {
46
46
  BUTTON_STYLE,
47
47
  CODE_BLOCK_STYLE,
48
+ agentDisplayName,
48
49
  appendAudit,
49
50
  credentialKeyArmed,
50
51
  deploymentOrg,
@@ -56,7 +57,8 @@ import {
56
57
  type EncryptedKvVault,
57
58
  } from "@open-everywhere/core/agent/adapter-api.js";
58
59
  import { randomBytes, timingSafeEqual } from "node:crypto";
59
- import { SLACK_DEPLOY_HOST_PLACEHOLDER, slackAppManifestTemplate, slackAppNameProblem } from "../app-manifest.js";
60
+ import { SLACK_DEPLOY_HOST_PLACEHOLDER, slackAppManifestTemplate, slackAppNameProblem, slackManifestAppName } from "../app-manifest.js";
61
+ import { slackManifestOptionsFromSettings } from "../ops.js";
60
62
  import {
61
63
  SETUP_COOKIE_NAME,
62
64
  mintSetupCookie,
@@ -171,9 +173,10 @@ function baseUrl(deps: SlackSetupPageDeps): string | undefined {
171
173
  return pageBaseUrl(deps.env ?? process.env);
172
174
  }
173
175
 
174
- /** The manifest, with this deployment's host substituted for the shipped placeholder. */
176
+ /** The manifest, with this deployment's host substituted for the shipped placeholder. Rendered from
177
+ * the settings (OPE-2334): the form's explicit name wins, else the configured `identity.name`. */
175
178
  function manifestForHost(host: string, appName: string | undefined): string {
176
- const template = slackAppManifestTemplate(appName ? { appName } : {});
179
+ const template = slackAppManifestTemplate(slackManifestOptionsFromSettings(appName ? { appName } : {}));
177
180
  // The placeholder appears in four request URLs; a global replace is the substitution.
178
181
  return template.split(SLACK_DEPLOY_HOST_PLACEHOLDER).join(host);
179
182
  }
@@ -189,7 +192,7 @@ function manifestHost(base: string): string {
189
192
  function claimScreen(retry: boolean): Response {
190
193
  return securePageResponse(
191
194
  200,
192
- "Set up Open Everywhere",
195
+ `Set up ${agentDisplayName()}`,
193
196
  (retry ? `<p style="color:#b91c1c">That code did not match. Check the deployment's logs and try again.</p>` : "") +
194
197
  `<p>This deployment logged a one-time setup code when it started. Find it in the deployment's logs ` +
195
198
  `(<code>vercel logs</code>, <code>docker logs</code>) and paste it here.</p>` +
@@ -210,14 +213,14 @@ function createScreen(host: string): Response {
210
213
  `<p>Open <code>api.slack.com/apps</code>, scroll to <strong>Your App Configuration Tokens</strong>, ` +
211
214
  `and generate one for the workspace that will host the agent. Paste it below. It needs the ` +
212
215
  `<code>app_configurations:write</code> scope and lasts about 12 hours.</p>` +
213
- `<p>Open Everywhere will create the Slack app for <code>${escapeHtml(host)}</code> and keep its ` +
216
+ `<p>This page will create the Slack app for <code>${escapeHtml(host)}</code> and keep its ` +
214
217
  `credentials in this deployment's own encrypted store. The configuration token is used once and never stored.</p>` +
215
218
  `<form method="post" action="${escapeHtml(SLACK_SETUP_PATH)}">` +
216
219
  `<input type="hidden" name="${FIELD_ACTION}" value="create">` +
217
220
  `<p><input name="${FIELD_CONFIG_TOKEN}" type="password" autocomplete="off" required style="width:100%;padding:.5rem;font:inherit" ` +
218
221
  `placeholder="xoxe.xoxp-… (app configuration token)"></p>` +
219
222
  `<p><label>App name (optional)<br><input name="${FIELD_APP_NAME}" type="text" autocomplete="off" ` +
220
- `style="width:100%;padding:.5rem;font:inherit" placeholder="Open Everywhere"></label></p>` +
223
+ `style="width:100%;padding:.5rem;font:inherit" placeholder="${escapeHtml(slackManifestAppName(slackManifestOptionsFromSettings()))}"></label></p>` +
221
224
  `<button type="submit" style="${BUTTON_STYLE}">Create the Slack app</button>` +
222
225
  `</form>`,
223
226
  );
@@ -228,7 +231,7 @@ function installScreen(authorizeUrl: string): Response {
228
231
  return securePageResponse(
229
232
  200,
230
233
  "Install to your workspace",
231
- `<p>The Slack app exists. Installing it is the step that gives Open Everywhere a bot token for ` +
234
+ `<p>The Slack app exists. Installing it is the step that gives ${escapeHtml(agentDisplayName())} a bot token for ` +
232
235
  `your workspace, and it has to be done by someone who can approve an app there.</p>` +
233
236
  `<p><a href="${escapeHtml(authorizeUrl)}" style="${BUTTON_STYLE};display:inline-block;text-decoration:none">` +
234
237
  `Install to Slack</a></p>` +
@@ -243,7 +246,7 @@ function doneScreen(state: SlackInstallState): Response {
243
246
  return securePageResponse(
244
247
  200,
245
248
  "Slack is installed",
246
- `<p>Open Everywhere is installed in <strong>${escapeHtml(state.teamName ?? state.teamId)}</strong>. ` +
249
+ `<p>${escapeHtml(agentDisplayName())} is installed in <strong>${escapeHtml(state.teamName ?? state.teamId)}</strong>. ` +
247
250
  `Its credentials are in this deployment's encrypted store; nothing else needs setting.</p>` +
248
251
  `<p>Two config edits are left. They are not secrets — they say which workspace this deployment ` +
249
252
  `trusts, and who administers it — so they belong in your git repo and deploy with it.</p>` +
@@ -336,7 +339,7 @@ export async function handleSlackSetupGet(req: Request, deps: SlackSetupPageDeps
336
339
  return securePageResponse(
337
340
  500,
338
341
  "This deployment does not know its own URL",
339
- `<p>Open Everywhere could not work out the public URL to put in the Slack app, so it will not ` +
342
+ `<p>This deployment could not work out the public URL to put in the Slack app, so it will not ` +
340
343
  `create one that points at the wrong place.</p>` +
341
344
  `<p>Set <code>OE_PUBLIC_BASE_URL</code> to this deployment's public URL and redeploy.</p>`,
342
345
  );
package/agent/status.ts CHANGED
@@ -12,12 +12,18 @@
12
12
  // 🔴 EVERY ANSWER IS A VERDICT, AND EVERY VERDICT IS ONE THIS BUNDLE CAN DEFEND. The route is
13
13
  // public, so nothing here returns a token, a team id, a channel key, a count, or a timestamp.
14
14
 
15
- import { deploymentOrg, getOperationalStore, unifiedDomainSessionKey, type SurfaceStatus } from "@open-everywhere/core/agent/adapter-api.js";
15
+ import {
16
+ deploymentOrg,
17
+ getOperationalStore,
18
+ surfaceEventVerifiedAt,
19
+ unifiedDomainSessionKey,
20
+ type SurfaceStatus,
21
+ } from "@open-everywhere/core/agent/adapter-api.js";
16
22
 
17
23
  const set = (v: string | undefined): boolean => v !== undefined && v.trim() !== "";
18
24
 
19
25
  /**
20
- * The two `channel_session` key shapes a Slack turn can land under, and why BOTH are needed.
26
+ * The two `channel_session` key shapes the LEGACY evidence can be found under.
21
27
  *
22
28
  * A session row is keyed by the supervisor grain's `registryKey`
23
29
  * (`packages/core/agent/channels/unified-supervisor.ts` `supervisorGrainFor`), and that key is NOT
@@ -30,10 +36,9 @@ const set = (v: string | undefined): boolean => v !== undefined && v.trim() !==
30
36
  * • a NAMED window group → `oe-domain::<group name>`;
31
37
  * • a DM seat → the DM pseudo-domain's key.
32
38
  *
33
- * 🔴 THE LAST THREE CARRY NO PLATFORM MARKER AT ALL, so no prefix can find them. This deployment's
34
- * shipped settings turn topology on, so the first shape alone matched nothing — the field could
35
- * never read `yes`, which is worse than not having it. Matching both of the first two covers the
36
- * default solo case; the rest stay `unknown`, and `docs/deploy/contract.md` says so.
39
+ * The last three carry no platform marker, so no prefix can find them. That is why the PRIMARY
40
+ * evidence is now the signature gate's own stamp (OPE-2242, below), which no key shape can hide.
41
+ * These prefixes stay only for a deployment whose Slack traffic all predates the stamp.
37
42
  */
38
43
  // Built with core's OWN key builder, never a copied prefix literal: a change to the key shape then
39
44
  // reaches this reporter instead of silently making it match nothing again.
@@ -42,18 +47,26 @@ const SLACK_SESSION_PREFIXES = ["slack/", unifiedDomainSessionKey("slack/")] as
42
47
  /**
43
48
  * Has this deployment ever VERIFIED a signed Slack event?
44
49
  *
45
- * The evidence is a Slack channel session in the operational store. A session row is written by a
46
- * turn, and a turn is reached only AFTER `/eve/v1/slack`'s `v0=` HMAC gate accepts the delivery, so
47
- * a row is proof that a signed Slack event was verified by this deployment. It is NOT proof about
48
- * the CURRENT signing secret: a rotation, or a restored database, leaves the row behind.
50
+ * Two kinds of evidence, checked in order:
49
51
  *
50
- * 🔴 ONE-DIRECTIONAL ON PURPOSE. No row means NO EVIDENCE, not "no": a workspace that installed the
51
- * app and has not yet addressed it writes nothing, a deployment whose store was just migrated
52
- * writes nothing, and the three key shapes above carry no platform marker to find. "unknown" is the
53
- * honest answer there, and reporting "no" would train the reader to ignore the field on exactly the
54
- * deployments it is meant to help.
52
+ * 1. THE STAMP (OPE-2242). `slack-front.ts` calls core's `stampSurfaceEventVerified("slack")`
53
+ * right after its `v0=` HMAC verify passes, on every delivery and before any classification.
54
+ * It is a deployment-level `instance_meta` row, so it reads the same for every domain topology:
55
+ * `unified` mode, a named window group and a DM-only deployment included.
56
+ * 2. A LEGACY SESSION ROW. A `channel_session` row is written by a turn, and a turn is reached only
57
+ * after the same gate accepts the delivery, so a row is proof too. It covers a deployment whose
58
+ * traffic predates the stamp, in the two key shapes above that carry the platform.
59
+ *
60
+ * Neither is proof about the CURRENT signing secret: a rotation, or a restored database, leaves
61
+ * both behind.
62
+ *
63
+ * 🔴 ONE-DIRECTIONAL ON PURPOSE. No evidence means NO EVIDENCE, not "no": a workspace that has not
64
+ * yet sent the app an event, a store that cannot be read, or a deployment whose store was just
65
+ * migrated writes nothing. "unknown" is the honest answer there, and reporting "no" would train the
66
+ * reader to ignore the field on exactly the deployments it is meant to help.
55
67
  */
56
68
  async function eventsVerified(): Promise<SurfaceStatus["eventsVerified"]> {
69
+ if ((await surfaceEventVerifiedAt("slack")) !== null) return "yes";
57
70
  try {
58
71
  const store = getOperationalStore();
59
72
  const org = deploymentOrg();
@@ -2,19 +2,35 @@
2
2
  * substitutes it (packages/core/agent/ops/surface-manifest.ts); the setup check leaves it for the
3
3
  * operator, who has usually not deployed yet when they run it. */
4
4
  export declare const SLACK_DEPLOY_HOST_PLACEHOLDER = "YOUR-DEPLOY-URL";
5
- /** The app name the manifest carries when the operator names none. */
5
+ /** The app name the manifest carries when the operator names none AND the deployment configures no
6
+ * `identity.name`. The same brand default core's `agentDisplayName()` falls back to. */
6
7
  export declare const SLACK_DEFAULT_APP_NAME = "Open Everywhere";
8
+ /** Slack's limit on a shortcut's name. A longer one fails at paste time. */
9
+ export declare const SLACK_SHORTCUT_NAME_MAX = 24;
7
10
  /** Slack's limit on an app name (`display_information.name`). A longer name fails at paste time. */
8
11
  export declare const SLACK_APP_NAME_MAX = 35;
9
12
  /** Why `raw` cannot name the Slack app, or `undefined` when it can. The setup CLI's
10
13
  * `--app-name` reads this; the ops tool applies core's generic check with {@link SLACK_APP_NAME_MAX}. */
11
14
  export declare function slackAppNameProblem(raw: string): string | undefined;
12
- /** Options for {@link slackAppManifestTemplate}. */
15
+ /** Options for {@link slackAppManifestTemplate}. This module imports nothing, so the caller reads
16
+ * the deployment's settings and passes them in (OPE-2334): `agent/ops.ts` and the `/setup` page
17
+ * read core's resolvers, and the setup CLI reads the app's own settings file. */
13
18
  export interface SlackAppManifestOptions {
14
- /** The app name and the bot user's display name. Default {@link SLACK_DEFAULT_APP_NAME}. A
15
- * second install in one workspace needs its own name, or the two apps look the same. */
19
+ /** An EXPLICIT app name (`--app-name`, the ops tool's `appName`, the `/setup` form). It wins
20
+ * over {@link agentName}. A second install in one workspace needs its own name, or the two apps
21
+ * look the same. */
16
22
  readonly appName?: string;
23
+ /** The deployment's configured name — the `identity.name` setting. It names the app, the bot user,
24
+ * the shortcuts and the command description when no explicit {@link appName} is given. Absent ⇒
25
+ * {@link SLACK_DEFAULT_APP_NAME}. */
26
+ readonly agentName?: string;
27
+ /** The slash command's name without the slash — the `identity.commandName` setting. Absent ⇒
28
+ * {@link SLACK_DEFAULT_COMMAND_NAME}. */
29
+ readonly commandName?: string;
17
30
  }
31
+ /** The app name a manifest carries: the explicit name, else the configured name, else the default.
32
+ * A configured name past Slack's limit is cut to fit, because Slack refuses the whole paste. */
33
+ export declare function slackManifestAppName(opts?: SlackAppManifestOptions): string;
18
34
  /** A ready-to-paste Slack app manifest (Slack's "create from manifest" flow) with the scope/event
19
35
  * set docs/deploy/surfaces/slack.md walks through by hand. */
20
36
  export declare function slackAppManifestTemplate(opts?: SlackAppManifestOptions): string;
@@ -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,sEAAsE;AACtE,eAAO,MAAM,sBAAsB,oBAAoB,CAAC;AAExD,oGAAoG;AACpG,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC;0GAC0G;AAC1G,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAKnE;AAED,oDAAoD;AACpD,MAAM,WAAW,uBAAuB;IACtC;6FACyF;IACzF,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;+DAC+D;AAC/D,wBAAgB,wBAAwB,CAAC,IAAI,GAAE,uBAA4B,GAAG,MAAM,CAyJnF;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,EAOrD,CAAC;AAEF;;8BAE8B;AAC9B,eAAO,MAAM,0BAA0B,EAAE,SAAS,MAAM,EAKvD,CAAC"}