---
product: "AG Studio"
title: "Custom Tools"
description: "Learn how to author a tool an agent can call, either from your own action or by wrapping one of AG Studio's validated commands."
framework: javascript
version: "3.0.0"
related:
    - title: "Tools Overview"
      url: "https://www.ag-grid.com/studio/javascript/ai-tools/"
    - title: "Built-in Tools"
      url: "https://www.ag-grid.com/studio/javascript/ai-tools-builtin/"
    - title: "External Tools"
      url: "https://www.ag-grid.com/studio/javascript/ai-tools-external/"
llms: "https://www.ag-grid.com/studio/llms.txt"
---

# Custom Tools

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

#### Custom Tools

```ts
import {
  AgAiConversationItem,
  AgAiToolCall,
  AgAiToolSchema,
  AgBuiltInAiCommandRef,
  AgLlmRequest,
  AgReportState,
  AgStudioApi,
  AgStudioProperties,
  createStudio,
  enableStudioDevValidations,
} from "ag-studio";
import { openaiAdapter } from "./shared/openaiAdapter.ts";
import { salesData } from "./data.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 = "";

const initialState: AgReportState = {
  pages: [
    {
      id: "main",
      widgets: {
        "revenue-by-region": {
          type: "bar-chart-grouped",
          dataMapping: {
            categoryKey: [{ id: "sales.region" }],
            valueKey: [{ id: "sales.revenue", aggregation: "sum" }],
          },
          format: { caption: { enabled: true, text: "Revenue by Region" } },
        },
      },
      widgetLayout: {
        "revenue-by-region": { xTrack: 0, yTrack: 0, xSpan: 24, ySpan: 18 },
      },
    },
  ],
  selectedPageId: "main",
};

const studioProperties: AgStudioProperties = {
  mode: "edit",
  initialState,
  data: salesData,
};

interface CommandDemo {
  label: string;
  ref: AgBuiltInAiCommandRef;
  prompt: string;
}

const SYSTEM_INSTRUCTIONS = [
  "You are operating an AG Studio dashboard via a single tool call.",
  'The page id is "main". It contains one widget: id "revenue-by-region", type "bar-chart-grouped".',
  'The data source "sales" exposes fields: region (text), product (text), revenue (currency).',
  "Call the provided tool exactly once with arguments that satisfy the user request and the tool schema.",
].join(" ");

const COMMANDS: CommandDemo[] = [
  {
    label: "Execute Query",
    ref: { type: "AgExecuteQueryCommand" },
    prompt: "Run a query that returns average revenue per product.",
  },
  {
    label: "Add Page Filter",
    ref: { type: "AgAddPageFilterCommand" },
    prompt: "Filter the page so only the EMEA region is included.",
  },
  {
    label: "Remove Page Filter",
    ref: { type: "AgRemovePageFilterCommand" },
    prompt: "Remove the page filter that is currently restricting the region.",
  },
  {
    label: "Add Widget Filter",
    ref: { type: "AgAddWidgetFilterCommand" },
    prompt:
      "On the revenue-by-region widget, filter so only the Technology product is shown.",
  },
  {
    label: "Remove Widget Filter",
    ref: { type: "AgRemoveWidgetFilterCommand" },
    prompt: "Remove the product filter from the revenue-by-region widget.",
  },
  {
    label: "Add Widget",
    ref: { type: "AgAddWidgetCommand" },
    prompt:
      "Add a new KPI (value) widget showing total revenue. Place it at xTrack 0, yTrack 18, spanning 8 columns and 6 rows.",
  },
  {
    label: "Position Widget",
    ref: { type: "AgPositionWidgetCommand" },
    prompt:
      "Move the revenue-by-region widget to the right half of the page (xTrack 12, yTrack 0, xSpan 12, ySpan 18).",
  },
  {
    label: "Remove Widget",
    ref: { type: "AgRemoveWidgetCommand" },
    prompt: "Delete the revenue-by-region widget.",
  },
  {
    label: "Configure Widget",
    ref: {
      type: "AgConfigureWidgetCommand",
      params: { widgetType: "bar-chart-grouped" },
    },
    prompt:
      'Re-caption the revenue-by-region widget to "Total Revenue by Region".',
  },
];

const assistant = openaiAdapter({ endpoint: AI_API_URL, key: AI_API_TOKEN });

let studioApi: AgStudioApi;

const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);

/** Run the demo for one command type, as the buttons above call it. */
async function runCommand(type: string): Promise<void> {
  const demo = COMMANDS.find((candidate) => candidate.ref.type === type);
  if (!demo) {
    return;
  }
  try {
    await runDemo(demo);
  } catch (err) {
    console.error("[ai-custom-tools] failed", type, err);
  }
}

(window as any).runCommand = runCommand;

async function runDemo(demo: CommandDemo): Promise<void> {
  const command = studioApi.defineAiCommand(demo.ref);
  const schema = command.toJSONSchema() as Record<string, unknown>;
  // OpenAI's Responses API requires the tool `parameters` root to be an
  // object schema - `anyOf`/`oneOf` at the root (which several of our
  // lens-derived command schemas produce) is rejected. Wrap every schema
  // in a single-property envelope and unwrap before `apply()`. Hoist
  // `$defs` to the wrapper root so `#/$defs/...` refs still resolve.
  const { $defs, ...inner } = schema;
  const wrappedSchema: Record<string, unknown> = {
    type: "object",
    properties: { command: inner },
    required: ["command"],
    additionalProperties: false,
  };
  if ($defs !== undefined) wrappedSchema.$defs = $defs;
  const tool: AgAiToolSchema = {
    name: demo.ref.type,
    description: `Built-in AG Studio command: ${demo.ref.type}`,
    parameters: wrappedSchema as AgAiToolSchema["parameters"],
  };

  console.log(
    `[ai-custom-tools] ${demo.ref.type} - message sent:`,
    demo.prompt,
  );
  console.log(`[ai-custom-tools] ${demo.ref.type} - tool schema:`, tool);

  const userMessage: AgAiConversationItem = {
    id: `msg-${Date.now()}`,
    kind: "input",
    type: "message",
    role: "user",
    content: [{ type: "text", text: demo.prompt }],
    status: "completed",
  };

  const request: AgLlmRequest = {
    input: [userMessage],
    instructions: SYSTEM_INSTRUCTIONS,
    tools: [tool],
    toolChoice: { name: tool.name },
    responseFormat: { type: "text" },
  };

  const handler = assistant.executeTurn(request);
  // The adapter's `complete` only resolves once the stream is drained;
  // the underlying fetch is initiated lazily by the async iterator.
  for await (const _event of handler.stream) {
    // Drain - we only care about the final response, not intermediate events.
  }
  const response = await handler.complete;
  const toolCall = response.output.find(
    (item): item is AgAiToolCall => item.type === "function_call",
  );
  if (!toolCall) {
    console.warn(
      `[ai-custom-tools] ${demo.ref.type} - LLM returned no tool call`,
      response,
    );
    return;
  }

  const wrappedArgs = JSON.parse(toolCall.arguments);
  const args = wrappedArgs?.command;
  console.log(`[ai-custom-tools] ${demo.ref.type} - tool args from LLM:`, args);

  const result = await command.apply(args);
  console.log(`[ai-custom-tools] ${demo.ref.type} - command result:`, result);
}

