# Ephemera — Slack messaging (App Manifest + Web API + CLI)

> Self-executing Markdown. The **generic Slack binding** of the *messaging* intent — send and receive Slack
> messages through a single **App Manifest** (scopes, event subscriptions, interactivity) that Slack's App
> Config API renders, validates, and installs, driven by this plan's Web API calls and, for Socket-Mode
> MODEs, the CLI. There is no infrastructure to provision beyond the app registration itself — Slack hosts
> the app record; this plan owns rendering the manifest correctly for the chosen MODE, minting/installing
> it, and handing back a referenceable handle. The live Slack API is the source of truth; this file is
> intent + ledger + audit.

> **Provides / Requires**: **Provides** `slack-bot(APP_ID@TEAM)` (bot MODEs — `bot-token` / `bot-socket` /
> `bot-http`) — the referenceable handle a consumer wires by app id + team; `slack-channel(webhook)`
> (MODE=`incoming-webhook`) — a single inbound URL scoped to one channel. **Requires** nothing mandatory —
> a Slack workspace and an App Manifest are what this plan provisions itself; the operator only supplies
> OAuth consent at install time (the 🔴 gate).

---

## 🤖 Director prompt

You are the Director. Execute this plan:
- Observe-before-act; verify each step before advancing
- **🔴 GATE only on create-app (§2) and workspace-install (§3)** — these are the only two calls that mint
  live tokens; manifest render/validate and teardown-observe are ungated
- Secrets go to the macOS Keychain, **never echoed** — every `security` call is scoped
  `-s <item-name> -a "$KEYCHAIN_ACCOUNT"`; the ledger (this file) stores Keychain item **names** only,
  never a token value
- Teardown revokes + deletes only what this plan created — the workspace itself is untouched
- Use only the commands in this plan

> **Candor:** authored, dogfood is human-gated — creating the app and installing it into a workspace both
> mint live tokens and need a real Slack workspace + OAuth consent, done by a human, not this plan; `status`
> stays `not-created` through every authoring task. Confirm exact Web API shapes against the live
> `api.slack.com` reference before any future re-run — the live API remains the source of truth.

```
Legend  🟢 create · 🟡 config · 🔴 GATE · 💥 destructive · ⏳ wait · ✔ verify
```

## Intent

Send and receive Slack messages through a single App Manifest, rendered deterministically from a `MODE`
selection, then created/installed via the Web API (`apps.manifest.*`, `oauth.v2.access`) and, for
Socket-Mode-eligible MODEs, kept alive via the CLI/SDK. The unit of work is a registered Slack app plus
whatever tokens its MODE requires — a bot user token, an app-level token, or a single incoming-webhook URL.

### What this unit owns — the spine, not the closet

This unit owns exactly one piece of spine knowledge: the deterministic **MODE → manifest render matrix**
(below) — given a `MODE`, the manifest's `socket_mode_enabled`, `request_url` fields, and scope set are
fully determined, no ambiguity. Everything Slack's manifest schema *can* express beyond that — App Home
tabs, interactivity/shortcuts blocks, scope-preset bundles, custom Block Kit surfaces, workflow steps — is
**not** pre-shipped here. An agent authors the specific manifest block a consumer actually needs, on
demand, against this matrix's invariants; a **realized** plan therefore carries only the shape it actually
used, never the full manifest surface Slack's docs describe.

| MODE | secret(s) | data flow | public URL? | canonical use |
|------|-----------|-----------|-------------|---------------|
| `incoming-webhook` | webhook URL | outbound only | no | API → Slack |
| `bot-token` | `xoxb-` | outbound `chat.postMessage` | no | rich notify |
| `bot-socket` | `xoxb-` + `xapp-` | bidirectional, Socket Mode (free) | no | home agent |
| `bot-http` | `xoxb-` + signing secret | bidirectional, Events HTTP | yes | hosted (non-free) |

### Candor

- **Authored, dogfood is human-gated.** Creating the app (§2) and installing it into a workspace (§3) both
  mint live tokens and need a real Slack workspace + OAuth consent — a human, not this plan, does that;
  `status` stays `not-created` through every authoring task.
- Validate the rendered manifest against `apps.manifest.validate` at apply time, before
  `apps.manifest.create`/`.update` — a manifest that parses as valid YAML/JSON can still fail Slack's
  schema (an unknown scope name, a missing `request_url` on an HTTP event subscription, etc).
- The live Slack API (`api.slack.com`, `apps.manifest.*`, `auth.test`) is the source of truth, not this
  file — treat every realized value recorded here as a cache of what Slack last confirmed.
