eleven / ocho Public

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)
Paul Campbell c4a41d463b5c1a1cb093820ba12e4ce9b4694118 parent e29f0fa
6 files changed, +143 −25
  • agent/agent.mjs +41 −13
  • db.go +37 −0
  • docs/bots.md +13 −8
  • main.go +31 −0
  • web/pages/admin.html +10 −2
  • web/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 &amp; 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");