08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 1
2// Local bridge between the cymsimweb page and a Claude Code session.
3//
4// The page runs in a browser and cannot start Claude Code itself, so this process runs on the
5// user's machine: it drives the Claude Agent SDK (which uses the local `claude` login, i.e. the
6// user's Claude Code subscription), and relays the tools Claude calls to the page over a
7// WebSocket, where they read the app's state, replace the YAML, start and stop runs, and play
8// the audio. It also serves the built site from dist/ so the page can be used from the same
9// origin. Listens on 127.0.0.1 only and accepts WebSocket connections only from known origins.
10//
11// npm run bridge # ws://localhost:8765, site at http://localhost:8765
12// node bridge/server.mjs --port 9000 --origin https://my.site --model claude-sonnet-5
13import fs from "node:fs";
14import http from "node:http";
15import path from "node:path";
16import { fileURLToPath } from "node:url";
17import { WebSocketServer } from "ws";
18import { z } from "zod";
19import { createSdkMcpServer, query, tool } from "@anthropic-ai/claude-agent-sdk";
20import { defaultConfig } from "../src/config/schema.ts";
21import { PRESETS } from "../src/config/presets.ts";
22import { configToYaml } from "../src/config/yaml.ts";
24const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
26// ---------------------------------------------------------------- arguments
27const argv = process.argv.slice(2);
28const opt = (name, def) => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : def; };
29const PORT = Number(opt("--port", process.env.CYMSIMWEB_BRIDGE_PORT ?? 8765));
30const MODEL = opt("--model", process.env.CYMSIMWEB_BRIDGE_MODEL ?? undefined);
31const EXTRA_ORIGINS = argv.flatMap((a, i) => (a === "--origin" && argv[i + 1] ? [argv[i + 1]] : []));
32const ALLOWED_ORIGINS = new Set([
33 "https://cymsimweb--proof-of-concept.vault1-sites.magland.org",
34 "https://vault1.magland.org",
35 ...EXTRA_ORIGINS,
36]);
37const isAllowedOrigin = (origin) => {
38 if (!origin) return true; // non-browser clients (tests); browsers always send Origin
39 if (ALLOWED_ORIGINS.has(origin)) return true;
40 return /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
41};
43// ---------------------------------------------------------------- what Claude is told about the app
44const read = (f) => { try { return fs.readFileSync(path.join(root, f), "utf8"); } catch { return ""; } };
45const SYSTEM_APPEND = `
46# cymsimweb assistant
48You are embedded in cymsimweb, a browser application that simulates a struck cymbal with a nonlinear
49finite-element shell model on WebGPU (the companion of the cymsim Python package). The user talks to
50you in a chat panel beside the app. You can see the app's state and change it through the
51mcp__cymsimweb__* tools; the tools run inside the page. Your working directory is the app's source
52repository: read README.md, THEORY.md, src/config/schema.ts (the configuration fields and defaults),
53src/model/*.ts (geometry, mesh, elements, shell, exciters, radiation) and src/gpu/shaders.ts when a
54question needs the details. The equations are in the Python package's docs/model.md, which you may
55fetch at https://vault1.magland.org/proof-of-concept/cymsim/blob/main/docs/model.md . Do not modify
56files: you have no write access, and the app is changed only through the tools.
58## Tools
59- get_app_state: the current YAML, its validation issues, the run state and progress, the set-up
60 summary of the last run, the latest energies and hit results, and a spectrogram summary (per-octave
61 band onsets and levels, spectral centroid over time). Call it before answering questions about
62 what is going on, and after a run to interpret the result.
63- set_yaml: replace the configuration in the editor with a complete YAML document. Omitted fields take
64 the defaults, so a short document is fine, but write every field the user asked about explicitly.
65 The tool returns validation issues; fix them and call again if there are any. Units are SI.
66- select_preset: load one of the built-in configurations.
67- start_run / pause_run / stop_run: control the simulation. A run takes tens of seconds to minutes
68 depending on the GPU; start_run returns immediately, so check progress with get_app_state.
69- play_audio: play the generated sound from a time; set_signal chooses the unbaffled or the baffled
70 (Rayleigh) listener signal for the spectrogram and playback.
3534482Linear spectrogram axis by default; capture and replay the displacement movie at a chosen slowdownJeremy Magland 71- play_movie: replay the captured displacement of the plate, slowed down by a factor (50 is a good
72 start; the lowest modes of an 18 inch crash are a few hundred Hz, so x200 to x500 shows individual
73 cycles). set_movie_fps sets how many frames per second of simulated time the next run captures
74 (2000 by default, 0 disables it); the frames are what play_movie replays.
75- set_spectrogram_axis: linear (the default) or log frequency axis, and the upper edge in Hz.
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 76
77## Guidance
78When the user describes a cymbal or a stroke in words, translate it into the configuration: size sets
79profile.radius (18 inch crash: 0.2286 m; 20 inch ride: 0.254 m), weight sets the thicknesses (thin
80crash h_rim 0.8-1.0 mm, medium 1.0-1.2 mm, ride 1.6-2.0 mm at the rim, thicker toward the bell),
81bell size sets bell_height and bell_shoulder_radius, a stick shoulder hit is a "stick" exciter near
82the rim (r about 0.85 R) and a soft mallet stroke a "mallet" exciter on the bow (r about 0.6 R).
83Loudness is the exciter velocity (stick 4-9 m/s, mallet 3-8 m/s). Losses: sigma0 sets the decay of the
84low modes (T60 about 6.9/sigma0 s), sigma1 the loss growing with frequency. Numerics: element_size
850.006 m is the default resolution and needs a discrete GPU; 0.01-0.02 m for quick previews; duration
86is the simulated seconds. Several hits go in the hits list, each with its own time, position, velocity
87and exciter. After setting a configuration, say briefly what you set and offer to start the run
88(or start it if the user asked for that). When a run finishes, get_app_state and describe the
89result: peak contact force, contact time, how nonlinear (max|w|/h), and whether the band onsets show
90the upward cascade (high bands rising later than low ones).
92Keep replies short and concrete. Do not paste the whole YAML into the chat unless asked; the editor
93shows it.
95## The YAML schema (every field with its default)
96\`\`\`yaml
97${configToYaml(defaultConfig())}\`\`\`
99## Presets
100${PRESETS.map((p) => `- ${p.id}: ${p.name}`).join("\n")}
102## README.md
103${read("README.md")}
104`;
106// ---------------------------------------------------------------- one chat session per socket
107class Session {
108 constructor(ws) {
109 this.ws = ws; this.sessionId = null; this.active = null; this.queue = []; this.pending = new Map(); this.nextId = 1;
110 const relay = (name, description, schema, extras) => tool(name, description, schema, async (args) => this.callBrowser(name, args), { alwaysLoad: true, ...extras });
111 this.server = createSdkMcpServer({
112 name: "cymsimweb", version: "1.0.0",
113 tools: [
114 relay("get_app_state", "Read the app's state: current YAML and validation issues, run state and progress, set-up summary, latest energies and hit results, spectrogram band summary.", {}, { annotations: { readOnlyHint: true } }),
115 relay("set_yaml", "Replace the configuration YAML in the editor. Returns validation issues (empty when valid).", { yaml: z.string().describe("Complete YAML document; omitted fields take the defaults") }),
116 relay("select_preset", "Load a built-in preset into the editor.", { id: z.enum(PRESETS.map((p) => p.id)) }),
117 relay("start_run", "Build the set-up from the current YAML and start the simulation. Returns immediately.", {}),
118 relay("pause_run", "Pause or continue the running simulation.", {}),
119 relay("stop_run", "Stop the running simulation, keeping the audio generated so far.", {}),
120 relay("play_audio", "Play the generated sound in the browser from a time in seconds.", { from_seconds: z.number().min(0).default(0) }),
121 relay("set_signal", "Choose which listener signal the spectrogram and playback use.", { signal: z.enum(["unbaffled", "rayleigh"]) }),
3534482Linear spectrogram axis by default; capture and replay the displacement movie at a chosen slowdownJeremy Magland 122 relay("play_movie", "Replay the captured displacement of the cymbal in the plate view, slowed down.", {
123 slowdown: z.number().min(2).max(10000).default(50).describe("how many times slower than reality"),
124 from_seconds: z.number().min(0).default(0).describe("simulated time to start from"),
125 }),
126 relay("set_spectrogram_axis", "Set the spectrogram's frequency axis.", {
127 scale: z.enum(["linear", "log"]),
128 max_frequency_hz: z.number().min(500).max(96000).optional().describe("upper edge; omit for the Nyquist frequency"),
129 }),
130 relay("set_movie_fps", "Set how many frames of the displacement movie are captured per second of simulated time (0 disables capture). Applies to the next run.", { fps: z.number().min(0).max(44100) }),
08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 131 ],
132 });
133 }
135 send(obj) { if (this.ws.readyState === this.ws.OPEN) this.ws.send(JSON.stringify(obj)); }
137 /** Ask the page to run a tool and wait for its answer. */
138 callBrowser(name, args) {
139 const id = this.nextId++;
140 this.send({ type: "tool_call", id, name, args });
141 return new Promise((resolve) => {
142 const timer = setTimeout(() => { this.pending.delete(id); resolve({ content: [{ type: "text", text: "the page did not answer within 120 s" }], isError: true }); }, 120000);
143 this.pending.set(id, (result) => { clearTimeout(timer); resolve(result); });
144 });
145 }
147 onMessage(raw) {
148 let msg;
149 try { msg = JSON.parse(raw); } catch { return; }
150 if (msg.type === "tool_result") {
151 const cb = this.pending.get(msg.id);
152 if (cb) { this.pending.delete(msg.id); cb({ content: [{ type: "text", text: typeof msg.text === "string" ? msg.text : JSON.stringify(msg.text) }], isError: !!msg.isError }); }
153 } else if (msg.type === "user" && typeof msg.text === "string" && msg.text.trim()) {
154 if (this.active) this.queue.push(msg.text); else void this.run(msg.text);
155 } else if (msg.type === "interrupt") {
156 this.queue = []; void this.active?.interrupt();
157 } else if (msg.type === "reset") {
158 this.queue = []; void this.active?.interrupt(); this.sessionId = null; this.send({ type: "reset" });
159 }
160 }
162 async run(text) {
163 const q = query({
164 prompt: text,
165 options: {
166 cwd: root,
167 resume: this.sessionId ?? undefined,
168 includePartialMessages: true,
169 systemPrompt: { type: "preset", preset: "claude_code", append: SYSTEM_APPEND },
170 mcpServers: { cymsimweb: this.server },
171 tools: ["Read", "Glob", "Grep", "WebFetch"],
172 allowedTools: ["Read", "Glob", "Grep", "WebFetch", "mcp__cymsimweb__*"],
173 permissionMode: "dontAsk",
174 maxTurns: 40,
175 ...(MODEL ? { model: MODEL } : {}),
176 },
177 });
178 this.active = q;
179 this.send({ type: "status", state: "working" });
180 try {
181 for await (const m of q) {
182 if (m.type === "system" && m.subtype === "init") { this.sessionId = m.session_id; this.send({ type: "init", session_id: m.session_id, model: m.model }); }
183 else if (m.type === "stream_event") {
184 const e = m.event;
185 if (e.type === "content_block_delta" && e.delta?.type === "text_delta" && m.parent_tool_use_id === null) this.send({ type: "delta", text: e.delta.text });
186 else if (e.type === "content_block_start" && e.content_block?.type === "text" && m.parent_tool_use_id === null) this.send({ type: "block_start" });
187 } else if (m.type === "assistant") {
188 for (const block of m.message.content) {
189 if (block.type === "tool_use") this.send({ type: "tool_use", name: block.name.replace(/^mcp__cymsimweb__/, ""), input: summarize(block.input) });
190 }
191 } else if (m.type === "result") {
192 this.sessionId = m.session_id;
193 this.send({ type: "result", ok: m.subtype === "success", error: m.subtype === "success" ? undefined : m.subtype, cost: m.total_cost_usd, turns: m.num_turns });
194 }
195 }
196 } catch (err) {
197 this.send({ type: "error", message: String(err?.message ?? err) });
198 } finally {
199 this.active = null;
200 this.send({ type: "status", state: "idle" });
201 const next = this.queue.shift();
202 if (next) void this.run(next);
203 }
204 }
205}
207function summarize(input) {
208 const s = JSON.stringify(input ?? {});
209 return s.length > 160 ? s.slice(0, 157) + "..." : s;
210}
212// ---------------------------------------------------------------- static site + WebSocket
213const MIME = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".wasm": "application/wasm", ".map": "application/json" };
214const dist = path.join(root, "dist");
215const server = http.createServer((req, res) => {
216 const url = new URL(req.url ?? "/", "http://localhost");
217 let file = path.normalize(path.join(dist, decodeURIComponent(url.pathname)));
218 if (!file.startsWith(dist)) { res.writeHead(403); res.end(); return; }
219 if (url.pathname === "/" || !fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(dist, "index.html");
220 if (!fs.existsSync(file)) { res.writeHead(404, { "content-type": "text/plain" }); res.end("dist/ not found: run `npm run build` first, or use the deployed site with this bridge"); return; }
221 res.writeHead(200, { "content-type": MIME[path.extname(file)] ?? "application/octet-stream" });
222 fs.createReadStream(file).pipe(res);
223});
224const wss = new WebSocketServer({ server, verifyClient: ({ origin }) => isAllowedOrigin(origin) });
225wss.on("connection", (ws, req) => {
226 const session = new Session(ws);
227 console.log(`[bridge] page connected from ${req.headers.origin ?? "no origin"}`);
228 ws.send(JSON.stringify({ type: "hello", cwd: root, model: MODEL ?? "default", version: 1 }));
229 ws.on("message", (data) => session.onMessage(data.toString()));
230 ws.on("close", () => { void session.active?.interrupt(); console.log("[bridge] page disconnected"); });
231});
232server.listen(PORT, "127.0.0.1", () => {
233 console.log(`[bridge] Claude Code bridge for cymsimweb on ws://localhost:${PORT} (site: http://localhost:${PORT}, model: ${MODEL ?? "Claude Code default"})`);
234 console.log(`[bridge] accepting pages from localhost and ${[...ALLOWED_ORIGINS].join(", ")}`);
235});