LLM index: /llms.txt

Tool

Status-aware UI for rendering tool calls (input and output JSON) in agent/chat interfaces. Tool is provider-agnostic and works well with Vercel AI SDK tool parts by mapping runtime tool states to the component status model.

Input
{
  "origin": "LHR",
  "destination": "SFO",
  "date": "2026-06-14",
  "cabin": "economy"
}
Output
{
  "route": "LHR -> SFO",
  "bestOption": {
    "airline": "BA",
    "priceUsd": 742,
    "duration": "10h 45m",
    "stops": 0
  }
}
import {
  Tool,
  ToolContent,
  ToolInput,
  ToolOutput,
  ToolTrigger,
} from "@/components/nexus-ui/tool";

function ToolDefault() {
  const input = {
    origin: "LHR",
    destination: "SFO",
    date: "2026-06-14",
    cabin: "economy",
  };

  const output = {
    route: "LHR -> SFO",
    bestOption: {
      airline: "BA",
      priceUsd: 742,
      duration: "10h 45m",
      stops: 0,
    },
  };

  return (
    <Tool status="completed" defaultOpen>
      <ToolTrigger name="search_flights" />
      <ToolContent>
        <ToolInput payload={input} />
        <ToolOutput payload={output} />
      </ToolContent>
    </Tool>
  );
}

export default ToolDefault;

Installation

npx shadcn@latest add @nexus-ui/tool
pnpm dlx shadcn@latest add @nexus-ui/tool
yarn dlx shadcn@latest add @nexus-ui/tool
bunx shadcn@latest add @nexus-ui/tool

Install the following dependencies:

npx shadcn@latest add badge collapsible && npm install @hugeicons/react @hugeicons/core-free-icons @react-symbols/icons shiki
pnpm dlx shadcn@latest add badge collapsible && pnpm add @hugeicons/react @hugeicons/core-free-icons @react-symbols/icons shiki
yarn dlx shadcn@latest add badge collapsible && yarn add @hugeicons/react @hugeicons/core-free-icons @react-symbols/icons shiki
bunx shadcn@latest add badge collapsible && bun add @hugeicons/react @hugeicons/core-free-icons @react-symbols/icons shiki

Copy the following files into your project.

components/nexus-ui/tool.tsx
"use client";

