Meet Frontfriend.Your brand. Your rules. Your agents, connected.Explore what’s new

Tool

Tool calls with running, waiting, error, and complete states, output details, and approval requests.

Interactive preview

The shared preview uses React Base. The source below uses Reka UI. Each example runs with local data.

Installation

Complete project setup with vue/reka, then install these components:

pnpm exec frontfriend download trace code-block icon button message bubble item sheet -f vue --primitive reka

Map input streaming to running, client input waiting to pending, output errors to error, and available output to a complete line. A pending approval uses Item outside Trace. Keep the approval ID with the response and accept only one decision. These examples do not execute commands.

The output sheet uses JSON syntax highlighting through the Code Block helpers. Plain command output does not load the tokenizer.

Usage

Approval

Save as ToolApprovalExample.vue.

<script setup lang="ts">
import { ref, nextTick } from "vue";
import { Trace, TraceIcon, TraceLine, TraceTitle } from "@/components/ui/trace";

import { Icon } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
  Item,
  ItemMedia,
  ItemContent,
  ItemTitle,
  ItemDescription,
  ItemActions,
} from "@/components/ui/item";
const decision = ref<"pending" | "approved" | "denied">("pending");
const result = ref<HTMLParagraphElement | null>(null);
async function decide(value: "approved" | "denied") {
  if (decision.value !== "pending") return;
  decision.value = value;
  await nextTick();
  result.value?.focus();
}
</script>
<template>
  <div
    style="
      width: 100%;
      max-width: 384px;
      min-width: 0;
      min-height: 288px;
      margin-inline: auto;
      display: flex;
      flex-direction: column;
      gap: 16px;
      padding-block: 24px;
      font-size: var(--text-sm);
      line-height: 1.5;
    "
  >
    <Message align="end">
      <MessageContent>
        <Bubble align="end" variant="muted">
          <BubbleContent>
            Run the build before I ship this change.
          </BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
    <Item v-if="decision === 'pending'" variant="outline" size="sm">
      <ItemMedia variant="icon">
        <Icon name="Terminal" />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>Run pnpm build?</ItemTitle>
        <ItemDescription>The command runs in this project.</ItemDescription>
      </ItemContent>
      <ItemActions>
        <Button size="sm" variant="outline" @click="decide('denied')">
          Deny
        </Button>
        <Button size="sm" @click="decide('approved')">Approve</Button>
      </ItemActions>
    </Item>
    <Message v-else>
      <MessageContent style="gap: 16px">
        <Trace>
          <TraceLine :status="decision === 'approved' ? 'running' : undefined">
            <TraceIcon>
              <Icon name="Terminal" />
            </TraceIcon>
            <TraceTitle>
              {{
                decision === "approved"
                  ? "Running pnpm build"
                  : "Denied pnpm build"
              }}
            </TraceTitle>
          </TraceLine>
        </Trace>
        <p ref="result" tabindex="-1">
          {{
            decision === "approved"
              ? "Build approved. The command is running."
              : "Build cancelled. No command was run."
          }}
        </p>
      </MessageContent>
    </Message>
    <div>
      <Button size="sm" variant="ghost" @click="decision = 'pending'">
        Reset
      </Button>
    </div>
  </div>
</template>

Output sheet

Save as ToolOutputSheetExample.vue.

<script setup lang="ts">
import { ref, watch } from "vue";
import { Trace, TraceIcon, TraceLine, TraceTitle } from "@/components/ui/trace";
import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockLine,
  CodeBlockToken,
} from "@/components/ui/code-block";
import { Icon } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
  Sheet,
  SheetTrigger,
  SheetContent,
  SheetHeader,
  SheetTitle,
  SheetDescription,
} from "@/components/ui/sheet";
import {
  highlightCode,
  type HighlightedToken,
} from "@/components/ui/code-block/highlighter";
const sheetOpen = ref(false);
const lines = ref<HighlightedToken[][]>([]);
watch(sheetOpen, async (open, _, onCleanup) => {
  if (!open || lines.value.length) return;
  let active = true;
  onCleanup(() => {
    active = false;
  });
  const result = await highlightCode(value, "json");
  if (active)
    lines.value = result ?? value.split("\n").map((content) => [{ content }]);
});
const value =
  '{\n  "name": "checkout",\n  "private": true,\n  "scripts": {\n    "build": "vite build",\n    "test": "vitest run"\n  }\n}';
