Skip to content

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.

On this page
ts
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.

Open
A tool call in each of its states.

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.

StateWordNote after the wordWhen
awaiting-approvalWaitinga person has not decided yet
pendingRunningthe handler runs, with a spinner
resolvedDonethe handler answered
rejectedRejectedYou said: “…”, when the person wrote something instead of choosinga person refused it
rejectedRejectedRefused by policya policy refused it
rejectedRejectedNot run: an earlier call was refuseda paused server or agent run asked about several calls, and an earlier one was refused
abortedStoppednonethe reply was stopped (Stop)
abortedStoppedTimed outthe handler outlived toolTimeoutMs
abortedStoppedNot available hereno handler is registered for the tool
abortedStoppedNo one could approve itthe call needs approval and nothing can ask
abortedStoppedTurn limit reachedthe tool’s own maxTurns ran out
failedFailedthe 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:

ts
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.

ts
import { aparteGlobalConfig } from '@aparte/core';

aparteGlobalConfig.registerToolSummary('read_file', (segment) =>
  String(segment.toolCall.input['path'] ?? ''));
Open
read_file with the file it read, without opening the row.

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.

ts
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.

ts
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'])}` }),
);
Open
The decision, asked at the composer: the question, the arguments, the choices.

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.

ts
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.

ts
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.

ts
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);
Open
A tool drawn by a renderer of its own, in place of the row.

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:

ts
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:

ts
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;
}