The Direct LLM Runner runs the agent loop in the page.
Each turn, the agent asks your adapter for a completion, executes any tools the model called, and goes round again. Studio drives that loop, so this runner is the least you have to build.
Use one of the others when the model must be called from your infrastructure (Client Tool Runner) or the loop is code you own (Custom Runner).
Declaring the Agent Copy Link
directLlmRunner builds one. Its adapter, instructions and tools all sit on the config:
// Your AgLlmAdapter, as built in The Adapter below.
const adapter = myOpenAiAdapter({ endpoint: '/api/llm' });
directLlmRunner({
id: 'analyst',
adapter,
instructions: () => 'You build dashboards for a team of climate analysts.',
tools: () => [studio.viewSchema(), studio.executeQuery(), studio.addWidget()],
});Instructions and tools are callbacks, re-resolved every run - see Agent Overview.
To run Studio's five agents on one model, you do not have to declare them at all. Pass the adapter to createAiHarness and it pairs each built-in agent with this runner for you:
const studioProperties = {
// Your AgLlmAdapter, as built in The Adapter below.
const adapter = myOpenAiAdapter({ endpoint: '/api/llm' });
ai: ({ api }) => createAiHarness(api, { adapter }),
// other studio properties ...
}For a roster of your own, list the agents on the harness config - see Built-in Harness.
The Adapter Copy Link
AG Studio is provider-agnostic and bundles no connection to any LLM. You supply an adapter implementing AgLlmAdapter, which translates between Studio's request format and whatever your provider expects. An agent running its own loop needs no adapter, because the provider call happens there.
The example below ships a complete OpenAI adapter. Copy it as a starting point and adapt it to your provider.
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. Each one drives the adapter through a different
* shape of turn: a read, a query, and a change to the page.
*/
const PROMPT_STARTERS: AgAiPromptStarter[] = [
{
label: "Summarise this page",
prompt:
"Summarise what this page shows: every widget, the fields it reads, and what stands out.",
},
{
label: "Hottest cities",
prompt: "Which ten cities record the highest maximum temperature?",
},
{
label: "Filter to Europe",
prompt: "Filter this page to cities in Europe.",
},
];
/**
* 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,
// Whichever model the reader picks arrives on `request.model`, and the adapter passes
// its id and effort straight through to the provider.
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;
}
The AgLlmAdapter Interface Copy Link
The adapter is a plain object. Its one required method is executeTurn.
Execute a single turn of conversation with the AI. A turn consists of sending input and receiving a streamed response. options.signal aborts the in-flight turn when the run is cancelled; pass it to the transport (e.g. fetch). It is deliberately kept off AgLlmRequest so adapters can spread request into a provider body without leaking it.
|
executeTurn Copy Link
executeTurn is called each time Studio needs an AI response. It receives an AgLlmRequest and must return an AgLlmResponseHandler synchronously. The handler exposes a live stream and a completion promise.
The Request Copy Link
Each call to executeTurn receives everything the model needs for one turn.
Conversation history to send to the AI, providing context.
|
System instructions for this specific turn, overriding defaults.
|
Tools available for the AI to use during this turn.
|
Strategy for how the AI should choose tools. |
Output format configuration controlling response structure.
|
Which model to answer with, and how much effort to spend, as chosen for this turn. Present only when the chat was configured with models to choose between; map it onto whatever your provider calls these. An adapter that ignores it always uses its own default model.
|
Choosing the Model Copy Link
request.model is present only when the chat was configured with models to choose between. It carries the id and effort of whichever the reader picked, exactly as you declared them. Use ids your provider already understands and the mapping is a pass-through:
const body = {
model: request.model?.id ?? 'gpt-5.6-terra',
reasoning: request.model?.effort ? { effort: request.model.effort } : undefined,
// ...
};An adapter that ignores the field always answers on its own model, as it does when no models are configured.
The Response Copy Link
executeTurn returns an AgLlmResponseHandler: a stream of incremental events, and a complete promise that resolves with the final response.
The turn's content as it arrives: the message, reasoning and tool-call events of AgAiEvent. A turn is part of a run, so an adapter emits content events only - the run and step boundaries belong to whatever is driving the loop.
|
Promise that resolves when the response is fully complete. Contains the final, consolidated response data.
|
The final response has this shape:
Unique identifier for this response.
|
Timestamp when the response was created (milliseconds since epoch).
|
Error details if the response failed.
|
Details about why the response was incomplete. Present when the AI couldn't fully complete its response.
|
Output items produced by the AI (messages, tool calls, reasoning).
|
Current status of the response.
|
Token usage for this turn, when the adapter surfaces it. Consumed for observability (per-turn/run token + cost metrics); optional so an adapter that can't report it is valid.
|
The model that produced this response, when the adapter reports it (used for per-turn cost).
|
Stream Events Copy Link
The stream yields AgAiEvent values, the same AG-UI vocabulary every runner emits - see AG-UI Compatibility. A turn is one part of a run, so an adapter emits content events only: the run and step boundaries belong to whatever drives the loop, which here is Studio.
| Event | Description |
|---|---|
TEXT_MESSAGE_START | An assistant message has begun, under the messageId the later events reference. |
TEXT_MESSAGE_CONTENT | A delta of answer text to append to that message. |
TEXT_MESSAGE_END | The message is finished. |
REASONING_MESSAGE_START | Reasoning has begun, rendered in the panel apart from the answer. |
REASONING_MESSAGE_CONTENT | A delta of reasoning text. |
REASONING_MESSAGE_END | The reasoning is finished. |
TOOL_CALL_START | The model has called a tool, named by toolCallName under a toolCallId. |
TOOL_CALL_ARGS | A delta of that call's JSON arguments. |
TOOL_CALL_END | The call's arguments are complete. |
RAW | A provider event with no Studio equivalent, carried through untouched. |
Each of the three start/content/end sequences has a *_CHUNK shorthand - TEXT_MESSAGE_CHUNK, REASONING_MESSAGE_CHUNK, TOOL_CALL_CHUNK - which condenses the sequence into repeated single events, the id omitted to continue what the last chunk began. Both forms describe the same thing and Studio reads either, so emit whichever is the shorter translation from your provider's stream.
A failed turn is reported on the complete promise's AgLlmResponse, through status and error, rather than as a stream event.
Tool Calls Copy Link
The adapter does not execute tools. It only:
- Passes the
AgAiToolSchema[]inrequest.toolsto the LLM. - Relays the tool-call output items from the LLM back through the stream.
Tool name.
|
Human-readable description for the LLM.
|
JSON Schema describing the tool's parameters.
|
Where the tool executes (default client). See AgAiToolKind.
|
For a provided tool: the provider's own declaration of it, passed through to the adapter untouched. Studio never reads this - it is whatever shape your provider expects (for OpenAI's hosted web search, { type: 'web_search' }).
|
The agent intercepts those tool calls, executes them, and feeds the results back as function_call_output items on the next turn. Your adapter never needs to know what view_schema or configure_widget do.
Handle one exception: a provider-hosted tool carries the provider's own declaration on provider, and your adapter has to pass it through in the provider's native form rather than converting it to a function schema.
const tools = request.tools?.map((tool) =>
tool.kind === 'provided' ? tool.provider : { type: 'function', function: toFunctionSchema(tool) }
); Keeping Keys Off the Client Copy Link
executeTurn runs in the browser, so calling a provider directly exposes your API key. For production, point executeTurn at your own backend endpoint instead: forward the AgLlmRequest, call the provider server-side with your secret key, and stream the response back. The adapter contract is unchanged - only the URL it calls differs.
Next Copy Link
- Built-in Agents - the agents this runner will drive
- Built-in Harness - persistence, observability, sessions
- Client Tool Runner - the alternative: run the loop on your server