Tool call

A recipe, not a package namespace. A tool call in an assistant's reply is a Disclosure whose summary shows the tool's name and a Badge for its status, with the input and output in a DataList inside. A call that waits for approval adds two Buttons. What the tool does and when it runs are up to your product. There is no ToolCall.* export.

On this page

Open a call to read its input and output. The last call waits for approval: approve it to watch it run, or deny it.

I'll find where the focus ring color is set, then make a branch for the change.

search_docs Done
Input
{
  "query": "focus ring color token"
}
Output
[
  {
    "page": "/tokens",
    "heading": "Focus ring"
  },
  {
    "page": "/guides/build-on-todae",
    "heading": "Change the focus ring"
  }
]
fetch_page Failed
Input
{
  "url": "https://example.com/old-tokens"
}
Error
The page returned 404 Not Found.
create_branch Needs approval
Input
{
  "name": "focus-ring-brand",
  "from": "main"
}

The call

Keep each call as data that your agent's stream updates: an id, the tool's name, a status, the input, and the output or error once it has one. Map each status to a label and a tone once, so every call shows the same words.

import type { BadgeTone } from '@tounsoo/todae';

type ToolCallStatus = 'pending' | 'running' | 'done' | 'error' | 'denied';

type ToolCallData = {
  id: string;
  name: string;
  status: ToolCallStatus;
  input: Record<string, unknown>;
  output?: unknown;
  error?: string;
};

const STATUS: Record<ToolCallStatus, { label: string; tone: BadgeTone }> = {
  pending: { label: 'Needs approval', tone: 'warning' },
  running: { label: 'Running', tone: 'info' },
  done: { label: 'Done', tone: 'success' },
  error: { label: 'Failed', tone: 'critical' },
  denied: { label: 'Denied', tone: 'neutral' },
};

The summary holds the name in Code and the status as a Badge, so a screen reader hears both, then whether the call is open. The input and output are JSON in a CodeBlock, each a named region with a Tab stop so a keyboard can scroll a long line. Give the output a maximum height, since a tool can return a lot.

import { Badge, Code, CodeBlock, DataList, Disclosure } from '@tounsoo/todae';

export function ToolCall({ call }: { call: ToolCallData }) {
  const status = STATUS[call.status];
  return (
    <Disclosure.Root>
      <Disclosure.Summary>
        <Code.Root>{call.name}</Code.Root> <Badge.Root tone={status.tone}>{status.label}</Badge.Root>
      </Disclosure.Summary>
      <DataList.Root orientation="vertical">
        <DataList.Item>
          <DataList.Term>Input</DataList.Term>
          <DataList.Details>
            <CodeBlock.Root tabIndex={0} role="region" aria-label={`${call.name} input`}>
              {JSON.stringify(call.input, null, 2)}
            </CodeBlock.Root>
          </DataList.Details>
        </DataList.Item>
        {call.status === 'done' && call.output !== undefined && (
          <DataList.Item>
            <DataList.Term>Output</DataList.Term>
            <DataList.Details>
              <CodeBlock.Root tabIndex={0} role="region" aria-label={`${call.name} output`} style={{ maxBlockSize: '16rem' }}>
                {JSON.stringify(call.output, null, 2)}
              </CodeBlock.Root>
            </DataList.Details>
          </DataList.Item>
        )}
        {call.status === 'error' && (
          <DataList.Item>
            <DataList.Term>Error</DataList.Term>
            <DataList.Details>{call.error}</DataList.Details>
          </DataList.Item>
        )}
      </DataList.Root>
    </Disclosure.Root>
  );
}

Put each call, rendered by the ToolCall from Approval below, in the Message beside the text around it, in the order your stream gives them. A part of Message.Root is spaced from the next one, so a call needs no margin of its own. Set streaming from your agent's stream, not from the calls: on while the agent is producing the reply, including while a call runs, and off when the turn ends or pauses for a decision, as the end of Approval below says.

