diff --git a/examples/05-browser/README.md b/examples/05-browser/README.md
new file mode 100644
index 0000000..21d08af
--- /dev/null
+++ b/examples/05-browser/README.md
@@ -0,0 +1,31 @@
+# Browser examples
+
+A Vite + vanilla TypeScript app whose pages each run the agent loop entirely in the browser.
+Start the dev server from the repository root (after `pnpm install` and `pnpm -r build`):
+
+```bash
+pnpm --filter example-05-browser dev
+```
+
+Open the printed URL, paste an API key into the page, and try the pages — they link to each other:
+
+| Page | What it demonstrates |
+| --- | --- |
+| [`/`](index.html) — Chat | Streaming into the DOM, client-side tools (`set_theme`, `get_local_time`), and session persistence in `localStorage`: the session is plain JSON, so persisting it is `JSON.stringify(session)` and resuming after a reload is `agent.deserializeSession(...)` |
+| [`/canvas.html`](canvas.html) — Canvas | An agent that paints: every drawing primitive is a `tool()` executing against a 2D canvas context, so one instruction becomes a series of tool calls rendered as they run |
+| [`/structured.html`](structured.html) — Structured output | A schema passed as `responseFormat`; the typed, validated `response.value` fills the page's fields directly |
+| [`/anthropic.html`](anthropic.html) — Anthropic | The chat page with `AnthropicChatClient` swapped in — the agent, tools and streaming code carry over unchanged, which is the point (it skips the chat page's persistence to stay small) |
+
+Suggested first prompts:
+
+> Switch the page to dark mode, then tell me my time zone.
+
+> Draw a snowman on a blue background.
+
+## A note on API keys
+
+The key you paste stays in the tab's memory; the pages never store it. But any key that reaches a
+browser is readable by whoever uses that browser, which is why both the OpenAI and Anthropic SDKs
+require the explicit `dangerouslyAllowBrowser` opt-in these pages set. For anything beyond a local
+demo, run agents server-side and keep provider credentials there — or point the **Base URL** field
+at a proxy that holds the real key.
diff --git a/examples/05-browser/anthropic.html b/examples/05-browser/anthropic.html
new file mode 100644
index 0000000..71dd750
--- /dev/null
+++ b/examples/05-browser/anthropic.html
@@ -0,0 +1,53 @@
+
+
+
+ This page is the chat example with AnthropicChatClient swapped in — the
+ agent, tools and streaming code are unchanged. Keys pasted here stay in memory — in
+ production, run agents server-side.
+
+ Each drawing primitive is a client-side tool, so the function-calling loop renders on the
+ canvas as it runs. Keys pasted here stay in memory — in production, run agents server-side.
+
+ The whole agent loop runs in this page. The key below stays in memory, but anything shipped to
+ a browser is readable by its user — in production, run agents server-side and keep provider
+ credentials there.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/05-browser/package.json b/examples/05-browser/package.json
new file mode 100644
index 0000000..b93a879
--- /dev/null
+++ b/examples/05-browser/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "example-05-browser",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "engines": {
+ "node": ">=24"
+ },
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc -p tsconfig.json --noEmit"
+ },
+ "dependencies": {
+ "@anthropic-ai/sdk": "^0.120.0",
+ "@polymind-inc/agent-framework": "workspace:^",
+ "openai": "catalog:",
+ "zod": "^4.4.3"
+ },
+ "devDependencies": {
+ "@types/node": "catalog:",
+ "typescript": "catalog:",
+ "vite": "^8.2.2"
+ }
+}
diff --git a/examples/05-browser/src/anthropic.ts b/examples/05-browser/src/anthropic.ts
new file mode 100644
index 0000000..3980c0e
--- /dev/null
+++ b/examples/05-browser/src/anthropic.ts
@@ -0,0 +1,112 @@
+/**
+ * Anthropic — the same browser chat, different provider.
+ *
+ * `AnthropicChatClient` is a `ChatClient` like any other, so the agent, tools and streaming code
+ * carry over from the OpenAI chat page unchanged — only the client construction differs. (The
+ * chat page's localStorage persistence is left out to keep the comparison small.) The Anthropic
+ * SDK has the same explicit browser opt-in as OpenAI's, and CORS on the Anthropic API allows
+ * direct calls from a page.
+ *
+ * Run: `pnpm --filter example-05-browser dev`, then open /anthropic.html
+ */
+import Anthropic from '@anthropic-ai/sdk';
+import { Agent, type AgentSession, tool } from '@polymind-inc/agent-framework';
+import { AnthropicChatClient } from '@polymind-inc/agent-framework/anthropic';
+import { z } from 'zod';
+import { bubble, chip, element, errorText, readSettings, streamingBubble } from './ui.js';
+
+const setTheme = tool({
+ name: 'set_theme',
+ description: 'Switch the page between the light and dark theme',
+ parameters: z.object({ theme: z.enum(['light', 'dark']) }),
+ execute: ({ theme }) => {
+ document.documentElement.dataset.theme = theme;
+ return `The page is now in ${theme} mode.`;
+ },
+});
+
+const getLocalTime = tool({
+ name: 'get_local_time',
+ description: "Read the visitor's current local time and time zone from the browser",
+ parameters: z.object({}),
+ execute: () => ({
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ localTime: new Date().toString(),
+ }),
+});
+
+const log = element('#log');
+const composer = element('#composer');
+const promptInput = element('#prompt');
+const sendButton = element('#send');
+
+let agent: Agent | undefined;
+let session: AgentSession | undefined;
+let agentSettings = '';
+
+function currentAgent(): Agent {
+ const settings = readSettings('claude-sonnet-4-5');
+ const fingerprint = JSON.stringify(settings);
+ if (agent === undefined || fingerprint !== agentSettings) {
+ agent = new Agent({
+ client: new AnthropicChatClient({
+ model: settings.model,
+ client: new Anthropic({
+ apiKey: settings.apiKey,
+ dangerouslyAllowBrowser: true,
+ ...(settings.baseURL === '' ? {} : { baseURL: settings.baseURL }),
+ }),
+ }),
+ name: 'BrowserAssistant',
+ instructions:
+ 'You are a cheerful assistant living inside a web page. Your tools can restyle the page ' +
+ "and read the visitor's clock. Answer briefly.",
+ tools: [setTheme, getLocalTime],
+ });
+ agentSettings = fingerprint;
+ }
+ return agent;
+}
+
+async function send(): Promise {
+ const text = promptInput.value.trim();
+ if (text === '' || sendButton.disabled) {
+ return;
+ }
+ let active: Agent;
+ try {
+ active = currentAgent();
+ } catch (error) {
+ bubble(log, 'error', errorText(error));
+ return;
+ }
+ promptInput.value = '';
+ sendButton.disabled = true;
+ bubble(log, 'user', text);
+ const reply = streamingBubble(log);
+ try {
+ session ??= active.createSession();
+ const stream = active.run(text, { session });
+ for await (const update of stream) {
+ for (const content of update.contents) {
+ if (content.type === 'function_call') {
+ chip(log, `tool: ${content.name}`);
+ }
+ }
+ if (update.text !== '') {
+ reply.append(update.text);
+ }
+ }
+ } catch (error) {
+ reply.remove();
+ bubble(log, 'error', errorText(error));
+ } finally {
+ sendButton.disabled = false;
+ promptInput.focus();
+ }
+}
+
+composer.addEventListener('submit', (event) => {
+ event.preventDefault();
+ void send();
+});
diff --git a/examples/05-browser/src/canvas.ts b/examples/05-browser/src/canvas.ts
new file mode 100644
index 0000000..3e13979
--- /dev/null
+++ b/examples/05-browser/src/canvas.ts
@@ -0,0 +1,162 @@
+/**
+ * Canvas — an agent that paints with client-side tools.
+ *
+ * Every drawing primitive is a `tool()` whose `execute` runs in the page against a 2D canvas
+ * context. The function-calling loop turns one instruction ("draw a snowman") into a series of
+ * tool calls, each rendered the moment it executes. The session lives in memory, so follow-up
+ * instructions ("now add a hat") keep building on the same picture.
+ *
+ * Run: `pnpm --filter example-05-browser dev`, then open /canvas.html
+ */
+import { Agent, type AgentSession, tool } from '@polymind-inc/agent-framework';
+import { OpenAIChatClient } from '@polymind-inc/agent-framework/openai';
+import OpenAI from 'openai';
+import { z } from 'zod';
+import { bubble, chip, element, errorText, readSettings, streamingBubble } from './ui.js';
+
+const canvas = element('#canvas');
+const context = canvas.getContext('2d');
+if (context === null) {
+ throw new Error('This browser does not provide a 2D canvas context.');
+}
+
+const color = z.string().describe('Any CSS color, e.g. "tomato" or "#1d76db"').default('#1d76db');
+
+const drawCircle = tool({
+ name: 'draw_circle',
+ description: 'Draw a filled circle on the canvas',
+ parameters: z.object({ x: z.number(), y: z.number(), radius: z.number().positive(), color }),
+ execute: (args) => {
+ context.fillStyle = args.color;
+ context.beginPath();
+ context.arc(args.x, args.y, args.radius, 0, 2 * Math.PI);
+ context.fill();
+ return `Circle at (${args.x}, ${args.y}).`;
+ },
+});
+
+const drawRect = tool({
+ name: 'draw_rect',
+ description: 'Draw a filled rectangle on the canvas',
+ parameters: z.object({
+ x: z.number(),
+ y: z.number(),
+ width: z.number().positive(),
+ height: z.number().positive(),
+ color,
+ }),
+ execute: (args) => {
+ context.fillStyle = args.color;
+ context.fillRect(args.x, args.y, args.width, args.height);
+ return `Rectangle at (${args.x}, ${args.y}).`;
+ },
+});
+
+const drawLine = tool({
+ name: 'draw_line',
+ description: 'Draw a straight line on the canvas',
+ parameters: z.object({
+ x1: z.number(),
+ y1: z.number(),
+ x2: z.number(),
+ y2: z.number(),
+ color,
+ lineWidth: z.number().positive().default(2),
+ }),
+ execute: (args) => {
+ context.strokeStyle = args.color;
+ context.lineWidth = args.lineWidth;
+ context.beginPath();
+ context.moveTo(args.x1, args.y1);
+ context.lineTo(args.x2, args.y2);
+ context.stroke();
+ return `Line from (${args.x1}, ${args.y1}) to (${args.x2}, ${args.y2}).`;
+ },
+});
+
+const clearCanvas = tool({
+ name: 'clear_canvas',
+ description: 'Erase everything on the canvas',
+ parameters: z.object({}),
+ execute: () => {
+ context.clearRect(0, 0, canvas.width, canvas.height);
+ return 'The canvas is blank.';
+ },
+});
+
+const log = element('#log');
+const composer = element('#composer');
+const promptInput = element('#prompt');
+const sendButton = element('#send');
+
+let agent: Agent | undefined;
+let session: AgentSession | undefined;
+let agentSettings = '';
+
+function currentAgent(): Agent {
+ const settings = readSettings('gpt-4o-mini');
+ const fingerprint = JSON.stringify(settings);
+ if (agent === undefined || fingerprint !== agentSettings) {
+ agent = new Agent({
+ client: new OpenAIChatClient({
+ model: settings.model,
+ client: new OpenAI({
+ apiKey: settings.apiKey,
+ dangerouslyAllowBrowser: true,
+ ...(settings.baseURL === '' ? {} : { baseURL: settings.baseURL }),
+ }),
+ }),
+ name: 'CanvasPainter',
+ instructions:
+ `You paint on a ${canvas.width}×${canvas.height} canvas whose origin is the top-left ` +
+ 'corner. Compose pictures from your drawing tools — several calls per request is normal. ' +
+ 'After drawing, describe what you made in one short sentence.',
+ tools: [drawCircle, drawRect, drawLine, clearCanvas],
+ });
+ agentSettings = fingerprint;
+ }
+ return agent;
+}
+
+async function send(): Promise {
+ const text = promptInput.value.trim();
+ if (text === '' || sendButton.disabled) {
+ return;
+ }
+ let active: Agent;
+ try {
+ active = currentAgent();
+ } catch (error) {
+ bubble(log, 'error', errorText(error));
+ return;
+ }
+ promptInput.value = '';
+ sendButton.disabled = true;
+ bubble(log, 'user', text);
+ const reply = streamingBubble(log);
+ try {
+ session ??= active.createSession();
+ const stream = active.run(text, { session });
+ for await (const update of stream) {
+ for (const content of update.contents) {
+ if (content.type === 'function_call') {
+ chip(log, `tool: ${content.name}`);
+ }
+ }
+ if (update.text !== '') {
+ reply.append(update.text);
+ }
+ }
+ } catch (error) {
+ reply.remove();
+ bubble(log, 'error', errorText(error));
+ } finally {
+ sendButton.disabled = false;
+ promptInput.focus();
+ }
+}
+
+composer.addEventListener('submit', (event) => {
+ event.preventDefault();
+ void send();
+});
diff --git a/examples/05-browser/src/main.ts b/examples/05-browser/src/main.ts
new file mode 100644
index 0000000..5e0c5e4
--- /dev/null
+++ b/examples/05-browser/src/main.ts
@@ -0,0 +1,196 @@
+/**
+ * Chat — the agent loop running in the browser.
+ *
+ * The whole loop — model calls, function calling, streaming — runs in the page. Tools can
+ * therefore touch browser APIs directly: one restyles the page, another reads the visitor's
+ * clock. The session is plain JSON, persisted to `localStorage` across reloads.
+ *
+ * The API key entered in the page stays in this tab's memory, but anything shipped to a browser
+ * is readable by its user — in production, run agents server-side and keep credentials there.
+ *
+ * Run: `pnpm --filter example-05-browser dev`
+ */
+import { Agent, type AgentSession, tool } from '@polymind-inc/agent-framework';
+import { OpenAIChatClient } from '@polymind-inc/agent-framework/openai';
+import OpenAI from 'openai';
+import { z } from 'zod';
+import {
+ bubble,
+ chip,
+ element,
+ errorText,
+ loadJson,
+ readSettings,
+ removeStored,
+ saveJson,
+ streamingBubble,
+} from './ui.js';
+
+const SESSION_KEY = 'agent-framework-example.session';
+const TRANSCRIPT_KEY = 'agent-framework-example.transcript';
+
+// These tools run inside the page, so they can reach browser APIs the model cannot.
+const setTheme = tool({
+ name: 'set_theme',
+ description: 'Switch the page between the light and dark theme',
+ parameters: z.object({ theme: z.enum(['light', 'dark']) }),
+ execute: ({ theme }) => {
+ document.documentElement.dataset.theme = theme;
+ return `The page is now in ${theme} mode.`;
+ },
+});
+
+const getLocalTime = tool({
+ name: 'get_local_time',
+ description: "Read the visitor's current local time and time zone from the browser",
+ parameters: z.object({}),
+ execute: () => ({
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ localTime: new Date().toString(),
+ }),
+});
+
+const log = element('#log');
+const composer = element('#composer');
+const promptInput = element('#prompt');
+const sendButton = element('#send');
+const clearButton = element('#clear');
+
+type TranscriptEntry = { role: 'user' | 'assistant'; text: string };
+
+function isTranscriptEntry(value: unknown): value is TranscriptEntry {
+ if (typeof value !== 'object' || value === null) {
+ return false;
+ }
+ const entry = value as Record;
+ return (entry.role === 'user' || entry.role === 'assistant') && typeof entry.text === 'string';
+}
+
+function restoreTranscript(): TranscriptEntry[] {
+ const stored = loadJson(TRANSCRIPT_KEY);
+ if (stored === undefined) {
+ return [];
+ }
+ if (Array.isArray(stored) && stored.every(isTranscriptEntry)) {
+ return stored;
+ }
+ // Anything else parses but is not what this page wrote; drop it rather than render garbage.
+ removeStored(TRANSCRIPT_KEY);
+ return [];
+}
+
+// The session is the source of truth the model sees; the transcript only redraws past bubbles.
+const transcript: TranscriptEntry[] = restoreTranscript();
+for (const entry of transcript) {
+ bubble(log, entry.role, entry.text);
+}
+if (transcript.length > 0) {
+ chip(log, 'conversation restored from localStorage');
+}
+
+let agent: Agent | undefined;
+let session: AgentSession | undefined;
+let agentSettings = '';
+
+function currentAgent(): Agent {
+ const settings = readSettings('gpt-4o-mini');
+ const fingerprint = JSON.stringify(settings);
+ if (agent === undefined || fingerprint !== agentSettings) {
+ agent = new Agent({
+ client: new OpenAIChatClient({
+ model: settings.model,
+ // The OpenAI SDK refuses to run in a browser unless the risk of exposing the key is
+ // acknowledged explicitly. Here the key is typed into the page and kept in memory only.
+ client: new OpenAI({
+ apiKey: settings.apiKey,
+ dangerouslyAllowBrowser: true,
+ ...(settings.baseURL === '' ? {} : { baseURL: settings.baseURL }),
+ }),
+ }),
+ name: 'BrowserAssistant',
+ instructions:
+ 'You are a cheerful assistant living inside a web page. Your tools can restyle the page ' +
+ "and read the visitor's clock. Answer briefly.",
+ tools: [setTheme, getLocalTime],
+ });
+ agentSettings = fingerprint;
+ }
+ return agent;
+}
+
+function currentSession(active: Agent): AgentSession {
+ if (session === undefined) {
+ const saved = loadJson(SESSION_KEY);
+ if (saved !== undefined) {
+ try {
+ session = active.deserializeSession(saved);
+ } catch {
+ // Corrupted or incompatible saved state would fail every send; drop it and start over.
+ removeStored(SESSION_KEY);
+ chip(log, 'saved session could not be restored; starting fresh');
+ }
+ }
+ session ??= active.createSession();
+ }
+ return session;
+}
+
+async function send(): Promise {
+ const text = promptInput.value.trim();
+ if (text === '' || sendButton.disabled) {
+ return;
+ }
+ let active: Agent;
+ try {
+ active = currentAgent();
+ } catch (error) {
+ bubble(log, 'error', errorText(error));
+ return;
+ }
+ promptInput.value = '';
+ sendButton.disabled = true;
+ // Clearing mid-run would race the in-flight loop, which repopulates the log and storage.
+ clearButton.disabled = true;
+ bubble(log, 'user', text);
+ transcript.push({ role: 'user', text });
+ const reply = streamingBubble(log);
+ try {
+ const stream = active.run(text, { session: currentSession(active) });
+ for await (const update of stream) {
+ for (const content of update.contents) {
+ if (content.type === 'function_call') {
+ chip(log, `tool: ${content.name}`);
+ }
+ }
+ if (update.text !== '') {
+ reply.append(update.text);
+ }
+ }
+ const replyText = reply.text();
+ if (replyText !== '') {
+ transcript.push({ role: 'assistant', text: replyText });
+ }
+ saveJson(SESSION_KEY, session);
+ saveJson(TRANSCRIPT_KEY, transcript);
+ } catch (error) {
+ reply.remove();
+ bubble(log, 'error', errorText(error));
+ } finally {
+ sendButton.disabled = false;
+ clearButton.disabled = false;
+ promptInput.focus();
+ }
+}
+
+composer.addEventListener('submit', (event) => {
+ event.preventDefault();
+ void send();
+});
+
+clearButton.addEventListener('click', () => {
+ session = undefined;
+ transcript.length = 0;
+ log.replaceChildren();
+ removeStored(SESSION_KEY);
+ removeStored(TRANSCRIPT_KEY);
+});
diff --git a/examples/05-browser/src/structured.ts b/examples/05-browser/src/structured.ts
new file mode 100644
index 0000000..2ac1503
--- /dev/null
+++ b/examples/05-browser/src/structured.ts
@@ -0,0 +1,88 @@
+/**
+ * Structured output — typed extraction rendered straight into the page.
+ *
+ * Pass a schema as `responseFormat` and the parsed, validated value lands on `response.value`,
+ * typed from the schema — no JSON.parse, no manual checks. Here the value fills a definition
+ * list, which is exactly the kind of UI code that benefits from the value already being typed.
+ *
+ * Run: `pnpm --filter example-05-browser dev`, then open /structured.html
+ */
+import { Agent } from '@polymind-inc/agent-framework';
+import { OpenAIChatClient } from '@polymind-inc/agent-framework/openai';
+import OpenAI from 'openai';
+import { z } from 'zod';
+import { bubble, element, errorText, readSettings } from './ui.js';
+
+const EventInfo = z.object({
+ title: z.string(),
+ date: z.string().describe('ISO 8601 date'),
+ location: z.string(),
+ attendees: z.array(z.string()),
+});
+
+const log = element('#log');
+const form = element('#composer');
+const input = element('#source');
+const extractButton = element('#extract');
+const result = element('#result');
+
+function renderValue(value: z.infer): void {
+ const rows: [string, string][] = [
+ ['Title', value.title],
+ ['Date', value.date],
+ ['Location', value.location],
+ ['Attendees', value.attendees.join(', ')],
+ ];
+ const dl = document.createElement('dl');
+ for (const [term, detail] of rows) {
+ const dt = document.createElement('dt');
+ dt.textContent = term;
+ const dd = document.createElement('dd');
+ dd.textContent = detail;
+ dl.append(dt, dd);
+ }
+ result.replaceChildren(dl);
+}
+
+async function extract(): Promise {
+ const text = input.value.trim();
+ if (text === '' || extractButton.disabled) {
+ return;
+ }
+ let agent: Agent;
+ try {
+ const settings = readSettings('gpt-4o-mini');
+ agent = new Agent({
+ client: new OpenAIChatClient({
+ model: settings.model,
+ client: new OpenAI({
+ apiKey: settings.apiKey,
+ dangerouslyAllowBrowser: true,
+ ...(settings.baseURL === '' ? {} : { baseURL: settings.baseURL }),
+ }),
+ }),
+ instructions: 'Extract the event described in the user text.',
+ });
+ } catch (error) {
+ bubble(log, 'error', errorText(error));
+ return;
+ }
+ extractButton.disabled = true;
+ try {
+ const response = await agent.run(text, { responseFormat: EventInfo });
+ // A suspended response has no value yet, so narrow it before use.
+ if (response.value === undefined) {
+ throw new Error('The run stopped before producing its structured output.');
+ }
+ renderValue(response.value);
+ } catch (error) {
+ bubble(log, 'error', errorText(error));
+ } finally {
+ extractButton.disabled = false;
+ }
+}
+
+form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ void extract();
+});
diff --git a/examples/05-browser/src/style.css b/examples/05-browser/src/style.css
new file mode 100644
index 0000000..1adbdd6
--- /dev/null
+++ b/examples/05-browser/src/style.css
@@ -0,0 +1,215 @@
+:root {
+ color-scheme: light;
+ --bg: #f6f7f9;
+ --panel: #ffffff;
+ --text: #1f2328;
+ --muted: #59636e;
+ --accent: #0969da;
+ --border: #d1d9e0;
+}
+
+:root[data-theme="dark"] {
+ color-scheme: dark;
+ --bg: #0d1117;
+ --panel: #161b22;
+ --text: #e6edf3;
+ --muted: #9198a1;
+ --accent: #4493f8;
+ --border: #30363d;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family: system-ui, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+}
+
+main {
+ max-width: 720px;
+ min-height: 100dvh;
+ margin: 0 auto;
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+h1 {
+ margin: 0 0 0.5rem;
+ font-size: 1.4rem;
+}
+
+nav {
+ display: flex;
+ gap: 1rem;
+ margin-bottom: 0.75rem;
+ font-size: 0.85rem;
+}
+
+nav a {
+ color: var(--muted);
+ text-decoration: none;
+}
+
+nav a[aria-current="page"] {
+ color: var(--accent);
+ font-weight: 600;
+}
+
+nav a:hover {
+ color: var(--accent);
+}
+
+.warning {
+ margin: 0 0 0.75rem;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid var(--border);
+ border-left: 3px solid var(--accent);
+ border-radius: 6px;
+ background: var(--panel);
+ color: var(--muted);
+ font-size: 0.85rem;
+}
+
+.settings {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.5rem;
+}
+
+.settings label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ font-size: 0.8rem;
+ color: var(--muted);
+}
+
+input,
+button {
+ font: inherit;
+ padding: 0.5rem 0.65rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--panel);
+ color: var(--text);
+}
+
+input:focus {
+ outline: 2px solid var(--accent);
+ outline-offset: -1px;
+}
+
+#log {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ padding: 0.25rem 0;
+ overflow-y: auto;
+}
+
+.bubble {
+ max-width: 85%;
+ padding: 0.5rem 0.75rem;
+ border-radius: 10px;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.bubble.user {
+ align-self: flex-end;
+ background: var(--accent);
+ color: #ffffff;
+}
+
+.bubble.assistant {
+ align-self: flex-start;
+ background: var(--panel);
+ border: 1px solid var(--border);
+}
+
+.bubble.error {
+ align-self: stretch;
+ border: 1px solid #d1242f;
+ color: #d1242f;
+ background: var(--panel);
+}
+
+.chip {
+ align-self: flex-start;
+ padding: 0.15rem 0.6rem;
+ border: 1px dashed var(--border);
+ border-radius: 999px;
+ color: var(--muted);
+ font-size: 0.75rem;
+}
+
+#canvas {
+ align-self: center;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: #ffffff;
+ max-width: 100%;
+}
+
+textarea {
+ font: inherit;
+ width: 100%;
+ padding: 0.5rem 0.65rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--panel);
+ color: var(--text);
+ resize: vertical;
+}
+
+#result dl {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 0.35rem 1rem;
+ margin: 0;
+ padding: 0.75rem 1rem;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ background: var(--panel);
+}
+
+#result dt {
+ color: var(--muted);
+}
+
+#result dd {
+ margin: 0;
+}
+
+#composer {
+ display: flex;
+ gap: 0.5rem;
+}
+
+#composer:has(textarea) {
+ flex-direction: column;
+ align-items: flex-start;
+}
+
+#prompt {
+ flex: 1;
+}
+
+#send,
+#extract {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #ffffff;
+}
+
+#send:disabled,
+#extract:disabled {
+ opacity: 0.6;
+}
diff --git a/examples/05-browser/src/ui.ts b/examples/05-browser/src/ui.ts
new file mode 100644
index 0000000..b90750b
--- /dev/null
+++ b/examples/05-browser/src/ui.ts
@@ -0,0 +1,104 @@
+/** Small DOM and storage helpers shared by every page of this example. */
+
+export function element(selector: string): T {
+ const found = document.querySelector(selector);
+ if (found === null) {
+ throw new Error(`Missing element: ${selector}`);
+ }
+ return found;
+}
+
+export type BubbleKind = 'user' | 'assistant' | 'error';
+
+export function bubble(log: HTMLElement, kind: BubbleKind, text = ''): HTMLElement {
+ const el = document.createElement('div');
+ el.className = `bubble ${kind}`;
+ el.textContent = text;
+ log.append(el);
+ el.scrollIntoView({ block: 'end' });
+ return el;
+}
+
+/**
+ * An assistant bubble that appears on the first streamed chunk and grows by mutating a single
+ * text node, so a long response stays one DOM node instead of one per chunk.
+ */
+export function streamingBubble(log: HTMLElement): {
+ append(chunk: string): void;
+ text(): string;
+ remove(): void;
+} {
+ let el: HTMLElement | undefined;
+ let node: Text | undefined;
+ return {
+ append(chunk: string): void {
+ if (el === undefined || node === undefined) {
+ el = bubble(log, 'assistant');
+ node = document.createTextNode('');
+ el.append(node);
+ }
+ node.appendData(chunk);
+ el.scrollIntoView({ block: 'end' });
+ },
+ text: (): string => node?.data ?? '',
+ remove: (): void => el?.remove(),
+ };
+}
+
+export function chip(log: HTMLElement, text: string): void {
+ const el = document.createElement('div');
+ el.className = 'chip';
+ el.textContent = text;
+ log.append(el);
+ el.scrollIntoView({ block: 'end' });
+}
+
+export function loadJson(key: string): T | undefined {
+ try {
+ const raw = localStorage.getItem(key);
+ return raw === null ? undefined : (JSON.parse(raw) as T);
+ } catch {
+ return undefined;
+ }
+}
+
+export function saveJson(key: string, value: unknown): void {
+ try {
+ localStorage.setItem(key, JSON.stringify(value));
+ } catch {
+ // Storage can be unavailable (private windows, quota); the page works without persistence.
+ }
+}
+
+export function removeStored(key: string): void {
+ try {
+ localStorage.removeItem(key);
+ } catch {
+ // Storage can be unavailable; there is then nothing to remove.
+ }
+}
+
+export interface Settings {
+ apiKey: string;
+ baseURL: string;
+ model: string;
+}
+
+/** Read the API key / base URL / model row every page shares; throws until a key is entered. */
+export function readSettings(defaultModel: string): Settings {
+ const apiKeyInput = element('#api-key');
+ const apiKey = apiKeyInput.value.trim();
+ if (apiKey === '') {
+ apiKeyInput.focus();
+ throw new Error('Enter an API key first.');
+ }
+ return {
+ apiKey,
+ baseURL: element('#base-url').value.trim(),
+ model: element('#model').value.trim() || defaultModel,
+ };
+}
+
+export function errorText(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
diff --git a/examples/05-browser/structured.html b/examples/05-browser/structured.html
new file mode 100644
index 0000000..0f19043
--- /dev/null
+++ b/examples/05-browser/structured.html
@@ -0,0 +1,49 @@
+
+
+
+
+
+ Agent Framework — structured output example
+
+
+
+
+
+
+
Typed extraction into the page
+
+ The schema-validated response.value fills the fields below directly — no
+ JSON.parse in sight. Keys pasted here stay in memory — in production, run agents server-side.
+