eleven / ocho Public

Action chips: ask Eleven to do things; BOT badges on group messages; co-bot awareness (#61)

* web+agent: action chips (Eleven does things), BOT name badges, co-bot awareness

- Action chips: Eleven gets Claude tools (agent/actions.mjs) — asking it to
  "switch to dark mode" / "make it purple" / open the theme picker or your
  profile yields a one-tap button under its reply. Chips are consent-based:
  tool_use blocks ride the encrypted payload as `actions`, and the client
  runs one ONLY when tapped, through the ui.js whitelist (sanitizeActions:
  known types, valid params, capped count) — payloads aren't sender-signed,
  so every runnable action is harmless and reversible by design.
- Group messages now badge bot senders (agentTag on the name label), so
  who's a bot is visible on every message, not just the picker.
- Bots know their fellow helpers: the system prompt names the instance's
  other agents (from the roster set the crosstalk fix already tracks), so
  Claude no longer publicly doubts Eleven's legitimacy in shared groups.
- Tests: sanitizeActions (ui.test) + actions mapping incl. a whitelist-parity
  check between sidecar and client (test/actions.test.mjs, added to npm test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* agent: bots call the app by its product name, Eleven

APP_NAME in config.mjs (env LCHAT_APP_NAME, default "Eleven") feeds the
safety preamble; Eleven's guide persona/docs follow, including owning the
name collision with the app itself. lchat stays the project/repo name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* agent: chips must not vanish — require the tool call, widen max_tokens

Context is rebuilt from message text only, so the model saw its own past
chip replies as plain "tap to switch" texts and imitated them without
calling the tool. Make the rule explicit in the system prompt, and raise
max_tokens 1024->2048 so thinking + text + tool_use can't truncate the
tool call off the end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* agent: raise reply token cap to 4096 and log dropped empty replies

An empty response (e.g. output truncated at max_tokens mid-tool-call)
was silently dropped — no post, no log. Log it with the stop_reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* agent: annotate own chip replies in rebuilt context (kills imitation drift)

Context is text-only, so past chip replies looked like bare "tap to
switch" texts and the model imitated them without calling the tool.
Append an automatic "[note to self: attached N button(s)]" marker to the
bot's own action-bearing turns so history never shows bare text
"attaching" a button; the system prompt says never to write the marker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: mute the inline BOT badge on messages (gray, not accent)

It repeats on every bot reply, where bright accent reads as noise; the
picker/members badge keeps the accent as a discovery cue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* agent: force the tool call when a reply promises a button without one

Threads full of pre-annotation chip texts still bait the model into
"tap to switch" with no tool call — prompt rules lose to dozens of
in-context counter-examples. The promise is detectable, so make it
deterministic: retry once with tool_choice "any" (thinking off, as
forced tool choice requires).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 105c1b1a44fbc78620be84004a47f966105831db parent df93079
13 files changed, +329 −15
  • agent/actions.mjs +63 −0
  • agent/agent.mjs +2 −1
  • agent/config.mjs +3 −0
  • agent/eleven.mjs +8 −3
  • agent/runtime.mjs +73 −8
  • changelog/2026-07-02-action-chips.md +1 −0
  • changelog/2026-07-02-bot-name-badge.md +1 −0
  • package.json +1 −1
  • test/actions.test.mjs +47 −0
  • test/ui.test.mjs +24 −0
  • web/static/chat.js +60 −2
  • web/static/style.css +9 −0
  • web/static/ui.js +37 −0
diff --git a/agent/actions.mjs b/agent/actions.mjs
new file mode 100644
index 0000000..5496082
--- /dev/null
+++ b/agent/actions.mjs
@@ -0,0 +1,63 @@
+// Action chips: the Claude tools a helper may call, and the mapping from the
+// model's tool_use blocks to the `actions` array shipped inside the encrypted
+// message payload. The CLIENT is the whitelist that matters (ui.js
+// sanitizeActions) — this file only decides what the model can *propose*.
+// Chips never auto-run: the member taps to apply, which is why the set is
+// 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"];
+
+export const CHIP_TOOLS = [
+ {
+ name: "set_theme",
+ description:
+ "Attach a one-tap button that switches the member's colour theme when THEY tap it. " +
+ "Themes: litroom (warm amber 'Lit Room'), lagoon (teal), sunny (yellow), plum (purple), " +
+ "linen (soft neutral), classic (blue). Use when someone asks to change the look/colours.",
+ input_schema: {
+ type: "object",
+ properties: { theme: { type: "string", enum: THEMES } },
+ required: ["theme"],
+ },
+ },
+ {
+ name: "set_appearance",
+ description:
+ "Attach a one-tap button that switches light/dark mode when the member taps it. " +
+ "'system' follows their device. Use when someone asks for light mode, dark mode, etc.",
+ input_schema: {
+ type: "object",
+ properties: { appearance: { type: "string", enum: ["light", "dark", "system"] } },
+ required: ["appearance"],
+ },
+ },
+ {
+ name: "open_theme_picker",
+ description:
+ "Attach a one-tap button that opens the theme picker, for when they want to browse the options.",
+ input_schema: { type: "object", properties: {} },
+ },
+ {
+ name: "open_profile",
+ description:
+ "Attach a one-tap button that opens the member's own profile editor (screen name + photo).",
+ input_schema: { type: "object", properties: {} },
+ },
+];
+
+// actionsFromToolUse maps the response's tool_use blocks to payload actions.
+// Unknown tools and malformed inputs are dropped (the client re-validates too).
+export function actionsFromToolUse(content) {
+ const out = [];
+ for (const b of 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))
+ 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" });
+ }
+ return out;
+}
diff --git a/agent/agent.mjs b/agent/agent.mjs
index 827679a..18aad28 100644
--- a/agent/agent.mjs
+++ b/agent/agent.mjs
@@ -17,6 +17,7 @@ import { BASE, AGENT_NAME, requireBase, loadSession } from "./config.mjs";
import { provision } from "./provision.mjs";
import { runBot } from "./runtime.mjs";
import { startAnnouncer } from "./announcer.mjs";
+import { CHIP_TOOLS } from "./actions.mjs";
import { ELEVEN_NAME, ELEVEN_PERSONA, ELEVEN_DOCS, loadElevenSession, startWelcomer } from "./eleven.mjs";
// Synchronous logging: async stdout to a pipe is buffered and lost if the
@@ -72,7 +73,7 @@ try {
process.exit(1);
}
if (elevenSession) {
- runBot({ name: ELEVEN_NAME, session: elevenSession, builtinPersona: ELEVEN_PERSONA, builtinDocs: ELEVEN_DOCS });
+ runBot({ name: ELEVEN_NAME, session: elevenSession, builtinPersona: ELEVEN_PERSONA, builtinDocs: ELEVEN_DOCS, tools: CHIP_TOOLS });
startWelcomer();
}
diff --git a/agent/config.mjs b/agent/config.mjs
index 3c43a03..78d6d08 100644
--- a/agent/config.mjs
+++ b/agent/config.mjs
@@ -12,6 +12,9 @@ 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";
+// The app's user-facing name (the product is called Eleven; lchat is the
+// project/repo name). Bots refer to the app by this.
+export const APP_NAME = process.env.LCHAT_APP_NAME || "Eleven";
export const MODEL = process.env.LCHAT_MODEL || "claude-sonnet-5";
// The bare-name "am I being addressed?" gate in runtime.mjs — a tiny yes/no
// call, so it always uses the cheapest model regardless of the bot's MODEL.
diff --git a/agent/eleven.mjs b/agent/eleven.mjs
index b5f6c21..6c7b983 100644
--- a/agent/eleven.mjs
+++ b/agent/eleven.mjs
@@ -35,19 +35,24 @@ const POLL_MS = Number(process.env.LCHAT_WELCOME_POLL_MS) || 60 * 1000;
// Shipped-in persona + app knowledge (console config can still override per
// instance). Update these alongside features — they deploy with the sidecar.
export const ELEVEN_PERSONA =
- "You are the app's friendly guide. Help people get the most out of lchat: answer questions about " +
+ "You are the app's friendly guide. The app is called Eleven — yes, you share its name; own the joke " +
+ "if it comes up. Help people get the most out of it: answer questions about " +
"how the app works, suggest features they might not know, and keep answers concrete and step-by-step. " +
+ "You can DO some things for people via one-tap buttons: when someone asks to change the theme, switch " +
+ "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.' " +
"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 = [
- "lchat basics: every chat is end-to-end encrypted — the server (and its operator) can never read messages.",
+ "Eleven (the app) basics: every chat is end-to-end encrypted — the server (and its operator) can never read messages.",
"Start a chat: tap ➕, pick a person for a DM, or 'New group' to pick several people and name the group.",
"Helpers: bots live under the Helpers section of the ➕ picker; each says what it does. @mention a bot (@Claude) to get its attention in a group; in a DM it always replies.",
"Invite someone new: if the admin enabled member invites, ➕ → 'Invite someone' creates a private link — share it only with that person, since the link can read the chat it opens.",
"Groups: tap the group name for members; add people via the menu; swipe right on a message (or use the message menu) to reply; double-tap (touch) or press-and-hold (desktop) to react.",
"Photos & files: the paperclip attaches them; images are shrunk and stripped of location data before upload, and everything is encrypted.",
"Profile: tap your avatar to set a photo; it's encrypted so only members see it.",
- "Themes: the ⋮ menu has a Theme picker with light/dark and colour options.",
+ "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.",
].join("\n");
diff --git a/agent/runtime.mjs b/agent/runtime.mjs
index f383528..941baaa 100644
--- a/agent/runtime.mjs
+++ b/agent/runtime.mjs
@@ -8,7 +8,8 @@ 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 } from "./config.mjs";
+import { BASE, WS_BASE, MODEL, CLASSIFIER_MODEL, APP_NAME } from "./config.mjs";
+import { actionsFromToolUse } from "./actions.mjs";
const log = (m) => {
try {
@@ -22,8 +23,8 @@ const api = () => (claude ??= new Anthropic()); // reads ANTHROPIC_API_KEY from
// A non-overridable safety + app-awareness preamble is always applied, then the
// bot's persona (console config > builtin > env > default), then reference docs.
const SAFETY = (name) =>
- `You are ${name}, an AI assistant and a member of lchat — a small, private, ` +
- `end-to-end encrypted group chat; the server and its operator can't read messages, ` +
+ `You are ${name}, an AI assistant and a member of ${APP_NAME} — a small, private, ` +
+ `end-to-end encrypted group chat app; 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 only when someone addresses you directly (usually an @mention). ` +
`You can only take part in the conversation — no reminders, notifications, files, or web access — ` +
@@ -34,8 +35,10 @@ const DEFAULT_PERSONA = "Keep replies short, warm, and easy to read.";
// runBot starts one bot identity: { name, session, builtinPersona?, builtinDocs? }.
// builtin* are code-shipped defaults (e.g. Eleven's app guide) that console
-// config can still override per instance.
-export function runBot({ name, session, builtinPersona = "", builtinDocs = "" }) {
+// 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 }) {
let persona = builtinPersona || process.env.LCHAT_SYSTEM || DEFAULT_PERSONA;
let model = MODEL;
let docs = builtinDocs;
@@ -48,6 +51,28 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "" })
function systemPrompt() {
let s = `${SAFETY(name)}\n\n${persona}`;
+ // Bots know who their fellow helpers are — without this, one bot seeing
+ // another's message treats it as an impostor (Claude once publicly doubted
+ // Eleven's legitimacy). They still never reply to each other (processMsg).
+ const others = [...agentNames].filter((n) => n !== name);
+ if (others.length)
+ s +=
+ `\n\nOther helper bots are members of this server alongside you: ${others.join(", ")}. ` +
+ `They are legitimate, separately-run assistants (Eleven guides people around the app; ` +
+ `Announcer posts release notes; Claude is a general companion). You may see their messages ` +
+ `in shared groups; you never reply to them, and there's no need to question their presence.`;
+ // Context is rebuilt from message TEXT only, so the model sees its own past
+ // chip replies as bare "tap to switch" texts and starts imitating them
+ // without calling the tool — the button silently vanishes. Make the rule
+ // unconditional: promising a button REQUIRES the tool call, every time.
+ if (tools)
+ s +=
+ `\n\nYour buttons only exist when you call the matching tool: every reply that offers ` +
+ `a one-tap action ("tap to switch", "here you go") MUST include that tool call — text alone ` +
+ `attaches nothing, even if earlier replies in the conversation look like plain text. ` +
+ `Never tell someone a button is attached without calling the tool in that same reply. ` +
+ `"[note to self: …]" lines in the history are added automatically where you called tools ` +
+ `before — never write them yourself.`;
if (docs) s += `\n\nAlways keep this reference in mind:\n${docs}`;
return s;
}
@@ -174,7 +199,13 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "" })
// Self-identity is the configured name, not the session file's record — so
// renaming a bot is an env/default change + roster relabel, no re-provision.
const mine = p.name === name;
- out.push({ role: mine ? "assistant" : "user", content: mine ? p.text : `${p.name}: ${p.text}` });
+ let content = mine ? p.text : `${p.name}: ${p.text}`;
+ // Keep history truthful about tool calls: without this note the model
+ // sees its own past chip replies as bare text, learns that bare text
+ // "worked", and starts skipping the tool call — the chip vanishes.
+ if (mine && Array.isArray(p.actions) && p.actions.length)
+ content += `\n[note to self: this reply attached ${p.actions.length} button(s) via tool calls]`;
+ out.push({ role: mine ? "assistant" : "user", content });
}
// The API requires the first turn to be `user`.
while (out.length && out[0].role === "assistant") out.shift();
@@ -185,12 +216,16 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "" })
sendTyping(threadId, entry.key); // best-effort "…is typing" hint
const context = await buildContext(threadId, entry.key);
let text;
+ let actions = [];
try {
const r = await api().messages.create({
model,
- max_tokens: 1024,
+ // Thinking, text and any tool call all share this budget — a tight cap
+ // truncates the tool_use off the end and the chip silently vanishes.
+ max_tokens: 4096,
system: systemPrompt(),
thinking: { type: "adaptive" },
+ ...(tools ? { tools } : {}),
messages: context.length ? context : [{ role: "user", content: "Say hello." }],
});
text = r.content
@@ -198,12 +233,42 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "" })
.map((b) => b.text)
.join("")
.trim();
+ // Tool calls become one-tap chips on the message; there's no tool_result
+ // round-trip — the "result" is the member tapping (or not tapping) it.
+ if (tools) actions = actionsFromToolUse(r.content);
+ // Self-heal the promised-button case. Threads full of pre-annotation chip
+ // texts still bait the model into saying "tap to switch" WITHOUT the tool
+ // call (system-prompt rules lose to 25 in-context counter-examples). The
+ // promise is detectable, so make it deterministic: ask once more with a
+ // forced tool call. Forced tool_choice requires thinking off.
+ if (tools && !actions.length && /\b(tap|button|chip)\b/i.test(text || "")) {
+ tag(`reply promised a button without a tool call — retrying forced`);
+ const r2 = await api().messages.create({
+ model,
+ max_tokens: 4096,
+ system: systemPrompt(),
+ thinking: { type: "disabled" },
+ tools,
+ tool_choice: { type: "any" },
+ messages: context.length ? context : [{ role: "user", content: "Say hello." }],
+ });
+ actions = actionsFromToolUse(r2.content);
+ const t2 = r2.content
+ .filter((b) => b.type === "text")
+ .map((b) => b.text)
+ .join("")
+ .trim();
+ if (actions.length && t2) text = t2; // keep the original text if the retry was tool-only
+ }
+ if (!text && !actions.length) tag(`empty reply dropped (stop_reason=${r.stop_reason})`);
} catch (e) {
console.error(`[${name}] Claude error:`, e.message);
return;
}
+ if (!text && actions.length) text = "Tap to apply:";
if (!text) return;
- send({ type: "msg", thread_id: threadId, blob: await encrypt(entry.key, { name, text, ts: Date.now() }) });
+ const payload = { name, text, ts: Date.now(), ...(actions.length ? { actions } : {}) };
+ send({ type: "msg", thread_id: threadId, blob: await encrypt(entry.key, payload) });
}
async function sendTyping(threadId, key) {
diff --git a/changelog/2026-07-02-action-chips.md b/changelog/2026-07-02-action-chips.md
new file mode 100644
index 0000000..403fcca
--- /dev/null
+++ b/changelog/2026-07-02-action-chips.md
@@ -0,0 +1 @@
+Ask Eleven to change your look — "switch to dark mode" or "make it purple" gets you a one-tap button that applies it.
diff --git a/changelog/2026-07-02-bot-name-badge.md b/changelog/2026-07-02-bot-name-badge.md
new file mode 100644
index 0000000..6452495
--- /dev/null
+++ b/changelog/2026-07-02-bot-name-badge.md
@@ -0,0 +1 @@
+Bots are now labelled with a BOT badge next to their name on group messages.
diff --git a/package.json b/package.json
index 78bd5cf..df551fe 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"
+ "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"
}
}
diff --git a/test/actions.test.mjs b/test/actions.test.mjs
new file mode 100644
index 0000000..9c9f862
--- /dev/null
+++ b/test/actions.test.mjs
@@ -0,0 +1,47 @@
+// Tests for the sidecar's tool_use -> action-chip mapping. node test/actions.test.mjs
+import assert from "node:assert/strict";
+import { actionsFromToolUse, CHIP_TOOLS } from "../agent/actions.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.message.split("\n")[0]}`);
+ }
+}
+
+await test("maps tool_use blocks to actions, dropping junk", () => {
+ const actions = actionsFromToolUse([
+ { type: "text", text: "Here you go —" },
+ { type: "tool_use", name: "set_theme", input: { theme: "plum" } },
+ { type: "tool_use", name: "set_theme", input: { theme: "neon" } }, // invalid theme
+ { type: "tool_use", name: "set_appearance", input: { appearance: "dark" } },
+ { type: "tool_use", name: "rm_rf", input: {} }, // not a tool we offer
+ { type: "tool_use", name: "open_profile", input: {} },
+ ]);
+ assert.deepEqual(actions, [
+ { type: "set_theme", theme: "plum" },
+ { type: "set_appearance", appearance: "dark" },
+ { type: "open_profile" },
+ ]);
+});
+
+await test("every action the sidecar can emit survives the client whitelist", () => {
+ // The two whitelists (agent/actions.mjs and ui.js) must stay in step: a chip
+ // Eleven proposes that the client would drop is a silent no-op button.
+ const emitted = actionsFromToolUse([
+ { type: "tool_use", name: "set_theme", input: { theme: "litroom" } },
+ { type: "tool_use", name: "set_appearance", input: { appearance: "system" } },
+ { type: "tool_use", name: "open_theme_picker", input: {} },
+ { type: "tool_use", name: "open_profile", input: {} },
+ ]);
+ assert.equal(emitted.length, CHIP_TOOLS.length);
+ assert.equal(sanitizeActions(emitted).length, emitted.length);
+});
+
+console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/test/ui.test.mjs b/test/ui.test.mjs
index 1c93778..55b3a4b 100644
--- a/test/ui.test.mjs
+++ b/test/ui.test.mjs
@@ -15,6 +15,7 @@ import {
memberCountLabel,
DEFAULT_THEME,
DEFAULT_APPEARANCE,
+ sanitizeActions,
} from "../web/static/ui.js";
let failures = 0;
@@ -164,6 +165,29 @@ await test("ownMessage: never matches on empty identity", () => {
assert.equal(ownMessage({}, "u1", "Paul"), false);
});
+await test("sanitizeActions: whitelists types and validates params", () => {
+ const chips = sanitizeActions([
+ { type: "set_theme", theme: "plum" },
+ { type: "set_theme", theme: "neon" }, // not a real theme
+ { type: "set_appearance", appearance: "dark", label: " Go dark " },
+ { type: "delete_everything" }, // unknown type
+ { type: "open_profile" },
+ "junk",
+ ]);
+ assert.deepEqual(chips.map((c) => c.type), ["set_theme", "set_appearance", "open_profile"]);
+ assert.equal(chips[0].theme, "plum");
+ assert.equal(chips[1].label, "Go dark");
+});
+
+await test("sanitizeActions: caps the count and tolerates garbage", () => {
+ assert.deepEqual(sanitizeActions(null), []);
+ assert.deepEqual(sanitizeActions("x"), []);
+ const many = Array.from({ length: 9 }, () => ({ type: "open_theme_picker" }));
+ assert.equal(sanitizeActions(many).length, 4);
+ const longLabel = sanitizeActions([{ type: "open_profile", label: "x".repeat(99) }]);
+ assert.equal(longLabel[0].label.length, 40);
+});
+
await test("memberCountLabel: people and bots counted separately", () => {
assert.equal(memberCountLabel(2, 3), "2 members, 3 bots");
assert.equal(memberCountLabel(1, 1), "1 member, 1 bot");
diff --git a/web/static/chat.js b/web/static/chat.js
index e45011b..499d277 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -30,6 +30,7 @@ import {
composerToken,
pinnedFirst,
ownMessage,
+ sanitizeActions,
memberCountLabel,
} from "/static/ui.js";
import { richTokens, blockTokens, firstLink, typingSummary, replySnippet, formatBytes, autoGroupTitle, emojiOnlyCount, youtubeId } from "/static/format.js";
@@ -101,6 +102,51 @@ matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
});
applyTheme(currentTheme(), currentAppearance());
+// ---- action chips ---------------------------------------------------------
+// One-tap buttons a helper attaches to its reply ("Switch to dark" etc.).
+// Consent-based by design: nothing runs until the member taps — payloads
+// aren't sender-signed, so a chip is a *suggestion*, and the whitelist in
+// ui.js keeps every runnable action harmless and reversible.
+const CHIP_RUNNERS = {
+ set_theme: (a) => {
+ setTheme(a.theme);
+ return `${THEME_META[a.theme].name} ✓`;
+ },
+ set_appearance: (a) => {
+ setAppearance(a.appearance);
+ return { light: "Light ✓", dark: "Dark ✓", system: "Following your device ✓" }[a.appearance];
+ },
+ open_theme_picker: () => (showThemePicker(), null),
+ open_profile: () => (editProfile(), null),
+};
+const chipLabel = (a) =>
+ a.label ||
+ {
+ set_theme: `Switch to ${(THEME_META[a.theme] || {}).name || a.theme}`,
+ set_appearance: { light: "Switch to light", dark: "Switch to dark", system: "Follow my device" }[a.appearance],
+ open_theme_picker: "Open theme picker",
+ open_profile: "Edit my profile",
+ }[a.type];
+
+function buildActionChips(actions) {
+ const row = document.createElement("div");
+ row.className = "chip-row";
+ for (const a of actions) {
+ const b = document.createElement("button");
+ b.className = "action-chip";
+ b.textContent = chipLabel(a);
+ b.onclick = () => {
+ const done = CHIP_RUNNERS[a.type](a);
+ if (done) {
+ b.textContent = done; // applied in place — show it stuck
+ b.disabled = true;
+ }
+ };
+ row.appendChild(b);
+ }
+ return row;
+}
+
// Theme picker sheet (kebab → Theme): an appearance segmented control + a grid
// of colour swatches. Both apply live and persist; nothing leaves the browser.
function showThemePicker() {
@@ -489,12 +535,15 @@ function rosterLabel(userId) {
// A small "BOT" tag to mark an agent in the roster picker, so it's clear before
// you add it that it isn't a person. Self-contained styling (no CSS dependency).
-function agentTag() {
+// 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) {
const t = document.createElement("span");
t.textContent = "BOT";
t.title = "This is an agent (bot), not a person";
t.style.cssText =
- "margin-left:6px;font-size:0.7em;font-weight:600;letter-spacing:0.03em;padding:1px 5px;border-radius:6px;background:var(--accent,#888);color:#fff;vertical-align:middle;";
+ "margin-left:6px;font-size:0.7em;font-weight:600;letter-spacing:0.03em;padding:1px 5px;border-radius:6px;vertical-align:middle;" +
+ (muted ? "background:var(--border,#ddd);color:var(--muted,#666);" : "background:var(--accent,#888);color:#fff;");
return t;
}
@@ -1623,6 +1672,11 @@ async function appendMessage(threadId, m, scroll = true, container = null, cache
const nm = document.createElement("div");
nm.className = "name";
nm.textContent = data.name || "";
+ // Badge bot senders on the name label itself, so "who's a bot" is visible
+ // 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));
wrap.appendChild(nm);
}
// The bubble + its quote/image/preview + hover icons + exact time live in
@@ -1680,6 +1734,10 @@ async function appendMessage(threadId, m, scroll = true, container = null, cache
if (yt) content.appendChild(buildYouTubeCard(yt));
else if (link) addLinkPreview(content, link);
}
+ // Helper-proposed one-tap actions render as chips under the bubble (both the
+ // history and live paths come through here). sanitizeActions whitelists.
+ const chips = sanitizeActions(data.actions);
+ if (chips.length) content.appendChild(buildActionChips(chips));
// Reply/edit affordances: desktop hover icons (reply on any message; edit on
// your own in-window ones), touch swipe-right to reply. The press-hold /
// double-tap picker also offers both, via the replyMeta stored in msgData.
diff --git a/web/static/style.css b/web/static/style.css
index 1605805..3be2160 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -647,6 +647,15 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
}
.preview-remove svg { width: 13px; height: 13px; }
+/* Action chips: one-tap buttons a helper attaches under its bubble */
+.chip-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
+.action-chip {
+ background: var(--panel); color: var(--link); border: 1px solid var(--border);
+ border-radius: 14px; padding: 7px 14px; font-size: 14px; font-weight: 600; cursor: pointer;
+}
+.action-chip:hover:not(:disabled) { background: var(--bubble-them); }
+.action-chip:disabled { color: var(--muted); cursor: default; }
+
/* Text selection works everywhere now: desktop reacts with a stationary hold
(drag-select cancels it) and touch reacts with a double-tap, so long-press is
free for native selection/copy on mobile. */
diff --git a/web/static/ui.js b/web/static/ui.js
index f145e45..eb6f748 100644
--- a/web/static/ui.js
+++ b/web/static/ui.js
@@ -140,3 +140,40 @@ export function fileIcon(name) {
const t = FILE_TYPES[fileExt(name)];
return t ? t.icon : "📎";
}
+
+// ---- action chips ---------------------------------------------------------
+// Helpers (bots) may attach proposed actions to a message payload — the client
+// renders them as one-tap chips under the bubble. Chips are CONSENT-BASED: a
+// chip only runs when the member taps it, and this whitelist is the only thing
+// the client will render — unknown types and invalid params are dropped.
+// Payloads aren't sender-signed (yet), so any member could craft one: every
+// action here must therefore be harmless and reversible (switch a theme, open
+// a panel) — never destructive, never data-revealing.
+export const CHIP_TYPES = {
+ set_theme: { param: "theme", valid: (v) => THEMES.includes(v) },
+ set_appearance: { param: "appearance", valid: (v) => APPEARANCES.includes(v) },
+ open_theme_picker: {},
+ open_profile: {},
+};
+
+// sanitizeActions coerces an untrusted payload `actions` array to renderable
+// chips: whitelist the type, validate the param, cap the count and label size.
+export function sanitizeActions(actions) {
+ if (!Array.isArray(actions)) return [];
+ const out = [];
+ for (const a of actions) {
+ if (out.length === 4) break; // a message is not a menu
+ if (!a || typeof a !== "object") continue;
+ const spec = CHIP_TYPES[a.type];
+ if (!spec) continue;
+ const chip = { type: a.type };
+ if (spec.param) {
+ const v = a[spec.param];
+ if (typeof v !== "string" || !spec.valid(v)) continue;
+ chip[spec.param] = v;
+ }
+ if (typeof a.label === "string" && a.label.trim()) chip.label = a.label.trim().slice(0, 40);
+ out.push(chip);
+ }
+ return out;
+}