api.defineAiTool returns a tool ready to list on an agent.
It comes in two shapes: supply an execute for your own action, or wrap a command when the action should be validated and reusable.
import { Component } from "@angular/core";
import { AgStudio } from "ag-studio-angular";
import {
AgAiConversationItem,
AgAiToolCall,
AgAiToolSchema,
AgBuiltInAiCommandRef,
AgDataEngine,
AgDataSourcesDefinition,
AgLlmRequest,
AgReportState,
AgStudioApi,
AgStudioApiReadyEvent,
AgStudioMode,
AgStudioProperties,
enableStudioDevValidations,
} from "ag-studio";
import { openaiAdapter } from "./shared/openaiAdapter.ts";
import { salesData } from "./data.ts";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
interface CommandDemo {
label: string;
ref: AgBuiltInAiCommandRef;
prompt: string;
}
@Component({
selector: "my-app",
standalone: true,
imports: [AgStudio],
template: `<div style="display: flex; flex-direction: column; height: 100%">
<div class="example-controls">
<div class="controls-row">
<button type="button" (click)="runCommand('AgExecuteQueryCommand')">
Execute Query
</button>
<button type="button" (click)="runCommand('AgAddPageFilterCommand')">
Add Page Filter
</button>
<button type="button" (click)="runCommand('AgRemovePageFilterCommand')">
Remove Page Filter
</button>
<button type="button" (click)="runCommand('AgAddWidgetFilterCommand')">
Add Widget Filter
</button>
<button
type="button"
(click)="runCommand('AgRemoveWidgetFilterCommand')"
>
Remove Widget Filter
</button>
<button type="button" (click)="runCommand('AgAddWidgetCommand')">
Add Widget
</button>
<button type="button" (click)="runCommand('AgPositionWidgetCommand')">
Position Widget
</button>
<button type="button" (click)="runCommand('AgRemoveWidgetCommand')">
Remove Widget
</button>
<button type="button" (click)="runCommand('AgConfigureWidgetCommand')">
Configure Widget
</button>
</div>
</div>
<ag-studio
style="width: 100%; height: 100%;"
class="my-studio-container"
[initialState]="initialState"
[mode]="mode"
[data]="data"
(apiReady)="onApiReady($event)"
/>
</div> `,
})
export class AppComponent {
private studioApi!: AgStudioApi;
initialState: AgReportState = {
pages: [
{
id: "main",
widgets: {
"revenue-by-region": {
type: "bar-chart-grouped",
dataMapping: {
categoryKey: [{ id: "sales.region" }],
valueKey: [{ id: "sales.revenue", aggregation: "sum" }],
},
format: { caption: { enabled: true, text: "Revenue by Region" } },
},
},
widgetLayout: {
"revenue-by-region": { xTrack: 0, yTrack: 0, xSpan: 24, ySpan: 18 },
},
},
],
selectedPageId: "main",
};
mode: AgStudioMode = "edit";
data: AgDataSourcesDefinition | AgDataEngine = salesData;
/** Run the demo for one command type, as the buttons above call it. */
async runCommand(type: string): Promise<void> {
const demo = COMMANDS.find((candidate) => candidate.ref.type === type);
if (!demo) {
return;
}
try {
await this.runDemo(demo);
} catch (err) {
console.error("[ai-custom-tools] failed", type, err);
}
}
onApiReady(params: AgStudioApiReadyEvent) {
this.studioApi = params.api;
}
runDemo = async (demo: CommandDemo): Promise<void> => {
const command = this.studioApi.defineAiCommand(demo.ref);
const schema = command.toJSONSchema() as Record<string, unknown>;
// OpenAI's Responses API requires the tool `parameters` root to be an
// object schema - `anyOf`/`oneOf` at the root (which several of our
// lens-derived command schemas produce) is rejected. Wrap every schema
// in a single-property envelope and unwrap before `apply()`. Hoist
// `$defs` to the wrapper root so `#/$defs/...` refs still resolve.
const { $defs, ...inner } = schema;
const wrappedSchema: Record<string, unknown> = {
type: "object",
properties: { command: inner },
required: ["command"],
additionalProperties: false,
};
if ($defs !== undefined) wrappedSchema.$defs = $defs;
const tool: AgAiToolSchema = {
name: demo.ref.type,
description: `Built-in AG Studio command: ${demo.ref.type}`,
parameters: wrappedSchema as AgAiToolSchema["parameters"],
};
console.log(
`[ai-custom-tools] ${demo.ref.type} - message sent:`,
demo.prompt,
);
console.log(`[ai-custom-tools] ${demo.ref.type} - tool schema:`, tool);
const userMessage: AgAiConversationItem = {
id: `msg-${Date.now()}`,
kind: "input",
type: "message",
role: "user",
content: [{ type: "text", text: demo.prompt }],
status: "completed",
};
const request: AgLlmRequest = {
input: [userMessage],
instructions: SYSTEM_INSTRUCTIONS,
tools: [tool],
toolChoice: { name: tool.name },
responseFormat: { type: "text" },
};
const handler = assistant.executeTurn(request);
// The adapter's `complete` only resolves once the stream is drained;
// the underlying fetch is initiated lazily by the async iterator.
for await (const _event of handler.stream) {
// Drain - we only care about the final response, not intermediate events.
}
const response = await handler.complete;
const toolCall = response.output.find(
(item): item is AgAiToolCall => item.type === "function_call",
);
if (!toolCall) {
console.warn(
`[ai-custom-tools] ${demo.ref.type} - LLM returned no tool call`,
response,
);
return;
}
const wrappedArgs = JSON.parse(toolCall.arguments);
const args = wrappedArgs?.command;
console.log(
`[ai-custom-tools] ${demo.ref.type} - tool args from LLM:`,
args,
);
const result = await command.apply(args);
console.log(`[ai-custom-tools] ${demo.ref.type} - command result:`, result);
};
}
export const AI_API_URL = "https://ai-api.ag-grid.com/api/openai/v1";
export const AI_API_TOKEN = "";
const SYSTEM_INSTRUCTIONS = [
"You are operating an AG Studio dashboard via a single tool call.",
'The page id is "main". It contains one widget: id "revenue-by-region", type "bar-chart-grouped".',
'The data source "sales" exposes fields: region (text), product (text), revenue (currency).',
"Call the provided tool exactly once with arguments that satisfy the user request and the tool schema.",
].join(" ");
const COMMANDS: CommandDemo[] = [
{
label: "Execute Query",
ref: { type: "AgExecuteQueryCommand" },
prompt: "Run a query that returns average revenue per product.",
},
{
label: "Add Page Filter",
ref: { type: "AgAddPageFilterCommand" },
prompt: "Filter the page so only the EMEA region is included.",
},
{
label: "Remove Page Filter",
ref: { type: "AgRemovePageFilterCommand" },
prompt: "Remove the page filter that is currently restricting the region.",
},
{
label: "Add Widget Filter",
ref: { type: "AgAddWidgetFilterCommand" },
prompt:
"On the revenue-by-region widget, filter so only the Technology product is shown.",
},
{
label: "Remove Widget Filter",
ref: { type: "AgRemoveWidgetFilterCommand" },
prompt: "Remove the product filter from the revenue-by-region widget.",
},
{
label: "Add Widget",
ref: { type: "AgAddWidgetCommand" },
prompt:
"Add a new KPI (value) widget showing total revenue. Place it at xTrack 0, yTrack 18, spanning 8 columns and 6 rows.",
},
{
label: "Position Widget",
ref: { type: "AgPositionWidgetCommand" },
prompt:
"Move the revenue-by-region widget to the right half of the page (xTrack 12, yTrack 0, xSpan 12, ySpan 18).",
},
{
label: "Remove Widget",
ref: { type: "AgRemoveWidgetCommand" },
prompt: "Delete the revenue-by-region widget.",
},
{
label: "Configure Widget",
ref: {
type: "AgConfigureWidgetCommand",
params: { widgetType: "bar-chart-grouped" },
},
prompt:
'Re-caption the revenue-by-region widget to "Total Revenue by Region".',
},
];
const assistant = openaiAdapter({ endpoint: AI_API_URL, key: AI_API_TOKEN });
.controls-row button:disabled {
opacity: 0.6;
cursor: progress;
}
import '@angular/compiler';
import { provideHttpClient } from '@angular/common/http';
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app.component.ts';
const app = bootstrapApplication(AppComponent, {
providers: [provideHttpClient()],
});
import type { AgDataSourcesDefinition } from 'ag-studio';
export const salesData: AgDataSourcesDefinition = {
sources: [
{
id: 'sales',
data: [
{ region: 'EMEA', product: 'Furniture', revenue: 1200 },
{ region: 'EMEA', product: 'Technology', revenue: 1800 },
{ region: 'APAC', product: 'Furniture', revenue: 900 },
{ region: 'APAC', product: 'Technology', revenue: 1500 },
{ region: 'Americas', product: 'Furniture', revenue: 1100 },
{ region: 'Americas', product: 'Technology', revenue: 2000 },
],
fields: [
{ id: 'region', format: 'textFormat' },
{ id: 'product', format: 'textFormat' },
{ id: 'revenue', format: 'currencyFormat' },
],
},
],
};
/**
* 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),
};
}
Your Own Action Copy Link
The direct form: declare parameters, do the work, build a result.
const listReportsTool = api.defineAiTool({
name: 'list_reports',
description: 'List the saved reports this user can open.',
params: (s) =>
s.object({
team: s.string({ description: 'Restrict to one team. Omit for all teams.' }).optional(),
}),
execute: async (args, ctx) => {
const reports = await fetchReports({ team: args.team, signal: ctx.signal });
if (reports.length === 0) {
return ctx.error('No reports found. Ask the user to widen the search.');
}
return ctx.success(`Found ${reports.length} reports.`, { reports });
},
});params receives a shape builder and returns the tool's schema. execute receives the parsed arguments - already validated, so no defensive checks - plus a context carrying:
signal- aborts when the run is cancelled. Forward it to any fetch.run- the thread and run ids, if you need to scope a resource per conversation.success/error- result constructors.
Arguments that fail validation never reach execute. The model gets the validation issues back and can retry.
Parameters Copy Link
The builder covers the usual schema types plus Studio-aware helpers whose values resolve live:
params: (s) =>
s.object({
widgetId: s.widgetId(), // enum of widgets on the page now
widgetType: s.widgetType(), // enum of registered widget types
pageId: s.pageId().optional(),
label: s.string({ description: 'Shown on the widget.' }),
limit: s.number({ description: 'Maximum rows. Defaults to 50.' }).optional(),
mode: s.enum(['fast', 'thorough']),
}),Because the live helpers read current state, a tool using s.widgetId() on a page with no widgets has nothing to offer, and is withheld from the turn rather than advertised with an empty enum.
Write descriptions on individual parameters. They appear in the JSON Schema the model reads, and they are cheaper than correcting a misuse afterwards.
From a Command Copy Link
When the action changes dashboard state, put it in a command. The command validates and applies; the tool describes and formats.
const renameWidget = api.defineAiCommand((s) => ({
input: s.object({
widgetId: s.widgetId(),
title: s.string({ description: 'The new title.' }),
}),
execute: ({ widgetId, title }) => {
const state = api.getState();
const page = state.pages.find((candidate) => candidate.id === state.selectedPageId);
const widget = page?.widgets?.[widgetId];
if (!widget) {
return {
success: false,
error: new AgAiCommandError({ code: 'execute_failed', message: 'No such widget.' }),
};
}
api.setState(withTitle(state, widgetId, title));
return { success: true, value: title };
},
}));
const renameWidgetTool = api.defineAiTool({
name: 'rename_widget',
description: "Change a widget's title.",
command: renameWidget,
result: (title, args) => ({
response: `Renamed ${args.widgetId} to "${title}".`,
data: { widgetId: args.widgetId, title },
}),
});result receives what the command returned, the arguments it was called with, and a context with the abort signal. Return response for the model - a string, or an object that gets stringified - and optionally data for the tool's component.
A command failure becomes tool issues automatically. You do not handle it in result, which only runs on success.
Wrap a Built-in Command Copy Link
To keep Studio's validated action but present it your way, ask for the command by reference:
const placeWidget = api.defineAiTool({
name: 'place_widget',
description: 'Place a widget on the canvas at a grid position.',
command: api.defineAiCommand({ type: 'AgAddWidgetCommand' }),
result: async (_value, args) => ({
response: `Placed a ${args.type}.`,
data: { placed: args, health: await api.getAiContext().health.page() },
}),
});Ref type | What it does |
|---|---|
AgExecuteQueryCommand | Runs a query. Supports aggregation (group-by with measures) and projection (raw rows). |
AgAddWidgetCommand | Adds a widget to the canvas. |
AgPositionWidgetCommand | Moves or resizes a widget. Omitted fields keep current values. |
AgRemoveWidgetCommand | Removes a widget from the page. |
AgConfigureWidgetCommand | Configures a widget. Schema narrows by params.widgetType. |
AgAddPageFilterCommand | Appends a page-level filter. |
AgRemovePageFilterCommand | Removes a page-level filter. |
AgAddWidgetFilterCommand | Adds a widget-level filter. |
AgRemoveWidgetFilterCommand | Removes a widget-level filter. |
Refs that carry configuration take params:
const configureBar = api.defineAiCommand({
type: 'AgConfigureWidgetCommand',
params: { widgetType: 'bar-chart-grouped' },
});A command is usable on its own, with no tool and no agent: toJSONSchema() for your own harness, parse() to validate, apply() to run.
const result = await command.apply(args, { signal });
if (!result.success) {
// result.error.issues describes what was wrong
}Commands never throw. Handle the failure branch rather than wrapping calls in try/catch.
Deriving Input From State Copy Link
A command can derive its input shape from Studio's state, so an invalid mutation is rejected before anything is applied. The second argument to the factory carries lenses over Studio's state and query shapes:
const addNote = api.defineAiCommand((s, { state }) => {
const focus = state
.at('pages')
.where({ pageId: s.pageId().optional() }, (page, args) => page.id === args.pageId)
.at('widgets')
.focus('set');
return {
input: focus.mutation,
execute: (args) => {
const current = api.getState();
const result = focus.with(current, args);
if (!result.success) {
return {
success: false,
error: new AgAiCommandError({ code: 'invalid_input', message: 'Rejected.' }),
};
}
api.setState(result.next);
return { success: true, value: undefined };
},
};
});This is how Studio's own tools are built. It is more machinery than most integrations need. Use it when you want the state shape, rather than your own code, to decide what is valid.
JSON Schema Support Copy Link
Studio generates JSON Schema Draft 2020-12. Providers vary in what they accept, so when you author a tool for a specific model, watch for:
- Optional parameters. Some providers require every parameter in
required. Model an optional field as a union withnulland decode the result. - Nesting depth. Some providers cap it. Studio uses
$defsand$refto stay shallow, but a deep shape may still need breaking up. - A union at the root. Several providers reject a tool whose
parametersroot isanyOfrather than an object. If your command's input is a union, nest it under a key of your own:
api.defineAiTool({
name: 'run_query',
description: 'Run a query against the data.',
params: (s) => s.object({ query: queryShape }),
execute: async (args, ctx) => {
const applied = await command.apply(args.query, { signal: ctx.signal });
return applied.success ? ctx.success('Done.') : ctx.error('Query failed.');
},
});Studio's own execute_query does the same, for the same reason.
Next Copy Link
- Tool Components - render the call in the chat panel
- External Tools - tools run by a server or the provider
- Agent Context - what to describe to the model