# Ephemera — Local agent runner (Slack + Ollama, Bolt Socket Mode)

> Self-executing Markdown. The **local** binding of the *agent* intent — run a Slack-facing agent
> as a local process (Python `slack_bolt`, Socket Mode — no public URL, no inbound port) that answers
> Slack DMs/@mentions by calling a local Ollama-served reasoning model directly over HTTP (no CLI
> subprocess), strips the reasoning `thinking` channel, and replies in-thread. The process
> (`agent_runner.py`, backed by `agent_brain.py`) is the source of truth; this file is intent + ledger
> + audit.

> **Provides / Requires**: **Provides** nothing — this is a **leaf** plan. **Requires**
> `inference-endpoint(http://localhost:11434, qwen3.6-coder-64k)` (Unit 1, `inference.ollama.md` —
> discover the served **tag** via `GET /api/tags`, fail loud if absent, never assume the bare name) +
> `slack-bot(<APP_ID> @ <TEAM_ID>)` (Unit 2, `messaging.slack.md` — tokens resolved at runtime from
> macOS Keychain account `<keychain-account>`: `slack-bot-token` (`xoxb-`) + `slack-app-token` (`xapp-`),
> never echoed/logged).

---

## 🤖 Director prompt

You are the Director. Execute this plan:
- Observe-before-act; verify each step before advancing
- **No 🔴 gates in this plan** — this is a local process on this machine, no billable/global/cross-account
  act; `launchctl unload` and killing the runner are 💥-ish but trivially reversible (re-run §3)
- Secrets are resolved from Keychain (account `<keychain-account>`) at runtime and **never** echoed,
  logged, or written into Live State — only presence (exit-code probe) or a redacted prefix
  (`xoxb-`/`xapp-`) is ever recorded, never the value
- Write realized values + verify results back into Live State after each step
- Teardown stops the process and removes the `launchd` plist **only** — it never touches the Slack app or
  its tokens (Unit 2's teardown) and never removes the Ollama model (Unit 1's teardown)
- Use only the commands in this plan

> **Candor:** the durable learnings folded into this plan (the `/api/chat` + `think:false` empty-answer
> trap, the deterministic `!think` reasoning trigger, the input-cap/single-flight/empty-answer safeties,
> the security review's no-exec invariant) come from a dogfooded run — a real Slack DM round-trip (Slack in
> → local model → Slack reply, in-thread) observed live against a real workspace and a real Ollama daemon
> elsewhere — proven behavior, not speculation. This contributed copy's Live State ships reset to
> `not-created` (per Ephemera's contribution contract): treat every ✔ row as unverified until you re-run it
> against YOUR OWN Slack workspace, Ollama daemon, and Keychain account. The `RUN_MODE=launchd` movement
> (an always-on `plist`) is authored below but was deliberately never `launchctl load`ed during that
> dogfood — only exercised under `RUN_MODE=foreground` — per the Director prompt (no gate needed to author
> a local file); going always-on is a separate, deliberate action you take on your own host when ready.
> Both upstream units — the Slack app (Unit 2, `messaging.slack.md`) and the served model (Unit 1,
> `inference.ollama.md`) — must already be live on YOUR OWN infrastructure before this plan's runner can do
> anything; §0's Requires-discovery block below fails loud rather than assuming either.

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

## Intent

Run a small, always-on **Slack-facing agent** on this machine: a Python `slack_bolt` app in **Socket
Mode** (no public URL, no inbound port, no reverse proxy) that receives Slack DMs and @mentions, forwards
the message text to the local Ollama daemon's reasoning model (`qwen3.6-coder-64k`, direct HTTP —
`/api/chat` with `think: false`, no `ollama` CLI subprocess — see §1 Candor for why this replaced an
originally-authored `/v1/chat/completions` call), strips any residual `<think>…</think>` span, and replies
**in-thread**. The unit of work is a running **process** (`agent_runner.py`) backed by a thin client
module (`agent_brain.py`) that owns the Ollama round-trip; Bolt's own Socket Mode client owns Slack
reconnect.

