# 🤖 Bots (agents) — design

Status: **the sidecar exists and runs.** The default is the **Go** sidecar
(`lchat bot run`, part of the single binary — see *Direction: a Go sidecar*
below, now shipped); the **Node** sidecar (`agent/`) is kept as the runnable
reference. Beyond the "Direction" and "What exists today" sections, the rest is
proposed direction captured from design discussion, not built. This doc is the
single place for where we landed.

## What a bot is

A bot is a **normal member driven by code** — nothing more privileged at the
protocol level. It has:

- an **identity**: an ECDH keypair, a device token, a `users` row with
  `is_agent = 1`, and a roster label + (optional) avatar. One provision = one
  identity.
- a **personality/behaviour**: a system prompt, a model, and how it's triggered.
  This is *config*, not code.

Two layers, and the split is the whole governance story:

| Layer | What it is | Who owns it |
|---|---|---|
| **Engine** | the running process, the API key, the host, and *which threads the bot is in* | **admin/operator** — this is where all trust concentrates |
| **Behaviour** | system prompt + trigger→action recipes | **admin, or users** (within bounds) |

## The one rule still holds: the server stays blind

A bot is a client, exactly like a browser. It holds its own private key and the
thread keys wrapped to it; it decrypts and calls the model **off the lchat
server**. The server only ever sees ciphertext.

The deliberate, disclosed trade-off: to generate a reply the bot sends decrypted
messages to the **model provider (Anthropic)**. That's leakage to the provider,
not between users, and only a local model would close it. Say so in the UI where a
bot is added.

Two consequences to design around:

- **The bot's host is a high-value target.** A bot is deliberately a member of
  many private threads, so its host transiently holds all their plaintext + keys.
  Compromise of the host = read every thread that host's bots are in. Mitigate:
  one sidecar per instance, minimise threads per bot, harden the host, keep no
  ambient prod credentials on it — and above all, **run the sidecar somewhere
  better defended than the chat server** (next section).
- **No forward secrecy on removal** — removing a bot doesn't retroactively
  protect past messages, same as removing a human today. Future hardening that
  would bound both consequences: **rotate the thread key when a bot joins or
  leaves**, so a stolen bot key decrypts only the bot's membership window, not
  the thread's whole history.

## The sidecar runs anywhere: it's a pure outbound client

The sidecar never accepts a connection. It *dials out* to exactly two places —
the instance (HTTPS/WSS) and the Anthropic API — and nothing ever dials it: no
inbound ports, no webhooks, no route. The lchat server has zero knowledge of
where it runs (a bot is just a member on normal endpoints). That makes the bot
host a free variable in the threat model:

- **Same box as the instance** (the default today, as separate systemd units):
  convenient, but it puts the bot's keys on the most exposed machine in the
  system. Root on the chat server reads every bot thread.
- **Any other machine the operator trusts**: a home server behind NAT, a
  workstation, a Raspberry Pi. Root on the chat server then yields ciphertext
  and nothing else — the full "server compromise gets nothing" guarantee is
  restored, because the bot's keys sit on a box the attacker can't route to.
- **A private network with no inbound at all**: NAT-only, egress allowlisted to
  the instance + `api.anthropic.com`. The bot machine's attack surface shrinks
  to supply chain and the message content it processes. For a no-public-egress
  deployment, Claude is also reachable via AWS Bedrock / GCP Vertex over
  private endpoints — and a write-only changelog watcher needs no model API at
  all.

The honest framing (for consent copy too): a bot is **a member whose "device"
is a server**, and adding one to a thread extends that thread's E2E envelope to
the machine the operator chose. Where that machine lives is the single biggest
security decision the operator makes about bots.

One wrinkle of being a persistent outbound client: on a **hibernating**
instance (the activator, `activator.go`), an always-on sidecar would either
hold the instance awake forever or wake it right back on every reconnect. So
there the sidecar is **co-spawned** — started by the activator when its
instance wakes, stopped first when it sleeps — and every request the Go
sidecar makes carries a marker header the router excludes from the instance's
idle clock (`internal/bot/sidecarmark.go`). None of this changes the trust
model: the sidecar is still its own process with its own keys, still outbound-
only. See `docs/instance-router.md § Sidecars on hibernating instances`.

