WebMCP is experimental. document.modelContext ships in Chrome only, behind an origin trial, and is not part of AG Studio's supported browser matrix. Treat this page as a pattern to build on, not a stable integration.
WebMCP lets a page publish its own capabilities as structured tools, so an AI agent running in the browser can call them directly.
api.getAiTools() returns Studio's built-in tools as live instances. Each one reads dashboard state when it executes, and none of them needs an AI harness, so they can be exposed to WebMCP as they are.
The tools do need the AI module registered, AgStudioModuleRegistry.registerModules([AgStudioAiModule]), because they read the field schema and dashboard context the module provides. Without it every tool still registers and runs, but returns an empty schema.
This is the clearest case of a Studio integration with no harness: the agent is the browser's, the UI is the browser's, and Studio contributes tools.
The example includes a small bridge, webmcpBridge.ts, which maps one Studio tool to one document.modelContext.registerTool call and keeps the registrations matching live state.
import { Component } from "@angular/core";
import { AgStudio } from "ag-studio-angular";
import {
AgDataEngine,
AgDataSourcesDefinition,
AgReportState,
AgStudioAiModule,
AgStudioApi,
AgStudioApiReadyEvent,
AgStudioMode,
AgStudioModuleRegistry,
AgStudioProperties,
enableStudioDevValidations,
} from "ag-studio";
import { getGhcnCitiesData } from "./shared/ghcnCities/data.ts";
import { ghcnCitiesReportState } from "./shared/ghcnCities/state.ts";
import { PageUpdater } from "./interfaces.ts";
import { StudioWebMcpBridge, createWebMcpBridge } from "./webmcpBridge.ts";
AgStudioModuleRegistry.registerModules([AgStudioAiModule]);
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
@Component({
selector: "my-app",
standalone: true,
imports: [AgStudio],
template: `<div style="display: flex; flex-direction: column; height: 100%">
<div class="example-controls">
<div class="controls-row">
<button type="button" (click)="setPage('temperature')">
Temperature
</button>
<button type="button" (click)="setPage('precipitation')">
Precipitation
</button>
<button type="button" (click)="setPage('blank')">Blank</button>
</div>
<div class="controls-row">
<button type="button" id="modeButton" (click)="toggleMode()">
Switch to view mode
</button>
<button type="button" (click)="removeWidgets()">Remove widgets</button>
<button type="button" (click)="restoreWidgets()">
Restore widgets
</button>
<button type="button" (click)="addPage()">Add page</button>
<button type="button" (click)="removePage()">Remove page</button>
<button type="button" (click)="addCalculatedField()">
Add calculated field
</button>
<button type="button" (click)="removeCalculatedField()">
Remove calculated field
</button>
<button type="button" id="callToolButton" (click)="callTool()">
Call a tool
</button>
</div>
</div>
<div class="webmcp-panel">
<div class="webmcp-notice" id="webmcpNotice"></div>
<div class="webmcp-error" id="webmcpError"></div>
<div id="advertisedTools"></div>
<div id="liveTools"></div>
<div class="webmcp-output" id="toolOutput"></div>
</div>
<ag-studio
style="width: 100%; height: 100%;"
class="my-studio-container"
[mode]="mode"
[initialState]="initialState"
[data]="data"
(studioReady)="onStudioReady($event)"
(stateUpdated)="onStateUpdated($event)"
(renderStateChanged)="onRenderStateChanged($event)"
(studioPreDestroyed)="onStudioPreDestroyed($event)"
(apiReady)="onApiReady($event)"
/>
</div> `,
})
export class AppComponent {
private studioApi!: AgStudioApi;
mode: AgStudioMode = "edit";
initialState: AgReportState = ghcnCitiesReportState;
data: AgDataSourcesDefinition | AgDataEngine = getGhcnCitiesData(
"https://www.ag-grid.com/studio/example-assets",
);
onStudioReady(event) {
const studio = event.api.getAiTools();
bridge = createWebMcpBridge([
{ tool: studio.viewSchema(), readOnly: true },
{ tool: studio.viewPage(), readOnly: true },
{ tool: studio.viewWidget(), readOnly: true },
{ tool: studio.executeQuery(), readOnly: true },
]);
void reconcileAndRender();
}
onStateUpdated() {
void reconcileAndRender();
}
onRenderStateChanged() {
void reconcileAndRender();
}
onStudioPreDestroyed() {
bridge?.destroy();
}
toggleMode(): void {
const mode =
this.studioApi.getProperty("mode") === "edit" ? "view" : "edit";
this.studioApi.setProperty("mode", mode);
updateModeButton(mode);
}
removeWidgets(): void {
this.updateSelectedPage((page) => ({
...page,
widgets: {},
widgetLayout: {},
}));
}
restoreWidgets(): void {
this.updateSelectedPage((page) => {
const original = populatedPages.find(
(candidate) => candidate.id === page.id,
);
return original
? {
...page,
widgets: original.widgets,
widgetLayout: original.widgetLayout,
}
: page;
});
}
setPage(pageId: string): void {
this.applyState({ ...this.studioApi.getState(), selectedPageId: pageId });
}
addPage(): void {
const state = this.studioApi.getState();
if (state.pages.some((page) => page.id === "secondary")) return;
this.applyState({ ...state, pages: [...state.pages, { id: "secondary" }] });
}
removePage(): void {
// Never remove the last page: with no pages `view_widget` goes uncallable, which would collide
// the page scenario with the widget scenario.
const state = this.studioApi.getState();
this.applyState({
...state,
pages: state.pages.filter((page) => page.id !== "secondary"),
selectedPageId: "temperature",
});
}
addCalculatedField(): void {
this.applyState({
...this.studioApi.getState(),
schema: {
expressions: [
{ isMeasure: false, id: "temp_range", tableId: "weather" },
],
fields: {
temp_range: {
name: "Temp Range",
expression: "[weather.tmax] - [weather.tmin]",
},
},
},
});
}
removeCalculatedField(): void {
this.applyState({ ...this.studioApi.getState(), schema: {} });
}
async callTool(): Promise<void> {
const output = document.querySelector<HTMLElement>("#toolOutput")!;
const { modelContext } = document;
if (modelContext == null) return;
try {
const advertised = await modelContext.getTools();
const tool = advertised.find(
(candidate) => candidate.name === "view_schema",
);
if (tool == null) {
output.textContent = "view_schema is not currently advertised.";
return;
}
// `view_schema` takes no parameters at all, so an empty object is a complete call.
const result = await modelContext.executeTool(tool, JSON.stringify({}));
// The browser hands back the tool's `content` array; older builds returned a bare string.
output.textContent =
typeof result === "string"
? result
: result.content.map((part) => part.text).join("\n");
} catch (err) {
output.textContent = `Tool call failed: ${err instanceof Error ? err.message : String(err)}`;
}
}
onApiReady(params: AgStudioApiReadyEvent) {
this.studioApi = params.api;
}
// `api.getState()` hands back Studio's live state object, and `api.setState()` ignores a call whose
// argument is that same reference. Every control below therefore builds a new state object, and a
// new object for the slice it changes - Studio also compares each state slice by reference.
applyState = (state: AgReportState): void => {
this.studioApi.setState(state);
void reconcileAndRender();
};
updateSelectedPage = (update: PageUpdater): void => {
const state = this.studioApi.getState();
this.applyState({
...state,
pages: state.pages.map((page) =>
page.id === state.selectedPageId ? update(page) : page,
),
});
};
}
// `view_widget` is the only one of the four tools that can go uncallable: its widget-id enum comes
// from the *selected* page's widgets, so it is withheld whenever that page has none. Two controls
// show this - clearing the current page, and the page toolbar's blank page, which starts empty.
const populatedPages = ghcnCitiesReportState.pages;
let bridge: StudioWebMcpBridge | undefined;
let reconcileCount = 0;
const NOTICE_TEXT = [
"This browser does not expose document.modelContext, so nothing is registered with the browser.",
"WebMCP is experimental: Chrome 149+ behind an origin trial or chrome://flags/#enable-webmcp-testing,",
"and it requires a secure context. The list below is what would be advertised.",
].join(" ");
function updateModeButton(mode: string): void {
document.querySelector<HTMLElement>("#modeButton")!.textContent =
mode === "edit" ? "Switch to view mode" : "Switch to edit mode";
}
async function reconcileAndRender(): Promise<void> {
const errorEl = document.querySelector<HTMLElement>("#webmcpError")!;
try {
await bridge?.reconcile();
renderPanel();
errorEl.textContent = bridge?.lastError() ?? "";
} catch (err) {
errorEl.textContent = `Reconcile failed: ${err instanceof Error ? err.message : String(err)}`;
}
}
/**
* The notice and the call button belong to the panel, not to start-up: a framework renders its
* template after mount, so anything written at mount time has nothing to write into yet.
*/
function renderSupportNotice(): void {
if (document.modelContext != null) {
return;
}
document.querySelector<HTMLElement>("#webmcpNotice")!.textContent =
NOTICE_TEXT;
document.querySelector<HTMLButtonElement>("#callToolButton")!.disabled = true;
}
function renderPanel(): void {
renderSupportNotice();
const container = document.querySelector<HTMLElement>("#advertisedTools")!;
container.dataset.reconciles = String(++reconcileCount);
container.replaceChildren();
for (const { name, registrations } of bridge?.getAdvertisedTools() ?? []) {
const item = document.createElement("div");
item.className = "advertised-tool";
item.dataset.tool = name;
item.dataset.registrations = String(registrations);
item.textContent = `${name} (registrations: ${registrations})`;
container.appendChild(item);
}
}
async function renderLiveTools(): Promise<void> {
const live = document.querySelector<HTMLElement>("#liveTools")!;
const { modelContext } = document;
if (modelContext == null) return;
try {
const advertised = await modelContext.getTools();
live.textContent = `Registered with the browser: ${advertised.map((tool) => tool.name).join(", ")}`;
} catch (err) {
document.querySelector<HTMLElement>("#webmcpError")!.textContent =
`Could not read the registered tools: ${err instanceof Error ? err.message : String(err)}`;
}
}
:root {
--webmcp-notice-fg: #8a5300;
--webmcp-error-fg: #b3261e;
}
:root[data-dark-mode='true'] {
--webmcp-notice-fg: #fec84b;
--webmcp-error-fg: #fda29b;
}
.webmcp-panel {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
font-size: 13px;
line-height: 1.4;
color: var(--main-fg);
}
.webmcp-notice {
color: var(--webmcp-notice-fg);
}
.webmcp-error:not(:empty) {
color: var(--webmcp-error-fg);
}
.advertised-tool {
font-family: monospace;
}
.webmcp-output {
white-space: pre-wrap;
max-height: 120px;
overflow: auto;
font-family: monospace;
}
import '@angular/compiler';
import { provideHttpClient } from '@angular/common/http';
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app.component.ts';
const app = bootstrapApplication(AppComponent, {
providers: [provideHttpClient()],
});
import type { AgPageState } from 'ag-studio';
export type PageUpdater = (page: AgPageState) => AgPageState;
import type { AgAiTool } from 'ag-studio';
import { createAiToolContext } from 'ag-studio';
import type { WebMcpModelContext, WebMcpToolResult } from './webmcpTypes.ts';
export interface AdvertisedTool {
name: string;
registrations: number;
}
/**
* One tool to bridge, plus whether it only reads the dashboard. A browser agent may relax its
* confirmation policy on a tool annotated `readOnlyHint`, so the caller states this per tool
* rather than the bridge assuming it.
*/
export interface WebMcpBridgedTool {
tool: AgAiTool;
readOnly: boolean;
}
export interface StudioWebMcpBridge {
reconcile(): Promise<void>;
getAdvertisedTools(): AdvertisedTool[];
isSupported(): boolean;
lastError(): string | undefined;
destroy(): void;
}
interface RegistryEntry {
signature: string;
controller: AbortController;
registrations: number;
}
let callCounter = 0;
export function createWebMcpBridge(bridged: readonly WebMcpBridgedTool[]): StudioWebMcpBridge {
const registry = new Map<string, RegistryEntry>();
const modelContext: WebMcpModelContext | undefined = document.modelContext;
let queue: Promise<void> = Promise.resolve();
let error: string | undefined;
let destroyed = false;
// The browser renders the `content` array of the returned result; a bare string leaves it with
// nothing to show, so every branch below wraps its text in one `text` part.
const asResult = (text: string): WebMcpToolResult => ({ content: [{ type: 'text', text }] });
// `ctx.signal` is accepted but never consulted by these four tools, so aborting a registration
// does not cancel an in-flight `execute_query`; a late result must be dropped by the caller.
async function runTool(tool: AgAiTool, args: Record<string, unknown>): Promise<WebMcpToolResult> {
if (tool.execute == null) {
return asResult(`${tool.name} is not client-executable`);
}
const controller = new AbortController();
const id = ++callCounter;
try {
const result = await tool.execute(
{ toolCallId: `webmcp-${id}`, name: tool.name, args },
createAiToolContext({
signal: controller.signal,
run: { threadId: 'webmcp', runId: `webmcp-run-${id}` },
})
);
return asResult(result.success ? result.response : result.issues.map((issue) => issue.message).join('; '));
} catch (err) {
return asResult(`${tool.name} failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
/** Returns false when the browser rejected the registration, so the caller can drop the entry. */
async function register(
{ tool, readOnly }: WebMcpBridgedTool,
parameters: unknown,
controller: AbortController
): Promise<boolean> {
if (modelContext == null || destroyed) return true;
try {
await modelContext.registerTool(
{
name: tool.name,
description: tool.description,
inputSchema: parameters,
annotations: { readOnlyHint: readOnly },
execute: (args) => runTool(tool, args),
},
{ signal: controller.signal }
);
return true;
} catch (err) {
error = `Failed to register ${tool.name}: ${err instanceof Error ? err.message : String(err)}`;
return false;
}
}
async function reconcileOnce(): Promise<void> {
error = undefined;
for (const entryToBridge of bridged) {
// A pass suspended on `registerTool` can resume after `destroy()`, and must not register
// anything then: those registrations would outlive the Studio instance behind them.
if (destroyed) return;
const { tool } = entryToBridge;
const schema = tool.schema();
const entry = registry.get(tool.name);
if (schema == null) {
if (entry != null) {
registry.delete(tool.name);
if (modelContext != null) entry.controller.abort();
}
continue;
}
const signature = JSON.stringify(schema.parameters);
if (entry?.signature === signature) continue;
const controller = new AbortController();
registry.set(tool.name, {
signature,
controller,
registrations: (entry?.registrations ?? 0) + 1,
});
if (entry != null && modelContext != null) entry.controller.abort();
// A failed registration must not leave its signature behind: a matching signature makes
// every later pass skip a tool the browser does not have.
if (!(await register(entryToBridge, schema.parameters, controller))) registry.delete(tool.name);
}
}
return {
reconcile() {
const pass = queue.then(reconcileOnce);
// The chain's tail must never stay rejected, or one failed pass silently disables every
// later `reconcile()`. The caller still sees this pass's rejection.
queue = pass.catch(() => undefined);
return pass;
},
getAdvertisedTools() {
return Array.from(registry, ([name, entry]) => ({ name, registrations: entry.registrations }));
},
isSupported() {
return modelContext != null;
},
lastError() {
return error;
},
destroy() {
destroyed = true;
if (modelContext != null) {
for (const entry of registry.values()) entry.controller.abort();
}
registry.clear();
},
};
}
/**
* Structural types for the experimental WebMCP browser API (`document.modelContext`), which is
* absent from `lib.dom`. Covers only what this example calls.
*/
export interface WebMcpToolAnnotations {
readOnlyHint?: boolean;
untrustedContentHint?: boolean;
}
/**
* A tool result in the MCP `CallToolResult` shape the browser expects back from `execute`. Returning
* a bare string instead leaves the browser with no `content` to render, so the call reports success
* with no output.
*/
export interface WebMcpToolResult {
content: Array<{ type: 'text'; text: string }>;
}
export interface WebMcpToolDescriptor {
name: string;
description: string;
/** JSON Schema for the tool's arguments. The browser serialises it, so the shape is opaque here. */
inputSchema: unknown;
annotations?: WebMcpToolAnnotations;
execute(args: Record<string, unknown>): WebMcpToolResult | Promise<WebMcpToolResult>;
}
export interface WebMcpRegisterOptions {
signal?: AbortSignal;
exposedTo?: string[];
}
/** A tool as reported back by `getTools()`: `inputSchema` arrives as a serialised JSON string. */
export interface WebMcpAdvertisedToolDescriptor {
name: string;
description: string;
inputSchema: string;
}
export interface WebMcpModelContext {
registerTool(descriptor: WebMcpToolDescriptor, options?: WebMcpRegisterOptions): Promise<void>;
getTools(options?: { fromOrigins?: string[] }): Promise<WebMcpAdvertisedToolDescriptor[]>;
executeTool(
tool: WebMcpAdvertisedToolDescriptor,
input: string,
options?: { signal?: AbortSignal }
): Promise<WebMcpToolResult | string>;
addEventListener(type: 'toolchange', listener: () => void): void;
removeEventListener(type: 'toolchange', listener: () => void): void;
}
declare global {
interface Document {
/** Optional by design: the absence of this property is the feature detection for WebMCP. */
modelContext?: WebMcpModelContext;
}
}
import type { AgReportState } from 'ag-studio';
/**
* Starting report states for the GHCN world-cities weather data, shared by the AI docs
* examples and the eval harness so each one does not restate a dashboard it is not about.
* Pair either state with `getGhcnCitiesData` from the sibling `data` module - the widgets
* below reference that schema's fields and measures.
*/
/** An empty canvas: one page, no widgets. For examples whose point is that the assistant
* builds the dashboard from nothing. */
export const ghcnCitiesBlankState: AgReportState = {
pages: [{ id: 'main', widgets: {}, widgetLayout: {} }],
selectedPageId: 'main',
panels: {
filters: { collapsed: true },
edit: { collapsed: true },
data: { collapsed: true },
},
};
/**
* A three-page weather report: a finished temperature page, a deliberately unfinished
* precipitation page for the assistant to complete, and a blank page to build on. For
* examples that need existing widgets to read, edit or reason about.
*/
export const ghcnCitiesReportState: AgReportState = {
pages: [
// Page 1: a complete, titled temperature report.
{
id: 'temperature',
widgets: {
'temp-heading': {
type: 'text',
dataMapping: {},
format: {
style: { text: 'Global City Temperatures', typography: { fontSize: 20, fontWeight: 'bold' } },
},
},
'kpi-avg-high': {
type: 'value',
dataMapping: { value: [{ id: 'avgHigh' }] },
format: { caption: { enabled: true, text: 'Avg High' } },
},
'kpi-avg-low': {
type: 'value',
dataMapping: { value: [{ id: 'avgLow' }] },
format: { caption: { enabled: true, text: 'Avg Low' } },
},
'kpi-avg-range': {
type: 'value',
dataMapping: { value: [{ id: 'avgTempRange' }] },
format: { caption: { enabled: true, text: 'Avg Daily Range' } },
},
'temp-trend': {
type: 'line-chart',
dataMapping: {
categoryKey: [{ id: 'calendar::year' }],
valueKey: [{ id: 'avgHigh' }, { id: 'avgLow' }],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: 'Average Temperature by Year',
typography: { fontSize: 16, fontWeight: 'bold' },
},
},
},
'record-highs': {
type: 'column-chart-grouped',
dataMapping: {
categoryKey: [{ id: 'cities.city' }],
valueKey: [{ id: 'weather.tmax', aggregation: 'max' }],
tooltipKey: [{ id: 'cities.country' }],
},
format: {
title: {
enabled: true,
text: 'Record High Temperature by City',
typography: { fontSize: 16, fontWeight: 'bold' },
},
},
},
'range-by-band': {
type: 'column-chart-grouped',
dataMapping: {
categoryKey: [{ id: 'cities.latitudeBand' }],
valueKey: [{ id: 'avgTempRange' }],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: 'Average Daily Temperature Range by Climate Band',
typography: { fontSize: 16, fontWeight: 'bold' },
},
},
},
'daily-grid': {
type: 'grid',
dataMapping: {
cols: [
{ id: 'cities.city' },
{ id: 'weather.date' },
{ id: 'weather.tmax', aggregation: 'avg' },
{ id: 'weather.tmin', aggregation: 'avg' },
{ id: 'tempRange', aggregation: 'avg' },
{ id: 'weather.prcp', aggregation: 'sum' },
],
},
format: {
title: {
enabled: true,
text: 'Daily Observations',
typography: { fontSize: 16, fontWeight: 'bold' },
},
style: { theme: { rowHeight: 28 } },
},
},
},
widgetLayout: {
'temp-heading': { xTrack: 0, yTrack: 0, xSpan: 24, ySpan: 3 },
'kpi-avg-high': { xTrack: 0, yTrack: 3, xSpan: 8, ySpan: 8 },
'kpi-avg-low': { xTrack: 8, yTrack: 3, xSpan: 8, ySpan: 8 },
'kpi-avg-range': { xTrack: 16, yTrack: 3, xSpan: 8, ySpan: 8 },
'temp-trend': { xTrack: 0, yTrack: 11, xSpan: 24, ySpan: 22 },
'record-highs': { xTrack: 0, yTrack: 33, xSpan: 12, ySpan: 26 },
'range-by-band': { xTrack: 12, yTrack: 33, xSpan: 12, ySpan: 26 },
'daily-grid': { xTrack: 0, yTrack: 59, xSpan: 24, ySpan: 34 },
},
filter: { page: [] },
},
// Page 2: a deliberately unfinished precipitation report for the AI to complete.
{
id: 'precipitation',
widgets: {
'precip-heading': {
type: 'text',
dataMapping: {},
format: {
style: {
text: 'Precipitation (work in progress)',
typography: { fontSize: 20, fontWeight: 'bold' },
},
},
},
'rain-by-city': {
type: 'column-chart-grouped',
dataMapping: {
categoryKey: [{ id: 'cities.city' }],
valueKey: [{ id: 'totalRainfall' }],
tooltipKey: [{ id: 'cities.country' }],
},
format: {
title: {
enabled: true,
text: 'Total Rainfall by City',
typography: { fontSize: 16, fontWeight: 'bold' },
},
},
},
'wet-days-by-band': {
type: 'column-chart-grouped',
dataMapping: {
categoryKey: [{ id: 'cities.latitudeBand' }],
valueKey: [{ id: 'wetDays' }],
tooltipKey: [],
},
format: {
title: {
enabled: true,
text: 'Wet Days by Climate Band',
typography: { fontSize: 16, fontWeight: 'bold' },
},
},
},
},
widgetLayout: {
'precip-heading': { xTrack: 0, yTrack: 0, xSpan: 24, ySpan: 3 },
'rain-by-city': { xTrack: 0, yTrack: 3, xSpan: 12, ySpan: 26 },
'wet-days-by-band': { xTrack: 12, yTrack: 3, xSpan: 12, ySpan: 26 },
},
filter: { page: [] },
},
// Page 3: a blank canvas, ready for editing.
{
id: 'blank',
widgets: {},
widgetLayout: {},
filter: { page: [] },
},
],
selectedPageId: 'temperature',
panels: {
ai: { collapsed: false },
filters: { collapsed: false },
edit: { collapsed: true },
data: { collapsed: true },
},
};
import type {
AgDataSourceDefinition,
AgDataSourcesDefinition,
AgExpressionFieldDefinition,
AgFieldDefinition,
AgRelationDefinition,
} from 'ag-studio';
// NOAA GHCN-Daily "world cities" dataset. Weather facts are stored column-wise as
// raw GHCN integers (temperatures/precip in tenths, snow in mm) with dates as
// integer days since 1970-01-01; the scaling and date conversion below are the
// "format in the browser" step, so the shipped asset stays maximally compact.
//
// The asset base URL is supplied by the caller (a docs example passes its
// substituted asset path; the eval harness passes its own served path), so this
// canonical dataset is not bound to any one host's asset layout.
const MS_PER_DAY = 86_400_000;
// Raw column arrays keyed by field id, exactly as emitted by the generator
// (cityId/date are integer arrays; the measures may contain nulls).
type WeatherColumns = Record<string, (number | null)[]>;
// Keyed by base URL, not held once per process: the whole point of taking the URL from the caller
// is that two callers in one process can serve the asset from different roots, and a single cache
// would hand the second caller the first one's data.
const rawColumnsByBaseUrl = new Map<string, Promise<WeatherColumns>>();
const columnCache = new Map<string, (number | null)[]>();
function loadRawColumns(baseUrl: string): Promise<WeatherColumns> {
let columns = rawColumnsByBaseUrl.get(baseUrl);
if (columns == null) {
columns = fetch(`${baseUrl}/weather.columns.json`).then((r) => r.json());
rawColumnsByBaseUrl.set(baseUrl, columns);
}
return columns;
}
// Column values are transformed to display units once and memoised - repeated
// queries for the same field reuse the converted array.
async function getWeatherColumn(baseUrl: string, fieldId: string): Promise<(number | null)[]> {
const cacheKey = `${baseUrl}\u0000${fieldId}`;
const cached = columnCache.get(cacheKey);
if (cached != null) {
return cached;
}
const raw = await loadRawColumns(baseUrl);
const source = raw[fieldId] ?? [];
let column: (number | null)[];
if (fieldId === 'date') {
// Dates reach the engine as epoch milliseconds, which is the cheapest form it accepts:
// it converts them with a single division, where an ISO string costs a regex test and
// three slices per row. Two constraints on this line:
// - The multiply is required. A bare number is read as milliseconds, so passing the
// stored day integers straight through is not an error, it silently lands every
// observation in 1970.
// - Do not wrap this in a `new Date(...)`. A Date is read through local calendar
// accessors while a number is read as UTC, so a UTC-midnight Date decodes to the
// previous day anywhere west of Greenwich - the dataset would shift by a day
// depending on the reader's timezone.
column = source.map((day) => (day == null ? null : day * MS_PER_DAY));
} else if (fieldId === 'tmax' || fieldId === 'tmin' || fieldId === 'prcp') {
column = source.map((value) => (value == null ? null : value / 10));
} else {
column = source;
}
columnCache.set(cacheKey, column);
return column;
}
const weatherFields: AgFieldDefinition[] = [
{
id: 'cityId',
name: 'City ID',
description: 'Foreign key to the cities table (cities.id) identifying which city this reading belongs to.',
format: 'integerFormat',
cardinality: 'low',
hide: true,
},
{
id: 'date',
name: 'Date',
description: 'Calendar date of the observation. The data is daily - one row per city per day.',
format: 'dateFormat',
cardinality: 'high',
notBlank: true,
},
{
id: 'tmax',
name: 'Max Temp (°C)',
description: 'Highest air temperature recorded during the day, in degrees Celsius.',
format: 'decimalFormat',
cardinality: 'medium',
},
{
id: 'tmin',
name: 'Min Temp (°C)',
description: 'Lowest air temperature recorded during the day, in degrees Celsius.',
format: 'decimalFormat',
cardinality: 'medium',
},
{
id: 'prcp',
name: 'Precipitation (mm)',
description:
'Total precipitation for the day (rain plus melted snow), in millimetres. 0 is a dry day; a blank means it was not recorded.',
format: 'decimalFormat',
cardinality: 'medium',
},
{
id: 'snow',
name: 'Snowfall (mm)',
description:
'Fresh snow that fell during the day, in millimetres. Usually 0 or blank outside cold-climate cities. Distinct from snow depth.',
format: 'integerFormat',
cardinality: 'medium',
},
{
id: 'snwd',
name: 'Snow Depth (mm)',
description:
'Depth of snow lying on the ground at observation time, in millimetres. Distinct from snowfall, which is only the fresh fall that day.',
format: 'integerFormat',
cardinality: 'medium',
},
];
const cityFields: AgFieldDefinition[] = [
{
id: 'id',
name: 'City ID',
description: 'Primary key; the join target for weather.cityId.',
format: 'integerFormat',
cardinality: 'low',
hide: true,
},
{
id: 'city',
name: 'City',
description: 'City name. This is the label most reports group by.',
format: 'textFormat',
cardinality: 'low',
},
{
id: 'country',
name: 'Country',
description: 'Country the city is located in.',
format: 'textFormat',
cardinality: 'low',
},
{
id: 'region',
name: 'Region',
description: 'Continent-level grouping, such as Europe, Asia or North America.',
format: 'textFormat',
cardinality: 'low',
},
{
id: 'latitudeBand',
name: 'Climate Band',
description: 'Climate band derived from latitude: Tropical, Subtropical, Temperate, Subpolar or Polar.',
format: 'textFormat',
cardinality: 'low',
},
{
id: 'latitude',
name: 'Latitude',
description: 'City-centre latitude in decimal degrees (positive north). Suitable for plotting on a map.',
format: 'decimalFormat',
cardinality: 'low',
},
{
id: 'longitude',
name: 'Longitude',
description: 'City-centre longitude in decimal degrees (positive east).',
format: 'decimalFormat',
cardinality: 'low',
},
{
id: 'elevation',
name: 'Elevation (m)',
description: 'Elevation of the backing weather station, in metres above sea level.',
format: 'decimalFormat',
cardinality: 'low',
},
{
id: 'stationName',
name: 'Station',
description: 'Name of the NOAA GHCN weather station whose readings back this city.',
format: 'textFormat',
cardinality: 'low',
},
];
function getWeatherSource(baseUrl: string): AgDataSourceDefinition<'column'> {
return {
id: 'weather',
name: 'Daily Weather',
dataShape: 'column',
tables: [
{
id: 'weather',
name: 'Daily Weather',
description:
'Daily weather observations, one row per city per day. Temperatures are in degrees Celsius and precipitation and snow in millimetres; a blank means the value was not recorded that day. Join cityId to the cities table for city attributes.',
fields: weatherFields,
},
],
getData: async (_tableId, fieldIds) => ({
data: await Promise.all(fieldIds.map((fieldId) => getWeatherColumn(baseUrl, fieldId))),
}),
};
}
const citiesByBaseUrl = new Map<string, Promise<Record<string, unknown>[]>>();
function loadCities(baseUrl: string): Promise<Record<string, unknown>[]> {
let cities = citiesByBaseUrl.get(baseUrl);
if (cities == null) {
cities = fetch(`${baseUrl}/cities.json`).then((r) => r.json());
citiesByBaseUrl.set(baseUrl, cities);
}
return cities;
}
function getCitiesSource(baseUrl: string): AgDataSourceDefinition<'row'> {
return {
id: 'cities',
name: 'Cities',
dataShape: 'row',
tables: [
{
id: 'cities',
name: 'Cities',
description:
'One row per city: the dimension describing each city and the weather station backing it. Join cities.id to weather.cityId.',
fields: cityFields,
},
],
getData: async () => ({ data: await loadCities(baseUrl) }),
};
}
const relationships: AgRelationDefinition[] = [
{
id: 'weather-cities',
source: { tableId: 'weather', fieldId: 'cityId' },
target: { tableId: 'cities', fieldId: 'id' },
type: 'many-to-one',
},
// Bind the observation date to a generated calendar (no date table needed) so
// charts can group by `calendar::year`, `calendar::monthOfYear`, etc.
{
id: 'weather-calendar',
source: { tableId: 'weather', fieldId: 'date' },
target: { calendarId: 'calendar' },
},
];
// A day counts as "frost"/"hot"/"wet" via a 0/1 calculated column; the matching
// measures below sum those flags. Comparing a null reading yields no count.
function dayFlag(fieldId: string, operator: 'lessThan' | 'greaterThanOrEqual', threshold: number) {
return {
operator: 'if' as const,
inputs: [
{ operator, inputs: [{ id: fieldId }, { type: 'number' as const, value: threshold }] },
{ type: 'number' as const, value: 1 },
{ type: 'number' as const, value: 0 },
],
};
}
const expressions: AgExpressionFieldDefinition[] = [
// --- Calculated columns (row-level) ---
{
id: 'tempRange',
name: 'Temp Range (°C)',
description:
'Daily temperature range (max temp minus min temp), in degrees Celsius. A large range suggests a continental or dry climate; a small range suggests a maritime one.',
isMeasure: false,
format: 'decimalFormat',
expression: { operator: 'subtract', inputs: [{ id: 'weather.tmax' }, { id: 'weather.tmin' }] },
},
{
id: 'isFrost',
isMeasure: false,
format: 'integerFormat',
hide: true,
expression: dayFlag('weather.tmin', 'lessThan', 0),
},
{
id: 'isHot',
isMeasure: false,
format: 'integerFormat',
hide: true,
expression: dayFlag('weather.tmax', 'greaterThanOrEqual', 30),
},
{
id: 'isWet',
isMeasure: false,
format: 'integerFormat',
hide: true,
expression: dayFlag('weather.prcp', 'greaterThanOrEqual', 1),
},
// --- Measures (aggregates over the grouped period) ---
{
id: 'avgHigh',
name: 'Avg High (°C)',
description: 'Average of the daily maximum temperatures over the grouped period, in degrees Celsius.',
isMeasure: true,
format: 'decimalFormat',
expression: { id: 'weather.tmax', aggregation: 'avg' },
},
{
id: 'avgLow',
name: 'Avg Low (°C)',
description: 'Average of the daily minimum temperatures over the grouped period, in degrees Celsius.',
isMeasure: true,
format: 'decimalFormat',
expression: { id: 'weather.tmin', aggregation: 'avg' },
},
{
id: 'avgTempRange',
name: 'Avg Temp Range (°C)',
description: 'Average daily temperature range (max minus min) over the grouped period, in degrees Celsius.',
isMeasure: true,
format: 'decimalFormat',
expression: { id: 'tempRange', aggregation: 'avg' },
},
{
id: 'totalRainfall',
name: 'Total Rainfall (mm)',
description: 'Total precipitation over the grouped period, in millimetres.',
isMeasure: true,
format: 'decimalFormat',
expression: { id: 'weather.prcp', aggregation: 'sum' },
},
{
id: 'totalSnowfall',
name: 'Total Snowfall (mm)',
description: 'Total fresh snowfall over the grouped period, in millimetres.',
isMeasure: true,
format: 'integerFormat',
expression: { id: 'weather.snow', aggregation: 'sum' },
},
{
id: 'frostDays',
name: 'Frost Days',
description: 'Number of days in the grouped period with a minimum temperature below 0°C.',
isMeasure: true,
format: 'integerFormat',
expression: { id: 'isFrost', aggregation: 'sum' },
},
{
id: 'hotDays',
name: 'Hot Days (≥30°C)',
description: 'Number of days in the grouped period with a maximum temperature of at least 30°C.',
isMeasure: true,
format: 'integerFormat',
expression: { id: 'isHot', aggregation: 'sum' },
},
{
id: 'wetDays',
name: 'Wet Days (≥1mm)',
description: 'Number of days in the grouped period with at least 1 mm of precipitation.',
isMeasure: true,
format: 'integerFormat',
expression: { id: 'isWet', aggregation: 'sum' },
},
];
export function getGhcnCitiesData(assetsBaseUrl: string): AgDataSourcesDefinition {
const baseUrl = `${assetsBaseUrl}/ghcn-cities`;
return {
description:
'Daily weather for 39 major world cities over roughly the last 100 years, from NOAA ' +
'GHCN-Daily. The weather table has one row per city per day (max/min temperature in degrees ' +
'Celsius, precipitation and snow in millimetres); a blank reading means it was not recorded. ' +
'Each row joins via cityId to the cities dimension (city, country, region, climate band, ' +
'coordinates and the backing station). The observation date is bound to a calendar, so results ' +
'can be grouped or trended by year, quarter, month or month-of-year. Calculated fields add the ' +
'daily temperature range; measures provide average high/low, average range, total ' +
'rainfall/snowfall, and counts of frost days (min below 0°C), hot days (max at least 30°C) and ' +
'wet days (at least 1 mm). Typical questions: compare cities or climate bands, show long-term ' +
'temperature trends, or find the wettest or snowiest places.',
sources: [getWeatherSource(baseUrl), getCitiesSource(baseUrl)],
relationships,
expressions,
// Generated spine covering the ~100-year data window (see the generator's
// --start-year). Keep `from`/`to` aligned with the data on each release refresh.
calendars: [
{
id: 'calendar',
label: 'Calendar',
range: { from: { type: 'date', value: '1926-01-01' }, to: { type: 'date', value: '2026-12-31' } },
fragments: ['year', 'quarter', 'month', 'monthOfYear', 'dayOfWeek'],
},
],
};
}
The Read-Only Slice Copy Link
api.getAiTools() returns fifteen tools, including a per-widget configureWidget factory and tools that only make sense inside Studio's own harness. The example publishes four: view_schema, view_page, view_widget, and execute_query.
Each is passed to the bridge as { tool, readOnly: true }, which the bridge turns into annotations.readOnlyHint. The caller declares that per tool, because a browser agent may relax its confirmation policy on a tool the page annotates as read-only.
Registering the mutating tools instead would hand an arbitrary external agent write access to the dashboard, and would push multi-step orchestration onto a client that has none of Studio's system prompts. Four read-only tools stay small, need no AI harness, and still exercise every branch of the reconcile pattern below.
| Tool | Parameters | Behaviour |
|---|---|---|
view_schema | None | Static schema. Always callable. |
view_page | None | Reports the active page. Always callable. |
view_widget | Live enums of page and widget ids | Uncallable while the dashboard has no widgets or no pages. |
execute_query | Query shape derived from the loaded field schema | Always callable. Its schema content changes with the field schema. |
view_widget is the only one of the four that can become uncallable. view_page carries no page id, and execute_query falls back to a non-empty field list, so neither ever drops out of the advertised set.
The Reconcile Pattern Copy Link
A Studio tool's schema is derived from live state. tool.schema() re-reads that state on every call and returns undefined while the tool is uncallable. A WebMCP registration is the opposite: once registerTool resolves, the descriptor is fixed until its AbortSignal fires. Bridging the two takes a reconcile step.
The bridge keeps one AbortController per registered tool, plus a signature of the serialised inputSchema. On each pass, for every tool:
schema()returnsundefined- abort the controller and drop the entry, so the tool is no longer advertised.- The signature matches the stored one - do nothing, so calling
reconcile()more often than needed costs nothing. - The signature differs - abort the old controller, then register a fresh descriptor.
- There is no entry - register.
A rejected registerTool drops the entry again, so the next pass retries instead of skipping a tool the browser never accepted. lastError() carries the reason until the following pass.
reconcile() is async and serialised. registerTool returns a promise, and Chrome does not specify what happens when a name is re-registered while its unregistration is still pending, so the bridge chains each abort and register rather than firing both in one tick.
What Drives a Reconcile Copy Link
The example calls reconcile() from onStudioReady, onStateUpdated and onRenderStateChanged, and calls destroy() from onStudioPreDestroyed. destroy() aborts every controller and also stops any pass still waiting on registerTool, which would otherwise register a tool against a destroyed Studio instance.
Those events do not cover every state change.
A clean api.setState() raises no stateUpdated event. Page and widget changes an application drives through setState are invisible to the public event surface, so the example calls reconcile() explicitly after each of its own setState calls.
Replacing the reactive data property swaps the rows but does not re-derive the field schema. execute_query keeps enumerating the previous source's field ids, and reconcile() cannot correct it, because the schema signature has not changed. The route to a new field set is api.destroy() followed by a fresh createStudio(), which rebuilds the bridge from a new api.getAiTools().
Adding or removing a calculated field does change the field schema, and it dispatches stateUpdated, so it reconciles on its own. The example uses that control to show execute_query re-registering, and the widget controls to show view_widget leaving and re-entering the advertised set.
Tool Parameters Copy Link
A Studio tool advertises exactly the parameters its own action needs. A command-backed tool exposes its command's schema verbatim, with no status or envelope parameters wrapped around it, and view_schema takes none at all.
The bridge advertises the schema's parameters as they come. The one exception is execute_query, whose query shape is a union of the aggregation and projection forms. The bridge nests that under a query key so the root stays an object, because several LLM providers reject a tool whose parameters root is anyOf - see JSON Schema Support.
Availability Copy Link
document.modelContext requires Chrome 149 or later, a secure context, and the origin trial enabled. For local development, turn on chrome://flags/#enable-webmcp-testing. The API is gated by the tools permissions policy, which defaults to self, so a cross-origin iframe needs allow="tools".
navigator.modelContext is the deprecated spelling of the same API. Use document.modelContext.
The example feature-detects document.modelContext. When it is missing, the example renders a notice, disables the button that calls a tool, and keeps running the reconcile bookkeeping, so the panel still shows which tools would be advertised in any browser.
Extending to the Mutating Tools Copy Link
The same bridge handles the mutating tools without change. They are AgAiTool instances with the same schema() and execute() shape. What changes is the risk.
Pass them as { tool, readOnly: false } so the bridge does not claim readOnlyHint for a tool that writes, and use exposedTo on the register options to limit which agents can reach a tool. Authenticating and permissioning the WebMCP surface is out of scope for the example, and is yours to design.
Next Copy Link
- Tools Overview - the tools this page publishes, and running them yourself
- Custom Tools - authoring a tool of your own to publish
- Agent Context - describing the data to an agent