Skip to content
KONIGI Patterns

AI Assistants / Understanding answers / Tool call trace

‹ 5 of 5

Tool call trace

People need to see which actions the assistant took and whether they succeeded.

Updated September 12, 2026

Problem

An assistant may say it checked a source or completed an action. People need a way to see what actually happened.

Solution

Show each lookup or action as a compact row with a plain-language name, relevant details, and its result. Let people expand the row for more information when needed.

Distinguish reading information from changing it. For actions that need approval, show the exact change before it happens. An email approval, for example, needs the recipient and message, not just the name of the action.

Show failed, empty, and blocked results as clearly as successful ones. Remove credentials and unnecessary private information from displayed details. Keep long sequences collapsed behind a useful summary.

Use when

The assistant retrieves information or takes actions outside the visible conversation.

Don’t use when

The model is answering from its own parameters. A trace panel that’s always empty teaches people to stop opening it, which costs exactly when a call finally does happen.

Trade-offs

Full details can overwhelm people and expose sensitive information. Compact summaries are easier to read but may hide the reason an action failed. Frequent approval requests can encourage people to approve without reviewing, so each request needs meaningful detail.

Checklist

  • Is the tool’s name written in language a viewer understands?
  • Are reads and writes visually distinguished?
  • Does a write require approval, and does the approval show the payload?
  • Are failed and empty calls as visible as successful ones?
  • Can the viewer see the arguments, and do they reveal what was understood?
  • Is anything sensitive in the arguments or responses redacted?
  • Does the trace collapse when there are twenty calls?
  • Is the elapsed time per call shown?
  • Can the viewer stop a chain of calls partway?
  • Does the answer say when it proceeded despite a failed call?

Compare

Perplexity shows its search steps as the primary disclosure, putting the verifiable part of its process on screen instead of the unverifiable reasoning. ChatGPT surfaces tool activity as collapsed status lines within the turn, keeping the transcript readable while the work is legible. Claude streams tool calls as first-class content blocks alongside text, so a call is part of the response rather than metadata attached to it. Microsoft Copilot in a tenant has the sharpest version of the write problem, since a call that edits a shared document affects colleagues who never saw the approval.

Reasoning disclosure summarizes the assistant’s approach. Source list collects retrieved material, while Citation chip connects it to individual claims.

Scoped context defines available material and access. Generation error handles failures that prevent the answer from finishing.

Tool call trace anatomy Three calls in a collapsed trace: a read that ran freely, a read that failed and was written around, and a write held behind an approval gate showing the actual payload rather than the intent. Reads run. Writes ask first, and show the payload searched the calendar for "Tuesday 14:00" 0.4s · read read finance/Q3.xlsx — permission denied failed Send an email? to: landlord@example.com subject: Break clause, 14 March approve edit the answer proceeded despite the failed read, and said so 1 2 3 4 1 THE ARGUMENTS A query that reads nothing like the question is a misunderstanding, caught. 2 FAILURES STAY VISIBLE A model writes around a gap, and the result is confident and unsupported. 3 SHOW THE PAYLOAD Approving "send email" tells the viewer nothing. The recipient and body do. 4 READS AGAINST WRITES One changes nothing. The other cannot be undone by any interface once it has run. Raw arguments carry credentials and identifiers, which is the argument for a summarised row.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

Reads run. Writes ask first, and the approval shows the actual payload rather than a description of the intent. A read that failed stays in the trace, because the answer that proceeded around it depends on knowing that.

shadcn
npx shadcn@latest add collapsible button
npm
lucide-react
Tokens
--card--card-foreground--primary--primary-foreground--muted--muted-foreground--border--status-warn--chart-1
  1. Send an email?

    to: landlord@example.com

    subject: Break clause, 14 March

    body: We intend to exercise the break clause on 14 March and will return the premises with vacant possession…

ToolCallTrace.tsxOne row per call, expandable to the request. A pending write renders as a gate with its payload instead of a row.

import { useState } from "react";
import { ChevronRight } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

export type Call = {
  id: string;
  /** The tool in plain language, with the arguments that matter. This is the
   *  line most viewers read, and a query that reads nothing like the question
   *  is a misunderstanding caught in the act. */
  label: string;
  /** A read changes nothing and runs freely. A write changes the world and
   *  cannot be undone by any interface once it has run, so it asks first. */
  kind: "read" | "write";
  /** The actual request. Behind the row for a read; up front for a write
   *  awaiting approval, because approving "send email" tells nobody anything. */
  request: Record<string, string>;
  outcome:
    | { status: "pending" }
    | { status: "running" }
    | { status: "ok"; elapsedMs: number; response: string }
    | { status: "failed"; elapsedMs?: number; error: string };
};

type Props = {
  calls: Call[];
  onApprove: (id: string) => void;
  onEdit: (id: string) => void;
};

const seconds = (ms: number) => `${(ms / 1000).toFixed(1)}s`;