import {
  createContext,
  useContext,
  type ComponentProps,
  type CSSProperties,
} from "react";
import {
  ArrowDown01Icon,
  CancelCircleIcon,
  CheckmarkCircle01Icon,
  Clock01Icon,
  Loading03Icon,
  ToolsIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";

import { Badge } from "@/components/ui/badge";
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";

import {
  CodeBlock,
  CodeBlockContent,
  CodeblockShiki,
} from "@/components/nexus-ui/codeblock-new";

type ToolStatus = "pending" | "ready" | "running" | "completed" | "error";

type ToolMeta = {
  label: string;
  icon: IconSvgElement;
  color: { bg: string; fg: string };
  iconClassName?: string;
};

const TOOL_META: Record<ToolStatus, ToolMeta> = {
  pending: {
    label: "Pending",
    icon: ToolsIcon,
    color: { bg: "var(--color-gray-100)", fg: "var(--color-gray-500)" },
  },
  ready: {
    label: "Ready",
    icon: Clock01Icon,
    color: { bg: "var(--color-orange-100)", fg: "var(--color-orange-600)" },
  },
  running: {
    label: "Running",
    icon: Loading03Icon,
    color: { bg: "var(--color-blue-100)", fg: "var(--color-blue-600)" },
    iconClassName: "animate-spin",
  },
  completed: {
    label: "Completed",
    icon: CheckmarkCircle01Icon,
    color: { bg: "var(--color-green-100)", fg: "var(--color-green-600)" },
  },
  error: {
    label: "Error",
    icon: CancelCircleIcon,
    color: { bg: "var(--color-red-100)", fg: "var(--color-red-600)" },
  },
};

type ToolContextValue = {
  status: ToolStatus;
  meta: ToolMeta;
};

const ToolContext = createContext<ToolContextValue | null>(null);

function isToolStatus(value: unknown): value is ToolStatus {
  return (
    typeof value === "string" &&
    Object.prototype.hasOwnProperty.call(TOOL_META, value)
  );
}

function useToolContext(component: string): ToolContextValue {
  const context = useContext(ToolContext);
  if (!context) {
    throw new Error(`${component} must be used within <Tool>`);
  }
  return context;
}

function stringifyToolPayload(payload: unknown): string {
  if (typeof payload === "string") return payload;
  if (payload === undefined) return "";

  try {
    return JSON.stringify(payload, null, 2);
  } catch {
    return String(payload);
  }
}

type ToolProps = ComponentProps<typeof Collapsible> & {
  status: ToolStatus;
};

function Tool({ status, className, style, ...props }: ToolProps) {
  const resolvedStatus = isToolStatus(status) ? status : "pending";
  const meta = TOOL_META[resolvedStatus];

  return (
    <ToolContext.Provider value={{ status: resolvedStatus, meta }}>
      <Collapsible
        data-slot="tool"
        className={cn(
          "not-prose w-full max-w-100 border dark:border-accent bg-card",
          "data-[state=closed]:rounded-xl data-[state=open]:rounded-xl",
          className,
        )}
        style={
          {
            "--tool-color": meta.color.fg,
            "--tool-bg": meta.color.bg,
            ...style,
          } as CSSProperties
        }
        {...props}
      />
    </ToolContext.Provider>
  );
}

type ToolTriggerProps = Omit<
  ComponentProps<typeof CollapsibleTrigger>,
  "children"
> & {
  name: string;
};

function ToolTrigger({ name, className, ...props }: ToolTriggerProps) {
  const { meta } = useToolContext("ToolTrigger");

  return (
    <CollapsibleTrigger
      data-slot="tool-trigger"
      className={cn(
        "group flex h-10 w-full cursor-pointer items-center justify-between px-3 py-2",
        className,
      )}
      {...props}
    >
      <div className="flex items-center gap-2">
        <HugeiconsIcon
          data-slot="tool-trigger-icon"
          icon={meta.icon}
          strokeWidth={2}
          className={cn("size-4 text-(--tool-color)", meta.iconClassName)}
        />
        <span
          data-slot="tool-trigger-name"
          className="text-sm leading-6 font-[450] text-primary"
        >
          {name}
        </span>
        <Badge
          data-slot="tool-trigger-badge"
          className="h-6 bg-(--tool-bg)/60 font-[450] text-(--tool-color) dark:bg-(--tool-color)/10 dark:text-(--tool-color)"
        >
          {meta.label}
        </Badge>
      </div>

      <HugeiconsIcon
        data-slot="tool-trigger-chevron"
        icon={ArrowDown01Icon}
        strokeWidth={1.75}
        className="size-4 transition-transform duration-200 group-data-[state=open]:rotate-180"
      />
    </CollapsibleTrigger>
  );
}

type ToolContentProps = ComponentProps<typeof CollapsibleContent>;

function ToolContent({ className, ...props }: ToolContentProps) {
  return (
    <CollapsibleContent
      data-slot="tool-content"
      className={cn(
        "flex flex-col gap-6 p-3 pt-4",
        "overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down",
        className,
      )}
      {...props}
    />
  );
}

type ToolPartProps = {
  kind: "input" | "output";
  payload: unknown;
  errorText?: string;
};

function ToolPart({ kind, payload, errorText }: ToolPartProps) {
  const { status } = useToolContext("ToolPart");
  const code = stringifyToolPayload(payload);
  const isOutputError = kind === "output" && status === "error";
  const hasPayload = payload !== undefined && payload !== null;
  const shouldShowCodeblock = !isOutputError || hasPayload;
  const title = kind === "input" ? "Input" : isOutputError ? "Error" : "Output";

  return (
    <div data-slot={`tool-${kind}`} className="flex flex-col gap-3">
      <span
        data-slot={`tool-${kind}-title`}
        className={cn(
          "text-xs leading-4 font-[450] text-muted-foreground uppercase",
          isOutputError && "text-destructive",
        )}
      >
        {title}
      </span>
      {isOutputError ? (
        <div
          data-slot="tool-output-error"
          className="rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm leading-6 text-destructive dark:bg-destructive/10"
        >
          {errorText ?? "Tool execution failed"}
        </div>
      ) : null}
      {shouldShowCodeblock ? (
        <CodeBlock
          data-slot="tool-output-error-codeblock"
          className="rounded-lg"
          keepBackground
        >
          <CodeBlockContent>
            <CodeblockShiki language="json">{code}</CodeblockShiki>
          </CodeBlockContent>
        </CodeBlock>
      ) : null}
    </div>
  );
}

type ToolPayloadProps = {
  payload: unknown;
};

function ToolInput({ payload }: ToolPayloadProps) {
  return <ToolPart kind="input" payload={payload} />;
}

type ToolOutputProps = ToolPayloadProps & {
  showWhen?: ToolStatus[];
  errorText?: string;
};

function ToolOutput({
  payload,
  showWhen = ["completed"],
  errorText,
}: ToolOutputProps) {
  const { status } = useToolContext("ToolOutput");
  if (!showWhen.includes(status)) return null;

  return <ToolPart kind="output" payload={payload} errorText={errorText} />;
}

export type { ToolStatus };
export { Tool, ToolTrigger, ToolContent, ToolInput, ToolOutput };
components/nexus-ui/codeblock-new.tsx
"use client";

import { FileIcon } from "@react-symbols/icons/utils";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
  createContext,
  useEffect,
  useContext,
  useState,
  type CSSProperties,
  type ComponentProps,
  type ReactNode,
} from "react";

