---
product: "AG Studio"
title: "Built-in Harness"
description: "Learn how to configure the harness AG Studio ships, including its agents, persistence, observability and sessions."
framework: angular
version: "3.0.0"
related:
    - title: "Harness Overview"
      url: "https://www.ag-grid.com/studio/archive/3.0.0/angular/ai-harness/"
    - title: "Direct LLM Runner"
      url: "https://www.ag-grid.com/studio/archive/3.0.0/angular/ai-direct-llm-runner/"
    - title: "Client Tool Runner"
      url: "https://www.ag-grid.com/studio/archive/3.0.0/angular/ai-client-tool-runner/"
    - title: "Custom Runner"
      url: "https://www.ag-grid.com/studio/archive/3.0.0/angular/ai-custom-runner/"
    - title: "Custom Harness"
      url: "https://www.ag-grid.com/studio/archive/3.0.0/angular/ai-custom-harness/"
llms: "https://www.ag-grid.com/studio/archive/3.0.0/llms.txt"
---

# Built-in Harness

`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](https://www.ag-grid.com/studio/archive/3.0.0/angular/ai-custom-harness/).

## Declaring Agents

The agent framework is configured through the `ai` property. Use the function form, because everything you list hangs off `api`.

#### Direct LLM Runner

For Studio's five agents on one model, pass the adapter straight to `createAiHarness`:

```ts
<ag-studio
    [ai]="ai"
    /* other studio properties ... */ />

this.ai = ({ api }) => createAiHarness(api, { adapter });
```

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:

```ts
<ag-studio
    [ai]="ai"
    /* other studio properties ... */ />

this.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',
    }));
```

See [Direct LLM Runner](https://www.ag-grid.com/studio/archive/3.0.0/angular/ai-direct-llm-runner/) for the adapter contract.

#### Client Tool Runner

Give `clientToolRunner` a `run` that answers one turn, and list the tools Studio should execute on its behalf:

```ts
<ag-studio
    [ai]="ai"
    /* other studio properties ... */ />

this.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',
    }));
```

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](https://www.ag-grid.com/studio/archive/3.0.0/angular/ai-client-tool-runner/) for the events a run emits.

#### Custom Runner

A loop that already executes its own tools needs no factory. Hand the harness the definition and its `run`:

```ts
<ag-studio
    [ai]="ai"
    /* other studio properties ... */ />

this.ai = ({ api }) =>
    createAiHarness(api, () => ({
        agents: [{ id: 'analyst', run: myLoop }],
        primary: 'analyst',
    }));
```

See [Custom Runner](https://www.ag-grid.com/studio/archive/3.0.0/angular/ai-custom-runner/) for what your loop has to emit.

> **Note**
>
> `ai` is marked `@initial`: set it at construction time. Registering the module comes first - see [Agent Quick Start](https://www.ag-grid.com/studio/archive/3.0.0/angular/ai-quickstart/#1-register-the-module).

## Config Reference

`agents` is the roster, and `primary` names the agent new threads start from. The rest of this page covers the remaining options.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `agents` | `AgAnyAiAgentRunner[]` |  | 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`. |
| `primary` | `string` |  | The agent new threads start from. |
| `models` | `AgAiModel[]` |  | 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. |
| `history` | `AgAiHistoryStore` |  | Persistence backend for threads. Omit for in-memory only. |
| `promptStarters` | `AgAiPromptStarter[]` |  | 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` | `AgAiTelemetryObserver[]` |  | 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

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:

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

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `listThreads` | `Function` |  | Thread summaries for the roster (no message bodies). |
| `loadThread` | `Function` |  | A thread's full durable content, loaded when it is opened. |
| `saveThread` | `Function` |  | Persist a thread on change (the host debounces); also creates a new thread. |
| `deleteThread` | `Function` |  | Remove a thread and everything stored under it. |
| `addEventListener` | `Function` |  | 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. |
| `removeEventListener` | `Function` |  | 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.

#### Persisted Conversation

```ts
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()],
});
```

[Live example: Persisted Conversation](https://www.ag-grid.com/studio/archive/3.0.0/examples/ai-builtin-harness/ai-persistence-example/angular/)

## Observability

The harness emits a typed stream of boundary events. Register an observer to feed metrics, a cost meter, or an eval harness:

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

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

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](https://www.ag-grid.com/studio/archive/3.0.0/angular/ai-tool-components/).

## Sessions

A session is one live conversation, and it is what a UI reads:

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `threadId` | `string` |  | The thread this session is the live view of. |
| `addEventListener` | `Function` |  | 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. |
| `removeEventListener` | `Function` |  | Stop notifying a listener added with AgAiChatSession.addEventListener. |
| `messages` | `readonly AgAiChatMessage[]` |  | Ordered messages. New reference on change; parts grow as content streams. |
| `status` | `AgAiRunStatus` |  | Where this conversation has got to: idle, running, waiting to be answered, or failed. |
| `artifacts` | `readonly AgAiChatArtifact<unknown>[]` |  | Durable, non-message outputs (e.g. the plan). New reference on change. |
| `sharedState` | `unknown` |  | 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. |
| `sendMessage` | `Function` |  | 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. |
| `cancel` | `Function` |  | Interrupt the active run. |
| `respond` | `Function` |  | 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. |
| `close` | `Function` |  | 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.

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

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `addEventListener` | `Function` |  | 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. |
| `removeEventListener` | `Function` |  | Stop notifying a listener added with AgAiHarness.addEventListener. |
| `agents` | `readonly AgAiAgentDescriptor[]` |  | Agents that can speak. New reference on change. |
| `models` | `readonly AgAiModel[]` |  | 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. |
| `threads` | `readonly AgAiThreadSummary[]` |  | 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. |
| `promptStarters` | `readonly AgAiPromptStarter[]` |  | 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. |
| `getAgent` | `Function` |  | One agent from the roster, or `undefined` when nothing holds that id. |
| `getThread` | `Function` |  | One conversation's summary, or `undefined` when nothing holds that id. |
| `openThread` | `Function` |  | Idempotent: the same `threadId` returns the same live session. |
| `createThread` | `Function` |  | Start a conversation with the named agent and return its live session. Rejects when no agent holds that id. |
| `deleteThread` | `Function` |  | Remove a conversation, closing its session and taking any conversation nested below it with it. Does nothing when nothing holds that id. |
| `getSession` | `Function` |  | 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. |
| `setThreadModel` | `Function` |  | 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. |
| `dispose` | `Function` |  | Release any resources the harness holds (e.g. a persistence subscription). |

## Next

- [Direct LLM Runner](https://www.ag-grid.com/studio/archive/3.0.0/angular/ai-direct-llm-runner/) - the shortest route to a working agent
- [Client Tool Runner](https://www.ag-grid.com/studio/archive/3.0.0/angular/ai-client-tool-runner/) - your request, Studio's tool execution
- [Custom Runner](https://www.ag-grid.com/studio/archive/3.0.0/angular/ai-custom-runner/) - your loop, untouched
