Vue Embedded AnalyticsCustom Runner

Version 3.0.0

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:

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 instead, which runs them for you.

What You Own Copy Link

Everything the other two runners hand over:

ResponsibilityDirect LLM and Client ToolCustom
Run boundaries (RUN_STARTED, RUN_FINISHED)Studio emits themYou emit them
RoundsStudio re-enters run until nothing is outstandingYour loop decides when it is done
Tool executionStudio executes what you left unresolvedYou call execute yourself
TelemetryStudio emits itYou call ctx.emit, or there is none
maxTurnsStudio counts and stopsNot counted - your loop is the limit

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

Emitting a Run Copy Link

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 documents. Only the run brackets differ, and here you emit them.

Executing Tools Yourself Copy Link

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:

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 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 Copy Link

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 Copy Link

The events your run yields are the AG-UI 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 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 Copy Link

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

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:

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, and you wrap the same definition in clientToolRunner instead.

Shared State Copy Link

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 Copy Link