Skip to content

Client-side tools & the browser adapter

An MCP server gives the assistant tools that run on a server. A client-side tool runs in the visitor’s browser instead. The server interrupts the turn, the kit runs your handler against the live DOM or app state, and the result flows back into the model’s context. This is how the assistant reads the page the user is looking at, and how it changes that page without a round-trip through your backend.

There are two ways to register one. Pick by what the tool does.

Use diosc('tool', name, handler) when the tool is a single self-contained action — read something, move somewhere, poke one piece of app state.

diosc('tool', 'highlight_row', async (params) => {
const row = document.querySelector(`[data-id="${params.id}"]`);
if (!row) return { error: `Row ${params.id} not found` };
row.scrollIntoView();
row.classList.add('is-highlighted');
return { highlighted: params.id };
});

The backend fires a browser:<name> interrupt for the matching tool. The kit runs your handler with the tool’s parameters and submits whatever it returns as the tool result. handler(params) may be sync or async. Return any JSON-serializable value; return { error: '…' } (or throw) to report failure — the message becomes the tool’s error result.

The kit ships two built-in browser tools, registered for you:

ToolWhat it does
navigateRoutes through the kit’s navigation handler. A host that called setNavigationHandler (React Router, SvelteKit goto, and so on) gets SPA navigation with no reload; otherwise it falls back to window.location.assign. Returns { navigatedTo }.
read_pageWith a selector, returns that element’s content (trimmed text) and html. Without one, returns { url, path, title, content }, where content is the page’s visible text with script/style/img/svg/iframe stripped, capped at 5,000 characters.

navigate is what the Northwind storefront wires up to let the assistant move the shopper between pages — the hub only ever emits paths it validated against the assistant’s Sitemap. You can override either built-in by registering your own handler under the same name.

Use diosc('browserAdapter', adapter) when the assistant needs to understand and mutate a live view — a form, an editor, a canvas. The adapter does two things a raw tool can’t: it feeds the model a fresh snapshot of the page every turn, and it turns a set of typed intents into first-class tools with a built-in approval gate.

diosc('browserAdapter', {
// Pulled fresh on every turn and attached to the outgoing message,
// so the model always reasons about the current page.
async read() {
return {
url: location.href,
title: document.title,
description: 'Flowchart editor — the open document',
content: editor.toMarkdown(),
data: { nodeCount: editor.nodes.length },
};
},
intents: [
{
name: 'apply_changes',
description: 'Apply a diff of node/edge operations to the open diagram.',
schema: {
type: 'object',
properties: { ops: { type: 'array', items: { type: 'object' } } },
required: ['ops'],
},
async handler(args) {
const applied = editorBridge.apply(args.ops);
return { success: true, data: { applied } };
},
},
],
});

adapter.read() returns a PageSnapshot{ url, title, description?, content?, data? }. The kit calls it on every invoke and attaches the result to the user message, so the model sees the page as it is right now, not as it was when the widget loaded. description is your one-line summary of the view, content is its semantic text or markdown, and data is any JSON-serializable structured state.

Each entry in adapter.intents is an IntentDefinition{ name, description, schema, handler, approval? } — and becomes a tool the model can call, registered under the name browser_<name> (so the intent above is the tool browser_apply_changes). schema is JSON Schema describing the intent’s arguments; the model fills it, and the kit hands the result to handler(args).

Only metadata crosses the wire. The kit serializes each intent’s name, description, schema, and — if present — its approval.severity. The handler, and the approval.summary/diff functions, stay in the browser and never reach the backend or the model. Pass diosc('browserAdapter', null) to clear the adapter; the kit stops attaching snapshots and unregisters the intent dispatcher.

An intent handler returns an IntentResult:

interface IntentResult {
success: boolean;
data?: unknown; // what the model reads back
error?: string; // failure detail
file?: IntentFileEmission; // deliver a downloadable file (below)
}

Put whatever the model should reason about next in data — the count of rows changed, the new document title, the id it created. Set success: false with an error when the action couldn’t complete.

A client-side tool can hand the user a file the browser generated — a PNG export of the canvas, a CSV built from the current table, a rendered PDF. Set file on the result to an IntentFileEmission:

async handler(args) {
const blob = await canvas.toBlob('image/png');
return {
success: true,
file: { content: blob, filename: 'diagram.png', mimeType: 'image/png' },
};
}

content may be a Blob/File (best for binary), a data: URL string, or a plain string treated as text. filename includes the extension; mimeType is required for a typeless Blob or a text string.

The kit uploads the bytes through the authenticated transport — the same path the file picker uses, so the same session and the user’s file permission apply — renders a download chip on the assistant message, and strips the bytes before the result returns. What the model sees is a byte-free descriptor: { fileId, filename, format, downloadUrl, sizeBytes } — the same shape the server-side generate_file tool produces. The bytes never enter the model’s context, which is what keeps the Credential Blind boundary intact. The chip is baked into the saved message, so it survives a reload.

Do not attach a chip yourself. Returning file is the whole contract — the kit does the upload, the chip, and the byte stripping. If delivery fails, the result degrades to { success: false } rather than throwing.

An intent that mutates state — writes to a record, applies a diff, deletes something — should ask the user first. Add an approval block:

{
name: 'apply_changes',
description: 'Apply a diff to the open diagram.',
schema: { /* … */ },
approval: {
severity: 'medium',
summary: (args) => `Apply ${args.ops.length} change(s) to the diagram`,
diff: (args) => args.ops.map((op) => ({
field: op.target,
current: op.before ?? '',
next: op.after ?? '',
})),
},
async handler(args) { /* … */ },
}

When approval is present, the kit gates the intent through its built-in client-local consensus dialog BEFORE running the handler — this is Responsibility-First: the assistant may not trigger a declared intent without an explicit human decision. severity sets the badge; summary(args) titles the request; diff(args) returns { field, current, next } rows the dialog renders as a before/after table.

The user sees the consensus dialog hold the proposed change — the diff shows exactly which nodes are about to land — and nothing runs until they approve.

The outcome shapes the call:

  • Approve — the handler runs with the model’s arguments.
  • Edit — the user’s edited values arrive as the handler’s arguments instead.
  • Reject — the handler never runs; the intent returns { success: false, error: 'Action declined by user' }, with the user’s reason appended when they gave one.

Only severity travels to the backend. The summary, diff, and handler all run in the browser, so the model never sees the human-readable prompt or the diff — it only learns the decision. This gate is the same model as server-side Consensus, applied to a tool that runs in the page.

Intents are re-read off the current adapter each time one is dispatched. If the page changed between the model deciding to call browser_apply_changes and the interrupt arriving — the user navigated away, you swapped adapters, the editor closed — the intent is no longer in adapter.intents. The kit returns { error: 'intent "apply_changes" no longer available — page changed' } rather than running a stale handler against a view that no longer exists. The model reads that and re-plans.

  • Tutorial: Plynth — a browser adapter whose browser_apply_changes intent edits a live diagram through an editor bridge, with destructive operations approval-gated.
  • Tutorial: Northwind — the built-in navigate tool moving a shopper between validated storefront routes.

Next: Frontend integration for loading the widget, or the Client command API reference for every diosc() command.