---
product: "AG Studio"
title: "Custom Runner"
description: "Learn how to run an agent loop that owns its own rounds, run boundaries and tool execution, and how to wrap an existing agent SDK in one."
framework: react
version: "3.0.0"
related:
    - title: "Harness Overview"
      url: "https://www.ag-grid.com/studio/react/ai-harness/"
    - title: "Built-in Harness"
      url: "https://www.ag-grid.com/studio/react/ai-builtin-harness/"
    - title: "Direct LLM Runner"
      url: "https://www.ag-grid.com/studio/react/ai-direct-llm-runner/"
    - title: "Client Tool Runner"
      url: "https://www.ag-grid.com/studio/react/ai-client-tool-runner/"
    - title: "Custom Harness"
      url: "https://www.ag-grid.com/studio/react/ai-custom-harness/"
llms: "https://www.ag-grid.com/studio/llms.txt"
---

# Custom Runner

A custom runner is an agent whose loop already does everything. There is no factory for it, because there is nothing for Studio to add:

```ts
const analyst = {
    id: 'analyst',
    description: 'Builds dashboards from a natural-language request.',
    run: myLoop,
};
```

Hand that to the harness's `agents` and Studio drives none of it. Use this runner when your loop resolves its own tool calls: an agent SDK whose `streamText` executes every tool itself, or a scripted sequence that decides for itself when it is done.

If your loop cannot execute a tool in the browser, use the [Client Tool Runner](https://www.ag-grid.com/studio/react/ai-client-tool-runner/) instead, which runs them for you.

## What You Own

Everything the other two runners hand over:

| Responsibility | Direct LLM and Client Tool | Custom |
| --- | --- | --- |
| Run boundaries (`RUN_STARTED`, `RUN_FINISHED`) | Studio emits them | **You emit them** |
| Rounds | Studio re-enters `run` until nothing is outstanding | **Your loop decides when it is done** |
| Tool execution | Studio executes what you left unresolved | **You call `execute` yourself** |
| Telemetry | Studio emits it | **You call `ctx.emit`, or there is none** |
| `maxTurns` | Studio counts and stops | Not counted - your loop is the limit |

Because Studio adds no round loop, a run ends when your generator returns.

## Emitting a Run

```ts
run: async function* (input, ctx) {
    const { threadId, runId } = input;
    yield { type: 'RUN_STARTED', threadId, runId };

    const messageId = `msg-${runId}`;
    yield { type: 'TEXT_MESSAGE_START', messageId, role: 'assistant' };
    for await (const delta of myLoop.stream({ messages: input.messages, signal: ctx.signal })) {
        yield { type: 'TEXT_MESSAGE_CONTENT', messageId, delta };
    }
    yield { type: 'TEXT_MESSAGE_END', messageId };

    yield { type: 'RUN_FINISHED', threadId, runId };
},
```

The event vocabulary is the same one [Client Tool Runner](https://www.ag-grid.com/studio/react/ai-client-tool-runner/#the-events) documents. Only the run brackets differ, and here you emit them.

## Executing Tools Yourself

`ctx.tools()` returns the agent's tools, re-resolved per call. `execute` takes an invocation - the call id, the tool name, and the decoded arguments - plus a per-call context:

```ts
const tool = ctx.tools().find((candidate) => candidate.name === call.name);

if (tool?.execute) {
    const result = await tool.execute(
        { toolCallId: call.id, name: tool.name, args: call.args },
        createAiToolContext({ signal: ctx.signal })
    );
}
```

`args` is a decoded object, not the JSON string the model produced, so parse the model's arguments before calling. `execute` is optional, because an [external tool](https://www.ag-grid.com/studio/react/ai-tools-external/) is declared here and run elsewhere, so guard it rather than asserting it.

Nothing else runs these for you at this runner, so a tool your loop never calls never runs.

## Wrapping an Agent SDK

If you have built on CopilotKit, the AI SDK or similar, the framework keeps its loop and you translate at two points.

### Its output becomes AG-UI events

The events your `run` yields are the [AG-UI](https://docs.ag-ui.com) vocabulary, declared by Studio as `AgAiEvent`. A framework that already speaks AG-UI needs no mapping here: hand its stream straight through. Anything else translates into the same event names - see [AG-UI Compatibility](https://www.ag-grid.com/studio/react/ai-agents/#ag-ui-compatibility) for what Studio does and does not guarantee about that alignment.

Emit `TEXT_MESSAGE_CONTENT` per delta rather than one block at the end, so text streams into the panel. If your framework exposes reasoning separately, use `REASONING_MESSAGE_*` so it renders as reasoning rather than answer text.

### Studio's tools become its tools

A custom runner executes its own tools, so convert Studio's tools into your framework's own abstraction:

```ts
const studioTools = [studio.viewSchema(), studio.executeQuery(), studio.addWidget()];

const sdkTools = studioTools
    .map((tool) => ({ tool, schema: tool.schema() }))
    .filter((entry) => entry.schema !== undefined)
    .map(({ tool, schema }) => ({
        name: tool.name,
        description: tool.description,
        parameters: schema.parameters,
        execute: async (args) => {
            const result = await tool.execute!(
                { toolCallId: crypto.randomUUID(), name: tool.name, args },
                createAiToolContext({ signal: ctx.signal })
            );
            return result.success ? result.response : result.issues.map((issue) => issue.message).join('; ');
        },
    }));
```

Rebuild that list **per turn**, not once at construction. `tool.schema()` reads live state, and a tool with nothing to offer returns `undefined` - filter those out rather than advertising an empty enum.

Emit the tool-call events as well, so the calls appear in the panel with their declared label and component:

```ts
yield { type: 'TOOL_CALL_START', toolCallId: call.id, toolCallName: call.name };
yield { type: 'TOOL_CALL_ARGS', toolCallId: call.id, delta: JSON.stringify(call.args) };
yield { type: 'TOOL_CALL_END', toolCallId: call.id };
```

These events are for display only. A custom runner has no round loop around it, so emitting them executes nothing and Studio will not call `run` again with a result appended - your loop has already done the work. If you want Studio to execute the calls and resume you with the results, that is the [Client Tool Runner](https://www.ag-grid.com/studio/react/ai-client-tool-runner/), and you wrap the same definition in `clientToolRunner` instead.

### Shared State

If your framework maintains state you want persisted with the thread, emit `STATE_SNAPSHOT`, or `STATE_DELTA` with a JSON Patch. Studio stores the latest snapshot on the thread and gives it back on reload.

## Next

- [Client Tool Runner](https://www.ag-grid.com/studio/react/ai-client-tool-runner/) - let Studio run the rounds and the tools
- [Custom Harness](https://www.ag-grid.com/studio/react/ai-custom-harness/) - replacing the conversation as well as the loop
- [Built-in Harness](https://www.ag-grid.com/studio/react/ai-builtin-harness/) - where the agent is registered