**Shared acceptance contract** (every agent binding must pass):
1. a Slack message (DM or @mention) produces a well-formed non-empty reply, posted **in the same thread**
   (`thread_ts = event.thread_ts or event.ts`)
2. the reply is generated by the **resolved/discovered model tag** (determinism — same inputs ⇒ same
   served tag)
3. **(local-specific)** the reply never contains a leaked `<think>…</think>` span and never contains a
   Keychain token value

### Candor

- **Discover, don't assume, the served tag.** Per Unit 1's own acceptance contract, this plan discovers
  `qwen3.6-coder-64k` via `GET /api/tags` at call time and **fails loud** (raises, refuses to answer) if
  the tag is absent — it never falls back to an unqualified/bare model name.
- **`/api/chat` + `think:false`, not `/v1` (a live debugging finding).** This plan originally called
  `/v1/chat/completions` with `max_tokens`, trusting Unit 1's note that `/v1` `content` already omits the
  `thinking` channel. Live dogfooding found a sharper problem than a stripping leak: on `/v1` the *hidden*
  `thinking` still consumes the completion budget before the visible answer gets a turn, so a substantive
  question could come back with an **empty** final answer (Slack `no_text`) even though the call
  "succeeded." Switching to `/api/chat` with `think: false` skips the reasoning channel outright — no
  budget wasted on hidden thought, a direct and faster answer (verified live). §1's defensive
  `<think>…</think>` strip stays regardless, belt-and-suspenders — never trust upstream stripping (or a
  disabled channel) alone on a public-facing reply.
