Tool calls with human approval — human-in-the-loop UI
A tool is a function the model can ask to run — read a file, hit an API, delete
something. Register a definition plus a handler and AparteClient does the rest: it
offers the tool to the model, runs your handler when it’s called, feeds the result back,
and renders the call as a row you can open. For anything sensitive, one flag makes the model
wait for a human to click Approve before your handler ever runs. Why the row shows what
it shows is its own page: What a tool-call UI has to show.
Define and register a tool
Section titled “Define and register a tool”A tool is a plain AparteTool object plus an AparteToolHandler, registered together
with aparteGlobalConfig.registerTool:
import { aparteGlobalConfig } from '@aparte/core';import type { AparteTool, AparteToolHandler } from '@aparte/core';
const getTimeTool: AparteTool = { name: 'get_time', description: 'Return the current time in a given IANA timezone.', inputSchema: { type: 'object', properties: { timezone: { type: 'string' } }, required: ['timezone'], },};
const getTimeHandler: AparteToolHandler = async (call) => ({ toolCallId: call.id, content: new Date().toLocaleString('en-US', { timeZone: call.input.timezone as string }),});
aparteGlobalConfig.registerTool(getTimeTool, getTimeHandler);inputSchemais a plain JSON Schema object, sent to the model as-is.- The handler receives an
AparteToolCall({ id, name, input }) and anAbortSignal(fires on a timeout or a stream abort), and must resolve anAparteToolResult({ toolCallId, content }). systemPrompt?on the tool is injected automatically once registered — tell the model when to use it without touching your main prompt.maxTurns?overrides the client’s globalmaxTurnsfor this tool only.
The model → tool_call → result loop
Section titled “The model → tool_call → result loop”Register the tool, register the default renderers, and start a client:
import { registerDefaultRenderers, AparteClient } from '@aparte/core';
registerDefaultRenderers();new AparteClient().start();AparteClient sends every registered tool with the request. The one case where it does
not is a model that declares its capabilities and leaves function_calling out — a
statement the client respects. A model that says nothing (which is what a
GET /models listing usually amounts to) gets the tools: registering one is an explicit
act, and dropping it silently because a listing is terse would turn your registration
into a no-op with nothing to read anywhere. When the model calls one:
- A
tool_callsegment is added (status: 'pending') — the built-in renderer shows a row with the tool name and a spinner. - The client resolves the handler via
aparteGlobalConfig.getToolHandler(name), runs it, and on resolve flips the segment tostatus: 'resolved'. - The
tool_calland its result are appended to history and the provider is re-called automatically, so the model sees the outcome and continues. - If
maxTurns(per-tool or global) is hit first, the segment becomes'aborted'.
AparteToolCallSegment.status is one of
'pending' | 'resolved' | 'aborted' | 'awaiting-approval' | 'rejected' — the last two
only apply to approval-gated tools.
What the row shows
Section titled “What the row shows”One line per call: the tool’s name, a spinner while it runs, and the state as a word at
the far end — Running, Done, Rejected, Stopped. When the call has arguments or a
result, that line becomes a disclosure, and opening it shows both — the arguments the
model chose under Input, pretty-printed, and whatever your handler returned under
Output. A registered highlight provider colours them; without one
they are escaped text, because a tool’s arguments are model-authored and are never
injected as HTML.
It opens on a click and never on its own, including while a decision is pending. The reasoning block stays closed while it is being produced, which is the most live moment there is, so a tool call has no stronger claim to unroll itself.
Every word is a locale key:
import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.extendLocale({ toolInput: 'Arguments', toolOutput: 'Result', toolRunning: 'Working…', toolCompleted: 'Done', toolRejected: 'Refused', toolStopped: 'Stopped',});And every part is a class, so restyling needs no renderer:
| Class | The part |
|---|---|
.aparte-tool-summary | the clickable line |
.aparte-tool-toggle | the chevron |
.aparte-tool-label | the call’s identity — holds .aparte-tool-icon and .aparte-tool-name |
.aparte-tool-spinner | shown only while pending |
.aparte-tool-state | the state word, pushed to the far end |
.aparte-tool-detail | the opened body |
.aparte-tool-part | one of Input / Output — holds .aparte-tool-part-label and .aparte-tool-part-body |
:root { --aparte-tool-row-radius: 0; } /* the row's corner */
.aparte-tool-summary:hover { background: none; }.aparte-tool-state { font-variant: small-caps; }Replacing the markup outright is a custom tool renderer instead.
Require approval (human-in-the-loop)
Section titled “Require approval (human-in-the-loop)”What the reader sees when the loop stops. The row is the anchor — it opens onto the arguments the model sent — and the decision itself is asked at the composer.
<!-- A tool marked `needsApproval` stops the turn BEFORE its handler runs. Core draws the pause: the row is the anchor, and the decision is asked at the composer. --><aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant"></aparte-chat-bubble>
<script> document.querySelector('aparte-chat-bubble').setSegments([ { id: 's1', type: 'tool_call', status: 'awaiting-approval', toolCall: { id: 't1', name: 'delete_file', input: { path: 'src/legacy/old-client.ts' } }, }, ]); // Open the row the way a reader deciding would: the argument the decision is about // (which file) is what the demo is here to show. document.querySelector('details')?.setAttribute('open', '');</script>Set needsApproval: true on the tool:
const deleteFilesTool: AparteTool = { name: 'delete_files', description: 'Delete a file from the workspace. Destructive — always ask first.', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], }, needsApproval: true,};
aparteGlobalConfig.registerTool(deleteFilesTool, async (call) => { // ... actually delete call.input.path ... return { toolCallId: call.id, content: `Deleted ${call.input.path}` };});Before running the handler, AparteClient flips the segment to
status: 'awaiting-approval' and asks at the composer — the same place every other
request for the user is answered, through the same requestUserInput a tool handler
calls. The panel carries the question, the call’s arguments under it, the choices and
a free-text field; the row in the transcript is the anchor, saying which tool is
waiting, and holds nothing clickable.
The arguments are on the panel because they are what is being approved — the same text the row shows, from the same function, so the two surfaces cannot disagree about what is about to run. The panel is capped at half the viewport and the block inside it scrolls, so a diff or a plan does not push the buttons off the surface they are asked on; the full version stays in the thread, which is already scrollable, copyable and persisted.
Set details on your own requestUserInput({ kind: 'approval' }) call to put text
there — it is rendered through textContent, never as markup, so a model-chosen path
cannot become markup on the surface someone clicks Approve on:
import { aparteGlobalConfig } from '@aparte/core';
const answer = await aparteGlobalConfig.requestUserInput({ kind: 'approval', message: 'Run delete_file?', // The call being approved, shown under the question. Text, never markup. details: JSON.stringify({ path: 'src/index.ts' }, null, 2), options: [ { value: 'allow', label: 'Approve', tone: 'affirm' }, { value: 'deny', label: 'Reject', tone: 'deny' }, ],});The complete example below passes it the same way.
It also dispatches aparte-tool-approval-request on the target element
(detail: { toolCallId, toolName, input }) — observation only, for an app that wants to
raise an OS notification when a gate opens.
- On reject, the handler never runs. A synthetic “rejected by user” result is fed back, the turn’s remaining tool calls are skipped — the model may have asked for several, and refusing one cannot license the others — and then the model is given another turn, so it actually reads the refusal and can answer it. It could not before: the turn simply ended there, and telling the assistant what you wanted instead meant retyping it as a new message it then read out of order.
- A stop is not a reject. Pressing Stop while a tool waits for approval marks the
segment
abortedand appends nothing: there is nothing true to tell the model. The two used to be indistinguishable, so a stopped turn was reported as a refusal. - On approve, the handler runs with the original input, unless the decision carries a
plain-object
payload, which is merged onto the input first — so a custom approval surface can edit the arguments (fix a path, tighten a query) before the tool runs. The built-in panel sends no payload. - Typing instead of choosing is a refusal that carries your words: the instruction
becomes the
tool_resultthe model reads on the turn it gets back. That is only useful because a refusal hands the model a turn — before, whatever you wrote had nowhere to go.
To drive approval from something with no DOM — a CLI, a webhook, an ops channel — or to
decide without asking at all, pass an approvalResolver in AparteClientOptions. It
replaces the panel entirely:
new AparteClient({ // The whole CALL, not just its id: you cannot ask "run this?" without naming what. approvalResolver: async (call, signal) => ({ approved: await confirmWithOpsTeam(call.name, call.input, signal), // Optional, on a refusal: the words the model reads back. instruction: 'use the staging bucket instead', }),}).start();An “auto” mode is this and nothing more: a resolver that answers without asking anybody.
A policy, per call — modes
Section titled “A policy, per call — modes”needsApproval is a declaration about a tool; a mode — plan (read-only), ask,
auto-edit, auto — is a decision about each call, and the same run_command can be
a read or an execution. That decision has a seam of its own: an AparteApprovalPolicy
registered with setApprovalPolicy() rules on every call, before the panel is involved.
aparteGlobalConfig.setApprovalPolicy((call, tool) => { 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; // no opinion: the tool's own flag decides});The ruling is an AparteApprovalRuling: allow runs without asking and never pauses the
row; ask puts the call to the person exactly as a needsApproval tool is; deny refuses
it, and its reason is what the model reads — verbatim, not “the user rejected this”,
because nobody did. getApprovalPolicy() reads it back and ruleOnToolCall(call) is the
one place the policy and the flag are combined. A host’s own approvalResolver is not
affected: it already owns the decision. The four modes, a read/write/exec classification of
your tool names and a switch for the composer’s toolbar are
@aparte/plugin-approval.
Custom tool renderer
Section titled “Custom tool renderer”Replace the generic row for a specific tool name with registerToolRenderer. render
returns either an HTML string or a ready DOM element ('' renders nothing — e.g. a
UI-only tool); setup runs once after injection for listeners; getStyles is injected
into document.head once per tool. For a needsApproval tool this only takes over
after approval:
import { aparteGlobalConfig } from '@aparte/core';import type { AparteToolRenderer } from '@aparte/core';
const webSearchRenderer: AparteToolRenderer = { render: (segment) => `<div class="aparte-tool-label">Searching the web…</div>`, setup: (element, segment) => { /* wire listeners after injection, if any */ },};
aparteGlobalConfig.registerToolRenderer('web_search', webSearchRenderer);A call changes several times in one turn — its result lands, a decision is made, it
fails — and on each change core rebuilds your markup from render() unless you declare
update, which patches the element in place instead. Rebuilding is right for a
receipt and wrong for anything with state: a mounted preview, an opened disclosure or a
focused control is lost in a rebuild. relabel is called on every config change
(setLocale, setIconProvider, reset()) so the strings you drew follow the locale;
replace text and glyphs, add or remove no node. Both are the contracts a segment
renderer already has, so one object can serve as both:
import { aparteGlobalConfig } from '@aparte/core';import type { AparteToolRenderer } from '@aparte/core';
const reportRenderer: AparteToolRenderer = { render: (segment) => { const el = document.createElement('div'); el.className = 'my-report'; el.dataset['status'] = segment.status ?? 'pending'; return el; }, // The result landed: mark it, keep the element. update: (element, segment) => { element.dataset['status'] = segment.status ?? 'pending'; if (segment.result) element.textContent = segment.result; }, relabel: (element) => { element.setAttribute('aria-label', aparteGlobalConfig.t('toolOutput')); },};
aparteGlobalConfig.registerToolRenderer('build_report', reportRenderer);Complete example: approve/reject with no backend
Section titled “Complete example: approve/reject with no backend”This runs with no model and no API key — it drives the viewport the same way
AparteClient would, so you can see the whole mechanic. Adapted from
apps/examples/vanilla-dist:
import '@aparte/core';import '@aparte/core/styles.css';import { registerDefaultRenderers, aparteGlobalConfig } from '@aparte/core';
registerDefaultRenderers();
const chat = document.querySelector('aparte-chat')!;const vp = () => (chat as any).viewport;
let n = 0;
function reply(text: string) { // `status` is part of the shape, not decoration: a finished assistant turn says so. vp().appendMessage({ id: `a-${++n}`, role: 'assistant', content: text, timestamp: Date.now(), status: 'completed' });}
// Human-in-the-loop with no client and no loop: the row is the anchor in the// transcript, and `requestUserInput` asks at the composer. This is the same function// the built-in gate calls, so a page and a real agent loop ask identically.async function askApproval() { const id = `a-${++n}`; const segId = `seg-${n}`; vp().appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now(), status: 'streaming' }); vp().addSegment(id, { id: segId, type: 'tool_call', status: 'awaiting-approval', toolCall: { id: `tc-${n}`, name: 'delete_files', input: { path: '~/notes/todo.md' } }, });
try { const answer = await aparteGlobalConfig.requestUserInput({ kind: 'approval', message: 'Run delete_files?', // What is being approved, under the question. Rendered through `textContent`, // never as markup — a model-chosen path is not trusted here either. details: JSON.stringify({ path: '~/notes/todo.md' }, null, 2), // The options are YOURS. Core cannot write "and always for this tool" or know // that your app has somewhere to remember it. options: [ { value: 'allow', label: 'Approve', tone: 'affirm' }, { value: 'deny', label: 'Reject', tone: 'deny' }, ], }); const picked = answer.action === 'accept' ? (answer.content as { option?: string; instruction?: string }) : {}; const approved = !picked.instruction && picked.option === 'allow'; vp().updateSegment(id, segId, { status: approved ? 'resolved' : 'rejected' }); reply(approved ? 'Approved — the file would be deleted here.' : picked.instruction ? `Understood: ${picked.instruction}` : 'Rejected — nothing happened.'); } catch { // It ended without an answer: a stopped turn, or nothing mounted to ask it. vp().updateSegment(id, segId, { status: 'aborted' }); }}
chat.addEventListener('aparte-send', (e) => { const text = (e as CustomEvent).detail.content as string; vp().appendMessage({ id: `u-${++n}`, role: 'user', content: text, timestamp: Date.now() }); if (text.trim().toLowerCase().includes('delete')) askApproval(); else reply(`You said: "${text}". Type "delete" to see a human-in-the-loop tool approval.`);});Type a message containing “delete” and the row appears in the transcript while the
choices appear in the composer — the same panel AparteClient raises. Swap
the manual addSegment call for a registered delete_files tool (needsApproval: true)
plus a started AparteClient, and a real model drives the exact same segment and events.
Next steps
Section titled “Next steps”- Customization — render hooks and the action registry for everything outside tool segments.
- The agent engine — the headless
runStreamAgentloop, for running this same tool + approval flow off the main thread or on a server.