AG Studio Launch Week 🚀🚀🚀 28 Sep - 2 Oct 2026 🚀🚀🚀 Join now

JavaScript Embedded AnalyticsCustom Tools

Version 3.0.0

api.defineAiTool returns a tool ready to list on an agent.

It comes in two shapes: supply an execute for your own action, or wrap a command when the action should be validated and reusable.

Your Own Action Copy Link

The direct form: declare parameters, do the work, build a result.

const listReportsTool = api.defineAiTool({
    name: 'list_reports',
    description: 'List the saved reports this user can open.',
    params: (s) =>
        s.object({
            team: s.string({ description: 'Restrict to one team. Omit for all teams.' }).optional(),
        }),
    execute: async (args, ctx) => {
        const reports = await fetchReports({ team: args.team, signal: ctx.signal });
        if (reports.length === 0) {
            return ctx.error('No reports found. Ask the user to widen the search.');
        }
        return ctx.success(`Found ${reports.length} reports.`, { reports });
    },
});

params receives a shape builder and returns the tool's schema. execute receives the parsed arguments - already validated, so no defensive checks - plus a context carrying:

  • signal - aborts when the run is cancelled. Forward it to any fetch.
  • run - the thread and run ids, if you need to scope a resource per conversation.
  • success / error - result constructors.

Arguments that fail validation never reach execute. The model gets the validation issues back and can retry.

Parameters Copy Link

The builder covers the usual schema types plus Studio-aware helpers whose values resolve live:

params: (s) =>
    s.object({
        widgetId: s.widgetId(),                              // enum of widgets on the page now
        widgetType: s.widgetType(),                          // enum of registered widget types
        pageId: s.pageId().optional(),
        label: s.string({ description: 'Shown on the widget.' }),
        limit: s.number({ description: 'Maximum rows. Defaults to 50.' }).optional(),
        mode: s.enum(['fast', 'thorough']),
    }),

Because the live helpers read current state, a tool using s.widgetId() on a page with no widgets has nothing to offer, and is withheld from the turn rather than advertised with an empty enum.

Write descriptions on individual parameters. They appear in the JSON Schema the model reads, and they are cheaper than correcting a misuse afterwards.

From a Command Copy Link

When the action changes dashboard state, put it in a command. The command validates and applies; the tool describes and formats.

const renameWidget = api.defineAiCommand((s) => ({
    input: s.object({
        widgetId: s.widgetId(),
        title: s.string({ description: 'The new title.' }),
    }),
    execute: ({ widgetId, title }) => {
        const state = api.getState();
        const page = state.pages.find((candidate) => candidate.id === state.selectedPageId);
        const widget = page?.widgets?.[widgetId];
        if (!widget) {
            return {
                success: false,
                error: new AgAiCommandError({ code: 'execute_failed', message: 'No such widget.' }),
            };
        }
        api.setState(withTitle(state, widgetId, title));
        return { success: true, value: title };
    },
}));

const renameWidgetTool = api.defineAiTool({
    name: 'rename_widget',
    description: "Change a widget's title.",
    command: renameWidget,
    result: (title, args) => ({
        response: `Renamed ${args.widgetId} to "${title}".`,
        data: { widgetId: args.widgetId, title },
    }),
});

result receives what the command returned, the arguments it was called with, and a context with the abort signal. Return response for the model - a string, or an object that gets stringified - and optionally data for the tool's component.

A command failure becomes tool issues automatically. You do not handle it in result, which only runs on success.

Wrap a Built-in Command Copy Link

To keep Studio's validated action but present it your way, ask for the command by reference:

const placeWidget = api.defineAiTool({
    name: 'place_widget',
    description: 'Place a widget on the canvas at a grid position.',
    command: api.defineAiCommand({ type: 'AgAddWidgetCommand' }),
    result: async (_value, args) => ({
        response: `Placed a ${args.type}.`,
        data: { placed: args, health: await api.getAiContext().health.page() },
    }),
});
Ref typeWhat it does
AgExecuteQueryCommandRuns a query. Supports aggregation (group-by with measures) and projection (raw rows).
AgAddWidgetCommandAdds a widget to the canvas.
AgPositionWidgetCommandMoves or resizes a widget. Omitted fields keep current values.
AgRemoveWidgetCommandRemoves a widget from the page.
AgConfigureWidgetCommandConfigures a widget. Schema narrows by params.widgetType.
AgAddPageFilterCommandAppends a page-level filter.
AgRemovePageFilterCommandRemoves a page-level filter.
AgAddWidgetFilterCommandAdds a widget-level filter.
AgRemoveWidgetFilterCommandRemoves a widget-level filter.

Refs that carry configuration take params:

const configureBar = api.defineAiCommand({
    type: 'AgConfigureWidgetCommand',
    params: { widgetType: 'bar-chart-grouped' },
});

A command is usable on its own, with no tool and no agent: toJSONSchema() for your own harness, parse() to validate, apply() to run.

const result = await command.apply(args, { signal });
if (!result.success) {
    // result.error.issues describes what was wrong
}

Commands never throw. Handle the failure branch rather than wrapping calls in try/catch.

Deriving Input From State Copy Link

A command can derive its input shape from Studio's state, so an invalid mutation is rejected before anything is applied. The second argument to the factory carries lenses over Studio's state and query shapes:

const addNote = api.defineAiCommand((s, { state }) => {
    const focus = state
        .at('pages')
        .where({ pageId: s.pageId().optional() }, (page, args) => page.id === args.pageId)
        .at('widgets')
        .focus('set');

    return {
        input: focus.mutation,
        execute: (args) => {
            const current = api.getState();
            const result = focus.with(current, args);
            if (!result.success) {
                return {
                    success: false,
                    error: new AgAiCommandError({ code: 'invalid_input', message: 'Rejected.' }),
                };
            }
            api.setState(result.next);
            return { success: true, value: undefined };
        },
    };
});

This is how Studio's own tools are built. It is more machinery than most integrations need. Use it when you want the state shape, rather than your own code, to decide what is valid.

JSON Schema Support Copy Link

Studio generates JSON Schema Draft 2020-12. Providers vary in what they accept, so when you author a tool for a specific model, watch for:

  • Optional parameters. Some providers require every parameter in required. Model an optional field as a union with null and decode the result.
  • Nesting depth. Some providers cap it. Studio uses $defs and $ref to stay shallow, but a deep shape may still need breaking up.
  • A union at the root. Several providers reject a tool whose parameters root is anyOf rather than an object. If your command's input is a union, nest it under a key of your own:
api.defineAiTool({
    name: 'run_query',
    description: 'Run a query against the data.',
    params: (s) => s.object({ query: queryShape }),
    execute: async (args, ctx) => {
        const applied = await command.apply(args.query, { signal: ctx.signal });
        return applied.success ? ctx.success('Done.') : ctx.error('Query failed.');
    },
});

Studio's own execute_query does the same, for the same reason.

Next Copy Link