import { cn } from "@/lib/utils";
import { highlight, Themes } from "@/lib/shiki/highlighter";
import type { BundledLanguage } from "shiki/bundle/web";

const highlighterPromise = highlight();
type DivProps = ComponentProps<"div">;
type CodeBlockProps = DivProps & {
  keepBackground?: boolean;
};
type CodeBlockCopyContextValue = {
  content: string;
  setContent: (value: string) => void;
};
const CodeBlockCopyContext = createContext<CodeBlockCopyContextValue | null>(
  null,
);

interface CodeblockClientShikiProps extends DivProps {
  code?: string;
  language?: string;
  lineNumbers?: boolean;
  children?: ReactNode;
}

type ShikiToken = {
  content: string;
  htmlStyle?: Record<string, string>;
};

const buildRawTokenRows = (input: string): ShikiToken[][] =>
  input.split(/\r?\n/).map((line) => [{ content: line || "\u00A0" }]);

const EMPTY_TOKEN_ROW: ShikiToken[] = [{ content: "\u00A0" }];

const resolveCodeToHighlight = (children: ReactNode, code?: string): string =>
  typeof children === "string"
    ? children
    : Array.isArray(children) &&
        children.length === 1 &&
        typeof children[0] === "string"
      ? children[0]
      : (code ?? "");

const CodeBlock = ({
  children,
  className,
  keepBackground = false,
  ...props
}: CodeBlockProps) => {
  const [copyContent, setCopyContent] = useState("");

  return (
    <CodeBlockCopyContext.Provider
      value={{ content: copyContent, setContent: setCopyContent }}
    >
      <div
        className={cn(
          "not-prose",
          "my-0 flex w-full flex-col overflow-hidden rounded-xl",
          keepBackground
            ? "border-none bg-secondary dark:bg-background"
            : "border bg-card dark:border-accent",
          "text-[13px] font-[450]",
          className,
        )}
        {...props}
      >
        {children}
      </div>
    </CodeBlockCopyContext.Provider>
  );
};

type CodeBlockHeaderProps = DivProps;

const CodeBlockHeader = ({
  children,
  className,
  ...props
}: CodeBlockHeaderProps) => {
  return (
    <div
      className={cn(
        "not-prose", // Disable Markdown Styles
        "flex h-9.5 items-center justify-between gap-2 px-4",
        "text-fd-muted-foreground",
        className,
      )}
      {...props}
    >
      {children}
    </div>
  );
};

interface CodeBlockIconProps extends DivProps {
  language?: string;
}

const CodeBlockIcon = ({ language, className }: CodeBlockIconProps) => {
  return (
    <FileIcon
      width={16}
      height={16}
      fileName={`.${language ?? ""}`}
      autoAssign={true}
      className={cn(className)}
    />
  );
};

type CodeBlockGroupProps = DivProps;

