eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– Bots (agents) β€” design
2
3Status: **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*
5below, now shipped); the **Node** sidecar (`agent/`) is kept as the runnable
6reference. Beyond the "Direction" and "What exists today" sections, the rest is
7proposed direction captured from design discussion, not built. This doc is the
8single place for where we landed.
9
10## What a bot is
11
12A bot is a **normal member driven by code** β€” nothing more privileged at the
13protocol 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
21Two 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
30A bot is a client, exactly like a browser. It holds its own private key and the
31thread keys wrapped to it; it decrypts and calls the model **off the lchat
32server**. The server only ever sees ciphertext.
33
34The deliberate, disclosed trade-off: to generate a reply the bot sends decrypted
35messages to the **model provider (Anthropic)**. That's leakage to the provider,
36not between users, and only a local model would close it. Say so in the UI where a
37bot is added.
38
39Two 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
55The sidecar never accepts a connection. It *dials out* to exactly two places β€”
56the instance (HTTPS/WSS) and the Anthropic API β€” and nothing ever dials it: no
57inbound ports, no webhooks, no route. The lchat server has zero knowledge of
58where it runs (a bot is just a member on normal endpoints). That makes the bot
59host 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
75The honest framing (for consent copy too): a bot is **a member whose "device"
76is a server**, and adding one to a thread extends that thread's E2E envelope to
77the machine the operator chose. Where that machine lives is the single biggest
78security decision the operator makes about bots.
79
80One wrinkle of being a persistent outbound client: on a **hibernating**
81instance (the activator, `activator.go`), an always-on sidecar would either
82hold the instance awake forever or wake it right back on every reconnect. So
83there the sidecar is **co-spawned** β€” started by the activator when its
84instance wakes, stopped first when it sleeps β€” and every request the Go
85sidecar makes carries a marker header the router excludes from the instance's
86idle clock (`internal/bot/sidecarmark.go`). None of this changes the trust
87model: the sidecar is still its own process with its own keys, still outbound-
88only. See `docs/instance-router.md Β§ Sidecars on hibernating instances`.
89
90## Direction: a Go sidecar (decided 2026-07-06, shipped 2026-07-07)
91
92The sidecar was ported to **Go** β€” a subcommand of the single binary (`lchat
93bot run`), run as its **own process** with its own env (never inside the server
94process: the bot holds private keys + the Anthropic key, and folding it into
95the server would collapse the trust boundary above). It is now the **default**;
96the full status is below.
97
98**The Node sidecar (`agent/`) is kept in-repo on purpose** β€” a supported,
99runnable **reference implementation**, not deprecated and not scheduled for
100removal. It's the worked example of how to run a bot in Node if you'd rather,
101and (because it imports `web/static/crypto.js` verbatim) the clearest
102illustration of the exact E2EE wire format a client speaks. Keep it working
103alongside 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
105the reference actively guards the port against drift.
106
107Why:
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
117The one real cost: Go must reimplement `crypto.js`'s wire format
118(ECIES seal/wrap, `iv||ciphertext` base64url) byte-for-byte and stay in
119lockstep β€” the Node sidecar gets that for free by importing the file. The
120native-app work is already committed to publishing **cross-language crypto test
121vectors** for its Swift port; the Go sidecar becomes the second consumer of the
122same fixture set. Incremental path: port the **Announcer** first (write-only,
123no model API β€” exercises provision/connect/encrypt with the least surface),
124then 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
128raw/pkcs8 keypairs), pinned by `internal/e2ee/testdata/vectors.json` β€” fixtures
129generated from `crypto.js` itself by `test/gen-e2ee-vectors.mjs` (regenerate
130them 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
133secret comes from `LCHAT_INVITE` in the env, never argv β€” and writes a session
134file byte-compatible with the Node sidecar's `session.json`, so either
135implementation can take over the other's identity in place. **And the
136changelog engine** (which powered the Announcer until its retirement β€” see
137below): it baselines silently on first run, batches new changelog entries into
138one encrypted post per thread, and self-declares kind `bot`; today it runs only
139under an explicit recipe's `changelog` rule (docs/bot-recipes.md). **And the live
140runtime**: `lchat bot run` (`internal/bot/runtime.go`) is the persona-agnostic
141core of `runtime.mjs` in Go β€” WebSocket with reconnect/backoff/keepalive,
142thread-key unwrap, cursor-gated idempotent delivery, catch-up after a blip,
143never-reply-to-agents, DM-always / group-@mention triggers β€” with the brain
144behind a single `Handler` seam. Today that seam holds the **deterministic
145command floor** (`internal/bot/commands.go`, the Go twin of `commands.mjs`;
146drift-pinned to `web/static/bot-commands.js` by a test), so a keyless Go bot
147answers 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:
149the Go runtime answers the *first* message of a newly-seen thread (Node
150baselines the cursor past it and stays silent). **And recipes**: the runtime
151is recipe-shaped (`internal/bot/recipe.go`, schema + rationale in
152`docs/bot-recipes.md`) — behaviour as an ordered trigger→action rule list from
153a bounded palette (message Γ— trigger/any/keyword/regex β†’ commands/reply/model;
154changelog β†’ announce), validated fail-closed, one `<session>.recipe.json` per
155identity, watcher rules loudly flagged. **And the model layer**: `brain.go` is
156the Claude-backed reply path (`internal/bot/anthropic.go`, a minimal net/http
157Messages client β€” no new dependency) reached via the recipe `model` action.
158Set `ANTHROPIC_API_KEY` and the bot self-declares kind `ai` and upgrades from
159the command floor to conversational replies β€” same layered system prompt
160(non-overridable safety preamble β†’ console persona β†’ fellow-helpers line β†’
161docs), cache-marked system block, adaptive thinking, the chip tools
162(drift-pinned to `actions.mjs`), the promised-button forced-retry self-heal,
163and the listen-mode "name" classifier gate β€” all as in `runtime.mjs`. **And
164connected-services** (`internal/bot/connect.go` + `dnsimple.go`): the capability
165ops `handleConnect` / `handleOAuthExchange` are ported β€” a member seals a
166time-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
168DNSimple domains or runs the confidential OAuth exchange via the shared broker,
169and seals the result back to the member. **The Go sidecar is now a full drop-in
170for the Node one**: everything the shipped bots do β€” the deterministic floor,
171conversational Eleven/Claude, connected-services, and the changelog engine β€”
172runs 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
174client-side as data (`docs/conversation-scripts.md`). `agent/` stays as the
175reference implementation; session/state/recipe files are Node-compatible so
176either can take over the other's identity in place.
177
178## What exists today (`agent/`)
179
180A 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
207Limits today: reactive only (no scheduler, can't self-initiate β€” so it **cannot
208remind you**, and may falsely promise to); short-term memory only (re-reads the
209last ~30 messages of the thread per reply β€” nothing persisted); no tools. One
210behaviour is still interim vs. the target below: in a group it reads recent
211history to compose a reply (target: **mention-scoped**).
212
213## One sidecar per instance, all bots in it
214
215Each instance runs **exactly one sidecar** β€” a separate headless process,
216never part of the server process. That single sidecar drives **all of the
217instance's bots** (default + custom): each bot is its own member identity with
218its 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
230On 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
232the instance's own versioned binary) with env in `/data/<host>-bot.env` and
233sessions in `/data/<host>-bot/`. The Node reference sidecar (`agent/`) runs
234the same way anywhere Node does: point `LCHAT_BASE` at the instance, feed it
235the agent invites, run `node agent/agent.mjs`. (The original deployment shape
236β€” a per-instance container app on the Miren platform, `Dockerfile.agent` β€”
237was retired 2026-07-15 along with that platform; the sidecar has no inbound
238ports, so any box that can dial out can host it.)
239
240## Configuration & control
241
242Split 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
246secrets (`LCHAT_ADMIN_TOKEN`, `LCHAT_DB`). Whoever deploys the sidecar is the
247operator; 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
249encryption dance.
250That'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
252a wrapped blob of it. (An earlier draft put the key in the console encrypted to
253the sidecar β€” that over-solved a problem you avoid entirely by not routing the key
254through the app.)
255
256**Bot config β†’ the admin console** (name, avatar, model, system prompt, reference
257docs, read-scope, availability) β€” the thing an admin actually wants a UI for.
258Stored server-side, pulled by the sidecar. If you want prompts/docs hidden from
259the server too, wrap *those* to the sidecar's key (the key itself is already out
260of the picture, in env). For a first version, bot config can even be a
261sidecar-side file and skip the console.
262
263**Once the key is set, a few default bots exist** β€” e.g. a friendly general helper
264and a thread summariser (kid-safe, conservative). They're examples: fully editable
265or 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
278Catalog metadata that member browsers must render (name, avatar, description,
279availability) is readable by members β€” stored like roster labels, or wrapped to
280members via the instance key if labels are kept from the server.
281
282### Is the sidecar active and working?
283
284The instance needs **no** knowledge of the sidecar to *function* β€” a bot is just
285a member on normal endpoints, which is what keeps the server dumb and the sidecar
286swappable. So skip a static `SIDECAR_ACTIVE` env flag: it encodes intent, not
287reality (it reads `true` while the sidecar is crashed β€” the exact false
288reassurance to avoid). Derive status from live facts the server already has β€” it
289sees whether each bot member is connected, and it holds the bot *config* for the
290catalog, 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
304the first bot there is none. In practice it comes for free from the **default bots
305the sidecar seeds on first boot**: their appearance (agent users the server didn't
306create) *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
313A lightweight boot heartbeat is then only belt-and-braces β€” for the
314sidecar-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
316sidecar actually being there can't drift the way a hand-set flag can (set `true`,
317deploy fails, UI offers bots that can't run). Net: **capability** is sticky (a
318sidecar has registered β†’ show the UI); **health** is live (connected + recent
319heartbeat β†’ green/red). Both drive admin UX only β€” bots still *function* with zero
320server 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
335Original design default (superseded): **a group bot does NOT read the whole
336conversation.** It ingests only
337messages **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
339it Bob's message to answer). Its context in a group is just its own
340sub-conversation: mentions of it, the parents those replies point to, and its own
341past replies β€” never the general chatter.
342
343Why 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
349Honest limit: the bot is a thread member and holds the thread key, so "only reads
350mentions" 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.
352A cryptographic "cannot read the rest" would mean not giving the bot the thread
353key and instead re-encrypting each mention (and its replied-to parent) to the
354bot β€” stronger, but it complicates how the bot posts a group-readable reply.
355Flagged as possible future hardening, not v1.
356
357**Ambient / watcher bots are the explicit exception.** Summaries ("what did we
358decide?") and keyword nudges (the inclusivity bot) genuinely need every message.
359That's a distinct, clearly-labelled mode: a **"reads every message in this
360group"** flag, heavier consent, and a visible "a helper is watching for X" notice.
361So the consent gate now means something β€” most bots are mention-only and cheap to
362add; only the few that must read everything carry the loud flag.
363
364A watcher's detection is **JS in the sidecar, not a Claude tool** β€” a trigger that
365gates whether a message reaches the model. So there are two layers of exposure,
366which 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
374The **action** on a match is a choice: a **fixed template** (post/DM a pre-written
375nudge β€” no model call, deterministic, and often best for a nudge) or an **LLM
376call** (phrase it in context). Trigger = JS; action = template or LLM. This is the
377trigger→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
388The moat is **shared context**, which a standalone Claude/ChatGPT structurally
389can'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
391a thread, answer "what did we decide?", watch for a keyword). Invest here.
392
393The exception that keeps DMs worthwhile: for **kids**, an in-app DM companion's
394value is precisely that the alternative β€” unsupervised ChatGPT β€” is what you're
395preventing. Treat DMs as the safe sandbox + proactive/reminder lane, not the
396differentiator.
397
398Implications:
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
410Members 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,
412so it would be invisible. Bots therefore **cannot ride the roster** for
413discovery.
414
415**Resolution: a separate, admin-curated "Helpers" catalog, exempt from the
416connections rule.** People stay connections-only (privacy β€” no seeing
417strangers); bots are intentional, vetted helpers, so the admin's
418members-available set is shown to everyone regardless of connection. Once a
419member adds/DMs a bot, it becomes a normal shared-thread connection and appears
420in their usual list β€” no special-casing after that.
421
422Surface it as a **"Helpers" section** in the picker (distinct from People β€”
423reinforces "not a person"), each entry = name + avatar + **one-line
424description**. The description doubles as the consent hint. **Weight it toward
425groups**: the prominent affordance is "Add a helper" in the group create /
426add-member flow (where the value is); "DM a helper" is the secondary sandbox
427lane. 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
431default-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 β€”
433that 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
435the description as the row's second line. The open decision on name/description
436storage was resolved toward **in clear**, consistent with roster labels today β€”
437revisit both together if labels ever move under the instance key.
438
439## Governance
440
441Mirror the existing `Users can invite members` with **`Users can create bots`** β€”
442but it's not a 1:1 analogy, because a bot carries an API key, compute, and
443read-scope a member invite never does.
444
445The 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
457Controls the invite toggle never needed, because user bots spend shared
458resources: **per-user caps**, rate limits, a **kill switch**, and admin
459visibility into which user bots exist. Plus a per-bot **`available_to_members`**
460flag (operator-only bots β€” e.g. the dev bot below β€” never appear in the catalog).
461
462## Creating bots in plain English
463
464Yes β€” 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
466people to be inclusive"); an LLM **builder** translates it (structured output)
467into the config schema β€” name, system prompt, model, trigger, action, read-scope.
468Plain English is just another way to fill the form; both emit the same config, so
469build the schema + form first and layer the builder on top.
470
471Guardrails 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
4941. **Chat responder** β€” exists. DM/mention reply.
4952. **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.
5003. **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**.
5044. **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
538The third default identity β€” the write-only **Announcer**, which posted release
539notes into every group it was added to β€” is retired. The app now announces its
540own updates with **no bot in the loop**, so it works on instances that run no
541sidecar at all: on startup the server diffs its embedded changelog against a
542watermark 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
544read-only chat view). Members no longer see release notes in their groups β€”
545updates are the admins' concern.
546
547Existing deployments: the new `lchat bot run` skips a session named
548`Announcer` (running it under the default recipe would turn the old write-only
549bot into a responder), so a pre-retirement EnvironmentFile keeps working.
550Remove the Announcer member from the roster (admin console β†’ members) whenever
551convenient; its session/state files on the sidecar machine can be deleted. An
552operator who *wants* group-posted release notes can still run a custom bot
553with a `changelog` recipe rule (docs/bot-recipes.md).
554