if (typeof window !== "undefined") {
  // Attach external event handlers to window so they can be called from index.html
  (<any>window).runCommand = runCommand;
}
```

[Live example: Custom Tools](https://www.ag-grid.com/studio/examples/ai-custom-tools/ai-custom-tools-example/typescript/)

## Your Own Action

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

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

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

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

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

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

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

## Wrap a Built-in Command

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

```ts
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 `type` | What it does |
| --- | --- |
| `AgExecuteQueryCommand` | Runs a query. Supports aggregation (group-by with measures) and projection (raw rows). |
| `AgAddWidgetCommand` | Adds a widget to the canvas. |
| `AgPositionWidgetCommand` | Moves or resizes a widget. Omitted fields keep current values. |
| `AgRemoveWidgetCommand` | Removes a widget from the page. |
| `AgConfigureWidgetCommand` | Configures a widget. Schema narrows by `params.widgetType`. |
| `AgAddPageFilterCommand` | Appends a page-level filter. |
| `AgRemovePageFilterCommand` | Removes a page-level filter. |
| `AgAddWidgetFilterCommand` | Adds a widget-level filter. |
| `AgRemoveWidgetFilterCommand` | Removes a widget-level filter. |

Refs that carry configuration take `params`:

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

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

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:

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

Studio generates [JSON Schema Draft 2020-12](https://json-schema.org/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:

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

- [Tool Components](https://www.ag-grid.com/studio/javascript/ai-tool-components/) - render the call in the chat panel
- [External Tools](https://www.ag-grid.com/studio/javascript/ai-tools-external/) - tools run by a server or the provider
- [Agent Context](https://www.ag-grid.com/studio/javascript/ai-context/) - what to describe to the model
