Skip to content
Star on GitHub
ConversationTool call
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Tool call

Each tool the agent calls, with its input and output. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add tool-call

Checking which plans changed this month before I update the pricing page.

Two plans changed. I updated their prices and the yearly saving on the page.

01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add tool-call

React

$npx designme add tool-call --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Tool call lists the tools an agent calls while it works, placed between its opening message and its answer. Each call shows its name and status, and opens to show the JSON the agent sent and the JSON that came back.

  • When your agent uses tools or MCP servers and people want to see what it actually ran.
  • When you need to debug a wrong answer by checking the input and output of each call.
  • When several calls run one after another and the answer should only appear once they have all finished.
03

Usage

Import it and drive it from your own state.

Import

import { ToolCall } from '@/components/designme/tool-call/ToolCall';

Example

The demo above as a React component, 35 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { ToolCall, type ToolCallItem } from './ToolCall';
import { useScript, useTakeover } from '../lib/useScript';

/* ---------- Tool call: two calls run one after the other, then the answer comes in and the first call opens ---------- */
const CALLS: ToolCallItem[] = [
  { key: 'plans', icon: 'db', server: 'billing', name: 'list_plan_changes', state: 'queued', duration: '412 ms',
    input: { since: '2026-10-01' }, output: [{ plan: 'Pro', price: 24 }, { plan: 'Team', price: 48 }] },
  { key: 'edit', icon: 'edit', server: 'files', name: 'edit', state: 'queued', duration: '1.2 s',
    input: { path: 'Pricing.tsx', lines: 6 }, output: { changed: 6, saved: true } }
];
export default function ToolCallDemo() {
  const [calls, setCalls] = useState(CALLS);
  const [open, setOpen] = useState<string[]>([]);
  const ref = useRef<HTMLDivElement>(null), mine = useTakeover(ref);
  useScript(async ({ wait }) => {
    await wait(600);
    for (const k of CALLS.map(c => c.key)) {
      setCalls(cs => cs.map(c => c.key === k ? { ...c, state: 'running' } : c)); await wait(1300);
      setCalls(cs => cs.map(c => c.key === k ? { ...c, state: 'done' } : c));
    }
    // the preview opens the first call to show what came back, unless you have taken over
    if (!mine()) setOpen(o => o.includes('plans') ? o.filter(k => k !== 'plans') : [...o, 'plans']);
  });
  return (
    <div ref={ref} style={{ width: '100%' }}>
      <ToolCall calls={calls} open={open} onOpenChange={setOpen}
        message="Checking which plans changed this month before I update the pricing page."
        reply="Two plans changed. I updated their prices and the yearly saving on the page." />
    </div>
  );
}
04

Props

Everything ToolCall takes.

PropTypeWhat it does
messageReactNodeThe agent's message before the calls
callsrequiredToolCallItem[]
replyReactNodeThe agent's answer after the calls. It fades in once every call is done
openstring[]Keys of the calls that are open. Leave it out to let each call toggle itself
defaultOpenstring[]
onOpenChange(open: string[]) => void
classNamestring
05

Code

The full source, as the CLI installs it.