- **Connection ≠ event delivery.** `apps.connections.open` returning a `wss://` url proves the Socket Mode
  connection *opens* — it does not prove events actually arrive over it (pitfall #1, a private-channel
  scope snapshot that can mask a real gap; pitfall #2, a phantom open websocket with no traffic). This
  unit's acceptance proves the socket opens; real event delivery is a dogfood/composition check one layer
  up, not this plan's job.

## Provisioning Inputs

| # | Question | Options | Default | Sets | Manifest field |
|---|----------|---------|---------|------|----------------|
| 1 | Which delivery/runtime MODE? | `incoming-webhook` / `bot-token` / `bot-socket` / `bot-http` | `bot-socket` | `MODE` | drives `settings.socket_mode_enabled` + every `request_url` (see MODE → manifest render matrix) |
| 2 | Bot scopes? | free-text (comma-separated Slack scope names) | `chat:write, app_mentions:read, im:history, im:write` | `SCOPES` | `oauth_config.scopes.bot` |
| 3 | Subscribed events? | free-text (comma-separated Slack event names) | `app_mention, message.im` | `EVENTS` | `settings.event_subscriptions.bot_events` |
| 4 | How does the manifest reach Slack? | `manual-paste` / `api` | `manual-paste` | `DELIVERY` | n/a — `manual-paste`: paste into the App Config UI; `api`: `apps.manifest.create`/`.update` |
| 5 | Public request URL? | free-text URL (`bot-http` only) | none | `REQUEST_URL` | `settings.event_subscriptions.request_url` + `settings.interactivity.request_url` + every slash command's `url` |
| 6 | Environment | `dev` / `stg` / `prod` | `dev` | `ENV` | `APP_NAME` (`home-agent-${ENV}`) + every resource name |

**Read-back note (pitfall #7):** the default `SCOPES` above covers *posting* + *IM* only. If a consumer
needs to *read* channel/group/MPIM history (not just receive events pushed to it), add the matching
`-history` read scopes — `channels:history` / `groups:history` / `mpim:history`.

**`KEYCHAIN_ACCOUNT`** (default `ephemera-slack-${ENV}`) is not a manifest field — it scopes every
`security` Keychain call for this plan's secrets and **MUST differ** from the prod ORC account
(pitfall #10), so this plan's secrets can never collide with, or be readable via, the production account.

```yaml
# → written into Live State once resolved
resolved_inputs:
  mode:             bot-socket
  scopes:           "chat:write,app_mentions:read,im:history,im:write"
  events:           "app_mention,message.im"
  delivery:         manual-paste
  request_url:      null            # bot-http only
  env:              dev
  keychain_account: ephemera-slack-dev
  resolved_by: <operator>
  resolved_at: <date>
```

## 0. Variables

```bash
export ENV="dev" MODE="bot-socket" DELIVERY="manual-paste"
export APP_NAME="home-agent-${ENV}"
export SCOPES="chat:write,app_mentions:read,im:history,im:write"
export EVENTS="app_mention,message.im"
export KEYCHAIN_ACCOUNT="ephemera-slack-${ENV}"   # MUST differ from the prod ORC account (pitfall #10)
export KC_BOT_TOKEN="slack-bot-token-${ENV}" KC_APP_TOKEN="slack-app-token-${ENV}" KC_WEBHOOK="slack-webhook-${ENV}"
```

## Dependency frontier

```
manifest (§1) ─> create-app 🔴 (§2) ─> install 🔴 (§3) ─> [§4 Provides, §5 acceptance]
```
Non-negotiable edges: the manifest must render and validate before `apps.manifest.create` (§2, 🔴 — mints
the app record); the app must exist before workspace install (§3, 🔴 — mints the bot/app tokens); install
must complete before this plan can hand out `slack-bot(APP_ID@TEAM)` / `slack-channel(webhook)` (§4) or run
acceptance (§5). Teardown reverses this — revoke + delete the app; the workspace itself is untouched.

*(§1–§3 movements — render/create/install — are authored below; §4 Provides, §5 acceptance, the Step-2b
inbound-mute runbook, Update, Teardown, and Deliberately-not-included are authored further below in this
same file — this unit's authoring is now complete.)*

## MODE → manifest render matrix

| MODE | `socket_mode_enabled` | `request_url` fields | scope set |
|------|------------------------|-----------------------|-----------|
| `bot-socket` | `true` | omitted everywhere — no `event_subscriptions.request_url`, no `interactivity.request_url`, no slash-command `url` | `SCOPES` as resolved (bidirectional bot scopes) |
| `bot-http` | `false` | **required** — `${REQUEST_URL}` set on `event_subscriptions.request_url`, `interactivity.request_url`, and every slash command's `url` | `SCOPES` as resolved (bidirectional bot scopes) |
| `incoming-webhook` | omitted (no events, no socket) | omitted (no `event_subscriptions`; ⚠ `bot_user` **required** — proven live 2026-07-02, see below) | `scopes.bot: [incoming-webhook]` only |
| `bot-token` | omitted (no events, no socket) | omitted (no `bot_user`/`event_subscriptions` blocks) | posting scopes only (e.g. `chat:write`) |

These four rows are the entire owned invariant — deterministic, no branching beyond `MODE`. Any manifest
block not implied by this table (App Home, shortcuts, workflow steps, scope presets beyond posting/read) is
authored fresh, per-consumer, in a later realized plan — never pre-shipped generically here.

## Blueprint — Slack App Manifest

> **Candor:** the manifest is a pure function of the Provisioning Inputs plus the MODE → manifest render
> matrix above — there is no additional judgment exercised at render time. What follows is the top-level
> schema shape Slack's App Manifest API expects (`display_information` / `features` / `oauth_config` /
> `settings`), annotated with which sub-block each MODE emits or omits. Anything Slack's schema can express
> beyond these four top-level keys and their MODE-gated children (App Home config, interactivity blocks,
> shortcuts, workflow steps, org-wide app settings) is *not* pre-shipped here — confirm exact field shapes
> against `https://docs.slack.dev/reference/app-manifest` (the former `api.slack.com/reference/manifests`
> now 302s there) before authoring anything beyond this shape, since the live API is the source of truth,
> not this file.

Slack's App Manifest is a single YAML/JSON document with four top-level keys:

```yaml
display_information:      # always present — name/description/branding, MODE-independent
  name: ${APP_NAME}
  description: <free text>
  background_color: "#4A154B"   # optional
features:                 # bot_user present in EVERY MODE — any bot scope (incoming-webhook included)
  bot_user:               # makes Slack demand one: "OAuth requires bot user" (proven live 2026-07-02)
    display_name: <bot display name>
    always_online: true | false   # optional; incoming-webhook MODE can omit it
oauth_config:
  scopes:
    bot: [ ...${SCOPES} ]   # bot-socket/bot-http/bot-token: SCOPES as resolved
                            # incoming-webhook: [ incoming-webhook ] — the ONLY bot scope, replaces SCOPES
settings:
  event_subscriptions:      # present for bot-socket/bot-http; ABSENT for incoming-webhook/bot-token
    bot_events: [ ...${EVENTS} ]
    request_url: ${REQUEST_URL}   # bot-http ONLY — bot-socket omits this key entirely
  interactivity:            # authored on demand, not by the realized default — if present:
    is_enabled: true
    request_url: ${REQUEST_URL}   # bot-http: required; bot-socket: omitted
  socket_mode_enabled: true | false   # true only for bot-socket; false for bot-http; key omitted for
                                      # incoming-webhook/bot-token (no bot user, no event delivery at all)
  org_deploy_enabled:    false
  token_rotation_enabled: false
```

Per-MODE inclusion rules (restating the render matrix as manifest-shape deltas, not new invariants):

- **`bot-socket`** — `features.bot_user` present, `settings.socket_mode_enabled: true`, `event_subscriptions`
  present with `bot_events` but **no** `request_url` key anywhere (event_subscriptions, interactivity, or
  any slash command) — Slack rejects a `request_url` alongside `socket_mode_enabled: true`.
- **`bot-http`** — `features.bot_user` present, `settings.socket_mode_enabled: false`, and `${REQUEST_URL}`
  is **required** on `event_subscriptions.request_url`, `interactivity.request_url` (if interactivity is
  authored), and every slash command's `url` — an HTTP-mode manifest with any of those blocks present but no
  `request_url` fails `apps.manifest.validate`.
- **`incoming-webhook`** — ⚠ `features.bot_user` **IS required** (proven live 2026-07-02: `incoming-webhook`
  is a *bot* scope, and Slack's validator rejects any manifest carrying a bot scope without a `bot_user` —
  the error is *"OAuth requires bot user"*; the `display_name` simply names the poster). No
  `event_subscriptions`, no `socket_mode_enabled`; `oauth_config.scopes.bot` is replaced wholesale with
  `[ incoming-webhook ]` — `SCOPES` from Provisioning Inputs does not apply to this MODE; and
  `settings.incoming_webhooks.incoming_webhooks_enabled: true` is required (confirmed against the live
  schema 2026-07-02).
- **`bot-token`** — `features.bot_user` present (a bot identity exists to post as), but no
  `event_subscriptions`, no `interactivity`, no `socket_mode_enabled` — posting-only scopes (e.g.
  `chat:write`), no inbound event delivery of any kind.

**Delivery (`DELIVERY` input) — how the rendered manifest reaches Slack:**

- **`manual-paste`** (default) — print the rendered manifest to stdout, then a human opens
  `https://api.slack.com/apps?new_app=1` → **Create New App** → **From an app manifest** → picks the target
  workspace → pastes the manifest — ⚠ **the paste dialog defaults to its JSON tab** (proven live 2026-07-02:
  YAML pasted there reads "invalid"); click the **YAML** tab first, or hand the human JSON — → reviews the
  scope/URL summary Slack shows → confirms. No API call, no config token needed; this is the 🔴-gated path
  §2 assumes by default.
- **`api`** — `apps.manifest.create` (or `.update` for an existing `APP_ID`) with an App Configuration Token
  (`xoxe-`) in the `Authorization` header — mints/updates the app record programmatically. Needs a config
  token minted by a human in the Slack API console first; not exercised by this authoring task.

**Validation — local vs. live:**

- **Local (this task, agent-runnable, no token):** YAML parses, and the MODE-gated invariants hold —
  `socket_mode_enabled` matches MODE, `request_url` is present/absent per MODE, the scope set matches
  `SCOPES` (or is replaced by `incoming-webhook` for that MODE). This catches shape/typo errors before a
  human ever touches the Slack UI, but it is **not** Slack's own schema check.
- **Live, authoritative (`apps.manifest.validate`):** requires an App Configuration Token (`xoxe-`) and a
  live API call — confirms Slack itself accepts the manifest (valid scope names, no conflicting fields,
  size limits, etc). This is the true gate before `apps.manifest.create`/`.update`; it is human-gated
  (minting a config token is a manual step in the Slack API console) and is **not** part of this task's
  runnable test — recorded here as `pending` until a human runs it.

## 1. Render manifest  🟢

> No gate — this is a local, reversible file write (a pure function of the Provisioning Inputs + the
> MODE → manifest render matrix above); nothing external is touched, no token is minted, no app exists yet.

`slack-manifest.yaml` (already rendered in a prior authoring task, per the resolved inputs for
`MODE=bot-socket` — `features.bot_user` present, `socket_mode_enabled: true`, no `request_url` anywhere,
`oauth_config.scopes.bot` = `$SCOPES` as resolved):

```yaml
display_information:
  name: home-agent-dev
  description: Home agent (Ephemera messaging.slack binding)
  background_color: "#4A154B"
features:
  bot_user:
    display_name: home-agent
    always_online: true
oauth_config:
  scopes:
    bot: [ chat:write, app_mentions:read, im:history, im:write ]
settings:
  event_subscriptions:
    bot_events: [ app_mention, message.im ]
  socket_mode_enabled: true
  org_deploy_enabled: false
  token_rotation_enabled: false
```

✔ verify (local, read-only, re-runnable, no token, no gate):
```bash
cd <project>   # the directory containing this plan's slack-manifest.yaml — your project root
python3 - <<'PY'
import yaml, sys
d = yaml.safe_load(open("slack-manifest.yaml"))
assert d["settings"]["socket_mode_enabled"] is True, "bot-socket must set socket_mode_enabled: true"
assert "request_url" not in d["settings"].get("event_subscriptions", {}), "bot-socket must omit request_url"
assert "request_url" not in d["settings"].get("interactivity", {}), "bot-socket must omit request_url"
assert d["oauth_config"]["scopes"]["bot"] == ["chat:write","app_mentions:read","im:history","im:write"], "scope set must match $SCOPES"
assert "bot_user" in d["features"], "bot-socket must carry a bot_user"
print("MANIFEST-LOCAL-OK")
PY
```

This proves the file parses as YAML and the MODE-gated invariants from the render matrix hold — it is
**not** Slack's own schema check. `apps.manifest.validate` (needs a human-minted `xoxe-` config token) is
the authoritative **live** check and remains `pending` until a human runs it, per the Blueprint's
Local-vs-live validation note above.

> → Live State: `manifest valid` = `MANIFEST-LOCAL-OK` if the assertions above pass (local only), result
> `PASS (local)`/`FAIL`; live `apps.manifest.validate` stays `pending` until a human runs it against a real
> config token.

### Secrets — macOS Keychain (never the ledger, never echoed)

Every `security` call below is scoped `-s <item-name> -a "$KEYCHAIN_ACCOUNT"` (pitfall #10) — never a bare
`-s` — so it can only ever touch this plan's own items, never the prod ORC account's.

```bash
# store (value entered interactively — NEVER on the command line):
security add-generic-password -s "$KC_BOT_TOKEN" -a "$KEYCHAIN_ACCOUNT" -w
# use (pipe into a header; assert only .ok; never print the token):
TOKEN=$(security find-generic-password -s "$KC_BOT_TOKEN" -a "$KEYCHAIN_ACCOUNT" -w)
curl -fsS -H "Authorization: Bearer $TOKEN" https://slack.com/api/auth.test | jq -e .ok
# delete (teardown) — match BOTH -s and -a so it can only hit this plan's item (pitfall #10):
security delete-generic-password -s "$KC_BOT_TOKEN" -a "$KEYCHAIN_ACCOUNT"
```

The ledger (this file, Live State below) records `KC_BOT_TOKEN` / `KC_APP_TOKEN` / `KC_WEBHOOK` item
**names** only — never a value, never a `xox[baprs]-`/`xoxe-` shape. `KEYCHAIN_ACCOUNT`
(`ephemera-slack-${ENV}`) MUST differ from the prod ORC account, so this plan's secrets can never collide
with, or be readable via, the production account. A bare `-s` delete (no `-a`) is forbidden — it could
match another account's item sharing the same item name.

## 2. Create app  🔴

> **GATE** — this mints an external app record (and, for socket MODEs, lets the human mint an app-level
> token next). Two members, selected by the `DELIVERY` input; `manual-paste` is the default and the only
> member without a config token.

**`manual-paste`** (default — always available, no config token needed):
1. Print the rendered `slack-manifest.yaml` (§1 above) to stdout for the human to paste.
2. Human opens `https://api.slack.com/apps?new_app=1` → **Create New App** → **From an app manifest** →
   picks the **TEST workspace** (never prod) → ⚠ **switches the paste dialog to its YAML tab** (it defaults
   to JSON — YAML pasted into the JSON tab reads "invalid"; alternatively hand the human the JSON render) →
   pastes the manifest → reviews the scope/URL summary Slack shows → confirms.
3. Human confirms creation; capture `APP_ID` from the resulting app's Basic Information page.
   *(`incoming-webhook` MODE, proven live 2026-07-02: after create, the webhook itself is minted via*
   *sidebar **Incoming Webhooks** → **Add New Webhook to Workspace** → pick the channel → **Allow** —*
   *that consent screen IS this MODE's §3 install; copy the `https://hooks.slack.com/services/…` URL.)*
4. **Socket MODEs only** (only `bot-socket` is Socket-Mode; `bot-http` is not): human
   creates an app-level token under *Basic Information → App-Level Tokens* — name it, grant scope
   `connections:write`, generate → yields `xapp-`. Human stores it immediately (value entered
   interactively, never on the command line):
   ```bash
   security add-generic-password -s "$KC_APP_TOKEN" -a "$KEYCHAIN_ACCOUNT" -w
   ```

**`api`** (alternative — needs a human-minted config token first):
```bash
curl -fsS -F token="$CONFIG_TOKEN" -F manifest=@slack-manifest.yaml https://slack.com/api/apps.manifest.create | jq -e .ok
```
`$CONFIG_TOKEN` is a Slack App Configuration Token (`xoxe-`), minted once by a human in the Slack API
console (a manual bootstrap step, not part of this plan) and sourced from Keychain — never echoed, never
passed as a literal on the command line beyond the variable reference shown.

✔ app exists: the API response's `app_id` field is present (`api` member), or the App Config UI shows the
new app (`manual-paste` member).

Marker: 🔴 — creates an external app record and, for socket MODEs, lets the human mint a live app-level
token. `→ Live State: APP_ID`.

## 3. Install to workspace  🔴

> **GATE** — OAuth consent against a real workspace mints the live bot token (and, for `incoming-webhook`,
> a live webhook URL). This is the second and last gated step; nothing after it mints new external state.

Human clicks **Install to Workspace** on the app's OAuth & Permissions page, reviews the resolved scopes
(`$SCOPES` from Provisioning Inputs, or `[incoming-webhook]` for that MODE) in the consent screen, and
approves.

- **Bot MODEs** (`bot-token`/`bot-socket`/`bot-http`): install mints the bot token `xoxb-`. Human stores it
  immediately (value entered interactively, never on the command line):
  ```bash
  security add-generic-password -s "$KC_BOT_TOKEN" -a "$KEYCHAIN_ACCOUNT" -w
  ```
- **`incoming-webhook` MODE**: install instead yields a single inbound webhook URL. Human stores it:
  ```bash
  security add-generic-password -s "$KC_WEBHOOK" -a "$KEYCHAIN_ACCOUNT" -w
  ```

✔ `auth.test` (token read from Keychain, assert only `.ok` plus identity fields — never echo the token):
```bash
TOKEN=$(security find-generic-password -s "$KC_BOT_TOKEN" -a "$KEYCHAIN_ACCOUNT" -w)
curl -fsS -H "Authorization: Bearer $TOKEN" https://slack.com/api/auth.test | jq -e '.ok and .team_id and .bot_id'
```
Record `TEAM_ID` and `BOT_USER_ID` from the response into Live State.

Marker: 🔴 — grants live OAuth scopes against a real workspace and mints the bot token (or webhook URL).
`→ Live State: TEAM_ID, BOT_USER_ID, KC_BOT_TOKEN (name)` (or `KC_WEBHOOK (name)` for `incoming-webhook`).

## 4. Provides handoff  ✔

> No gate — install (§3) already minted the live handle this movement publishes; nothing new is minted or
> touched here, this is a read/announce step.

Bot MODEs (`bot-token` / `bot-socket` / `bot-http`) publish the handle:
```
slack-bot(APP_ID@TEAM)
```
`incoming-webhook` MODE publishes instead:
```
slack-channel(webhook)
```
— referencing the Keychain item name `$KC_WEBHOOK`, never the URL value itself.

This is the handle a consumer wires by name: a deploy-alert notifier, an API endpoint that posts a Slack
message, or a later Ephemera plan's 🔴-gate human-approval step. A consumer resolves the live token/URL
itself from Keychain (`$KEYCHAIN_ACCOUNT` + the recorded item name) — it never reads a secret value out of
this ledger, only the item **name**.

✔ handle resolvable (read-only, re-runnable, token never echoed):
```bash
TOKEN=$(security find-generic-password -s "$KC_BOT_TOKEN" -a "$KEYCHAIN_ACCOUNT" -w)
curl -fsS -H "Authorization: Bearer $TOKEN" https://slack.com/api/auth.test | jq -e '.ok and .bot_id and .team_id'
```
(`incoming-webhook`: `security find-generic-password -s "$KC_WEBHOOK" -a "$KEYCHAIN_ACCOUNT" -w` resolves to
a non-empty value — assert presence only, never print it.)

`auth.test` returns `team_id`/`user_id`/`bot_id` — it does **not** return `app_id`. `APP_ID` itself was
already captured from the create-app response back in §2 (`app_id` field on `apps.manifest.create`, or the
App Config UI for `manual-paste`); this verify only re-confirms identity (`bot_id`/`team_id`), not `APP_ID`.

> → Live State: `Provides` = `slack-bot(APP_ID@TEAM)` (bot MODEs, using the realized `APP_ID`/`TEAM_ID`) or
> `slack-channel(webhook)` (`incoming-webhook`) — the published handle a consumer wires against.

## 5. Acceptance  ✔

> **Acceptance proves only what this unit can prove WITHOUT a consumer.** Outbound MODEs self-verify — a
> message posts, full stop. `bot-socket` acceptance is deliberately narrower than "the bot works end to
> end": it proves `auth.test` (identity) and `apps.connections.open` → `wss://` (the socket opens) —
> nothing more. The DM/mention round-trip reply that actually proves the bot *receives and answers*
> belongs to the **composition** consuming this unit (the home-agent, Unit 3), not to this connectivity
> unit. And `wss://` itself is a narrower proof than it looks: it proves the socket **opens**, not that
> Slack events ever **arrive** on it — the Step-2b runbook immediately below covers two ways a socket stays
> open while events go silently nowhere.

**`incoming-webhook` / `bot-token` (outbound — a message posting IS the acceptance, no round-trip exists):**
```bash
# bot-token — chat.postMessage
TOKEN=$(security find-generic-password -s "$KC_BOT_TOKEN" -a "$KEYCHAIN_ACCOUNT" -w)
curl -fsS -H "Authorization: Bearer $TOKEN" -H 'Content-type: application/json' \
  -d '{"channel":"#general","text":"Ephemera acceptance ping"}' \
  https://slack.com/api/chat.postMessage | jq -e .ok
```
```bash
# incoming-webhook — POST to the webhook URL itself; assert the body itself is `ok`
WEBHOOK=$(security find-generic-password -s "$KC_WEBHOOK" -a "$KEYCHAIN_ACCOUNT" -w)
RESP=$(curl -fsS -H 'Content-type: application/json' -d '{"text":"Ephemera acceptance ping"}' "$WEBHOOK")
[ "$RESP" = "ok" ] && echo "WEBHOOK-POST-OK"
```

**`bot-socket`:**
```bash
TOKEN=$(security find-generic-password -s "$KC_BOT_TOKEN" -a "$KEYCHAIN_ACCOUNT" -w)
curl -fsS -H "Authorization: Bearer $TOKEN" https://slack.com/api/auth.test | jq -e .ok

APPT=$(security find-generic-password -s "$KC_APP_TOKEN" -a "$KEYCHAIN_ACCOUNT" -w)
curl -fsS -H "Authorization: Bearer $APPT" -X POST https://slack.com/api/apps.connections.open | jq -e '.ok and (.url|startswith("wss://"))'
```
Two explicit notes, not implicit:
- **(a)** the DM/mention round-trip reply — send a DM, observe the bot's reply — is the **composition's**
  acceptance (home-agent, Unit 3), not this unit's. This unit's acceptance stops at "the app authenticates
  and the socket can open."
- **(b)** a `wss://` URL proves the socket **opens**; it does not prove events are **delivered** over it.
  Two silent-mute failure modes pass this exact check while receiving nothing — see the runbook
  immediately below.

**`bot-http`:**
```bash
TOKEN=$(security find-generic-password -s "$KC_BOT_TOKEN" -a "$KEYCHAIN_ACCOUNT" -w)
curl -fsS -H "Authorization: Bearer $TOKEN" https://slack.com/api/auth.test | jq -e .ok
```
Plus the Events API URL-verification challenge: Slack POSTs `{"type":"url_verification","challenge":"..."}`
to `$REQUEST_URL`; the endpoint must echo `challenge` back verbatim (observed as the "Retry" indicator
clearing in the Event Subscriptions UI, or an equivalent scripted reply if `$REQUEST_URL` is under this
plan's control). This proves Slack can reach `$REQUEST_URL` — it says nothing about the business logic
handling events past the handshake.

**Negative ✔ (every MODE, agent-runnable, re-runnable, no token needed):**
```bash
grep -nE 'xox[baprs]-[0-9A-Za-z]{8,}' messaging.slack.md slack-manifest.yaml 2>/dev/null && echo "LEAK!" || echo NO-TOKEN-LEAK
```
No real token value ever appears in the ledger, logs, or Arc surface — only Keychain item **names** and
bare format labels (`xoxb-`, `xapp-`) as prose, never a matching real secret.

### Inbound delivery — silent-mute runbook (socket mode)

> Production candor: both failure modes below PASS `apps.connections.open` — the socket opens, the
> `wss://` acceptance check above goes green — while events silently never arrive. Neither is a defect in
> this plan; both are Slack platform behaviors to rule out before assuming a token is bad or reinstalling.

- **#1 private-channel scope snapshot.** If the bot joined a private channel **before** its event scopes
  (`app_mention` / `message.im` / `message.groups`) existed on the app, that channel's membership snapshot
  is stale — Slack captures scope grants at join time and does **not** re-propagate them retroactively.
  Events from that specific private channel silently never fire, even with an open socket and other
  channels working fine. Public channels are immune (no join-time scope snapshot). **Fix:** `/remove @bot`
  then `/invite @bot` in the affected channel — re-joining re-captures the current scope set. Try this
  **before** reinstalling the app or rotating any token; it is the cheaper fix and the actual root cause
  more often than a bad token.
- **#2 phantom-ws / 10-connection cap.** The `xapp-` app-level token caps at **10 concurrent** Socket Mode
  websocket sessions. A daemon bounce (crash, redeploy, sleep/wake) can leave a **phantom** connection open
  on Slack's side — it holds a slot and receives hash-routed events for some channels but never processes
  them, because the process that opened it is gone. **Diagnostic:** the `hello` payload reports
  `num_connections > 1`; a fresh connect attempt returns `too_many_websockets`; the last processed envelope
  timestamp is hours old despite `apps.connections.open` having just succeeded. **Recovery:** toggle Socket
  Mode off → wait → on, on the app's Basic Information page (drops Slack's server-side session records for
  the app); if that doesn't clear it, the nuclear option is regenerating the `xapp-` token itself
  (invalidates every open session at once).
- **Cross-reference, not this unit's job:** **socket-loop robustness** — #3 retry-forever without bounded
  backoff hammering Slack after a network blip, and #4 a ~45s read idle-timeout or a half-open TCP zombie
  going silently mute for hours without a clean disconnect — is the consuming **home-agent composition's**
  (Unit 3) concern, not this connectivity unit's; see that plan's handoff section for the reconnect-loop
  design. This unit only proves the socket can open, once.

## Live State

```yaml
status:        not-created      # published template - run it to realize state
               # recipe); teardown 💥 go given — Keychain item deleted+verified; ⏳ ONE remaining human
               # step: delete app `ephemera-alerts-dev` in its App Config UI (Basic Information → Delete
               # App — the webhook URL stays mintable from the app's Incoming Webhooks page until then)
last_action:   dogfood + teardown 2026-07-02 — §1 render (webhook MODE) → 🔴 §2 create-app (manual-paste,
               human: Mark) → 🔴 §3 install (Add-New-Webhook-to-Workspace consent → channel → URL to
               Keychain) → delivery proven by the consuming recipe → 💥 Keychain item deleted. Two
               render-matrix defects found live + folded (bot_user required; JSON-default paste tab).
last_verified: 2026-07-02 — webhook POST 200×2 (via the notifier Lambda; ALARM + OK both delivered,
               human-confirmed in-channel)
resolved_inputs: { mode: incoming-webhook, scopes: n/a (MODE replaces with [incoming-webhook]), events: n/a, delivery: manual-paste, request_url: null, env: dev, keychain_account: ephemera-slack-dev, app_name: ephemera-alerts-dev }
```

| key | value (filled on apply) |
|-----|--------------------------|
| APP_ID | created via manual-paste (id on the app's Basic Information page — not captured this run) |
| TEAM_ID | `—` (webhook MODE — no bot token minted, no `auth.test`) |
| BOT_USER_ID | `—` (webhook MODE) |
| KC_BOT_TOKEN (name) | `—` (webhook MODE) |
| KC_APP_TOKEN (name) | `—` (webhook MODE) |
| KC_WEBHOOK (name) | `slack-webhook-dev` @ account `ephemera-slack-dev` |

| ✔ check | expected | observed | result |
|---------|----------|----------|--------|
| manifest valid | `apps.manifest.validate` returns `ok: true` | live equivalent: the App-Config paste dialog accepted it (after the two folded fixes) | PASS |
| app created | `apps.manifest.create` returns an `app_id` | manual-paste: app visible in App Config UI | PASS |
| installed (`auth.test`) | `auth.test` returns matching `APP_ID`/`TEAM_ID`/`BOT_USER_ID` | n/a (webhook MODE mints a URL, not a bot token) | n/a |
| socket live *(socket)* / outbound post *(webhook/token)* | `apps.connections.open` returns a `wss://` url *(bot-socket)*; or `chat.postMessage`/webhook POST returns `ok: true` *(bot-token / incoming-webhook)* | webhook POST 200 ×2 (ALARM + OK, via alerts-notifier-dev; human saw both) | PASS |
| token absent from ledger *(negative)* | this file contains no `xox[baprs]-` shape — only Keychain item **names** are recorded | grep clean (names + `hooks.slack.com` prose only) | PASS |

## Update (idempotent reconcile)  🟡

> The whole plan is a pure function of the Provisioning Inputs (`MODE`, `SCOPES`, `EVENTS`, `REQUEST_URL`,
> `ENV`) — re-rendering the manifest and re-asserting scopes reconciles to the same state; it never mints a
> second app or a duplicate token.

```bash
cd <project>   # the directory containing this plan's slack-manifest.yaml — your project root
# re-render slack-manifest.yaml from the (possibly updated) Provisioning Inputs — same pure function as §1
```
**`manual-paste`:** print the re-rendered manifest; a human re-pastes it into the existing app's
**App Manifest** editor (App Config UI) and confirms — this replaces the whole manifest, scopes included.

**`api`:**
```bash
curl -fsS -F token="$CONFIG_TOKEN" -F app_id="$APP_ID" -F manifest=@slack-manifest.yaml \
  https://slack.com/api/apps.manifest.update | jq -e .ok
```

Re-assert scopes took (read-only, re-runnable):
```bash
TOKEN=$(security find-generic-password -s "$KC_BOT_TOKEN" -a "$KEYCHAIN_ACCOUNT" -w)
curl -fsS -H "Authorization: Bearer $TOKEN" https://slack.com/api/auth.test | jq -e '.ok and .bot_id'
```
(`auth.test` has no `app_id` field — `$APP_ID` used in the `apps.manifest.update` call above is the value
already captured from §2's create-app response, not something re-read here.)

> Safe to re-run any number of times: `apps.manifest.update`/`manual-paste` against an unchanged manifest
> is a no-op reconcile (same scopes, same `request_url`s, same `socket_mode_enabled`); a genuine update
> means changing a Provisioning Input (adding a scope, flipping `MODE`, changing `REQUEST_URL`) and
> re-running §1's render + this Update in full. **A scope addition on an existing install requires
> re-consent** — Slack prompts **Reinstall App** (a fresh, still 🔴-gated OAuth screen) only when the bot
> scope set actually grows; a manifest-only change with unchanged scopes does not re-trigger install.
> → Live State: a no-op reconcile leaves `APP_ID`/`TEAM_ID` unchanged; a scope change surfaces as a pending
> reinstall requirement, not an automatic re-grant.

## Teardown (observe-first, resumable)  💥

> 💥 The only irreversible acts in this plan: revoking tokens and deleting the app record. Observe-first —
> an already-gone app/token is a no-op, not an error. Touches **only** this plan's own app and **only**
> `$KEYCHAIN_ACCOUNT`-scoped Keychain items — never the workspace itself, never any other account's item.

```bash
cd <project>   # the directory containing this plan's slack-manifest.yaml — your project root
# observe first — already-gone is a no-op, not a failure
TOKEN=$(security find-generic-password -s "$KC_BOT_TOKEN" -a "$KEYCHAIN_ACCOUNT" -w 2>/dev/null) || TOKEN=""
if [ -z "$TOKEN" ]; then
  echo "no bot token in Keychain — app already torn down or never installed; no-op"
else
  curl -fsS -H "Authorization: Bearer $TOKEN" https://slack.com/api/auth.revoke | jq -e .ok   # 💥 revoke
fi

# delete the app record — api member (needs a config token) or the UI member
curl -fsS -F token="$CONFIG_TOKEN" -F app_id="$APP_ID" https://slack.com/api/apps.manifest.delete | jq -e .ok
# — or, manual: a human deletes the app from its App Config UI (Basic Information → Delete App)

# remove Keychain items — BOTH -s and -a required (pitfall #10); a bare -s delete is forbidden
security delete-generic-password -s "$KC_BOT_TOKEN" -a "$KEYCHAIN_ACCOUNT"
security delete-generic-password -s "$KC_APP_TOKEN" -a "$KEYCHAIN_ACCOUNT"
security delete-generic-password -s "$KC_WEBHOOK"   -a "$KEYCHAIN_ACCOUNT"
```

✔ verify (read-only, re-runnable):
```bash
TOKEN=$(security find-generic-password -s "$KC_BOT_TOKEN" -a "$KEYCHAIN_ACCOUNT" -w 2>/dev/null) || TOKEN=""
[ -z "$TOKEN" ] && echo "KEYCHAIN-BOT-TOKEN-GONE"
curl -fsS -H "Authorization: Bearer ${TOKEN:-invalid}" https://slack.com/api/auth.test | jq -e '.ok == false' \
  && echo "AUTH-TEST-NOW-FAILS"
```

Resumable: a crash mid-teardown leaves the app/tokens in whatever partial state they were in
(revoked-but-not-deleted, or deleted-but-Keychain-items-still-present); re-entry re-observes (does the
Keychain item still resolve? does `apps.manifest.delete`/the UI still show the app?) and either no-ops
(already gone) or retries the remaining step — nothing here requires manual reconciliation beyond
re-running this block.

Never touches the Slack workspace itself (no channels/users/other apps affected), and never touches a
Keychain item outside `$KEYCHAIN_ACCOUNT` — the `-s`+`-a` pairing on every delete call scopes it to exactly
this plan's three items.

> → Live State: `status: gone`; clear `APP_ID`, `TEAM_ID`, `BOT_USER_ID`, `KC_BOT_TOKEN (name)`,
> `KC_APP_TOKEN (name)`.

## Deliberately not included

- **The pre-enumerated Slack feature surface** — App Home tabs, interactivity/shortcuts blocks, slash
  commands beyond the render matrix's bare invariants, and any scope-preset ladder beyond the four
  MODE-driven scope sets — is not pre-shipped here (see "What this unit owns — the spine, not the
  closet"). It is authored on demand, against this unit's MODE → manifest render matrix, when a consumer
  actually needs it — so a realized plan carries only the shape it actually used, never a staler mirror of
  Slack's own manifest schema (`https://api.slack.com/reference/manifests`) that drifts out of sync.
- **Multi-workspace public distribution (OAuth-for-others)** — this unit installs into **one** workspace
  via a human clicking Install; a public Slack app distributed to other workspaces via
  `oauth.v2.access`'s full authorize-redirect flow is a materially different trust/consent surface, not a
  default this plan assumes.
- **Message persistence / a database** — this unit is a pure messaging binding; storing message history,
  thread state, or any derived data is the consuming composition's concern (its own store), not this
  plan's.
- **RAG (retrieval-augmented generation)** — would require `vector.cloudflare.md` (or an equivalent vector
  store binding) as a dependency; out of scope for a connectivity-only unit.
- **The agent brain** — deciding what to say, when to reply, and how to reason over an inbound event lives
  in the **home-agent composition** (Unit 3), not this unit. This plan hands that composition a working
  socket/webhook/token; it carries no reply logic itself.
- **Paid hosting as a default** — `bot-http` is supported (the render matrix's fourth row) but is
  deliberately not the default MODE: it requires a publicly reachable `$REQUEST_URL`, i.e. a non-free
  always-on host, where `bot-socket` (the default) is free and needs nothing public-facing.
