---
product: "AG Studio"
title: "Tool Components"
description: "Learn how to give a tool call its wording in the chat panel, and a component of your own for the body that opens beneath it."
framework: react
version: "3.0.0"
related:
    - title: "Chat UI Overview"
      url: "https://www.ag-grid.com/studio/react/ai-chat-ui/"
    - title: "Panel Features"
      url: "https://www.ag-grid.com/studio/react/ai-chat-features/"
llms: "https://www.ag-grid.com/studio/llms.txt"
---

# Tool Components

The chat panel renders a run of tool calls as a sequence of steps, one line each, with a marker carrying the outcome.

You declare two things per tool. `label` gives a step its words. `detail` gives it a component for the body that opens when the reader expands it: a result table, a preview, a confirmation prompt. A tool with no `detail` has no body, and its step does not expand.

This applies only when you use Studio's chat panel. An integration with [no harness](https://www.ag-grid.com/studio/react/ai-tools/#without-a-harness) renders whatever it likes.

The example below opens on a conversation that has already run, so both kinds of detail are on screen straight away: Studio's own `execute_query`, and a `web_search` tool the example declares with a component of its own. Expand either, or send a message to watch the streaming and executing phases. The agent is scripted, so no LLM is involved.

#### Tool Components

```tsx
("use client");

import React, {
  useCallback,
  useMemo,
  useRef,
  useState,
  StrictMode,
} from "react";
import { createRoot } from "react-dom/client";
import { AgStudio, AgStudioProvider, AgStudioRef } from "ag-studio-react";
import {
  AgAiEvent,
  AgAiHarnessSetup,
  AgAiModel,
  AgAiRunInput,
  AgDataEngine,
  AgDataSourcesDefinition,
  AgReportState,
  AgStudioAiModule,
  AgStudioApi,
  AgStudioMode,
  AgStudioProperties,
  Record,
  clientToolRunner,
  createAiHarness,
  enableStudioDevValidations,
} from "ag-studio";
import { getGhcnCitiesData } from "./shared/ghcnCities/data.tsx";
import { ghcnCitiesReportState } from "./shared/ghcnCities/state.tsx";
import { AGENT_ID, SEARCH_HITS, createSeededHistoryStore } from "./history.tsx";
import SearchResults from "./searchResults.tsx";

if (process.env.NODE_ENV !== "production") {
  // Enable extended validations only for development
  enableStudioDevValidations();
}

/**
 * The models offered beside the send button. The scripted agent ignores the choice - nothing here
 * reaches a provider - but the ids are the ones an app would declare, so the picker reads as it
 * would in one.
 */
const MODELS: AgAiModel[] = [
  { id: "gpt-5.6-terra", label: "GPT-5.6 Terra" },
  { id: "gpt-5.6-sol", label: "GPT-5.6 Sol" },
  { id: "gpt-5.6-luna", label: "GPT-5.6 Luna" },
];

/** Wait, so the example shows the streaming and executing phases rather than flashing past them. */
const pause: (ms: number) => Promise<void> = (ms: number) => {
  return new Promise((resolve) => setTimeout(resolve, ms));
};

const StudioExample = () => {
  const studioRef = useRef<AgStudioRef>(null);
  const containerStyle = useMemo(() => ({ width: "100%", height: "100%" }), []);
  const studioStyle = useMemo(() => ({ height: "100%", width: "100%" }), []);
  const [data, setData] = useState<AgDataSource>(
    getGhcnCitiesData("https://www.ag-grid.com/studio/example-assets"),
  );
  const initialState = useMemo<AgReportState>(() => {
    return ghcnCitiesReportState;
  }, []);
  const ai = useMemo<AgAiHarnessSetup>(() => {
    return ({ api }) => {
      /**
       * A provider-hosted tool: the LLM provider runs the search itself and returns the result
       * inline, so this declares the tool without an `execute`. Studio never calls it - it only
       * has to know how to present what comes back.
       */
      const webSearch = api.defineAiTool({
        name: "web_search",
        description: "Search the web and return the most relevant results.",
        kind: "provided",
        provider: { type: "web_search" },
      });
      /**
       * A scripted loop, so the example needs no LLM. It emits the search call, streams its
       * arguments, then emits the result itself - which is what a provider-hosted tool looks like
       * from the client's side, and is why the run settles in a single round.
       */
      async function* run(input: AgAiRunInput): AsyncGenerator<AgAiEvent> {
        const { threadId, runId } = input;
        yield { type: "RUN_STARTED", threadId, runId };
        const introId = `msg-${runId}-intro`;
        yield {
          type: "TEXT_MESSAGE_START",
          messageId: introId,
          role: "assistant",
        };
        yield {
          type: "TEXT_MESSAGE_CONTENT",
          messageId: introId,
          delta: "Checking what has been published.",
        };
        yield { type: "TEXT_MESSAGE_END", messageId: introId };
        const toolCallId = `${runId}-search`;
        yield {
          type: "TOOL_CALL_START",
          toolCallId,
          toolCallName: "web_search",
        };
        // Streamed a few characters at a time, so the component's streaming phase shows.
        const args = JSON.stringify({
          query: "Singapore annual rainfall record",
        });
        for (const chunk of args.match(/.{1,4}/g) ?? []) {
          await pause(40);
          yield { type: "TOOL_CALL_ARGS", toolCallId, delta: chunk };
        }
        yield { type: "TOOL_CALL_END", toolCallId };
        await pause(600);
        yield {
          type: "TOOL_CALL_RESULT",
          messageId: `${toolCallId}-result`,
          toolCallId,
          content: JSON.stringify({
            success: true,
            response: "3 results.",
            data: SEARCH_HITS,
          }),
        };
        const summaryId = `msg-${runId}-summary`;
        yield {
          type: "TEXT_MESSAGE_START",
          messageId: summaryId,
          role: "assistant",
        };
        yield {
          type: "TEXT_MESSAGE_CONTENT",
          messageId: summaryId,
          delta: "Singapore is the one the coverage keeps returning to.",
        };
        yield { type: "TEXT_MESSAGE_END", messageId: summaryId };
        yield { type: "RUN_FINISHED", threadId, runId };
      }
      return createAiHarness(api, () => ({
        agents: [
          clientToolRunner({
            id: AGENT_ID,
            description:
              "Reads the rainfall data and searches the web about it.",
            tools: () => [webSearch],
            run,
          }),
        ],
        primary: AGENT_ID,
        models: MODELS,
        // A conversation already in the panel on load, so both kinds of detail are visible
        // without sending anything. It also means no prompt starters are declared here: they
        // show only on an empty thread, and this one opens with messages already in it.
        history: createSeededHistoryStore(),
      }));
    };
  }, []);
  const aiToolDisplay = useMemo<
    Record<string, TRegistry["aiToolDisplay"]>
  >(() => {
    return {
      web_search: {
        label: ({ args, result }) => {
          // Arguments stream in, so `query` may not have arrived yet.
          const query = typeof args.query === "string" ? args.query : "the web";
          return {
            text: result ? `Searched for ${query}` : `Searching for ${query}`,
            pill: result?.success
              ? `${SEARCH_HITS.hits.length} results`
              : undefined,
          };
        },
        detail: SearchResults,
      },
    };
  }, []);

  const setPage = useCallback((pageId: string) => {
    studioRef.current!.api?.setState({
      ...studioRef.current!.api.getState(),
      selectedPageId: pageId,
    });
  }, []);

  return (
    <div style={containerStyle}>
      <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
        <div className="example-controls">
          <div className="controls-row">
            <button onClick={() => setPage("temperature")}>Temperature</button>
            <button onClick={() => setPage("precipitation")}>
              Precipitation
            </button>
            <button onClick={() => setPage("blank")}>Blank</button>
          </div>
        </div>

        <AgStudio
          ref={studioRef}
          style={studioStyle}
          className="my-studio-container"
          data={data}
          mode={"edit"}
          initialState={initialState}
          ai={ai}
          aiToolDisplay={aiToolDisplay}
        />
      </div>
    </div>
  );
};

const root = createRoot(document.getElementById("root")!);
root.render(
  <StrictMode>
    <AgStudioProvider modules={[AgStudioAiModule]}>
      <StudioExample />
    </AgStudioProvider>
  </StrictMode>,
);
```

