Tool Call Card
SaveShow 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
fetch_page
Read /docs/api/rate-limits
Input
{
"path": "/docs/api/rate-limits"
}Installation
npx @seamlessui/cli add tool-call-cardPre-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-runUsage
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.
search_docs
Search the docs for “rate limits”
search_docs
Search the docs for “rate limits”
search_docs
Search the docs for “rate limits”
Output
Found 3 pages: /docs/api/rate-limits, /docs/plans, /docs/errors#429
search_docs
Search the docs for “rate limits”
Search index unavailable (503).
API
| Prop | Type | Default | Description |
|---|---|---|---|
| name | string | — | Tool name, shown in monospace. |
| status | "queued" | "running" | "success" | "error" | — | Current state of the call. |
| summary | ReactNode | — | One line describing what the call does. |
| input | unknown | — | Arguments passed to the tool, rendered as formatted JSON. |
| output | ReactNode | — | Result shown on success. Strings render in a soft-wrapping well. |
| error | ReactNode | — | Message shown on failure in a red tinted box, always visible. |
| durationMs | number | — | Elapsed time, shown once the call finishes. |
| defaultOpen | boolean | false | Opens 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.tsxShowHide
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.
<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.vueShowHide
<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>