Skip to content
Seamless UI

Tool Call Card

Save

Show the status and result of an agent tool call.

Category
Agent
Version
0.2.0
Frameworks
Next.js, Vite + React, Vite + Vue
Styling
Tailwind CSS v4

Preview

Tool Call Card preview
Tool call status

fetch_page

Running

Read /docs/api/rate-limits

Input
{
  "path": "/docs/api/rate-limits"
}

Installation

npx @seamlessui/cli add tool-call-card

Pre-release@seamlessui/cli is not on npm yet. Install from a local build.

Review the plan first. A dry run lists every file and package without changing anything:

npx @seamlessui/cli add tool-call-card --dry-run

Usage

tool-call-card-example.tsx
import { ToolCallCard } from "@/components/seamless/agent/tool-call-card";

export default function ToolCallCardExample() {
  return (
    <div className="grid w-full max-w-md gap-3">
      <ToolCallCard
        name="search_docs"
        status="success"
        summary="Search the docs for “rate limits”"
        input={{ query: "rate limits", limit: 5 }}
        output="Found 3 pages."
        durationMs={820}
      />
      <ToolCallCard
        name="fetch_page"
        status="error"
        summary="Read /docs/api/rate-limits"
        error="The docs server returned 504 Gateway Timeout."
        durationMs={1340}
      />
    </div>
  );
}

States

Every state, rendered from local fixture data.

Tool Call Card states

search_docs

Queued

Search the docs for “rate limits”

search_docs

Running

Search the docs for “rate limits”

search_docs

820 msSucceeded

Search the docs for “rate limits”

Output
Found 3 pages: /docs/api/rate-limits, /docs/plans, /docs/errors#429

search_docs

820 msFailed

Search the docs for “rate limits”

Search index unavailable (503).

API

PropTypeDefaultDescription
namestring—Tool name, shown in monospace.
status"queued" | "running" | "success" | "error"—Current state of the call.
summaryReactNode—One line describing what the call does.
inputunknown—Arguments passed to the tool, rendered as formatted JSON.
outputReactNode—Result shown on success. Strings render in a soft-wrapping well.
errorReactNode—Message shown on failure in a red tinted box, always visible.
durationMsnumber—Elapsed time, shown once the call finishes.
defaultOpenbooleanfalseOpens the input and output disclosure initially.

Accessibility

  • The status label is a live region, so screen readers announce Running, Succeeded, or Failed as the status changes.
  • Every state has a text label and its own stroke glyph (dashed circle, spinning arc, check, x); color is never the only signal.
  • aria-busy is set while running.
  • Input and output use native <details>/<summary>, which work with the keyboard out of the box. Height animates only where ::details-content is supported.
  • With reduced motion the spinner holds still and the glyph swap, check draw, and disclosure are instant; the label still changes.

Files

Installed into src/components/seamless, or components/seamless when the project has no src directory.

  • src/components/seamless/agent/tool-call-card.tsxtool-call-card
  • Also installs badge@0.2.0registry dependency
  • npm packages: noneReact and Tailwind CSS v4 required

Source

The exact source the CLI installs, from /r/items/react/tool-call-card/0.2.0.json.

agent/tool-call-card.tsxShow
agent/tool-call-card.tsx
import type { ComponentProps, ReactNode } from "react";
import { Badge, type BadgeTone } from "../ui/badge";

export type ToolCallStatus = "queued" | "running" | "success" | "error";

const statuses: Record<ToolCallStatus, { label: string; tone: BadgeTone; border: string }> = {
  queued: { label: "Queued", tone: "neutral", border: "border-zinc-950/10 dark:border-white/10" },
  running: { label: "Running", tone: "info", border: "border-sky-500/35" },
  success: { label: "Succeeded", tone: "success", border: "border-zinc-950/10 dark:border-white/10" },
  error: { label: "Failed", tone: "danger", border: "border-red-500/35" },
};

const ease = "ease-[cubic-bezier(0.23,1,0.32,1)]";