const CodeBlockGroup = ({
  children,
  className,
  ...props
}: CodeBlockGroupProps) => {
  return (
    <div
      className={cn(
        "flex items-center gap-2",
        "text-fd-muted-foreground",
        className,
      )}
      {...props}
    >
      {children}
    </div>
  );
};

const CodeBlockContent = ({ className, children, ...props }: DivProps) => {
  return (
    <div
      className={cn(
        "no-scrollbar max-h-96 overflow-auto overscroll-x-none",
        "rounded-xl px-4 text-sm leading-6",
        "font-mono whitespace-pre",
        className,
      )}
      {...props}
    >
      {children}
    </div>
  );
};

const CodeblockShiki = ({
  code,
  language = "tsx",
  lineNumbers = false,
  className,
  children,
  ...props
}: CodeblockClientShikiProps) => {
  const setCopyContent = useContext(CodeBlockCopyContext)?.setContent;
  const codeToHighlight = resolveCodeToHighlight(children, code);
  const [tokenRows, setTokenRows] = useState<ShikiToken[][]>(() =>
    buildRawTokenRows(codeToHighlight),
  );

  useEffect(() => {
    setCopyContent?.(codeToHighlight);
  }, [codeToHighlight, setCopyContent]);

  useEffect(() => {
    let cancelled = false;

    async function clientHighlight() {
      const rawRows = buildRawTokenRows(codeToHighlight);
      if (!cancelled) {
        setTokenRows(rawRows);
      }

      if (!codeToHighlight) {
        return;
      }

      try {
        const highlighter = await highlighterPromise;
        const result = await highlighter.codeToTokens(codeToHighlight, {
          lang: language as BundledLanguage,
          themes: {
            light: Themes.light,
            dark: Themes.dark,
          },
        });
        if (!cancelled) {
          setTokenRows((result.tokens ?? rawRows) as ShikiToken[][]);
        }
      } catch {
        if (!cancelled) {
          setTokenRows(rawRows);
        }
      }
    }

    void clientHighlight();

    return () => {
      cancelled = true;
    };
  }, [codeToHighlight, language]);

  return (
    <div
      className={cn(
        "no-scrollbar w-full overflow-auto overscroll-x-none py-0",
        className,
      )}
      {...props}
    >
      <pre
        className={cn(
          "shiki",
          lineNumbers ? "shiki-line-numbers" : "nd-no-line-numbers",
        )}
      >
        <code>
          {tokenRows.map((row, rowIndex) => (
            <span key={`row-${rowIndex}`} className="line">
              {(row.length ? row : EMPTY_TOKEN_ROW).map((token, tokenIndex) => (
                <span
                  key={`token-${rowIndex}-${tokenIndex}`}
                  style={token.htmlStyle as CSSProperties | undefined}
                >
                  {token.content || "\u00A0"}
                </span>
              ))}
              {rowIndex < tokenRows.length - 1 && "\n"}
            </span>
          ))}
        </code>
      </pre>
    </div>
  );
};

type CodeBlockCopyButtonProps = ComponentProps<"button">;

const CodeBlockCopyButton = ({
  className,
  ...props
}: CodeBlockCopyButtonProps) => {
  const content = useContext(CodeBlockCopyContext)?.content ?? "";
  const [isCopied, setIsCopied] = useState<boolean>(false);

  const copyToClipboard = async (text: string) => {
    try {
      await navigator.clipboard.writeText(text);
      return true;
    } catch (err) {
      console.error("Failed to copy text: ", err);
      return false;
    }
  };

  useEffect(() => {
    if (!isCopied) return;

    const timeout = setTimeout(() => {
      setIsCopied(false);
    }, 2000);
    return () => clearTimeout(timeout);
  }, [isCopied]);

  const handleCopy = async () => {
    if (!content) return;
    await copyToClipboard(content);
    setIsCopied(true);
  };

  return (
    <button
      title="Copy to clipboard"
      className={cn(
        "relative flex size-7 cursor-pointer items-center justify-center rounded-full text-ring hover:text-primary",
        "",
        className,
      )}
      onClick={handleCopy}
      {...props}
    >
      {isCopied ? (
        <HugeiconsIcon
          icon={Tick02Icon}
          strokeWidth={2}
          className="size-3.5 animate-in text-green-900 duration-200 zoom-in-50 dark:text-green-400"
        />
      ) : (
        <HugeiconsIcon
          icon={Copy01Icon}
          strokeWidth={2}
          className="size-3.5 animate-in duration-200 zoom-in-50"
        />
      )}
    </button>
  );
};

