| 1 | # π€ Bots (agents) β design |
| 2 | |
| 3 | Status: **the sidecar exists and runs.** The default is the **Go** sidecar |
| 4 | (`lchat bot run`, part of the single binary β see *Direction: a Go sidecar* |
| 5 | below, now shipped); the **Node** sidecar (`agent/`) is kept as the runnable |
| 6 | reference. Beyond the "Direction" and "What exists today" sections, the rest is |
| 7 | proposed direction captured from design discussion, not built. This doc is the |
| 8 | single place for where we landed. |
| 9 | |
| 10 | ## What a bot is |
| 11 | |
| 12 | A bot is a **normal member driven by code** β nothing more privileged at the |
| 13 | protocol level. It has: |
| 14 | |
| 15 | - an **identity**: an ECDH keypair, a device token, a `users` row with |
| 16 | `is_agent = 1`, and a roster label + (optional) avatar. One provision = one |
| 17 | identity. |
| 18 | - a **personality/behaviour**: a system prompt, a model, and how it's triggered. |
| 19 | This is *config*, not code. |
| 20 | |
| 21 | Two layers, and the split is the whole governance story: |
| 22 | |
| 23 | | Layer | What it is | Who owns it | |
| 24 | |---|---|---| |
| 25 | | **Engine** | the running process, the API key, the host, and *which threads the bot is in* | **admin/operator** β this is where all trust concentrates | |
| 26 | | **Behaviour** | system prompt + triggerβaction recipes | **admin, or users** (within bounds) | |
| 27 | |
| 28 | ## The one rule still holds: the server stays blind |
| 29 | |
| 30 | A bot is a client, exactly like a browser. It holds its own private key and the |
| 31 | thread keys wrapped to it; it decrypts and calls the model **off the lchat |
| 32 | server**. The server only ever sees ciphertext. |
| 33 | |
| 34 | The deliberate, disclosed trade-off: to generate a reply the bot sends decrypted |
| 35 | messages to the **model provider (Anthropic)**. That's leakage to the provider, |
| 36 | not between users, and only a local model would close it. Say so in the UI where a |
| 37 | bot is added. |
| 38 | |
| 39 | Two consequences to design around: |
| 40 | |
| 41 | - **The bot's host is a high-value target.** A bot is deliberately a member of |
| 42 | many private threads, so its host transiently holds all their plaintext + keys. |
| 43 | Compromise of the host = read every thread that host's bots are in. Mitigate: |
| 44 | one sidecar per instance, minimise threads per bot, harden the host, keep no |
| 45 | ambient prod credentials on it β and above all, **run the sidecar somewhere |
| 46 | better defended than the chat server** (next section). |
| 47 | - **No forward secrecy on removal** β removing a bot doesn't retroactively |
| 48 | protect past messages, same as removing a human today. Future hardening that |
| 49 | would bound both consequences: **rotate the thread key when a bot joins or |
| 50 | leaves**, so a stolen bot key decrypts only the bot's membership window, not |
| 51 | the thread's whole history. |
| 52 | |
| 53 | ## The sidecar runs anywhere: it's a pure outbound client |
| 54 | |
| 55 | The sidecar never accepts a connection. It *dials out* to exactly two places β |
| 56 | the instance (HTTPS/WSS) and the Anthropic API β and nothing ever dials it: no |
| 57 | inbound ports, no webhooks, no route. The lchat server has zero knowledge of |
| 58 | where it runs (a bot is just a member on normal endpoints). That makes the bot |
| 59 | host a free variable in the threat model: |
| 60 | |
| 61 | - **Same box as the instance** (the default today, as separate systemd units): |
| 62 | convenient, but it puts the bot's keys on the most exposed machine in the |
| 63 | system. Root on the chat server reads every bot thread. |
| 64 | - **Any other machine the operator trusts**: a home server behind NAT, a |
| 65 | workstation, a Raspberry Pi. Root on the chat server then yields ciphertext |
| 66 | and nothing else β the full "server compromise gets nothing" guarantee is |
| 67 | restored, because the bot's keys sit on a box the attacker can't route to. |
| 68 | - **A private network with no inbound at all**: NAT-only, egress allowlisted to |
| 69 | the instance + `api.anthropic.com`. The bot machine's attack surface shrinks |
| 70 | to supply chain and the message content it processes. For a no-public-egress |
| 71 | deployment, Claude is also reachable via AWS Bedrock / GCP Vertex over |
| 72 | private endpoints β and a write-only changelog watcher needs no model API at |
| 73 | all. |
| 74 | |
| 75 | The honest framing (for consent copy too): a bot is **a member whose "device" |
| 76 | is a server**, and adding one to a thread extends that thread's E2E envelope to |
| 77 | the machine the operator chose. Where that machine lives is the single biggest |
| 78 | security decision the operator makes about bots. |
| 79 | |
| 80 | One wrinkle of being a persistent outbound client: on a **hibernating** |
| 81 | instance (the activator, `activator.go`), an always-on sidecar would either |
| 82 | hold the instance awake forever or wake it right back on every reconnect. So |
| 83 | there the sidecar is **co-spawned** β started by the activator when its |
| 84 | instance wakes, stopped first when it sleeps β and every request the Go |
| 85 | sidecar makes carries a marker header the router excludes from the instance's |
| 86 | idle clock (`internal/bot/sidecarmark.go`). None of this changes the trust |
| 87 | model: the sidecar is still its own process with its own keys, still outbound- |
| 88 | only. See `docs/instance-router.md Β§ Sidecars on hibernating instances`. |
| 89 | |
| 90 | ## Direction: a Go sidecar (decided 2026-07-06, shipped 2026-07-07) |
| 91 | |
| 92 | The sidecar was ported to **Go** β a subcommand of the single binary (`lchat |
| 93 | bot run`), run as its **own process** with its own env (never inside the server |
| 94 | process: the bot holds private keys + the Anthropic key, and folding it into |
| 95 | the server would collapse the trust boundary above). It is now the **default**; |
| 96 | the full status is below. |
| 97 | |
| 98 | **The Node sidecar (`agent/`) is kept in-repo on purpose** β a supported, |
| 99 | runnable **reference implementation**, not deprecated and not scheduled for |
| 100 | removal. It's the worked example of how to run a bot in Node if you'd rather, |
| 101 | and (because it imports `web/static/crypto.js` verbatim) the clearest |
| 102 | illustration of the exact E2EE wire format a client speaks. Keep it working |
| 103 | alongside the Go port; the Go tests pin themselves to these JS sources |
| 104 | (`bot-commands.js`, `actions.mjs`, `eleven.mjs`, the shared `knowledge.md`), so |
| 105 | the reference actively guards the port against drift. |
| 106 | |
| 107 | Why: |
| 108 | |
| 109 | - **Kills the Node runtime dependency** β today the router box needs Node 24 + |
| 110 | `npm ci` + a separate rsync deploy path solely for bots. |
| 111 | - **Makes "run it anywhere" real**: copying one static binary to a Raspberry Pi |
| 112 | or a locked-down VPC box is a far better story than standing up Node there. |
| 113 | - Nothing in the sidecar is Node-specific: `ws` β `coder/websocket` (already a |
| 114 | dependency), `@anthropic-ai/sdk` β the official Go SDK (which also speaks |
| 115 | Bedrock/Vertex), and the crypto is ECDH P-256 + HKDF + AES-GCM, all stdlib. |
| 116 | |
| 117 | The one real cost: Go must reimplement `crypto.js`'s wire format |
| 118 | (ECIES seal/wrap, `iv||ciphertext` base64url) byte-for-byte and stay in |
| 119 | lockstep β the Node sidecar gets that for free by importing the file. The |
| 120 | native-app work is already committed to publishing **cross-language crypto test |
| 121 | vectors** for its Swift port; the Go sidecar becomes the second consumer of the |
| 122 | same fixture set. Incremental path: port the **Announcer** first (write-only, |
| 123 | no model API β exercises provision/connect/encrypt with the least surface), |
| 124 | then the shared runtime, then Eleven's persona layer. |
| 125 | |
| 126 | **Status: the crypto port is landed.** `internal/e2ee` mirrors `crypto.js` |
| 127 | (AES-GCM blobs, ECIES wrap/seal/open, invite derivation, WebCrypto-compatible |
| 128 | raw/pkcs8 keypairs), pinned by `internal/e2ee/testdata/vectors.json` β fixtures |
| 129 | generated from `crypto.js` itself by `test/gen-e2ee-vectors.mjs` (regenerate |
| 130 | them after any deliberate `crypto.js` change and re-run `go test |
| 131 | ./internal/e2ee`). **Provisioning is landed too**: `lchat bot provision` |
| 132 | (`internal/bot` + `botcmd.go`) claims an agent invite headlessly β the invite |
| 133 | secret comes from `LCHAT_INVITE` in the env, never argv β and writes a session |
| 134 | file byte-compatible with the Node sidecar's `session.json`, so either |
| 135 | implementation can take over the other's identity in place. **And the |
| 136 | changelog engine** (which powered the Announcer until its retirement β see |
| 137 | below): it baselines silently on first run, batches new changelog entries into |
| 138 | one encrypted post per thread, and self-declares kind `bot`; today it runs only |
| 139 | under an explicit recipe's `changelog` rule (docs/bot-recipes.md). **And the live |
| 140 | runtime**: `lchat bot run` (`internal/bot/runtime.go`) is the persona-agnostic |
| 141 | core of `runtime.mjs` in Go β WebSocket with reconnect/backoff/keepalive, |
| 142 | thread-key unwrap, cursor-gated idempotent delivery, catch-up after a blip, |
| 143 | never-reply-to-agents, DM-always / group-@mention triggers β with the brain |
| 144 | behind a single `Handler` seam. Today that seam holds the **deterministic |
| 145 | command floor** (`internal/bot/commands.go`, the Go twin of `commands.mjs`; |
| 146 | drift-pinned to `web/static/bot-commands.js` by a test), so a keyless Go bot |
| 147 | answers exactly like a keyless Node one. One process hosts many identities |
| 148 | (one session file per bot on the command line), and one deliberate divergence: |
| 149 | the Go runtime answers the *first* message of a newly-seen thread (Node |
| 150 | baselines the cursor past it and stays silent). **And recipes**: the runtime |
| 151 | is recipe-shaped (`internal/bot/recipe.go`, schema + rationale in |
| 152 | `docs/bot-recipes.md`) β behaviour as an ordered triggerβaction rule list from |
| 153 | a bounded palette (message Γ trigger/any/keyword/regex β commands/reply/model; |
| 154 | changelog β announce), validated fail-closed, one `<session>.recipe.json` per |
| 155 | identity, watcher rules loudly flagged. **And the model layer**: `brain.go` is |
| 156 | the Claude-backed reply path (`internal/bot/anthropic.go`, a minimal net/http |
| 157 | Messages client β no new dependency) reached via the recipe `model` action. |
| 158 | Set `ANTHROPIC_API_KEY` and the bot self-declares kind `ai` and upgrades from |
| 159 | the command floor to conversational replies β same layered system prompt |
| 160 | (non-overridable safety preamble β console persona β fellow-helpers line β |
| 161 | docs), cache-marked system block, adaptive thinking, the chip tools |
| 162 | (drift-pinned to `actions.mjs`), the promised-button forced-retry self-heal, |
| 163 | and the listen-mode "name" classifier gate β all as in `runtime.mjs`. **And |
| 164 | connected-services** (`internal/bot/connect.go` + `dnsimple.go`): the capability |
| 165 | ops `handleConnect` / `handleOAuthExchange` are ported β a member seals a |
| 166 | time-boxed grant to the bot's key, the bot opens it with its own private key |
| 167 | (a grant sealed to a different bot won't open, so it stays silent), lists |
| 168 | DNSimple domains or runs the confidential OAuth exchange via the shared broker, |
| 169 | and seals the result back to the member. **The Go sidecar is now a full drop-in |
| 170 | for the Node one**: everything the shipped bots do β the deterministic floor, |
| 171 | conversational Eleven/Claude, connected-services, and the changelog engine β |
| 172 | runs from the single `lchat` binary with no Node. The one thing deliberately |
| 173 | *not* in the sidecar is Eleven's onboarding welcomer, which is moving |
| 174 | client-side as data (`docs/conversation-scripts.md`). `agent/` stays as the |
| 175 | reference implementation; session/state/recipe files are Node-compatible so |
| 176 | either can take over the other's identity in place. |
| 177 | |
| 178 | ## What exists today (`agent/`) |
| 179 | |
| 180 | A headless Node sidecar that behaves like a member's browser: |
| 181 | |
| 182 | - imports `web/static/crypto.js` **verbatim** (runs unmodified in Node), so its |
| 183 | wire format is guaranteed to match the app. |
| 184 | - `provision.mjs` β one-time headless `join.js`: derive invite id from the |
| 185 | `#fragment`, generate a keypair, claim a device token, register the pubkey β |
| 186 | `session.json` (the bot's identity + private key; lives only on the host). |
| 187 | - `agent.mjs` β connect `/ws`, unwrap thread keys, on each incoming `msg` |
| 188 | decrypt, build context from the thread's recent history, call Claude |
| 189 | (`claude-opus-4-8`, adaptive thinking), post an encrypted reply. **DM β replies |
| 190 | to everything; group β only on @mention by default.** A per-bot **listen mode** |
| 191 | (console `bot_configs.listen`) can widen that to `name`, where a bare-name |
| 192 | mention also replies β but only if a cheap Haiku yes/no call judges the bot is |
| 193 | actually being addressed (talking *about* a bot doesn't summon it). Reconnects |
| 194 | with backoff; picks up new threads on first unknown `thread_id`. |
| 195 | - Server + web: additive `users.is_agent` column, roster `BOT` tag, and a |
| 196 | dedicated admin **"Add an agent"** section (distinct from inviting a person) |
| 197 | that captures **model + system prompt + reference docs** and stores them in a |
| 198 | `bot_configs` table. The sidecar pulls its own config from `/api/bot-config` |
| 199 | and applies it β a **non-overridable safety/app-awareness preamble** is always |
| 200 | prepended, then the console-set persona, then the docs. |
| 201 | - Still design, not built: **editing** an existing bot's config from the console; |
| 202 | wrapping the config to the sidecar's key so the server can't read prompts/docs; |
| 203 | and the ambient **watcher** read scope. The form now captures persona/model/ |
| 204 | docs, the catalog fields (description + availability), and the **listen mode** |
| 205 | (@mention-only, the default, vs. bare-name). |
| 206 | |
| 207 | Limits today: reactive only (no scheduler, can't self-initiate β so it **cannot |
| 208 | remind you**, and may falsely promise to); short-term memory only (re-reads the |
| 209 | last ~30 messages of the thread per reply β nothing persisted); no tools. One |
| 210 | behaviour is still interim vs. the target below: in a group it reads recent |
| 211 | history to compose a reply (target: **mention-scoped**). |
| 212 | |
| 213 | ## One sidecar per instance, all bots in it |
| 214 | |
| 215 | Each instance runs **exactly one sidecar** β a separate headless process, |
| 216 | never part of the server process. That single sidecar drives **all of the |
| 217 | instance's bots** (default + custom): each bot is its own member identity with |
| 218 | its own WebSocket connection, but one process, one host, one API key. |
| 219 | |
| 220 | - **Env:** `LCHAT_BASE` + `ANTHROPIC_API_KEY` β the sidecar's own deploy env, |
| 221 | the same channel as the app's operator secrets. The lchat server never sees |
| 222 | the key. Per-bot config comes from the admin console (next section). |
| 223 | - A small data dir holds the sidecar's **keystore** β one keypair per bot β so |
| 224 | a redeploy doesn't re-provision. Private keys never leave the host. |
| 225 | - The sidecar **auto-provisions** each configured-but-new bot: generate a |
| 226 | keypair, register its pubkey, mark it `is_agent`, store the private key locally. |
| 227 | |
| 228 | ### Deploy |
| 229 | |
| 230 | On the fleet, `lchat add-bot <host>` provisions the identities and the |
| 231 | `lchat-bot@<host>` systemd unit runs `lchat bot run` (the Go sidecar inside |
| 232 | the instance's own versioned binary) with env in `/data/<host>-bot.env` and |
| 233 | sessions in `/data/<host>-bot/`. The Node reference sidecar (`agent/`) runs |
| 234 | the same way anywhere Node does: point `LCHAT_BASE` at the instance, feed it |
| 235 | the agent invites, run `node agent/agent.mjs`. (The original deployment shape |
| 236 | β a per-instance container app on the Miren platform, `Dockerfile.agent` β |
| 237 | was retired 2026-07-15 along with that platform; the sidecar has no inbound |
| 238 | ports, so any box that can dial out can host it.) |
| 239 | |
| 240 | ## Configuration & control |
| 241 | |
| 242 | Split config by what it *is*: |
| 243 | |
| 244 | **The Anthropic API key β the sidecar's env** (`ANTHROPIC_API_KEY`, in |
| 245 | `/data/<host>-bot.env` on the fleet), the same channel as the app's operator |
| 246 | secrets (`LCHAT_ADMIN_TOKEN`, `LCHAT_DB`). Whoever deploys the sidecar is the |
| 247 | operator; they set it at deploy and rotate it by updating env. Crucially the |
| 248 | **lchat server never touches the key in any form** β no store-key endpoint, no |
| 249 | encryption dance. |
| 250 | That's simpler than routing it through the console *and* strictly better for |
| 251 | "server blind": the server has zero involvement with the key, rather than holding |
| 252 | a wrapped blob of it. (An earlier draft put the key in the console encrypted to |
| 253 | the sidecar β that over-solved a problem you avoid entirely by not routing the key |
| 254 | through the app.) |
| 255 | |
| 256 | **Bot config β the admin console** (name, avatar, model, system prompt, reference |
| 257 | docs, read-scope, availability) β the thing an admin actually wants a UI for. |
| 258 | Stored server-side, pulled by the sidecar. If you want prompts/docs hidden from |
| 259 | the server too, wrap *those* to the sidecar's key (the key itself is already out |
| 260 | of the picture, in env). For a first version, bot config can even be a |
| 261 | sidecar-side file and skip the console. |
| 262 | |
| 263 | **Once the key is set, a few default bots exist** β e.g. a friendly general helper |
| 264 | and a thread summariser (kid-safe, conservative). They're examples: fully editable |
| 265 | or removable. |
| 266 | |
| 267 | **Adding / editing a bot** is a console form: |
| 268 | |
| 269 | | Field | Notes | |
| 270 | |---|---| |
| 271 | | Name + avatar | shown in the Helpers catalog with the `BOT` badge | |
| 272 | | Model | Opus / Sonnet / Haiku per bot | |
| 273 | | System prompt | the personality β layered on a **non-overridable kid-safety preamble** | |
| 274 | | Reference docs | context the bot should always have (instructions, house rules, an FAQ); included in its context, with retrieval for large docs later | |
| 275 | | Read scope / trigger | mention-only (default) vs. ambient watcher β see next section | |
| 276 | | Available to members | whether it appears in the Helpers catalog (operator-only bots stay hidden) | |
| 277 | |
| 278 | Catalog metadata that member browsers must render (name, avatar, description, |
| 279 | availability) is readable by members β stored like roster labels, or wrapped to |
| 280 | members via the instance key if labels are kept from the server. |
| 281 | |
| 282 | ### Is the sidecar active and working? |
| 283 | |
| 284 | The instance needs **no** knowledge of the sidecar to *function* β a bot is just |
| 285 | a member on normal endpoints, which is what keeps the server dumb and the sidecar |
| 286 | swappable. So skip a static `SIDECAR_ACTIVE` env flag: it encodes intent, not |
| 287 | reality (it reads `true` while the sidecar is crashed β the exact false |
| 288 | reassurance to avoid). Derive status from live facts the server already has β it |
| 289 | sees whether each bot member is connected, and it holds the bot *config* for the |
| 290 | catalog, so "configured vs. actually connected" needs no new state: |
| 291 | |
| 292 | - **Connected / last-seen per bot**, read from the hub (it already tracks live |
| 293 | clients by user id). A small `GET /api/admin/bots` powers a "Bots" panel. |
| 294 | - **Heartbeat** for liveness beyond a bare socket (reuse the existing `active` |
| 295 | beat or a periodic ping) β "last heard 20s ago", so a wedged-but-connected |
| 296 | sidecar is distinguishable from a healthy one. |
| 297 | - **A "Test bot" button** (like the existing test-push): DM the bot a ping and |
| 298 | confirm a reply. This is the real end-to-end check β and it validates the key, |
| 299 | since a bot that's connected but can't reply almost always means a missing or |
| 300 | invalid `ANTHROPIC_API_KEY`. The sidecar can report a status string |
| 301 | (`model ok` / `auth error`) to make the cause explicit. |
| 302 | |
| 303 | **Whether to offer "Add a bot" at all** can't come from bot config alone β before |
| 304 | the first bot there is none. In practice it comes for free from the **default bots |
| 305 | the sidecar seeds on first boot**: their appearance (agent users the server didn't |
| 306 | create) *is* the "a sidecar lives here" signal. Two conditions keep that honest: |
| 307 | |
| 308 | - The **sidecar** must seed the defaults, not the server β otherwise it's circular. |
| 309 | - Record the capability **sticky** the first time an agent user appears, so the |
| 310 | offer survives the admin later deleting every bot (don't gate purely on "are |
| 311 | there bots right now"). |
| 312 | |
| 313 | A lightweight boot heartbeat is then only belt-and-braces β for the |
| 314 | sidecar-present-but-zero-bots edge, and for live **health**. So a manual |
| 315 | `SIDECAR_ACTIVE` env boolean stays the wrong tool: a signal derived from the |
| 316 | sidecar actually being there can't drift the way a hand-set flag can (set `true`, |
| 317 | deploy fails, UI offers bots that can't run). Net: **capability** is sticky (a |
| 318 | sidecar has registered β show the UI); **health** is live (connected + recent |
| 319 | heartbeat β green/red). Both drive admin UX only β bots still *function* with zero |
| 320 | server awareness. |
| 321 | |
| 322 | ## How much a bot reads in a group |
| 323 | |
| 324 | > **Decision (2026-07-02): recent-history context stays.** In practice the |
| 325 | > context is what makes replies good β a bot answering "@Claude is Bob right?" |
| 326 | > without the surrounding conversation is useless β so a mention-TRIGGERED bot |
| 327 | > reading recent thread history (what's built: ~30 recent messages at reply |
| 328 | > time) is the accepted behaviour, and the mention-only ingestion described |
| 329 | > below is NOT planned. Be honest about it in consent copy: adding a bot to a |
| 330 | > group means it reads recent messages whenever it's called on. Watchers |
| 331 | > (below) remain the distinct, heavier-consent mode: they process EVERY |
| 332 | > message, not just when summoned. The original design rationale is kept for |
| 333 | > the record: |
| 334 | |
| 335 | Original design default (superseded): **a group bot does NOT read the whole |
| 336 | conversation.** It ingests only |
| 337 | messages **addressed to it** β those that @mention it β plus, when a mention is a |
| 338 | **reply**, the single message being replied to (so "@Claude is Bob right?" gives |
| 339 | it Bob's message to answer). Its context in a group is just its own |
| 340 | sub-conversation: mentions of it, the parents those replies point to, and its own |
| 341 | past replies β never the general chatter. |
| 342 | |
| 343 | Why this is the default: |
| 344 | - **Privacy + consent.** A mention-only bot is far less invasive than one reading |
| 345 | everything, so adding it to a group is a light decision β and it minimises what |
| 346 | leaves the E2E envelope to reach the model provider. |
| 347 | - **Safe-for-kids.** Give the bot the least it needs to do its job. |
| 348 | |
| 349 | Honest limit: the bot is a thread member and holds the thread key, so "only reads |
| 350 | mentions" is a **policy in the sidecar, not a cryptographic guarantee** β it |
| 351 | *could* decrypt the rest; it chooses not to, and the code is small and auditable. |
| 352 | A cryptographic "cannot read the rest" would mean not giving the bot the thread |
| 353 | key and instead re-encrypting each mention (and its replied-to parent) to the |
| 354 | bot β stronger, but it complicates how the bot posts a group-readable reply. |
| 355 | Flagged as possible future hardening, not v1. |
| 356 | |
| 357 | **Ambient / watcher bots are the explicit exception.** Summaries ("what did we |
| 358 | decide?") and keyword nudges (the inclusivity bot) genuinely need every message. |
| 359 | That's a distinct, clearly-labelled mode: a **"reads every message in this |
| 360 | group"** flag, heavier consent, and a visible "a helper is watching for X" notice. |
| 361 | So the consent gate now means something β most bots are mention-only and cheap to |
| 362 | add; only the few that must read everything carry the loud flag. |
| 363 | |
| 364 | A watcher's detection is **JS in the sidecar, not a Claude tool** β a trigger that |
| 365 | gates whether a message reaches the model. So there are two layers of exposure, |
| 366 | which is the honest reading of "reads every message": |
| 367 | |
| 368 | - The **sidecar** decrypts and matches every message (`/\bguys?\b/i`) β this is |
| 369 | the host-side exposure the consent flag is about, policy-enforced not |
| 370 | cryptographic. |
| 371 | - The **model provider** sees only *matches* β non-matching chatter never leaves |
| 372 | the sidecar, so provider cost/exposure stay scoped to hits. |
| 373 | |
| 374 | The **action** on a match is a choice: a **fixed template** (post/DM a pre-written |
| 375 | nudge β no model call, deterministic, and often best for a nudge) or an **LLM |
| 376 | call** (phrase it in context). Trigger = JS; action = template or LLM. This is the |
| 377 | triggerβaction recipe, and it's separate from Claude tool-use (the dev-bot lane). |
| 378 | |
| 379 | > Recipes have a **sibling schema**: `docs/conversation-scripts.md` β Eleven's |
| 380 | > deterministic onboarding flows as data, interpreted client-side (needed for |
| 381 | > the native apps). Same chip/bubble message shapes, deliberately *not* one |
| 382 | > unified behaviour language. When the Go sidecar runtime is built |
| 383 | > (Β§ Direction below), it should be recipe-shaped from day one β an engine |
| 384 | > executing config β so declarative bots don't require a second rewrite. |
| 385 | |
| 386 | ## Where bots earn their keep: groups, not DMs |
| 387 | |
| 388 | The moat is **shared context**, which a standalone Claude/ChatGPT structurally |
| 389 | can't have. A DM bot is a worse-UX version of tools people already have; a bot |
| 390 | *in a group* can see and act on the conversation this group is having (summarise |
| 391 | a thread, answer "what did we decide?", watch for a keyword). Invest here. |
| 392 | |
| 393 | The exception that keeps DMs worthwhile: for **kids**, an in-app DM companion's |
| 394 | value is precisely that the alternative β unsupervised ChatGPT β is what you're |
| 395 | preventing. Treat DMs as the safe sandbox + proactive/reminder lane, not the |
| 396 | differentiator. |
| 397 | |
| 398 | Implications: |
| 399 | |
| 400 | - Build **group-native, ambient** behaviours (summarise / answer-from-thread / |
| 401 | watch-and-nudge), not a better DM chat loop. |
| 402 | - **Group-scoped memory is both the most valuable and the safest kind.** The |
| 403 | cross-user-leak risk of bot memory *disappears* when memory is scoped to a |
| 404 | group, because everyone who'd read it already shares the thread. Memory scope |
| 405 | follows read scope: a mention-only bot remembers its own exchanges; an ambient |
| 406 | watcher can remember the whole thread. Build memory for groups first. |
| 407 | |
| 408 | ## Discovery under the connections-only roster |
| 409 | |
| 410 | Members now see `connections(userID)` = self + people they share a thread with β |
| 411 | **not** the full directory (`db.go` `connections()`). A new bot shares no thread, |
| 412 | so it would be invisible. Bots therefore **cannot ride the roster** for |
| 413 | discovery. |
| 414 | |
| 415 | **Resolution: a separate, admin-curated "Helpers" catalog, exempt from the |
| 416 | connections rule.** People stay connections-only (privacy β no seeing |
| 417 | strangers); bots are intentional, vetted helpers, so the admin's |
| 418 | members-available set is shown to everyone regardless of connection. Once a |
| 419 | member adds/DMs a bot, it becomes a normal shared-thread connection and appears |
| 420 | in their usual list β no special-casing after that. |
| 421 | |
| 422 | Surface it as a **"Helpers" section** in the picker (distinct from People β |
| 423 | reinforces "not a person"), each entry = name + avatar + **one-line |
| 424 | description**. The description doubles as the consent hint. **Weight it toward |
| 425 | groups**: the prominent affordance is "Add a helper" in the group create / |
| 426 | add-member flow (where the value is); "DM a helper" is the secondary sandbox |
| 427 | lane. Show only bots the admin marked available (see governance). |
| 428 | |
| 429 | **Built (helpers-catalog):** `bot_configs` gained `description` + |
| 430 | `available_to_members` (set from the admin's "Add an agent" form, availability |
| 431 | default-on there, off in the schema). `connections()` unions in agents matching |
| 432 | `is_agent = 1 AND available_to_members = 1` that have registered a public key β |
| 433 | that one exemption *is* the catalog; the client just renders roster entries with |
| 434 | `is_agent` under a "Helpers" heading (new-chat and add-to-group pickers), with |
| 435 | the description as the row's second line. The open decision on name/description |
| 436 | storage was resolved toward **in clear**, consistent with roster labels today β |
| 437 | revisit both together if labels ever move under the instance key. |
| 438 | |
| 439 | ## Governance |
| 440 | |
| 441 | Mirror the existing `Users can invite members` with **`Users can create bots`** β |
| 442 | but it's not a 1:1 analogy, because a bot carries an API key, compute, and |
| 443 | read-scope a member invite never does. |
| 444 | |
| 445 | The setting governs **behaviour on the admin's engine**, tiered by read-scope: |
| 446 | |
| 447 | - **Default off.** On kids-facing instances, keep it off (loosen only as opt-in). |
| 448 | - **ON β members may create *personal* bots freely** (a bot in their own DM): |
| 449 | low risk, only their own data. |
| 450 | - **Group-watching bots always need group/admin consent β even when the toggle |
| 451 | is on.** Reading a shared room is a shared decision; one member can't silently |
| 452 | bug it. This gate is separate from the toggle and never removed by it. |
| 453 | - **Capabilities come from an admin-approved recipe palette** (reply / nudge / |
| 454 | remind / summarise / keyword-watch) β declarative triggerβaction, never |
| 455 | arbitrary tools or the build/deploy lane. |
| 456 | |
| 457 | Controls the invite toggle never needed, because user bots spend shared |
| 458 | resources: **per-user caps**, rate limits, a **kill switch**, and admin |
| 459 | visibility into which user bots exist. Plus a per-bot **`available_to_members`** |
| 460 | flag (operator-only bots β e.g. the dev bot below β never appear in the catalog). |
| 461 | |
| 462 | ## Creating bots in plain English |
| 463 | |
| 464 | Yes β but as a **front-end to the same bounded recipe**, not a code generator. A |
| 465 | "Describe a bot" box takes natural language ("watch for 'guys' and gently remind |
| 466 | people to be inclusive"); an LLM **builder** translates it (structured output) |
| 467 | into the config schema β name, system prompt, model, trigger, action, read-scope. |
| 468 | Plain English is just another way to fill the form; both emit the same config, so |
| 469 | build the schema + form first and layer the builder on top. |
| 470 | |
| 471 | Guardrails that keep it inside the boundary: |
| 472 | |
| 473 | - **Translate into the supported palette only** β allowed triggers |
| 474 | (keyword/regex/mention/schedule) and actions (reply / DM-nudge / summarise / |
| 475 | template-or-LLM). An out-of-palette request ("delete messages", "email people", |
| 476 | "deploy code") gets "I can't do that yet", never invented. The plain-English |
| 477 | layer is UX, never a capability escalation. |
| 478 | - **Review-and-confirm before it goes live.** Show the derived config back in |
| 479 | plain terms β *"This bot will read every message in the group and privately DM |
| 480 | anyone who says 'guys' a reminder."* This is both the did-I-get-it-right check |
| 481 | and the **consent moment**: the builder infers read-scope from the request |
| 482 | (watcher β reads everything; Q&A-on-mention β mention-only) and must surface it, |
| 483 | since the user didn't ask for it explicitly. |
| 484 | - **Same governance + consent tiers.** A personal DM bot is light; a described |
| 485 | group watcher still hits the group/admin consent gate (driven by the derived |
| 486 | read-scope). The **kid-safety preamble is layered on regardless** of the user's |
| 487 | words, and the builder declines malicious intents ("watch for X and mock them"). |
| 488 | - A small one-off **LLM call on the instance key**, covered by the per-user caps. |
| 489 | Editing works the same way ("also watch for 'dude'", "make it friendlier") β |
| 490 | diff β confirm. |
| 491 | |
| 492 | ## Capability tiers (roadmap) |
| 493 | |
| 494 | 1. **Chat responder** β exists. DM/mention reply. |
| 495 | 2. **Watcher recipes** β group-native, the differentiated layer. A **trigger** |
| 496 | (keyword/regex/schedule/mention) + an **action** (reply / private-DM nudge / |
| 497 | summarise). The inclusivity-nudge bot ("watch for `guy`, gently DM the |
| 498 | sender") is the template β detection is cheap string-matching; the model only |
| 499 | phrases the nudge. Reads the whole group β consent + transparency required. |
| 500 | 3. **Memory & reminders** β a `set_reminder` tool + a **per-thread/per-group, |
| 501 | host-side, encrypted** store + a scheduler that posts proactively (rides the |
| 502 | app's existing web push for delivery). Scope memory per-thread/group, **never |
| 503 | global-per-bot**. |
| 504 | 4. **Operator-only dev bot** β tools (bash/git/gh/miren) to build features. |
| 505 | Technically just an agent tool-use loop, but it's the highest-risk thing here: |
| 506 | chat input is untrusted, and a bot with GitHub push + deploy rights holds the |
| 507 | exact credentials that protect everyone's E2EE. Rules: **separate |
| 508 | operator-only identity, never in a family/kids thread; bots draft, humans ship |
| 509 | (PR, not direct deploy); least-privilege scoped creds; sandboxed worktree; |
| 510 | authenticate the requester.** Managed Agents is the purpose-built substrate |
| 511 | (sandbox + `always_ask` gates + git proxy). Never user-grantable. |
| 512 | |
| 513 | ## Consent, transparency, safety invariants |
| 514 | |
| 515 | - **Group default is mention-only** (bot reads only messages @-ing it + a |
| 516 | mention's replied-to parent). Reading the whole group is the loud, opt-in |
| 517 | watcher exception. |
| 518 | - Adding a group-watcher **is** the consent moment β surface what it does and |
| 519 | that it reads every message. |
| 520 | - Bots always wear the `BOT` badge; consider a "a helper is watching for X" |
| 521 | notice in a watched group. |
| 522 | - **Cross-user isolation invariant:** one model request = one thread's decrypted |
| 523 | history, nothing else. The shared API key is *not* a leak vector (stateless, |
| 524 | per-request). Never give a bot shared, cross-thread memory. Prompt caching is |
| 525 | safe (prefix match returns your own tokens). |
| 526 | |
| 527 | ## Open questions |
| 528 | |
| 529 | - Catalog metadata: clear (like roster labels today) vs encrypted to members. |
| 530 | - Miren routeless worker vs health endpoint. |
| 531 | - Reminder scheduler: in-sidecar timer vs the platform cron. |
| 532 | - Kids-instance defaults: personal bots off entirely, or on with a curated |
| 533 | recipe palette only. |
| 534 | - Per-bot model policy (cost): who may pick Opus vs Sonnet vs Haiku. |
| 535 | |
| 536 | ## The Announcer is retired |
| 537 | |
| 538 | The third default identity β the write-only **Announcer**, which posted release |
| 539 | notes into every group it was added to β is retired. The app now announces its |
| 540 | own updates with **no bot in the loop**, so it works on instances that run no |
| 541 | sidecar at all: on startup the server diffs its embedded changelog against a |
| 542 | watermark and notifies the space's **admins** (a Web Push / APNs banner, |
| 543 | `announce.go`), and the notes live in the **Admin app's update stream** (a |
| 544 | read-only chat view). Members no longer see release notes in their groups β |
| 545 | updates are the admins' concern. |
| 546 | |
| 547 | Existing deployments: the new `lchat bot run` skips a session named |
| 548 | `Announcer` (running it under the default recipe would turn the old write-only |
| 549 | bot into a responder), so a pre-retirement EnvironmentFile keeps working. |
| 550 | Remove the Announcer member from the roster (admin console β members) whenever |
| 551 | convenient; its session/state files on the sidecar machine can be deleted. An |
| 552 | operator who *wants* group-posted release notes can still run a custom bot |
| 553 | with a `changelog` recipe rule (docs/bot-recipes.md). |
| 554 | |