8.0k New

Tool

Displays a tool call the agent makes, from input to output.

Epicenter

Local-first, open source apps

Special Sponsor
Which package managers are installed?
command not found: pnpm

Node 24 and npm 11 are installed. pnpm is not.

<script lang="ts">
  import TerminalIcon from "@lucide/svelte/icons/terminal";
  import { Chat } from "@ai-sdk/svelte";
  import { untrack } from "svelte";
  import * as Bubble from "$lib/components/ui/bubble/index.js";
  import * as CodeBlock from "$lib/components/ui/code-block/index.js";
  import * as Message from "$lib/components/ui/message/index.js";
  import * as Trace from "$lib/components/ui/trace/index.js";
  import { createChat, getMessageText } from "$lib/ai-sdk.js";
  import { Button } from "$lib/components/ui/button/index.js";
  import type { UIDataTypes, UIMessage } from "ai";
 
  type ChatMessage = UIMessage<
    unknown,
    UIDataTypes,
    {
      runCommand: {
        input: { command: string };
        output: { stdout: string };
      };
    }
  >;
 
  type ToolPart = Extract<
    ChatMessage["parts"][number],
    { type: "tool-runCommand" }
  >;
 
  const script = createChat<ChatMessage>()
    .user("Which package managers are installed?")
    .assistant(({ writer }) => {
      writer
        .tool("runCommand", { input: { command: "node --version" } })
        .sleep(900)
        .output({ stdout: "v24.18.0" });
      writer
        .tool("runCommand", { input: { command: "npm --version" } })
        .sleep(900)
        .output({ stdout: "11.6.2" });
      writer
        .tool("runCommand", { input: { command: "pnpm --version" } })
        .sleep(900)
        .error("command not found: pnpm");
      writer.text("Node 24 and npm 11 are installed. pnpm is not.");
    });
 
  const transcript = script.get();
  const [question] = script.get(1);
  const transport = script.transport({ delayMs: 20 });
 
  const chat = new Chat<ChatMessage>({ messages: transcript, transport });
  const busy = $derived(
    chat.status === "submitted" || chat.status === "streaming"
  );
</script>
 
