Let the model call your code, and ask a person first
Register a tool the model can call, show each call as it runs, say what it targets, and make a person approve it before it runs.
Register a tool
Section titled “Register a tool”import { aparteGlobalConfig, AparteClient } from '@aparte/core';
import type { AparteTool, AparteToolHandler } from '@aparte/core';
const getTime: AparteTool = {
name: 'get_time',
description: 'Return the current time in a given IANA timezone.',
inputSchema: {
type: 'object',
properties: { timezone: { type: 'string' } },
required: ['timezone'],
},
};
const handleGetTime: AparteToolHandler = async (call) => ({
toolCallId: call.id,
content: new Date().toLocaleString('en-US', { timeZone: String(call.input['timezone']) }),
});
aparteGlobalConfig.registerTool(getTime, handleGetTime);
new AparteClient().start();AparteClient offers every registered tool to the model, runs your handler when the model calls
it, gives the result back, and lets the model go on. inputSchema is a JSON Schema, sent as it is.
The handler gets the call — { id, name, input } — an AbortSignal that fires when the turn is
stopped or the tool runs too long, and a context whose target is the chat the turn belongs to.
It answers { toolCallId, content }: content is what the model reads, and an optional
structuredContent reaches a renderer of yours as
segment.structuredResult, never the model.
A tool’s systemPrompt tells the model when to use it, and its maxTurns is the last turn of a
reply in which it may still run. On the client, toolTimeoutMs is the time a handler may take,
five minutes by default: a call past its turns or its time is stopped, and the reply ends with it.
The client’s maxTurns caps the model’s turns in one reply, ten by default, and a reply that
reaches it ends on an error message. A model whose declared capabilities leave out
function_calling gets no tools; a model that declares nothing gets them.
What the row shows
Section titled “What the row shows”<aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant"></aparte-chat-bubble>
<script>
const call = (id, name, input) => ({ id, name, input });
document.querySelector('aparte-chat-bubble').setSegments([
// Running: the model asked, the handler has not answered yet.
{ id: 's1', type: 'tool_call', status: 'pending', toolCall: call('t1', 'search_docs', { query: 'release checklist' }) },
// Paused for a person (a tool marked needsApproval): the decision is asked at the composer.
{ id: 's2', type: 'tool_call', status: 'awaiting-approval', toolCall: call('t2', 'run_command', { cmd: 'pnpm publish' }) },
{ id: 's3', type: 'tool_call', status: 'resolved', toolCall: call('t3', 'read_file', { path: 'CHANGELOG.md' }), result: '## 0.19.2' },
// Refused, and the row says by whom: a person who wrote something instead of choosing
// (refusal.instruction), a policy (the note says so; the reason went to the model), or the loop, which
// did not run a call because an earlier one of the same step was refused.
{ id: 's4', type: 'tool_call', status: 'rejected', toolCall: call('t4', 'delete_file', { path: 'src/legacy.ts' }),
result: 'The user rejected this tool call and said: archive it instead',
refusal: { by: 'person', instruction: 'archive it instead' } },
{ id: 's5', type: 'tool_call', status: 'rejected', toolCall: call('t5', 'run_command', { cmd: 'rm -rf build' }),
result: 'Deleting is off in this workspace.',
refusal: { by: 'policy' } },
{ id: 's6', type: 'tool_call', status: 'rejected', toolCall: call('t6', 'write_file', { path: 'notes.md' }),
result: 'Tool execution was rejected by the user.',
refusal: { by: 'earlier-refusal' } },
// Stopped, and why. A plain Stop (abortCause 'stopped') has no note.
{ id: 's7', type: 'tool_call', status: 'aborted', toolCall: call('t7', 'fetch_page', { url: 'https://example.com' }), abortCause: 'stopped' },
{ id: 's8', type: 'tool_call', status: 'aborted', toolCall: call('t8', 'crawl_site', { url: 'https://example.com' }), abortCause: 'timeout' },
{ id: 's9', type: 'tool_call', status: 'aborted', toolCall: call('t9', 'send_email', { to: 'a@example.com' }), abortCause: 'no-handler' },
{ id: 's10', type: 'tool_call', status: 'failed', toolCall: call('t10', 'write_file', { path: '/etc/hosts' }), result: 'EACCES: permission denied' },
]);
</script>Each call is one line in the reply, in the order it happened: the tool’s name and its state.
Opened, it shows the arguments the model chose under Input and the result under Output.
A part that is JSON is pretty-printed, and coloured when a highlighter is
registered; any other text stays as it is. Each part has a copy button, and a long one scrolls
inside a box capped by --aparte-tool-part-max-height (300px), a focusable region a keyboard can
scroll. What a tool is called, and whether its row is drawn at all, is yours to say:
Showing tool calls to a person, or to a developer.
| State | Word | Note after the word | When |
|---|---|---|---|
awaiting-approval | Waiting | a person has not decided yet | |
pending | Running | the handler runs, with a spinner | |
resolved | Done | the handler answered | |
rejected | Rejected | You said: “…”, when the person wrote something instead of choosing | a person refused it |
rejected | Rejected | Refused by policy | a policy refused it |
rejected | Rejected | Not run: an earlier call was refused | a paused server or agent run asked about several calls, and an earlier one was refused |
aborted | Stopped | none | the reply was stopped (Stop) |
aborted | Stopped | Timed out | the handler outlived toolTimeoutMs |
aborted | Stopped | Not available here | no handler is registered for the tool |
aborted | Stopped | No one could approve it | the call needs approval and nothing can ask |
aborted | Stopped | Turn limit reached | the tool’s own maxTurns ran out |
failed | Failed | the handler threw |
The note tells the person who refused a call, or why it did not run, in their language. It is
not the sentence the model reads: a refused row shows the note and no Output at all, because
its result is the sentence written for the model, a policy’s reason included (a conversation
saved by an earlier version keeps its Output). On the segment, refusal holds { by, instruction? } (by is
'person', 'policy' or 'earlier-refusal') and abortCause holds 'stopped', 'timeout',
'no-handler', 'no-resolver' or 'turn-limit'; result stays the model’s sentence.
A handler that throws marks its call failed and ends the reply on the error. A failure the model
should read and work around is a result instead: return it as content. The words are locale keys,
and extendLocale changes them:
import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.extendLocale({
toolRunning: 'Working…', toolCompleted: 'Done', toolRejected: 'Refused',
toolStopped: 'Stopped', toolFailed: 'Failed', approvalWaiting: 'Waiting',
toolInput: 'Arguments', toolOutput: 'Result',
// the notes after the word
toolRejectedSaid: 'You answered: “{instruction}”', toolRefusedByPolicy: 'Blocked by a rule',
toolNotRunAfterRefusal: 'Skipped', toolTimedOut: 'Took too long',
toolUnavailable: 'Not available', toolNoApprover: 'Nobody to ask', toolTurnLimit: 'Too many steps',
});The row is .aparte-segment-tool-call: its line .aparte-tool-summary, rounded by
--aparte-tool-row-radius, the word .aparte-tool-state, the note .aparte-tool-note, and the
opened part .aparte-tool-detail. The class reference lists them all.
Show what a call targets
Section titled “Show what a call targets”import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.registerToolSummary('read_file', (segment) =>
String(segment.toolCall.input['path'] ?? ''));<!-- 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;
// One line after the tool's name: what it targeted. Plain text, never HTML; the row,
// its states and its disclosure stay the library's.
aparteGlobalConfig.registerToolSummary('read_file', (segment) => String(segment.toolCall.input.path ?? ''));
aparteGlobalConfig.registerToolSummary('search_docs', (segment) => `“${segment.toolCall.input.query}”`);
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' },
]);
</script>A summary is the line the row writes after the tool’s name: read_file skills.json rather than
read_file alone. It is read again each time the call changes and when the language switches,
and written as text; an empty string, or a summary that throws, shows nothing. A tool drawn by a
renderer of its own shows no summary.
registerToolSummary writes the summary field of the tool’s display, on its own. Its first
argument is a tool name, a RegExp tested against it, or '*' for every tool nothing more
specific describes, so registerToolSummary(/^mcp__/, …) covers every tool an MCP server
brings. registerToolDisplay sets a summary beside a label, an icon and the rest, and
unregisterToolSummary drops only the summary. See
how a display is matched.
Show the arguments as a person reads them
Section titled “Show the arguments as a person reads them”import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.registerToolInputFormatter('write_file', (input) =>
`${String(input['path'] ?? '')}, ${String(input['content'] ?? '').length} characters`);The approval panel shows a call’s arguments under its question, and the row behind its disclosure; both show the JSON the model sent, until the tool has a formatter. Then both show its text, so a person approves the call the row describes. It is written as text; an empty string shows no arguments, and a formatter that throws falls back to the JSON. The panel reads the client’s configuration and the row the chat’s, so register it on the one the chat uses.
Like the summary, it is a field of the tool’s display (formatInput), takes a name, a RegExp
or '*', and unregisterToolInputFormatter drops only that field.
Require approval (human-in-the-loop)
Section titled “Require approval (human-in-the-loop)”import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.registerTool(
{
name: 'delete_file',
description: 'Delete a file from the workspace.',
inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
needsApproval: true,
},
async (call) => ({ toolCallId: call.id, content: `Deleted ${String(call.input['path'])}` }),
);<!-- In your app, AparteClient asks this for any tool registered with needsApproval: true.
Here the request is made by hand: import { requestUserInput } from '@aparte/core'. -->
<aparte-chat style="height: 26rem; --aparte-elic-max-height: 20rem"></aparte-chat>
<script>
const { requestUserInput } = window.aparte;
const chat = document.querySelector('aparte-chat');
chat.viewport.appendMessage({ id: 'u1', role: 'user', content: 'Clean up the old client.', timestamp: Date.now() });
chat.viewport.appendMessage({
id: 'a1', role: 'assistant', timestamp: Date.now(),
segments: [{
id: 's1', type: 'tool_call', status: 'awaiting-approval',
toolCall: { id: 't1', name: 'delete_file', input: { path: 'src/legacy/old-client.ts' } },
}],
});
// The decision is asked at the composer, with the arguments it is about. AparteClient
// runs or refuses the call on the answer; here the answer is played onto the row by hand.
const row = (updates) => chat.viewport.updateSegment('a1', 's1', updates);
const reply = (content) => chat.viewport.addSegment('a1', { id: 's2', type: 'text', content });
requestUserInput({
kind: 'approval',
message: 'Allow delete_file?',
details: JSON.stringify({ path: 'src/legacy/old-client.ts' }, null, 2),
options: [
{ value: 'approve', label: 'Approve' },
{ value: 'reject', label: 'Reject', tone: 'deny' },
],
}).then((answer) => {
const typed = answer.action === 'accept' ? (answer.content.instruction || '').trim() : '';
if (answer.action === 'accept' && !typed && answer.content.option === 'approve') {
row({ status: 'pending' });
setTimeout(() => {
row({ status: 'resolved', result: 'Deleted src/legacy/old-client.ts.' });
reply('Done: the old client is gone.');
}, 900);
} else {
// Typing instead of choosing is a refusal that carries the person's words.
row({
status: 'rejected',
result: 'Tool execution was rejected by the user.',
refusal: typed ? { by: 'person', instruction: typed } : { by: 'person' },
});
reply(typed ? 'Understood, I will do that instead.' : 'Understood, I left it in place.');
}
}).catch(() => {});
</script>With needsApproval, the call waits before its handler runs. The row says which tool waits, and
the decision is asked at the composer: the question, the arguments the model chose, the choices,
and a field to write something else. The panel is <aparte-elicitation>, which <aparte-chat>
and the framework wrappers already hold; a layout you compose yourself has to include it, or, with
no one to ask, the call is stopped. Its words are the locale keys approvalAsk (Allow {tool}?),
approveTool, rejectTool and approvalInstructionPlaceholder.
The question names the call the way its row does: {tool} is the row’s label (else the tool’s
identifier), then what the summary says it targets. With a label and a summary it can read “Allow
Delete a file a.ts?”. The question belongs to the built-in approval channel, AparteClient’s; an
app with its own loop writes its own. approvalAsk is the lever for the wording of this one:
extendLocale({ approvalAsk: 'Run {tool}?' }). The arguments block under the question is
unchanged.
- Approve runs the handler.
- Reject never runs it: the model reads the refusal, the other calls of that turn are skipped, and the model gets a turn to answer it.
- Writing instead of choosing is a refusal that carries your words, which the model reads and
the row quotes back to the person (
You said: “…”) — except when an AG-UI agent paused on its own tool (an interrupt), which gets only{ approved: false }. - Stop ends the reply, and the call shows Stopped; the model is told nothing.
Each time a call starts to wait, the chat sends aparte-tool-approval-request with
{ toolCallId, toolName, input }, for a notification of yours. When the loop runs on your server,
the same panel answers its tools, even after a reload:
approvals across the network.
Decide in code
Section titled “Decide in code”import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.setApprovalPolicy((call) => {
if (call.name === 'run_command' && String(call.input['cmd']).startsWith('rm '))
return { verdict: 'deny', reason: 'Deleting is off in this workspace.' };
if (call.name.startsWith('read_')) return { verdict: 'allow' };
return undefined; // the tool's own needsApproval decides
});A policy rules on each call before anyone is asked: allow runs it, ask asks the person as
needsApproval would, and deny refuses it, with the reason the model reads; the row says
Refused by policy; the person does not read the reason, the model does. The modes people
know from agent apps — plan, ask, auto-edit, auto — with a switch in the composer’s toolbar, are
@aparte/plugin-approval.
To answer with no panel at all — from a command line, a webhook, a team channel — give the client
an approvalResolver. It answers every call of a needsApproval tool, and a policy set with
setApprovalPolicy, the approval modes included, is then not consulted: rule on the call in the
resolver, or use a policy, not both.
import { AparteClient } from '@aparte/core';
declare function askTheTeam(name: string, input: Record<string, unknown>, signal: AbortSignal): Promise<boolean>;
new AparteClient({
approvalResolver: async (call, signal) => ({
approved: await askTheTeam(call.name, call.input, signal),
instruction: 'Use the staging bucket instead.', // read by the model on a refusal
by: 'person', // the row quotes the instruction back: You said: “…”
}),
}).start();A resolver may also answer a payload, an object merged into the arguments before the handler
runs, to correct a path while approving; the row still shows what the model sent. On a refusal it
may answer by: 'policy' when a rule refused the call and not a person, so the row says
Refused by policy and not You said: …; without by, a reason is read as a policy’s and an
instruction as the person’s. So an answer that carries a reason of its own and no by shows
Refused by policy: say by: 'person' when a person gave it.
Custom tool renderer
Section titled “Custom tool renderer”import { aparteGlobalConfig } from '@aparte/core';
import type { AparteToolRenderer } from '@aparte/core';
const searchRenderer: AparteToolRenderer = {
render(segment) {
const el = document.createElement('div');
el.className = 'my-search';
el.textContent = `Searching for ${String(segment.toolCall.input['query'] ?? '')}`;
return el;
},
update(el, segment) {
if (segment.status === 'awaiting-approval') el.textContent = 'Waiting for your approval';
else if (segment.status === 'pending') el.textContent = 'Searching…';
else if (segment.status === 'resolved') el.textContent = segment.result ?? '';
else el.textContent = `Search ${segment.status}`; // rejected, aborted, failed
},
};
aparteGlobalConfig.registerToolRenderer('web_search', searchRenderer);<!-- 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 get_weather call is drawn by this instead of the default row. Returning an
// element (not a string) means the model's data never goes through innerHTML.
aparteGlobalConfig.registerToolRenderer('get_weather', {
render(segment) {
const card = document.createElement('div');
card.className = 'aparte-card';
const body = document.createElement('div');
body.className = 'aparte-card__body';
body.textContent = segment.status === 'resolved'
? `${segment.toolCall.input.city}: ${segment.result}`
: `Checking the weather in ${segment.toolCall.input.city}…`;
card.append(body);
return card;
},
});
document.querySelector('aparte-chat-bubble').setSegments([
{ id: 's1', type: 'tool_call', status: 'resolved', toolCall: { id: 't1', name: 'get_weather', input: { city: 'Lille' } }, result: 'cloudy, 14 °C' },
]);
</script>A renderer draws every call of one tool in place of the row, in every state: render when the
call appears, update when it changes — a decision is made, its result lands — and relabel when
the language or the icons change. Without update, the call is drawn again from render at each
change. Read segment.status, as above: the renderer shows the wait for approval itself, and a
refused or failed call carries the refusal or the error in segment.result.
Return one element or a string of markup. An empty string returned as the call starts keeps it out of the reply; returned at any other time — a later change, a stored conversation drawn again — it brings back the default row.
The arguments and the result come from the model and the tool: never write them into markup as
they are. Set them as textContent, as above, or pass them through escapeHtml and escapeAttr.
A result meant to be shown as a thing — a document, a form, a receipt — comes as the handler’s
structuredContent, which the renderer reads as segment.structuredResult; the
question and artifact plugins draw their cards so.
A renderer is shown a refused or stopped call too, with the reason on the segment: refusal
({ by, instruction? }) and abortCause. Read them to say it in your own words:
import { aparteGlobalConfig } from '@aparte/core';
import type { AparteToolCallSegment } from '@aparte/core';
function whyNot(segment: AparteToolCallSegment): string {
if (segment.refusal?.by === 'person')
return segment.refusal.instruction ? `You said: ${segment.refusal.instruction}` : 'You refused it';
if (segment.refusal?.by === 'policy') return 'A rule blocked it';
if (segment.refusal?.by === 'earlier-refusal') return 'Skipped after a refusal';
if (segment.abortCause === 'timeout') return 'It took too long';
return 'It did not run';
}
aparteGlobalConfig.registerToolRenderer('send_email', {
render(segment) {
const el = document.createElement('div');
el.textContent = segment.status === 'rejected' || segment.status === 'aborted'
? `Email not sent. ${whyNot(segment)}`
: `Email to ${String(segment.toolCall.input['to'] ?? '')}`;
return el;
},
});A message clips what its parts draw outside it, so a renderer’s menu or popover goes on
document.body, placed from its trigger’s position:
function openMenu(trigger: HTMLElement): HTMLElement {
const menu = document.createElement('div');
menu.className = 'aparte-menu';
const r = trigger.getBoundingClientRect();
menu.style.cssText = `position: fixed; left: ${r.left}px; top: ${r.bottom}px;`;
document.body.append(menu);
return menu;
}