</script>
<template>
  <div
    style="
      width: 100%;
      max-width: 384px;
      min-width: 0;
      min-height: 288px;
      margin-inline: auto;
      display: flex;
      flex-direction: column;
      gap: 16px;
      padding-block: 24px;
      font-size: var(--text-sm);
      line-height: 1.5;
    "
  >
    <Message align="end">
      <MessageContent>
        <Bubble align="end" variant="muted">
          <BubbleContent>Which commands does this app provide?</BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
    <Message>
      <MessageContent style="gap: 16px">
        <Trace>
          <Sheet v-model:open="sheetOpen">
            <SheetTrigger as-child>
              <Button variant="ghost">
                <TraceLine>
                  <TraceIcon>
                    <Icon name="FileText" />
                  </TraceIcon>
                  <TraceTitle>Read package.json</TraceTitle>
                  <TraceIcon>
                    <Icon name="ChevronRight" />
                  </TraceIcon>
                </TraceLine>
              </Button>
            </SheetTrigger>
            <SheetContent>
              <SheetHeader>
                <SheetTitle>package.json</SheetTitle>
                <SheetDescription>
                  Build and test commands for this project.
                </SheetDescription>
              </SheetHeader>
              <div style="min-width: 0; padding-block: 24px">
                <CodeBlock size="sm">
                  <CodeBlockContent line-numbers wrap>
                    <template v-if="lines.length">
                      <CodeBlockLine
                        v-for="(line, index) in lines"
                        :key="index"
                      >
                        <CodeBlockToken
                          v-for="(token, tokenIndex) in line"
                          :key="tokenIndex"
                          :token="token"
                        />
                      </CodeBlockLine>
                    </template>
                    <template v-else>{{ value }}</template>
                  </CodeBlockContent>
                </CodeBlock>
              </div>
            </SheetContent>
          </Sheet>
        </Trace>
        <p>The app provides build and test commands.</p>
      </MessageContent>
    </Message>
  </div>
</template>

Complete example

Save as ToolExample.vue.

<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from "vue";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Icon } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
type Phase = "complete" | "running" | "error" | "pending";
const props = withDefaults(defineProps<{ state?: Phase }>(), {
  state: "complete",
});
const phase = ref<Phase>(props.state);
let timer: ReturnType<typeof setTimeout> | undefined;
watch(
  () => props.state,
  (value) => {
    clearTimeout(timer);
    phase.value = value;
  },
);
onBeforeUnmount(() => clearTimeout(timer));
function replay() {
  clearTimeout(timer);
  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
    phase.value = "complete";
    return;
  }
  phase.value = "running";
  timer = setTimeout(() => {
    phase.value = "complete";
  }, 3600);
}
const commands = [
  { command: "node --version", output: "v24.18.0" },
  { command: "npm --version", output: "11.6.2" },
  {
    command: "pnpm --version",
    output: "command not found: pnpm",
    failed: true,
  },
];
</script>

