Meet Frontfriend.Your brand. Your rules. Your agents, connected.Explore what’s new

Tool

Tool calls with running, waiting, error, and complete states, output details, and approval requests.

Interactive preview

The shared preview uses React Base. The source below uses Base UI. Each example runs with local data.

Installation

Complete project setup with react/base, then install these components:

pnpm exec frontfriend download trace code-block icon button message bubble item sheet -f react --primitive base

Map input streaming to running, client input waiting to pending, output errors to error, and available output to a complete line. A pending approval uses Item outside Trace. Keep the approval ID with the response and accept only one decision. These examples do not execute commands.

The output sheet uses JSON syntax highlighting through the Code Block helpers. Plain command output does not load the tokenizer.

Usage

Approval

import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
  Item,
  ItemMedia,
  ItemContent,
  ItemTitle,
  ItemDescription,
  ItemActions,
} from "@/components/ui/item";
import { Icon } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { Trace, TraceLine, TraceIcon, TraceTitle } from "@/components/ui/trace";
import * as React from "react";
const frame: React.CSSProperties = {
  width: "100%",
  maxWidth: 384,
  minWidth: 0,
  minHeight: 288,
  marginInline: "auto",
  display: "flex",
  flexDirection: "column",
  gap: 16,
  paddingBlock: 24,
  fontSize: "var(--text-sm)",
  lineHeight: 1.5,
};
export default function AiToolExample() {
  const [decision, setDecision] = React.useState<
    "pending" | "approved" | "denied"
  >("pending");
  const result = React.useRef<HTMLParagraphElement>(null);
  React.useEffect(() => {
    if (decision !== "pending") result.current?.focus();
  }, [decision]);
  return (
    <div style={frame}>
      <Message align="end">
        <MessageContent>
          <Bubble align="end" variant="muted">
            <BubbleContent>
              Run the build before I ship this change.
            </BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      {decision === "pending" ? (
        <Item variant="outline" size="sm">
          <ItemMedia variant="icon">
            <Icon name="Terminal" />
          </ItemMedia>
          <ItemContent>
            <ItemTitle>Run pnpm build?</ItemTitle>
            <ItemDescription>The command runs in this project.</ItemDescription>
          </ItemContent>
          <ItemActions>
            <Button
              size="sm"
              variant="outline"
              onClick={() => setDecision("denied")}
            >
              Deny
            </Button>
            <Button size="sm" onClick={() => setDecision("approved")}>
              Approve
            </Button>
          </ItemActions>
        </Item>
      ) : (
        <Message>
          <MessageContent style={{ gap: 16 }}>
            <Trace>
              <TraceLine
                status={decision === "approved" ? "running" : undefined}
              >
                <TraceIcon>
                  <Icon name="Terminal" />
                </TraceIcon>
                <TraceTitle>
                  {decision === "approved"
                    ? "Running pnpm build"
                    : "Denied pnpm build"}
                </TraceTitle>
              </TraceLine>
            </Trace>
            <p ref={result} tabIndex={-1}>
              {decision === "approved"
                ? "Build approved. The command is running."
                : "Build cancelled. No command was run."}
            </p>
          </MessageContent>
        </Message>
      )}
      <div>
        <Button
          size="sm"
          variant="ghost"
          onClick={() => setDecision("pending")}
        >
          Reset
        </Button>
      </div>
    </div>
  );
}

Output sheet

import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Trace, TraceLine, TraceIcon, TraceTitle } from "@/components/ui/trace";
import {
  Sheet,
  SheetTrigger,
  SheetContent,
  SheetHeader,
  SheetTitle,
  SheetDescription,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Icon } from "@/components/ui/icon";
