eleven / messenger Public

Eleven: a bot that upgrades to AI, plus AI/BOT helper badges (#133)

Split deterministic bots (BOT) from LLM agents (AI). Eleven runs a deterministic
command floor (welcome + /help, /theme, /dark, /light, /profile) with no API key
and upgrades to conversational AI when a key is present; its badge follows. Kind
is self-declared by the sidecar (POST /api/bot-config); Announcer badges BOT.
Adds an editable welcome message and a keyless (bot-only) sidecar mode.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell be9c09d5951de7ec3d5cf3c13d381b8565c6a6bf parent fbc5c4d
16 files changed, +489 −33
  • agent/actions.mjs +5 −2
  • agent/agent.mjs +11 −1
  • agent/announcer.mjs +19 −0
  • agent/commands.mjs +93 −0
  • agent/config.mjs +6 −0
  • agent/eleven.mjs +25 −4
  • agent/runtime.mjs +54 −4
  • bots_test.go +72 −0
  • changelog/2026-07-04-ai-helpers-and-commands.md +1 −0
  • db.go +58 −11
  • deploy-agent.sh +9 −3
  • main.go +46 −0
  • package.json +1 −1
  • test/commands.test.mjs +73 −0
  • web/static/admin.js +6 −1
  • web/static/chat.js +10 −6
diff --git a/agent/actions.mjs b/agent/actions.mjs
index 5496082..ceb5ca4 100644
--- a/agent/actions.mjs
+++ b/agent/actions.mjs
@@ -6,7 +6,10 @@
// small, harmless, and reversible. Dependency-free so tests can import it.
// Keep the theme list + descriptions in step with web/static/ui.js THEMES.
-const THEMES = ["litroom", "lagoon", "sunny", "plum", "linen", "classic"];
+// Exported so the deterministic command floor (commands.mjs) validates against
+// the same lists the model tools do.
+export const THEMES = ["litroom", "lagoon", "sunny", "plum", "linen", "classic"];
+export const APPEARANCES = ["light", "dark", "system"];
export const CHIP_TOOLS = [
{
@@ -54,7 +57,7 @@ export function actionsFromToolUse(content) {
if (b.type !== "tool_use") continue;
const input = b.input || {};
if (b.name === "set_theme" && THEMES.includes(input.theme)) out.push({ type: "set_theme", theme: input.theme });
- else if (b.name === "set_appearance" && ["light", "dark", "system"].includes(input.appearance))
+ else if (b.name === "set_appearance" && APPEARANCES.includes(input.appearance))
out.push({ type: "set_appearance", appearance: input.appearance });
else if (b.name === "open_theme_picker") out.push({ type: "open_theme_picker" });
else if (b.name === "open_profile") out.push({ type: "open_profile" });
diff --git a/agent/agent.mjs b/agent/agent.mjs
index 18aad28..bba2970 100644
--- a/agent/agent.mjs
+++ b/agent/agent.mjs
@@ -73,7 +73,17 @@ try {
process.exit(1);
}
if (elevenSession) {
- runBot({ name: ELEVEN_NAME, session: elevenSession, builtinPersona: ELEVEN_PERSONA, builtinDocs: ELEVEN_DOCS, tools: CHIP_TOOLS });
+ // Eleven runs a deterministic command floor (welcome + slash commands) that
+ // works with no API key, and "upgrades" to conversational AI when one is set —
+ // its badge (BOT vs AI) follows, self-declared on connect. See runtime.mjs.
+ runBot({
+ name: ELEVEN_NAME,
+ session: elevenSession,
+ builtinPersona: ELEVEN_PERSONA,
+ builtinDocs: ELEVEN_DOCS,
+ tools: CHIP_TOOLS,
+ commands: true,
+ });
startWelcomer();
}
diff --git a/agent/announcer.mjs b/agent/announcer.mjs
index c0d2a5f..aac8a19 100644
--- a/agent/announcer.mjs
+++ b/agent/announcer.mjs
@@ -38,6 +38,24 @@ const POLL_MS = Number(process.env.LCHAT_ANNOUNCE_POLL_MS) || 5 * 60 * 1000;
const readJSON = (p) => (fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null);
+// Announce our kind once so the UI badges us BOT — the announcer is deterministic
+// (it never calls a model; it only decrypts thread keys to *encrypt* what it
+// posts). Best-effort; retried on a later tick if it fails.
+let kindDeclared = false;
+async function declareBot(session) {
+ if (kindDeclared) return;
+ try {
+ const r = await fetch(`${BASE}/api/bot-config`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ token: session.token, kind: "bot" }),
+ });
+ kindDeclared = r.ok;
+ } catch (e) {
+ log(`declareBot: ${e.message}`);
+ }
+}
+
// Provision the announcer's own identity from LCHAT_ANNOUNCER_INVITE (same
// claim flow as provision.mjs, separate session file). Returns null — quietly
// disabled — when there's neither a session nor an invite.
@@ -70,6 +88,7 @@ async function loadOrProvision() {
export async function announceTick() {
const session = await loadOrProvision();
if (!session) return false;
+ await declareBot(session);
const api = (path) =>
fetch(`${BASE}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(session.token)}`).then((r) =>
r.json()
diff --git a/agent/commands.mjs b/agent/commands.mjs
new file mode 100644
index 0000000..59ec686
--- /dev/null
+++ b/agent/commands.mjs
@@ -0,0 +1,93 @@
+// Eleven's deterministic command floor: slash-commands it answers WITHOUT the
+// model. This is the "bot" half of a helper that upgrades to "ai" — the welcome
+// and these commands work with no ANTHROPIC_API_KEY, and conversational replies
+// layer on top when a key is present (see runtime.mjs). A command maps text to
+// { text, actions? }: the SAME one-tap action chips the LLM can propose
+// (actions.mjs), just triggered deterministically, so "make it dark" (AI) and
+// "/dark" (no AI) reach the identical chip. Pure + dependency-light so it's
+// unit-testable in Node (test/commands.test.mjs).
+import { THEMES, APPEARANCES } from "./actions.mjs";
+
+// Human-friendly theme names for the reply text (the chip itself carries the id).
+const THEME_LABELS = {
+ litroom: "Lit Room (warm amber)",
+ lagoon: "Lagoon (teal)",
+ sunny: "Sunny (yellow)",
+ plum: "Plum (purple)",
+ linen: "Linen (soft neutral)",
+ classic: "Classic (blue)",
+};
+
+function themeCmd(args) {
+ const name = (args || "").trim().toLowerCase();
+ if (THEMES.includes(name))
+ return { text: `Here you go — tap to switch to ${THEME_LABELS[name] || name}.`, actions: [{ type: "set_theme", theme: name }] };
+ // No/unknown theme → list the options and offer the picker.
+ return { text: `Themes: ${THEMES.join(", ")}. Tap to browse, or try e.g. /theme lagoon.`, actions: [{ type: "open_theme_picker" }] };
+}
+
+function appearanceCmd(mode) {
+ return { text: `Here you go — tap to switch to ${mode} mode.`, actions: [{ type: "set_appearance", appearance: mode }] };
+}
+
+// name -> { help, run(args, ctx) -> { text, actions? } }. `run` is pure: no I/O,
+// no model. `ctx.aiEnabled` only tweaks the /help wording (it never gates a chip).
+export const COMMANDS = {
+ "/help": { help: "what I can do", run: (_args, ctx) => ({ text: helpText(ctx) }) },
+ "/theme": { help: "switch colour theme, e.g. /theme lagoon", run: themeCmd },
+ "/themes": {
+ help: "browse the theme picker",
+ run: () => ({ text: "Here you go — tap to browse themes.", actions: [{ type: "open_theme_picker" }] }),
+ },
+ "/dark": { help: "switch to dark mode", run: () => appearanceCmd("dark") },
+ "/light": { help: "switch to light mode", run: () => appearanceCmd("light") },
+ "/profile": {
+ help: "edit your screen name & photo",
+ run: () => ({ text: "Here you go — tap to open your profile.", actions: [{ type: "open_profile" }] }),
+ },
+};
+
+// A bullet list of the commands and what they do — shared by /help and Eleven's
+// own reference (eleven.mjs), so the LLM side knows the commands its bot side
+// answers and can point people at them. One source of truth, no drift.
+export function commandSummary() {
+ return Object.entries(COMMANDS)
+ .map(([name, c]) => `• ${name} — ${c.help}`)
+ .join("\n");
+}
+
+function helpText(ctx) {
+ return [
+ "Here's what I can do right now:",
+ commandSummary(),
+ ctx && ctx.aiEnabled
+ ? "\nOr just ask me anything in your own words — I'll do my best to help."
+ : "\nConversational AI answers aren't enabled on this server, so I stick to the commands above.",
+ ].join("\n");
+}
+
+// parseCommand splits "/theme lagoon" into { name:"/theme", args:"lagoon" }, or
+// returns null when the text isn't a registered command (no leading "/", or the
+// first word isn't one we own — so "/unknown …" falls through to normal chat).
+export function parseCommand(text) {
+ const t = (text || "").trim();
+ if (t[0] !== "/") return null;
+ const sp = t.indexOf(" ");
+ const name = (sp === -1 ? t : t.slice(0, sp)).toLowerCase();
+ if (!COMMANDS[name]) return null;
+ return { name, args: sp === -1 ? "" : t.slice(sp + 1).trim() };
+}
+
+// runCommand returns { text, actions } for a handled command, or null if the
+// text isn't one (the caller then falls back to the model, or degrades).
+export function runCommand(text, ctx = {}) {
+ const cmd = parseCommand(text);
+ if (!cmd) return null;
+ const res = COMMANDS[cmd.name].run(cmd.args, ctx);
+ return res || null;
+}
+
+// The registered command names, for typeahead / help surfaces later.
+export function commandNames() {
+ return Object.keys(COMMANDS);
+}
diff --git a/agent/config.mjs b/agent/config.mjs
index 78d6d08..5a4c2aa 100644
--- a/agent/config.mjs
+++ b/agent/config.mjs
@@ -11,6 +11,12 @@ import { fileURLToPath } from "node:url";
export const BASE = (process.env.LCHAT_BASE || "").replace(/\/$/, "");
export const WS_BASE = BASE.replace(/^http/, "ws");
+// Whether the model brain is available. With no key the sidecar still runs its
+// deterministic side (welcomes + slash commands), so a helper "upgrades" from
+// BOT to AI exactly when a key is present — the badge follows this (runtime.mjs
+// self-declares kind), and non-command messages degrade to a commands hint.
+export const AI_ENABLED = !!(process.env.ANTHROPIC_API_KEY || "").trim();
+
export const AGENT_NAME = process.env.LCHAT_AGENT_NAME || "Claude";
// The app's user-facing name (the product is called Eleven; lchat is the
// project/repo name). Bots refer to the app by this.
diff --git a/agent/eleven.mjs b/agent/eleven.mjs
index 8c4a96d..1ac48d6 100644
--- a/agent/eleven.mjs
+++ b/agent/eleven.mjs
@@ -17,8 +17,9 @@
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { importKey, encrypt, decrypt, generateKey, wrapThreadKey, unwrapThreadKey } from "../web/static/crypto.js";
-import { BASE, requireBase } from "./config.mjs";
+import { BASE, requireBase, AI_ENABLED } from "./config.mjs";
import { provision } from "./provision.mjs";
+import { commandSummary } from "./commands.mjs";
const log = (m) => {
try {
@@ -42,6 +43,9 @@ export const ELEVEN_PERSONA =
"light/dark mode, browse themes, or edit their profile, call the matching tool — it attaches a button " +
"they tap to apply (nothing happens without their tap, and only on their own device). Keep the " +
"accompanying text short, like: 'Here you go — tap to switch.' " +
+ "You're not only an AI — you're also a bot: the very same actions are available as instant slash " +
+ "commands (like /dark or /theme lagoon) that work even when AI features are switched off. When it's " +
+ "natural, mention the quick command alongside the button so people learn the shortcut. " +
"If someone asks something unrelated to the app, you can chat — but gently note that Claude (the other " +
"helper) is the better companion for general questions.";
export const ELEVEN_DOCS = [
@@ -55,6 +59,9 @@ export const ELEVEN_DOCS = [
"Themes: the ⋮ menu has a Theme picker with light/dark and colour options — or people can just ask you to switch it (you offer a one-tap button).",
"Notifications: enable them from the ⋮ menu; you won't be notified for chats you have open.",
"The Announcer helper posts a note in groups it's added to whenever the app gets new features.",
+ "You answer these slash commands as well as free-form questions — they run instantly and work even " +
+ "when AI is off, so point people at the one that fits:\n" +
+ commandSummary(),
].join("\n");
const readJSON = (p) => (fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null);
@@ -71,13 +78,18 @@ export async function loadElevenSession() {
return session;
}
-const welcomeText = (name) =>
+// The closing line adapts to whether Eleven has its model brain: with AI it
+// invites free-form questions; without, it points at the commands it can
+// actually run — so the welcome never promises what this instance can't do.
+const welcomeText = (name, aiEnabled) =>
`Hi ${name || "there"}, welcome! 👋 I'm ${ELEVEN_NAME}, your guide to this app.\n\n` +
`A few things worth knowing:\n` +
`• Everything here is end-to-end encrypted — only the people in a chat can read it.\n` +
`• Tap ➕ to start a chat or a group. Helpers (like me) have their own section there.\n` +
`• Double-tap a message to react, swipe right to reply.\n\n` +
- `Ask me anything about how the app works — I'm happy to help!`;
+ (aiEnabled
+ ? `Ask me anything about how the app works — I'm happy to help!`
+ : `Try /help to see what I can do.`);
// Member display names are encrypted under the instance key (the server is
// blind to them). Fetch + unwrap our copy; null if a holder hasn't granted it to
@@ -135,6 +147,14 @@ export async function welcomeTick() {
// re-run after a partial failure): don't create a duplicate, post into it.
const { threads = [] } = await get("/api/threads");
+ // Admin-tweakable welcome text (console → bot_configs.welcome). Falls back to
+ // the shipped template; a literal "{name}" is replaced with the member's name.
+ let welcomeTmpl = "";
+ try {
+ const cfg = await get("/api/bot-config");
+ if (cfg && cfg.welcome) welcomeTmpl = cfg.welcome;
+ } catch {}
+
for (const person of newcomers) {
try {
let dm = threads.find((t) => t.kind === "dm" && (t.members || []).includes(person.user_id));
@@ -161,7 +181,8 @@ export async function welcomeTick() {
dm = { id: r.thread_id };
}
const name = await memberName(person, instanceKey);
- const blob = await encrypt(key, { name: ELEVEN_NAME, text: welcomeText(name), ts: Date.now() });
+ const text = welcomeTmpl ? welcomeTmpl.replaceAll("{name}", name || "there") : welcomeText(name, AI_ENABLED);
+ const blob = await encrypt(key, { name: ELEVEN_NAME, text, ts: Date.now() });
const pr = await fetch(`${BASE}/api/messages?token=${encodeURIComponent(session.token)}`, {
method: "POST",
headers: { "content-type": "application/json" },
diff --git a/agent/runtime.mjs b/agent/runtime.mjs
index e6dd702..204e20e 100644
--- a/agent/runtime.mjs
+++ b/agent/runtime.mjs
@@ -8,8 +8,9 @@ import fs from "node:fs";
import Anthropic from "@anthropic-ai/sdk";
import WebSocket from "ws";
import { importKey, encrypt, decrypt, unwrapThreadKey } from "../web/static/crypto.js";
-import { BASE, WS_BASE, MODEL, CLASSIFIER_MODEL, APP_NAME } from "./config.mjs";
+import { BASE, WS_BASE, MODEL, CLASSIFIER_MODEL, APP_NAME, AI_ENABLED } from "./config.mjs";
import { actionsFromToolUse } from "./actions.mjs";
+import { runCommand } from "./commands.mjs";
const log = (m) => {
try {
@@ -38,7 +39,7 @@ const DEFAULT_PERSONA = "Keep replies short, warm, and easy to read.";
// config can still override per instance. `tools` (Claude tool defs) let the
// bot propose one-tap action chips; tool_use blocks in a reply become the
// payload's `actions` (see actions.mjs — the client whitelists again).
-export function runBot({ name, session, builtinPersona = "", builtinDocs = "", tools = null }) {
+export function runBot({ name, session, builtinPersona = "", builtinDocs = "", tools = null, commands = false }) {
let persona = builtinPersona || process.env.LCHAT_SYSTEM || DEFAULT_PERSONA;
let model = MODEL;
let docs = builtinDocs;
@@ -280,12 +281,45 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "", t
console.error(`[${name}] Claude error:`, e.message);
return;
}
+ if (!text && !actions.length) return;
+ await postReply(threadId, entry, text, actions);
+ }
+
+ // Encrypt + send a reply payload (text + optional one-tap chips). Shared by the
+ // model path (replyInner) and the deterministic command path (processMsg).
+ async function postReply(threadId, entry, text, actions = []) {
if (!text && actions.length) text = "Tap to apply:";
if (!text) return;
const payload = { name, text, ts: Date.now(), ...(actions.length ? { actions } : {}) };
send({ type: "msg", thread_id: threadId, blob: await encrypt(entry.key, payload) });
}
+ // Strip a leading "@Name" so a group command ("@Eleven /help") parses; a bare
+ // command in a DM passes through unchanged.
+ function stripMention(text) {
+ return (text || "").replace(new RegExp(`^\\s*@${name}\\b[:,]?\\s*`, "i"), "").trim();
+ }
+
+ // The reply when there's no model brain and the message wasn't a command.
+ function degradeText() {
+ return "I can help with a few quick commands — try /help. (Conversational AI answers aren't enabled on this server.)";
+ }
+
+ // Tell the server what we are so it can badge us honestly: "ai" when the model
+ // brain is available, else "bot" (deterministic). Re-declared on every connect,
+ // so toggling the key (a redeploy) flips the badge with no admin action.
+ async function declareKind() {
+ try {
+ await fetch(`${BASE}/api/bot-config`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ token: session.token, kind: AI_ENABLED ? "ai" : "bot" }),
+ });
+ } catch (e) {
+ tag(`declareKind: ${e.message}`);
+ }
+ }
+
async function sendTyping(threadId, key, expiry = 15) {
try {
// Same shape chat.js emits (see hub.go's "typing" relay). expiry 0 is the
@@ -321,6 +355,7 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "", t
await loadConfig();
await loadAgents();
await loadThreads();
+ await declareKind();
await catchUp();
for (const d of queue.splice(0)) ws.send(d);
});
@@ -349,8 +384,23 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "", t
if (agentNames.has(p.name)) return; // bots never talk to bots (see loadAgents)
const trigger = replyTrigger(entry.kind, p.text);
if (!trigger) return;
- if (trigger === "classify" && !(await isAddressed(threadId, entry.key))) return;
- await reply(threadId, entry);
+ // The bare-name gate needs the model; with no AI there's nothing to classify.
+ if (trigger === "classify" && (!AI_ENABLED || !(await isAddressed(threadId, entry.key)))) return;
+ // Deterministic command floor first — works with or without a model brain.
+ if (commands) {
+ const res = runCommand(stripMention(p.text), { aiEnabled: AI_ENABLED, name });
+ if (res) {
+ await postReply(threadId, entry, res.text, res.actions || []);
+ return;
+ }
+ }
+ // Conversational upgrade: only when the model brain is available.
+ if (AI_ENABLED) {
+ await reply(threadId, entry);
+ return;
+ }
+ // No AI and not a command → a deterministic hint (command-capable bots only).
+ if (commands) await postReply(threadId, entry, degradeText(), []);
}
async function onFrame(data) {
diff --git a/bots_test.go b/bots_test.go
index bc7f832..ea3ab46 100644
--- a/bots_test.go
+++ b/bots_test.go
@@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"net/http"
+ "strings"
"testing"
)
@@ -299,3 +300,74 @@ func TestAdminEditBotConfig(t *testing.T) {
t.Fatalf("sidecar view: ok=%v %+v", ok, bc)
}
}
+
+// A sidecar self-declares its identity's kind ("ai"/"bot") with its OWN device
+// token; it surfaces on the admin users list to drive the AI/BOT badge. The
+// endpoint accepts only an agent's own valid token and the two known kinds, and
+// kind never clobbers (nor is clobbered by) the admin config write — both are
+// narrow upserts. Welcome text round-trips to the sidecar-facing config.
+func TestBotKindDeclareAndWelcome(t *testing.T) {
+ ts, srv := newTestServer(t)
+ uid, _ := srv.db.createUser("Eleven")
+ srv.db.createKeyInvite("inv-k", uid, "", testClaimHash, 0)
+ tok, _, _ := srv.db.claimKeyInvite("inv-k", "Eleven", "", testClaimSecret)
+ srv.db.setUserPublicKey(uid, "pub-eleven")
+ srv.db.setUserAgent(uid)
+
+ declare := func(token, kind string) int {
+ r, _ := http.Post(ts.URL+"/api/bot-config", "application/json",
+ strings.NewReader(`{"token":"`+token+`","kind":"`+kind+`"}`))
+ return r.StatusCode
+ }
+ kindOf := func(id string) string {
+ users, _ := srv.db.listUsers()
+ for _, u := range users {
+ if u.ID == id {
+ return u.Kind
+ }
+ }
+ return "?"
+ }
+
+ // Default before any declare: an agent row reads as "ai".
+ if k := kindOf(uid); k != "ai" {
+ t.Fatalf("default agent kind: want ai, got %q", k)
+ }
+ // Declare bot (Eleven running commands-only) → badge flips to BOT.
+ if s := declare(tok, "bot"); s != http.StatusOK || kindOf(uid) != "bot" {
+ t.Fatalf("declare bot: status/kind = %d/%q", s, kindOf(uid))
+ }
+ // Re-declare ai (its model brain came online) → badge flips back.
+ if s := declare(tok, "ai"); s != http.StatusOK || kindOf(uid) != "ai" {
+ t.Fatalf("re-declare ai: status/kind = %d/%q", s, kindOf(uid))
+ }
+
+ // Guards: unknown kind, a non-agent's token, and a bogus token are all refused.
+ if s := declare(tok, "watcher"); s != http.StatusBadRequest {
+ t.Fatalf("bad kind: want 400, got %d", s)
+ }
+ person, _ := srv.db.createUser("Paul")
+ srv.db.createKeyInvite("inv-p", person, "", testClaimHash, 0)
+ ptok, _, _ := srv.db.claimKeyInvite("inv-p", "Paul", "", testClaimSecret)
+ if s := declare(ptok, "bot"); s != http.StatusBadRequest {
+ t.Fatalf("non-agent declare: want 400, got %d", s)
+ }
+ if s := declare("nope", "bot"); s != http.StatusForbidden {
+ t.Fatalf("bogus token: want 403, got %d", s)
+ }
+
+ // kind and the admin config write don't clobber each other (narrow upserts),
+ // and the admin-set welcome reaches the sidecar-facing config.
+ declare(tok, "bot")
+ if r := adminPost(t, ts.URL+"/api/admin/bots/"+uid+"/config",
+ `{"system_prompt":"be brief","welcome":"Hi {name}, welcome aboard!"}`); r.StatusCode != http.StatusOK {
+ t.Fatalf("admin edit: %d", r.StatusCode)
+ }
+ if k := kindOf(uid); k != "bot" {
+ t.Fatalf("admin edit clobbered kind: got %q", k)
+ }
+ bc, ok, _ := srv.db.botConfig(uid)
+ if !ok || bc.Welcome != "Hi {name}, welcome aboard!" {
+ t.Fatalf("welcome round-trip: ok=%v %+v", ok, bc)
+ }
+}
diff --git a/changelog/2026-07-04-ai-helpers-and-commands.md b/changelog/2026-07-04-ai-helpers-and-commands.md
new file mode 100644
index 0000000..b3dde8d
--- /dev/null
+++ b/changelog/2026-07-04-ai-helpers-and-commands.md
@@ -0,0 +1 @@
+Helpers now show what they are: an **AI** badge when a helper is powered by AI (so it's clear that what you send it is answered by an AI service), or a **BOT** badge for a simple automated helper that stays on this server. And you can now ask Eleven for quick things with slash commands — try `/help`, `/theme lagoon`, `/dark`, or `/profile` — no need to spell it out in a sentence.
diff --git a/db.go b/db.go
index 33f279b..2bcd061 100644
--- a/db.go
+++ b/db.go
@@ -248,6 +248,16 @@ CREATE TABLE IF NOT EXISTS bot_configs (
// = only on an @mention (the default), 'name' = also when spoken to by
// bare name (the sidecar still gates that through a classifier).
"ALTER TABLE bot_configs ADD COLUMN listen TEXT NOT NULL DEFAULT ''",
+ // Helper kind (docs/bots.md): 'ai' (LLM-backed — messages it sees are sent
+ // to a model provider) vs 'bot' (deterministic — nothing leaves the server).
+ // The sidecar SELF-DECLARES this on connect, so Eleven reads BOT while it
+ // runs commands-only and AI once its model brain is live. Badge only; no
+ // privilege. Default 'ai' so a pre-declare agent row reads as an assistant.
+ "ALTER TABLE bot_configs ADD COLUMN kind TEXT NOT NULL DEFAULT 'ai'",
+ // Admin-tweakable welcome DM text used by the guide (Eleven's welcomer).
+ // '' = the shipped default; a literal '{name}' is replaced with the new
+ // member's name. Operator copy, like the persona — never message content.
+ "ALTER TABLE bot_configs ADD COLUMN welcome TEXT NOT NULL DEFAULT ''",
} {
if _, err := db.sql.Exec(c); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -443,6 +453,8 @@ type User struct {
PendingDM bool `json:"pending_dm"`
// IsAgent marks a bot, so the console can badge it and offer "Edit bot".
IsAgent bool `json:"is_agent,omitempty"`
+ // Kind is "ai" or "bot" for agents (drives the admin AI/BOT badge); "" for people.
+ Kind string `json:"kind,omitempty"`
}
func (db *DB) createUser(label string) (string, error) {
@@ -463,7 +475,8 @@ func (db *DB) listUsers() ([]User, error) {
(SELECT COUNT(*) FROM key_invites k WHERE k.user_id = u.id
AND k.used_at IS NULL AND (k.expires_at IS NULL OR k.expires_at > ?)),
(SELECT COUNT(*) FROM key_invites k WHERE k.user_id = u.id
- AND k.thread_id IS NOT NULL AND k.used_at IS NULL)
+ AND k.thread_id IS NOT NULL AND k.used_at IS NULL),
+ COALESCE((SELECT kind FROM bot_configs b WHERE b.user_id = u.id), 'ai')
FROM users u ORDER BY u.created_at DESC`, now)
if err != nil {
return nil, err
@@ -474,7 +487,8 @@ func (db *DB) listUsers() ([]User, error) {
var u User
var joinedAt sql.NullInt64
var pending, pendingDM, isAgent int
- if err := rows.Scan(&u.ID, &u.Label, &u.CreatedAt, &isAgent, &u.Devices, &joinedAt, &pending, &pendingDM); err != nil {
+ var kind string
+ if err := rows.Scan(&u.ID, &u.Label, &u.CreatedAt, &isAgent, &u.Devices, &joinedAt, &pending, &pendingDM, &kind); err != nil {
return nil, err
}
if joinedAt.Valid {
@@ -484,6 +498,9 @@ func (db *DB) listUsers() ([]User, error) {
u.Pending = pending > 0
u.PendingDM = pendingDM > 0
u.IsAgent = isAgent != 0
+ if u.IsAgent {
+ u.Kind = kind
+ }
out = append(out, u)
}
return out, rows.Err()
@@ -715,6 +732,9 @@ type RosterEntry struct {
// One-line admin-authored blurb for agents (the Helpers catalog entry —
// doubles as the consent hint). Always "" for people.
Description string `json:"description,omitempty"`
+ // Kind is "ai" (LLM-backed) or "bot" (deterministic) for agents; "" for
+ // people. Drives the AI/BOT badge — the honest "where do my words go?" cue.
+ Kind string `json:"kind,omitempty"`
}
func (db *DB) setUserPublicKey(userID, pub string) error {
@@ -895,7 +915,31 @@ type BotConfig struct {
Model string `json:"model"`
SystemPrompt string `json:"system_prompt"`
Docs string `json:"docs"`
- Listen string `json:"listen"` // ''/'mention' = @mention only, 'name' = also bare name
+ Listen string `json:"listen"` // ''/'mention' = @mention only, 'name' = also bare name
+ Welcome string `json:"welcome"` // admin-tweakable welcome DM (the guide uses it); "" = shipped default
+}
+
+// setBotKind records an agent's kind ("ai" | "bot"), self-declared by the sidecar
+// on connect (LLM-backed vs deterministic). A narrow upsert: it touches only the
+// kind column, so it never clobbers admin-edited config — and setBotConfig never
+// clobbers kind. Order-independent.
+func (db *DB) setBotKind(userID, kind string) error {
+ _, err := db.sql.Exec(
+ `INSERT INTO bot_configs(user_id, kind, updated_at) VALUES(?, ?, ?)
+ ON CONFLICT(user_id) DO UPDATE SET kind = excluded.kind, updated_at = excluded.updated_at`,
+ userID, kind, time.Now().Unix())
+ return err
+}
+
+// setBotWelcome stores the admin-tweakable welcome DM text (same narrow-upsert
+// discipline as setBotKind, so it composes with setBotConfig without a signature
+// change or clobbering other fields).
+func (db *DB) setBotWelcome(userID, welcome string) error {
+ _, err := db.sql.Exec(
+ `INSERT INTO bot_configs(user_id, welcome, updated_at) VALUES(?, ?, ?)
+ ON CONFLICT(user_id) DO UPDATE SET welcome = excluded.welcome, updated_at = excluded.updated_at`,
+ userID, welcome, time.Now().Unix())
+ return err
}
func (db *DB) setBotConfig(userID, model, systemPrompt, docs, listen, description string, available bool) error {
@@ -915,8 +959,8 @@ func (db *DB) setBotConfig(userID, model, systemPrompt, docs, listen, descriptio
// 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, listen FROM bot_configs WHERE user_id = ?", userID).
- Scan(&c.Model, &c.SystemPrompt, &c.Docs, &c.Listen)
+ err := db.sql.QueryRow("SELECT model, system_prompt, docs, listen, COALESCE(welcome, '') FROM bot_configs WHERE user_id = ?", userID).
+ Scan(&c.Model, &c.SystemPrompt, &c.Docs, &c.Listen, &c.Welcome)
if err == sql.ErrNoRows {
return c, false, nil
}
@@ -937,9 +981,9 @@ func (db *DB) fullBotConfig(userID string) (FullBotConfig, bool, error) {
var c FullBotConfig
var av int
err := db.sql.QueryRow(
- `SELECT model, system_prompt, docs, COALESCE(listen, ''), COALESCE(description, ''), COALESCE(available_to_members, 0)
+ `SELECT model, system_prompt, docs, COALESCE(listen, ''), COALESCE(welcome, ''), COALESCE(description, ''), COALESCE(available_to_members, 0)
FROM bot_configs WHERE user_id = ?`, userID).
- Scan(&c.Model, &c.SystemPrompt, &c.Docs, &c.Listen, &c.Description, &av)
+ Scan(&c.Model, &c.SystemPrompt, &c.Docs, &c.Listen, &c.Welcome, &c.Description, &av)
if err == sql.ErrNoRows {
return c, false, nil
}
@@ -961,7 +1005,7 @@ func (db *DB) setAvatar(userID, blobID string) error {
// they share a thread with). Kept for admin/back-end use and tests.
func (db *DB) roster() ([]RosterEntry, error) {
rows, err := db.sql.Query(
- `SELECT u.id, u.label, COALESCE(u.label_blob, ''), u.public_key, COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0), COALESCE(b.description, '')
+ `SELECT u.id, u.label, COALESCE(u.label_blob, ''), u.public_key, COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai')
FROM users u LEFT JOIN bot_configs b ON b.user_id = u.id
WHERE u.public_key IS NOT NULL AND u.public_key <> '' ORDER BY u.label`,
)
@@ -984,7 +1028,7 @@ func (db *DB) roster() ([]RosterEntry, error) {
// connections-only; only vetted, clearly-badged helpers are instance-visible.
func (db *DB) connections(userID string) ([]RosterEntry, error) {
rows, err := db.sql.Query(`
- SELECT u.id, u.label, COALESCE(u.label_blob, ''), COALESCE(u.public_key, ''), COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0), COALESCE(b.description, '')
+ SELECT u.id, u.label, COALESCE(u.label_blob, ''), COALESCE(u.public_key, ''), COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai')
FROM users u LEFT JOIN bot_configs b ON b.user_id = u.id
WHERE u.id = ?
OR u.id IN (
@@ -1007,11 +1051,14 @@ func scanRoster(rows *sql.Rows) ([]RosterEntry, error) {
for rows.Next() {
var e RosterEntry
var isAgent int
- if err := rows.Scan(&e.UserID, &e.Label, &e.LabelBlob, &e.PublicKey, &e.Avatar, &isAgent, &e.Description); err != nil {
+ var kind string
+ if err := rows.Scan(&e.UserID, &e.Label, &e.LabelBlob, &e.PublicKey, &e.Avatar, &isAgent, &e.Description, &kind); err != nil {
return nil, err
}
e.IsAgent = isAgent != 0
- if !e.IsAgent {
+ if e.IsAgent {
+ e.Kind = kind // "ai" | "bot" — badge cue; only meaningful for agents
+ } else {
e.Description = "" // description is Helpers-catalog metadata, agents only
}
out = append(out, e)
diff --git a/deploy-agent.sh b/deploy-agent.sh
index 16ecd16..154fcd5 100755
--- a/deploy-agent.sh
+++ b/deploy-agent.sh
@@ -4,18 +4,22 @@
# git stays clean.
#
# Usage:
-# LCHAT_BASE=https://<host> ANTHROPIC_API_KEY=sk-ant-... [LCHAT_INVITE=<T>] \
+# LCHAT_BASE=https://<host> [ANTHROPIC_API_KEY=sk-ant-...] [LCHAT_INVITE=<T>] \
# ./deploy-agent.sh <instance>
#
# <instance> -> app "<instance>-bot", disk "<instance>-bot-data"
#
+# ANTHROPIC_API_KEY is OPTIONAL: omit it for a deterministic, bot-only sidecar —
+# Eleven still welcomes new members and answers slash commands, badges BOT (not
+# AI), and declines free-form questions until a key is set. With a key it
+# "upgrades" to conversational AI. (Claude/Announcer are unaffected either way.)
+#
# 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"
@@ -27,7 +31,9 @@ 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")
+ENVARGS=(-e "LCHAT_BASE=$LCHAT_BASE")
+# Optional: omit for a deterministic, bot-only sidecar (see header).
+[ -n "${ANTHROPIC_API_KEY:-}" ] && ENVARGS+=(-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")
diff --git a/main.go b/main.go
index da74d0a..50e8d76 100644
--- a/main.go
+++ b/main.go
@@ -153,6 +153,7 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("POST /api/pubkey", s.setPubkey)
mux.HandleFunc("GET /api/roster", s.getRoster)
mux.HandleFunc("GET /api/bot-config", s.getBotConfig)
+ mux.HandleFunc("POST /api/bot-config", s.declareBotKind)
mux.HandleFunc("GET /api/me", s.getMe)
mux.HandleFunc("POST /api/device/signout", s.signout)
mux.HandleFunc("POST /api/apns/subscribe", s.apnsSubscribe)
@@ -1233,6 +1234,41 @@ func (s *Server) getBotConfig(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, c)
}
+// declareBotKind lets a sidecar self-report its identity's kind — "ai" (LLM-backed:
+// what it reads is sent to a model provider) or "bot" (deterministic: nothing
+// leaves the server). Authed by the bot's OWN device token, not the admin token:
+// it can only set its own kind, and only for an agent. This is why Eleven can
+// badge BOT when it runs commands-only and AI once its model brain is live —
+// the sidecar knows which, and the badge follows the truth.
+func (s *Server) declareBotKind(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ Kind string `json:"kind"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if !s.tokenOK(body.Token) {
+ httpError(w, http.StatusForbidden, "not a member")
+ return
+ }
+ uid, ok := s.db.deviceTokenUser(body.Token)
+ if !ok || !s.db.isAgent(uid) {
+ httpError(w, http.StatusBadRequest, "not a bot")
+ return
+ }
+ if body.Kind != "ai" && body.Kind != "bot" {
+ httpError(w, http.StatusBadRequest, "bad kind")
+ return
+ }
+ if err := s.db.setBotKind(uid, body.Kind); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
@@ -1424,10 +1460,20 @@ func (s *Server) setAdminBotConfig(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusBadRequest, "bad listen")
return
}
+ if len(body.Welcome) > 2000 {
+ httpError(w, http.StatusBadRequest, "welcome too long")
+ return
+ }
if err := s.db.setBotConfig(id, body.Model, body.SystemPrompt, body.Docs, body.Listen, strings.TrimSpace(body.Description), body.Available); err != nil {
httpError(w, http.StatusInternalServerError, "db error")
return
}
+ // Welcome text is a narrow upsert (see setBotWelcome) so it composes with the
+ // config write above without widening setBotConfig's signature.
+ if err := s.db.setBotWelcome(id, strings.TrimSpace(body.Welcome)); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
diff --git a/package.json b/package.json
index b83afaa..71f0f0e 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/actions.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node home/test/crypto.test.mjs"
+ "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node home/test/crypto.test.mjs"
}
}
diff --git a/test/commands.test.mjs b/test/commands.test.mjs
new file mode 100644
index 0000000..ddc90f9
--- /dev/null
+++ b/test/commands.test.mjs
@@ -0,0 +1,73 @@
+// Tests for Eleven's deterministic command floor. node test/commands.test.mjs
+import assert from "node:assert/strict";
+import { parseCommand, runCommand, commandNames, commandSummary } from "../agent/commands.mjs";
+import { sanitizeActions } from "../web/static/ui.js";
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log(`PASS ${name}`);
+ } catch (e) {
+ failures++;
+ console.log(`FAIL ${name} — ${e && e.message}`);
+ }
+}
+
+await test("parseCommand recognises registered commands", () => {
+ assert.deepEqual(parseCommand("/theme lagoon"), { name: "/theme", args: "lagoon" });
+ assert.deepEqual(parseCommand("/help"), { name: "/help", args: "" });
+ assert.deepEqual(parseCommand(" /Dark "), { name: "/dark", args: "" }); // trims + lowercases the verb
+});
+
+await test("parseCommand ignores non-commands", () => {
+ assert.equal(parseCommand("hello there"), null);
+ assert.equal(parseCommand("/unknown thing"), null); // unowned verb → falls through to chat
+ assert.equal(parseCommand(""), null);
+ assert.equal(parseCommand("not /theme"), null); // a slash mid-sentence isn't a command
+});
+
+await test("theme command emits a valid set_theme chip", () => {
+ const res = runCommand("/theme plum");
+ assert.equal(res.actions[0].type, "set_theme");
+ assert.equal(res.actions[0].theme, "plum");
+});
+
+await test("unknown theme offers the picker instead of a bad chip", () => {
+ const res = runCommand("/theme nope");
+ assert.equal(res.actions[0].type, "open_theme_picker");
+ assert.match(res.text, /lagoon/); // lists the real options
+});
+
+await test("appearance + profile commands emit their chips", () => {
+ assert.equal(runCommand("/dark").actions[0].appearance, "dark");
+ assert.equal(runCommand("/light").actions[0].appearance, "light");
+ assert.equal(runCommand("/profile").actions[0].type, "open_profile");
+});
+
+await test("every command's chips survive the client whitelist", () => {
+ // The whole point: a deterministic chip is indistinguishable from a model one,
+ // so sanitizeActions (the client's only render gate) must accept them all.
+ for (const name of commandNames()) {
+ const res = runCommand(name === "/theme" ? "/theme lagoon" : name);
+ const actions = res.actions || [];
+ assert.equal(sanitizeActions(actions).length, actions.length, `${name} produced an unrenderable chip`);
+ }
+});
+
+await test("help wording reflects whether AI is available", () => {
+ assert.match(runCommand("/help", { aiEnabled: true }).text, /ask me anything/i);
+ assert.match(runCommand("/help", { aiEnabled: false }).text, /aren't enabled/i);
+});
+
+await test("runCommand returns null for non-commands", () => {
+ assert.equal(runCommand("just chatting"), null);
+});
+
+await test("commandSummary lists every command (fed to Eleven's instructions)", () => {
+ const s = commandSummary();
+ for (const name of commandNames()) assert.match(s, new RegExp(name.replace("/", "\\/")));
+});
+
+console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/web/static/admin.js b/web/static/admin.js
index ba76314..ba75105 100644
--- a/web/static/admin.js
+++ b/web/static/admin.js
@@ -201,7 +201,8 @@ function renderUsers(users) {
label.textContent = u.is_agent ? u.label || "(bot)" : "Pending invite";
if (u.is_agent) {
const tag = document.createElement("span");
- tag.textContent = "BOT";
+ // AI = LLM-backed (what it sees goes to a model provider); BOT = deterministic.
+ tag.textContent = u.kind === "ai" ? "AI" : "BOT";
tag.className = "bot-tag";
label.appendChild(tag);
}
@@ -317,6 +318,9 @@ async function showEditBotModal(u) {
const docs = field(document.createElement("textarea"), cfg.docs);
docs.rows = 3;
docs.placeholder = "Reference the bot should always know. Optional.";
+ const welcome = field(document.createElement("textarea"), cfg.welcome);
+ welcome.rows = 3;
+ welcome.placeholder = "Welcome DM sent to new members (the guide sends this). Use {name} for their name. Blank = default.";
const listen = field(document.createElement("select"), "");
for (const [v, t] of [
["", "Replies in groups only when @mentioned (default)"],
@@ -369,6 +373,7 @@ async function showEditBotModal(u) {
docs: docs.value.trim(),
listen: listen.value,
description: desc.value.trim(),
+ welcome: welcome.value.trim(),
available_to_members: avail.checked,
});
if (r.ok) {
diff --git a/web/static/chat.js b/web/static/chat.js
index bb00baa..d97e4a4 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -622,10 +622,13 @@ function rosterLabel(userId) {
// you add it that it isn't a person. Self-contained styling (no CSS dependency).
// Accent by default (a discovery cue in pickers/lists); pass muted=true inline
// on messages, where it repeats on every reply and bright accent reads as noise.
-function agentTag(muted) {
+function agentTag(muted, kind) {
const t = document.createElement("span");
- t.textContent = "BOT";
- t.title = "This is an agent (bot), not a person";
+ const ai = kind === "ai";
+ t.textContent = ai ? "AI" : "BOT";
+ t.title = ai
+ ? "An AI helper — what you send it is sent to an AI provider to answer"
+ : "An automated helper (bot), not a person — it runs on this server";
t.style.cssText =
"margin-left:6px;font-size:0.7em;font-weight:600;letter-spacing:0.03em;padding:1px 5px;border-radius:6px;vertical-align:middle;transform:translateY(-1px);" +
(muted ? "background:var(--border,#ddd);color:var(--muted,#666);" : "background:var(--accent,#888);color:#fff;");
@@ -638,7 +641,7 @@ function pickerName(e) {
const nm = document.createElement("span");
nm.textContent = e.label;
if (e.is_agent) {
- nm.appendChild(agentTag());
+ nm.appendChild(agentTag(false, e.kind));
if (e.description) {
const d = document.createElement("span");
d.className = "helper-desc";
@@ -1880,7 +1883,8 @@ async function appendMessage(threadId, m, scroll = true, container = null, cache
// on every message, not just in the picker. Sender names aren't signed, so
// this is matched against the roster's agent labels (same trust level as
// the name shown beside it).
- if (roster.some((e) => e.is_agent && e.label === data.name)) nm.appendChild(agentTag(true));
+ const ag = roster.find((e) => e.is_agent && e.label === data.name);
+ if (ag) nm.appendChild(agentTag(true, ag.kind));
wrap.appendChild(nm);
}
// The bubble + its quote/image/preview + hover icons + exact time live in
@@ -4047,7 +4051,7 @@ function showMembers() {
lbl.textContent = me ? `${name} (you)` : name;
// Transparency: a bot in the room stays visibly a bot, not just in the picker.
const entry = roster.find((x) => x.user_id === uid);
- if (entry && entry.is_agent) lbl.appendChild(agentTag());
+ if (entry && entry.is_agent) lbl.appendChild(agentTag(false, entry.kind));
main.appendChild(lbl);
if (!me)
main.onclick = async () => {