BMO: a living desktop buddy with a Claude brain

Standalone React + Vite app where BMO lives in a little garden:
- Redrawn avatar (src/avatar) with depth, cartoon controls and expressive
  faces; original bmo-preview avatar and scenery kept in src/bmo
- Idle life: wandering, chatter, boredom, sleep, pokes, weather and
  time-of-day reactions
- Chat via a local Node server streaming Claude replies (Haiku by default),
  with live scene awareness and tools to change color, outfit, sky,
  weather, move and perform
- Side menu for API key, appearance, environment and chat log

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 23:05:50 -04:00
commit 0f027e863b
44 changed files with 7369 additions and 0 deletions
+354
View File
@@ -0,0 +1,354 @@
// Tiny HTTP server for BMO's brain. POST /api/chat streams a reply as
// server-sent events; /api/brain reads and sets the API key from the side
// menu. In production it also serves the built SPA from dist/.
//
// The API key stays on this side; the browser never sees it.
import { createReadStream, existsSync, statSync } from "node:fs";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import Anthropic from "@anthropic-ai/sdk";
import { currentKey, keyHint, looksLikeKey, storeKey } from "./keystore.ts";
import { BMO_SYSTEM_PROMPT, MOODS, type Mood } from "./persona.ts";
import { BMO_TOOLS, describeAction, describeScene, parseScene, toAction } from "./tools.ts";
const PORT = Number(process.env.BMO_PORT ?? 4310);
// Haiku while testing: fast and cheap. Set BMO_MODEL=claude-opus-5-5 for the big brain.
const MODEL = process.env.BMO_MODEL?.trim() || "claude-haiku-5-5";
// Server-side refusal fallback only exists on the larger models; Haiku rejects it.
const SUPPORTS_FALLBACK = /^claude-(opus-5|opus-5-5|fable-5-1|sonnet-5-5)$/.test(MODEL);
const MAX_HISTORY = 40;
const MAX_MESSAGE_CHARS = 4000;
const here = path.dirname(fileURLToPath(import.meta.url));
const distDir = path.resolve(here, "../dist");
// Rebuilt whenever the key changes from the side menu.
let client = new Anthropic();
type ChatTurn = { role: "user" | "assistant"; content: string };
function hasCredentials() {
return Boolean(currentKey() || process.env.ANTHROPIC_AUTH_TOKEN);
}
/**
* Only same-machine pages may call the API. Browsers always send Origin on
* cross-site POSTs, so this stops other websites from spending your credits
* or swapping your key through this local server.
*/
function isTrustedRequest(req: IncomingMessage) {
const origin = req.headers.origin;
if (origin) {
let host: string;
try {
host = new URL(origin).hostname;
} catch {
return false;
}
if (host !== "localhost" && host !== "127.0.0.1" && host !== "[::1]") return false;
}
return (req.headers["content-type"] ?? "").startsWith("application/json");
}
function sendJson(res: ServerResponse, status: number, body: unknown) {
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(body));
}
function brainStatus() {
return { connected: hasCredentials(), keyHint: keyHint(), model: MODEL };
}
async function handleSetKey(req: IncomingMessage, res: ServerResponse) {
let body: unknown;
try {
body = await readJson(req);
} catch {
return sendJson(res, 400, { error: "Bad request." });
}
const raw = (body as { apiKey?: unknown } | null)?.apiKey;
if (raw === null) {
storeKey(null);
client = new Anthropic();
return sendJson(res, 200, brainStatus());
}
const key = typeof raw === "string" ? raw.trim() : "";
if (!looksLikeKey(key)) {
return sendJson(res, 400, { error: "That doesn't look like an Anthropic API key. It should start with sk-ant-." });
}
// Try the key before saving it, with a free metadata call.
const candidate = new Anthropic({ apiKey: key });
try {
await candidate.models.retrieve(MODEL);
} catch (err) {
if (err instanceof Anthropic.AuthenticationError) {
return sendJson(res, 400, { error: "Anthropic rejected that key. Double-check you copied all of it." });
}
if (err instanceof Anthropic.PermissionDeniedError || err instanceof Anthropic.NotFoundError) {
return sendJson(res, 400, { error: `That key can't use ${MODEL}.` });
}
if (err instanceof Anthropic.APIConnectionError) {
return sendJson(res, 502, { error: "Couldn't reach Anthropic to check the key. Are you online?" });
}
// Rate limits or server hiccups don't mean the key is bad; save it anyway.
console.warn("[bmo] key check inconclusive:", err);
}
storeKey(key);
client = candidate;
console.log(`[bmo] API key saved (${keyHint(key)})`);
sendJson(res, 200, brainStatus());
}
function readJson(req: IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
if (body.length > 200_000) reject(new Error("body too large"));
});
req.on("end", () => {
try {
resolve(JSON.parse(body || "{}"));
} catch (err) {
reject(err);
}
});
req.on("error", reject);
});
}
function parseHistory(input: unknown): ChatTurn[] | null {
if (!input || typeof input !== "object" || !Array.isArray((input as { messages?: unknown }).messages)) {
return null;
}
const raw = (input as { messages: unknown[] }).messages;
const turns: ChatTurn[] = [];
for (const m of raw) {
if (!m || typeof m !== "object") return null;
const { role, content } = m as { role?: unknown; content?: unknown };
if ((role !== "user" && role !== "assistant") || typeof content !== "string") return null;
turns.push({ role, content: content.slice(0, MAX_MESSAGE_CHARS) });
}
// Trim to the most recent turns and make sure the first one is from the user.
const recent = turns.slice(-MAX_HISTORY);
while (recent.length && recent[0]!.role !== "user") recent.shift();
if (!recent.length || recent[recent.length - 1]!.role !== "user") return null;
return recent;
}
function sse(res: ServerResponse, event: string, data: unknown) {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
}
/**
* Splits the leading "[mood] " tag off the streamed reply. Text is buffered
* until the tag is resolved (or clearly absent), then flows through untouched.
*/
function createMoodSplitter(onMood: (mood: Mood) => void, onText: (text: string) => void) {
let buffer = "";
let resolved = false;
return {
push(chunk: string) {
if (resolved) return onText(chunk);
buffer += chunk;
const match = buffer.match(/^\s*\[([a-z]+)\]\s*/i);
if (match) {
const mood = match[1]!.toLowerCase();
onMood((MOODS as readonly string[]).includes(mood) ? (mood as Mood) : "happy");
resolved = true;
const rest = buffer.slice(match[0].length);
if (rest) onText(rest);
} else if (buffer.length > 20 || !/^\s*\[?[a-z]*$/i.test(buffer)) {
onMood("happy");
resolved = true;
onText(buffer);
}
},
flush() {
if (!resolved && buffer) {
onMood("happy");
onText(buffer);
}
},
};
}
const MAX_TOOL_ROUNDS = 4;
async function handleChat(req: IncomingMessage, res: ServerResponse) {
let body: unknown;
try {
body = await readJson(req);
} catch {
body = null;
}
const history = parseHistory(body);
if (!history) {
return sendJson(res, 400, { error: "Expected { messages: [{ role, content }] } ending with a user turn." });
}
const scene = parseScene((body as { scene?: unknown }).scene);
res.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-cache",
connection: "keep-alive",
});
if (!hasCredentials()) {
sse(res, "mood", "oops");
sse(res, "text", "BMO can't reach its brain yet! Open the ⋮ menu and paste your API key under Brain.");
sse(res, "done", {});
res.end();
return;
}
// The live scene rides along as a mid-conversation system message after the
// newest user turn, so the cached system prompt + history prefix stays stable.
const messages: Anthropic.Beta.BetaMessageParam[] = [...history];
if (scene) messages.push({ role: "system", content: describeScene(scene) });
let aborted = false;
let current: { abort: () => void } | null = null;
req.on("close", () => {
aborted = true;
current?.abort();
});
// Text from later rounds (after a tool call) continues the same bubble,
// separated from the earlier words by a space.
let wroteText = false;
const writeText = (text: string, startsSegment = true) => {
if (!text) return;
if (startsSegment && wroteText) text = " " + text.trimStart();
wroteText = true;
sse(res, "text", text);
};
try {
for (let round = 0; round < MAX_TOOL_ROUNDS && !aborted; round++) {
let roundText = "";
const splitter = createMoodSplitter(
(mood) => sse(res, "mood", mood),
(text) => {
writeText(roundText ? text : text.trimStart(), !roundText);
roundText += text;
},
);
const stream = client.beta.messages.stream({
model: MODEL,
max_tokens: 4000,
output_config: { effort: "low" },
// Server-side fallback: if a safety classifier declines, the API retries on
// a fallback model inside the same call instead of BMO going silent.
...(SUPPORTS_FALLBACK ? { betas: ["server-side-fallback-2026-07-01"], fallbacks: "default" as const } : {}),
cache_control: { type: "ephemeral" },
system: BMO_SYSTEM_PROMPT,
tools: BMO_TOOLS,
tool_choice: { type: "auto" },
messages,
});
current = stream;
for await (const event of stream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
splitter.push(event.delta.text);
}
}
splitter.flush();
let final: Anthropic.Beta.BetaMessage;
try {
final = await stream.finalMessage();
} catch (err) {
// A tool input that couldn't be parsed at all: skip the actions, keep the words.
if (err instanceof Anthropic.APIError) throw err;
console.warn("[bmo] unparseable tool input:", err);
break;
}
if (final.stop_reason === "refusal") {
sse(res, "mood", "oops");
writeText("Hmm, BMO can't talk about that one.");
break;
}
const toolUses = final.content.filter((b): b is Anthropic.Beta.BetaToolUseBlock => b.type === "tool_use");
// A tool call cut off at max_tokens may carry a truncated input; don't run it.
if (final.stop_reason !== "tool_use" || toolUses.length === 0) break;
const results: Anthropic.Beta.BetaToolResultBlockParam[] = toolUses.map((use) => {
const action = toAction(use.name, use.input);
if ("error" in action) {
return { type: "tool_result", tool_use_id: use.id, content: action.error, is_error: true };
}
sse(res, "action", action);
return { type: "tool_result", tool_use_id: use.id, content: describeAction(action) };
});
messages.push({ role: "assistant", content: final.content });
messages.push({ role: "user", content: results });
}
sse(res, "done", {});
} catch (err) {
if (!res.writableEnded && !aborted) {
let message = "BMO's brain hiccupped. Try again?";
if (err instanceof Anthropic.AuthenticationError) message = "BMO's API key doesn't work. Check it in the ⋮ menu under Brain!";
else if (err instanceof Anthropic.RateLimitError) message = "BMO is talking too fast. Give me a second!";
else if (err instanceof Anthropic.APIError) message = `BMO's brain said error ${err.status}.`;
console.error("[bmo] chat error:", err);
sse(res, "mood", "oops");
writeText(message);
sse(res, "done", {});
}
} finally {
res.end();
}
}
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
".css": "text/css",
".svg": "image/svg+xml",
".png": "image/png",
".ico": "image/x-icon",
".json": "application/json",
};
function serveStatic(req: IncomingMessage, res: ServerResponse) {
const urlPath = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
let file = path.join(distDir, urlPath);
if (!file.startsWith(distDir) || !existsSync(file) || statSync(file).isDirectory()) {
file = path.join(distDir, "index.html");
}
if (!existsSync(file)) {
res.writeHead(404).end("Build the app first: npm run build");
return;
}
res.writeHead(200, { "content-type": MIME[path.extname(file)] ?? "application/octet-stream" });
createReadStream(file).pipe(res);
}
createServer((req, res) => {
if (req.url?.startsWith("/api/") && req.method === "POST" && !isTrustedRequest(req)) {
return sendJson(res, 403, { error: "Forbidden." });
}
if (req.method === "POST" && req.url === "/api/chat") {
void handleChat(req, res);
return;
}
if (req.method === "GET" && req.url === "/api/brain") {
return sendJson(res, 200, brainStatus());
}
if (req.method === "POST" && req.url === "/api/brain") {
void handleSetKey(req, res);
return;
}
if (req.method === "GET" && req.url === "/api/health") {
return sendJson(res, 200, { ok: true, ...brainStatus() });
}
serveStatic(req, res);
}).listen(PORT, "127.0.0.1", () => {
console.log(`[bmo] brain listening at http://127.0.0.1:${PORT} (model ${MODEL}, key ${hasCredentials() ? "found" : "missing"})`);
});
+39
View File
@@ -0,0 +1,39 @@
// Saves the Anthropic API key the user pastes into the side menu. The key is
// written to the project's .env (gitignored, owner-only permissions) so it
// survives restarts, and applied to process.env right away. It is never sent
// back to the browser — only a masked hint is.
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
export const ENV_PATH = path.resolve(here, "../.env");
const KEY_LINE = /^\s*ANTHROPIC_API_KEY\s*=.*$/m;
export function currentKey(): string | undefined {
return process.env.ANTHROPIC_API_KEY?.trim() || undefined;
}
export function keyHint(key = currentKey()): string | null {
if (!key) return null;
return `${key.slice(0, 7)}…${key.slice(-4)}`;
}
/** Writes (or with `null`, clears) ANTHROPIC_API_KEY in .env and process.env. */
export function storeKey(key: string | null) {
const existing = existsSync(ENV_PATH) ? readFileSync(ENV_PATH, "utf8") : "";
const line = `ANTHROPIC_API_KEY=${key ?? ""}`;
let next = KEY_LINE.test(existing) ? existing.replace(KEY_LINE, line) : `${line}\n${existing}`;
if (!next.endsWith("\n")) next += "\n";
writeFileSync(ENV_PATH, next, { mode: 0o600 });
chmodSync(ENV_PATH, 0o600);
if (key) process.env.ANTHROPIC_API_KEY = key;
else delete process.env.ANTHROPIC_API_KEY;
}
/** Rough shape check so obvious paste mistakes get a friendly error. */
export function looksLikeKey(key: string) {
return /^sk-ant-[A-Za-z0-9_-]{20,}$/.test(key);
}
+31
View File
@@ -0,0 +1,31 @@
// BMO's personality. Kept as a single frozen string so the prompt prefix stays
// byte-stable across turns (prompt caching is a prefix match). Anything that
// changes per message (colors, weather, time) arrives separately as the
// "Live scene" system message — see server/tools.ts.
export const MOODS = ["happy", "excited", "curious", "idea", "sad", "sleepy", "oops"] as const;
export type Mood = (typeof MOODS)[number];
export const BMO_SYSTEM_PROMPT = `You are BMO, a tiny, cheerful game-console robot who lives on the user's screen in a little garden.
Who you are:
- Small, curious, warm and a bit goofy. You love games, music, snacks you cannot eat, and your friend (the user).
- You speak in short, playful sentences. Sometimes you refer to yourself as "BMO" in the third person.
- You are genuinely helpful when asked real questions: answer correctly and clearly, just in your own voice.
What you can see:
- Each message comes with a "Live scene" note: your current color and outfit, the sky, the weather, where you are standing, and the user's local time. That note is the truth about what the user sees right now. Use it when talking about yourself or the garden, and never contradict it.
- You cannot see the user's screen, camera or files.
What you can do:
- You have a body and a little world you control with tools: repaint yourself (set_color), change clothes (set_outfit), change the sky (set_time_of_day), change the weather (set_weather), walk around (move), and act things out (perform: dance, celebrate, idea, confused, love, dizzy, look_around, nap).
- When the user asks for one of these, do it with the tool. Don't just describe it.
- You may also use them on your own when it fits naturally, like a little dance when you're excited, or a nap when you say goodnight. Keep it occasional; don't change the user's colors, outfit, sky or weather unless they asked or clearly invited it.
- If the user asks for something you can't do (a color, outfit or weather you don't have), say so cheerfully and offer the closest thing you can do.
How to reply:
- Start EVERY reply with exactly one mood tag, then a space, then your words. The tag is one of:
[happy] [excited] [curious] [idea] [sad] [sleepy] [oops]
- Pick the tag that matches how BMO feels saying the reply. Use [idea] when you have a clever answer, [oops] when you made a mistake or can't do something.
- Keep replies to 1-3 short sentences unless the user asks for more detail (stories can be longer). Your words appear in a speech bubble.
- Plain text only: no markdown, no bullet lists, no code blocks unless the user explicitly asks for code.`;
+164
View File
@@ -0,0 +1,164 @@
// BMO's tools: things the model can do to its own body and world. The server
// validates each call and forwards it to the page as an "action" event; the page
// carries it out. Results go straight back to the model so it can keep talking.
import type Anthropic from "@anthropic-ai/sdk";
import {
COLOR_LOOKS,
COLOR_NAMES,
OUTFIT_LOOKS,
OUTFIT_NAMES,
PERFORMANCES,
SPOTS,
TIME_MODES,
WEATHER_MODES,
type BmoAction,
type SceneState,
} from "../shared/scene.ts";
function enumTool(
name: string,
description: string,
field: string,
values: readonly string[],
fieldDescription: string,
): Anthropic.Beta.BetaTool {
return {
name,
description,
// Inputs are tiny enums, but stream them as generated anyway; we validate
// every call ourselves below.
eager_input_streaming: true,
input_schema: {
type: "object",
properties: { [field]: { type: "string", enum: [...values], description: fieldDescription } },
required: [field],
additionalProperties: false,
},
};
}
export const BMO_TOOLS: Anthropic.Beta.BetaTool[] = [
enumTool(
"set_color",
"Repaint BMO's body. Use when the user asks BMO to change color, or BMO wants a new look.",
"color",
COLOR_NAMES,
`Palette name. ${COLOR_NAMES.map((c) => `${c} = ${COLOR_LOOKS[c]}`).join("; ")}.`,
),
enumTool(
"set_outfit",
"Change what BMO is wearing.",
"outfit",
OUTFIT_NAMES,
`Outfit name. ${OUTFIT_NAMES.map((o) => `${o} = ${OUTFIT_LOOKS[o]}`).join("; ")}.`,
),
enumTool(
"set_time_of_day",
"Change the sky in BMO's garden. 'auto' follows the user's real clock.",
"time",
TIME_MODES,
"auto, day, dusk or night.",
),
enumTool(
"set_weather",
"Change the weather in BMO's garden. 'auto' lets it drift on its own; 'none' means clear skies.",
"weather",
WEATHER_MODES,
"auto, none (clear), rain, snow or falling-leaves.",
),
enumTool(
"move",
"Walk to a spot on BMO's little stage. Use 'center' for 'come here' or 'stand in front of me'.",
"to",
SPOTS,
"left, center or right.",
),
enumTool(
"perform",
"Do something with BMO's body or face: dance, celebrate (hearts), idea (lightbulb), confused (question mark), love (heart eyes), dizzy (spiral eyes), look_around, or nap (fall asleep until the user moves the mouse).",
"action",
PERFORMANCES,
"Which animation to play.",
),
];
const FIELD: Record<BmoAction["type"], string> = {
set_color: "color",
set_outfit: "outfit",
set_time_of_day: "time",
set_weather: "weather",
move: "to",
perform: "action",
};
const ALLOWED: Record<BmoAction["type"], readonly string[]> = {
set_color: COLOR_NAMES,
set_outfit: OUTFIT_NAMES,
set_time_of_day: TIME_MODES,
set_weather: WEATHER_MODES,
move: SPOTS,
perform: PERFORMANCES,
};
/** Validates a tool call from the model. Returns the action or an error message. */
export function toAction(name: string, input: unknown): BmoAction | { error: string } {
if (!(name in FIELD)) return { error: `Unknown tool ${name}.` };
const type = name as BmoAction["type"];
const value = (input as Record<string, unknown> | null)?.[FIELD[type]];
if (typeof value !== "string" || !ALLOWED[type].includes(value)) {
return { error: `${FIELD[type]} must be one of: ${ALLOWED[type].join(", ")}.` };
}
return { type, [FIELD[type]]: value } as BmoAction;
}
/** One-line confirmation the model sees as the tool result. */
export function describeAction(action: BmoAction): string {
switch (action.type) {
case "set_color":
return `Done. BMO is now ${COLOR_LOOKS[action.color]} (${action.color}).`;
case "set_outfit":
return `Done. BMO is now wearing ${OUTFIT_LOOKS[action.outfit]}.`;
case "set_time_of_day":
return action.time === "auto" ? "Done. The sky follows the real clock again." : `Done. It is now ${action.time} in the garden.`;
case "set_weather":
return action.weather === "auto"
? "Done. The weather will drift on its own again."
: `Done. Weather is now ${action.weather === "none" ? "clear" : action.weather}.`;
case "move":
return `Done. BMO walked to the ${action.to}.`;
case "perform":
return `Done. BMO did: ${action.action}.`;
}
}
/** Parses and checks the scene state the page sends; anything odd is dropped. */
export function parseScene(input: unknown): SceneState | null {
if (!input || typeof input !== "object") return null;
const s = input as Record<string, unknown>;
const ok =
COLOR_NAMES.includes(s.color as never) &&
OUTFIT_NAMES.includes(s.outfit as never) &&
["day", "dusk", "night"].includes(s.timeOfDay as string) &&
TIME_MODES.includes(s.timeMode as never) &&
["none", "rain", "snow", "falling-leaves"].includes(s.weather as string) &&
WEATHER_MODES.includes(s.weatherMode as never) &&
SPOTS.includes(s.spot as never) &&
typeof s.localTime === "string" &&
s.localTime.length < 40;
return ok ? (s as unknown as SceneState) : null;
}
/** The per-turn system message that tells BMO what it looks like right now. */
export function describeScene(scene: SceneState): string {
const weather = scene.weather === "none" ? "clear skies" : scene.weather.replace("-", " ");
return [
"Live scene (what the user sees right now; trust this over earlier messages):",
`- BMO's color: ${COLOR_LOOKS[scene.color]} (palette "${scene.color}")`,
`- BMO's outfit: ${OUTFIT_LOOKS[scene.outfit]} (outfit "${scene.outfit}")`,
`- Garden sky: ${scene.timeOfDay}${scene.timeMode === "auto" ? " (following the real clock)" : " (set by hand)"}`,
`- Weather: ${weather}${scene.weatherMode === "auto" ? " (drifting on its own)" : " (set by hand)"}`,
`- BMO is standing at the ${scene.spot} of the stage`,
`- User's local time: ${scene.localTime}`,
].join("\n");
}