ConversationTool call
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callTool 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add tool-callReact
npx designme add tool-call --reactThe 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.
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.
- Each call has a
state: queued waits, running shows a spinner and "Running", and done shows a check with theduration. - The call's name is shown with its
serveror namespace in front, like billing.list_plan_changes, plus an optionalicon. - Clicking a call folds open its Input and Output, printed as JSON with keys, strings and numbers in their own colours. Output reads "Waiting…" until the call is done.
- Which calls are open can be left to the calls themselves or controlled with
open,defaultOpenandonOpenChange. replyis laid out from the start and fades in a frame after every call is done, so nothing below it jumps.
- Each call's header is a button with
aria-expandedshowing whether its input and output are open. - Closed input and output panels are
inertandaria-hidden, and the reply staysaria-hiddenuntil every call is done. - The spinner and status icons are decorative and hidden from screen readers.
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>
);
}
Props
Everything ToolCall takes.
| Prop | Type | What it does |
|---|---|---|
message | ReactNode | The agent's message before the calls |
callsrequired | ToolCallItem[] | |
reply | ReactNode | The agent's answer after the calls. It fades in once every call is done |
open | string[] | Keys of the calls that are open. Leave it out to let each call toggle itself |
defaultOpen | string[] | |
onOpenChange | (open: string[]) => void | |
className | string |
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();
});
});
Questions
What people ask about it.
- How do I update a call while it runs?
- Keep
callsin your state and change each call'sstatefrom 'queued' to 'running' to 'done' as your agent reports progress. Addoutputanddurationwhen 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
openfrom your own state and add the call'skeywhen you mark it done, as the example does with the first call.
More in Conversation
Everything for asking, answering and replying, by text or by voice.