<template>
  <div
    style="
      width: 100%;
      max-width: 384px;
      min-width: 0;
      min-height: 288px;
      margin-inline: auto;
      display: flex;
      flex-direction: column;
      gap: 16px;
      padding-block: 24px;
      font-size: var(--text-sm);
      line-height: 1.5;
    "
  >
    <Message align="end">
      <MessageContent>
        <Bubble align="end" variant="muted">
          <BubbleContent>Which package managers are installed?</BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
    <Message>
      <MessageContent style="gap: 16px; min-width: 0">
        <Trace>
          <template v-for="(command, index) in commands" :key="command.command">
            <TraceCollapsible
              :unmount-on-hide="false"
              v-if="
                phase === 'complete' || (index === 0 && phase !== 'pending')
              "
              :default-open="command.failed"
            >
              <TraceCollapsibleTrigger>
                <TraceLine :status="command.failed ? 'error' : undefined">
                  <TraceIcon>
                    <Icon name="Terminal" />
                  </TraceIcon>
                  <TraceTitle>
                    {{ command.failed ? "Failed to run" : "Ran" }}
                    {{ command.command }}
                  </TraceTitle>
                </TraceLine>
              </TraceCollapsibleTrigger>
              <TraceCollapsibleContent>
                <CodeBlock size="sm">
                  <CodeBlockContent wrap style="max-height: 256px">
                    {{ command.output }}
                  </CodeBlockContent>
                </CodeBlock>
              </TraceCollapsibleContent>
            </TraceCollapsible>
            <TraceLine
              v-else
              :status="index === 2 && phase === 'running' ? 'pending' : phase"
            >
              <TraceIcon>
                <Icon name="Terminal" />
              </TraceIcon>
              <TraceTitle>
                {{
                  phase === "pending" || (index === 2 && phase === "running")
                    ? "Waiting to run"
                    : phase === "error"
                      ? "Failed to run"
                      : "Running"
                }}
                {{ command.command }}
              </TraceTitle>
            </TraceLine>
          </template>
        </Trace>
        <p v-if="phase === 'complete'">
          Node 24 and npm 11 are installed. pnpm is not.
        </p>
      </MessageContent>
    </Message>
    <div>
      <Button
        size="sm"
        variant="outline"
        :disabled="phase === 'running'"
        @click="replay"
      >
        Replay
      </Button>
    </div>
  </div>
</template>

AI SDK

The adapter reads tool-runCommand parts and maps every input, approval, output, error, and denial state. Pass clientCallIds for client tools that remain input-available after streaming stops; they stay pending until addToolOutput supplies a result. Approval requests are Item cards outside Trace. Pass addToolApprovalResponse from your chat client. The adapter sends the approval ID once and permits retry if the callback rejects. Configure lastAssistantMessageIsCompleteWithApprovalResponses on your chat client to resume after all approvals are answered.

The basic example runs without a provider. For the AI SDK adapter example only, install the SDK types:

pnpm add ai

Pass a message from your chat client to the adapter. The supplied message does not call a provider.

Save as AiToolAdapter.vue.

<script setup lang="ts">
import type { UIMessage } from "ai";
import { computed, ref, watch, nextTick } from "vue";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceCollapsibleContent,
  TraceLine,
  TraceTitle,
  TraceIcon,
} from "@/components/ui/trace";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Icon } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import {
  Item,
  ItemMedia,
  ItemContent,
  ItemTitle,
  ItemDescription,
  ItemActions,
} from "@/components/ui/item";
type SdkCommandMessage = UIMessage<
  unknown,
  {},
  {
    runCommand: {
      input: { command: string };
      output: { stdout: string; exitCode: number };
    };
  }
>;
type SdkCommandPart = Extract<
  SdkCommandMessage["parts"][number],
  { type: "tool-runCommand" }