import { useHighlightedCode } from "@/components/ui/code-block/use-highlighted-code";
import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockLine,
} from "@/components/ui/code-block";
import * as React from "react";
const frame: React.CSSProperties = {
  width: "100%",
  maxWidth: 384,
  minWidth: 0,
  minHeight: 288,
  marginInline: "auto",
  display: "flex",
  flexDirection: "column",
  gap: 16,
  paddingBlock: 24,
  fontSize: "var(--text-sm)",
  lineHeight: 1.5,
};
function JsonOutput({ value }: { value: string }) {
  const lines = useHighlightedCode(value, "json");
  return (
    <CodeBlock size="sm">
      <CodeBlockContent lineNumbers wrap>
        {lines.map((line, index) => (
          <CodeBlockLine key={index}>{line}</CodeBlockLine>
        ))}
      </CodeBlockContent>
    </CodeBlock>
  );
}
export default function AiToolExample() {
  const value =
    '{\n  "name": "checkout",\n  "private": true,\n  "scripts": {\n    "build": "vite build",\n    "test": "vitest run"\n  }\n}';
  return (
    <div style={frame}>
      <Message align="end">
        <MessageContent>
          <Bubble align="end" variant="muted">
            <BubbleContent>Which commands does this app provide?</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message>
        <MessageContent style={{ gap: 16 }}>
          <Trace>
            <Sheet>
              <SheetTrigger render={<Button variant="ghost" />}>
                <TraceLine>
                  <TraceIcon>
                    <Icon name="FileText" />
                  </TraceIcon>
                  <TraceTitle>Read package.json</TraceTitle>
                  <TraceIcon>
                    <Icon name="ChevronRight" />
                  </TraceIcon>
                </TraceLine>
              </SheetTrigger>
              <SheetContent>
                <SheetHeader>
                  <SheetTitle>package.json</SheetTitle>
                  <SheetDescription>
                    Build and test commands for this project.
                  </SheetDescription>
                </SheetHeader>
                <div style={{ minWidth: 0, paddingBlock: 24 }}>
                  <JsonOutput value={value} />
                </div>
              </SheetContent>
            </Sheet>
          </Trace>
          <p>The app provides build and test commands.</p>
        </MessageContent>
      </Message>
    </div>
  );
}

Complete example

import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceLine,
  TraceIcon,
  TraceTitle,
  TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Button } from "@/components/ui/button";
import * as React from "react";
type Phase = "complete" | "running" | "error" | "pending";
function useRun(initial: Phase) {
  const [phase, setPhase] = React.useState(initial);
  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(
    undefined,
  );
  React.useEffect(() => {
    clearTimeout(timer.current);
    setPhase(initial);
  }, [initial]);
  React.useEffect(() => () => clearTimeout(timer.current), []);
  function replay() {
    clearTimeout(timer.current);
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setPhase("complete");
      return;
    }
    setPhase("running");
    timer.current = setTimeout(() => setPhase("complete"), 3600);
  }
  return { phase, replay };
}
const frame: React.CSSProperties = {
  width: "100%",
  maxWidth: 384,
  minWidth: 0,
  minHeight: 288,
  marginInline: "auto",
  display: "flex",
  flexDirection: "column",
  gap: 16,
  paddingBlock: 24,
  fontSize: "var(--text-sm)",
  lineHeight: 1.5,
};
const commands = [
  { command: "node --version", output: "v24.18.0" },
  { command: "npm --version", output: "11.6.2" },
  {
    command: "pnpm --version",
    output: "command not found: pnpm",
    failed: true,
  },
];
export default function AiToolExample() {
  const { phase, replay } = useRun("complete");
  return (
    <div style={frame}>
      <Message align="end">
        <MessageContent>
          <Bubble align="end" variant="muted">
            <BubbleContent>Which package managers are installed?</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message>
        <MessageContent style={{ gap: 16, minWidth: 0 }}>
          <Trace>
            {commands.map((command, index) =>
              phase === "complete" || (index === 0 && phase !== "pending") ? (
                <TraceCollapsible
                  key={command.command}
                  defaultOpen={command.failed}
                >
                  <TraceCollapsibleTrigger>
                    <TraceLine status={command.failed ? "error" : undefined}>
                      <TraceIcon>
                        <Icon name="Terminal" />
                      </TraceIcon>
                      <TraceTitle>
                        {`${command.failed ? "Failed to run" : "Ran"} ${command.command}`}
                      </TraceTitle>
                    </TraceLine>
                  </TraceCollapsibleTrigger>
                  <TraceCollapsibleContent keepMounted>
                    <CodeBlock size="sm">
                      <CodeBlockContent wrap style={{ maxHeight: 256 }}>
                        {command.output}
                      </CodeBlockContent>
                    </CodeBlock>
                  </TraceCollapsibleContent>
                </TraceCollapsible>
              ) : (
                <TraceLine
                  key={command.command}
                  status={
                    index === 2 && phase === "running" ? "pending" : phase
                  }
                >
                  <TraceIcon>
                    <Icon name="Terminal" />
                  </TraceIcon>
                  <TraceTitle>
                    {`${
                      phase === "pending" ||
                      (index === 2 && phase === "running")
                        ? "Waiting to run"
                        : phase === "error"
                          ? "Failed to run"
                          : "Running"
                    } ${command.command}`}
                  </TraceTitle>
                </TraceLine>
              ),
            )}
          </Trace>
          {phase === "complete" && (
            <p>Node 24 and npm 11 are installed. pnpm is not.</p>
          )}
        </MessageContent>
      </Message>
      <div>
        <Button
          size="sm"
          variant="outline"
          onClick={replay}
          disabled={phase === "running"}
        >
          Replay
        </Button>
      </div>
    </div>
  );
}