<Message.Root streaming={streaming} aria-label="Assistant">
  <Message.Content>
    <Prose.Root>{intro}</Prose.Root>
  </Message.Content>
  {calls.map((call) => (
    <ToolCall key={call.id} call={call} onDecide={(approved) => decide(call.id, approved)} />
  ))}
  <Message.Content>
    <Prose.Root>{answer}</Prose.Root>
  </Message.Content>
</Message.Root>

For a tool whose input is a few plain values, such as a search query, a horizontal DataList of the input's keys reads better than JSON.

Approval

A call that waits for approval opens, so its input can be read before it runs, and shows Approve and Deny after it. Set open from the status, so the call opens when it starts waiting and closes when it runs or is denied. React sets open only when that value changes, so a call someone opens by hand stays open as it finishes. Name the buttons' group by the tool's name, so "Approve" says what it approves. When two waiting calls can use the same tool, add something from the input that tells them apart, such as a file's path, to the group's name and to each region's name.

The buttons go away once the call is decided, which would drop focus to the page. useFocusHandoff on their group moves focus to the call's summary when they go while one has focus, whether the click decided the call or the decision came from elsewhere, such as another device. The DataList from the call above goes where the … is.

import { Badge, Button, Code, CodeBlock, DataList, Disclosure, Stack, useFocusHandoff } from '@tounsoo/todae';
import { useId, useRef } from 'react';

export function ToolCall({ call, onDecide }: { call: ToolCallData; onDecide: (approved: boolean) => void }) {
  const nameId = useId();
  const summary = useRef<HTMLElement>(null);
  const handoff = useFocusHandoff({ to: summary });
  const status = STATUS[call.status];

  return (
    <Stack.Root gap="condensed">
      <Disclosure.Root open={call.status === 'pending'}>
        <Disclosure.Summary ref={summary}>
          <Code.Root id={nameId}>{call.name}</Code.Root> <Badge.Root tone={status.tone}>{status.label}</Badge.Root>
        </Disclosure.Summary>
        …
      </Disclosure.Root>
      {call.status === 'pending' && (
        <Stack.Root ref={handoff} direction="row" gap="condensed" role="group" aria-labelledby={nameId}>
          <Button.Root onClick={() => onDecide(true)}>Approve</Button.Root>
          <Button.Root variant="secondary" onClick={() => onDecide(false)}>
            Deny
          </Button.Root>
        </Stack.Root>
      )}
    </Stack.Root>
  );
}

A call that waits pauses the agent's turn, so clear streaming while any call waits, even if another still runs, and set it again when the agent resumes, whether the call was approved or denied. The buttons then don't sit inside a busy message. Conversation says the message finished when the wait starts and that one started when the turn resumes. Those words don't mention the approval. For a clearer cue, set announceMessages={false} on Conversation.Root and announce the start, the wait and the finish yourself with useAnnouncer. An announcement of the wait made alongside Conversation's own "finished" would lose one of the two, since only the newer of two pending messages is read.

Accessible composition

Todae handles: useFocusHandoff moves focus to the summary when the approval buttons unmount while one has focus. Disclosure.Root is a native <details>, so the browser handles the toggle, Enter and Space, the expanded state a screen reader hears, and find in page in a closed call. The summary's focus ring. A Badge has no role, so its label is read with the summary's text.

You supply: A status label in words, not only a tone's color. A Tab stop, role and name for each CodeBlock, as on the Code page. A name for the approval buttons' group. The summary as the place focus goes when the approval buttons go away.

Watch for: Buttons, links or other controls inside the summary: the summary is itself the toggle, and a control inside it can't be reached the same way in every browser. Removing a call from the reply when it finishes: find in page and screen readers can no longer reach what the agent did. Announcing every status change: Conversation already says when the reply finishes, and a call that runs and finishes on its own is part of that.

When to use

When: Showing what an assistant ran on the reader's behalf in a chat: a search, a fetch, a file change, with its input and output. Asking the reader to approve a call before it runs.

When not: The assistant's reasoning, which streams as text → a Disclosure with Prose. Progress through known steps of a task → Steps. A result the reader must act on before going on → an Alert in the message.