export interface ToolCallCardProps extends Omit<ComponentProps<"article">, "children"> {
  /** Tool name, for example `search_docs`. */
  name: string;
  status: ToolCallStatus;
  /** One line describing what the call does. */
  summary?: ReactNode;
  /** Arguments passed to the tool. Rendered as formatted JSON. */
  input?: unknown;
  /** Result shown when the call succeeds. Strings render as preformatted text. */
  output?: ReactNode;
  /** Message shown when the call fails. */
  error?: ReactNode;
  /** Elapsed time, shown once the call finishes. */
  durationMs?: number;
  /** Opens the input and output details initially. */
  defaultOpen?: boolean;
}

/** One agent tool call: its status, what it does, how long it took, and its input and output. */
export function ToolCallCard({
  name,
  status,
  summary,
  input,
  output,
  error,
  durationMs,
  defaultOpen = false,
  className,
  ...props
}: ToolCallCardProps) {
  const meta = statuses[status];
  const finished = status === "success" || status === "error";
  const showOutput = status === "success" && output != null;
  const hasInput = input !== undefined;
  const detailsLabel = hasInput && showOutput ? "Input and output" : hasInput ? "Input" : "Output";

  return (
    <article
      data-status={status}
      aria-busy={status === "running" || undefined}
      className={[
        "overflow-hidden rounded-xl border bg-white text-left text-zinc-950 shadow-[0_1px_2px_rgb(0_0_0/0.04)]",
        "transition-[border-color] duration-200 motion-reduce:transition-none",
        "dark:bg-black dark:text-white dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06)]",
        meta.border,
        className,
      ]
        .filter(Boolean)
        .join(" ")}
      {...props}
    >
      <div className="flex items-start gap-3 p-4">
        <StatusIcon key={status} status={status} />
        <div className="min-w-0 flex-1">
          <div className="flex items-center justify-between gap-3">
            <h3
              className={[
                "min-w-0 truncate font-mono text-[13px] font-medium transition-colors duration-200 motion-reduce:transition-none",
                status === "queued" ? "text-zinc-600 dark:text-zinc-400" : "",
              ].join(" ")}
            >
              {name}
            </h3>
            <div className="flex shrink-0 items-center gap-2.5">
              {finished && durationMs != null ? (
                <span className="font-mono text-xs text-zinc-500 tabular-nums">{formatDuration(durationMs)}</span>
              ) : null}
              <span role="status">
                <Badge tone={meta.tone}>{meta.label}</Badge>
              </span>
            </div>
          </div>
          {summary ? <p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{summary}</p> : null}
          {status === "error" && error != null ? (
            <p
              key="error"
              className={`mt-3 rounded-lg border border-red-500/35 bg-red-500/[0.08] px-3 py-2 text-sm text-red-700 transition-[opacity,translate] duration-200 ${ease} starting:-translate-y-1 starting:opacity-0 motion-reduce:transition-none dark:text-red-300`}
            >
              {error}
            </p>
          ) : null}
        </div>
      </div>

      {hasInput || showOutput ? (
        <details
          open={defaultOpen}
          className={[
            "group border-t border-zinc-950/[0.07] [interpolate-size:allow-keywords] dark:border-white/[0.08]",
            // Height animates where ::details-content and interpolate-size are supported; elsewhere it simply opens.
            "[&::details-content]:h-0 [&::details-content]:overflow-clip [&::details-content]:transition-[height,content-visibility] [&::details-content]:duration-[220ms] [&::details-content]:ease-[cubic-bezier(0.23,1,0.32,1)] [&::details-content]:[transition-behavior:allow-discrete]",
            "open:[&::details-content]:h-auto motion-reduce:[&::details-content]:transition-none",
          ].join(" ")}
        >
          <summary
            className={[
              "flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-2.5 text-xs font-medium select-none [&::-webkit-details-marker]:hidden",
              "text-zinc-600 transition-colors duration-150 hover:bg-zinc-950/[0.03] hover:text-zinc-950 motion-reduce:transition-none",
              "dark:text-zinc-400 dark:hover:bg-white/[0.04] dark:hover:text-white",
              "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-sky-500",
            ].join(" ")}
          >
            {detailsLabel}
            <svg
              aria-hidden="true"
              viewBox="0 0 16 16"
              className={`size-4 text-zinc-500 transition-transform duration-200 ${ease} group-open:rotate-180 motion-reduce:transition-none`}
            >
              <path d="m4.5 6.25 3.5 3.5 3.5-3.5" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
            </svg>
          </summary>
          <div
            className={`grid grid-cols-[minmax(0,1fr)] gap-3 px-4 pt-1 pb-4 opacity-0 transition-opacity duration-200 ${ease} group-open:opacity-100 motion-reduce:transition-none`}
          >
            {/* The summary already names a lone section, so labels only appear when there are two. */}
            {hasInput ? <Section label={showOutput ? "Input" : undefined}>{JSON.stringify(input, null, 2)}</Section> : null}
            {showOutput ? (
              <Section label={hasInput ? "Output" : undefined} wrap>
                {output}
              </Section>
            ) : null}
          </div>
        </details>
      ) : null}
    </article>
  );
}