AI SDK

The adapter reads tool-runCommand parts and maps every input, approval, output, error, and denial state. Pass clientCallIds for client tools that remain input-available after streaming stops; they stay pending until addToolOutput supplies a result. Approval requests are Item cards outside Trace. Pass addToolApprovalResponse from your chat client. The adapter sends the approval ID once and permits retry if the callback rejects. Configure lastAssistantMessageIsCompleteWithApprovalResponses on your chat client to resume after all approvals are answered.

The basic example runs without a provider. For the AI SDK adapter example only, install the SDK types:

pnpm add ai

Pass a message from your chat client to the adapter. The supplied message does not call a provider.

import type { UIMessage } from "ai";
import {
  Item,
  ItemMedia,
  ItemContent,
  ItemTitle,
  ItemDescription,
  ItemActions,
} from "@/components/ui/item";
import { Icon } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceLine,
  TraceIcon,
  TraceTitle,
  TraceCollapsibleContent,
} from "@/components/ui/trace";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import * as React from "react";
type SdkCommandMessage = UIMessage<
  unknown,
  {},
  {
    runCommand: {
      input: {
        command: string;
      };
      output: {
        stdout: string;
        exitCode: number;
      };
    };
  }
>;
const sdkInitialMessage: SdkCommandMessage = {
  id: "tools",
  role: "assistant",
  parts: [
    {
      type: "tool-runCommand",
      toolCallId: "version",
      state: "output-available",
      input: { command: "node --version" },
      output: { stdout: "v24.18.0", exitCode: 0 },
    },
    {
      type: "tool-runCommand",
      toolCallId: "missing",
      state: "output-error",
      input: { command: "pnpm --version" },
      errorText: "command not found: pnpm",
    },
    {
      type: "tool-runCommand",
      toolCallId: "streaming-input",
      state: "input-streaming",
      input: { command: "git status" },
    },
    {
      type: "tool-runCommand",
      toolCallId: "client",
      state: "input-available",
      input: { command: "read clipboard" },
    },
    {
      type: "tool-runCommand",
      toolCallId: "denied",
      state: "output-denied",
      input: { command: "rm -rf .cache" },
      approval: { id: "deny-cache", approved: false },
    },
    {
      type: "tool-runCommand",
      toolCallId: "approve",
      state: "approval-requested",
      input: { command: "pnpm build" },
      approval: { id: "build-approval" },
    },
  ],
};
const frame: React.CSSProperties = {
  width: "100%",
  maxWidth: 384,
  minWidth: 0,
  minHeight: 288,
  marginInline: "auto",
  display: "flex",
  flexDirection: "column",
  gap: 16,
  paddingBlock: 24,
  fontSize: "var(--text-sm)",
  lineHeight: 1.5,
};
type SdkCommandPart = Extract<
  SdkCommandMessage["parts"][number],
  {
    type: "tool-runCommand";
  }