export {
  CodeBlock,
  CodeBlockHeader,
  CodeBlockIcon,
  CodeBlockGroup,
  CodeBlockContent,
  CodeblockShiki,
  CodeBlockCopyButton,
};
lib/shiki/highlighter.ts
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
import {
  bundledLanguages,
  type Highlighter,
  type RegexEngine,
  createHighlighter,
} from "shiki/bundle/web";

let jsEngine: RegexEngine | null = null;
let highlighter: Promise<Highlighter> | null = null;

// Settings for UI components
const Themes = {
  light: "github-light",
  dark: "github-dark",
};

const allBundledLanguageIds = Object.keys(bundledLanguages);


const getJsEngine = (): RegexEngine => {
  jsEngine ??= createJavaScriptRegexEngine();
  return jsEngine;
};

const highlight = async (): Promise<Highlighter> => {
  highlighter ??= createHighlighter({
    langs: allBundledLanguageIds,
    themes: ["github-light", "github-dark"],
    engine: getJsEngine(),
  });
  return highlighter;
};
export { highlight, Themes };
app/shiki.css
/* Shiki Light/Dark Mode */
html.light .shiki,
html.light .shiki span {
  font-family: var(--font-mono);
  background-color: transparent !important;
}

html.dark .shiki,
html.dark .shiki span {
  font-family: var(--font-mono);
  color: var(--shiki-dark) !important;
  background-color: transparent !important;
}

/* Base Shiki Pre & Span Styles */
pre.shiki {
  @apply py-3;
}

pre.shiki span.line {
  @apply px-4 py-0.5;
}

/* Shiki Word Wrap */
pre.shiki-word-wrap {
  white-space: pre-wrap;
  word-break: break-word;
}

pre.shiki-word-wrap span.line {
  display: inline-block;
  width: 100%;
  box-sizing: border-box;
  padding-top: 0.2px;
  padding-bottom: 0.2px;
}

/* Shiki Line Numbers */
pre.shiki-line-numbers code {
  counter-reset: step;
  counter-increment: step 0;
  .line {
    &::before {
      counter-increment: step;
      @apply mr-6 inline-block border-transparent text-right text-sm whitespace-nowrap text-muted-foreground content-[counter(step)];
    }
  }
}

.nd-no-line-numbers code .line::before {
  content: none;
  display: none;
}

/* Shiki Highlight */
pre span.shiki-line-highlight {
  @apply relative z-0 inline-block w-full;
  &::after {
    content: "";
    @apply absolute top-0 left-0 -z-10 h-full w-full border-l-2 border-neutral-400 bg-neutral-500/20! opacity-40;
  }
}

/* Shiki Notation Diff */
pre.has-diff span.line.diff {
  @apply relative inline-block w-full;
}

pre.has-diff span.line.diff.add {
  @apply bg-emerald-300/20! dark:bg-emerald-700/20!;
  &::before {
    content: "+";
    @apply absolute left-2 text-green-600 dark:text-green-400;
  }
}

pre.has-diff span.line.diff.remove {
  @apply bg-red-300/20! opacity-70 dark:bg-red-600/20!;
  &::before {
    content: "-";
    @apply absolute left-2 text-red-600 dark:text-red-400;
  }
}

/* Shiki Notation Focus */
pre.shiki-has-focused .line:not(.focused) {
  @apply opacity-50 blur-[0.8px] transition-opacity duration-200 ease-in-out;
}

pre.shiki-has-focused:hover .line:not(.focused) {
  @apply opacity-100 blur-none;
}

/* Shiki Line Anchors */
pre.shiki-line-anchors .line:target {
  @apply scroll-mt-14 bg-blue-400/15! dark:bg-blue-600/15!;
}

pre.shiki-line-numbers.shiki-line-anchors code .line::before {
  @apply cursor-pointer transition-colors select-none;
}

pre.shiki-line-numbers.shiki-line-anchors code .line::before:hover {
  @apply text-blue-500 underline dark:text-blue-400;
}