## Direction: a Go sidecar (decided 2026-07-06, shipped 2026-07-07)

The sidecar was ported to **Go** — a subcommand of the single binary (`lchat
bot run`), run as its **own process** with its own env (never inside the server
process: the bot holds private keys + the Anthropic key, and folding it into
the server would collapse the trust boundary above). It is now the **default**;
the full status is below.

**The Node sidecar (`agent/`) is kept in-repo on purpose** — a supported,
runnable **reference implementation**, not deprecated and not scheduled for
removal. It's the worked example of how to run a bot in Node if you'd rather,
and (because it imports `web/static/crypto.js` verbatim) the clearest
illustration of the exact E2EE wire format a client speaks. Keep it working
alongside the Go port; the Go tests pin themselves to these JS sources
(`bot-commands.js`, `actions.mjs`, `eleven.mjs`, the shared `knowledge.md`), so
the reference actively guards the port against drift.

Why:

- **Kills the Node runtime dependency** — today the router box needs Node 24 +
  `npm ci` + a separate rsync deploy path solely for bots.
- **Makes "run it anywhere" real**: copying one static binary to a Raspberry Pi
  or a locked-down VPC box is a far better story than standing up Node there.
- Nothing in the sidecar is Node-specific: `ws` → `coder/websocket` (already a
  dependency), `@anthropic-ai/sdk` → the official Go SDK (which also speaks
  Bedrock/Vertex), and the crypto is ECDH P-256 + HKDF + AES-GCM, all stdlib.

The one real cost: Go must reimplement `crypto.js`'s wire format
(ECIES seal/wrap, `iv||ciphertext` base64url) byte-for-byte and stay in
lockstep — the Node sidecar gets that for free by importing the file. The
native-app work is already committed to publishing **cross-language crypto test
vectors** for its Swift port; the Go sidecar becomes the second consumer of the
same fixture set. Incremental path: port the **Announcer** first (write-only,
no model API — exercises provision/connect/encrypt with the least surface),
then the shared runtime, then Eleven's persona layer.