- **Reply in-thread (pitfall #5).** Every reply posts with `thread_ts = event.thread_ts or event.ts` —
  never top-level — so multi-turn conversations stay legible in Slack.
- **Reconnect is Bolt's job, not this plan's (pitfall #3).** Socket Mode's WebSocket reconnect/backoff is
  handled entirely inside `slack_bolt`'s `SocketModeHandler`; this plan's only obligation is to **log every
  connect/disconnect event** so a silent reconnect-loop or a half-open TCP zombie (pitfall #4, a ~45s idle
  socket gone mute for hours without a clean disconnect) is visible in logs. No standalone watchdog is
  authored here — deferred until a live zombie is actually observed.
- **#8/#9 are N/A.** This runner calls Ollama directly over HTTP from within the Python process — it does
  **not** spawn a Claude/CLI subprocess — so grant-surface / strict-MCP pitfalls don't apply to this plan.
- **This plan owns the round-trip acceptance Unit 2 deferred.** `messaging.slack.md` (Unit 2) proves only
  `auth.test` (identity) and that the Socket Mode connection **opens** (`wss://`) — it explicitly declines
  to prove a message is ever received and answered. That proof (a real DM in, an in-thread model reply
  out) is this plan's §4 acceptance, not Unit 2's.

### Security

> A security review's central finding, stated here as an invariant this plan depends on and must keep
> true across future changes.

**The model's output is never executed — grep-verified.** `agent_runner.py` and `agent_brain.py`
contain no `eval(`, `exec(`, `os.system`, `shell=True`, or `Popen`; the only `subprocess` call in either
file is a hardcoded `security find-generic-password -s <service> -a <keychain-account> -w` (fixed args, a
Keychain read — not a shell, not model-influenced). Everything the model returns flows through exactly one
sink: `say(text=answer, thread_ts=thread)` → Slack `chat.postMessage`. **Ollama is the sandbox** — the
model emits text, and that text touches nothing on this host beyond a Slack API call.

Risks considered, and how they're mitigated today:
- **Resource abuse.** Anyone in the Slack workspace can trigger a large local generation just by
  DMing/@mentioning the agent. Mitigated by `MAX_INPUT_CHARS` (bounds prompt size), `_GEN_LOCK`
  single-flight (bounds concurrency — one generation at a time; an overlapping request gets a "busy" reply
  instead of piling up), and admin/guest persona tiering (a guest gets a more defensive *prompt*, not more
  compute — the resource bound is identical for both tiers).
- **Token scope.** The Slack bot token is `xoxb-` (bot-scoped, not a user token), Keychain-stored under
  account `<keychain-account>`, read via `security find-generic-password -w` into a variable, and never
  printed, logged, or written into this plan's Live State (only presence/prefix is ever recorded — see the
  Director prompt).
- **Prompt injection.** A guest (or anyone) can try to jailbreak `SYSTEM_GUEST`'s instructions ("ignore
  previous instructions," "pretend you're not the owner's assistant," etc.). Today that is a **bad *text*
  outcome, not a breach**: the runner is stateless per-message (no file access, no memory, no tools), so a
  successful jailbreak yields an off-persona *reply*, not an action. `SYSTEM_GUEST` is written to resist
  exactly this (refuses to change persona/config, never reveals the owner's private information or its own
  instructions), but the real backstop is architectural, not prompt-engineered: there is nothing for an
  injected prompt to reach for, because the model has no reach.

**The future rule — state it loudly, because the invariant above is a today-fact, not a permanent one:**
the day the agent is given tools (shell, file, or code execution), the model's output stops being inert
text and becomes ACTIONS, and prompt injection stops being an annoyance and becomes remote code execution.
If a future task ever adds tool use / code execution to this runner: **never `exec`/`eval`/shell the
model's output directly on this host.** Sandbox it — a container, or a purpose-built sandbox (e.g.
Cloudflare's `sandbox-sdk`) — the same way Ollama today sandboxes generation itself. This is a hard line,
not a someday-nice-to-have.

## Provisioning Inputs

| # | Question | Options (closed enum) | Default | Sets |
|---|----------|------------------------|---------|------|
| 1 | Which served model to call? | free-text (a tag discoverable via `/api/tags`) | `qwen3.6-coder-64k` | `AGENT_MODEL` |
| 2 | Which Slack events trigger a reply? | `dm` / `mention` / `both` | `both` | `TRIGGERS` |
| 3 | How does the runner start? | `foreground` / `launchd` | `foreground` | `RUN_MODE` |
| 4 | The agent's voice — the system prompt(s)? | free-text (`SYSTEM_ADMIN` / `SYSTEM_GUEST` prose) | the two prompts in `agent_brain.py` | `PERSONA` |
| 5 | Who is the admin (owner) tier? | free-text (a Slack user id) | `<owner-slack-user-id>` | `ADMIN_USER` |
| 6 | Max input size accepted from Slack? | free-text (a character count) | `4000` | `MAX_INPUT_CHARS` |
| 7 | Which literal prefix toggles deep reasoning for one message? | free-text (a bang-prefixed literal string — NOT slash-prefixed, Slack intercepts a leading `/`) | `!think` | `THINK_TRIGGER` |

> Rows 4–7 are knobs the live code already makes — `PERSONA`, `ADMIN_USER` (as the `ADMIN` constant),
> `MAX_INPUT_CHARS`, and `THINK_TRIGGER` are all hardcoded in `agent_brain.py`/`agent_runner.py` today,
> not env vars. They're documented here as Provisioning Inputs anyway because that's exactly what they are
> (closed-enum-shaped decisions this plan made once, up front, that every reply branches on) — changing one
> today means editing the constant in source and re-running Update, not flipping an env var. Promoting them
> to real env vars later would be a mechanical change, not a design change.

```yaml
# → written into Live State once resolved
resolved_inputs:
  agent_model:      qwen3.6-coder-64k
  triggers:         both
  run_mode:         foreground
  persona:          "SYSTEM_ADMIN / SYSTEM_GUEST (see agent_brain.py)"
  admin_user:       <owner-slack-user-id>
  max_input_chars:  4000
  think_trigger:    "!think"
  keychain_account: <keychain-account>
  resolved_by: <operator>
  resolved_at: <date>
```

## 0. Variables

```bash
export OLLAMA_HOST="127.0.0.1:11434" AGENT_MODEL="qwen3.6-coder-64k"
export KEYCHAIN_ACCOUNT="<keychain-account>" KC_BOT_TOKEN="slack-bot-token" KC_APP_TOKEN="slack-app-token"
```

Requires-discovery — read-only, fail loud if an upstream unit isn't live, never assume:

```bash
# Requires-discovery — fail loud if an upstream unit isn't live (do NOT assume):
curl -fs "http://$OLLAMA_HOST/api/tags" | grep -q "$AGENT_MODEL" || { echo "Unit 1 not serving $AGENT_MODEL"; exit 1; }
security find-generic-password -s "$KC_BOT_TOKEN" -a "$KEYCHAIN_ACCOUNT" >/dev/null 2>&1 || { echo "Unit 2 bot token missing"; exit 1; }
```

## Dependency frontier

```
Unit1 inference-endpoint + Unit2 slack-bot ── discovered ──> brain (agent_brain) ─> runner (agent_runner) ─> run(§3) ─> ✔ round-trip acceptance(§4)
```
Non-negotiable edges: both upstream units must be **discovered live** (never assumed via a shared state
file) before the brain module can resolve a model tag or a token; the brain must exist before the runner
process can import and call it; the runner must actually be running (§3) before round-trip acceptance
(§4) can be observed against it. Teardown reverses only the local edges this plan owns — the process and
its `launchd` plist — never Unit 1's model or Unit 2's app/tokens.

## 1. Write the runner  🟢

> Two focused files, one responsibility each — no shared state, no hidden coupling. `agent_brain.py`
> owns the Ollama round-trip only; `agent_runner.py` owns the Slack bridge only and imports the brain.

`agent_brain.py` — `served_tag(model)` discovers the served tag via `GET /api/tags` and **fails loud**
(`RuntimeError`) if `model` isn't served, never falling back to an unqualified name. `ask(prompt, admin,
think=False, timeout=240)` calls **`/api/chat`** — **not** `/v1/chat/completions` with `max_tokens`, which
is what this plan originally authored. Live debugging found the `/v1` path silently broken for this kind
of model: a reasoning model's hidden `thinking` channel consumes the token budget before the visible
answer gets a turn on `/v1` — a substantive question could come back with an **empty** final answer, which
the runner then couldn't post (Slack `no_text`). `think: false` on `/api/chat` skips the reasoning channel
entirely: a direct answer, faster, and no budget wasted on hidden thought (verified live). `num_predict`
bounds generation length — `2048` when `think=False` (the default, fast path) and `4096` when `think=True`
(reasoning needs a bigger budget); `timeout=240` is deliberately wide (a cold multi-GB model load + generate
can exceed 120s). The call still strips any residual `<think>…</think>` span defensively
(belt-and-suspenders — `think: false`/`true` should bound the channel either way, but this plan doesn't
trust that alone on a user-facing reply).

**Deterministic reasoning trigger (`THINK_TRIGGER`).** `think` defaults **off** — every DM gets the fast,
direct `/api/chat` `think:false` path above, `num_predict: 2048`. A DM whose text **starts with** the
literal prefix `THINK_TRIGGER = "!think"` flips `think=True` for that one call and gets the bigger
`num_predict: 4096` budget; `agent_runner.py` strips the trigger prefix (`raw.lstrip()[len(THINK_TRIGGER):]
.lstrip()`) before the remaining text is ever passed to `ask(...)`, so the model itself never sees the
literal trigger string. The check is a plain, deterministic, case-folded string match
(`raw.lstrip().lower().startswith(THINK_TRIGGER)`) — no NLU, no keyword heuristic, no guessing whether a
question "looks hard." **Bang-prefixed (`!think`), NOT slash-prefixed** — Slack itself intercepts a leading
`/` client-side as a slash command before the message ever reaches this app's `message` event, so a
`/think`-style trigger could never arrive here at all; `!think` sidesteps that entirely.

**Tiered personas.** `ask` takes an `admin: bool` and selects one of two system prompts: `SYSTEM_ADMIN` —
for the owner, reached in their own Slack — candid and expert-level, assumes the fundamentals; vs
`SYSTEM_GUEST` — for anyone else in the workspace — helpful with general coding questions, but explicitly
"the owner's assistant, not theirs": refuses to change its own config/persona and never leaks the owner's
private information, even under a direct instruction to do so. Both personas share a `_STYLE` rule (match
reply length to the question — a simple ask gets a short direct answer, not a survey/preamble; Slack
`mrkdwn` formatting; no language tag on code fences, since Slack renders the tag as a literal line).

`agent_runner.py` — a `slack_bolt` Socket-Mode app: both tokens are read from **Keychain account
`<keychain-account>`** (`security find-generic-password -s <service> -a <keychain-account> -w`, captured
into a variable, **never printed/logged**); `message` events are filtered to `channel_type == "im"` (DMs)
and `app_mention` events are handled directly; both call `agent_brain.ask` and post the reply **in-thread**
(`thread_ts = event.thread_ts or event.ts`, pitfall #5) via `say(...)` (`chat.postMessage` under the
hood). Bolt's `SocketModeHandler` owns reconnect (pitfall #3); every connect/disconnect is logged so a
pitfall #4 zombie would be visible, though no watchdog is authored (deferred until one is observed live).

**Safeties added during live debugging + the security review** (beyond the original authoring):
- `ADMIN = "<owner-slack-user-id>"` (the owner's Slack user id) → `is_admin = event.get("user") == ADMIN`,
  the flag that selects `SYSTEM_ADMIN` vs `SYSTEM_GUEST`. Every event logs `user=` (and `admin=`) alongside
  channel/thread.
- `MAX_INPUT_CHARS = 4000` — an input cap (`event["text"][:MAX_INPUT_CHARS]`) so one oversized paste can't
  blow the tight context window or run away generating.
- `_GEN_LOCK` (`threading.Lock`) — single-flight: only one model generation runs at a time. An overlapping
  request while busy gets an immediate `_busy with another request…_` reply instead of queuing/piling up —
  bounds machine load under spam or a slow cold-load.
- **Empty-answer guard** — if `agent_brain.ask` returns empty/whitespace, the runner substitutes a
  visible placeholder rather than calling `say(text="")` (which Slack rejects as `no_text`). This turns
  what was the root-cause `/v1` bug (above) into a merely-annoying UX message instead of a silent failure,
  and it stays as a defensive backstop even now that `/api/chat` + `think:false` fixes the root cause.
- In-thread reply and Keychain-sourced, never-logged tokens are unchanged since the original authoring.

✔ verify (local files, no gate — nothing billable/global/irreversible about writing Python):
```bash
cd <project>   # the directory containing this plan's agent_brain.py / agent_runner.py — your project root
python3 test_agent_brain.py
python3 -c "import ast; ast.parse(open('agent_runner.py').read()); print('SYNTAX-OK')"
```
Expected: `ALL-BRAIN-TESTS-PASS` (live Ollama) and `SYNTAX-OK` (structure only — a full import would read
Keychain / start connecting).
> → Live State: `brain answers` = observed test-suite result.

## 2. Verify secrets resolve  ✔

> Exit-code probe only — presence, never the value. Both services must resolve from the same Keychain
> account (`<keychain-account>`) Unit 2 populated; this plan never writes to Keychain, only reads.

```bash
for s in slack-bot-token slack-app-token; do
  security find-generic-password -s "$s" -a "$KEYCHAIN_ACCOUNT" >/dev/null 2>&1 && echo "$s: resolves" || { echo "$s: MISSING"; exit 1; }