/* Shiki Highlighted Word */
pre span.shiki-word-highlight {
  @apply relative z-0 inline-block rounded-sm px-0.5;
  &::after {
    content: "";
    @apply absolute inset-0 -z-10 rounded-sm bg-neutral-500/25!;
  }
}

Ensure your Shiki CSS is loaded.

CodeblockShiki expects .shiki styles. Import app/shiki.css once at your app root (for example in app/layout.tsx):

import "./global.css";
import "./shiki.css";

Update import paths to match your project setup.

Usage

import {
  Tool,
  ToolTrigger,
  ToolContent,
  ToolInput,
  ToolOutput,
  type ToolStatus,
} from "@/components/nexus-ui/tool";
<Tool status="completed" defaultOpen>
  <ToolTrigger name="get_weather" />
  <ToolContent>
    <ToolInput payload={{ city: "Paris", unit: "celsius" }} />
    <ToolOutput
      payload={{ city: "Paris", temperature: 22, condition: "sunny" }}
    />
  </ToolContent>
</Tool>

Examples

Pending

Set status="pending" and render only ToolTrigger + ToolContent while arguments are still streaming.

Model is still streaming tool arguments...
import { Tool, ToolContent, ToolTrigger } from "@/components/nexus-ui/tool";
import { TextShimmer } from "@/components/nexus-ui/text-shimmer";

function ToolPending() {
  return (
    <Tool status="pending" defaultOpen>
      <ToolTrigger name="extract_receipt_fields" />
      <ToolContent>
        <div className="text-sm text-muted-foreground">
          <TextShimmer invertLight>
            Model is still streaming tool arguments...
          </TextShimmer>
        </div>
      </ToolContent>
    </Tool>
  );
}

export default ToolPending;

Ready

Set status="ready", render ToolInput with parsed arguments, and omit ToolOutput until execution starts.

Input
{
  "channel": "#support",
  "text": "Escalate ticket #4821 to on-call engineer",
  "mention": "@ops-oncall"
}
import {
  Tool,
  ToolContent,
  ToolInput,
  ToolTrigger,
} from "@/components/nexus-ui/tool";

function ToolReady() {
  const input = {
    channel: "#support",
    text: "Escalate ticket #4821 to on-call engineer",
    mention: "@ops-oncall",
  };

  return (
    <Tool status="ready" defaultOpen>
      <ToolTrigger name="send_slack_message" />
      <ToolContent>
        <ToolInput payload={input} />
      </ToolContent>
    </Tool>
  );
}

export default ToolReady;

Running

Set status="running" and keep only ToolInput visible while the tool call is in progress.

Input
{
  "userId": "usr_9f23",
  "includeInvoices": true,
  "includeUsage": true,
  "lookbackDays": 90
}
Fetching billing summary...
import {
  Tool,
  ToolContent,
  ToolInput,
  ToolTrigger,
} from "@/components/nexus-ui/tool";
import { TextShimmer } from "@/components/nexus-ui/text-shimmer";

function ToolRunning() {
  const input = {
    userId: "usr_9f23",
    includeInvoices: true,
    includeUsage: true,
    lookbackDays: 90,
  };

  return (
    <Tool status="running" defaultOpen>
      <ToolTrigger name="fetch_billing_summary" />
      <ToolContent>
        <ToolInput payload={input} />
        <div className="text-sm text-muted-foreground">
          <TextShimmer invertLight>Fetching billing summary...</TextShimmer>
        </div>
      </ToolContent>
    </Tool>
  );
}

export default ToolRunning;

Error State

Set status="error", keep ToolInput visible, and render ToolOutput with errorText (and showWhen={["error"]}) to show a destructive error message.

Input
{
  "to": "ceo@acme.com",
  "subject": "Weekly KPI Summary",
  "templateId": "kpi-weekly-v2"
}
Error
SMTP authentication failed for configured sender
import {
  Tool,
  ToolContent,
  ToolInput,
  ToolOutput,
  ToolTrigger,
} from "@/components/nexus-ui/tool";

function ToolErrorState() {
  const input = {
    to: "ceo@acme.com",
    subject: "Weekly KPI Summary",
    templateId: "kpi-weekly-v2",
  };

  const error = {
    code: "smtp_auth_failed",
    message: "SMTP authentication failed for configured sender",
  };

  return (
    <Tool status="error" defaultOpen>
      <ToolTrigger name="send_email" />
      <ToolContent>
        <ToolInput payload={input} />
        <ToolOutput payload={null} errorText={error.message} showWhen={["error"]} />
      </ToolContent>
    </Tool>
  );
}

