eleven / messenger Public

Bot agents: E2EE sidecar, is_agent badge, Miren deploy, and design doc

Adds AI bots as chat participants without weakening the server-is-blind property. Built and verified end-to-end on a dedicated throwaway instance.

- **agent/** sidecar: a headless E2EE chat responder that reuses `web/static/crypto.js` verbatim; app-aware system prompt; keepalive + backfill-on-reconnect; self-provisions on boot.
- **Miren deploy:** `Dockerfile.agent`, `deploy-agent.sh`, `.miren/agent.app.toml` — one worker app per instance, API key in its own env (the lchat server never sees it).
- **server+web:** additive `is_agent` column, `agent:true` invites, roster `BOT` badge, and a dedicated admin "Add an agent" section.
- **docs/bots.md:** the full design (personas, read-scope, governance, plain-English creation) plus the deploy gotchas.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell eeffd1ccf36617a047108dcbd37bf4c168e790fa parent f83ff5b
15 files changed, +1054 −3
  • .miren/agent.app.toml +33 −0
  • Dockerfile.agent +20 −0
  • agent/.gitignore +2 −0
  • agent/README.md +55 −0
  • agent/agent.mjs +247 −0
  • agent/config.mjs +35 −0
  • agent/package-lock.json +111 −0
  • agent/package.json +16 −0
  • agent/provision.mjs +59 −0
  • db.go +15 −2
  • deploy-agent.sh +37 −0
  • docs/bots.md +390 −0
  • main.go +7 −0
  • web/pages/admin.html +9 −0
  • web/static/admin.js +18 −1
diff --git a/.miren/agent.app.toml b/.miren/agent.app.toml
new file mode 100644
index 0000000..c92a856
--- /dev/null
+++ b/.miren/agent.app.toml
@@ -0,0 +1,33 @@
+# Miren config for a bot sidecar app (one per lchat instance). Templated by
+# deploy-agent.sh (__APP__ -> "<instance>-bot"). This is a worker, not a public
+# site: it has a service+port only so Miren can health-check it (see the health
+# endpoint in agent.mjs); no route is set, so it has no public hostname.
+name = "__APP__"
+
+[build]
+dockerfile = "Dockerfile.agent"
+
+[services.web]
+port = 8080
+
+# One instance, and a persistent disk (fixed concurrency is required for a disk).
+[services.web.concurrency]
+mode = "fixed"
+num_instances = 1
+
+# Persists the agent's session (device token + private key) across redeploys, so
+# it doesn't re-provision. Private keys never leave this disk.
+[[services.web.disks]]
+name = "__APP__-data"
+mount_path = "/data"
+size_gb = 1
+
+[[env]]
+key = "LCHAT_SESSION"
+value = "/data/session.json"
+
+# Set at deploy time via `-e` (see deploy-agent.sh), NOT declared here:
+# LCHAT_BASE the instance to connect to, e.g. https://test-bots.luan.chat
+# ANTHROPIC_API_KEY billing credential — lives only in this app's env
+# LCHAT_INVITE one-time invite #fragment; used only on first boot
+# PORT container port for the health check (defaults to 8080)
diff --git a/Dockerfile.agent b/Dockerfile.agent
new file mode 100644
index 0000000..6647aeb
--- /dev/null
+++ b/Dockerfile.agent
@@ -0,0 +1,20 @@
+# The bot sidecar (a separate Miren app from the lchat server). Node runtime;
+# imports web/static/crypto.js verbatim, so both live under /app with their
+# relative layout preserved (agent.mjs -> ../web/static/crypto.js).
+FROM node:22-alpine
+WORKDIR /app
+
+# Install deps first for layer caching.
+COPY agent/package.json agent/package-lock.json ./agent/
+RUN cd agent && npm ci --omit=dev
+
+# App source + the shared crypto module (kept at ../web/static/crypto.js).
+COPY agent/ ./agent/
+COPY web/static/crypto.js ./web/static/crypto.js
+
+WORKDIR /app/agent
+EXPOSE 8080
+# Absolute path: the platform may launch the container with cwd=/app, not this
+# WORKDIR. Module resolution (node_modules, ../web/static/crypto.js) is relative
+# to the file, not cwd, so it works regardless.
+CMD ["node", "/app/agent/agent.mjs"]
diff --git a/agent/.gitignore b/agent/.gitignore
new file mode 100644
index 0000000..9625664
--- /dev/null
+++ b/agent/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+session.json
diff --git a/agent/README.md b/agent/README.md
new file mode 100644
index 0000000..272fe2a
--- /dev/null
+++ b/agent/README.md
@@ -0,0 +1,55 @@
+# lchat agent sidecar
+
+A headless lchat member that relays a thread to Claude and posts replies. It
+behaves exactly like a member's browser: it holds its own keypair, is added to
+threads by a human, decrypts messages with the thread key, calls the Claude API,
+and posts an encrypted reply. **The lchat server only ever sees ciphertext** —
+the agent's private key and your `ANTHROPIC_API_KEY` live only on this host.
+
+It runs as a separate process, not part of the Go binary, and shares
+`web/static/crypto.js` verbatim (imported directly), so its wire format is
+guaranteed to match the app.
+
+## What you provide
+
+| | |
+|---|---|
+| `ANTHROPIC_API_KEY` | The only recurring secret + cost. Every DM and every group @mention is one Claude API call billed to your account. |
+| `LCHAT_BASE` | Your instance URL, e.g. `https://test.luan.chat`. |
+| `LCHAT_INVITE` | **One-time.** The `#fragment` of a `/join/…` link minted in the admin console (tick "This is an agent"). Spent after provisioning. |
+
+Optional: `LCHAT_AGENT_NAME` (default `Claude`), `LCHAT_MODEL` (default
+`claude-opus-4-8`; try `claude-sonnet-5` for lower cost), `LCHAT_SYSTEM` (system
+prompt), `LCHAT_SESSION` (session-file path).
+
+## Run
+
+```sh
+cd agent
+npm install
+
+# 1. Provision once (creates session.json — the bot's identity; back it up)
+LCHAT_BASE=https://test.luan.chat LCHAT_INVITE='<#fragment from the invite link>' \
+ npm run provision
+
+# 2. Run
+export ANTHROPIC_API_KEY=sk-ant-...
+LCHAT_BASE=https://test.luan.chat npm start
+```
+
+Then, in the app, add the agent to a group or start a DM with it. It replies to
+every message in a DM, and only when @mentioned by name in a group.
+
+## Notes & limits
+
+- **The Claude API sees plaintext.** The lchat server does not — but the reply is
+ generated by sending decrypted messages to Anthropic. Only a local model would
+ close that gap. Disclose this where you add the bot.
+- **Catches up on reconnect.** On a WebSocket reconnect it backfills messages
+ missed during the gap (per-thread cursor), so a message sent in the ~1s blip
+ still gets a reply. A full process restart resets cursors to each thread's
+ latest, so it won't answer messages sent while the process itself was down (and
+ never replies to old backlog on first start).
+- **No forward secrecy on removal** — same as removing a human member today.
+- Keep one sidecar per instance so a compromised host can read only the threads
+ that bot was added to.
diff --git a/agent/agent.mjs b/agent/agent.mjs
new file mode 100644
index 0000000..ffa24b4
--- /dev/null
+++ b/agent/agent.mjs
@@ -0,0 +1,247 @@
+// The agent runtime. Behaves like a member's browser (chat.js), headless:
+// connect the WebSocket, decrypt incoming messages with the thread key, ask
+// Claude for a reply, encrypt it, and post it back. The lchat server only ever
+// sees ciphertext; plaintext exists only here and at the Claude API.
+import fs from "node:fs";
+import http from "node:http";
+import Anthropic from "@anthropic-ai/sdk";
+import WebSocket from "ws";
+import { importKey, encrypt, decrypt, unwrapThreadKey } from "../web/static/crypto.js";
+import { BASE, WS_BASE, AGENT_NAME, MODEL, requireBase, loadSession } from "./config.mjs";
+import { provision } from "./provision.mjs";
+
+// Synchronous logging: async stdout to a pipe is buffered and lost if the
+// container is killed (e.g. a failed health check), so write key lines with
+// fs.writeSync so they always reach the logs.
+const log = (m) => {
+ try {
+ fs.writeSync(1, `${m}\n`);
+ } catch {}
+};
+process.on("uncaughtException", (e) => {
+ log(`uncaughtException: ${e?.stack || e}`);
+ process.exit(1);
+});
+process.on("unhandledRejection", (e) => {
+ log(`unhandledRejection: ${e?.stack || e}`);
+ process.exit(1);
+});
+log(`boot: ${AGENT_NAME} starting, base=${BASE}`);
+
+requireBase();
+
+// Health endpoint first (bind 0.0.0.0 explicitly for the platform health
+// check), so the container is healthy while it provisions/connects.
+const PORT = Number(process.env.PORT) || 8080;
+http
+ .createServer((_req, res) => {
+ res.writeHead(200, { "content-type": "text/plain" });
+ res.end("ok\n");
+ })
+ .listen(PORT, "0.0.0.0", () => log(`health listening on :${PORT}`));
+
+// On a fresh host (empty disk) with an invite in env, self-provision on boot.
+let session = loadSession();
+if (!session && process.env.LCHAT_INVITE) {
+ log("no session; provisioning from LCHAT_INVITE…");
+ try {
+ session = await provision(process.env.LCHAT_INVITE);
+ log(`provisioned "${session.name}"`);
+ } catch (e) {
+ log(`provision failed: ${e?.message || e}`);
+ process.exit(1);
+ }
+}
+if (!session) {
+ log("no session — set LCHAT_INVITE (or run provision)");
+ process.exit(1);
+}
+
+const claude = new Anthropic(); // reads ANTHROPIC_API_KEY from the environment
+
+const SYSTEM =
+ process.env.LCHAT_SYSTEM ||
+ `You are ${AGENT_NAME}, an AI assistant and a member of lchat — a small, private, ` +
+ `end-to-end encrypted group chat. Every message is encrypted on each person's device; ` +
+ `the server and its operator can't read anything, and that privacy is the whole point of the app. ` +
+ `You are one participant among the people here. You may be in a one-to-one DM or in a group; ` +
+ `in a group you're brought in when someone mentions you by name. ` +
+ `You can only take part in the conversation — you have no way to act outside it (no reminders, ` +
+ `notifications, files, or web access), so never promise to do those things; if asked, say you can't yet. ` +
+ `Keep replies short, warm, and easy to read. This chat may include children, so always be kind and ` +
+ `age-appropriate, and never say anything scary, adult, or unsafe. ` +
+ `Each message is prefixed with the sender's name — reply naturally and don't prefix your own name.`;
+
+const keys = new Map(); // threadId -> { key: CryptoKey, kind: "dm"|"group"|"self" }
+const cursors = new Map(); // threadId -> last message id we've processed
+
+const api = (path) =>
+ fetch(`${BASE}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(session.token)}`).then((r) =>
+ r.json()
+ );
+
+const maxId = (messages) => messages.reduce((m, x) => Math.max(m, x.id || 0), 0);
+
+// Fetch our threads and cache the (unwrapped) key for any we don't hold yet. A
+// newly-seen thread's cursor starts at its latest message, so we never reply to
+// history — only to messages that arrive after we become aware of it.
+async function loadThreads() {
+ const { threads = [] } = await api("/api/threads");
+ for (const t of threads) {
+ if (!t.wrapped || keys.has(t.id)) continue;
+ try {
+ const raw = await unwrapThreadKey(t.wrapped, session.userPriv);
+ keys.set(t.id, { key: await importKey(raw), kind: t.kind });
+ const { messages = [] } = await api(`/api/messages?thread=${encodeURIComponent(t.id)}&limit=1`);
+ cursors.set(t.id, maxId(messages));
+ } catch (e) {
+ log(`load thread ${t.id}: ${e.message}`);
+ }
+ }
+}
+
+// DMs to the bot: always answer. Groups: only when @mentioned by name.
+function wantsReply(kind, text) {
+ if (kind === "dm") return true;
+ return new RegExp(`(^|[^a-z0-9])@?${AGENT_NAME}\\b`, "i").test(text || "");
+}
+
+// Build the Claude message list from recent thread history (already includes the
+// triggering message, since the server stores before it broadcasts).
+async function buildContext(threadId, key) {
+ const { messages = [] } = await api(`/api/messages?thread=${encodeURIComponent(threadId)}&limit=30`);
+ const out = [];
+ for (const m of messages) {
+ let p;
+ try {
+ p = await decrypt(key, m.blob);
+ } catch {
+ continue; // e.g. a message from before we held the key
+ }
+ if (!p.text) continue;
+ const mine = p.name === session.name;
+ out.push({ role: mine ? "assistant" : "user", content: mine ? p.text : `${p.name}: ${p.text}` });
+ }
+ // The API requires the first turn to be `user`.
+ while (out.length && out[0].role === "assistant") out.shift();
+ return out;
+}
+
+async function reply(threadId, entry) {
+ sendTyping(threadId, entry.key); // best-effort "…is typing" hint
+ const context = await buildContext(threadId, entry.key);
+ let text;
+ try {
+ const r = await claude.messages.create({
+ model: MODEL,
+ max_tokens: 1024,
+ system: SYSTEM,
+ thinking: { type: "adaptive" },
+ messages: context.length ? context : [{ role: "user", content: "Say hello." }],
+ });
+ text = r.content
+ .filter((b) => b.type === "text")
+ .map((b) => b.text)
+ .join("")
+ .trim();
+ } catch (e) {
+ console.error("Claude error:", e.message);
+ return;
+ }
+ if (!text) return;
+ send({ type: "msg", thread_id: threadId, blob: await encrypt(entry.key, { name: session.name, text, ts: Date.now() }) });
+}
+
+async function sendTyping(threadId, key) {
+ try {
+ // Same shape chat.js emits (see hub.go's "typing" relay).
+ send({ type: "typing", thread_id: threadId, blob: await encrypt(key, { type: "typing", name: session.name, expiry: 15 }) });
+ } catch {}
+}
+
+// ---- WebSocket with reconnect + a small outbound queue -------------------
+let ws;
+let backoff = 1000;
+let ping; // keepalive timer (the server closes idle sockets ~60s; it ignores "ping")
+const queue = [];
+
+function send(obj) {
+ const data = JSON.stringify(obj);
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
+ else queue.push(data);
+}
+
+function connect() {
+ // `ws` sends no Origin header, so it passes the server's same-origin check.
+ ws = new WebSocket(`${WS_BASE}/ws?token=${encodeURIComponent(session.token)}`);
+ ws.on("open", async () => {
+ backoff = 1000;
+ log(`connected to ${BASE}`);
+ clearInterval(ping);
+ ping = setInterval(() => {
+ try {
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
+ } catch {}
+ }, 30000);
+ await loadThreads();
+ await catchUp();
+ for (const d of queue.splice(0)) ws.send(d);
+ });
+ ws.on("message", (data) => onFrame(data).catch((e) => log(`frame: ${e.message}`)));
+ ws.on("error", (e) => log(`WS error: ${e.message}`));
+ ws.on("close", () => {
+ clearInterval(ping);
+ log(`disconnected; reconnecting in ${backoff}ms`);
+ setTimeout(connect, backoff);
+ backoff = Math.min(backoff * 2, 30000);
+ });
+}
+
+// Decrypt one message, advance the thread cursor, and reply if it's for us.
+async function processMsg(threadId, id, blob) {
+ const entry = keys.get(threadId);
+ if (!entry) return;
+ cursors.set(threadId, Math.max(cursors.get(threadId) || 0, id || 0));
+ let p;
+ try {
+ p = await decrypt(entry.key, blob);
+ } catch {
+ return;
+ }
+ if (p.name === session.name) return; // our own message, echoed back
+ if (!wantsReply(entry.kind, p.text)) return;
+ await reply(threadId, entry);
+}
+
+async function onFrame(data) {
+ let f;
+ try {
+ f = JSON.parse(data);
+ } catch {
+ return;
+ }
+ if (f.type !== "msg" || !f.thread_id) return; // ignore reactions/edits/typing/etc.
+ if (!keys.has(f.thread_id)) await loadThreads(); // a thread we were just added to
+ await processMsg(f.thread_id, f.id, f.blob);
+}
+
+// On (re)connect, catch up on anything that arrived while disconnected, so a
+// message sent during a reconnect blip still gets a reply. Cursors start at each
+// thread's latest on first sight, so the initial connect backfills nothing.
+async function catchUp() {
+ for (const threadId of keys.keys()) {
+ const after = cursors.get(threadId) || 0;
+ try {
+ const { messages = [] } = await api(`/api/messages?thread=${encodeURIComponent(threadId)}&after=${after}`);
+ for (const m of messages) await processMsg(threadId, m.id, m.blob);
+ } catch (e) {
+ log(`catchUp ${threadId}: ${e.message}`);
+ }
+ }
+}
+
+// On first start the agent only reacts to messages that arrive after boot
+// (cursors start at each thread's latest, so no backlog is answered). On a
+// reconnect it catches up on anything missed during the gap (see catchUp).
+await loadThreads();
+connect();
diff --git a/agent/config.mjs b/agent/config.mjs
new file mode 100644
index 0000000..49c501e
--- /dev/null
+++ b/agent/config.mjs
@@ -0,0 +1,35 @@
+// Shared config + session storage for the agent sidecar.
+//
+// The session file holds the agent's device token and its ECDH private key — it
+// IS the agent's identity, and (like the ANTHROPIC_API_KEY) it lives only on the
+// agent host, never on the lchat server. Keep it secret; back it up.
+import { readFileSync, writeFileSync, existsSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+
+// The instance base URL, e.g. https://test.luan.chat. WS base is derived
+// (https -> wss, http -> ws).
+export const BASE = (process.env.LCHAT_BASE || "").replace(/\/$/, "");
+export const WS_BASE = BASE.replace(/^http/, "ws");
+
+export const AGENT_NAME = process.env.LCHAT_AGENT_NAME || "Claude";
+export const MODEL = process.env.LCHAT_MODEL || "claude-opus-4-8";
+
+// Session lives next to this module by default so `node agent.mjs` works from
+// any cwd; override with LCHAT_SESSION.
+export const SESSION_PATH =
+ process.env.LCHAT_SESSION || fileURLToPath(new URL("./session.json", import.meta.url));
+
+export function requireBase() {
+ if (!BASE) {
+ console.error("Set LCHAT_BASE to your instance URL, e.g. https://test.luan.chat");
+ process.exit(1);
+ }
+}
+
+export function loadSession() {
+ return existsSync(SESSION_PATH) ? JSON.parse(readFileSync(SESSION_PATH, "utf8")) : null;
+}
+
+export function saveSession(s) {
+ writeFileSync(SESSION_PATH, JSON.stringify(s, null, 2));
+}
diff --git a/agent/package-lock.json b/agent/package-lock.json
new file mode 100644
index 0000000..a34b957
--- /dev/null
+++ b/agent/package-lock.json
@@ -0,0 +1,111 @@
+{
+ "name": "lchat-agent",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "lchat-agent",
+ "version": "0.1.0",
+ "dependencies": {
+ "@anthropic-ai/sdk": "^0.109.0",
+ "ws": "^8.21.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@anthropic-ai/sdk": {
+ "version": "0.109.0",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.109.0.tgz",
+ "integrity": "sha512-y7P4eLyW5uNut4fXpOUEHqhJwx7dnxrWAfCQE4Lcgm0hSFQuIeHa7CWEKE5dFolEjQJE/RFKkppjri05r2OK/Q==",
+ "license": "MIT",
+ "dependencies": {
+ "json-schema-to-ts": "^3.1.1",
+ "standardwebhooks": "^1.0.0"
+ },
+ "bin": {
+ "anthropic-ai-sdk": "bin/cli"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "zod": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@stablelib/base64": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
+ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
+ "license": "MIT"
+ },
+ "node_modules/fast-sha256": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
+ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
+ "license": "Unlicense"
+ },
+ "node_modules/json-schema-to-ts": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
+ "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.3",
+ "ts-algebra": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/standardwebhooks": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
+ "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
+ "license": "MIT",
+ "dependencies": {
+ "@stablelib/base64": "^1.0.0",
+ "fast-sha256": "^1.3.0"
+ }
+ },
+ "node_modules/ts-algebra": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
+ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
+ "license": "MIT"
+ },
+ "node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/agent/package.json b/agent/package.json
new file mode 100644
index 0000000..81d2752
--- /dev/null
+++ b/agent/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "lchat-agent",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "Headless lchat member that relays a thread to Claude and posts replies — end-to-end encrypted, keys never touch the lchat server.",
+ "engines": { "node": ">=20" },
+ "scripts": {
+ "provision": "node provision.mjs",
+ "start": "node agent.mjs"
+ },
+ "dependencies": {
+ "@anthropic-ai/sdk": "^0.109.0",
+ "ws": "^8.21.0"
+ }
+}
diff --git a/agent/provision.mjs b/agent/provision.mjs
new file mode 100644
index 0000000..a42aea5
--- /dev/null
+++ b/agent/provision.mjs
@@ -0,0 +1,59 @@
+// Provisioning: turn an admin invite into a persisted agent session.
+//
+// This is web/static/join.js run headlessly. Mint a fresh-person invite in the
+// admin console (ideally the "This is an agent" one so it gets a bot badge) and
+// pass its #fragment as LCHAT_INVITE. The fragment never reaches the server: we
+// derive the invite id from it, generate our own keypair, claim a device token,
+// and register the public key in the roster.
+//
+// Exposed as provision(T) so the runtime (agent.mjs) can self-provision on first
+// boot in a container; also runnable directly: `node provision.mjs`.
+import { deriveInvite, generateUserKeypair } from "../web/static/crypto.js";
+import { BASE, AGENT_NAME, SESSION_PATH, requireBase, loadSession, saveSession } from "./config.mjs";
+
+// provision claims the invite secret T, generates a keypair, registers the
+// pubkey, and persists + returns the session. Throws on any failure.
+export async function provision(T) {
+ const post = (path, body) =>
+ fetch(`${BASE}${path}`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ }).then((r) => r.json());
+
+ const { id } = await deriveInvite(T);
+ const info = await (await fetch(`${BASE}/api/key-invite/${encodeURIComponent(id)}`)).json();
+ if (!info.ok) throw new Error("invite is used or invalid — mint a fresh one");
+ if (info.wrapped) throw new Error("that's an add-device link; use a fresh-person invite");
+
+ const kp = await generateUserKeypair();
+ const claim = await post(`/api/key-invite/${encodeURIComponent(id)}/claim`, { label: AGENT_NAME });
+ if (!claim.ok || !claim.token) throw new Error("couldn't claim the invite (it may have just been used)");
+ await post("/api/pubkey", { token: claim.token, public_key: kp.publicKey });
+
+ const session = { token: claim.token, name: AGENT_NAME, userPub: kp.publicKey, userPriv: kp.privateKey };
+ saveSession(session);
+ return session;
+}
+
+// CLI: `node provision.mjs` with LCHAT_INVITE in the environment.
+if (import.meta.url === `file://${process.argv[1]}`) {
+ requireBase();
+ if (loadSession()) {
+ console.error(`Already provisioned (${SESSION_PATH}). Delete it to re-provision.`);
+ process.exit(1);
+ }
+ const T = process.env.LCHAT_INVITE;
+ if (!T) {
+ console.error("Set LCHAT_INVITE to the #fragment of a /join/... link from the admin console.");
+ process.exit(1);
+ }
+ try {
+ await provision(T);
+ console.log(`Provisioned "${AGENT_NAME}". Session written to ${SESSION_PATH}.`);
+ console.log("Now add the agent to a group, or start a DM with it from the app, then run: npm start");
+ } catch (e) {
+ console.error(e.message);
+ process.exit(1);
+ }
+}
diff --git a/db.go b/db.go
index 0b132eb..16db849 100644
--- a/db.go
+++ b/db.go
@@ -180,6 +180,9 @@ CREATE TABLE IF NOT EXISTS hidden_threads (
"ALTER TABLE users ADD COLUMN avatar_blob TEXT",
// Group photo: an encrypted blob id (encrypted with the thread key).
"ALTER TABLE threads ADD COLUMN avatar_blob TEXT",
+ // Bot flag: a user driven by an agent (see agent/), not a person. Only
+ // used to badge them in the UI — it grants no privilege.
+ "ALTER TABLE users ADD COLUMN is_agent INTEGER NOT NULL DEFAULT 0",
} {
if _, err := db.sql.Exec(c); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -535,6 +538,7 @@ type RosterEntry struct {
Label string `json:"label"`
PublicKey string `json:"public_key"`
Avatar string `json:"avatar,omitempty"`
+ IsAgent bool `json:"is_agent,omitempty"` // an agent/bot, not a person
}
func (db *DB) setUserPublicKey(userID, pub string) error {
@@ -542,6 +546,13 @@ func (db *DB) setUserPublicKey(userID, pub string) error {
return err
}
+// setUserAgent flags a user as an agent (bot) so the UI can badge it. Purely
+// cosmetic — it confers no extra permission.
+func (db *DB) setUserAgent(userID string) error {
+ _, err := db.sql.Exec("UPDATE users SET is_agent = 1 WHERE id = ?", userID)
+ return err
+}
+
func (db *DB) setAvatar(userID, blobID string) error {
var b any
if blobID != "" {
@@ -555,7 +566,7 @@ func (db *DB) setAvatar(userID, blobID string) error {
// any member may read it to start a thread with anyone.
func (db *DB) roster() ([]RosterEntry, error) {
rows, err := db.sql.Query(
- "SELECT id, label, public_key, COALESCE(avatar_blob, '') FROM users WHERE public_key IS NOT NULL AND public_key <> '' ORDER BY label",
+ "SELECT id, label, public_key, COALESCE(avatar_blob, ''), COALESCE(is_agent, 0) FROM users WHERE public_key IS NOT NULL AND public_key <> '' ORDER BY label",
)
if err != nil {
return nil, err
@@ -564,9 +575,11 @@ func (db *DB) roster() ([]RosterEntry, error) {
out := []RosterEntry{}
for rows.Next() {
var e RosterEntry
- if err := rows.Scan(&e.UserID, &e.Label, &e.PublicKey, &e.Avatar); err != nil {
+ var isAgent int
+ if err := rows.Scan(&e.UserID, &e.Label, &e.PublicKey, &e.Avatar, &isAgent); err != nil {
return nil, err
}
+ e.IsAgent = isAgent != 0
out = append(out, e)
}
return out, rows.Err()
diff --git a/deploy-agent.sh b/deploy-agent.sh
new file mode 100755
index 0000000..035fe0e
--- /dev/null
+++ b/deploy-agent.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# Deploy the bot sidecar as its own Miren app, alongside (but separate from) an
+# lchat instance. Mirrors deploy-instance.sh: templates a throwaway app.toml so
+# git stays clean.
+#
+# Usage:
+# LCHAT_BASE=https://<host> ANTHROPIC_API_KEY=sk-ant-... [LCHAT_INVITE=<T>] \
+# ./deploy-agent.sh <instance>
+#
+# <instance> -> app "<instance>-bot", disk "<instance>-bot-data"
+#
+# LCHAT_INVITE is only needed on the FIRST deploy (the sidecar self-provisions
+# and persists its session to the /data disk; later deploys ignore it).
+set -euo pipefail
+
+INSTANCE="${1:?usage: deploy-agent.sh <instance>}"
+: "${LCHAT_BASE:?set LCHAT_BASE (e.g. https://test-bots.luan.chat)}"
+: "${ANTHROPIC_API_KEY:?set ANTHROPIC_API_KEY}"
+CLUSTER="${MIREN_CLUSTER:-luanchat}"
+APP="${INSTANCE}-bot"
+
+REPO="$(cd "$(dirname "$0")" && pwd)"
+TMP="$(mktemp -d)"
+trap 'rm -rf "$TMP"' EXIT
+
+cp -R "$REPO"/. "$TMP"/
+rm -rf "$TMP/.git"
+sed "s/__APP__/$APP/g" "$REPO/.miren/agent.app.toml" > "$TMP/.miren/app.toml"
+
+ENVARGS=(-e "LCHAT_BASE=$LCHAT_BASE" -e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY")
+[ -n "${LCHAT_INVITE:-}" ] && ENVARGS+=(-e "LCHAT_INVITE=$LCHAT_INVITE")
+[ -n "${LCHAT_AGENT_NAME:-}" ] && ENVARGS+=(-e "LCHAT_AGENT_NAME=$LCHAT_AGENT_NAME")
+[ -n "${LCHAT_MODEL:-}" ] && ENVARGS+=(-e "LCHAT_MODEL=$LCHAT_MODEL")
+
+echo "Deploying sidecar '$APP' (cluster $CLUSTER) -> $LCHAT_BASE"
+miren deploy -d "$TMP" -C "$CLUSTER" -f "${ENVARGS[@]}"
+echo "✅ sidecar '$APP' deployed. Logs: miren logs -a $APP -C $CLUSTER"
diff --git a/docs/bots.md b/docs/bots.md
new file mode 100644
index 0000000..8c4212c
--- /dev/null
+++ b/docs/bots.md
@@ -0,0 +1,390 @@
+# Bots (agents) — design
+
+Status: **the sidecar exists and runs** (`agent/`, deployed to the `test` instance);
+everything past "What exists today" 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.
+- **No forward secrecy on removal** — removing a bot doesn't retroactively
+ protect past messages, same as removing a human today.
+
+## 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.** Reconnects with backoff; picks up
+ new threads on first unknown `thread_id`.
+- Server side (on branch `agent-sidecar`): additive `users.is_agent` column,
+ `createKeyInvite` accepts `agent:true`, roster returns `is_agent`, a dedicated
+ admin **"Add an agent"** section (distinct from inviting a person), and a `BOT`
+ tag in the new-chat picker.
+
+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. Also
+interim vs. the target below: config is per-bot **env vars** (target:
+console-managed), and 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 Node worker deployed as its own
+Miren app, separate from the Go binary (that's a static distroless image with no
+Node runtime). 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.
+
+- Node Dockerfile; build context includes `agent/` **and** `web/static/crypto.js`.
+- **Env:** `LCHAT_BASE` + `ANTHROPIC_API_KEY` — the sidecar's own Miren 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 disk 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.
+- If Miren won't run a routeless worker, add a trivial `200 OK` health endpoint
+ (the one thing to verify against the cluster before writing the Dockerfile).
+
+### Deploy (built and proven on `test-bots`)
+
+Files: `Dockerfile.agent` (Node), `.miren/agent.app.toml` (templated per instance),
+`deploy-agent.sh`. Deploy:
+
+```sh
+LCHAT_BASE=https://<host> ANTHROPIC_API_KEY=sk-ant-... LCHAT_INVITE=<T> \
+ ./deploy-agent.sh <instance> # -> app "<instance>-bot"
+```
+
+The app has a `[services.web]` with a health port (no route → no public hostname),
+a `/data` disk for the session, and self-provisions on first boot from
+`LCHAT_INVITE`. Three gotchas cost real time — worth remembering:
+
+1. **Miren launches the container with cwd `/app`, not the Dockerfile `WORKDIR`.**
+ Use an absolute `CMD` (`node /app/agent/agent.mjs`) or you get `MODULE_NOT_FOUND`.
+2. **Node's async stdout is lost when a container is killed.** Log startup lines
+ with `fs.writeSync(1, …)` or you debug blind. Also bind the health server to
+ `0.0.0.0` explicitly.
+3. **Rapid redeploys of a disk-backed app strand the disk lease** — dead sandboxes
+ hold it, so new ones fail to boot (`disk ... has an active lease`). Don't churn;
+ if you do, `miren app delete <app> -f` then deploy once (recover
+ `ANTHROPIC_API_KEY` from the app env first — it lives only there).
+
+## Configuration & control
+
+Split config by what it *is*:
+
+**The Anthropic API key → the sidecar's Miren env** (`ANTHROPIC_API_KEY`), 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, rotate it by
+updating env, and recover it with `miren env get`. 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
+
+Default: **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).
+
+## 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).
+
+Server shape: a catalog query `is_agent = 1 AND available_to_members = 1` with a
+`description`. **Open decision:** bot name/description are admin-authored catalog
+metadata — store consistent with how the instance stores roster labels (in clear
+today), or encrypt to members via the instance key if labels are to be kept from
+the server.
+
+**Integration note:** the `is_agent` badge is on `agent-sidecar` (off `main`);
+the connections model is on `worktree-user-invites-and-connections`. When they
+converge, move the `is_agent` SELECT/scan onto `connections()` / `scanRoster`,
+and add the catalog surface — it's new, not a tweak to the roster.
+
+## 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.
diff --git a/main.go b/main.go
index bf4f7fb..8d4669e 100644
--- a/main.go
+++ b/main.go
@@ -975,6 +975,7 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) {
ID string `json:"id"`
Wrapped string `json:"wrapped"`
Expires int64 `json:"expires"`
+ Agent bool `json:"agent"` // mark the invited user as a bot (badge only)
}
if json.NewDecoder(r.Body).Decode(&body) != nil {
httpError(w, http.StatusBadRequest, "bad request")
@@ -999,6 +1000,12 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusBadRequest, "unknown user")
return
}
+ if body.Agent {
+ if err := s.db.setUserAgent(userID); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ }
if err := s.db.createKeyInvite(body.ID, userID, body.Wrapped, body.Expires); err != nil {
httpError(w, http.StatusInternalServerError, "db error")
return
diff --git a/web/pages/admin.html b/web/pages/admin.html
index 6ad8fec..8aff9b7 100644
--- a/web/pages/admin.html
+++ b/web/pages/admin.html
@@ -58,6 +58,15 @@
</form>
</section>
+ <section>
+ <h2>Add an agent</h2>
+ <p class="sub" style="margin:0 0 8px">Adds an AI bot as a member. This mints a one-time link the agent sidecar uses to set up the bot's identity; it then appears in chats as a member with a BOT badge. The sidecar for this instance must be running.</p>
+ <form id="agentForm" class="inline-form" autocomplete="off">
+ <input id="agentLabel" placeholder="Bot name (e.g. Claude)" maxlength="60" />
+ <button type="submit">Add agent</button>
+ </form>
+ </section>
+
<section>
<h2>Notifications</h2>
<p class="sub" style="margin:0 0 8px">Send a test notification to everyone who has them enabled — a quick way to check delivery.</p>
diff --git a/web/static/admin.js b/web/static/admin.js
index fa33df3..7ad6aef 100644
--- a/web/static/admin.js
+++ b/web/static/admin.js
@@ -37,6 +37,7 @@ async function init() {
wireGroupName();
wireTestPush();
wireCreate();
+ wireCreateAgent();
renderUsers((await res.json()).users || []);
}
@@ -82,10 +83,25 @@ function wireCreate() {
};
}
+// Adding an agent is a deliberate, separate action from inviting a person: it
+// mints an invite flagged as an agent, so the joiner is badged as a bot. The
+// sidecar consumes this link to provision the bot's identity.
+function wireCreateAgent() {
+ document.getElementById("agentForm").onsubmit = async (e) => {
+ e.preventDefault();
+ const name = document.getElementById("agentLabel").value.trim();
+ const minted = await mintInvite(name, null, true);
+ if (!minted) return;
+ document.getElementById("agentLabel").value = "";
+ showInviteModal(minted.link, name || "New agent");
+ loadUsers();
+ };
+}
+
// Mint a single-use invite: generate the secret T client-side, wrap the group
// key under it, and store only {id, wrapped} server-side. Pass a userId to
// re-issue for an existing person (add/recover a device). Returns the link.
-async function mintInvite(label, userId) {
+async function mintInvite(label, userId, agent) {
// Auth-only invite (threads model): no instance-wide key to wrap — the joiner
// generates their own user keypair on join. See docs/threads-and-dms.md.
const T = randomSecret();
@@ -95,6 +111,7 @@ async function mintInvite(label, userId) {
user_id: userId || "",
id,
wrapped: "",
+ agent: !!agent,
});
if (!res.ok) {
toast("Couldn't create invite");