A tool is how an agent acts on a dashboard. It is a self-contained value: it advertises a JSON schema to the model, and when the model calls it, it runs against live state and reports back.
Tools need no chat panel, no harness, and no agent. api.getAiTools() returns values you can execute yourself, and everything else in this section builds on them.
The example below has no panel, no harness and no LLM. Each button drives Studio through tools and commands directly, and the console shows what each returns.
import {
createApp,
defineComponent,
onBeforeMount,
ref,
shallowRef,
} from "vue";
import { AgStudio } from "ag-studio-vue3";
import {
AgAiTool,
AgAiToolResult,
AgApiQuery,
AgDataEngine,
AgDataSourcesDefinition,
AgReportState,
AgStudioAiModule,
AgStudioApi,
AgStudioApiReadyEvent,
AgStudioMode,
AgStudioModuleRegistry,
AgStudioProperties,
createAiToolContext,
enableStudioDevValidations,
} from "ag-studio";
import { getGhcnCitiesData } from "./shared/ghcnCities/data.ts";
import { ghcnCitiesBlankState } from "./shared/ghcnCities/state.ts";
AgStudioModuleRegistry.registerModules([AgStudioAiModule]);
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
/** Run one tool the way a harness would: its arguments, and a per-call context. */
async function runTool(
tool: AgAiTool,
args: Record<string, unknown>,
): Promise<AgAiToolResult> {
const result = await tool.execute!(
{ toolCallId: `call-${Date.now()}`, name: tool.name, args },
createAiToolContext(),
);
if (result.success) {
console.log(`[${tool.name}] ${result.response}`, result.data ?? "");
} else {
console.warn(
`[${tool.name}] rejected:`,
result.issues.map((issue) => issue.message).join("; "),
);
}
return result;
}
const VueExample = defineComponent({
template: `
<div style="height: 100%">
<div style="display: flex; flex-direction: column; height: 100%">
<div class="example-controls">
<div class="controls-row">
<button v-on:click="describeContext()">Describe context</button>
<button v-on:click="listAdvertisedTools()">Which tools are callable?</button>
<button v-on:click="viewSchema()">Run view_schema</button>
<button v-on:click="runQuery()">Run execute_query</button>
<button v-on:click="buildFromTemplate()">Build from a template</button>
</div>
</div>
<ag-studio
style="width: 100%; height: 100%;"
class="my-studio-container"
@api-ready="onApiReady"
:data="data"
:mode="mode"
:initialState="initialState"></ag-studio>
</div>
</div>
`,
components: {
"ag-studio": AgStudio,
},
setup(props) {
const studioApi = shallowRef<AgStudioApi | null>(null);
const data = ref<AgDataSourcesDefinition | AgDataEngine>(
getGhcnCitiesData("https://www.ag-grid.com/studio/example-assets"),
);
const mode = ref<AgStudioMode>("edit");
const initialState = ref<AgReportState>(ghcnCitiesBlankState);
/** What an agent would read before deciding anything. */
const describeContext: () => void = () => {
const context = studioApi.value.getAiContext();
const { tables } = context.schema();
console.log(
"[context] tables:",
tables.map(
(table) =>
`${table.name} (${table.fields.map((field) => field.name).join(", ")})`,
),
);
console.log(
"[context] widget types available:",
context.catalogue().length,
);
console.log(
"[context] aggregations:",
context.vocabulary.aggregations.join(", "),
);
};
const viewSchema: () => Promise<void> = async () => {
await runTool(studioApi.value.getAiTools().viewSchema(), {});
};
const runQuery: () => Promise<void> = async () => {
const query: AgApiQuery = {
kind: "apiQuery",
variant: "aggregation",
axes: [{ dimensions: [{ field: { id: "cities.city" }, as: "city" }] }],
// `as` names the output column. Without one the engine emits its internal key, which is
// unreadable to a model as well as to a person.
measures: [
{ field: { id: "weather.tmax", aggregation: "avg" }, as: "avgHigh" },
],
};
// `execute_query` nests the query under a key, so its schema root stays an object.
await runTool(studioApi.value.getAiTools().executeQuery(), { query });
};
/**
* Build a page from a template using the validated commands, with no model involved.
*
* `AgAddWidgetCommand` seeds a widget's type and its place on the grid and nothing else, so each
* entry below carries the configuration that `AgConfigureWidgetCommand` applies straight after.
*/
const buildFromTemplate: () => Promise<void> = async () => {
const addWidget = studioApi.value.defineAiCommand({
type: "AgAddWidgetCommand",
});
const template = [
{
widgetId: "temp-by-city",
type: "bar-chart-grouped",
layout: { xTrack: 0, yTrack: 0, xSpan: 12, ySpan: 26 },
config: {
dataMapping: {
categoryKey: [{ id: "cities.city" }],
valueKey: [{ id: "avgHigh" }],
},
format: { title: { enabled: true, text: "Average High by City" } },
},
},
{
widgetId: "rainfall-by-band",
type: "donut-chart",
layout: { xTrack: 12, yTrack: 0, xSpan: 12, ySpan: 26 },
config: {
dataMapping: {
categoryKey: [{ id: "cities.latitudeBand" }],
valueKey: [{ id: "totalRainfall" }],
},
format: {
title: { enabled: true, text: "Total Rainfall by Climate Band" },
},
},
},
{
widgetId: "detail",
type: "grid",
layout: { xTrack: 0, yTrack: 26, xSpan: 24, ySpan: 30 },
config: {
dataMapping: {
cols: [
{ id: "cities.city" },
{ id: "cities.country" },
{ id: "avgHigh" },
{ id: "avgLow" },
{ id: "totalRainfall" },
],
},
format: { title: { enabled: true, text: "City Summary" } },
},
},
] as const;
for (const { widgetId, type, layout, config } of template) {
const added = await addWidget.apply({ widgetId, type, ...layout });
if (!added.success) {
console.warn("[template] rejected", widgetId, added.error.issues);
continue;
}
// A configure command is built per widget type, because its input shape is that widget
// type's own config - there is no one shape covering every widget.
const configureWidget = studioApi.value.defineAiCommand({
type: "AgConfigureWidgetCommand",
params: { widgetType: type },
});
const configured = await configureWidget.apply({
op: "patch",
widgetId,
value: config,
});
if (!configured.success) {
console.warn(
"[template] placed but not configured",
widgetId,
configured.error.issues,
);
continue;
}
console.log("[template] placed and configured", widgetId);
}
};
/** An agent's-eye view: which tools can be called right now, and with what parameters. */
const listAdvertisedTools: () => void = () => {
const studio = studioApi.value.getAiTools();
const tools = [
studio.viewSchema(),
studio.viewPage(),
studio.viewWidget(),
studio.executeQuery(),
studio.addWidget(),
studio.positionWidget(),
];
for (const tool of tools) {
const schema = tool.schema();
console.log(
schema
? `[advertised] ${tool.name}`
: `[withheld] ${tool.name} - nothing to offer in the current state`,
schema?.parameters ?? "",
);
}
};
const onApiReady = (params: AgStudioApiReadyEvent) => {
studioApi.value = params.api;
};
return {
studioApi,
data,
mode,
initialState,
onApiReady,
describeContext,
viewSchema,
runQuery,
buildFromTemplate,
listAdvertisedTools,
};
},
});
const app = createApp(VueExample);
app.mount("#app");
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'],
},
],
};
}
Anatomy Copy Link
The name the LLM calls it by.
|
What the tool does, as the LLM reads it.
|
Where the tool runs. See AgAiToolKind.
|
The current LLM-facing schema, or undefined when uncallable.
|
Carries out one call against current state. Present only on a client tool: a server or provided tool is declared here and run elsewhere, so guard this rather than assuming it.
|
Three parts of that shape are worth calling out.
schema() is a function, not a value. It is called every turn, and it reads live state. A tool whose parameters include a widget-id enum reports the widgets that exist now.
A tool can be uncallable. When schema() returns undefined, the tool is withheld from that turn entirely - not advertised, not callable. That happens to configure_widget on a page with no widgets: rather than offering the model a choice with no valid values, the tool drops out until there is something to configure.
execute is optional. A client tool runs here. A server or provided tool is declared here and run elsewhere - see External Tools.
Commands: the Layer Below Copy Link
Most tools that change something are built on a command, and the split matters when you author your own.
A command is an action: an input shape plus an execute. It validates its arguments against the shape, applies the change, and returns a discriminated result. It has no name, no description and no idea an LLM exists.
A tool is the LLM-facing wrapper: the name and description the model sees, and the formatting that turns what the command returned into a response the model can read.
// The command: schema + action.
const setTheme = api.defineAiCommand((s) => ({
input: s.object({ theme: s.enum(['light', 'dark']) }),
execute: ({ theme }) => {
applyTheme(theme);
return { success: true, value: theme };
},
}));
// The tool: what the model sees.
const setThemeTool = api.defineAiTool({
name: 'set_theme',
description: 'Switch the dashboard between light and dark.',
command: setTheme,
result: (theme) => ({ response: `Theme is now ${theme}.` }),
});The command's input shape is the tool's schema, with nothing wrapped around it. Splitting them this way lets the validated action be reused: the same command can back a tool, a button in your own UI, and a scripted migration.
Results Copy Link
A tool returns a discriminated result, never a thrown error:
execute: (args, ctx) => {
if (!isAllowed(args)) {
return ctx.error('That region is not available to this user.');
}
return ctx.success(`Found ${rows.length} rows.`, { rows });
},response is the string the model reads. data is an optional structured payload, serialised to the model and handed to the tool's component for rendering. Failures carry messages the model sees, so it can correct itself and try again.
The Four Authoring Paths Copy Link
| You want | Use | Page |
|---|---|---|
| A tool Studio already ships | api.getAiTools() | Built-in Tools |
| Your own action, validated | api.defineAiTool({ command, result }) | Custom Tools |
| Studio's action, your wrapper | api.defineAiCommand({ type: 'AgX' }) then wrap it | Custom Tools |
| A tool run by a server or the provider | api.defineAiTool({ kind: 'server' | 'provided' }) | External Tools |
Descriptions Are Prompts Copy Link
A tool's description is prompt text, and it is the cheapest lever you have over behaviour. There are two ways to change it without touching the tool:
- Per listing, for one agent:
studio.addWidget({ description: '...' }) - Globally: the
aiTextproperty, keyedtools.<name>.description
Both work for tools you define yourself, under the name the tool advertises. aiText holds prompt text rather than reader-facing wording, so it is not translated - see Localisation.
Without a Harness Copy Link
A harness is only needed to manage a conversation. The tools work on their own, driven by an automated process, an agent of your own, or something else entirely. WebMCP publishes them to the browser's own agent.
The AI module must still be registered, because it carries the tools, the context service and the licence. Tool schemas read live state, so build them per turn rather than once.
Running a Tool Copy Link
A tool takes an invocation and a per-call context, and hands back a discriminated result:
const studio = api.getAiTools();
const tool = studio.executeQuery();
const result = await tool.execute!({ toolCallId: 'call-1', name: tool.name, args: { query } }, createAiToolContext());An invocation carries the call id, the tool name, and args as a decoded object. If you are relaying a call from a model, parse its arguments first - the tool validates them against its own shape, so a bad payload comes back as issues rather than an exception.
createAiToolContext builds what a harness would normally supply. Pass a signal to make the call cancellable, and a run if a tool needs to scope a resource per conversation.
execute is optional on AgAiTool, because external tools declare a schema without one. Every tool from api.getAiTools() has one, hence the assertion above; guard it instead when the tool could be external.
Running a Command Copy Link
For an automated process with no LLM anywhere, use a command rather than a tool. It validates its input and returns a result:
const addWidget = api.defineAiCommand({ type: 'AgAddWidgetCommand' });
const result = await addWidget.apply({
widgetId: 'sales',
type: 'bar-chart-grouped',
xTrack: 0,
yTrack: 0,
xSpan: 6,
ySpan: 4,
});
if (!result.success) {
console.error(result.error.message);
}Custom Tools covers writing commands of your own.
Next Copy Link
- Built-in Tools - the catalogue
- Custom Tools - authoring
- Tool Components - rendering a call in the panel