ToolCall.tsxReact · 107 lines
'use client';
import { useEffect, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import { enter, Fold, Swap } from '../lib/motion';
import '../designme.css';
import './tool-call.css';

/** Any value a tool takes or returns */
export type Json = string | number | boolean | null | Json[] | { [key: string]: Json };

export interface ToolCallItem {
  key: string;
  /** The tool's server or namespace, like "billing" */
  server?: string;
  /** The tool's name, like "list_plan_changes" */
  name: string;
  /** The icon in front of the call */
  icon?: IconName;
  /** queued waits, running spins, done shows the time it took and the output */
  state: 'queued' | 'running' | 'done';
  /** How long it took, like "412 ms". Shown once it is done */
  duration?: string;
  /** What the agent sent, shown as highlighted JSON */
  input?: Json;
  /** What came back, shown once it is done */
  output?: Json;
}

export interface ToolCallProps {
  /** The agent's message before the calls */
  message?: ReactNode;
  calls: ToolCallItem[];
  /** The agent's answer after the calls. It fades in once every call is done */
  reply?: ReactNode;
  /** Keys of the calls that are open. Leave it out to let each call toggle itself */
  open?: string[];
  defaultOpen?: string[];
  onOpenChange?: (open: string[]) => void;
  className?: string;
}

/** JSON on one line per object, each array item on its own line, keys, strings and numbers in their own colours */
function JsonView({ value, depth = 0 }: { value: Json; depth?: number }): ReactNode {
  if (typeof value === 'string') return <span className="dm-s">"{value}"</span>;
  if (typeof value === 'number') return <span className="dm-n">{value}</span>;
  if (value === null || typeof value === 'boolean') return <span className="dm-k">{String(value)}</span>;
  if (Array.isArray(value)) {
    if (!value.length) return '[]';
    const pad = '\n' + '  '.repeat(depth + 1);
    return <>[ {value.map((v, i) => <span key={i}>{i ? ',' + pad : ''}<JsonView value={v} depth={depth + 1} /></span>)} ]</>;
  }
  const keys = Object.keys(value);
  if (!keys.length) return '{}';
  return <>{'{ '}{keys.map((k, i) => <span key={k}>{i ? ', ' : ''}<span className="dm-k">"{k}"</span>: <JsonView value={value[k]} depth={depth} /></span>)}{' }'}</>;
}

function Call({ call, open, onToggle, index }: { call: ToolCallItem; open: boolean; onToggle: () => void; index: number }) {
  const { state } = call;
  return (
    <div className={cx('dm-tcx-c dm-enter', open && 'dm-open')} style={enter(200 + index * 90).style}>
      <button type="button" className="dm-tcx-h" aria-expanded={open} onClick={onToggle}>
        <span className="dm-tcx-ic">{state === 'running' ? <span className="dm-radar" style={{ color: 'var(--dm-fg)' }} /> : <Icon name={call.icon ?? 'code'} size="xs" />}</span>
        <span className="dm-tcx-n">{call.server && <span>{call.server}.</span>}<b>{call.name}</b></span>
        <span className="dm-tcx-ms">
          <Swap>{state === 'done' ? call.duration ?? 'Done' : state === 'running' ? 'Running' : 'Queued'}</Swap>
          <span>{state === 'running'
            ? <span className="dm-spin" style={{ width: 10, height: 10 }} />
            : state === 'done' ? <span style={{ color: 'var(--dm-ok)' }}><Icon name="check" size="xs" /></span> : null}</span>
        </span>
        <Icon name="chev-d" size="xs" className="dm-tcx-chev" />
      </button>
      <Fold open={open}>
        <div className="dm-tcx-io">
          <div><div className="dm-tcx-l">Input</div><pre>{call.input === undefined ? '' : <JsonView value={call.input} />}</pre></div>
          <div><div className="dm-tcx-l">Output</div><pre>{state === 'done' && call.output !== undefined ? <JsonView value={call.output} /> : 'Waiting…'}</pre></div>
        </div>
      </Fold>
    </div>
  );
}

/** The tools an agent calls, with what went in and what came back, between its message and its answer. */
export function ToolCall({ message, calls, reply, open, defaultOpen = [], onOpenChange, className }: ToolCallProps) {
  const [openKeys, setOpen] = useControllable(open, defaultOpen, onOpenChange);
  const done = calls.length > 0 && calls.every(c => c.state === 'done');
  // the answer is laid out from the start and fades in a frame after the last call finishes
  const [shown, setShown] = useState(false);
  useEffect(() => {
    if (!done) { setShown(false); return; }
    const raf = requestAnimationFrame(() => setShown(true));
    return () => cancelAnimationFrame(raf);
  }, [done]);
  const toggle = (key: string) => setOpen(openKeys.includes(key) ? openKeys.filter(k => k !== key) : [...openKeys, key]);

  return (
    <div data-dm="" className={cx('dm-tcx', className)}>
      {message && <p className="dm-msg">{message}</p>}
      <div className="dm-tcx-calls">
        {calls.map((c, i) => <Call key={c.key} call={c} index={i} open={openKeys.includes(c.key)} onToggle={() => toggle(c.key)} />)}
      </div>
      {reply && <p className={cx('dm-msg dm-tcx-end', shown && 'dm-in')} aria-hidden={!shown}>{reply}</p>}
    </div>
  );
}
tool-call.cssReact · 21 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Tool call ---------- */
.dm-tcx{display:flex;flex-direction:column;gap:var(--dm-s-3)}
.dm-tcx-calls{display:flex;flex-direction:column;gap:var(--dm-s-2)}
.dm-tcx-end{opacity:0;transition:opacity .6s var(--dm-ease),filter .6s,transform .6s var(--dm-ease);filter:blur(3px);transform:translateY(4px)}
.dm-tcx-end.dm-in{opacity:1;filter:none;transform:none}
.dm-tcx-calls{margin-inline:-8px}
.dm-tcx-h{display:flex;align-items:center;gap:10px;width:100%;padding:6px var(--dm-s-2);border-radius:10px;text-align:left;font-size:13px;transition:background .2s}
@media (hover:hover) and (pointer:fine){.dm-tcx-h:hover{background:var(--dm-row)}}
.dm-tcx-ic{width:24px;height:24px;border-radius:var(--dm-r-sm);background:var(--dm-chip);display:grid;place-items:center;color:var(--dm-fg2);flex:none}
.dm-tcx-n{flex:1;min-width:0;font-family:var(--dm-mono);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dm-tcx-n b{font-weight:500;color:var(--dm-fg)}
.dm-tcx-n span{color:var(--dm-muted)}
.dm-tcx-ms{font-size:12px;color:var(--dm-muted);display:flex;align-items:center;gap:6px}
.dm-tcx-chev{color:var(--dm-muted);transition:transform .45s var(--dm-ease)}
.dm-tcx-c.dm-open .dm-tcx-chev{transform:rotate(180deg)}
.dm-tcx-io{padding:2px var(--dm-s-2) var(--dm-s-2) 42px;display:grid;gap:var(--dm-s-2)}
.dm-tcx-l{font-size:11.5px;color:var(--dm-muted);margin-bottom:var(--dm-s-1)}
.dm-tcx-io pre{margin:0;padding:9px 11px;border-radius:10px;background:var(--dm-chip);font-family:var(--dm-mono);font-size:11.5px;line-height:1.65;white-space:pre-wrap;word-break:break-word}
.dm-tcx-io .dm-k{color:var(--dm-pb)}.dm-tcx-io .dm-s{color:var(--dm-pm)}.dm-tcx-io .dm-n{color:var(--dm-pp)}
tool-call.htmlHTML · 10 lines
<div class="card bare" data-c="tool-call">
  <div class="stage-in">
    <div class="tcx">
      <p class="msg tcx-m1">Checking which plans changed this month before I update the pricing page.</p>
      <div class="tcx-calls"></div>
      <p class="msg tcx-end">Two plans changed. I updated their prices and the yearly saving on the page.</p>
    </div>
  </div>
</div>
tool-call.cssHTML · 20 lines
/* ---------- Tool call ---------- */
.tcx{display:flex;flex-direction:column;gap:var(--s-3)}
.tcx-calls{display:flex;flex-direction:column;gap:var(--s-2)}
.tcx-end{opacity:0;transition:opacity .6s var(--ease),filter .6s,transform .6s var(--ease);filter:blur(3px);transform:translateY(4px)}
.tcx-end.in{opacity:1;filter:none;transform:none}
.tcx-calls{margin-inline:-8px}
.tcx-h{display:flex;align-items:center;gap:10px;width:100%;padding:6px var(--s-2);border-radius:10px;text-align:left;font-size:13px;transition:background .2s}
@media (hover:hover) and (pointer:fine){.tcx-h:hover{background:var(--row)}}
.tcx-ic{width:24px;height:24px;border-radius:var(--r-sm);background:var(--chip);display:grid;place-items:center;color:var(--fg2);flex:none}
.tcx-n{flex:1;min-width:0;font-family:var(--mono);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tcx-n b{font-weight:500;color:var(--fg)}
.tcx-n span{color:var(--muted)}
.tcx-ms{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:6px}
.tcx-chev{color:var(--muted);transition:transform .45s var(--ease)}
.tcx-c.open .tcx-chev{transform:rotate(180deg)}
.tcx-io{padding:2px var(--s-2) var(--s-2) 42px;display:grid;gap:var(--s-2)}
.tcx-l{font-size:11.5px;color:var(--muted);margin-bottom:var(--s-1)}
.tcx-io pre{margin:0;padding:9px 11px;border-radius:10px;background:var(--chip);font-family:var(--mono);font-size:11.5px;line-height:1.65;white-space:pre-wrap;word-break:break-word}
.tcx-io .k{color:var(--pb)}.tcx-io .s{color:var(--pm)}.tcx-io .n{color:var(--pp)}
tool-call.jsHTML · 28 lines
DM.def('tool-call', (r, c) => {
  const calls = [
    { ic:'db', n:'billing', f:'list_plan_changes', ms:'412 ms', inp:'{ <span class="k">"since"</span>: <span class="s">"2026-10-01"</span> }', out:'[ { <span class="k">"plan"</span>: <span class="s">"Pro"</span>, <span class="k">"price"</span>: <span class="n">24</span> },\n  { <span class="k">"plan"</span>: <span class="s">"Team"</span>, <span class="k">"price"</span>: <span class="n">48</span> } ]' },
    { ic:'edit', n:'files', f:'edit', ms:'1.2 s', inp:'{ <span class="k">"path"</span>: <span class="s">"Pricing.tsx"</span>, <span class="k">"lines"</span>: <span class="n">6</span> }', out:'{ <span class="k">"changed"</span>: <span class="n">6</span>, <span class="k">"saved"</span>: <span class="k">true</span> }' }
  ];
  const host = $('.tcx-calls', r);
  calls.forEach(k => {
    k.el = html(`<div class="tcx-c"><button class="tcx-h" aria-expanded="false"><span class="tcx-ic">${I(k.ic,'xs')}</span><span class="tcx-n"><span>${k.n}.</span><b>${k.f}</b></span><span class="tcx-ms"><span class="tt">Queued</span><span class="g"></span></span><svg class="i xs tcx-chev"><use href="#i-chev-d"/></svg></button><div class="fold shut"><div><div class="tcx-io"><div><div class="tcx-l">Input</div><pre>${k.inp}</pre></div><div><div class="tcx-l">Output</div><pre class="out">Waiting…</pre></div></div></div></div></div>`);
    host.appendChild(k.el);
    const b = $('.tcx-h', k.el), f = $('.fold', k.el);
    b.onclick = () => { const s = f.classList.toggle('shut'); k.el.classList.toggle('open', !s); b.setAttribute('aria-expanded', !s); };
  });
  stagger($$('.tcx-c', r), 90, 200);
  c.run(async () => {
    await c.wait(600);
    for (const k of calls){
      $('.g', k.el).innerHTML = '<span class="spin" style="width:10px;height:10px"></span>'; swap($('.tt', k.el), 'Running');
      $('.tcx-ic', k.el).innerHTML = '<span class="radar" style="color:var(--fg)"></span>';
      await c.wait(1300);
      $('.tcx-ic', k.el).innerHTML = I(k.ic,'xs'); $('pre.out', k.el).innerHTML = k.out;
      $('.g', k.el).innerHTML = `<span style="color:var(--ok)">${I('check','xs')}</span>`; swap($('.tt', k.el), k.ms);
    }
    $('.tcx-end', r).classList.add('in');
    // the preview opens the first call to show what came back, unless you have taken over
    if (!c.mine) $('.tcx-h', calls[0].el).click();
  });
});
06

Questions

What people ask about it.

How do I update a call while it runs?
Keep calls in your state and change each call's state from 'queued' to 'running' to 'done' as your agent reports progress. Add output and duration when it finishes.
What can I pass as input and output?
Any JSON value: strings, numbers, booleans, null, arrays and objects. Objects print on one line and each array item gets its own line.
Can I open a call automatically when it finishes?
Yes. Control open from your own state and add the call's key when you mark it done, as the example does with the first call.
07

More in Conversation

Everything for asking, answering and replying, by text or by voice.

Live answerAnswers that stream in with numbered sources and next steps.
ComposerMessage input with attachments, what to work on, a mode and dictation.
Thread panelSwitch between agent threads and reply in place.
Artifact cardA file the agent made, with its versions, Open, Copy and Download.
Rich text editorA minimal editor with an inline AI menu that streams a suggestion to accept or discard.
Voice captureTalk to the agent, then fix what it misheard before sending.
Voice orbA hands-free voice chat, with an orb that shows who is talking.
Answer action barCopy, regenerate, rate and listen to an answer from one row under it.
All 15 in Conversation →