done
```
Expected: both `resolves`.
> → Live State: `secrets resolve` = observed result (which services resolved, never a value).

## 3. Run  🟡

> Two `RUN_MODE`s (Provisioning Input #3): `foreground` (default, this dogfood) and `launchd` (an always-on
> plist, authored below — the runbook is genericized from a live production load; run it yourself once
> you're ready to go always-on on your own host).

### RUN_MODE=foreground (dogfooded)

> A bare `python3 agent_runner.py`, logs to a file so the Director can observe the connect without
> blocking.

```bash
cd <project> && python3 agent_runner.py > /tmp/agent.log 2>&1 &
sleep 6 && grep -iE "starting|connected|hello|A new session|Bolt app is running" /tmp/agent.log | tail -5
```
Expected: `<agent-name> starting …`, `A new session has been established`, and `⚡️ Bolt app is running!` —
no traceback. If it errors, read `/tmp/agent.log`.
> → Live State: `runner connects` = observed log lines (+ PID).

### RUN_MODE=launchd (authored, not loaded)

> **The load sequence — a documented runbook, genericized from a real production load.** Four steps: 1–2
> are prep/read-only, 3 is the only step that touches `launchd`, 4 is verify-only. Re-run this same
> sequence for any future plist edit — it's idempotent by design (step 3's `unload` before `load`).

**1. Resolve the REAL interpreter — the hard-won gotcha (pitfall #9's PATH point, concretely).** launchd
does **not** use your login shell's `PATH`, so the plist must reference the fully-resolved, **non-shim**
python — never bare `python3`, and never a version-manager (e.g. `pyenv`) **shim**: a shim needs that
manager's shell init to resolve which version to `exec` into, and launchd's environment never sources that
init, so a shim-backed job fails to start **silently** (no traceback — just a `launchctl list` row that
never gets a PID). Resolve the real interpreter with:
```bash
python3 -c 'import sys; print(sys.executable)'
```
which prints something like `<resolved-non-shim-python3>` (e.g.
`<project>`) — **not** the shim path `command -v python3` would
return. Before touching `launchd`, verify that exact interpreter has the runner's deps:
```bash
PY=<resolved-non-shim-python3>
"$PY" -c "import slack_bolt" && echo bolt-ok
```
Expected: `bolt-ok` — verified **before** loading, not discovered after a silent failure.

**2. Stop the foreground runner first (pitfall #2 — Socket Mode's 10-connection cap).** Two live clients
holding a Socket Mode connection on the same app token risk tripping that cap — and at minimum, two
processes would race to answer the same event.
```bash
pkill -f agent_runner.py
sleep 3
```
Never load the plist while a foreground PID still holds the socket.

**3. Install + load, idempotently.** `com.example.agent.plist` (flat at the repo root, alongside the
runner) — `ProgramArguments` uses the fully-resolved interpreter from step 1 (never the shim, per the
gotcha above); `RunAtLoad` + `KeepAlive` = `true` (always-on, auto-restart on crash);
`StandardOutPath`/`StandardErrorPath` reuse the same `/tmp/agent.log` / `/tmp/agent.err` the foreground
dogfood already wrote to.

```bash
cd <project>
plutil -lint com.example.agent.plist                                     # ✔ structure check — read-only
cp com.example.agent.plist ~/Library/LaunchAgents/com.example.agent.plist
launchctl unload ~/Library/LaunchAgents/com.example.agent.plist 2>/dev/null
launchctl load ~/Library/LaunchAgents/com.example.agent.plist
```
The `unload` first (stderr suppressed — a no-op the first time there's nothing loaded yet) is what makes
this re-runnable: re-running the whole sequence after a future plist edit never double-loads or fights a
stale registration.

**4. ✔ verify.**
```bash
launchctl list | grep agent
```
Expected: a `com.example.agent` row with a **numeric PID** and last-exit-status **`0`** — no
crash-loop/negative status.
```bash
grep -E "A new session has been established|Bolt app is running" /tmp/agent.log | tail -5
```
Expected both lines present. Then re-prove the acceptance contract under `launchd` the same way §4 proved
it under foreground: send a DM, and get the human's confirmation the reply landed in Slack.
> → Live State: `runner connects (launchd)` = observed `launchctl list` row (numeric PID, exit status `0`)
> + session-established/Bolt-running log lines + a repeated §4 round-trip, result PASS/FAIL. Not yet
> executed against this contributed copy — run this member yourself (after stopping any foreground runner,
> per step 2 above) once you're ready to go always-on on your own host.

## 4. Acceptance (round-trip)  ✔

> **This plan owns this acceptance** — `messaging.slack.md` (Unit 2) proved only `auth.test` (identity)
> and that the Socket Mode connection *opens*; it explicitly deferred proving a message is ever received
> and answered. A real DM/@mention producing a model-authored, in-thread reply is the proof Unit 2 left
> for this plan to close.

The human sends a DM (or @mention) to the agent in Slack. Then confirm the loop fired, read-only, from the
running process's own log — never by asserting on the Slack side alone:
```bash
sleep 4 && grep -E "event ch=|posted reply" /tmp/agent.log | tail -4
```
Expected: an `event ch=… len=…` line, then a `posted reply len=…` line, **and** the human independently
confirms the agent's reply appears in the DM thread in Slack. Both together are the round-trip PASS — the
log alone is necessary but not sufficient (it doesn't prove Slack rendered the reply); the human's
confirmation is necessary but not sufficient alone (it doesn't prove no token leaked into the path).
> → Live State: `round-trip` = observed DM text/reply summary (never a token), `no token leaked` =
> observed negative-check result.

## Live State

```yaml
status:        not-created      # published template - run it to realize state
last_action:   authored — local (Slack + Ollama) agent binding
last_verified: —
```

| ✔ check | expected | observed | result |
|---------|----------|----------|--------|
| brain answers | `agent_brain.ask(...)` returns a non-empty, thinking-stripped answer (live Ollama test) | — | — |
| secrets resolve | both `slack-bot-token` and `slack-app-token` resolve in Keychain account `<keychain-account>` (exit-code probe, no value printed) | — | — |
| runner connects | Socket Mode `hello`/connect logged, no traceback | — | — |
| runner connects (launchd) | `launchctl list` shows a `com.example.agent` row with a numeric PID + last-exit-status `0`; log shows session-established + Bolt-running (§3 runbook) | — | — |
| round-trip | a real Slack DM/@mention produces an in-thread model reply (human-in-the-loop) | — | — |
| no token leaked (negative) | no log line, reply, or Live State row ever contains a Keychain token value | — | — |

> The round-trip acceptance above is the proof `messaging.slack.md` (Unit 2) explicitly deferred — prove
> it live against YOUR OWN workspace/daemon before trusting this row. `launchd`/Update/Teardown/
> Deliberately-not-included are all authored and ready; none has been executed against this contributed
> copy — it ships `status: not-created` and every ✔ row unverified, per Ephemera's contribution contract.

## Update (idempotent reconcile)  🟡

> The runner is a pure function of its two source files plus the plist; re-editing either and restarting
> reconciles to the new behavior. No state to migrate — every message is handled statelessly (see
> Deliberately not included, below).

```bash
# after editing agent_brain.py / agent_runner.py:
cd <project>
python3 -c "import ast; ast.parse(open('agent_runner.py').read()); ast.parse(open('agent_brain.py').read()); print('SYNTAX-OK')"
python3 test_agent_brain.py                                     # re-run the brain's own test suite (live Ollama)
launchctl kickstart -k gui/$(id -u)/com.example.agent            # -k = kill-then-restart the running launchd job
```

Safe to re-run any number of times: `kickstart -k` always restarts (no dedup needed, unlike Ollama's
content-addressed `create` in the sibling plan), and a restart with unchanged files is a no-op from the
outside (same code, new PID). A genuine update is a source edit; verify it the same way §1/§4 already do
(syntax check, brain test suite, then a live DM round-trip) before trusting the new PID. (Under
`RUN_MODE=foreground`, the equivalent reconcile is: stop the old PID, re-run §3's foreground start.)
> → Live State: `last_action` records what changed (a persona wording tweak, a safety constant, etc.) and
> the new PID; a no-op restart leaves observed behavior identical.

## Teardown (observe-first, resumable)  💥

> Reverses only what this plan owns — the process and its `launchd` plist. **Never** touches the Slack app
> or its Keychain tokens (Unit 2's teardown, `messaging.slack.md`) and **never** removes the Ollama model
> (Unit 1's teardown, `inference.ollama.md`).

```bash
launchctl list | grep -q com.example.agent && \
  launchctl unload ~/Library/LaunchAgents/com.example.agent.plist 2>/dev/null || \
  echo "launchd job already absent — no-op"
