Skip to main content
Urbicon UI

ToolCallCardexperimental

A collapsible card for one agent tool call: a status header with the tool name, and JSON input/output (or an error) in the body. It starts expanded when the call fails.

Playground

Input
{
  "city": "Berlin",
  "unit": "celsius"
}
Variant
State
<script lang="ts">
  import { ToolCallCard } from '@urbicon-ui/blocks';

  const toolCall = {
    type: 'tool-call',
    id: 'get_weather-1',
    name: 'get_weather',
    input: { city: 'Berlin', unit: 'celsius' },
    state: 'running'
  };
</script>

<ToolCallCard
  {toolCall}
/>

01 Examples

Lifecycle: running to complete

You hold the tool-call part and update its state and output as the real call resolves. While it runs, the header shows a spinner beside the tool name; when it completes, the status reads Done and the output is available. It stays collapsed throughout, so the plain header sits quietly in a chat stream.
Input
{
  "city": "Berlin",
  "unit": "celsius"
}
<script lang="ts">
  import { onDestroy } from 'svelte';
  import { Button, ToolCallCard, type ChatToolCallPart } from '@urbicon-ui/blocks';

  // The consumer owns the part and mutates its `state` / `output` as the
  // real call resolves — here a timer stands in for the transport layer.
  let call = $state<ChatToolCallPart>({
    type: 'tool-call',
    id: 'weather-1',
    name: 'get_weather',
    state: 'running',
    input: { city: 'Berlin', unit: 'celsius' }
  });

  let timer: ReturnType<typeof setTimeout> | undefined;

  function run() {
    clearTimeout(timer);
    call = { ...call, state: 'running', output: undefined };
    timer = setTimeout(() => {
      call = {
        ...call,
        state: 'complete',
        output: { temperature: 21, condition: 'Partly cloudy', humidity: 0.54 }
      };
    }, 1600);
  }

  onDestroy(() => clearTimeout(timer));
</script>

<div class="space-y-3">
  <ToolCallCard toolCall={call} />
  <Button size="sm" variant="outlined" onclick={run}>Replay call</Button>
</div>

Failure starts expanded

A call in the error state starts expanded, so the failure is visible without a click, and shows errorMessage above the input. A manual toggle afterwards overrides the auto-open.

permission denied for relation "invoices" (SQLSTATE 42501)

Input
{
  "sql": "SELECT * FROM invoices WHERE due < now()"
}
<script lang="ts">
  import { ToolCallCard, type ChatToolCallPart } from '@urbicon-ui/blocks';

  // A failed call: the card starts expanded so the error is visible without a
  // click. `errorMessage` renders above the (still available) input.
  const call: ChatToolCallPart = {
    type: 'tool-call',
    id: 'db-query-7',
    name: 'run_query',
    state: 'error',
    input: { sql: 'SELECT * FROM invoices WHERE due < now()' },
    errorMessage: 'permission denied for relation "invoices" (SQLSTATE 42501)'
  };
</script>

<ToolCallCard toolCall={call} />

Framed variant for run logs

variant="card" wraps the header in a frame: outline, radius, status badge, and full width. Use it for a run log or agent trace, where the calls are the content the reader came for. The default plain variant suits an inline chat stream.
Input
{
  "house": "cala",
  "nights": 3
}
Output
{
  "available": 4
}

rate plan expired (RATE_STALE)

Input
{
  "room": "sea-view-2"
}
Input
{
  "room": "sea-view-2",
  "refresh": true
}
<script lang="ts">
  import { ToolCallCard, type ChatToolCallPart } from '@urbicon-ui/blocks';

  // A run log: here the calls ARE the content, so each one gets a frame of its
  // own instead of the quiet line the chat stream uses.
  const trace: ChatToolCallPart[] = [
    {
      type: 'tool-call',
      id: 'trace-1',
      name: 'list_rooms',
      state: 'complete',
      input: { house: 'cala', nights: 3 },
      output: { available: 4 }
    },
    {
      type: 'tool-call',
      id: 'trace-2',
      name: 'price_stay',
      state: 'error',
      input: { room: 'sea-view-2' },
      errorMessage: 'rate plan expired (RATE_STALE)'
    },
    {
      type: 'tool-call',
      id: 'trace-3',
      name: 'price_stay',
      state: 'running',
      input: { room: 'sea-view-2', refresh: true }
    }
  ];
</script>

<div class="space-y-2">
  {#each trace as call (call.id)}
    <ToolCallCard toolCall={call} variant="card" />
  {/each}
</div>

Domain-specific body via the children snippet

Pass a children snippet to replace the default JSON input/output with a view built for the tool. The snippet receives the same part; the status header (status + monospaced tool name) and the collapse mechanics stay. Here a web_search result set renders as a ranked list instead of raw JSON.
  • OKLCH in CSS: why we moved

    example.com/oklch

    94%
  • A perceptual color picker

    example.com/picker

    87%
  • Gamut mapping explained

    example.com/gamut

    71%
<script lang="ts">
  import { Badge, ToolCallCard, type ChatToolCallPart } from '@urbicon-ui/blocks';

  type SearchHit = { title: string; url: string; score: number };

  const call: ChatToolCallPart = {
    type: 'tool-call',
    id: 'search-3',
    name: 'web_search',
    state: 'complete',
    input: { query: 'oklch color space' },
    output: [
      { title: 'OKLCH in CSS: why we moved', url: 'example.com/oklch', score: 0.94 },
      { title: 'A perceptual color picker', url: 'example.com/picker', score: 0.87 },
      { title: 'Gamut mapping explained', url: 'example.com/gamut', score: 0.71 }
    ] satisfies SearchHit[]
  };
</script>

<!-- The children snippet replaces the default JSON body with a domain view of
     the same part — the header (status + tool name) stays intact. -->
<ToolCallCard toolCall={call}>
  {#snippet children(part)}
    {@const hits = (part.output ?? []) as SearchHit[]}
    <ul class="divide-border-subtle divide-y">
      {#each hits as hit (hit.url)}
        <li class="flex items-center justify-between gap-3 py-2">
          <div class="min-w-0">
            <p class="text-text-primary truncate text-sm font-medium">{hit.title}</p>
            <p class="text-text-tertiary truncate text-xs">{hit.url}</p>
          </div>
          <Badge intent="neutral" variant="soft" size="sm">
            {(hit.score * 100).toFixed(0)}%
          </Badge>
        </li>
      {/each}
    </ul>
  {/snippet}
</ToolCallCard>

02 Accessibility

Status is text, not just color

The state label (Pending / Running / Done / Failed) is always in the header, and always exactly once: the plain header prints it as visible text, the framed one shows it as a decorative Badge (aria-hidden) paired with a single sr-only line. The spinner is decorative in both, so assistive tech reads the status once and never announces a spinner as content.

Disclosure semantics

The header is a real <button> with aria-expanded and aria-controls pointing at the body region, the same Collapsible contract as the rest of the library. Tab to reach it, Enter or Space to toggle. Focus rings use focus-visible:.

Untrusted output

Input and output render as plain text inside CodeBlock, not interpreted HTML, because tool results are untrusted data.

03 API Reference

18 props
18 props 1 required
Prop
Type
Default
Description

04 Types

Local type definitions used by this component.

6 types
Name
Kind
Category
Used by
Description

05 Installation

Import

import { ToolCallCard } from '@urbicon-ui/blocks';
import type { ChatToolCallPart } from '@urbicon-ui/blocks';