[Live example: Tool Components](https://www.ag-grid.com/studio/examples/ai-tool-components/ai-tool-components-example/reactFunctionalTs/)

## Declare It

Presentation is declared per tool name on the `aiToolDisplay` Studio property, not on the tool:

```ts
const studioProperties = {
    ai: ({ api }) => createAiHarness(api, () => ({ agents: [analyst], primary: 'analyst' })),
    aiToolDisplay: {
        list_reports: {
            label: ({ result }) => ({ text: result ? 'Looked up reports' : 'Looking up reports' }),
            detail: ReportListCard,
        },
        execute_query: { label: () => ({ text: 'Querying' }) },
        audit_log: { label: () => ({ text: '' }), hidden: true },
    },
};
```

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `Function` |  | The words shown on the collapsed row, and optionally the value shown at its right-hand end. Called on every render, so it also owns the wording while the call is still in flight and the wording when it failed. Name what the call acted on rather than identifying it by id: the passed AgAiToolLabelParams.fieldName, AgAiToolLabelParams.widgetName and AgAiToolLabelParams.tableName resolve an id to the name the reader sees elsewhere. |
| `detail` | `any` |  | A component rendering the expanded body of this tool's calls, receiving AgAiToolDetailParams. A tool that declares none has no expanded body, and its row is not interactive. |
| `hidden` | `boolean` |  | When true, calls to this tool are not shown in the message list at all. |

Declarations are keyed by name, not by tool, because a conversation reloaded from history no longer has the tool that ran it - only the name it was called by. A replayed thread therefore renders the same as a live one, and these declarations apply to a harness you wrote yourself as well as to one built by `createAiHarness`.

Studio's own tools come pre-declared. An entry here replaces the declaration for that tool.

`hidden: true` leaves calls to that tool out of the message list entirely, which is useful for bookkeeping tools a user has no reason to see.

`detail` is typed for the framework you are writing in, so a component you pass is checked as one. That holds without a type annotation of your own, including in plain TypeScript, where `detail` is a component class.

## Write the Label

`label` is called on every render, so it owns the wording for every phase of the call, not only the finished one. It returns the step's words and, optionally, a short value shown at the right-hand end of the line.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `args` | `Partial<TArgs>` |  | The call's arguments so far - partial while they stream in. |
| `status` | `AgAiToolDisplayStatus` |  | Where the call has got to. `done` covers both outcomes - read `result.success` to tell them apart - `awaiting_approval` means it is waiting to be allowed to run, and `cancelled` means the run was stopped before the call settled. |
| `result` | `AgAiToolResult` |  | The call's outcome. Present once the call has settled. |
| `fieldName` | `Function` |  | The display name of a field, including any aggregation or date grain it carries. |
| `widgetName` | `Function` |  | A widget's title, or the name of its type when it has no title of its own. |
| `tableName` | `Function` |  | A table's display name. |

```ts
label: ({ args, status, result, fieldName }) => {
    if (status === 'cancelled') {
        return { text: 'Looking up reports', pill: 'Stopped' };
    }
    if (result == null) {
        return { text: 'Looking up reports' };
    }
    if (!result.success) {
        return { text: 'Could not look up the reports', pill: `${result.issues.length} issues` };
    }
    return { text: 'Looked up reports', pill: `${result.data.reports.length} found` };
},
```

Describe the work rather than its outcome. The marker beside the step already says whether it finished, failed or was stopped, so "Added Revenue by region" reads better than "Successfully added widget".

Name what the call acted on rather than showing an id. `fieldName`, `widgetName` and `tableName` resolve an id to the name the reader sees elsewhere in the app, and return `undefined` when the id names nothing, such as a field that has since been deleted.

### Streaming Arguments

`label` runs on every render, so it sees the arguments as they arrive. Partially-parsed JSON means a field may be missing, and a string may be half-written, so guard every access:

```ts
// Wrong: throws mid-stream, before `team` has arrived.
label: ({ args }) => ({ text: `Looking up ${args.team.toUpperCase()}` }),

// Right: nothing to show yet is a normal state.
label: ({ args }) => ({ text: args.team ? `Looking up ${args.team}` : 'Looking up reports' }),
```

## Write the Detail

A detail component follows the same contract as any other Studio custom component: return an element, optionally refresh, optionally clean up. It renders only the body that opens beneath the step. The marker, the words and the pill stay Studio's, which is what keeps a long run readable however many tools contributed to it.

```ts
type Params = AgAiToolDetailParams<{ team?: string }, { reports: Report[] }>;

class ReportListCard {
    private element!: HTMLElement;

    init(params: Params) {
        this.element = document.createElement('div');
        this.render(params);
    }

    getGui() {
        return this.element;
    }

    refresh(params: Params) {
        this.render(params);
    }

    private render(params: Params) {
        const { result } = params;
        if (result == null) {
            this.element.textContent = `Looking up reports for ${params.args.team ?? 'all teams'}...`;
            return;
        }
        if (!result.success) {
            this.element.textContent = result.issues.map((issue) => issue.message).join(', ');
            return;
        }
        this.element.textContent = `${result.data?.reports.length ?? 0} reports`;
    }
}
```

The body brings no background or padding of its own. Content that needs to sit on its own surface should bring its own border.

In React, Angular or Vue, write a component in that framework. React and Angular components are passed directly. A Vue component is passed either directly or by the name it is registered under on the component hosting Studio, matching how a custom widget's `comp` is supplied. Switch the example above between frameworks to see each one.

## What the Detail Receives

The params are discriminated on `status`:

- `streaming` - the model is still writing the arguments. `args` is partial: fields appear as they arrive, so guard every access.
- `ready` - the arguments are complete and the call is queued.
- `executing` - the tool is running.
- `done` - `result` is authoritative, and is either a success carrying `data` or a failure carrying `issues`.
- `cancelled` - the run was stopped before the call settled, so there may be no result at all.

`data` is whatever the tool's `result` or `execute` put there. Type both generics at your component's declaration to get it typed end to end.

In the panel, a step opens only once its call has settled, and a failed call opens onto its issues, listed by the panel rather than by your component. Your `detail` is therefore created with a successful result already in hand. The in-flight states above are what `label` sees on every render, and what a component sees if you drive one yourself.

## Next

- [Custom Tools](https://www.ag-grid.com/studio/react/ai-custom-tools/) - putting something in `data` worth rendering
- [Chat UI](https://www.ag-grid.com/studio/react/ai-chat-ui/) - what the panel does with a tool call