>;
function commandState(
  part: SdkCommandPart,
  streaming: boolean,
  client = false,
) {
  const command = part.input?.command ?? "command";
  const denied =
    part.state === "output-denied" ||
    (part.state === "approval-responded" && !part.approval.approved);
  const finished = part.state === "output-available" && !part.preliminary;
  const pending =
    part.state === "approval-requested" ||
    (part.state === "input-available" && client);
  const approved =
    part.state === "approval-responded" && part.approval.approved;
  const failed =
    part.state === "output-error" ||
    (finished && part.output.exitCode !== 0) ||
    (!denied && !finished && !pending && !approved && !streaming);
  const status = denied
    ? undefined
    : pending
      ? "pending"
      : failed
        ? "error"
        : finished
          ? undefined
          : "running";
  return {
    status,
    title: `${denied ? "Denied" : pending ? "Waiting to run" : failed ? "Failed to run" : finished ? "Ran" : "Running"} ${command}`,
    output:
      part.state === "output-error"
        ? part.errorText
        : part.state === "output-available"
          ? part.output.stdout
          : undefined,
  } as const;
}

const props = withDefaults(
  defineProps<{
    message: SdkCommandMessage;
    streaming: boolean;
    clientCallIds?: string[];
    addToolApprovalResponse: (response: {
      id: string;
      approved: boolean;
    }) => void | PromiseLike<void>;
  }>(),
  { clientCallIds: () => [] },
);

const parts = computed(() =>
  props.message.parts.filter(
    (part): part is SdkCommandPart => part.type === "tool-runCommand",
  ),
);
const rows = computed(() =>
  parts.value
    .filter((part) => part.state !== "approval-requested")
    .map((part) => ({
      id: part.toolCallId,
      ...commandState(
        part,
        props.streaming,
        props.clientCallIds.includes(part.toolCallId),
      ),
    })),
);
const submitted = ref<Record<string, boolean>>({}),
  errors = ref<Record<string, string>>({});
const pending = new Set<string>();
const results = new Map<string, HTMLElement>();
const focusApproval = ref<string>();
watch(
  () => props.message,
  async (current) => {
    if (!focusApproval.value) return;
    const responded = current.parts.find(
      (part) =>
        part.type === "tool-runCommand" &&
        part.state !== "approval-requested" &&
        "approval" in part &&
        part.approval?.id === focusApproval.value,
    );
    if (responded) {
      await nextTick();
      if (responded.type === "tool-runCommand")
        results.get(responded.toolCallId)?.focus();
      focusApproval.value = undefined;
    }
  },
);
async function approve(id: string, approved: boolean) {
  if (pending.has(id)) return;
  pending.add(id);
  focusApproval.value = id;
  submitted.value = { ...submitted.value, [id]: true };
  errors.value = { ...errors.value, [id]: "" };
  try {
    await props.addToolApprovalResponse({ id, approved });
  } catch (error) {
    pending.delete(id);
    focusApproval.value = undefined;
    submitted.value = { ...submitted.value, [id]: false };
    errors.value = {
      ...errors.value,
      [id]: error instanceof Error ? error.message : String(error),
    };
  }
}
const open = ref<Record<string, boolean>>({});
function setOpen(id: string, value: boolean) {
  open.value = { ...open.value, [id]: value };
}
</script>
<template>
  <div
    style="
      width: 100%;
      max-width: 384px;
      min-width: 0;
      margin-inline: auto;
      display: grid;
      gap: 16px;
      padding-block: 24px;
      font-size: var(--text-sm);
      line-height: 1.5;
    "
  >
    <template v-for="part in parts" :key="part.toolCallId">
      <Item
        v-if="part.state === 'approval-requested'"
        variant="outline"
        size="sm"
      >
        <ItemMedia variant="icon">
          <Icon name="Terminal" />
        </ItemMedia>
        <ItemContent>
          <ItemTitle>Run {{ part.input.command }}?</ItemTitle>
          <ItemDescription v-if="errors[part.approval.id]" role="alert">
            {{ errors[part.approval.id] }}
          </ItemDescription>
        </ItemContent>
        <ItemActions>
          <Button
            size="sm"
            variant="outline"
            :disabled="submitted[part.approval.id]"
            @click="approve(part.approval.id, false)"
          >
            Deny
          </Button>
          <Button
            size="sm"
            :disabled="submitted[part.approval.id]"
            @click="approve(part.approval.id, true)"
          >
            Approve
          </Button>
        </ItemActions>
      </Item>
    </template>
    <Trace>
      <div
        v-for="row in rows"
        :key="row.id"
        role="group"
        :tabindex="-1"
        :aria-label="row.title"
        :ref="
          (element) => {
            if (element) results.set(row.id, element as HTMLElement);
            else results.delete(row.id);
          }
        "
      >
        <TraceCollapsible
          :unmount-on-hide="false"
          v-if="row.output !== undefined"
          :open="
            open[row.id] ?? (row.status === 'error' || row.status === 'running')
          "
          @update:open="setOpen(row.id, $event)"
        >
          <TraceCollapsibleTrigger>
            <TraceLine :status="row.status">
              <TraceIcon>
                <Icon
                  :name="row.status === 'error' ? 'CircleAlert' : 'Terminal'"
                />
              </TraceIcon>
              <TraceTitle>{{ row.title }}</TraceTitle>
            </TraceLine>
          </TraceCollapsibleTrigger>
          <TraceCollapsibleContent>
            <CodeBlock
              size="sm"
              :variant="row.status === 'error' ? 'destructive' : 'default'"
            >
              <CodeBlockContent wrap style="max-height: 256px">
                {{ row.output }}
              </CodeBlockContent>
            </CodeBlock>
          </TraceCollapsibleContent>
        </TraceCollapsible>
        <template v-else>
          <TraceLine :status="row.status">
            <TraceIcon>
              <Icon
                :name="row.status === 'error' ? 'CircleAlert' : 'Terminal'"
              />
            </TraceIcon>
            <TraceTitle>{{ row.title }}</TraceTitle>
          </TraceLine>
        </template>
      </div>
    </Trace>
  </div>