/** A labeled well. `wrap` soft-wraps long lines (tool output); JSON keeps its layout and scrolls. */
function Section({ label, wrap = false, children }: { label?: string; wrap?: boolean; children: ReactNode }) {
  return (
    <div>
      {label ? <p className="mb-1.5 text-xs font-medium text-zinc-500">{label}</p> : null}
      {typeof children === "string" ? (
        <pre className={`${wrap ? "break-words whitespace-pre-wrap" : "overflow-x-auto"} rounded-lg border border-zinc-950/[0.07] bg-zinc-950/[0.02] px-3 py-2.5 font-mono text-xs leading-relaxed text-zinc-800 dark:border-white/[0.08] dark:bg-transparent dark:text-zinc-300`}>
          {children}
        </pre>
      ) : (
        <div className="text-sm">{children}</div>
      )}
    </div>
  );
}

/**
 * The shared status glyphs at 20px. Remounts on each status change (via `key`), so the
 * new shape scales in and the success check draws; both are instant with reduced motion.
 */
function StatusIcon({ status }: { status: ToolCallStatus }) {
  const base = `size-5 shrink-0 transition-[opacity,scale] duration-200 ${ease} starting:scale-90 starting:opacity-0 motion-reduce:transition-none`;
  // A 16 unit box drawn at 20px: a 1.2 stroke renders at 1.5px.
  const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.2, strokeLinecap: "round", strokeLinejoin: "round" } as const;

  if (status === "running") {
    return (
      <svg aria-hidden="true" viewBox="0 0 16 16" {...stroke} className={`${base} text-sky-600 dark:text-sky-400`}>
        <circle cx="8" cy="8" r="6.25" strokeOpacity="0.2" />
        <path d="M8 1.75A6.25 6.25 0 1 1 1.75 8" className="origin-center motion-safe:animate-spin" />
      </svg>
    );
  }
  if (status === "success") {
    return (
      <svg aria-hidden="true" viewBox="0 0 16 16" {...stroke} className={`${base} text-emerald-600 dark:text-emerald-400`}>
        <circle cx="8" cy="8" r="6.25" />
        <path
          d="m5.35 8.25 1.75 1.75 3.55-3.75"
          pathLength={1}
          strokeDasharray="1"
          className={`[stroke-dashoffset:0] transition-[stroke-dashoffset] delay-75 duration-300 ${ease} starting:[stroke-dashoffset:1] motion-reduce:transition-none`}
        />
      </svg>
    );
  }
  if (status === "error") {
    return (
      <svg aria-hidden="true" viewBox="0 0 16 16" {...stroke} className={`${base} text-red-600 dark:text-red-400`}>
        <circle cx="8" cy="8" r="6.25" />
        <path d="m6.1 6.1 3.8 3.8m0-3.8-3.8 3.8" />
      </svg>
    );
  }
  return (
    <svg aria-hidden="true" viewBox="0 0 16 16" {...stroke} className={`${base} text-zinc-500`}>
      <circle cx="8" cy="8" r="6.25" strokeDasharray="2.15 2.75" />
    </svg>
  );
}

