# Recipe — "a local Slack agent backed by Ollama" (no cloud, no public URL)

> A **golden path** for a personal assistant you run on your own machine: a local Ollama model answering
> Slack DMs and @mentions **in-thread**, over Socket Mode — **no cloud account, no per-token bill, no inbound
> port, no reverse proxy**. This is **composition without includes** — each plan stays standalone; this doc
> only sequences them and shows what each Provides to the next. The machine-readable edges live in
> [`catalog.json`](../../services/ephemera-releases/catalog.json).

## What you end up with

A working home agent: you DM a Slack bot, a model running on your laptop answers in the same thread. The
whole loop is local — the only thing that leaves your machine is the Slack WebSocket. The shape (every box is
one standalone plan):

```
inference.ollama ──Provides inference-endpoint(localhost:11434, MODEL)──┐
  (local daemon, MLX)                                                   │
                                                                        ├──► agent.local   (the leaf: Slack ↔ Ollama bridge)
messaging.slack  ──Provides slack-bot(APP_ID@TEAM)──────────────────────┘        Requires BOTH; Provides nothing
  (one App Manifest, bot-socket MODE)
```

## ⚠️ The key idea: two Provides, discovered at runtime — never a shared state file

`agent.local` is a **leaf** that **Requires** `inference-endpoint` **and** `slack-bot`. It never hardcodes
either: at call time it discovers the **served model tag** via `GET /api/tags` (fails loud if absent — never
a bare model name), and it reads the Slack **bot + app tokens from the macOS Keychain** (never echoed, never
written into a plan). That is the hand-off — two **names** resolved live, not a `.tfstate`. So the order is
forced: **serve the model and register the Slack app first; run the agent last.**

## The order

| step | plan | knobs | Provides → | Requires |
|------|------|-------|-----------|----------|
| 1 | `inference.ollama.md` | `BASE_MODEL=qwen3.6:27b-mlx`, `NUM_CTX=65536`, `MODEL=qwen3.6-coder-64k`, `AUTOSTART` | `inference-endpoint(localhost:11434, MODEL)` (+ OpenAI-compat `/v1`) | — (the daemon + a base model — it provisions them) |
| 2 | `messaging.slack.md` | `MODE=bot-socket`, `SCOPES`, `EVENTS` | `slack-bot(APP_ID@TEAM)` | — (a Slack workspace; **🔴 install/OAuth consent**) |
| 3 | `agent.local.md` | `AGENT_MODEL` (= step-1 `MODEL`), `TRIGGERS=both`, `RUN_MODE`, `PERSONA` | — (leaf) | `inference-endpoint` (step 1) + `slack-bot` (step 2) |

`AGENT_MODEL` (step 3) **must equal the served tag** step 1 Provides — the agent discovers it via `/api/tags`,
so a typo fails loud rather than silently answering from the wrong model.

## How to drive it

Plain English, one plan at a time — serve the brain, register the app, then bridge them:

```
"serve a local model with inference.ollama.md, base qwen3.6:27b-mlx, 64k context"   # 1  → inference-endpoint
"create a Slack app with messaging.slack.md, bot-socket mode"                        # 2  (🔴 install + OAuth consent) → slack-bot
"run the local agent with agent.local.md, foreground, both triggers"                # 3  (discovers 1 & 2)
```

After each plan, its **acceptance contract** is the gate before the next:

- **step 1** — `GET /api/tags` lists the served tag **and** `/api/chat` returns `done_reason=stop`,
  `eval_count>0`, a non-empty **final** answer (a bare `.response` can be empty mid-thought — a false pass).
- **step 2** — `auth.test → {ok:true}` (bot identity) and `apps.connections.open → wss://` (the Socket Mode
  connection opens). Note: step 2 proves the socket **opens**, not that a message is ever answered —
- **step 3** — that deferred proof is **this step's** acceptance: DM (or @mention) the bot → a well-formed,
  non-empty model reply, posted **in the same thread**, with no leaked `<think>…</think>` span and no token
  value. When that round-trip works, the agent is live.

## Candor — gates, secrets, and the sandbox

- **The only 🔴 in the whole recipe is the Slack install/OAuth consent** (step 2). Local inference has **no**
  gate — no denial-of-wallet, no global blast radius, no cross-account exposure; the lone 💥 is `ollama rm`
  (deleting a local model). Running the agent (`launchctl` load / kill) is trivially reversible.
- **Secrets never touch a plan.** The `xoxb-` bot token and `xapp-` app token live in the macOS Keychain
  (account `ephemera-slack-<env>`), read into a variable at runtime; the ledger records only presence/prefix.
- **The model's output is never executed — Ollama is the sandbox.** `agent_runner.py` / `agent_brain.py`
  contain no `eval`/`exec`/`os.system`/`shell=True`/`Popen`; every model reply flows through exactly one sink
  (`say(...)` → Slack `chat.postMessage`). `MAX_INPUT_CHARS` + a single-flight generation lock bound abuse.

## Swap a binding, keep the recipe

Each box is one binding of a portable intent, so the shape survives a substitution:

- **Hosted brain instead of local** — swap step 1 for `inference.cloudflare.md` (Workers AI). You gain a URL
  and lose the laptop dependency; you take on a **per-token bill** and a `PROTECT` 🔴 (denial-of-wallet).
- **A different chat surface** — swap step 2 for another `messaging.*` binding once one exists; `agent.local`
  only Requires `slack-bot` today, but the intent (`messaging`) is provider-neutral.

## Related — gate changes to the agent's own code

The agent runner is code you'll edit. `adversarial-review.multi-model.md` (intent `code-review`) installs a
pre-merge gate that runs a diff through external model CLIs — and its `local-only` reviewer is **this same
Ollama daemon**, so you can review changes to the agent without any egress.

## Deliberately not a meta-plan

This is a doc, not an `agent.app.local.md` orchestration plan — no new intent, just an ordering of three
existing ones and the Provides/Requires they already declare. If a single "run all of it" entry point earns
its keep later, a thin meta-plan can list this same Requires-order; until then, the standalone plans + this
recipe are the lighter answer.
