---
product: "AG Studio"
title: "Direct LLM Runner"
description: "Learn how to have AG Studio run the whole agent loop against a model you reach through an adapter."
framework: javascript
version: "3.0.0"
related:
    - title: "Harness Overview"
      url: "https://www.ag-grid.com/studio/javascript/ai-harness/"
    - title: "Built-in Harness"
      url: "https://www.ag-grid.com/studio/javascript/ai-builtin-harness/"
    - title: "Client Tool Runner"
      url: "https://www.ag-grid.com/studio/javascript/ai-client-tool-runner/"
    - title: "Custom Runner"
      url: "https://www.ag-grid.com/studio/javascript/ai-custom-runner/"
    - title: "Custom Harness"
      url: "https://www.ag-grid.com/studio/javascript/ai-custom-harness/"
llms: "https://www.ag-grid.com/studio/llms.txt"
---

# Direct LLM Runner

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](https://www.ag-grid.com/studio/javascript/ai-client-tool-runner/)) or the loop is code you own ([Custom Runner](https://www.ag-grid.com/studio/javascript/ai-custom-runner/)).

## Declaring the Agent

`directLlmRunner` builds one. Its adapter, instructions and tools all sit on the config:

```ts
// 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](https://www.ag-grid.com/studio/javascript/ai-agents/).

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:

```js
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](https://www.ag-grid.com/studio/javascript/ai-builtin-harness/#declaring-agents).

## The Adapter

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](https://www.ag-grid.com/studio/javascript/ai-custom-runner/) 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.

#### Direct LLM Runner

```ts
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;
}
```

[Live example: Direct LLM Runner](https://www.ag-grid.com/studio/examples/ai-direct-llm-runner/ai-direct-llm-runner-example/typescript/)

## The AgLlmAdapter Interface

The adapter is a plain object. Its one required method is `executeTurn`.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `executeTurn` | `Function` |  | 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

`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

Each call to `executeTurn` receives everything the model needs for one turn.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `input` | `AgAiConversationItem[]` |  | Conversation history to send to the AI, providing context. |
| `instructions` | `string` |  | System instructions for this specific turn, overriding defaults. |
| `tools` | `AgAiToolSchema[]` |  | Tools available for the AI to use during this turn. |
| `toolChoice` | `"none" \| "required" \| "auto" \| AgLlmToolChoice` |  | Strategy for how the AI should choose tools. 'auto': AI decides whether to use tools 'none': AI must not use tools 'required': AI must use at least one tool AgLlmToolChoice: AI must use the specified tool |
| `responseFormat` | `AgLlmJsonFormat \| AgLlmTextFormat` |  | Output format configuration controlling response structure. |
| `model` | `AgAiModelSelection` |  | 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

`request.model` is present only when the chat was [configured with models to choose between](https://www.ag-grid.com/studio/javascript/ai-chat-features/#offering-a-choice-of-model). 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:

```ts
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

`executeTurn` returns an `AgLlmResponseHandler`: a `stream` of incremental events, and a `complete` promise that resolves with the final response.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `stream` | `{ [asyncIterator]: any }` |  | 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. |
| `complete` | `Promise<AgLlmResponse>` |  | Promise that resolves when the response is fully complete. Contains the final, consolidated response data. |

The final response has this shape:

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` |  | Unique identifier for this response. |
| `createdAt` | `number` |  | Timestamp when the response was created (milliseconds since epoch). |
| `error` | `AgLlmResponseError` |  | Error details if the response failed. |
| `incompleteDetails` | `AgLlmResponseIncompleteDetails` |  | Details about why the response was incomplete. Present when the AI couldn't fully complete its response. |
| `output` | `AgAiOutputItem[]` |  | Output items produced by the AI (messages, tool calls, reasoning). |
| `status` | `"completed" \| "failed" \| "in_progress" \| "cancelled" \| "queued" \| "incomplete"` |  | Current status of the response. |
| `usage` | `AgAiUsage` |  | 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. |
| `model` | `string` |  | The model that produced this response, when the adapter reports it (used for per-turn cost). |

## Stream Events

The stream yields `AgAiEvent` values, the same [AG-UI](https://docs.ag-ui.com) vocabulary every runner emits - see [AG-UI Compatibility](https://www.ag-grid.com/studio/javascript/ai-agents/#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

The adapter does **not** execute tools. It only:

1. Passes the `AgAiToolSchema[]` in `request.tools` to the LLM.
2. Relays the tool-call output items from the LLM back through the stream.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | `string` |  | Tool name. |
| `description` | `string` |  | Human-readable description for the LLM. |
| `parameters` | [`AgJSONSchema`](https://www.ag-grid.com/studio/javascript/custom-widgets/#ai-integration) |  | JSON Schema describing the tool's parameters. |
| `kind` | `AgAiToolKind` |  | Where the tool executes (default `client`). See AgAiToolKind. |
| `provider` | `unknown` |  | 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](https://www.ag-grid.com/studio/javascript/ai-tools-external/) 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.

```ts
const tools = request.tools?.map((tool) =>
    tool.kind === 'provided' ? tool.provider : { type: 'function', function: toFunctionSchema(tool) }
);
```

## Keeping Keys Off the Client

`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

- [Built-in Agents](https://www.ag-grid.com/studio/javascript/ai-builtin-agents/) - the agents this runner will drive
- [Built-in Harness](https://www.ag-grid.com/studio/javascript/ai-builtin-harness/) - persistence, observability, sessions
- [Client Tool Runner](https://www.ag-grid.com/studio/javascript/ai-client-tool-runner/) - the alternative: run the loop on your server