>;
function sdkCommandState(
  part: SdkCommandPart,
  streaming: boolean,
  client = false,
) {
  const command = part.input?.command ?? "command";
  const denied =
    part.state === "output-denied" ||
    (part.state === "approval-responded" && !part.approval.approved);
  const finished = part.state === "output-available" && !part.preliminary;
  const waiting =
    part.state === "approval-requested" ||
    (part.state === "input-available" && client);
  const approved =
    part.state === "approval-responded" && part.approval.approved;
  const failed =
    part.state === "output-error" ||
    (finished && part.output.exitCode !== 0) ||
    (!denied && !finished && !waiting && !approved && !streaming);
  const status = denied
    ? undefined
    : waiting
      ? "pending"
      : failed
        ? "error"
        : finished
          ? undefined
          : "running";
  const title = `${denied ? "Denied" : waiting ? "Waiting to run" : failed ? "Failed to run" : finished ? "Ran" : "Running"} ${command}`;
  const output =
    part.state === "output-error"
      ? part.errorText
      : part.state === "output-available"
        ? part.output.stdout
        : undefined;
  return { status, title, output } as const;
}
function SdkPanel({
  title,
  status,
  children,
  initialOpen = false,
}: {
  title: string;
  status?: "running" | "pending" | "error";
  children: React.ReactNode;
  initialOpen?: boolean;
}) {
  const [open, setOpen] = React.useState<boolean>();
  const expanded = open ?? initialOpen;
  return (
    <TraceCollapsible open={expanded} onOpenChange={setOpen}>
      <TraceCollapsibleTrigger>
        <TraceLine status={status}>
          <TraceIcon>
            <Icon name={status === "error" ? "CircleAlert" : "Terminal"} />
          </TraceIcon>
          <TraceTitle>{title}</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent keepMounted>{children}</TraceCollapsibleContent>
    </TraceCollapsible>
  );
}
function SdkCommandRow({
  part,
  streaming,
  client = false,
}: {
  part: SdkCommandPart;
  streaming: boolean;
  client?: boolean;
}) {
  const view = sdkCommandState(part, streaming, client);
  const output = view.output;
  if (output !== undefined)
    return (
      <SdkPanel
        title={view.title}
        status={view.status}
        initialOpen={view.status === "error" || view.status === "running"}
      >
        <CodeBlock
          size="sm"
          variant={view.status === "error" ? "destructive" : "default"}
        >
          <CodeBlockContent wrap style={{ maxHeight: 256 }}>
            {output}
          </CodeBlockContent>
        </CodeBlock>
      </SdkPanel>
    );
  return (
    <TraceLine status={view.status}>
      <TraceIcon>
        <Icon name="Terminal" />
      </TraceIcon>
      <TraceTitle>{view.title}</TraceTitle>
    </TraceLine>
  );
}
function ToolSdkAdapter({
  message,
  streaming,
  clientCallIds = [],
  addToolApprovalResponse,
}: {
  message: SdkCommandMessage;
  streaming: boolean;
  clientCallIds?: string[];
  addToolApprovalResponse: (response: {
    id: string;
    approved: boolean;
  }) => void | PromiseLike<void>;
}) {
  const [submitted, setSubmitted] = React.useState<Record<string, boolean>>({});
  const [errors, setErrors] = React.useState<Record<string, string>>({});
  const pending = React.useRef(new Set<string>());
  const results = React.useRef(new Map<string, HTMLDivElement>());
  const focusApproval = React.useRef<string | undefined>(undefined);
  React.useEffect(() => {
    if (!focusApproval.current) return;
    const responded = message.parts.find(
      (part) =>
        part.type === "tool-runCommand" &&
        part.state !== "approval-requested" &&
        "approval" in part &&
        part.approval?.id === focusApproval.current,
    );
    if (responded) {
      if (responded.type === "tool-runCommand")
        results.current.get(responded.toolCallId)?.focus();
      focusApproval.current = undefined;
    }
  }, [message]);
  async function approve(id: string, approved: boolean) {
    if (pending.current.has(id)) return;
    pending.current.add(id);
    focusApproval.current = id;
    setSubmitted((current) => ({ ...current, [id]: true }));
    setErrors((current) => ({ ...current, [id]: "" }));
    try {
      await addToolApprovalResponse({ id, approved });
    } catch (error) {
      pending.current.delete(id);
      focusApproval.current = undefined;
      setSubmitted((current) => ({ ...current, [id]: false }));
      setErrors((current) => ({
        ...current,
        [id]: error instanceof Error ? error.message : String(error),
      }));
    }
  }
  const parts = message.parts.filter(
    (part): part is SdkCommandPart => part.type === "tool-runCommand",
  );
  return (
    <div style={{ display: "grid", gap: 16 }}>
      {parts
        .filter((part) => part.state === "approval-requested")
        .map((part) => (
          <Item key={part.toolCallId} variant="outline" size="sm">
            <ItemMedia variant="icon">
              <Icon name="Terminal" />
            </ItemMedia>
            <ItemContent>
              <ItemTitle>Run {part.input.command}?</ItemTitle>
              {errors[part.approval.id] && (
                <ItemDescription role="alert">
                  {errors[part.approval.id]}
                </ItemDescription>
              )}
            </ItemContent>
            <ItemActions>
              <Button
                size="sm"
                variant="outline"
                disabled={submitted[part.approval.id]}
                onClick={() => void approve(part.approval.id, false)}
              >
                Deny
              </Button>
              <Button
                size="sm"
                disabled={submitted[part.approval.id]}
                onClick={() => void approve(part.approval.id, true)}
              >
                Approve
              </Button>
            </ItemActions>
          </Item>
        ))}
      <Trace>
        {parts
          .filter((part) => part.state !== "approval-requested")
          .map((part) => (
            <div
              key={part.toolCallId}
              tabIndex={-1}
              role="group"
              aria-label={
                sdkCommandState(
                  part,
                  streaming,
                  clientCallIds.includes(part.toolCallId),
                ).title
              }
              ref={(element) => {
                if (element) results.current.set(part.toolCallId, element);
                else results.current.delete(part.toolCallId);
              }}
            >
              <SdkCommandRow
                part={part}
                streaming={streaming}
                client={clientCallIds.includes(part.toolCallId)}
              />
            </div>
          ))}
      </Trace>
    </div>
  );
}
export default function AiToolExample() {
  const [message, setMessage] = React.useState(sdkInitialMessage);
  return (
    <div style={frame}>
      <ToolSdkAdapter
        message={message}
        streaming
        clientCallIds={["client"]}
        addToolApprovalResponse={({ id, approved }) =>
          setMessage((current) => ({
            ...current,
            parts: current.parts.map((part) =>
              part.type === "tool-runCommand" &&
              part.state === "approval-requested" &&
              part.approval.id === id
                ? {
                    ...part,
                    state: "approval-responded",
                    approval: { id, approved },
                  }
                : part,
            ),
          }))
        }
      />
    </div>
  );
}