function formatDuration(ms: number): string {
  return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)} s`;
}

Vue

A native Vue 3 implementation ships in the Vue pilot. Run the same command in a Vite + Vue project; the CLI detects Vue and installs these files instead. The live preview above is the React version; the Vue example below is built in a clean Vite + Vue app by CI.

tool-call-card-example.vue
<script setup lang="ts">
import ToolCallCard from "@/components/seamless/agent/ToolCallCard.vue";
</script>

<template>
  <div class="grid w-full max-w-md gap-3">
    <ToolCallCard
      name="search_docs"
      status="success"
      summary="Search the docs for “rate limits”"
      :input="{ query: 'rate limits', limit: 5 }"
      output="Found 3 pages."
      :duration-ms="820"
    />
    <ToolCallCard name="fetch_page" status="error" summary="Read /docs/api/rate-limits" error="The docs server returned 504 Gateway Timeout." :duration-ms="1340" />
  </div>
</template>
  • src/components/seamless/agent/ToolCallCard.vuevue/tool-call-card@0.2.0
agent/ToolCallCard.vueShow
agent/ToolCallCard.vue
<script lang="ts">
export type ToolCallStatus = "queued" | "running" | "success" | "error";
</script>

<script setup lang="ts">
import { computed } from "vue";
import Badge, { type BadgeTone } from "../ui/Badge.vue";

const props = withDefaults(
  defineProps<{
    /** Tool name, for example `search_docs`. */
    name: string;
    status: ToolCallStatus;
    summary?: string;
    /** Arguments passed to the tool. Rendered as formatted JSON. */
    input?: unknown;
    /** Result shown when the call succeeds. */
    output?: string;
    /** Message shown when the call fails. */
    error?: string;
    durationMs?: number;
    defaultOpen?: boolean;
  }>(),
  { defaultOpen: false },
);

const statuses: Record<ToolCallStatus, { label: string; tone: BadgeTone; border: string }> = {
  queued: { label: "Queued", tone: "neutral", border: "border-zinc-950/10 dark:border-white/10" },
  running: { label: "Running", tone: "info", border: "border-sky-500/35" },
  success: { label: "Succeeded", tone: "success", border: "border-zinc-950/10 dark:border-white/10" },
  error: { label: "Failed", tone: "danger", border: "border-red-500/35" },
};

const ease = "ease-[cubic-bezier(0.23,1,0.32,1)]";

const meta = computed(() => statuses[props.status]);
const finished = computed(() => props.status === "success" || props.status === "error");
const showOutput = computed(() => props.status === "success" && props.output != null);
const hasInput = computed(() => props.input !== undefined);
const detailsLabel = computed(() => (hasInput.value && showOutput.value ? "Input and output" : hasInput.value ? "Input" : "Output"));
const duration = computed(() =>
  props.durationMs == null ? "" : props.durationMs < 1000 ? `${Math.round(props.durationMs)} ms` : `${(props.durationMs / 1000).toFixed(1)} s`,
);

// The shared status glyphs at 20px; the new shape scales in on each status change and the success check draws.
const glyph = `size-5 shrink-0 transition-[opacity,scale] duration-200 ${ease} starting:scale-90 starting:opacity-0 motion-reduce:transition-none`;
const well =
  "rounded-lg border border-zinc-950/[0.07] bg-zinc-950/[0.02] px-3 py-2.5 font-mono text-xs leading-relaxed text-zinc-800 dark:border-white/[0.08] dark:bg-transparent dark:text-zinc-300";
</script>

<template>
  <article
    :data-status="props.status"
    :aria-busy="props.status === 'running' || undefined"
    :class="[
      'overflow-hidden rounded-xl border bg-white text-left text-zinc-950 shadow-[0_1px_2px_rgb(0_0_0/0.04)]',
      'transition-[border-color] duration-200 motion-reduce:transition-none',
      'dark:bg-black dark:text-white dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06)]',
      meta.border,
    ]"
  >
    <div class="flex items-start gap-3 p-4">
      <svg
        :key="props.status"
        aria-hidden="true"
        viewBox="0 0 16 16"
        fill="none"
        stroke="currentColor"
        stroke-width="1.2"
        stroke-linecap="round"
        stroke-linejoin="round"
        :class="[
          glyph,
          props.status === 'running'
            ? 'text-sky-600 dark:text-sky-400'
            : props.status === 'success'
              ? 'text-emerald-600 dark:text-emerald-400'
              : props.status === 'error'
                ? 'text-red-600 dark:text-red-400'
                : 'text-zinc-500',
        ]"
      >
        <template v-if="props.status === 'running'">
          <circle cx="8" cy="8" r="6.25" stroke-opacity="0.2" />
          <path d="M8 1.75A6.25 6.25 0 1 1 1.75 8" class="origin-center motion-safe:animate-spin" />
        </template>
        <template v-else-if="props.status === 'success'">
          <circle cx="8" cy="8" r="6.25" />
          <path
            d="m5.35 8.25 1.75 1.75 3.55-3.75"
            path-length="1"
            stroke-dasharray="1"
            :class="`[stroke-dashoffset:0] transition-[stroke-dashoffset] delay-75 duration-300 ${ease} starting:[stroke-dashoffset:1] motion-reduce:transition-none`"
          />
        </template>
        <template v-else-if="props.status === 'error'">
          <circle cx="8" cy="8" r="6.25" />
          <path d="m6.1 6.1 3.8 3.8m0-3.8-3.8 3.8" />
        </template>
        <circle v-else cx="8" cy="8" r="6.25" stroke-dasharray="2.15 2.75" />
      </svg>
      <div class="min-w-0 flex-1">
        <div class="flex items-center justify-between gap-3">
          <h3
            :class="[
              'min-w-0 truncate font-mono text-[13px] font-medium transition-colors duration-200 motion-reduce:transition-none',
              props.status === 'queued' ? 'text-zinc-600 dark:text-zinc-400' : '',
            ]"
          >
            {{ props.name }}
          </h3>
          <div class="flex shrink-0 items-center gap-2.5">
            <span v-if="finished && duration" class="font-mono text-xs text-zinc-500 tabular-nums">{{ duration }}</span>
            <span role="status">
              <Badge :tone="meta.tone">{{ meta.label }}</Badge>
            </span>
          </div>
        </div>
        <p v-if="props.summary" class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{{ props.summary }}</p>
        <p
          v-if="props.status === 'error' && props.error"
          :class="`mt-3 rounded-lg border border-red-500/35 bg-red-500/[0.08] px-3 py-2 text-sm text-red-700 transition-[opacity,translate] duration-200 ${ease} starting:-translate-y-1 starting:opacity-0 motion-reduce:transition-none dark:text-red-300`"
        >
          {{ props.error }}
        </p>
      </div>
    </div>
    <details
      v-if="hasInput || showOutput"
      :open="props.defaultOpen"
      :class="[
        'group border-t border-zinc-950/[0.07] [interpolate-size:allow-keywords] dark:border-white/[0.08]',
        // Height animates where ::details-content and interpolate-size are supported; elsewhere it simply opens.
        '[&::details-content]:h-0 [&::details-content]:overflow-clip [&::details-content]:transition-[height,content-visibility] [&::details-content]:duration-[220ms] [&::details-content]:ease-[cubic-bezier(0.23,1,0.32,1)] [&::details-content]:[transition-behavior:allow-discrete]',
        'open:[&::details-content]:h-auto motion-reduce:[&::details-content]:transition-none',
      ]"
    >
      <summary
        class="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-2.5 text-xs font-medium text-zinc-600 transition-colors duration-150 select-none hover:bg-zinc-950/[0.03] hover:text-zinc-950 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-sky-500 motion-reduce:transition-none dark:text-zinc-400 dark:hover:bg-white/[0.04] dark:hover:text-white [&::-webkit-details-marker]:hidden"
      >
        {{ detailsLabel }}
        <svg
          aria-hidden="true"
          viewBox="0 0 16 16"
          :class="`size-4 text-zinc-500 transition-transform duration-200 ${ease} group-open:rotate-180 motion-reduce:transition-none`"
        >
          <path d="m4.5 6.25 3.5 3.5 3.5-3.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
        </svg>
      </summary>
      <div
        :class="`grid grid-cols-[minmax(0,1fr)] gap-3 px-4 pt-1 pb-4 opacity-0 transition-opacity duration-200 ${ease} group-open:opacity-100 motion-reduce:transition-none`"
      >
        <!-- The summary already names a lone section, so labels only appear when there are two. -->
        <div v-if="hasInput">
          <p v-if="showOutput" class="mb-1.5 text-xs font-medium text-zinc-500">Input</p>
          <pre :class="['overflow-x-auto', well]">{{ JSON.stringify(props.input, null, 2) }}</pre>
        </div>
        <div v-if="showOutput">
          <p v-if="hasInput" class="mb-1.5 text-xs font-medium text-zinc-500">Output</p>
          <pre :class="['break-words whitespace-pre-wrap', well]">{{ props.output }}</pre>
        </div>
      </div>
    </details>
  </article>
</template>