**Status: the crypto port is landed.** `internal/e2ee` mirrors `crypto.js`
(AES-GCM blobs, ECIES wrap/seal/open, invite derivation, WebCrypto-compatible
raw/pkcs8 keypairs), pinned by `internal/e2ee/testdata/vectors.json` — fixtures
generated from `crypto.js` itself by `test/gen-e2ee-vectors.mjs` (regenerate
them after any deliberate `crypto.js` change and re-run `go test
./internal/e2ee`). **Provisioning is landed too**: `lchat bot provision`
(`internal/bot` + `botcmd.go`) claims an agent invite headlessly — the invite
secret comes from `LCHAT_INVITE` in the env, never argv — and writes a session
file byte-compatible with the Node sidecar's `session.json`, so either
implementation can take over the other's identity in place. **And the
changelog engine** (which powered the Announcer until its retirement — see
below): it baselines silently on first run, batches new changelog entries into
one encrypted post per thread, and self-declares kind `bot`; today it runs only
under an explicit recipe's `changelog` rule (docs/bot-recipes.md). **And the live
runtime**: `lchat bot run` (`internal/bot/runtime.go`) is the persona-agnostic
core of `runtime.mjs` in Go — WebSocket with reconnect/backoff/keepalive,
thread-key unwrap, cursor-gated idempotent delivery, catch-up after a blip,
never-reply-to-agents, DM-always / group-@mention triggers — with the brain
behind a single `Handler` seam. Today that seam holds the **deterministic
command floor** (`internal/bot/commands.go`, the Go twin of `commands.mjs`;
drift-pinned to `web/static/bot-commands.js` by a test), so a keyless Go bot
answers exactly like a keyless Node one. One process hosts many identities
(one session file per bot on the command line), and one deliberate divergence:
the Go runtime answers the *first* message of a newly-seen thread (Node
baselines the cursor past it and stays silent). **And recipes**: the runtime
is recipe-shaped (`internal/bot/recipe.go`, schema + rationale in
`docs/bot-recipes.md`) — behaviour as an ordered trigger→action rule list from
a bounded palette (message × trigger/any/keyword/regex → commands/reply/model;
changelog → announce), validated fail-closed, one `<session>.recipe.json` per
identity, watcher rules loudly flagged. **And the model layer**: `brain.go` is
the Claude-backed reply path (`internal/bot/anthropic.go`, a minimal net/http
Messages client — no new dependency) reached via the recipe `model` action.
Set `ANTHROPIC_API_KEY` and the bot self-declares kind `ai` and upgrades from
the command floor to conversational replies — same layered system prompt
(non-overridable safety preamble → console persona → fellow-helpers line →
docs), cache-marked system block, adaptive thinking, the chip tools
(drift-pinned to `actions.mjs`), the promised-button forced-retry self-heal,
and the listen-mode "name" classifier gate — all as in `runtime.mjs`. **And
connected-services** (`internal/bot/connect.go` + `dnsimple.go`): the capability
ops `handleConnect` / `handleOAuthExchange` are ported — a member seals a
time-boxed grant to the bot's key, the bot opens it with its own private key
(a grant sealed to a different bot won't open, so it stays silent), lists
DNSimple domains or runs the confidential OAuth exchange via the shared broker,
and seals the result back to the member. **The Go sidecar is now a full drop-in
for the Node one**: everything the shipped bots do — the deterministic floor,
conversational Eleven/Claude, connected-services, and the changelog engine —
runs from the single `lchat` binary with no Node. The one thing deliberately
*not* in the sidecar is Eleven's onboarding welcomer, which is moving
client-side as data (`docs/conversation-scripts.md`). `agent/` stays as the
reference implementation; session/state/recipe files are Node-compatible so
either can take over the other's identity in place.

## What exists today (`agent/`)

A headless Node sidecar that behaves like a member's browser:

- imports `web/static/crypto.js` **verbatim** (runs unmodified in Node), so its
  wire format is guaranteed to match the app.
- `provision.mjs` — one-time headless `join.js`: derive invite id from the
  `#fragment`, generate a keypair, claim a device token, register the pubkey →
  `session.json` (the bot's identity + private key; lives only on the host).
- `agent.mjs` — connect `/ws`, unwrap thread keys, on each incoming `msg`
  decrypt, build context from the thread's recent history, call Claude
  (`claude-opus-4-8`, adaptive thinking), post an encrypted reply. **DM → replies
  to everything; group → only on @mention by default.** A per-bot **listen mode**
  (console `bot_configs.listen`) can widen that to `name`, where a bare-name
  mention also replies — but only if a cheap Haiku yes/no call judges the bot is
  actually being addressed (talking *about* a bot doesn't summon it). Reconnects
  with backoff; picks up new threads on first unknown `thread_id`.
- Server + web: additive `users.is_agent` column, roster `BOT` tag, and a
  dedicated admin **"Add an agent"** section (distinct from inviting a person)
  that captures **model + system prompt + reference docs** and stores them in a
  `bot_configs` table. The sidecar pulls its own config from `/api/bot-config`
  and applies it — a **non-overridable safety/app-awareness preamble** is always
  prepended, then the console-set persona, then the docs.
- Still design, not built: **editing** an existing bot's config from the console;
  wrapping the config to the sidecar's key so the server can't read prompts/docs;
  and the ambient **watcher** read scope. The form now captures persona/model/
  docs, the catalog fields (description + availability), and the **listen mode**
  (@mention-only, the default, vs. bare-name).

Limits today: reactive only (no scheduler, can't self-initiate — so it **cannot
remind you**, and may falsely promise to); short-term memory only (re-reads the
last ~30 messages of the thread per reply — nothing persisted); no tools. One
behaviour is still interim vs. the target below: in a group it reads recent
history to compose a reply (target: **mention-scoped**).

## One sidecar per instance, all bots in it

Each instance runs **exactly one sidecar** — a separate headless process,
never part of the server process. That single sidecar drives **all of the
instance's bots** (default + custom): each bot is its own member identity with
its own WebSocket connection, but one process, one host, one API key.

- **Env:** `LCHAT_BASE` + `ANTHROPIC_API_KEY` — the sidecar's own deploy env,
  the same channel as the app's operator secrets. The lchat server never sees
  the key. Per-bot config comes from the admin console (next section).
- A small data dir holds the sidecar's **keystore** — one keypair per bot — so
  a redeploy doesn't re-provision. Private keys never leave the host.
- The sidecar **auto-provisions** each configured-but-new bot: generate a
  keypair, register its pubkey, mark it `is_agent`, store the private key locally.

### Deploy

On the fleet, `lchat add-bot <host>` provisions the identities and the
`lchat-bot@<host>` systemd unit runs `lchat bot run` (the Go sidecar inside
the instance's own versioned binary) with env in `/data/<host>-bot.env` and
sessions in `/data/<host>-bot/`. The Node reference sidecar (`agent/`) runs
the same way anywhere Node does: point `LCHAT_BASE` at the instance, feed it
the agent invites, run `node agent/agent.mjs`. (The original deployment shape
— a per-instance container app on the Miren platform, `Dockerfile.agent` —
was retired 2026-07-15 along with that platform; the sidecar has no inbound
ports, so any box that can dial out can host it.)

## Configuration & control

Split config by what it *is*:

**The Anthropic API key → the sidecar's env** (`ANTHROPIC_API_KEY`, in
`/data/<host>-bot.env` on the fleet), the same channel as the app's operator
secrets (`LCHAT_ADMIN_TOKEN`, `LCHAT_DB`). Whoever deploys the sidecar is the
operator; they set it at deploy and rotate it by updating env. Crucially the
**lchat server never touches the key in any form** — no store-key endpoint, no
encryption dance.
That's simpler than routing it through the console *and* strictly better for
"server blind": the server has zero involvement with the key, rather than holding
a wrapped blob of it. (An earlier draft put the key in the console encrypted to
the sidecar — that over-solved a problem you avoid entirely by not routing the key
through the app.)

**Bot config → the admin console** (name, avatar, model, system prompt, reference
docs, read-scope, availability) — the thing an admin actually wants a UI for.
Stored server-side, pulled by the sidecar. If you want prompts/docs hidden from
the server too, wrap *those* to the sidecar's key (the key itself is already out
of the picture, in env). For a first version, bot config can even be a
sidecar-side file and skip the console.

**Once the key is set, a few default bots exist** — e.g. a friendly general helper
and a thread summariser (kid-safe, conservative). They're examples: fully editable
or removable.

**Adding / editing a bot** is a console form:

| Field | Notes |
|---|---|
| Name + avatar | shown in the Helpers catalog with the `BOT` badge |
| Model | Opus / Sonnet / Haiku per bot |
| System prompt | the personality — layered on a **non-overridable kid-safety preamble** |
| Reference docs | context the bot should always have (instructions, house rules, an FAQ); included in its context, with retrieval for large docs later |
| Read scope / trigger | mention-only (default) vs. ambient watcher — see next section |
| Available to members | whether it appears in the Helpers catalog (operator-only bots stay hidden) |

Catalog metadata that member browsers must render (name, avatar, description,
availability) is readable by members — stored like roster labels, or wrapped to
members via the instance key if labels are kept from the server.

### Is the sidecar active and working?

The instance needs **no** knowledge of the sidecar to *function* — a bot is just
a member on normal endpoints, which is what keeps the server dumb and the sidecar
swappable. So skip a static `SIDECAR_ACTIVE` env flag: it encodes intent, not
reality (it reads `true` while the sidecar is crashed — the exact false
reassurance to avoid). Derive status from live facts the server already has — it
sees whether each bot member is connected, and it holds the bot *config* for the
catalog, so "configured vs. actually connected" needs no new state:

- **Connected / last-seen per bot**, read from the hub (it already tracks live
  clients by user id). A small `GET /api/admin/bots` powers a "Bots" panel.
- **Heartbeat** for liveness beyond a bare socket (reuse the existing `active`
  beat or a periodic ping) → "last heard 20s ago", so a wedged-but-connected
  sidecar is distinguishable from a healthy one.
- **A "Test bot" button** (like the existing test-push): DM the bot a ping and
  confirm a reply. This is the real end-to-end check — and it validates the key,
  since a bot that's connected but can't reply almost always means a missing or
  invalid `ANTHROPIC_API_KEY`. The sidecar can report a status string
  (`model ok` / `auth error`) to make the cause explicit.

**Whether to offer "Add a bot" at all** can't come from bot config alone — before
the first bot there is none. In practice it comes for free from the **default bots
the sidecar seeds on first boot**: their appearance (agent users the server didn't
create) *is* the "a sidecar lives here" signal. Two conditions keep that honest:

- The **sidecar** must seed the defaults, not the server — otherwise it's circular.
- Record the capability **sticky** the first time an agent user appears, so the
  offer survives the admin later deleting every bot (don't gate purely on "are
  there bots right now").

A lightweight boot heartbeat is then only belt-and-braces — for the
sidecar-present-but-zero-bots edge, and for live **health**. So a manual
`SIDECAR_ACTIVE` env boolean stays the wrong tool: a signal derived from the
sidecar actually being there can't drift the way a hand-set flag can (set `true`,
deploy fails, UI offers bots that can't run). Net: **capability** is sticky (a
sidecar has registered → show the UI); **health** is live (connected + recent
heartbeat → green/red). Both drive admin UX only — bots still *function* with zero
server awareness.

## How much a bot reads in a group

> **Decision (2026-07-02): recent-history context stays.** In practice the
> context is what makes replies good — a bot answering "@Claude is Bob right?"
> without the surrounding conversation is useless — so a mention-TRIGGERED bot
> reading recent thread history (what's built: ~30 recent messages at reply
> time) is the accepted behaviour, and the mention-only ingestion described
> below is NOT planned. Be honest about it in consent copy: adding a bot to a
> group means it reads recent messages whenever it's called on. Watchers
> (below) remain the distinct, heavier-consent mode: they process EVERY
> message, not just when summoned. The original design rationale is kept for
> the record:

Original design default (superseded): **a group bot does NOT read the whole
conversation.** It ingests only
messages **addressed to it** — those that @mention it — plus, when a mention is a
**reply**, the single message being replied to (so "@Claude is Bob right?" gives
it Bob's message to answer). Its context in a group is just its own
sub-conversation: mentions of it, the parents those replies point to, and its own
past replies — never the general chatter.

Why this is the default:
- **Privacy + consent.** A mention-only bot is far less invasive than one reading
  everything, so adding it to a group is a light decision — and it minimises what
  leaves the E2E envelope to reach the model provider.
- **Safe-for-kids.** Give the bot the least it needs to do its job.

Honest limit: the bot is a thread member and holds the thread key, so "only reads
mentions" is a **policy in the sidecar, not a cryptographic guarantee** — it
*could* decrypt the rest; it chooses not to, and the code is small and auditable.
A cryptographic "cannot read the rest" would mean not giving the bot the thread
key and instead re-encrypting each mention (and its replied-to parent) to the
bot — stronger, but it complicates how the bot posts a group-readable reply.
Flagged as possible future hardening, not v1.

**Ambient / watcher bots are the explicit exception.** Summaries ("what did we
decide?") and keyword nudges (the inclusivity bot) genuinely need every message.
That's a distinct, clearly-labelled mode: a **"reads every message in this
group"** flag, heavier consent, and a visible "a helper is watching for X" notice.
So the consent gate now means something — most bots are mention-only and cheap to
add; only the few that must read everything carry the loud flag.

A watcher's detection is **JS in the sidecar, not a Claude tool** — a trigger that
gates whether a message reaches the model. So there are two layers of exposure,
which is the honest reading of "reads every message":

- The **sidecar** decrypts and matches every message (`/\bguys?\b/i`) — this is
  the host-side exposure the consent flag is about, policy-enforced not
  cryptographic.
- The **model provider** sees only *matches* — non-matching chatter never leaves
  the sidecar, so provider cost/exposure stay scoped to hits.

The **action** on a match is a choice: a **fixed template** (post/DM a pre-written
nudge — no model call, deterministic, and often best for a nudge) or an **LLM
call** (phrase it in context). Trigger = JS; action = template or LLM. This is the
trigger→action recipe, and it's separate from Claude tool-use (the dev-bot lane).

> Recipes have a **sibling schema**: `docs/conversation-scripts.md` — Eleven's
> deterministic onboarding flows as data, interpreted client-side (needed for
> the native apps). Same chip/bubble message shapes, deliberately *not* one
> unified behaviour language. When the Go sidecar runtime is built
> (§ Direction below), it should be recipe-shaped from day one — an engine
> executing config — so declarative bots don't require a second rewrite.

## Where bots earn their keep: groups, not DMs

The moat is **shared context**, which a standalone Claude/ChatGPT structurally
can't have. A DM bot is a worse-UX version of tools people already have; a bot
*in a group* can see and act on the conversation this group is having (summarise
a thread, answer "what did we decide?", watch for a keyword). Invest here.

The exception that keeps DMs worthwhile: for **kids**, an in-app DM companion's
value is precisely that the alternative — unsupervised ChatGPT — is what you're
preventing. Treat DMs as the safe sandbox + proactive/reminder lane, not the
differentiator.

Implications:

- Build **group-native, ambient** behaviours (summarise / answer-from-thread /
  watch-and-nudge), not a better DM chat loop.
- **Group-scoped memory is both the most valuable and the safest kind.** The
  cross-user-leak risk of bot memory *disappears* when memory is scoped to a
  group, because everyone who'd read it already shares the thread. Memory scope
  follows read scope: a mention-only bot remembers its own exchanges; an ambient
  watcher can remember the whole thread. Build memory for groups first.

## Discovery under the connections-only roster

Members now see `connections(userID)` = self + people they share a thread with —
**not** the full directory (`db.go` `connections()`). A new bot shares no thread,
so it would be invisible. Bots therefore **cannot ride the roster** for
discovery.

**Resolution: a separate, admin-curated "Helpers" catalog, exempt from the
connections rule.** People stay connections-only (privacy — no seeing
strangers); bots are intentional, vetted helpers, so the admin's
members-available set is shown to everyone regardless of connection. Once a
member adds/DMs a bot, it becomes a normal shared-thread connection and appears
in their usual list — no special-casing after that.

Surface it as a **"Helpers" section** in the picker (distinct from People —
reinforces "not a person"), each entry = name + avatar + **one-line
description**. The description doubles as the consent hint. **Weight it toward
groups**: the prominent affordance is "Add a helper" in the group create /
add-member flow (where the value is); "DM a helper" is the secondary sandbox
lane. Show only bots the admin marked available (see governance).

**Built (helpers-catalog):** `bot_configs` gained `description` +
`available_to_members` (set from the admin's "Add an agent" form, availability
default-on there, off in the schema). `connections()` unions in agents matching
`is_agent = 1 AND available_to_members = 1` that have registered a public key —
that one exemption *is* the catalog; the client just renders roster entries with
`is_agent` under a "Helpers" heading (new-chat and add-to-group pickers), with
the description as the row's second line. The open decision on name/description
storage was resolved toward **in clear**, consistent with roster labels today —
revisit both together if labels ever move under the instance key.

## Governance

Mirror the existing `Users can invite members` with **`Users can create bots`** —
but it's not a 1:1 analogy, because a bot carries an API key, compute, and
read-scope a member invite never does.

The setting governs **behaviour on the admin's engine**, tiered by read-scope:

- **Default off.** On kids-facing instances, keep it off (loosen only as opt-in).
- **ON → members may create *personal* bots freely** (a bot in their own DM):
  low risk, only their own data.
- **Group-watching bots always need group/admin consent — even when the toggle
  is on.** Reading a shared room is a shared decision; one member can't silently
  bug it. This gate is separate from the toggle and never removed by it.
- **Capabilities come from an admin-approved recipe palette** (reply / nudge /
  remind / summarise / keyword-watch) — declarative trigger→action, never
  arbitrary tools or the build/deploy lane.

Controls the invite toggle never needed, because user bots spend shared
resources: **per-user caps**, rate limits, a **kill switch**, and admin
visibility into which user bots exist. Plus a per-bot **`available_to_members`**
flag (operator-only bots — e.g. the dev bot below — never appear in the catalog).

## Creating bots in plain English

Yes — but as a **front-end to the same bounded recipe**, not a code generator. A
"Describe a bot" box takes natural language ("watch for 'guys' and gently remind
people to be inclusive"); an LLM **builder** translates it (structured output)
into the config schema — name, system prompt, model, trigger, action, read-scope.
Plain English is just another way to fill the form; both emit the same config, so
build the schema + form first and layer the builder on top.

Guardrails that keep it inside the boundary:

- **Translate into the supported palette only** — allowed triggers
  (keyword/regex/mention/schedule) and actions (reply / DM-nudge / summarise /
  template-or-LLM). An out-of-palette request ("delete messages", "email people",
  "deploy code") gets "I can't do that yet", never invented. The plain-English
  layer is UX, never a capability escalation.
- **Review-and-confirm before it goes live.** Show the derived config back in
  plain terms — *"This bot will read every message in the group and privately DM
  anyone who says 'guys' a reminder."* This is both the did-I-get-it-right check
  and the **consent moment**: the builder infers read-scope from the request
  (watcher → reads everything; Q&A-on-mention → mention-only) and must surface it,
  since the user didn't ask for it explicitly.
- **Same governance + consent tiers.** A personal DM bot is light; a described
  group watcher still hits the group/admin consent gate (driven by the derived
  read-scope). The **kid-safety preamble is layered on regardless** of the user's
  words, and the builder declines malicious intents ("watch for X and mock them").
- A small one-off **LLM call on the instance key**, covered by the per-user caps.
  Editing works the same way ("also watch for 'dude'", "make it friendlier") →
  diff → confirm.

## Capability tiers (roadmap)

1. **Chat responder** — exists. DM/mention reply.
2. **Watcher recipes** — group-native, the differentiated layer. A **trigger**
   (keyword/regex/schedule/mention) + an **action** (reply / private-DM nudge /
   summarise). The inclusivity-nudge bot ("watch for `guy`, gently DM the
   sender") is the template — detection is cheap string-matching; the model only
   phrases the nudge. Reads the whole group → consent + transparency required.
3. **Memory & reminders** — a `set_reminder` tool + a **per-thread/per-group,
   host-side, encrypted** store + a scheduler that posts proactively (rides the
   app's existing web push for delivery). Scope memory per-thread/group, **never
   global-per-bot**.
4. **Operator-only dev bot** — tools (bash/git/gh/miren) to build features.
   Technically just an agent tool-use loop, but it's the highest-risk thing here:
   chat input is untrusted, and a bot with GitHub push + deploy rights holds the
   exact credentials that protect everyone's E2EE. Rules: **separate
   operator-only identity, never in a family/kids thread; bots draft, humans ship
   (PR, not direct deploy); least-privilege scoped creds; sandboxed worktree;
   authenticate the requester.** Managed Agents is the purpose-built substrate
   (sandbox + `always_ask` gates + git proxy). Never user-grantable.

## Consent, transparency, safety invariants

- **Group default is mention-only** (bot reads only messages @-ing it + a
  mention's replied-to parent). Reading the whole group is the loud, opt-in
  watcher exception.
- Adding a group-watcher **is** the consent moment — surface what it does and
  that it reads every message.
- Bots always wear the `BOT` badge; consider a "a helper is watching for X"
  notice in a watched group.
- **Cross-user isolation invariant:** one model request = one thread's decrypted
  history, nothing else. The shared API key is *not* a leak vector (stateless,
  per-request). Never give a bot shared, cross-thread memory. Prompt caching is
  safe (prefix match returns your own tokens).

## Open questions

- Catalog metadata: clear (like roster labels today) vs encrypted to members.
- Miren routeless worker vs health endpoint.
- Reminder scheduler: in-sidecar timer vs the platform cron.
- Kids-instance defaults: personal bots off entirely, or on with a curated
  recipe palette only.
- Per-bot model policy (cost): who may pick Opus vs Sonnet vs Haiku.

## The Announcer is retired

The third default identity — the write-only **Announcer**, which posted release
notes into every group it was added to — is retired. The app now announces its
own updates with **no bot in the loop**, so it works on instances that run no
sidecar at all: on startup the server diffs its embedded changelog against a
watermark and notifies the space's **admins** (a Web Push / APNs banner,
`announce.go`), and the notes live in the **Admin app's update stream** (a
read-only chat view). Members no longer see release notes in their groups —
updates are the admins' concern.

Existing deployments: the new `lchat bot run` skips a session named
`Announcer` (running it under the default recipe would turn the old write-only
bot into a responder), so a pre-retirement EnvironmentFile keeps working.
Remove the Announcer member from the roster (admin console → members) whenever
convenient; its session/state files on the sidecar machine can be deleted. An
operator who *wants* group-posted release notes can still run a custom bot
with a `changelog` recipe rule (docs/bot-recipes.md).
