Skip to content
KONIGI

AI Assistants / Grounding and disclosure / Tool call trace

5 of 5

Tool call trace

The assistant did something in the world, and the viewer only sees the sentence describing it afterwards.

Updated September 12, 2026

Problem

The answer says it checked the calendar and the room is free. Something did happen between the question and the answer, and the viewer has no way to see what was queried, what came back, or whether the sentence is a report or an invention.

Solution

Render each call as a compact row: the tool’s name in plain language, the significant arguments, the outcome, and the time it took. Collapsed by default, expandable to the actual request and response. The collapsed line is what most viewers read; the expansion is for the case where the answer is surprising and someone needs to find out why.

The distinction that matters more than any styling choice is between reads and writes. A search, a lookup, a file read changes nothing and can run freely with a trace shown afterwards. A call that sends a message, files a ticket, moves money or edits a document changes the world, and once it’s run no interface can undo it. HAX guideline 16 is explicit that consequences belong before the action rather than after it, so writes want an approval step showing exactly what’s about to happen, with the arguments visible and editable.

That approval gate is where most products under-invest. Approving “send email” tells the viewer nothing. Approving a call that shows the recipient, the subject and the body is a decision someone can actually make. The general rule is that the gate should display the payload—not the intent.

Failures need to be as visible as successes. A tool that timed out, returned nothing, or hit a permission wall usually leaves the model to write around the gap, and the resulting answer is confident and unsupported. A failed row in the trace is the only signal the viewer gets that the sentence they’re reading was assembled without the data it claims.

Arguments deserve care for a second reason: they’re the clearest statement of what the assistant understood. A search whose query reads nothing like the question is a misunderstanding caught in the act, and it’s often catchable before the answer finishes.

Use when

The assistant can act, retrieve, or reach systems the viewer can’t see from the 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 traces are the most honest option and read as debug output in a consumer product, so the collapsed summary has to carry real information rather than a spinner and a verb. Approval gates on writes prevent the failures that matter and add friction to every action, which pushes people toward blanket approvals that defeat the gate. Raw arguments and responses can contain credentials, personal data, or internal identifiers that shouldn’t be on screen. A long chain of calls produces a trace longer than the answer, which then needs collapsing of its own.

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 is the same fold applied to thinking rather than acting. Source list is what a retrieval call produces. Citation chip binds an individual claim to what a call returned. Scoped context determines which tools are available at all. Generation error covers the case where a failed call ends the turn instead of being written around.

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 captures reference this pattern yet. Captures arrive product by product; see Products for what's in the gallery so far.