export default ToolErrorState;

Vercel AI SDK Integration

Tool maps cleanly to AI SDK tool parts by converting streamed tool part state into ToolStatus and passing input / output payloads directly.

Use it with Vercel AI SDK by:

  • mapping AI SDK tool part states (input-streaming, input-available, output-available, output-error) into your UI states (pending, ready, running, completed, error)
  • rendering part.input in ToolInput
  • rendering part.output in ToolOutput when available
  • passing errorText to ToolOutput for error state

ready is an app-level state in this mapping (for example, waiting on user approval before execution). AI SDK does not emit ready as a native tool part state.

pending via input-streaming is only available in streaming flows (for example, streamText / streamed UI messages).

Install the AI SDK

npm install ai @ai-sdk/react

Stream messages from your chat API route

app/api/chat/route.ts
import { streamText, convertToModelMessages, tool, type UIMessage } from "ai";
import { z } from "zod";

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: "anthropic/claude-sonnet-4.5",
    messages: await convertToModelMessages(messages),
    tools: {
      displayWeather: tool({
        description: "Get weather for a city",
        inputSchema: z.object({
          city: z.string(),
          unit: z.enum(["celsius", "fahrenheit"]),
        }),
        execute: async ({ city, unit }) => ({
          city,
          unit,
          temperature: 22,
          condition: "sunny",
        }),
      }),
    },
  });

  return result.toUIMessageStreamResponse();
}

Map assistant tool parts into Tool

"use client";

import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type UIMessage } from "ai";
import {
  Tool,
  ToolTrigger,
  ToolContent,
  ToolInput,
  ToolOutput,
  type ToolStatus,
} from "@/components/nexus-ui/tool";

type ToolPartLike = {
  type: string;
  state?: string;
  input?: unknown;
  output?: unknown;
  errorText?: string;
  error?: unknown;
};

function mapToolStatus(
  part: ToolPartLike,
  isAwaitingApproval: boolean,
): ToolStatus | null {
  switch (part.state) {
    case "input-streaming":
      // Streaming-only: arguments are still being generated.
      return "pending";
    case "input-available":
      // App-level mapping: use "ready" while waiting for approval/user action.
      return isAwaitingApproval ? "ready" : "running";
    case "output-available":
      return "completed";
    case "output-error":
      return "error";
    default:
      return null;
  }
}

function toolNameFromPartType(type: string): string {
  return type.startsWith("tool-") ? type.slice(5) : type;
}

export default function ToolCallsFromUseChat() {
  const { messages } = useChat({
    transport: new DefaultChatTransport({ api: "/api/chat" }),
  });

  const assistant = [...messages].reverse().find((m) => m.role === "assistant");
  if (!assistant) return null;

  const toolParts = assistant.parts.filter(
    (part): part is ToolPartLike => part.type.startsWith("tool-"),
  );

  if (toolParts.length === 0) return null;

  return (
    <div className="space-y-3">
      {toolParts.map((part, index) => {
        const status = mapToolStatus(part, false);
        if (!status) return null;

        return (
          <Tool key={`${part.type}-${index}`} status={status} defaultOpen>
            <ToolTrigger name={toolNameFromPartType(part.type)} />
            <ToolContent>
              <ToolInput payload={part.input} />
              <ToolOutput
                payload={part.output}
                errorText={part.errorText}
                showWhen={["completed", "error"]}
              />
            </ToolContent>
          </Tool>
        );
      })}
    </div>
  );
}

API Reference

Tool

Root wrapper for a single tool call. Provides status context and status color tokens for child components. Wraps Collapsible Root.

Prop

Type

ToolTrigger

Header row for tool name, status icon, status badge, and expand/collapse chevron. Wraps Collapsible Trigger.

Prop

Type

ToolContent

Body wrapper for input/output/error sections. Wraps Collapsible Content.

Prop

Type

ToolInput

Renders the tool input payload in a JSON codeblock.

Prop

Type

ToolOutput

Renders tool output payload in a JSON codeblock when the current tool state matches showWhen.

Prop

Type