The chat panel renders a run of tool calls as a sequence of steps, one line each, with a marker carrying the outcome.
You declare two things per tool. label gives a step its words. detail gives it a component for the body that opens when the reader expands it: a result table, a preview, a confirmation prompt. A tool with no detail has no body, and its step does not expand.
This applies only when you use Studio's chat panel. An integration with no harness renders whatever it likes.
The example below opens on a conversation that has already run, so both kinds of detail are on screen straight away: Studio's own execute_query, and a web_search tool the example declares with a component of its own. Expand either, or send a message to watch the streaming and executing phases. The agent is scripted, so no LLM is involved.
import {
AgAiEvent,
AgAiModel,
AgAiRunInput,
AgStudioAiModule,
AgStudioApi,
AgStudioModuleRegistry,
AgStudioProperties,
clientToolRunner,
createAiHarness,
createStudio,
enableStudioDevValidations,
} from "ag-studio";
import { getGhcnCitiesData } from "./shared/ghcnCities/data.ts";
import { ghcnCitiesReportState } from "./shared/ghcnCities/state.ts";
import { AGENT_ID, SEARCH_HITS, createSeededHistoryStore } from "./history.ts";
import { SearchResults } from "./searchResults.ts";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
AgStudioModuleRegistry.registerModules([AgStudioAiModule]);
/**
* The models offered beside the send button. The scripted agent ignores the choice - nothing here
* reaches a provider - but the ids are the ones an app would declare, so the picker reads as it
* would in one.
*/
const MODELS: AgAiModel[] = [
{ id: "gpt-5.6-terra", label: "GPT-5.6 Terra" },
{ id: "gpt-5.6-sol", label: "GPT-5.6 Sol" },
{ id: "gpt-5.6-luna", label: "GPT-5.6 Luna" },
];
/** Wait, so the example shows the streaming and executing phases rather than flashing past them. */
function pause(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const studioProperties: AgStudioProperties = {
data: getGhcnCitiesData("https://www.ag-grid.com/studio/example-assets"),
mode: "edit",
initialState: ghcnCitiesReportState,
ai: ({ api }) => {
/**
* A provider-hosted tool: the LLM provider runs the search itself and returns the result
* inline, so this declares the tool without an `execute`. Studio never calls it - it only
* has to know how to present what comes back.
*/
const webSearch = api.defineAiTool({
name: "web_search",
description: "Search the web and return the most relevant results.",
kind: "provided",
provider: { type: "web_search" },
});
/**
* A scripted loop, so the example needs no LLM. It emits the search call, streams its
* arguments, then emits the result itself - which is what a provider-hosted tool looks like
* from the client's side, and is why the run settles in a single round.
*/
async function* run(input: AgAiRunInput): AsyncGenerator<AgAiEvent> {
const { threadId, runId } = input;
yield { type: "RUN_STARTED", threadId, runId };
const introId = `msg-${runId}-intro`;
yield {
type: "TEXT_MESSAGE_START",
messageId: introId,
role: "assistant",
};
yield {
type: "TEXT_MESSAGE_CONTENT",
messageId: introId,
delta: "Checking what has been published.",
};
yield { type: "TEXT_MESSAGE_END", messageId: introId };
const toolCallId = `${runId}-search`;
yield { type: "TOOL_CALL_START", toolCallId, toolCallName: "web_search" };
// Streamed a few characters at a time, so the component's streaming phase shows.
const args = JSON.stringify({
query: "Singapore annual rainfall record",
});
for (const chunk of args.match(/.{1,4}/g) ?? []) {
await pause(40);
yield { type: "TOOL_CALL_ARGS", toolCallId, delta: chunk };
}
yield { type: "TOOL_CALL_END", toolCallId };
await pause(600);
yield {
type: "TOOL_CALL_RESULT",
messageId: `${toolCallId}-result`,
toolCallId,
content: JSON.stringify({
success: true,
response: "3 results.",
data: SEARCH_HITS,
}),
};
const summaryId = `msg-${runId}-summary`;
yield {
type: "TEXT_MESSAGE_START",
messageId: summaryId,
role: "assistant",
};
yield {
type: "TEXT_MESSAGE_CONTENT",
messageId: summaryId,
delta: "Singapore is the one the coverage keeps returning to.",
};
yield { type: "TEXT_MESSAGE_END", messageId: summaryId };
yield { type: "RUN_FINISHED", threadId, runId };
}
return createAiHarness(api, () => ({
agents: [
clientToolRunner({
id: AGENT_ID,
description: "Reads the rainfall data and searches the web about it.",
tools: () => [webSearch],
run,
}),
],
primary: AGENT_ID,
models: MODELS,
// A conversation already in the panel on load, so both kinds of detail are visible
// without sending anything. It also means no prompt starters are declared here: they
// show only on an empty thread, and this one opens with messages already in it.
history: createSeededHistoryStore(),
}));
},
// Presentation, declared by tool name so a reloaded thread renders the same way. It sits beside
// `ai` rather than inside it because the panel owns presentation and the harness owns execution.
//
// `execute_query` has no entry: Studio declares its own label and detail, and this example
// leaves both alone to show them next to a custom one.
aiToolDisplay: {
web_search: {
label: ({ args, result }) => {
// Arguments stream in, so `query` may not have arrived yet.
const query = typeof args.query === "string" ? args.query : "the web";
return {
text: result ? `Searched for ${query}` : `Searching for ${query}`,
pill: result?.success
? `${SEARCH_HITS.hits.length} results`
: undefined,
};
},
detail: SearchResults,
},
},
};
let studioApi: AgStudioApi;
function setPage(pageId: string) {
studioApi?.setState({ ...studioApi.getState(), selectedPageId: pageId });
}
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);
console.log(
"[example] the AI panel opens on a seeded conversation",
studioApi !== undefined,
);
(window as any).setPage = setPage;
if (typeof window !== "undefined") {
// Attach external event handlers to window so they can be called from index.html
(<any>window).setPage = setPage;
}
.search-results {
display: flex;
flex-direction: column;
gap: 12px;
}
.search-hit {
display: flex;
flex-direction: column;
gap: 2px;
}
.search-hit-title {
font-weight: 500;
}
.search-hit-source {
font-size: 0.85em;
opacity: 0.7;
}
.search-hit-snippet {
opacity: 0.85;
}
import type { AgAiChatMessage, AgAiHistoryStore, AgAiThread, AgAiThreadSummary, AgAiToolCallView } from 'ag-studio';
import type { SearchData } from './interfaces.ts';
export const AGENT_ID = 'weather-analyst';
/** Fixed so the seeded conversation reads the same on every load. */
const AT = Date.UTC(2026, 0, 14, 9, 30);
let nextId = 0;
function text(body: string): AgAiChatMessage {
return {
id: `m${++nextId}`,
role: 'assistant',
createdAt: AT + nextId * 1000,
parts: [{ type: 'text', text: body }],
};
}
function userText(body: string): AgAiChatMessage {
return { id: `m${++nextId}`, role: 'user', createdAt: AT + nextId * 1000, parts: [{ type: 'text', text: body }] };
}
function toolCall(call: Omit<AgAiToolCallView, 'toolCallId'>): AgAiChatMessage {
const id = `m${++nextId}`;
return {
id,
role: 'assistant',
createdAt: AT + nextId * 1000,
parts: [{ type: 'tool_call', toolCall: { toolCallId: `c${id}`, ...call } }],
};
}
/**
* The real `execute_query` argument shape, so Studio's own label has the same material to read as
* it does in the app.
*/
const RAINFALL_QUERY = {
query: {
kind: 'apiQuery',
variant: 'aggregation',
axes: [{ dimensions: [{ field: { id: 'cities.city' } }] }],
measures: [{ field: { id: 'weather.prcp', aggregation: 'sum' } }],
},
};
/** Shaped like the report's own query results, so Studio's detail reads as a real answer. */
const RAINFALL_ROWS = {
result: [
{ City: 'Singapore', 'Total Rainfall (mm)': 21_704 },
{ City: 'Tokyo', 'Total Rainfall (mm)': 14_286 },
{ City: 'Sydney', 'Total Rainfall (mm)': 11_035 },
{ City: 'New York', 'Total Rainfall (mm)': 10_912 },
{ City: 'London', 'Total Rainfall (mm)': 6_214 },
{ City: 'Cairo', 'Total Rainfall (mm)': 189 },
],
};
/** What the provider's web search came back with - the payload the custom component renders. */
export const SEARCH_HITS: SearchData = {
hits: [
{
title: 'Singapore records third-wettest year on record',
source: 'Meteorological Service Singapore',
snippet: 'Total rainfall reached 2,727mm across the island, well above the 1991-2020 long-term mean.',
},
{
title: 'Why the tropics dominate global rainfall totals',
source: 'World Meteorological Organization',
snippet: 'Convective activity near the equator drives daily totals that temperate cities rarely approach.',
},
{
title: 'Cairo rainfall stays near zero for a fourth decade',
source: 'Egyptian Meteorological Authority',
snippet: 'Annual accumulation remains under 25mm, among the lowest of any major world city.',
},
],
};
/**
* One conversation, already settled, so the page shows both kinds of detail the moment it loads:
* `execute_query` opens onto Studio's own component, and `web_search` onto the one this example
* declares. Sending a message runs the search again, live, to show the streaming phases.
*/
const SEARCH_THREAD: AgAiThread = {
summary: {
threadId: 'thread-rainfall',
agentId: AGENT_ID,
title: 'Rainfall by city',
updatedAt: AT + 10_000,
},
messages: [
userText('Which of these cities gets the most rain, and what is being written about it?'),
text('Reading the rainfall totals first.'),
toolCall({
name: 'execute_query',
args: RAINFALL_QUERY,
status: 'complete',
result: { success: true, response: '6 rows returned.', data: RAINFALL_ROWS },
}),
text('Now checking what has been published.'),
toolCall({
name: 'web_search',
args: { query: 'Singapore annual rainfall record' },
status: 'complete',
result: { success: true, response: '3 results.', data: SEARCH_HITS },
}),
text(
'Singapore is comfortably the wettest of the six, at roughly 21,700mm over the period - about ' +
'three and a half times London and two orders of magnitude above Cairo.'
),
],
};
/**
* An in-memory store holding the one seeded conversation. A real integration would persist threads;
* this one only needs them to survive the page.
*/
export function createSeededHistoryStore(): AgAiHistoryStore {
const threads = new Map<string, AgAiThread>([[SEARCH_THREAD.summary.threadId, SEARCH_THREAD]]);
return {
listThreads: async (): Promise<AgAiThreadSummary[]> => [...threads.values()].map((thread) => thread.summary),
loadThread: async (threadId) => threads.get(threadId),
saveThread: async (thread) => {
threads.set(thread.summary.threadId, thread);
},
deleteThread: async (threadId) => {
threads.delete(threadId);
},
addEventListener: () => {},
removeEventListener: () => {},
};
}
import type { AgAiToolDetailParams } from 'ag-studio';
/** The arguments the `web_search` tool is called with. */
export interface SearchArgs {
query: string;
}
/** One result the provider returned. */
export interface SearchHit {
title: string;
source: string;
snippet: string;
}
/** The structured payload the tool's result carries, and what the component renders. */
export interface SearchData {
hits: SearchHit[];
}
/**
* What the detail component receives. Typing both generics here - once - is what gives the
* component `args.query` and `result.data.hits` rather than `unknown` on both.
*/
export type SearchParams = AgAiToolDetailParams<SearchArgs, SearchData>;
import type { SearchHit, SearchParams } from './interfaces.ts';
/**
* A tool's detail component. It is created once per tool call, then refreshed as the call streams,
* executes and settles - so it renders every phase, including partially-arrived arguments.
*/
export class SearchResults {
private element!: HTMLElement;
init(params: SearchParams): void {
this.element = document.createElement('div');
this.element.className = 'search-results';
this.render(params);
}
getGui(): HTMLElement {
return this.element;
}
refresh(params: SearchParams): void {
this.render(params);
}
private render(params: SearchParams): void {
const { result } = params;
// While the arguments stream in, `query` may be absent or half-written.
if (result == null) {
this.element.textContent = `Searching for ${params.args.query ?? '...'}`;
return;
}
if (!result.success) {
this.element.textContent = result.issues.map((issue) => issue.message).join(', ');
return;
}
this.element.replaceChildren(...(result.data?.hits ?? []).map((hit) => renderHit(hit)));
}
}
function renderHit(hit: SearchHit): HTMLElement {
const row = document.createElement('div');
row.className = 'search-hit';
row.appendChild(line('search-hit-title', hit.title));
row.appendChild(line('search-hit-source', hit.source));
row.appendChild(line('search-hit-snippet', hit.snippet));
return row;
}
function line(className: string, text: string): HTMLElement {
const element = document.createElement('div');
element.className = className;
// `textContent`, not `innerHTML`: the payload came back from a provider-run tool.
element.textContent = text;
return element;
}
<div style="display: flex; flex-direction: column; height: 100%">
<div class="example-controls">
<div class="controls-row">
<button onclick="setPage('temperature')">Temperature</button>
<button onclick="setPage('precipitation')">Precipitation</button>
<button onclick="setPage('blank')">Blank</button>
</div>
</div>
<div id="myStudio" class="my-studio-container"></div>
</div>
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'],
},
],
};
}
Declare It Copy Link
Presentation is declared per tool name on the aiToolDisplay Studio property, not on the tool:
const studioProperties = {
ai: ({ api }) => createAiHarness(api, () => ({ agents: [analyst], primary: 'analyst' })),
aiToolDisplay: {
list_reports: {
label: ({ result }) => ({ text: result ? 'Looked up reports' : 'Looking up reports' }),
detail: ReportListCard,
},
execute_query: { label: () => ({ text: 'Querying' }) },
audit_log: { label: () => ({ text: '' }), hidden: true },
},
}; The words shown on the collapsed row, and optionally the value shown at its right-hand end. Called on every render, so it also owns the wording while the call is still in flight and the wording when it failed. Name what the call acted on rather than identifying it by id: the passed AgAiToolLabelParams.fieldName, AgAiToolLabelParams.widgetName and AgAiToolLabelParams.tableName resolve an id to the name the reader sees elsewhere.
|
A component rendering the expanded body of this tool's calls, receiving AgAiToolDetailParams. A tool that declares none has no expanded body, and its row is not interactive.
|
When true, calls to this tool are not shown in the message list at all.
|
Declarations are keyed by name, not by tool, because a conversation reloaded from history no longer has the tool that ran it - only the name it was called by. A replayed thread therefore renders the same as a live one, and these declarations apply to a harness you wrote yourself as well as to one built by createAiHarness.
Studio's own tools come pre-declared. An entry here replaces the declaration for that tool.
hidden: true leaves calls to that tool out of the message list entirely, which is useful for bookkeeping tools a user has no reason to see.
detail is typed for the framework you are writing in, so a component you pass is checked as one. That holds without a type annotation of your own, including in plain TypeScript, where detail is a component class.
Write the Label Copy Link
label is called on every render, so it owns the wording for every phase of the call, not only the finished one. It returns the step's words and, optionally, a short value shown at the right-hand end of the line.
The call's arguments so far - partial while they stream in.
|
Where the call has got to. done covers both outcomes - read result.success to tell them apart - awaiting_approval means it is waiting to be allowed to run, and cancelled means the run was stopped before the call settled.
|
The call's outcome. Present once the call has settled.
|
The display name of a field, including any aggregation or date grain it carries.
|
A widget's title, or the name of its type when it has no title of its own.
|
A table's display name.
|
label: ({ args, status, result, fieldName }) => {
if (status === 'cancelled') {
return { text: 'Looking up reports', pill: 'Stopped' };
}
if (result == null) {
return { text: 'Looking up reports' };
}
if (!result.success) {
return { text: 'Could not look up the reports', pill: `${result.issues.length} issues` };
}
return { text: 'Looked up reports', pill: `${result.data.reports.length} found` };
},Describe the work rather than its outcome. The marker beside the step already says whether it finished, failed or was stopped, so "Added Revenue by region" reads better than "Successfully added widget".
Name what the call acted on rather than showing an id. fieldName, widgetName and tableName resolve an id to the name the reader sees elsewhere in the app, and return undefined when the id names nothing, such as a field that has since been deleted.
Streaming Arguments Copy Link
label runs on every render, so it sees the arguments as they arrive. Partially-parsed JSON means a field may be missing, and a string may be half-written, so guard every access:
// Wrong: throws mid-stream, before `team` has arrived.
label: ({ args }) => ({ text: `Looking up ${args.team.toUpperCase()}` }),
// Right: nothing to show yet is a normal state.
label: ({ args }) => ({ text: args.team ? `Looking up ${args.team}` : 'Looking up reports' }), Write the Detail Copy Link
A detail component follows the same contract as any other Studio custom component: return an element, optionally refresh, optionally clean up. It renders only the body that opens beneath the step. The marker, the words and the pill stay Studio's, which is what keeps a long run readable however many tools contributed to it.
type Params = AgAiToolDetailParams<{ team?: string }, { reports: Report[] }>;
class ReportListCard {
private element!: HTMLElement;
init(params: Params) {
this.element = document.createElement('div');
this.render(params);
}
getGui() {
return this.element;
}
refresh(params: Params) {
this.render(params);
}
private render(params: Params) {
const { result } = params;
if (result == null) {
this.element.textContent = `Looking up reports for ${params.args.team ?? 'all teams'}...`;
return;
}
if (!result.success) {
this.element.textContent = result.issues.map((issue) => issue.message).join(', ');
return;
}
this.element.textContent = `${result.data?.reports.length ?? 0} reports`;
}
}The body brings no background or padding of its own. Content that needs to sit on its own surface should bring its own border.
In React, Angular or Vue, write a component in that framework. React and Angular components are passed directly. A Vue component is passed either directly or by the name it is registered under on the component hosting Studio, matching how a custom widget's comp is supplied. Switch the example above between frameworks to see each one.
What the Detail Receives Copy Link
The params are discriminated on status:
streaming- the model is still writing the arguments.argsis partial: fields appear as they arrive, so guard every access.ready- the arguments are complete and the call is queued.executing- the tool is running.done-resultis authoritative, and is either a success carryingdataor a failure carryingissues.cancelled- the run was stopped before the call settled, so there may be no result at all.
data is whatever the tool's result or execute put there. Type both generics at your component's declaration to get it typed end to end.
In the panel, a step opens only once its call has settled, and a failed call opens onto its issues, listed by the panel rather than by your component. Your detail is therefore created with a successful result already in hand. The in-flight states above are what label sees on every render, and what a component sees if you drive one yourself.
Next Copy Link
- Custom Tools - putting something in
dataworth rendering - Chat UI - what the panel does with a tool call