Add an agent: console-managed model, system prompt & reference docs
## What this adds The admin **"Add an agent"** section is now a real form — **name, model, system prompt, and reference docs** — so an admin can shape a bot's personality and knowledge without touching env vars or code. Follows #42. ### How it works - **Server:** a `bot_configs` table (`model` / `system_prompt` / `docs`, keyed by the agent's user id), populated when the agent is created via the key-invite call. New `GET /api/bot-config` returns the caller's **own** config (scoped to its device token), so a bot only ever reads its own. - **Admin:** the "Add an agent" form gains a model select plus system-prompt and reference-docs textareas. - **Sidecar:** fetches its config on boot and on each reconnect and applies it. A **non-overridable safety + app-awareness preamble** is always prepended (privacy, DM-vs-group, no out-of-band actions, kid-safe), then the console-set persona (falling back to `LCHAT_SYSTEM`, then a friendly default), then the reference docs. Model comes from config → env → default. ### Not in this PR (tracked in `docs/bots.md`) - **Editing** an existing bot's config from the console (this only sets it at creation). - **Wrapping** the config to the sidecar's key so the server can't read prompts/docs (stored in clear for now — operator config, not message content). - The **read-scope** (mention-only vs. watcher) and Helpers-catalog **availability** fields. Verified: `go build`, `go test`, `node --check` all pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
6 files changed,
+143
−25
agent/agent.mjs+41 −13db.go+37 −0docs/bots.md+13 −8main.go+31 −0web/pages/admin.html+10 −2web/static/admin.js+11 −2
diff --git a/agent/agent.mjs b/agent/agent.mjs| index ffa24b4..041e0f6 100644 |
| --- a/agent/agent.mjs |
| +++ b/agent/agent.mjs |
| @@ -59,18 +59,29 @@ if (!session) { |
| const claude = new Anthropic(); // reads ANTHROPIC_API_KEY from the environment |
| -const SYSTEM = |
| - process.env.LCHAT_SYSTEM || |
| +// A non-overridable safety + app-awareness preamble is always applied. The |
| +// persona, model, and reference docs come from the console-managed bot config |
| +// (see /api/bot-config), falling back to env then a friendly default. |
| +const SAFETY = |
| `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.`; |
| + `end-to-end encrypted group chat; the server and its operator can't read messages, ` + |
| + `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 — no reminders, notifications, files, or web access — ` + |
| + `so never promise to do those things; if asked, say you can't yet. ` + |
| + `This chat may include children: 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 DEFAULT_PERSONA = "Keep replies short, warm, and easy to read."; |
| + |
| +let persona = process.env.LCHAT_SYSTEM || DEFAULT_PERSONA; |
| +let model = MODEL; // env LCHAT_MODEL || claude-opus-4-8; overridden by console config |
| +let docs = ""; |
| + |
| +function systemPrompt() { |
| + let s = `${SAFETY}\n\n${persona}`; |
| + if (docs) s += `\n\nAlways keep this reference in mind:\n${docs}`; |
| + return s; |
| +} |
| const keys = new Map(); // threadId -> { key: CryptoKey, kind: "dm"|"group"|"self" } |
| const cursors = new Map(); // threadId -> last message id we've processed |
| @@ -100,6 +111,21 @@ async function loadThreads() { |
| } |
| } |
| +// Pull console-managed config (persona / model / reference docs) for this bot and |
| +// apply it over the env/defaults. Re-fetched on reconnect so admin edits take effect. |
| +async function loadConfig() { |
| + try { |
| + const c = await api("/api/bot-config"); |
| + if (c && typeof c === "object") { |
| + persona = c.system_prompt || process.env.LCHAT_SYSTEM || DEFAULT_PERSONA; |
| + model = c.model || MODEL; |
| + docs = c.docs || ""; |
| + } |
| + } catch (e) { |
| + log(`loadConfig: ${e.message}`); |
| + } |
| +} |
| + |
| // DMs to the bot: always answer. Groups: only when @mentioned by name. |
| function wantsReply(kind, text) { |
| if (kind === "dm") return true; |
| @@ -133,9 +159,9 @@ async function reply(threadId, entry) { |
| let text; |
| try { |
| const r = await claude.messages.create({ |
| - model: MODEL, |
| + model, |
| max_tokens: 1024, |
| - system: SYSTEM, |
| + system: systemPrompt(), |
| thinking: { type: "adaptive" }, |
| messages: context.length ? context : [{ role: "user", content: "Say hello." }], |
| }); |
| @@ -183,6 +209,7 @@ function connect() { |
| if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" })); |
| } catch {} |
| }, 30000); |
| + await loadConfig(); |
| await loadThreads(); |
| await catchUp(); |
| for (const d of queue.splice(0)) ws.send(d); |
| @@ -243,5 +270,6 @@ async function catchUp() { |
| // 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 loadConfig(); |
| await loadThreads(); |
| connect(); |
diff --git a/db.go b/db.go| index d0affee..ff06c6b 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -177,6 +177,16 @@ CREATE TABLE IF NOT EXISTS hidden_threads ( |
| user_id TEXT NOT NULL, |
| thread_id TEXT NOT NULL, |
| PRIMARY KEY (user_id, thread_id) |
| +); |
| +-- Console-managed config for an agent (bot), keyed by its user id: the model, |
| +-- system prompt (personality), and reference docs the sidecar applies. This is |
| +-- operator config, not message content; the sidecar pulls it with its own token. |
| +CREATE TABLE IF NOT EXISTS bot_configs ( |
| + user_id TEXT PRIMARY KEY, |
| + model TEXT NOT NULL DEFAULT '', |
| + system_prompt TEXT NOT NULL DEFAULT '', |
| + docs TEXT NOT NULL DEFAULT '', |
| + updated_at INTEGER NOT NULL |
| );`) |
| if err != nil { |
| return err |
| @@ -810,6 +820,33 @@ func (db *DB) setUserAgent(userID string) error { |
| return err |
| } |
| +// BotConfig is an agent's console-managed settings (see bot_configs). |
| +type BotConfig struct { |
| + Model string `json:"model"` |
| + SystemPrompt string `json:"system_prompt"` |
| + Docs string `json:"docs"` |
| +} |
| + |
| +func (db *DB) setBotConfig(userID, model, systemPrompt, docs string) error { |
| + _, err := db.sql.Exec( |
| + `INSERT INTO bot_configs(user_id, model, system_prompt, docs, updated_at) VALUES(?, ?, ?, ?, ?) |
| + ON CONFLICT(user_id) DO UPDATE SET model = excluded.model, system_prompt = excluded.system_prompt, docs = excluded.docs, updated_at = excluded.updated_at`, |
| + userID, model, systemPrompt, docs, time.Now().Unix(), |
| + ) |
| + return err |
| +} |
| + |
| +// botConfig returns an agent's config (ok=false if none has been set). |
| +func (db *DB) botConfig(userID string) (BotConfig, bool, error) { |
| + var c BotConfig |
| + err := db.sql.QueryRow("SELECT model, system_prompt, docs FROM bot_configs WHERE user_id = ?", userID). |
| + Scan(&c.Model, &c.SystemPrompt, &c.Docs) |
| + if err == sql.ErrNoRows { |
| + return c, false, nil |
| + } |
| + return c, err == nil, err |
| +} |
| + |
| func (db *DB) setAvatar(userID, blobID string) error { |
| var b any |
| if blobID != "" { |
diff --git a/docs/bots.md b/docs/bots.md| index 8c4212c..b20951d 100644 |
| --- a/docs/bots.md |
| +++ b/docs/bots.md |
| @@ -57,17 +57,22 @@ A headless Node sidecar that behaves like a member's browser: |
| (`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. |
| +- Server + web: additive `users.is_agent` column, roster `BOT` tag, and a |
| + dedicated admin **"Add an agent"** section (distinct from inviting a person) |
| + that captures **model + system prompt + reference docs** and stores them in a |
| + `bot_configs` table. The sidecar pulls its own config from `/api/bot-config` |
| + and applies it — a **non-overridable safety/app-awareness preamble** is always |
| + prepended, then the console-set persona, then the docs. |
| +- Still design, not built: **editing** an existing bot's config from the console; |
| + wrapping the config to the sidecar's key so the server can't read prompts/docs; |
| + and the **read-scope / availability** fields (mention-only vs. watcher, Helpers |
| + catalog visibility) — the form captures persona/model/docs only so far. |
| 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**). |
| +last ~30 messages of the thread per reply — nothing persisted); no tools. One |
| +behaviour is still interim vs. the target below: in a group it reads recent |
| +history to compose a reply (target: **mention-scoped**). |
| ## One sidecar per instance, all bots in it |
diff --git a/main.go b/main.go| index 04e9002..1ead4e4 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -115,6 +115,7 @@ func (s *Server) handler() http.Handler { |
| // directory; threads (DMs/groups) carry server-blind, per-member wrapped keys. |
| mux.HandleFunc("POST /api/pubkey", s.setPubkey) |
| mux.HandleFunc("GET /api/roster", s.getRoster) |
| + mux.HandleFunc("GET /api/bot-config", s.getBotConfig) |
| mux.HandleFunc("GET /api/me", s.getMe) |
| mux.HandleFunc("POST /api/device/signout", s.signout) |
| // Instance key + profile photos: a shared key for all-members content (avatars). |
| @@ -1031,6 +1032,28 @@ func (s *Server) claimInvite(w http.ResponseWriter, r *http.Request) { |
| // createKeyInvite stores a client-wrapped group key (the admin's browser did the |
| // wrapping; the server never sees the secret or plaintext key). Creates a user |
| // when no user_id is given (re-issue passes an existing user_id). |
| +// getBotConfig returns the caller's own agent config. The sidecar fetches this |
| +// with its device token to pick up console-managed model/prompt/docs; it's scoped |
| +// to the caller's user, so a bot only ever reads its own config. |
| +func (s *Server) getBotConfig(w http.ResponseWriter, r *http.Request) { |
| + token := r.URL.Query().Get("token") |
| + if !s.tokenOK(token) { |
| + httpError(w, http.StatusForbidden, "invalid invite") |
| + return |
| + } |
| + uid, ok := s.db.deviceTokenUser(token) |
| + if !ok { |
| + writeJSON(w, http.StatusOK, BotConfig{}) |
| + return |
| + } |
| + c, _, err := s.db.botConfig(uid) |
| + if err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, c) |
| +} |
| + |
| func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) { |
| if !s.requireAdmin(w, r) { |
| return |
| @@ -1042,6 +1065,10 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) { |
| Wrapped string `json:"wrapped"` |
| Expires int64 `json:"expires"` |
| Agent bool `json:"agent"` // mark the invited user as a bot (badge only) |
| + // Agent config (only when Agent) — the sidecar applies these. |
| + Model string `json:"model"` |
| + SystemPrompt string `json:"system_prompt"` |
| + Docs string `json:"docs"` |
| } |
| if json.NewDecoder(r.Body).Decode(&body) != nil { |
| httpError(w, http.StatusBadRequest, "bad request") |
| @@ -1071,6 +1098,10 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| + if err := s.db.setBotConfig(userID, body.Model, body.SystemPrompt, body.Docs); 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") |
diff --git a/web/pages/admin.html b/web/pages/admin.html| index 6bba372..ad97285 100644 |
| --- a/web/pages/admin.html |
| +++ b/web/pages/admin.html |
| @@ -65,8 +65,16 @@ |
| <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" /> |
| + <form id="agentForm" autocomplete="off"> |
| + <input id="agentLabel" placeholder="Bot name (e.g. Claude)" maxlength="60" style="display:block;width:100%;margin:0 0 8px" /> |
| + <select id="agentModel" style="display:block;width:100%;margin:0 0 8px"> |
| + <option value="">Default model (Opus)</option> |
| + <option value="claude-opus-4-8">Opus — most capable</option> |
| + <option value="claude-sonnet-5">Sonnet — balanced</option> |
| + <option value="claude-haiku-4-5">Haiku — fast & cheap</option> |
| + </select> |
| + <textarea id="agentPrompt" rows="4" placeholder="Personality / instructions (system prompt). Leave blank for a friendly default. A safety + privacy preamble is always applied on top." style="display:block;width:100%;margin:0 0 8px"></textarea> |
| + <textarea id="agentDocs" rows="3" placeholder="Reference the bot should always know — house rules, an FAQ, etc. Optional." style="display:block;width:100%;margin:0 0 8px"></textarea> |
| <button type="submit">Add agent</button> |
| </form> |
| </section> |
diff --git a/web/static/admin.js b/web/static/admin.js| index a2e2d38..f234c57 100644 |
| --- a/web/static/admin.js |
| +++ b/web/static/admin.js |
| @@ -109,9 +109,17 @@ function wireCreateAgent() { |
| document.getElementById("agentForm").onsubmit = async (e) => { |
| e.preventDefault(); |
| const name = document.getElementById("agentLabel").value.trim(); |
| - const minted = await mintInvite(name, null, true); |
| + const config = { |
| + model: document.getElementById("agentModel").value, |
| + system_prompt: document.getElementById("agentPrompt").value.trim(), |
| + docs: document.getElementById("agentDocs").value.trim(), |
| + }; |
| + const minted = await mintInvite(name, null, true, config); |
| if (!minted) return; |
| document.getElementById("agentLabel").value = ""; |
| + document.getElementById("agentModel").value = ""; |
| + document.getElementById("agentPrompt").value = ""; |
| + document.getElementById("agentDocs").value = ""; |
| showInviteModal(minted.link, name || "New agent"); |
| loadUsers(); |
| }; |
| @@ -120,7 +128,7 @@ function wireCreateAgent() { |
| // 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, agent) { |
| +async function mintInvite(label, userId, agent, config) { |
| // 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(); |
| @@ -131,6 +139,7 @@ async function mintInvite(label, userId, agent) { |
| id, |
| wrapped: "", |
| agent: !!agent, |
| + ...(config || {}), // model, system_prompt, docs — agent only |
| }); |
| if (!res.ok) { |
| toast("Couldn't create invite"); |