Skip to content

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.

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);
  • inputSchema is a plain JSON Schema object, sent to the model as-is.
  • The handler receives an AparteToolCall ({ id, name, input }) and an AbortSignal (fires on a timeout or a stream abort), and must resolve an AparteToolResult ({ 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 global maxTurns for this tool only.

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:

  1. A tool_call segment is added (status: 'pending') — the built-in renderer shows a row with the tool name and a spinner.
  2. The client resolves the handler via aparteGlobalConfig.getToolHandler(name), runs it, and on resolve flips the segment to status: 'resolved'.
  3. The tool_call and its result are appended to history and the provider is re-called automatically, so the model sees the outcome and continues.
  4. 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.

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:

ClassThe part
.aparte-tool-summarythe clickable line
.aparte-tool-togglethe chevron
.aparte-tool-labelthe call’s identity — holds .aparte-tool-icon and .aparte-tool-name
.aparte-tool-spinnershown only while pending
.aparte-tool-statethe state word, pushed to the far end
.aparte-tool-detailthe opened body
.aparte-tool-partone 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.

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.

Width Open in a tab
A tool call in `awaiting-approval`: the turn is paused and nothing has run yet.
<!-- 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 aborted and 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_result the 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.

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.

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.

  • Customization — render hooks and the action registry for everything outside tool segments.
  • The agent engine — the headless runStreamAgent loop, for running this same tool + approval flow off the main thread or on a server.