{#snippet toolRow(part: ToolPart, streaming: boolean, defaultOpen?: boolean)}
  {@const command = part.input?.command}
  {#if part.state === "output-available" || part.state === "output-error"}
    {@const failed = part.state === "output-error"}
    <Trace.Collapsible open={defaultOpen}>
      <Trace.CollapsibleTrigger>
        <Trace.Line status={failed ? "error" : undefined}>
          <Trace.Icon>
            <TerminalIcon />
          </Trace.Icon>
          <Trace.Title>
            {failed ? "Failed to run" : "Ran"}
            {command}
          </Trace.Title>
        </Trace.Line>
      </Trace.CollapsibleTrigger>
      <Trace.CollapsibleContent>
        <CodeBlock.Root size="sm">
          <CodeBlock.Content
            wrap
            class="max-h-64"
            code={failed ? part.errorText : part.output.stdout}
          />
        </CodeBlock.Root>
      </Trace.CollapsibleContent>
    </Trace.Collapsible>
  {:else}
    <Trace.Line status={streaming ? "running" : "error"}>
      <Trace.Icon>
        <TerminalIcon />
      </Trace.Icon>
      <Trace.Title>
        {streaming ? "Running" : "Failed to run"}
        {command}
      </Trace.Title>
    </Trace.Line>
  {/if}
{/snippet}
 
<div class="flex min-h-72 w-full max-w-sm flex-col gap-4 py-6 text-sm">
  {#each chat.messages as message (message.id)}
    {#if message.role === "user"}
      <Message.Root align="end">
        <Message.Content>
          <Bubble.Root align="end" variant="muted">
            <Bubble.Content>{getMessageText(message)}</Bubble.Content>
          </Bubble.Root>
        </Message.Content>
      </Message.Root>
    {:else}
      {@const streaming = busy && message === chat.messages.at(-1)}
      {@const tools = message.parts.filter(
        (part) => part.type === "tool-runCommand"
      )}
      {@const text = getMessageText(message)}
      <Message.Root>
        <Message.Content class="gap-4">
          {#if tools.length > 0}
            <Trace.Root>
              {#each tools as part (part.toolCallId)}
                <!-- Like `defaultOpen`: read once, when the row first renders. -->
                {@render toolRow(
                  part,
                  streaming,
                  untrack(() =>
                    busy ? undefined : part.state === "output-error"
                  )
                )}
              {/each}
            </Trace.Root>
          {/if}
          {#if text}
            <p>{text}</p>
          {/if}
        </Message.Content>
      </Message.Root>
    {/if}
  {/each}
  {#if !busy}
    <Button
      variant="outline"
      size="sm"
      class="mt-auto self-start"
      onclick={() => {
        chat.messages = [];
        void chat.sendMessage(question);
      }}
    >
      Replay
    </Button>
  {/if}
</div>

Tool is a tool call the agent makes, such as running a command or reading a file. The line follows the call from input to output, including errors and calls that wait for approval.

Tool is built with Trace with a Trace.Collapsible for each call that has output.

Installation

Tool is built with the Trace and Code Block components.

pnpm dlx shadcn-svelte@latest add trace code-block

Usage

<script lang="ts">
  import * as CodeBlock from "$lib/components/ui/code-block/index.js";
  import * as Trace from "$lib/components/ui/trace/index.js";
</script>
<Trace.Root>
  <Trace.Collapsible>
    <Trace.CollapsibleTrigger>
      <Trace.Line>
        <Trace.Icon>
          <TerminalIcon />
        </Trace.Icon>
        <Trace.Title>Ran git status --short</Trace.Title>
      </Trace.Line>
    </Trace.CollapsibleTrigger>
    <Trace.CollapsibleContent>
      <CodeBlock.Root size="sm">
        <CodeBlock.Content wrap class="max-h-64" code={output} />
      </CodeBlock.Root>
    </Trace.CollapsibleContent>
  </Trace.Collapsible>
</Trace.Root>

States

Trace does not read tool parts. Map each part's state to a status and a title.

Denied rm -rf dist Running pnpm test
<script lang="ts">
  import TerminalIcon from "@lucide/svelte/icons/terminal";
  import * as CodeBlock from "$lib/components/ui/code-block/index.js";
  import * as Trace from "$lib/components/ui/trace/index.js";
 
  const status = ` M app/api/chat/route.ts
 M components/chat.tsx`;
</script>
 
<Trace.Root class="max-w-sm">
  <Trace.Collapsible>
    <Trace.CollapsibleTrigger>
      <Trace.Line>
        <Trace.Icon>
          <TerminalIcon />
        </Trace.Icon>
        <Trace.Title>Ran git status --short</Trace.Title>
      </Trace.Line>
    </Trace.CollapsibleTrigger>
    <Trace.CollapsibleContent>
      <CodeBlock.Root size="sm">
        <CodeBlock.Content wrap class="max-h-64" code={status} />
      </CodeBlock.Root>
    </Trace.CollapsibleContent>
  </Trace.Collapsible>
  <Trace.Collapsible>
    <Trace.CollapsibleTrigger>
      <Trace.Line status="error">
        <Trace.Icon>
          <TerminalIcon />
        </Trace.Icon>
        <Trace.Title>Failed to run pnpm dev</Trace.Title>
      </Trace.Line>
    </Trace.CollapsibleTrigger>
    <Trace.CollapsibleContent>
      <CodeBlock.Root size="sm">
        <CodeBlock.Content
          wrap
          class="max-h-64"
          code="Command timed out after 120 seconds."
        />
      </CodeBlock.Root>
    </Trace.CollapsibleContent>
  </Trace.Collapsible>
  <Trace.Line>
    <Trace.Icon>
      <TerminalIcon />
    </Trace.Icon>
    <Trace.Title>Denied rm -rf dist</Trace.Title>
  </Trace.Line>
  <Trace.Line status="running">
    <Trace.Icon>
      <TerminalIcon />
    </Trace.Icon>
    <Trace.Title>Running pnpm test</Trace.Title>
  </Trace.Line>
</Trace.Root>
Tool part state status Title
input-streaming, input-available "running" while the chat streams Running …
input-streaming, input-available after the chat stops "error" Failed to run …
approval-requested - Not a line. An Item outside Trace with Approve and Deny buttons.
approval-responded "running" if approved, none if not Running … if approved, Denied … if not
output-available none Ran …
output-error "error" Failed to run …
output-denied none Denied …

A tool is a plain Trace.Line while it runs, and a Trace.Collapsible once its output arrives.

{#snippet toolLine(part: ToolPart, streaming: boolean)}
  {@const command = part.input?.command}
  {#if part.state === "output-denied" || (part.state === "approval-responded" && !part.approval.approved)}
    <Trace.Line>
      <Trace.Icon>
        <TerminalIcon />
      </Trace.Icon>
      <Trace.Title>Denied {command}</Trace.Title>
    </Trace.Line>
  {:else if part.state === "output-available" || part.state === "output-error"}
    {@const failed = part.state === "output-error"}
    <Trace.Collapsible>
      <Trace.CollapsibleTrigger>
        <Trace.Line status={failed ? "error" : undefined}>
          <Trace.Icon>
            <TerminalIcon />
          </Trace.Icon>
          <Trace.Title>{failed ? "Failed to run" : "Ran"} {command}</Trace.Title>
        </Trace.Line>
      </Trace.CollapsibleTrigger>
      <Trace.CollapsibleContent>
        <CodeBlock.Root size="sm">
          <CodeBlock.Content
            wrap
            class="max-h-64"
            code={failed ? part.errorText : part.output.stdout}
          />
        </CodeBlock.Root>
      </Trace.CollapsibleContent>
    </Trace.Collapsible>
  {:else}
    <Trace.Line status={streaming ? "running" : "error"}>
      <Trace.Icon>
        <TerminalIcon />
      </Trace.Icon>
      <Trace.Title>{streaming ? "Running" : "Failed to run"} {command}</Trace.Title>
    </Trace.Line>
  {/if}
{/snippet}
<Trace.Root>
  {#each message.parts.filter((part) => part.type === "tool-runCommand") as part (part.toolCallId)}
    {@render toolLine(part, streaming)}
  {/each}
</Trace.Root>

A tool that runs on the client stays input-available after the chat stops until you call addToolOutput. Treat it as waiting, not failed.

Output in a Sheet

For long output, wrap the Trace.Line in a Sheet.Trigger and show the output in the sheet.

<script lang="ts">
  import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
  import FileTextIcon from "@lucide/svelte/icons/file-text";
  import { Chat } from "@ai-sdk/svelte";
  import * as Bubble from "$lib/components/ui/bubble/index.js";
  import * as CodeBlock from "$lib/components/ui/code-block/index.js";
  import * as Message from "$lib/components/ui/message/index.js";
  import * as Sheet from "$lib/components/ui/sheet/index.js";
  import * as Trace from "$lib/components/ui/trace/index.js";
  import { createChat, getMessageText } from "$lib/ai-sdk.js";
  import { Button } from "$lib/components/ui/button/index.js";
  import type { UIDataTypes, UIMessage } from "ai";
 
  type ChatMessage = UIMessage<
    unknown,
    UIDataTypes,
    {
      readFile: {
        input: { path: string };
        output: { path: string; content: string };
      };
    }
  >;
 
  const PACKAGE_JSON = `{
  "name": "acme-chat",
  "private": true,
  "dependencies": {
    "@ai-sdk/react": "^4.0.0",
    "ai": "^7.0.0",
    "next": "^16.3.0"
  }
}`;
 
  const script = createChat<ChatMessage>()
    .user("Which AI SDK version does this app use?")
    .assistant(({ writer }) => {
      writer
        .tool("readFile", { input: { path: "package.json" } })
        .sleep(900)
        .output({ path: "package.json", content: PACKAGE_JSON });
      writer.text("It uses ai 7 with @ai-sdk/react 4.");
    });
 
  const initialMessages = script.get(0);
  const transport = script.transport({ delayMs: 20 });
 
  const chat = new Chat<ChatMessage>({ messages: initialMessages, transport });
  const nextMessage = $derived(script.next(chat.messages));
  const busy = $derived(
    chat.status === "submitted" || chat.status === "streaming"
  );
</script>
 
<div class="flex min-h-72 w-full max-w-sm flex-col gap-4 py-6 text-sm">
  {#each chat.messages as message (message.id)}
    {#if message.role === "user"}
      <Message.Root align="end">
        <Message.Content>
          <Bubble.Root align="end" variant="muted">
            <Bubble.Content>{getMessageText(message)}</Bubble.Content>
          </Bubble.Root>
        </Message.Content>
      </Message.Root>
    {:else}
      {@const streaming = busy && message === chat.messages.at(-1)}
      <Message.Root>
        <Message.Content class="gap-4">
          {#each message.parts as part, index (index)}
            {#if part.type === "text"}
              <p>{part.text}</p>
            {:else if part.type === "tool-readFile"}
              {#if part.state !== "output-available"}
                {@const running =
                  streaming &&
                  (part.state === "input-streaming" ||
                    part.state === "input-available")}
                <Trace.Root>
                  <Trace.Line status={running ? "running" : "error"}>
                    <Trace.Icon>
                      <FileTextIcon />
                    </Trace.Icon>
                    <Trace.Title>
                      {running ? "Reading" : "Failed to read"}
                      {part.input?.path}
                    </Trace.Title>
                  </Trace.Line>
                </Trace.Root>
              {:else}
                <Trace.Root>
                  <Sheet.Root>
                    <Sheet.Trigger>
                      <Trace.Line>
                        <Trace.Icon>
                          <FileTextIcon />
                        </Trace.Icon>
                        <Trace.Title>Read {part.output.path}</Trace.Title>
                        <Trace.Icon>
                          <ChevronRightIcon />
                        </Trace.Icon>
                      </Trace.Line>
                    </Sheet.Trigger>
                    <Sheet.Content>
                      <Sheet.Header>
                        <Sheet.Title class="font-mono"
                          >{part.output.path}</Sheet.Title
                        >
                        <Sheet.Description>
                          {part.output.content.split("\n").length} lines
                        </Sheet.Description>
                      </Sheet.Header>
                      <CodeBlock.Root class="mx-6 mb-6 w-auto">
                        <CodeBlock.Content
                          lineNumbers
                          code={part.output.content}
                        />
                      </CodeBlock.Root>
                    </Sheet.Content>
                  </Sheet.Root>
                </Trace.Root>
              {/if}
            {/if}
          {/each}
        </Message.Content>
      </Message.Root>
    {/if}
  {/each}
  {#if !busy}
    <Button
      variant="outline"
      size="sm"
      class="mt-auto self-start"
      onclick={() =>
        nextMessage
          ? void chat.sendMessage(nextMessage)
          : (chat.messages = initialMessages)}
    >
      {nextMessage ? "Send message" : "Reset"}
    </Button>
  {/if}
</div>

See Trace.

Approval

A pending approval is a question for the person, not a step the agent took, so it sits outside Trace as an Item. Once the person answers, the tool goes back to being a line. See Confirmation.

<script lang="ts">
  import CircleCheckIcon from "@lucide/svelte/icons/circle-check";
  import LoaderCircleIcon from "@lucide/svelte/icons/loader-circle";
  import TerminalIcon from "@lucide/svelte/icons/terminal";
  import { Chat } from "@ai-sdk/svelte";
  import {
    lastAssistantMessageIsCompleteWithApprovalResponses,
    type UIDataTypes,
    type UIMessage
  } from "ai";
  import * as Bubble from "$lib/components/ui/bubble/index.js";
  import * as Item from "$lib/components/ui/item/index.js";
  import * as Message from "$lib/components/ui/message/index.js";
  import * as Trace from "$lib/components/ui/trace/index.js";
  import { createChat, getMessageText } from "$lib/ai-sdk.js";
  import { Button } from "$lib/components/ui/button/index.js";
 
  type ChatMessage = UIMessage<
    unknown,
    UIDataTypes,
    {
      runCommand: {
        input: { command: string };
        output: { stdout: string };
      };
    }
  >;
 
  const script = createChat<ChatMessage>()
    .user("Clean up the build folder.")
    .sleep(600)
    .assistant(({ writer }) => {
      writer.reasoning(
        "The build output lives in dist. Deleting it cannot be undone, so I should ask before I run the command."
      );
      writer.sleep(400);
      writer.tool("runCommand", {
        input: { command: "rm -rf dist" },
        needsApproval: true,
        output: { stdout: "" }
      });
    })
    .assistant(({ writer, toolCall }) => {
      writer.sleep(600);
      writer.text(
        toolCall?.approved
          ? "Removed the dist folder."
          : "Okay, I left the dist folder in place."
      );
    });
 
  const initialMessages = script.get(0);
  const transport = script.transport({ delayMs: 20 });
 
  const chat = new Chat<ChatMessage>({
    messages: initialMessages,
    transport,
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses
  });
  const nextMessage = $derived(script.next(chat.messages));
  const busy = $derived(
    chat.status === "submitted" || chat.status === "streaming"
  );
 
  // The reader's choice for each reasoning row. Until they click, it follows `running`.
  let reasoningOpen = $state<Record<string, boolean>>({});
</script>
 
{#snippet reasoningLine(id: string, text: string, running: boolean)}
  <Trace.Collapsible
    open={reasoningOpen[id] ?? running}
    onOpenChange={(open) => (reasoningOpen[id] = open)}
  >
    <Trace.CollapsibleTrigger>
      <Trace.Line status={running ? "running" : undefined}>
        <Trace.Icon>
          {#if running}
            <LoaderCircleIcon class="animate-spin" />
          {:else}
            <CircleCheckIcon />
          {/if}
        </Trace.Icon>
        <Trace.Title>{running ? "Thinking" : "Thought"}</Trace.Title>
      </Trace.Line>
    </Trace.CollapsibleTrigger>
    <Trace.CollapsibleContent class="whitespace-pre-wrap"
      >{text}</Trace.CollapsibleContent
    >
  </Trace.Collapsible>
{/snippet}
 
<div class="flex min-h-72 w-full max-w-sm flex-col gap-4 py-6 text-sm">
  {#each chat.messages as message (message.id)}
    {#if message.role === "user"}
      <Message.Root align="end">
        <Message.Content>
          <Bubble.Root align="end" variant="muted">
            <Bubble.Content>{getMessageText(message)}</Bubble.Content>
          </Bubble.Root>
        </Message.Content>
      </Message.Root>
    {:else}
      <Message.Root>
        <Message.Content class="gap-4">
          {#each message.parts as part, index (index)}
            {#if part.type === "text"}
              <p>{part.text}</p>
            {:else if part.type === "reasoning"}
              <Trace.Root>
                {@render reasoningLine(
                  `${message.id}-${index}`,
                  part.text,
                  part.state === "streaming"
                )}
              </Trace.Root>
            {:else if part.type === "tool-runCommand"}
              {#if part.state === "approval-requested"}
                {@const approvalId = part.approval.id}
                <!-- A pending approval is a prompt, not activity, so it sits outside Trace. -->
                <Item.Root variant="outline" size="sm">
                  <Item.Media variant="icon">
                    <TerminalIcon />
                  </Item.Media>
                  <Item.Content>
                    <Item.Title>Run {part.input.command}?</Item.Title>
                    <Item.Description
                      >The command runs in this project.</Item.Description
                    >
                  </Item.Content>
                  <Item.Actions>
                    <Button
                      size="sm"
                      variant="outline"
                      onclick={() =>
                        chat.addToolApprovalResponse({
                          id: approvalId,
                          approved: false
                        })}
                    >
                      Deny
                    </Button>
                    <Button
                      size="sm"
                      onclick={() =>
                        chat.addToolApprovalResponse({
                          id: approvalId,
                          approved: true
                        })}
                    >
                      Approve
                    </Button>
                  </Item.Actions>
                </Item.Root>
              {:else}
                {@const denied =
                  part.state === "output-denied" ||
                  (part.state === "approval-responded" &&
                    !part.approval.approved)}
                <Trace.Root>
                  <Trace.Line
                    status={!denied && part.state !== "output-available"
                      ? "running"
                      : undefined}
                  >
                    <Trace.Icon>
                      <TerminalIcon />
                    </Trace.Icon>
                    <Trace.Title>
                      {denied
                        ? "Denied"
                        : part.state === "output-available"
                          ? "Ran"
                          : "Running"}
                      {part.input?.command}
                    </Trace.Title>
                  </Trace.Line>
                </Trace.Root>
              {/if}
            {/if}
          {/each}
        </Message.Content>
      </Message.Root>
    {/if}
  {/each}
  {#if !busy}
    <Button
      variant="outline"
      size="sm"
      class="mt-auto self-start"
      onclick={() => {
        if (nextMessage) {
          void chat.sendMessage(nextMessage);
        } else {
          reasoningOpen = {};
          chat.messages = initialMessages;
        }
      }}
    >
      {nextMessage ? "Send message" : "Reset"}
    </Button>
  {/if}
</div>
{#if part.state === "approval-requested"}
  <Item.Root variant="outline" size="sm">
    <Item.Media variant="icon">
      <TerminalIcon />
    </Item.Media>
    <Item.Content>
      <Item.Title>Run {part.input.command}?</Item.Title>
    </Item.Content>
    <Item.Actions>
      <Button
        size="sm"
        variant="outline"
        onclick={() =>
          chat.addToolApprovalResponse({
            id: part.approval.id,
            approved: false,
          })}
      >
        Deny
      </Button>
      <Button
        size="sm"
        onclick={() =>
          chat.addToolApprovalResponse({
            id: part.approval.id,
            approved: true,
          })}
      >
        Approve
      </Button>
    </Item.Actions>
  </Item.Root>
{/if}

Pass sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses to new Chat() so the chat continues once every approval has an answer.

API Reference

See Trace.