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>
15 files changed,
+1054
−3
.miren/agent.app.toml+33 −0Dockerfile.agent+20 −0agent/.gitignore+2 −0agent/README.md+55 −0agent/agent.mjs+247 −0agent/config.mjs+35 −0agent/package-lock.json+111 −0agent/package.json+16 −0agent/provision.mjs+59 −0db.go+15 −2deploy-agent.sh+37 −0docs/bots.md+390 −0main.go+7 −0web/pages/admin.html+9 −0web/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"); |