States

import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceLine,
  TraceIcon,
  TraceTitle,
  TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import * as React from "react";
const frame: React.CSSProperties = {
  width: "100%",
  maxWidth: 384,
  minWidth: 0,
  minHeight: 288,
  marginInline: "auto",
  display: "flex",
  flexDirection: "column",
  gap: 16,
  paddingBlock: 24,
  fontSize: "var(--text-sm)",
  lineHeight: 1.5,
};
function ToolStatesExample() {
  return (
    <Trace>
      <TraceCollapsible>
        <TraceCollapsibleTrigger>
          <TraceLine>
            <TraceIcon>
              <Icon name="Terminal" />
            </TraceIcon>
            <TraceTitle>Ran git status --short</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent keepMounted>
          <CodeBlock size="sm">
            <CodeBlockContent
              wrap
              style={{
                maxHeight: 256,
              }}
            >
              {` M app/api/chat/route.ts
 M components/chat.tsx`}
            </CodeBlockContent>
          </CodeBlock>
        </TraceCollapsibleContent>
      </TraceCollapsible>
      <TraceCollapsible>
        <TraceCollapsibleTrigger>
          <TraceLine status="error">
            <TraceIcon>
              <Icon name="Terminal" />
            </TraceIcon>
            <TraceTitle>Failed to run pnpm dev</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent keepMounted>
          <CodeBlock size="sm">
            <CodeBlockContent
              wrap
              style={{
                maxHeight: 256,
              }}
            >
              Command timed out after 120 seconds.
            </CodeBlockContent>
          </CodeBlock>
        </TraceCollapsibleContent>
      </TraceCollapsible>
      <TraceLine>
        <TraceIcon>
          <Icon name="Terminal" />
        </TraceIcon>
        <TraceTitle>Denied rm -rf dist</TraceTitle>
      </TraceLine>
      <TraceLine status="running">
        <TraceIcon>
          <Icon name="Terminal" />
        </TraceIcon>
        <TraceTitle>Running pnpm test</TraceTitle>
      </TraceLine>
    </Trace>
  );
}
export default function AiToolExample() {
  return (
    <div style={frame}>
      <ToolStatesExample />
    </div>
  );
}

Components and API

This composition has no separate component API. Use the APIs for its installed components.

ComponentTarget reference
trace Base UI API
code-block Base UI API
icon Base UI API
button Base UI API
message Base UI API
bubble Base UI API
item Base UI API
sheet Base UI API