Showing tool calls to a person, or to a developer
Name your tools in words, give them icons, hide the calls a consumer chat does not need, or open every row with raw input and output for a developer — with registerToolDisplay.
A tool call is drawn as a row that names the tool by its identifier, web_search or
delete_file, because that is all core knows: the identifier is your wire format, and core never
turns it into words. Who reads the row decides what it should say. A person asking a question wants
“Searching the web”; a developer watching an agent wants the raw input and output, open. Both are
one call, registerToolDisplay(match, display).
import { aparteGlobalConfig } from '@aparte/core';
import type { AparteToolDisplay } from '@aparte/core';
const display: AparteToolDisplay = {
label: 'Web search', // in place of web_search
icon: 'search', // an icon-provider key
summary: (segment) => String(segment.toolCall.input['query'] ?? ''),
formatInput: (input) => String(input['query'] ?? ''),
hidden: false, // true draws nothing
open: false, // true opens the row
};
aparteGlobalConfig.registerToolDisplay('web_search', display);Every field is optional, and what you leave out keeps core’s row.
A consumer chat
Section titled “A consumer chat”The person asked a question; what matters is the answer. Hide every call by default, then describe the few that are worth a line.
import { aparteGlobalConfig } from '@aparte/core';
import { searchIcon, globeIcon } from '@aparte/core/icons';
aparteGlobalConfig.registerIcons({ search: () => searchIcon, globe: () => globeIcon });
// Every tool nothing more specific describes: no row. A tool with an entry of its own
// (below) is described whole by it, so it is not hidden.
aparteGlobalConfig.registerToolDisplay('*', { hidden: true });
aparteGlobalConfig.registerToolDisplay('web_search', {
label: 'Searching the web',
icon: 'search',
summary: (segment) => `“${String(segment.toolCall.input['query'] ?? '')}”`,
});
aparteGlobalConfig.registerToolDisplay('fetch_page', {
label: 'Reading a page',
icon: 'globe',
summary: (segment) => String(segment.toolCall.input['url'] ?? ''),
});<!-- In your app: import { aparteGlobalConfig } from '@aparte/core'. -->
<aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant"></aparte-chat-bubble>
<script>
const { aparteGlobalConfig } = window.aparte;
// An icon is a key of the icon provider. 'search' ships in '@aparte/core/icons'; here a
// glyph of our own stands in for it.
aparteGlobalConfig.registerIcons({
search: () => '<svg class="aparte-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="10.5" cy="10.5" r="7"/><path d="m16 16 5.5 5.5"/></svg>',
});
// A label replaces the identifier; the identifier stays on the row as data-tool-name.
aparteGlobalConfig.registerToolDisplay('web_search', {
label: 'Searching the web',
icon: 'search',
summary: (segment) => `“${segment.toolCall.input.query}”`,
});
aparteGlobalConfig.registerToolDisplay('read_file', {
label: 'Reading a file',
icon: 'file',
summary: (segment) => String(segment.toolCall.input.path ?? ''),
});
// A label can read the call: the same tool, said differently once it is done.
aparteGlobalConfig.registerToolDisplay('delete_file', {
label: (segment) => (segment.status === 'resolved' ? 'Deleted a file' : 'Delete a file'),
icon: 'trash',
summary: (segment) => String(segment.toolCall.input.path ?? ''),
});
document.querySelector('aparte-chat-bubble').setSegments([
{ id: 's1', type: 'tool_call', status: 'resolved', toolCall: { id: 't1', name: 'web_search', input: { query: 'release checklist' } }, result: '3 pages' },
{ id: 's2', type: 'tool_call', status: 'resolved', toolCall: { id: 't2', name: 'read_file', input: { path: 'docs/release.md' } }, result: '## Release' },
{ id: 's3', type: 'tool_call', status: 'resolved', toolCall: { id: 't3', name: 'delete_file', input: { path: 'docs/draft.md' } }, result: 'Deleted' },
]);
</script>What the person sees:
- A label in the chat’s own font, in place of the identifier: “Searching the web”. The
identifier stays on the row as
data-tool-name, for a stylesheet or a test. - An icon from the icon provider.
file,editandterminalare core’s;searchandglobeare in@aparte/core/icons, and are drawn once you register them as above. A key nobody registered falls back totool. - A summary, the line after the label, as before.
- No row for every other tool: they fall to the
'*'entry. The two described tools are not hidden although they never say so, because an entry is used whole (see how a display is matched). A turn made only of hidden calls shows no header either, just the reply.
<!-- In your app: import { aparteGlobalConfig } from '@aparte/core'. -->
<aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant"></aparte-chat-bubble>
<script>
const { aparteGlobalConfig } = window.aparte;
// Every tool nothing more specific describes: no row. The calls stay in the message, in
// storage and in the history the model reads; an approval they need is still asked.
aparteGlobalConfig.registerToolDisplay('*', { hidden: true });
document.querySelector('aparte-chat-bubble').setSegments([
{ id: 's1', type: 'tool_call', status: 'resolved', toolCall: { id: 't1', name: 'search_docs', input: { query: 'release checklist' } }, result: '3 pages' },
{ id: 's2', type: 'tool_call', status: 'resolved', toolCall: { id: 't2', name: 'read_file', input: { path: 'docs/release.md' } }, result: '## Release' },
{ id: 's3', type: 'text', content: 'The release needs three steps: bump the version, run the gate, then tag it.' },
]);
</script>Hiding is a matter of the screen only. A hidden call stays in the message, in storage, and in the
history the model reads on its next turns; a reload draws it hidden again. An approval it needs is
still asked, and the question names it by its label and summary, so the person is never asked about
something they cannot see described. The default question is Allow {tool}?, so “Allow Searching the web?”; it is the
built-in approval channel’s (AparteClient’s), and an app with its own loop writes its own.
extendLocale({ approvalAsk: … }) changes the wording.
Registering a display notifies the chats on the page. One that changes a label, an icon or a
summary relabels the rows already drawn, keeping what the reader opened; one that sets or drops
hidden or open redraws the messages already on screen. A label can be a function of the segment, for a label that depends
on the call:
import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.registerToolDisplay('delete_file', {
label: (segment) => (segment.status === 'resolved' ? 'Deleted a file' : 'Delete a file'),
});A label that throws, or is empty, falls back to the identifier.
A developer’s view
Section titled “A developer’s view”The reader wants to see what the agent did: every call, open, with the input and output, and how long it took.
import { aparteGlobalConfig, isSegmentSettled, segmentDuration } from '@aparte/core';
import type { AparteToolSummary } from '@aparte/core';
// Every row arrives open in a chat (the default is written onto each segment as it enters it).
// A row drawn by hand, as in the demo below, takes `open` from a display instead.
aparteGlobalConfig.setSegmentDefaults('tool_call', { collapsed: false });
// How long each call took, on the row's line.
const took: AparteToolSummary = (segment) => {
const ms = segmentDuration(segment);
return isSegmentSettled(segment) && ms !== undefined ? `${(ms / 1000).toFixed(1)}s` : '';
};
aparteGlobalConfig.registerToolDisplay('*', { summary: took });
// Or open only the tools you are debugging. An entry is used whole, so it repeats the summary.
aparteGlobalConfig.registerToolDisplay('run_command', { open: true, summary: took });<!-- In your app: import { aparteGlobalConfig } from '@aparte/core'. -->
<aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant"></aparte-chat-bubble>
<script>
const { aparteGlobalConfig } = window.aparte;
// Every tool row arrives open: Input and Output on screen, each with a copy button. A display's
// open is read when the row is drawn; in a chat, setSegmentDefaults('tool_call', { collapsed: false })
// does the same for every row (a bare bubble like this one never applies a segment default).
aparteGlobalConfig.registerToolDisplay('*', { open: true });
document.querySelector('aparte-chat-bubble').setSegments([
{ id: 's1', type: 'tool_call', status: 'resolved', toolCall: { id: 't1', name: 'search_docs', input: { query: 'release checklist', limit: 3 } }, result: JSON.stringify({ hits: [{ title: 'Release', score: 0.92 }, { title: 'Changelog', score: 0.71 }] }) },
{ id: 's2', type: 'tool_call', status: 'resolved', toolCall: { id: 't2', name: 'run_command', input: { cmd: 'git status' } }, result: 'On branch main\nnothing to commit, working tree clean' },
]);
</script>An opened row shows the arguments under Input and the result under Output, as the model
and the tool wrote them (unless the tool has a formatInput, which then writes the Input): JSON is
pretty-printed, and coloured when a highlighter is registered; anything else
stays as it is. Each
part has a copy button, and a part longer than --aparte-tool-part-max-height (300px) scrolls in
its own box, on the code surface at the small size. A refused call shows who refused it, as a
note after the state word, and no Output.
A row opens in this order: the segment’s own collapsed — which is where a setSegmentDefaults
default lands, written onto the segment when it enters the message — else the display’s open,
else closed. So a segment default wins over a display’s open. The choice is read when the row is
drawn; an update never forces it, so a row the reader closed stays closed. The duration comes from the timing every
segment carries: Durations explains it, and core draws
none for a tool.
A summary registered on '*' is used for every tool that has no entry of its own. A tool with
its own entry gets that entry whole, and not the '*' one: add the summary there too, or register
by pattern, below.
How a display is matched
Section titled “How a display is matched”match is one of three things:
- an exact tool name,
'web_search'; - a
RegExp, tested against the name withname.search(re), so/^mcp__/covers every tool an MCP server brings and agflag is harmless; '*', every tool nothing more specific describes.
import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.registerToolDisplay('*', { icon: 'tool' });
aparteGlobalConfig.registerToolDisplay(/^mcp__/, { icon: 'terminal', label: 'MCP tool' });
aparteGlobalConfig.registerToolDisplay('mcp__github__search', { label: 'GitHub search' });
aparteGlobalConfig.getToolDisplay('mcp__github__search'); // the exact entry: { label }
aparteGlobalConfig.getToolDisplay('mcp__slack__post'); // the pattern entry: { icon, label }
aparteGlobalConfig.getToolDisplay('read_file'); // the '*' entry: { icon }Three rules:
- Exact name, else the first pattern registered that matches, else
'*'. Patterns are tried in the order you registered them. - One entry per matcher, and calls merge into it. Registering
'web_search'twice, once with a label and once with an icon, leaves one entry with both. - The entry that applies is used whole, never mixed.
'mcp__github__search'above gets its label and nothing from the pattern’s entry, not even the icon. Repeat what it should keep.
unregisterToolDisplay(match) drops an entry:
import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.registerToolDisplay('web_search', { label: 'Searching the web' });
aparteGlobalConfig.unregisterToolDisplay('web_search'); // back to web_searchregisterToolSummary and
registerToolInputFormatter are shorthands that write the summary and formatInput fields of
the same entry, with the same matchers, and their unregister… twins drop only that field.
registerToolRenderer and unregisterToolRenderer take the same matchers too, and '*' there is
one renderer for every tool without its own.
Hide, or draw something else
Section titled “Hide, or draw something else”hidden: true and a tool renderer that returns '' do the same thing: an empty placeholder
stands in for the call, which stays in the message and the history. Use hidden when the point is
to draw nothing; use a renderer when you draw something
else, a card or a receipt.
When both exist for one tool, the more specific one wins, and isToolHidden(name) answers which:
import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.registerToolDisplay('*', { hidden: true });
aparteGlobalConfig.registerToolRenderer('ask_user', {
render: () => 'Question asked', // a receipt, drawn by a plugin in practice
});
aparteGlobalConfig.isToolHidden('web_search'); // true: the '*' hide applies
aparteGlobalConfig.isToolHidden('ask_user'); // false: the renderer is more specific than '*'A '*' hide therefore leaves a tool with a renderer of its own drawn, such as
ask_user’s receipt. A hide as specific as the renderer wins:
registerToolDisplay('ask_user', { hidden: true }) hides it, and that is what receipt: false on
setupAskUser() does.
On a narrow screen
Section titled “On a narrow screen”The row keeps its state word whatever the width. The note and the target give way first, cut
with an ellipsis, and the note carries its full text as a title, so a hover still reads it.