Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions examples/05-browser/README.md
Original file line number Diff line number Diff line change
@@ -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.
53 changes: 53 additions & 0 deletions examples/05-browser/anthropic.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Agent Framework — Anthropic example</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<main>
<header>
<nav>
<a href="/">Chat</a>
<a href="/canvas.html">Canvas</a>
<a href="/structured.html">Structured output</a>
<a href="/anthropic.html" aria-current="page">Anthropic</a>
</nav>
<h1>Same page, different provider</h1>
<p class="warning">
This page is the chat example with <code>AnthropicChatClient</code> swapped in — the
agent, tools and streaming code are unchanged. Keys pasted here stay in memory — in
production, run agents server-side.
</p>
<div class="settings">
<label>
API key
<input id="api-key" type="password" placeholder="sk-ant-..." autocomplete="off" />
</label>
<label>
Base URL
<input id="base-url" type="text" placeholder="https://proxy.example.com (optional)" />
</label>
<label>
Model
<input id="model" type="text" value="claude-sonnet-4-5" />
</label>
</div>
</header>
<section id="log" role="log" aria-live="polite" aria-relevant="additions"></section>
<form id="composer">
<input
id="prompt"
type="text"
aria-label="Chat message"
placeholder="Try: switch the page to dark mode, then tell me my time zone"
autocomplete="off"
/>
<button id="send" type="submit">Send</button>
</form>
</main>
<script type="module" src="/src/anthropic.ts"></script>
</body>
</html>
53 changes: 53 additions & 0 deletions examples/05-browser/canvas.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Agent Framework — canvas example</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<main>
<header>
<nav>
<a href="/">Chat</a>
<a href="/canvas.html" aria-current="page">Canvas</a>
<a href="/structured.html">Structured output</a>
<a href="/anthropic.html">Anthropic</a>
</nav>
<h1>An agent that paints</h1>
<p class="warning">
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.
</p>
<div class="settings">
<label>
API key
<input id="api-key" type="password" placeholder="sk-..." autocomplete="off" />
</label>
<label>
Base URL
<input id="base-url" type="text" placeholder="https://proxy.example.com/v1 (optional)" />
</label>
<label>
Model
<input id="model" type="text" value="gpt-4o-mini" />
</label>
</div>
</header>
<canvas id="canvas" width="480" height="360"></canvas>
<section id="log" role="log" aria-live="polite" aria-relevant="additions"></section>
<form id="composer">
<input
id="prompt"
type="text"
aria-label="Drawing instruction"
placeholder="Try: draw a snowman on a blue background"
autocomplete="off"
/>
<button id="send" type="submit">Send</button>
</form>
</main>
<script type="module" src="/src/canvas.ts"></script>
</body>
</html>
54 changes: 54 additions & 0 deletions examples/05-browser/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Agent Framework — browser example</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<main>
<header>
<nav>
<a href="/" aria-current="page">Chat</a>
<a href="/canvas.html">Canvas</a>
<a href="/structured.html">Structured output</a>
<a href="/anthropic.html">Anthropic</a>
</nav>
<h1>Agent in the browser</h1>
<p class="warning">
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.
</p>
<div class="settings">
<label>
API key
<input id="api-key" type="password" placeholder="sk-..." autocomplete="off" />
</label>
<label>
Base URL
<input id="base-url" type="text" placeholder="https://proxy.example.com/v1 (optional)" />
</label>
<label>
Model
<input id="model" type="text" value="gpt-4o-mini" />
</label>
</div>
</header>
<section id="log" role="log" aria-live="polite" aria-relevant="additions"></section>
<form id="composer">
<input
id="prompt"
type="text"
aria-label="Chat message"
placeholder="Try: switch the page to dark mode, then tell me my time zone"
autocomplete="off"
/>
<button id="send" type="submit">Send</button>
<button id="clear" type="button">Clear</button>
</form>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
26 changes: 26 additions & 0 deletions examples/05-browser/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
112 changes: 112 additions & 0 deletions examples/05-browser/src/anthropic.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>('#log');
const composer = element<HTMLFormElement>('#composer');
const promptInput = element<HTMLInputElement>('#prompt');
const sendButton = element<HTMLButtonElement>('#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<void> {
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();
});
Loading