</template>

States

Save as ToolStatesExample.vue.

<script setup lang="ts">
import { Icon } from "@/components/ui/icon";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace";

const status = ` M server/api/chat.post.ts
 M components/Chat.vue`;
</script>

<template>
  <div
    style="
      width: 100%;
      max-width: 384px;
      min-width: 0;
      margin-inline: auto;
      padding-block: 24px;
      font-size: var(--text-sm);
      line-height: 1.5;
    "
  >
    <Trace>
      <TraceCollapsible :unmount-on-hide="false">
        <TraceCollapsibleTrigger>
          <TraceLine>
            <TraceIcon>
              <Icon name="Terminal" />
            </TraceIcon>
            <TraceTitle>Ran git status --short</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <CodeBlock size="sm">
            <CodeBlockContent wrap>
              {{ status }}
            </CodeBlockContent>
          </CodeBlock>
        </TraceCollapsibleContent>
      </TraceCollapsible>
      <TraceCollapsible :unmount-on-hide="false">
        <TraceCollapsibleTrigger>
          <TraceLine status="error">
            <TraceIcon>
              <Icon name="Terminal" />
            </TraceIcon>
            <TraceTitle>Failed to run pnpm dev</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <CodeBlock size="sm">
            <CodeBlockContent wrap>
              Command timed out after 120 seconds.
            </CodeBlockContent>
          </CodeBlock>
        </TraceCollapsibleContent>
      </TraceCollapsible>
      <TraceLine>
        <TraceIcon>
          <Icon name="Terminal" />
        </TraceIcon>
        <TraceTitle>Denied rm -rf dist</TraceTitle>
      </TraceLine>
      <TraceLine status="running">
        <TraceIcon>
          <Icon name="Terminal" />
        </TraceIcon>
        <TraceTitle>Running pnpm test</TraceTitle>
      </TraceLine>
    </Trace>
  </div>
</template>

Components and API

This composition has no separate component API. Use the APIs for its installed components.

ComponentTarget reference
trace Reka UI API
code-block Reka UI API
icon Reka UI API
button Reka UI API
message Reka UI API
bubble Reka UI API
item Reka UI API
sheet Reka UI API