createAiHarness builds the harness AG Studio ships. It owns the threads, the plan store and the delegation registry, and drives whichever agents it is given.
To replace it entirely, see Custom Harness.
Declaring Agents Copy Link
The agent framework is configured through the ai property. Use the function form, because everything you list hangs off api.
For Studio's five agents on one model, pass the adapter straight to createAiHarness:
const studioProperties = {
ai: ({ api }) => createAiHarness(api, { adapter }),
// other studio properties ...
}For anything else, pass a builder. It hands you the Studio tools and Studio's agents as definitions, so builtIn.lead can be spread, re-pointed at another model, or replaced:
const studioProperties = {
ai: ({ api }) =>
createAiHarness(api, ({ tools: { studio } }) => ({
agents: [
directLlmRunner({
id: 'analyst',
instructions: () => 'You build dashboards for a retail sales team.',
tools: () => [studio.viewSchema(), studio.executeQuery(), studio.addWidget()],
adapter,
}),
],
primary: 'analyst',
})),
// other studio properties ...
}See Direct LLM Runner for the adapter contract.
Give clientToolRunner a run that answers one turn, and list the tools Studio should execute on its behalf:
const studioProperties = {
ai: ({ api }) =>
createAiHarness(api, ({ tools: { studio } }) => ({
agents: [
clientToolRunner({
id: 'analyst',
tools: () => [studio.addWidget(), studio.executeQuery()],
run: (input, ctx) => myServer.stream(input, { signal: ctx.signal }),
}),
],
primary: 'analyst',
})),
// other studio properties ...
}The tools you list execute in the browser when your stream asks for them, so the loop can be remote while the actions stay local.
See Client Tool Runner for the events a run emits.
A loop that already executes its own tools needs no factory. Hand the harness the definition and its run:
const studioProperties = {
ai: ({ api }) =>
createAiHarness(api, () => ({
agents: [{ id: 'analyst', run: myLoop }],
primary: 'analyst',
})),
// other studio properties ...
}See Custom Runner for what your loop has to emit.
ai is marked @initial: set it at construction time. Registering the module comes first - see Agent Quick Start.
Config Reference Copy Link
agents is the roster, and primary names the agent new threads start from. The rest of this page covers the remaining options.
The agents in this conversation. Build each with directLlmRunner or clientToolRunner, or write the object yourself when you own the loop. AG's own five arrive as definitions on the config builder's builtIn.
|
The agent new threads start from.
|
The models a reader may choose between, in the order they are offered, each declaring the efforts it offers. Omit to offer no choice, and the chat panel shows no model picker.
|
Persistence backend for threads. Omit for in-memory only.
|
Suggestions offered in a conversation the user has not yet said anything in, shown above the message box and replaced by the conversation as soon as one is chosen or a message is typed. Each carries its own wording and the message it actually sends, so a short button can stand for a long request. Omit this - or pass an empty list - and nothing is shown.
|
Observers of the harness telemetry stream (metrics sinks, cost meters, eval scorers). Called synchronously in-loop per the AgAiTelemetryObserver contract. Fed by the loops Studio owns (directLlmRunner and clientToolRunner). An agent running a loop of its own reports nothing unless it emits through AgAiAgentRunContext.emit itself, so a roster mixing the two sees telemetry only from the agents Studio drives.
|
Persistence Copy Link
Conversations belong to the harness, not to Studio state. Studio's harness keeps threads in memory by default. Give it a history store and they become durable:
Error: Line 4: Unexpected token ...
To troubleshoot paste snippet here: 'https://esprima.org/demo/parse.html'
const studioProperties = {
ai: ({ api }) =>
createAiHarness(api, ({ builtIn }) => ({
agents: Object.values(builtIn).map((definition) => directLlmRunner({ ...definition, adapter })),
primary: 'lead',
history: myHistoryStore,
})),
}; Thread summaries for the roster (no message bodies).
|
A thread's full durable content, loaded when it is opened.
|
Persist a thread on change (the host debounces); also creates a new thread.
|
Remove a thread and everything stored under it.
|
Register a listener for external changes to the store (another tab/device, or a server write). The event carries no payload: the host re-lists the roster and takes any changed summary, while a conversation already open keeps the transcript on screen - a reload would discard a reply mid-stream. Fire it for a write you did not make; firing it from your own saveThread only costs a re-list. Remove it with AgAiHistoryStore.removeEventListener.
|
Stop notifying a listener added with AgAiHistoryStore.addEventListener.
|
listThreads() returns the summaries for the roster, loadThread() supplies a thread's messages when it is opened, saveThread() persists on change, and deleteThread() removes one. Threads load lazily, so the roster stays cheap however long the history gets.
A store can also tell Studio that something changed elsewhere - another tab, another device, a server write - by emitting changed. Studio re-lists the threads and reloads the open one.
A harness you implement yourself needs none of this, because persistence is part of what it replaces.
import {
AgAiModel,
AgAiPromptStarter,
AgStudioAiModule,
AgStudioApi,
AgStudioModuleRegistry,
AgStudioProperties,
createAiHarness,
createStudio,
enableStudioDevValidations,
} from "ag-studio";
import { getGhcnCitiesData } from "./shared/ghcnCities/data.ts";
import { ghcnCitiesReportState } from "./shared/ghcnCities/state.ts";
import { openaiAdapter } from "./shared/openaiAdapter.ts";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
export const AI_API_URL = "https://ai-api.ag-grid.com/api/openai/v1";
export const AI_API_TOKEN = "";
AgStudioModuleRegistry.registerModules([AgStudioAiModule]);
const adapter = openaiAdapter({
endpoint: AI_API_URL,
key: AI_API_TOKEN,
});
/**
* The suggestions a new conversation opens on. They show only on an empty thread, so a thread
* reopened from the store comes back to its messages rather than to these.
*/
const PROMPT_STARTERS: AgAiPromptStarter[] = [
{
label: "Summarise this page",
prompt: "Summarise what this page shows, in a couple of sentences.",
},
{
label: "Wettest cities",
prompt: "Which cities have the highest total rainfall?",
},
{
label: "Explain the data",
prompt: "What tables and fields does this dashboard have available?",
},
];
/**
* The models offered beside the send button. Each `id` reaches the adapter as declared here and is
* passed straight on to the provider, so these are real model ids. The first is the one a new
* conversation starts on.
*/
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" },
];
const studioProperties: AgStudioProperties = {
data: getGhcnCitiesData("https://www.ag-grid.com/studio/example-assets"),
mode: "edit",
initialState: ghcnCitiesReportState,
ai: ({ api }) =>
createAiHarness(api, {
adapter,
promptStarters: PROMPT_STARTERS,
models: MODELS,
}),
};
let studioApi: AgStudioApi;
function setPage(pageId: string) {
studioApi?.setState({ ...studioApi.getState(), selectedPageId: pageId });
}
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);
(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;
}
<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>
// =============================================================================
// QA run-link chord â copy the current run's trace-viewer URL to the clipboard
// =============================================================================
//
// A near-invisible affordance for QA: press the chord and the trace-viewer URL for
// the most-recent AI run lands on the clipboard, ready to paste into a bug report.
// Nothing renders until it is pressed, so end users never notice it.
//
// It is wired through the telemetry observer (see `otelObserver.ts`) because only
// that observer knows the run's OpenTelemetry trace id â it mints the run's root
// span, and the viewer addresses runs by trace id. `getTraceUrl` therefore returns
// undefined until the first run has started; because a run only starts once
// `window.otel` is present (the `opentelemetry` extra loaded), the chord is
// structurally inert in Plunker/Sandbox exports, which never load that extra.
/** Default chord: Ctrl/Cmd+Shift+L ("L" for run link). */
const DEFAULT_CHORD = (event: KeyboardEvent): boolean =>
(event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyL';
export interface TraceLinkChordOptions {
/** The trace-viewer URL for the current run, or undefined when no run has started yet. */
getTraceUrl: () => string | undefined;
/** Chord predicate; defaults to Ctrl/Cmd+Shift+L. */
matches?: (event: KeyboardEvent) => boolean;
}
/**
* Install the QA run-link chord on `window`. Returns a disposer that removes the listener.
* A no-op outside the browser (e.g. SSR), where there is no `window` to listen on.
*/
export function installTraceLinkChord(options: TraceLinkChordOptions): () => void {
if (typeof window === 'undefined') {
return () => {};
}
const { getTraceUrl, matches = DEFAULT_CHORD } = options;
const onKeyDown = (event: KeyboardEvent): void => {
if (!matches(event)) {
return;
}
const url = getTraceUrl();
if (!url) {
return;
}
event.preventDefault();
void copyRunLink(url);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}
/** Copy the URL to the clipboard and confirm; always log it so it is reachable if the copy fails. */
async function copyRunLink(url: string): Promise<void> {
console.info('%câ¶ Run link', 'font-weight:bold', url);
try {
await navigator.clipboard.writeText(url);
showToast('Run link copied');
} catch {
// Clipboard denied (no focus/permission) â the console link above is the fallback.
showToast('Run link in console');
}
}
/** A small auto-dismissing confirmation, so the silent chord gives feedback. */
function showToast(message: string): void {
const toast = document.createElement('div');
toast.textContent = message;
Object.assign(toast.style, {
position: 'fixed',
bottom: '16px',
right: '16px',
zIndex: '2147483647',
padding: '8px 12px',
borderRadius: '6px',
background: 'rgba(20, 20, 20, 0.92)',
color: '#fff',
font: '12px/1.4 system-ui, sans-serif',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
pointerEvents: 'none',
} satisfies Partial<CSSStyleDeclaration>);
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2000);
}
/**
* OpenAI Responses API adapter for AG Studio.
*
* This is example code - copy it into your project and adapt as needed.
* It maps between AG Studio's AI types and the OpenAI Responses API,
* handling encoding (AG â OpenAI), decoding (OpenAI â AG), and SSE streaming.
*/
import type {
AgAiConversationItem,
AgAiEvent,
AgAiOutputContent,
AgAiOutputItem,
AgAiOutputMessage,
AgAiReasoningItem,
AgAiToolSchema,
AgLlmAdapter,
AgLlmJsonFormat,
AgLlmRequest,
AgLlmResponse,
AgLlmResponseHandler,
AgLlmTextFormat,
} from 'ag-studio';
// =============================================================================
// OpenAI Types (hand-written, minimal)
// =============================================================================
interface OpenAiAdapterOptions {
key?: string;
endpoint?: string;
model?: string;
organization?: string;
}
interface OpenAiConfig {
endpoint: string;
key?: string;
model: string;
organization?: string;
}
// =============================================================================
// JSON Schema â OpenAI strict-mode subset
// =============================================================================
//
// The Shape library emits JSON Schema 2020-12. OpenAI's Responses API in
// `strict: true` mode accepts only a narrow subset. This transform bridges the
// two so docs examples work against OpenAI without forcing Shape authors to
// know the quirks.
//
// What OpenAI accepts: object/array/string/number/integer/boolean/enum/anyOf,
// `$ref` + `$defs` (including recursive), `additionalProperties: false`, and
// the standard string/number/array constraint keywords. Every key in
// `properties` must appear in `required`; optional fields are encoded as a
// nullable type. Open-ended `additionalProperties: <schema>` (i.e. Shape's
// `s.record(...)`) is **not** representable.
type JsonSchema = Record<string, unknown>;
const BANNED_KEYWORDS = [
'allOf',
'not',
'oneOf',
'if',
'then',
'else',
'prefixItems',
'patternProperties',
'propertyNames',
'unevaluatedProperties',
'unevaluatedItems',
'dependentSchemas',
'dependentRequired',
'contains',
] as const;
function isSchema(value: unknown): value is JsonSchema {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
// Shape encodes "undefined" (used in `union(T, undefined)` to mark optionality) as the
// sentinel `{ not: {} }`. OpenAI doesn't allow `not`, so strip these from any `anyOf`
// branches; the surrounding object handler turns the remaining schema nullable for
// optional properties.
function isUndefinedSentinel(s: unknown): boolean {
if (!isSchema(s)) return false;
return Object.keys(s).length === 1 && isSchema(s.not) && Object.keys(s.not as JsonSchema).length === 0;
}
function stripUndefinedSentinel(schema: JsonSchema): JsonSchema {
if (!Array.isArray(schema.anyOf)) return schema;
const filtered = (schema.anyOf as unknown[]).filter((b) => !isUndefinedSentinel(b));
if (filtered.length === schema.anyOf.length) return schema;
if (filtered.length === 0) {
throw new Error('toOpenAiSchema: schema reduces to `undefined`-only - nothing to express');
}
const { anyOf: _, ...rest } = schema;
if (filtered.length === 1 && isSchema(filtered[0])) {
return { ...filtered[0], ...rest } as JsonSchema;
}
return { ...rest, anyOf: filtered as JsonSchema[] };
}
function inferTypeFromValue(v: unknown): string | undefined {
if (v === null) return 'null';
if (typeof v === 'string') return 'string';
if (typeof v === 'boolean') return 'boolean';
if (typeof v === 'number') return Number.isInteger(v) ? 'integer' : 'number';
return undefined;
}
function makeNullable(schema: JsonSchema): JsonSchema {
if (typeof schema.type === 'string') {
return schema.type === 'null' ? schema : { ...schema, type: [schema.type, 'null'] };
}
if (Array.isArray(schema.type)) {
return schema.type.includes('null') ? schema : { ...schema, type: [...schema.type, 'null'] };
}
if (Array.isArray(schema.anyOf)) {
const branches = schema.anyOf as JsonSchema[];
const hasNull = branches.some((b) => isSchema(b) && b.type === 'null');
return hasNull ? schema : { ...schema, anyOf: [...branches, { type: 'null' }] };
}
return { anyOf: [schema, { type: 'null' }] };
}
function transformSchema(schema: JsonSchema): JsonSchema {
schema = stripUndefinedSentinel(schema);
// OpenAI strict mode rejects any sibling keyword on `$ref` (description, examples, etc.).
// Shape authors apply per-callsite descriptions on the outside of the def - preserve `$ref`
// itself, drop everything else; the description lives inside the referenced `$def` via the
// first emission.
if ('$ref' in schema) {
const { $ref, $defs } = schema as JsonSchema & { $ref: unknown };
return $defs !== undefined ? { $ref, $defs } : { $ref };
}
for (const kw of BANNED_KEYWORDS) {
if (kw in schema) {
throw new Error(`toOpenAiSchema: '${kw}' is not supported by OpenAI strict mode`);
}
}
if ('const' in schema) {
const { const: literalValue, ...rest } = schema as JsonSchema & { const: unknown };
const inferred = inferTypeFromValue(literalValue);
const out: JsonSchema = { ...rest, enum: [literalValue] };
if (out.type == null && inferred != null) out.type = inferred;
return transformSchema(out);
}
const out: JsonSchema = { ...schema };
if (Array.isArray(out.anyOf)) {
out.anyOf = (out.anyOf as JsonSchema[]).map((branch) => (isSchema(branch) ? transformSchema(branch) : branch));
}
if (isSchema(out.$defs)) {
const transformedDefs: JsonSchema = {};
for (const [k, v] of Object.entries(out.$defs as JsonSchema)) {
transformedDefs[k] = isSchema(v) ? transformSchema(v) : v;
}
out.$defs = transformedDefs;
}
if (out.type === 'object' || isSchema(out.properties)) {
if ('additionalProperties' in out && out.additionalProperties !== false) {
throw new Error(
'toOpenAiSchema: open-ended `additionalProperties` (e.g. s.record(...)) cannot be expressed in OpenAI strict mode'
);
}
const properties = isSchema(out.properties) ? out.properties : {};
const required = new Set(Array.isArray(out.required) ? (out.required as string[]) : []);
const newProperties: JsonSchema = {};
for (const [key, propSchema] of Object.entries(properties)) {
const transformed = isSchema(propSchema) ? transformSchema(propSchema) : propSchema;
newProperties[key] = required.has(key)
? transformed
: isSchema(transformed)
? makeNullable(transformed)
: transformed;
}
out.properties = newProperties;
out.required = Object.keys(newProperties);
out.additionalProperties = false;
}
// Array items keep their real schema: only optional PROPERTIES need the required+nullable
// rewrite. Advertising nullable items invites the model to emit `[null]` for values the
// AG-side shapes reject.
if (isSchema(out.items)) {
out.items = transformSchema(out.items);
}
return out;
}
function toOpenAiSchema(schema: JsonSchema): JsonSchema {
if (Array.isArray(schema.anyOf) && schema.type !== 'object' && !isSchema(schema.properties)) {
throw new Error(
'toOpenAiSchema: root schema cannot be `anyOf` - wrap in an object (e.g. `s.object({ value: ... })`)'
);
}
return transformSchema(schema);
}
// =============================================================================
// Encoding: AG â OpenAI
// =============================================================================
function encodeConversationItems(items: AgAiConversationItem[]): unknown[] {
return items.map((item) => {
if (item.kind === 'input' && item.type === 'message') {
return {
type: 'message',
role: item.role,
status: item.status,
content: item.content.map((c) => {
switch (c.type) {
case 'text':
return { type: 'input_text', text: c.text };
case 'image':
return {
type: 'input_image',
detail: c.detail,
file_id: c.fileId ?? null,
image_url: c.imageUrl ?? null,
};
case 'file':
return {
type: 'input_file',
file_id: c.fileId ?? null,
file_data: c.fileData,
file_url: c.fileUrl,
filename: c.filename,
};
}
}),
};
}
if (item.type === 'function_call_output') {
return {
type: 'function_call_output',
call_id: item.callId,
output: item.output,
status: item.status,
};
}
if (item.kind === 'output' && item.type === 'message') {
// No `id`: replayed history is reconstructed conversational context, not a resumed
// OpenAI response. Echoing the original `msg_âŠ` id makes the API treat it as response
// state and demand the linked `reasoning` item (which a view-derived history lacks).
return {
type: 'message',
role: 'assistant',
status: item.status,
content: item.content.map((c) => {
if (c.type === 'text') {
return {
type: 'output_text',
text: c.text,
annotations: c.annotations.map((ann) => {
switch (ann.type) {
case 'file_path':
return { type: 'file_path', file_id: ann.fileId, index: ann.index };
case 'file_citation':
return {
type: 'file_citation',
file_id: ann.fileId,
index: ann.index,
filename: ann.filename,
};
case 'url_citation':
return {
type: 'url_citation',
url: ann.url,
start_index: ann.startIndex,
end_index: ann.endIndex,
title: ann.title,
};
case 'container_file_citation':
return {
type: 'container_file_citation',
container_id: ann.containerId,
file_id: ann.fileId,
start_index: ann.startIndex,
end_index: ann.endIndex,
filename: ann.filename,
};
}
}),
};
}
return { type: 'refusal', refusal: c.refusal };
}),
};
}
if (item.kind === 'output' && item.type === 'function_call') {
// No `id` (same reason as the assistant message above): `call_id` alone pairs the call
// with its `function_call_output`, and a reconstructed `id` isn't a valid `fc_âŠ` anyway.
return {
type: 'function_call',
call_id: item.callId,
name: item.name,
arguments: item.arguments,
status: item.status,
};
}
if (item.kind === 'output' && item.type === 'reasoning') {
return {
id: item.id,
type: 'reasoning',
summary: item.summary.map((s) => ({ type: 'summary_text', text: s.text })),
content: item.content?.map((c) => ({ type: 'reasoning_text', text: c.text })),
};
}
throw new Error(`Unknown conversation item type: ${(item as { type: string }).type}`);
});
}
// =============================================================================
// Decoding: OpenAI â AG
// =============================================================================
// `toOpenAiSchema` rewrites optional properties as required + nullable to satisfy
// OpenAI strict mode, so the model returns `null` for unset optionals. AG-side
// validation treats those fields as optional (not nullable), so strip `null`s
// from tool-call argument payloads on the way back. Only object PROPERTIES are
// stripped: a null array item is either a genuinely nullable value that must
// survive (e.g. a rank filter's `[10, null]` bounds) or invalid input that
// AG-side validation should report rather than have silently deleted.
function stripNulls(value: unknown): unknown {
if (Array.isArray(value)) return value.map((v) => stripNulls(v));
if (value !== null && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
if (v === null) continue;
out[k] = stripNulls(v);
}
return out;
}
return value;
}
function stripNullsFromToolArgs(argsJson: string): string {
if (!argsJson) return argsJson;
let parsed: unknown;
try {
parsed = JSON.parse(argsJson);
} catch {
return argsJson;
}
return JSON.stringify(stripNulls(parsed));
}
function decodeAnnotations(annotations: any[]): any[] {
return (annotations ?? []).map((ann: any) => {
if (ann.type === 'file_path') {
return { type: 'file_path', fileId: ann.file_id, index: ann.index };
}
if (ann.type === 'file_citation') {
return { type: 'file_citation', fileId: ann.file_id, index: ann.index, filename: ann.filename };
}
if (ann.type === 'url_citation') {
return {
type: 'url_citation',
url: ann.url,
startIndex: ann.start_index,
endIndex: ann.end_index,
title: ann.title,
};
}
if (ann.type === 'container_file_citation') {
return {
type: 'container_file_citation',
containerId: ann.container_id,
fileId: ann.file_id,
startIndex: ann.start_index,
endIndex: ann.end_index,
filename: ann.filename,
};
}
return ann;
});
}
function decodeOutputContent(input: Record<string, any>): AgAiOutputContent {
if (input.type === 'output_text') {
return {
type: 'text',
text: input.text,
annotations: decodeAnnotations(input.annotations),
};
}
return input as AgAiOutputContent;
}
function decodeOutputItem(input: Record<string, any>): AgAiOutputItem {
switch (input.type) {
case 'message': {
const message: AgAiOutputMessage = {
id: input.id ?? '',
kind: 'output',
type: 'message',
role: 'assistant',
status: input.status ?? 'completed',
content: input.content.map(decodeOutputContent),
};
return message;
}
case 'function_call':
return {
id: input.id ?? '',
kind: 'output',
type: 'function_call',
callId: input.call_id,
name: input.name,
arguments: stripNullsFromToolArgs(input.arguments ?? ''),
status: input.status,
};
case 'reasoning': {
const reasoning: AgAiReasoningItem = {
id: input.id ?? '',
kind: 'output',
type: 'reasoning',
summary: input.summary.map((s: any) => ({ type: 'summary', text: s.text })),
content: input.content?.map((c: any) => ({ type: 'text', text: c.text })),
};
return reasoning;
}
default:
throw new Error(`Unknown output item type: ${input.type}`);
}
}
function decodeResponse(input: Record<string, any>): AgLlmResponse {
return {
id: input.id,
createdAt: input.created_at,
model: input.model,
incompleteDetails: input.incomplete_details ? { reason: input.incomplete_details.reason } : undefined,
output: input.output.map(decodeOutputItem),
status: input.status,
error: input.error ? { code: input.error.code, message: input.error.message } : undefined,
usage: input.usage
? {
inputTokens: input.usage.input_tokens,
outputTokens: input.usage.output_tokens,
totalTokens: input.usage.total_tokens,
reasoningTokens: input.usage.output_tokens_details?.reasoning_tokens,
cachedInputTokens: input.usage.input_tokens_details?.cached_tokens,
cacheWriteTokens: input.usage.input_tokens_details?.cache_write_tokens,
}
: undefined,
};
}
/** What a turn produced besides its events: the final response, or the failure that ended it. */
interface TurnOutcome {
response?: AgLlmResponse;
error?: Error;
}
/**
* Translates the OpenAI Responses stream into the events AG Studio reads.
*
* The provider is item-and-index shaped; AG Studio is message-shaped, keyed by id. The only state
* needed to bridge them is the item id of each open item, since argument deltas arrive against the
* item while tool events are keyed by the call.
*/
class ResponseStreamTranslator {
private readonly callIdByItemId = new Map<string, string>();
private readonly kindByItemId = new Map<string, 'message' | 'reasoning' | 'function_call'>();
/** The events one SSE payload maps to. Anything not recognised is ignored, not an error. */
translate(input: Record<string, any>, outcome: TurnOutcome): AgAiEvent[] {
switch (input.type) {
case 'response.output_item.added':
return this.open(input.item);
case 'response.output_item.done':
return this.close(input.item);
case 'response.output_text.delta':
case 'response.refusal.delta':
return [{ type: 'TEXT_MESSAGE_CONTENT', messageId: input.item_id, delta: input.delta }];
case 'response.reasoning_text.delta':
case 'response.reasoning_summary_text.delta':
return [{ type: 'REASONING_MESSAGE_CONTENT', messageId: input.item_id, delta: input.delta }];
case 'response.function_call_arguments.delta': {
const toolCallId = this.callIdByItemId.get(input.item_id);
return toolCallId ? [{ type: 'TOOL_CALL_ARGS', toolCallId, delta: input.delta }] : [];
}
case 'response.completed':
outcome.response = decodeResponse(input.response);
return [];
case 'response.failed':
case 'response.incomplete':
outcome.error ??= new Error(input.response?.error?.message ?? `Response ${input.type}.`);
return [];
case 'error':
outcome.error ??= new Error(`${input.code ?? 'api_error'}: ${input.message}`);
return [];
default:
return [];
}
}
private open(item: Record<string, any>): AgAiEvent[] {
switch (item?.type) {
case 'message':
this.kindByItemId.set(item.id, 'message');
return [{ type: 'TEXT_MESSAGE_START', messageId: item.id, role: 'assistant' }];
case 'reasoning':
this.kindByItemId.set(item.id, 'reasoning');
return [{ type: 'REASONING_MESSAGE_START', messageId: item.id, role: 'reasoning' }];
case 'function_call':
this.kindByItemId.set(item.id, 'function_call');
this.callIdByItemId.set(item.id, item.call_id);
return [{ type: 'TOOL_CALL_START', toolCallId: item.call_id, toolCallName: item.name }];
default:
return [];
}
}
private close(item: Record<string, any>): AgAiEvent[] {
switch (this.kindByItemId.get(item?.id)) {
case 'message':
return [{ type: 'TEXT_MESSAGE_END', messageId: item.id }];
case 'reasoning':
return [{ type: 'REASONING_MESSAGE_END', messageId: item.id }];
case 'function_call': {
const toolCallId = this.callIdByItemId.get(item.id);
return toolCallId ? [{ type: 'TOOL_CALL_END', toolCallId }] : [];
}
default:
return [];
}
}
}
// =============================================================================
// Stream Processor
// =============================================================================
async function* streamOpenAi(
config: OpenAiConfig,
requestBody: Record<string, unknown>,
outcome: TurnOutcome,
signal?: AbortSignal
): AsyncIterableIterator<AgAiEvent> {
const translator = new ResponseStreamTranslator();
const emit = (payload: string): AgAiEvent[] => {
if (payload === '[DONE]') {
return [];
}
try {
const parsed = JSON.parse(payload);
return parsed.type === 'keepalive' ? [] : translator.translate(parsed, outcome);
} catch (error) {
outcome.error ??= error instanceof Error ? error : new Error(String(error));
return [];
}
};
const response = await fetch(`${config.endpoint}/responses`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(config.key && { Authorization: `Bearer ${config.key}` }),
...(config.organization && { 'OpenAI-Organization': config.organization }),
},
body: JSON.stringify(requestBody),
signal,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error?.message || `HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split('\n\n');
buffer = frames.pop() ?? '';
for (const frame of frames) {
for (const line of frame.split('\n')) {
if (line.startsWith('data: ')) {
yield* emit(line.slice(6));
}
}
}
}
}
// =============================================================================
// Request Builder
// =============================================================================
function prepareToolChoice(
toolChoice: AgLlmRequest['toolChoice']
): 'auto' | 'none' | 'required' | { type: 'function'; name: string } | undefined {
if (!toolChoice) return undefined;
if (typeof toolChoice === 'string') return toolChoice;
return { type: 'function', name: toolChoice.name };
}
function prepareResponseFormat(format: AgLlmTextFormat | AgLlmJsonFormat): Record<string, unknown> {
if (format.type === 'text') return { type: 'text' };
return {
type: 'json_schema',
name: format.name,
description: format.description,
schema: toOpenAiSchema(format.schema as JsonSchema),
strict: true,
};
}
function runRequest(config: OpenAiConfig, request: AgLlmRequest, signal?: AbortSignal): AgLlmResponseHandler {
const { tools = [], toolChoice, responseFormat, input, model, ...rest } = request;
const requestBody: Record<string, unknown> = {
...rest,
input: encodeConversationItems(input),
// `request.model` carries whichever model the reader picked, and is absent when the chat
// offers no choice - so the adapter's own model is the fallback, not an override.
model: model?.id ?? config.model,
stream: true,
tools: tools.map((tool: AgAiToolSchema) => ({
type: 'function' as const,
name: tool.name,
description: tool.description,
parameters: toOpenAiSchema(tool.parameters as unknown as JsonSchema),
strict: true,
})),
tool_choice: prepareToolChoice(toolChoice),
text: { format: prepareResponseFormat(responseFormat!) },
// Studio's effort ids are passed straight through as OpenAI's reasoning effort. A model
// declared without efforts sends none, so the adapter's own default applies.
reasoning: { effort: model?.effort ?? 'medium' },
parallel_tool_calls: true,
};
const outcome: TurnOutcome = {};
const streamIterator = streamOpenAi(config, requestBody, outcome, signal);
let resolveComplete: (response: AgLlmResponse) => void;
let rejectComplete: (error: Error) => void;
const completePromise = new Promise<AgLlmResponse>((resolve, reject) => {
resolveComplete = resolve;
rejectComplete = reject;
});
// `complete` rejects on a failed turn: the host ends a run on a throw from here and reads
// nothing off the response's own status.
// A failed turn is reported once, through `complete`. Rethrowing as well would leave the
// rejection unobserved whenever a consumer stops reading the stream before awaiting it - which
// is exactly what happens on an HTTP error or a cancellation - and that surfaces as an unhandled
// rejection rather than as the run's own error.
async function* wrappedIterator(): AsyncIterableIterator<AgAiEvent> {
try {
yield* streamIterator;
} catch (error) {
outcome.error ??= error instanceof Error ? error : new Error(String(error));
}
if (outcome.error) {
rejectComplete(outcome.error);
} else if (outcome.response) {
resolveComplete(outcome.response);
} else {
rejectComplete(new Error('Stream completed without a final response.'));
}
}
// Marks the rejection observed for a consumer that abandons the stream and never awaits
// `complete`; anyone who does await it still sees the failure.
void completePromise.catch(() => {});
const wrapped = wrappedIterator();
return {
stream: { [Symbol.asyncIterator]: () => wrapped },
complete: completePromise,
};
}
// =============================================================================
// Factory Function
// =============================================================================
export function openaiAdapter(options: OpenAiAdapterOptions): AgLlmAdapter {
const config: OpenAiConfig = {
endpoint: options.endpoint ?? 'https://api.openai.com/v1',
key: options.key,
model: options.model ?? 'gpt-5.4-mini',
organization: options.organization,
};
return {
executeTurn: (request: AgLlmRequest, options?: { signal?: AbortSignal }) =>
runRequest(config, request, options?.signal),
};
}
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'],
},
],
};
}
Observability Copy Link
The harness emits a typed stream of boundary events. Register an observer to feed metrics, a cost meter, or an eval harness:
Error: Line 4: Unexpected token ...
To troubleshoot paste snippet here: 'https://esprima.org/demo/parse.html'
const studioProperties = {
ai: ({ api }) =>
createAiHarness(api, ({ builtIn }) => ({
agents: Object.values(builtIn).map((definition) => directLlmRunner({ ...definition, adapter })),
primary: 'lead',
observers: [
{
onEvent: (event) => {
if (event.type === 'turn_finished') {
recordUsage(event.usage, event.model);
}
},
},
],
})),
};Events mark run, turn, streamed-item and tool-execution boundaries. They carry only what a consumer cannot recompute: timestamps, the resolved instructions, the advertised tool schemas, token usage, and tool results. Durations, token totals and time-to-first-token are all derivable.
onEvent is called synchronously, in-loop. The harness does not advance until it returns, so point-in-time reads are reliable: an observer that calls api.getState() on tool_execution_finished sees exactly the state that call produced. Handlers are therefore on the critical path. Capture synchronously, and push anything expensive onto a queue. A throwing observer is logged and skipped; it never aborts a run.
Called once for each event, as the harness reaches it. It runs on the critical path, so read what is point-in-time-sensitive here and leave anything expensive to run afterwards.
|
Presenting Tool Calls Copy Link
How a tool's calls appear in the transcript is declared on the aiToolDisplay Studio property, not on the harness. Presentation is the panel's concern, and it applies whichever harness is driving the panel. See Tool Components.
Sessions Copy Link
A session is one live conversation, and it is what a UI reads:
The thread this session is the live view of.
|
Register a listener for any change to this conversation. The event carries no payload: re-read AgAiChatSession.messages/AgAiChatSession.status/ AgAiChatSession.artifacts/AgAiChatSession.sharedState. Remove it with AgAiChatSession.removeEventListener.
|
Stop notifying a listener added with AgAiChatSession.addEventListener.
|
Ordered messages. New reference on change; parts grow as content streams.
|
Where this conversation has got to: idle, running, waiting to be answered, or failed.
|
Durable, non-message outputs (e.g. the plan). New reference on change.
|
The state this conversation shares with its agent, as the agent last left it: an agent that publishes state does so through the protocol's snapshot and delta events, and the value here is what those add up to. It goes back out with the next message, so the agent resumes against the state the reader can see. undefined until an agent publishes any, and absent altogether on a harness that carries no shared state.
|
Add a message from the reader and start a run to answer it. The options carry anything attached to it and the model to answer this one message with.
|
Interrupt the active run.
|
Answer the tool call a paused run is waiting on, and let the run continue. Call it only while AgAiChatSession.status is awaiting_input, passing the toolCallId of the call sitting at awaiting_approval; a harness that pauses for no tool never reports either state and needs no implementation of this. Refusing a call does not stop the run: the reason is handed back to the agent as that call's outcome, so it can explain itself, offer something narrower, or give up. Stop the run outright with AgAiChatSession.cancel instead. Studio's own harness does not pause for approval yet, so it never reports either state and does not implement this. The surface is here for a harness of your own that does, and for Studio to grow into.
|
Release this session's connection when the UI is done with it.
|
Two properties of the design matter if you read a session yourself.
Snapshots change reference. messages, status and artifacts return a new reference whenever they change, and are never mutated in place, so reference-equality selectors work - useSyncExternalStore in React, a computed in Vue.
The change event carries no payload. It means "something changed, re-read". There is no diff to apply.
const harness = createAiHarness(api, ({ tools }) => config);
const session = await harness.openThread(threadId);
session.addEventListener('changed', () => render(session.messages, session.status));
session.sendMessage('Add a chart of sales by region');The harness tracks no active thread. Which conversation is on screen is the UI's business, and so is when to create one: Studio's panel calls createThread on the reader's first message, not when they click New. An empty roster is therefore normal, and a single-conversation embed can skip the roster and drive one session.
The harness itself exposes the roster, the thread list and the session accessors:
Register a listener for harness-level changes (roster or thread list). The event carries no payload: re-read the snapshots when it fires. Remove it with AgAiHarness.removeEventListener.
|
Stop notifying a listener added with AgAiHarness.addEventListener.
|
Agents that can speak. New reference on change.
|
The models a reader may choose between, in the order they are offered. New reference on change. Absent or empty means this harness offers no choice, and the chat panel shows no model picker.
|
Conversation catalogue. New reference on change. Empty is a valid state: Studio's own harness creates a conversation when the reader sends their first message, so a dashboard nobody has spoken to has no threads at all.
|
Suggestions to offer in a conversation nobody has said anything in yet, shown above the message box until one is chosen or a message is typed. Absent or empty shows nothing.
|
One agent from the roster, or undefined when nothing holds that id.
|
One conversation's summary, or undefined when nothing holds that id.
|
Idempotent: the same threadId returns the same live session.
|
Start a conversation with the named agent and return its live session. Rejects when no agent holds that id.
|
Remove a conversation, closing its session and taking any conversation nested below it with it. Does nothing when nothing holds that id.
|
The live session for a thread if it is already open, without opening or hydrating one. Returns undefined for a thread not yet opened, or when the harness surfaces no such session (e.g. a delegate sub-run it does not track). Lets the UI bind to a sub-run mid-flight.
|
Set the model a conversation uses from now on, as AgAiThreadSummary.model. A harness that offers models but does not implement this keeps no record of the choice, and the chat panel remembers it only for as long as it stays open.
|
Release any resources the harness holds (e.g. a persistence subscription).
|
Next Copy Link
- Direct LLM Runner - the shortest route to a working agent
- Client Tool Runner - your request, Studio's tool execution
- Custom Runner - your loop, untouched