function Row({ call }: { call: Call }) {
  const [open, setOpen] = useState(false);
  const o = call.outcome;
  const failed = o.status === "failed";
  const right =
    o.status === "ok" ? `${seconds(o.elapsedMs)} · ${call.kind}` :
    o.status === "failed" ? "failed" :
    o.status === "running" ? "running…" : "waiting";

  return (
    <Collapsible open={open} onOpenChange={setOpen}>
      <CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-md border border-dashed px-3 py-1.5 text-left">
        <ChevronRight className={cn("size-3 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")} />
        <span className={cn("truncate text-[11px]", failed ? "text-status-warn" : "text-card-foreground")}>
          {call.label}{failed && ` — ${o.error}`}
        </span>
        <span className={cn("ml-auto shrink-0 text-[11px] tabular-nums", failed ? "text-status-warn" : "text-muted-foreground")}>
          {right}
        </span>
      </CollapsibleTrigger>
      <CollapsibleContent>
        <pre className="mt-1 overflow-x-auto rounded-md bg-muted p-2 font-mono text-[11px] leading-relaxed text-muted-foreground">
          {Object.entries(call.request).map(([k, v]) => `${k}: ${v}\n`).join("")}
          {o.status === "ok" && `→ ${o.response}`}
          {o.status === "failed" && `→ ${o.error}`}
        </pre>
      </CollapsibleContent>
    </Collapsible>
  );
}

/** The gate shows the payload, not the intent. The recipient and subject are
 *  what make approving a decision someone can actually make. */
function Gate({ call, onApprove, onEdit }: { call: Call } & Props) {
  const question = call.label.charAt(0).toUpperCase() + call.label.slice(1) + "?";
  return (
    <div className="rounded-lg border-2 border-chart-1 p-3" role="group" aria-label={question}>
      <p className="text-sm text-card-foreground">{question}</p>
      <div className="mt-2.5 flex items-start gap-3">
        <div className="min-w-0 flex-1 space-y-1.5 font-mono text-[11px] text-muted-foreground">
          {Object.entries(call.request).map(([k, v]) => (
            <p key={k} className="truncate">{`${k}: ${v}`}</p>
          ))}
        </div>
        <Button size="sm" className="h-7 px-2.5 text-[11px]" onClick={() => onApprove(call.id)}>approve</Button>
        <Button size="sm" variant="ghost" className="h-7 px-2 text-[11px] text-muted-foreground" onClick={() => onEdit(call.id)}>edit</Button>
      </div>
    </div>
  );
}

export function ToolCallTrace({ calls, onApprove, onEdit }: Props) {
  return (
    <ol className="space-y-1.5" aria-label="Tool calls">
      {calls.map((call) => (
        <li key={call.id} className={cn(call.kind === "write" && call.outcome.status === "pending" && "pt-1.5")}>
          {call.kind === "write" && call.outcome.status === "pending"
            ? <Gate call={call} calls={calls} onApprove={onApprove} onEdit={onEdit} />
            : <Row call={call} />}
        </li>
      ))}
    </ol>
  );
}

demo.tsxHow it is called: a read that ran, a read that was refused, and an email waiting on approval.

import { useState } from "react";
import { ToolCallTrace, type Call } from "./ToolCallTrace";

/**
 * Three calls. A calendar search that ran, a spreadsheet read that hit a
 * permission wall and stays in the trace, and an email held behind the gate
 * with its recipient and subject showing. Approve sends it; the gate becomes
 * a row like the others.
 */
const CALLS: Call[] = [
  {
    id: "cal",
    kind: "read",
    label: 'searched the calendar for "Tuesday 14:00"',
    request: { calendar: "work", query: "Tuesday 14:00" },
    outcome: { status: "ok", elapsedMs: 400, response: "1 event: Design review, 14:00–14:45, Room 4B" },
  },
  {
    id: "q3",
    kind: "read",
    label: "read finance/Q3.xlsx",
    request: { path: "finance/Q3.xlsx", sheet: "summary" },
    outcome: { status: "failed", elapsedMs: 120, error: "permission denied" },
  },
  {
    id: "mail",
    kind: "write",
    label: "send an email",
    request: {
      to: "landlord@example.com",
      subject: "Break clause, 14 March",
      body: "We intend to exercise the break clause on 14 March and will return the premises with vacant possession…",
    },
    outcome: { status: "pending" },
  },
];

export default function Demo() {
  const [calls, setCalls] = useState(CALLS);
  const approve = (id: string) =>
    setCalls(calls.map((c) => (c.id === id ? { ...c, outcome: { status: "ok", elapsedMs: 900, response: "sent" } } : c)));
  // A real app hands the payload back to the composer here.
  const edit = () => {};

  return (
    <div className="rounded-lg border bg-card p-4">
      <ToolCallTrace calls={calls} onApprove={approve} onEdit={edit} />
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

No screenshots yet for this pattern. Browse Products to see other examples.