rm -f ~/Library/LaunchAgents/com.example.agent.plist
pkill -f agent_runner.py 2>/dev/null || echo "no foreground runner to kill — no-op"   # also covers RUN_MODE=foreground
```

✔ verify (read-only, re-runnable):
```bash
launchctl list | grep agent || echo "LAUNCHD-GONE"
pgrep -f agent_runner.py >/dev/null || echo "PROCESS-GONE"
```
Then confirm the negative side of the acceptance contract: a DM sent to the agent after teardown gets
**no** reply (the human observes this in Slack — there's no local log left to grep once the process is
gone).
> Resumable: a crash mid-teardown just leaves the plist/process present or absent; re-entry re-observes
> via `launchctl list`/`pgrep` and either no-ops or retries — there is no partial state to reconcile.
> → Live State: `status: gone`; clear the realized PID.
>
> *This 💥 has not been run against this contributed copy — Live State ships `status: not-created`.
> Teardown is authored here for completeness so a consumer who does run this plan on their own host has
> the reversal step ready; killing the runner is 💥-ish but trivially reversible (re-run §3).*

## Deliberately not included

- **Model tools / code execution.** Per § Security above, the model's output today is inert text with a
  single sink (`chat.postMessage`). Giving the agent tools — shell, file, or code-exec — is explicitly out
  of scope for this plan: it would turn prompt injection into remote code execution. If ever added, it
  requires a sandbox (a container, or something like Cloudflare's `sandbox-sdk`) — **never** exec the
  model's output directly on this host.
- **Conversation memory / history.** Every DM/@mention is answered statelessly — `agent_brain.ask` sees
  only the current message, not prior turns. A human reading the Slack thread sees continuity; the model
  doesn't. Multi-turn memory is a deliberate non-goal here, not an oversight.
- **RAG (retrieval-augmented generation).** Out of scope — would introduce a new dependency edge (a vector
  store, e.g. a future `vector.cloudflare.md`) this plan doesn't Require today.
- **Multi-user auth beyond the admin/guest binary tier.** `is_admin` is a single Slack-user-id check
  (`ADMIN_USER`); there is no role system, no per-guest allow/deny list, no workspace-membership check
  beyond "is this the one admin id, or not."
- **The #4 idle-watchdog.** A half-open Socket Mode connection (pitfall #4 — hours mute, no clean
  disconnect) has never been observed live; §1's Candor already logs every connect/disconnect so a zombie
  would be visible. A standalone watchdog process remains deferred until one actually happens, not authored
  speculatively.
- **A Claude-backed brain.** Swapping `agent_brain.py`'s Ollama call for a Claude/CLI-subprocess call
  would re-activate pitfalls #8/#9 (grant-surface / strict-MCP) that this plan's direct-HTTP-to-Ollama
  design deliberately sidesteps (see Candor, "#8/#9 are N/A").
