Tools, approval and asking the user: Registering tools, the tool-call row, human-in-the-loop approval, typed questions to the user (elicitation), and the ask-user plugin.
# Approval
> A tool call held until a person decides — the call and its arguments asked at the composer, approve, reject, or a word on what to do instead.
[A tool call waiting for a person](/preview/demo/approval/)
```html
```
Some tools should not run without a yes: deleting a file, sending an email, running a command. Mark such a tool `needsApproval` and the turn stops before its handler runs. The tool’s row in the transcript says it is waiting, and the question is asked where the person is already looking, at the composer: which tool, with which arguments, approve or reject. The person can also type an answer instead of clicking: “no, archive it instead”. That refuses the call, and the words reach the model as the tool’s result, so the next step can follow them. ## Variants [Section titled “Variants”](#variants) ### A choice that can be remembered [Section titled “A choice that can be remembered”](#a-choice-that-can-be-remembered) [An approval that can be remembered](/preview/demo/approval-scoped/)
```html
```
The options come with the request, so an app that can remember a grant offers a third one, and says what it reaches. Offer “always” only where your app keeps it. ## Usage [Section titled “Usage”](#usage) With `AparteClient`, marking the tool is all it takes:
```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'])}.` }),
);
```
To ask on your own terms, call `requestUserInput()` with `kind: 'approval'`. It resolves with the chosen option’s `value`, or with the typed `instruction`:
```ts
import { requestUserInput } from '@aparte/core';
import type { AparteApprovalAnswer } from '@aparte/core';
const result = await requestUserInput({
kind: 'approval',
message: 'Run run_command?',
details: 'git status',
options: [
{ value: 'once', label: 'Allow once' },
{ value: 'always', label: 'Always allow', description: 'git status, in this workspace' },
{ value: 'reject', label: 'Reject', tone: 'deny' },
],
});
if (result.action === 'accept') {
const { option, instruction } = result.content as AparteApprovalAnswer;
}
```
An `AparteApprovalOption` has a `value`, a `label`, an optional `description` (what choosing it commits to) and a `tone`, `affirm` or `deny`. A label can be a function, re-read when the language changes while the question is open. ## Customize [Section titled “Customize”](#customize) * Which calls ask is a policy: the [approval mode](/components/approval-mode/) switch decides by mode (plan, ask, auto-edit, auto) from your tool names. * `AparteClient`’s `approvalResolver` option takes the whole decision instead, for a CLI, a webhook or a rule written in code. * The panel is capped at `--aparte-elic-max-height`, half the window by default. ## Accessibility [Section titled “Accessibility”](#accessibility) * The arguments are shown as text, in a focusable group named “Arguments”, never as markup: they are written by the model. * The options are buttons in a group named from the `approvalOptionsLabel` key (“Your decision”); a decision takes one click or one key press. * The instruction field is named by its placeholder (“Or tell it what to do instead…”). * The panel takes the focus and gives it back when it closes (to the composer when there is nothing to go back to). From the chat’s own editor it focuses the panel itself, named by the question, so the next key does not pick an option. A reader writing in a field elsewhere on the page, or in another chat, keeps the focus, and a polite status says a question waits. Taken from nowhere, it does not scroll the page. ## Related [Section titled “Related”](#related) * [Approval mode](/components/approval-mode/) — when to ask, by mode. * [Tool call](/components/tool/) — the row that waits in the transcript. * [Question](/components/question/) — the same panel collecting an answer instead of a decision.
# Approval mode
> The switch every agent product ends up with — plan, ask, auto-edit, auto — deciding which tool calls run, which ask first, and which are refused.
[The approval mode switch in the composer](/preview/demo/approval-mode/)
```html
```
How much should the agent do without asking? **Plan** only reads. **Ask** confirms every change. **Auto-edit** lets changes through and asks before running commands. **Auto** never asks. `` is that switch, in the composer’s toolbar, and `@aparte/plugin-approval` applies it to every tool call. It decides; it never runs anything and stores nothing. The library cannot tell that `run_command` executes and `search` reads: those are your tool names. So you sort them once into `read`, `write` and `exec`, and the mode does the rest. | Mode | `read` | `write` | `exec` | not sorted | | ------------- | ------ | ---------------------- | ---------------------- | ----------------------- | | **plan** | runs | refused, with a reason | refused, with a reason | its own `needsApproval` | | **ask** | runs | asks | asks | its own `needsApproval` | | **auto-edit** | runs | runs | asks | its own `needsApproval` | | **auto** | runs | runs | runs | runs | “Asks” opens the [approval](/components/approval/) panel. “Refused, with a reason” is what makes *plan* usable: the model reads which tool was refused and why, and says what it would do instead. ## Usage [Section titled “Usage”](#usage)
```bash
npm install @aparte/plugin-approval @aparte/core
```
```ts
import { aparteGlobalConfig } from '@aparte/core';
import { setupApproval } from '@aparte/plugin-approval';
const approval = setupApproval(
{
classify: {
read: ['read_file', 'search', /^list_/],
write: ['write_file', 'edit_file'],
exec: ['run_command'],
},
mode: 'ask', // the default
},
aparteGlobalConfig, // the config last, like every setup function
);
approval.setMode('plan'); // from your own UI
approval.subscribe((mode, previous) => console.info(previous, '→', mode));
```
```html
```
A name in a list matches exactly; a `RegExp` matches a pattern. A change of mode applies to the next tool call, in the middle of a run too. `setupApproval()` takes `ApprovalSetupOptions` and returns an `ApprovalController` (`getMode`, `setMode`, `subscribe`), which `getApprovalController(config)` finds again from anywhere. The element also dispatches `aparte-approval-mode-change` with `{ mode, previousMode }` (`AparteApprovalModeChangeEventDetail`), bubbling, so an ancestor can save the choice. Nothing is remembered across a reload: keep `getMode()` wherever you keep preferences. The plugin ships no framework binding. In Angular, add `CUSTOM_ELEMENTS_SCHEMA` to the component that hosts the tag; Vue and Svelte accept it as it is. In a React `.tsx` file the tag is a compile error until you declare it once:
```ts
import type { DetailedHTMLProps, HTMLAttributes } from 'react';
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'aparte-approval-mode': DetailedHTMLProps, HTMLElement>;
}
}
}
```
[Your own element, or a plugin’s](/frameworks/elements/#react-vue-and-svelte-type-the-tag-from-your-own-package) covers the rule for any plugin’s element. The class is exported as `AparteApprovalMode`, for the type of a `ref`. ## Customize [Section titled “Customize”](#customize) The labels are English; a translated app sets `element.labels = { plan: 'Planifier', … }`. If the mode already lives elsewhere (a settings page, a URL, a store), take the policy without the controller. It reads the mode on every call:
```ts
import { aparteGlobalConfig } from '@aparte/core';
import { APPROVAL_MODES, classifyTool, createApprovalPolicy, rulingFor } from '@aparte/plugin-approval';
import type { ApprovalClassification, ApprovalMode } from '@aparte/plugin-approval';
const classify: ApprovalClassification = { read: ['read_file'], write: ['write_file'], exec: ['run_command'] };
let mode: ApprovalMode = 'ask'; // wherever your app keeps it
aparteGlobalConfig.setApprovalPolicy(createApprovalPolicy(() => mode, classify));
// The table, readable without installing a policy, for a settings screen:
APPROVAL_MODES; // ['plan', 'ask', 'auto-edit', 'auto']
classifyTool('run_command', classify); // 'exec'
rulingFor('plan', 'exec', { id: 'c1', name: 'run_command', input: {} }); // { verdict: 'deny', reason: '…' }
```
`ApprovalMode`, `ToolClass` and `ToolMatcher` (a name or a `RegExp`) are exported for the store that holds them. Underneath, a policy is core’s `AparteApprovalPolicy`: a function from a call to an `AparteApprovalRuling` (`allow`, `ask`, or `deny` with a `reason`), or `undefined` to let the tool’s own `needsApproval` decide. You can write one without the plugin:
```ts
import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.setApprovalPolicy((call) =>
call.name === 'run_command' && String(call.input['cmd']).startsWith('rm ')
? { verdict: 'deny', reason: 'Deleting is off in this workspace.' }
: undefined,
);
```
## API [Section titled “API”](#api) `` · class `AparteApprovalMode` **Properties** | Property | Type | Description | | ------------------- | --------------------------------------- | --------------------------------------------------------------------------------------- | | `labels` | `Partial>` | The label of each mode, for a localised host. Missing entries keep the English default. | | `mode` *(readonly)* | `ApprovalMode \| null` | The current mode, or `null` with no setup. | **Methods** | Method | Description | | ----------------------------------------------- | -------------------------------------------------------------------------------- | | `aparteConfigChanged(next: AparteConfig): void` | See `AparteConfigAware`: a boundary moved, so the controller may be another one. | **Events** | Event | Type | Description | | ----------------------------- | ---- | --------------------------------------------------------------------------- | | `aparte-approval-mode-change` | “ | After a switch: `{ mode, previousMode }`. Bubbles and crosses shadow roots. | ## Accessibility [Section titled “Accessibility”](#accessibility) * The switch is a select named from the `approvalModeLabel` key (“Approval mode”). * With no `setupApproval()` on its config, it renders disabled rather than offering a choice that would do nothing. ## Related [Section titled “Related”](#related) * [Approval](/components/approval/) — the panel a call that asks opens. * [Model selector](/components/model-selector/) — the other switch in the toolbar. * [Tools guide](/guides/tools/) — registering the tools the modes sort.
# Artifact
> A document the model produces — a page, a component, a script, a note — shown as a card with its code, a sandboxed preview, copy and download.
[A document the model produced, as a card](/preview/demo/artifact/)
```html
```
When the model writes a whole document rather than an answer, the reply shows it as a card: its title and kind, a **Code** tab, a **Preview** tab for what a browser can render, and buttons to copy and download it. The preview is mounted only when the reader opens it, in a sandboxed frame that can reach nothing outside itself, because what it runs is code the model wrote. Everything here comes from `@aparte/plugin-artifacts`: the `create_artifact` tool the model calls, the card, and an `` tag for a model that writes the document in its prose. ## Variants [Section titled “Variants”](#variants) ### A note [Section titled “A note”](#a-note) [A Markdown note as an artifact](/preview/demo/artifact-markdown/)
```html
```
The card offers what the kind allows: a note has no preview, so it has no Preview tab. ## Usage [Section titled “Usage”](#usage)
```bash
npm install @aparte/plugin-artifacts @aparte/core
```
```ts
import { setupArtifacts } from '@aparte/plugin-artifacts';
setupArtifacts();
```
One call registers the tool and its handler, the card on the tool’s result, the `` grammar on core’s parser, and the card on the segment that grammar produces. It returns a function that removes all four; a second argument scopes them to one `AparteConfig`. **A model with tools** calls `create_artifact` with `{ mimeType, title, content }`. The handler (`artifactHandler`) returns the document as the tool’s structured result and tells the model it is shown, so the model does not repeat it. Being a real tool, it goes through everything a tool does, the [approval mode](/components/approval-mode/) included. **A model without tools** writes the document in its reply, and the card streams in as it is written:
```text
Here is a first draft:
## aparté
A chat in Web Components, with the agent loop inside.
```
`type=` and `mimeType=` are both read (`mimeType` wins); `title=` is optional. While the block streams, the card pulses and the code follows the tokens; Preview and Download wait for the end. [Turn a tag into a segment](/guides/customization/segments/#turn-a-tag-into-a-segment) explains the mechanism. ## Customize [Section titled “Customize”](#customize)
```ts
import { setupArtifacts } from '@aparte/plugin-artifacts';
setupArtifacts({
name: 'create_artifact', // the tool's name as the model sees it
systemPrompt: false, // your own string, or false to send none
tag: 'artifact', // the tag recognised in the prose; false for none
preview: true, // false: no Preview tab; a function: your own document builder
});
```
| Option | Default | What it does | | -------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | | `name` | `'create_artifact'` | The tool’s name. | | `systemPrompt` | a short prompt saying when to produce a document | `false` sends none; a string replaces it. | | `tag` | `'artifact'` | The tag the grammar recognises. `false`: only the tool produces artifacts. | | `preview` | `true` | `false` removes the Preview tab; `(kind, body, title) => srcdoc` replaces the built-in document builder. | | `onBinary` | — | Produces the file for a binary kind; see below. | The built-in preview document is `buildSafePreviewDocument`, under the policy `PREVIEW_CSP`, declared both as the frame’s attribute and in a ``. ### Binary files [Section titled “Binary files”](#binary-files) For `pdf`, `xlsx` and `docx`, the model writes the code that produces the file, and something must run it. That something is yours:
```ts
import { setupArtifacts } from '@aparte/plugin-artifacts';
import type { ArtifactBinary, ArtifactSegment } from '@aparte/plugin-artifacts';
async function makeFile(artifact: ArtifactSegment): Promise {
const { bytes, filename } = await runInMySandbox(artifact.content); // yours
return { buffer: bytes, mime: artifact.mimeType, filename };
}
setupArtifacts({ onBinary: makeFile });
```
Once the source is complete, the card calls `onBinary` once, then shows the file’s name and size and enables Download, or shows the failure. A `previewHtml` on the result (a spreadsheet as a table) is sanitised like a reply before it is shown. Without `onBinary`, a binary artifact shows its source and offers no download. ### Kinds and building blocks [Section titled “Kinds and building blocks”](#kinds-and-building-blocks) `deriveArtifactKind(mimeType)` names the kind the card switches on (`react`, `html`, `js`, `css`, `svg`, `json`, `markdown`, `csv`, `text`, `pdf`, `xlsx`, `docx`), from a standard MIME type or Anthropic’s `application/vnd.ant.*` names. To assemble the pieces yourself: `createArtifactTool`, `ARTIFACT_SYSTEM_PROMPT`, `artifactSegment`, `artifactBlock`, `artifactFromToolCall`, `ARTIFACT_TAG`, `ARTIFACT_SEGMENT_TYPE`, and `artifactRenderer`, the card itself as a segment renderer. The types: `ArtifactSegment` (alias `AparteArtifactSegment`); `ArtifactInput`, the `{ mimeType, title?, content }` the tool takes and returns; `ArtifactsSetupOptions`, which is `ArtifactToolOptions` plus `ArtifactRenderOptions` plus `tag`; `ArtifactPreviewBuilder` and `ArtifactBinaryResolver`, the signatures of `preview` and `onBinary`. On a server, the package’s `node` entry registers the tool, its handler and the grammar without the card, so an SSR build imports it without a document. ## Accessibility [Section titled “Accessibility”](#accessibility) * Code and Preview are a real tab set: arrow keys move between tabs, and each pane is a focusable tab panel named by its tab. * Copy and Download are named from the locale; while the card is being written, its pulse is labelled from the `generating` key. ## Related [Section titled “Related”](#related) * [Code block](/components/code-block/) — code that is part of an answer rather than a document. * [Split layout](/guides/layout/#with-an-artifact-in-the-pane) — the document beside the chat. * [Tool call](/components/tool/) — the call the card is drawn from.
# Question
> A question put to the person mid-conversation — choices, yes or no, a short text, or several at once — answered at the composer and kept in the transcript.
[A question with choices, asked at the composer](/preview/demo/question/)
```html
```
Sometimes the next step needs an answer only the person has: which format, which audience, go or wait. The question opens in place of the composer’s input, with its choices; the person answers, and the conversation carries on. It can come from two places. **The model** asks it through the `ask_user` tool of `@aparte/plugin-ask-user`. **Your code** asks it with `requestUserInput()`, from a tool handler or anywhere else. Either way, `` presents it, and [``](/components/chat/) already contains one. ## Variants [Section titled “Variants”](#variants) ### Several questions at once [Section titled “Several questions at once”](#several-questions-at-once) [Several questions in one request](/preview/demo/question-form/)
```html
```
A form asks one question at a time, with a chip per question; the composer’s button sends the answers once the last one is given. ### What the transcript keeps [Section titled “What the transcript keeps”](#what-the-transcript-keeps) [The receipt a question leaves in the transcript](/preview/demo/question-receipt/)
```html
```
Once answered, the panel is gone. The plugin leaves a receipt in the reply instead: each question with its answer, so scrolling back shows what was asked. ## Usage [Section titled “Usage”](#usage) Let the model ask:
```ts
import { setupAskUser } from '@aparte/plugin-ask-user';
setupAskUser(); // registers the ask_user tool and its receipt
```
Or ask yourself:
```ts
import { requestUserInput } from '@aparte/core';
const result = await requestUserInput({
message: 'Which format should the report use?',
schema: {
type: 'enum',
options: [
{ value: 'md', label: 'Markdown', recommended: true },
{ value: 'pdf', label: 'PDF' },
],
},
});
if (result.action === 'accept') console.info(result.content); // 'md'
```
The schema is one field (`enum`, `boolean`, `string`) or an `object` of several, each with a `header` that names its chip. An `enum` can be `multiple`. The result is `{ action: 'accept', content }` or `{ action: 'decline' }` when the person skips; a request that ends without an answer (the turn was stopped, the `signal` fired) rejects with `AparteElicitationAbortError`. * Vanilla
```html
```
* React
```tsx
```
The `aparte-*` tags are typed JSX intrinsics as soon as you import from `@aparte/react`. * Vue
```vue
```
Declared through Vue’s `GlobalComponents`, so `vue-tsc` checks the tag in any template. * Svelte
```svelte
```
Declared through `SvelteHTMLElements`, so `svelte-check` covers the attributes and the `on:` handlers. * Angular
```ts
import { AparteElicitationDirective } from '@aparte/angular';
```
```html
```
A standalone directive whose selector IS the tag, so the real element sits in the template — `@if`, `@for` and content projection all reach it — and no `CUSTOM_ELEMENTS_SCHEMA` is needed. Installation and the framework-specific traps: [React](/frameworks/react/) · [Vue](/frameworks/vue/) · [Svelte](/frameworks/svelte/) · [Angular](/frameworks/angular/). ## Customize [Section titled “Customize”](#customize) `aparteGlobalConfig.setElicitationOptions()` sets how questions are presented: `allowOther` adds a free-text “Other…” to every choice (on by default), `layout` asks a form `stepped` (one at a time) or `stacked`, and `answerOnClick` makes a single choice answer on the click (on by default) rather than with the composer’s button. The `ask_user` tool is built, not fixed. `setupAskUser()` takes `maxOptions` (4 by default) and `maxQuestions` (5), the tool’s `name`, `description` and `systemPrompt` (or `false` to send none), and `receipt: false` for an app that records the exchange itself. These are `AskUserSetupOptions`, which extends `AskUserToolOptions`, what `createAskUserTool()` takes. To wire it by hand: `createAskUserTool()` and `askUserHandler` for the tool, `questionReceiptRenderer` and `buildReceipt` for the card. To draw the receipt your own way, `receiptRows()` turns a call’s `ReceiptSource` (its input, result and structured result) into `ReceiptRow`s, one `{ question, answer, declined? }` per question:
```ts
import { aparteGlobalConfig } from '@aparte/core';
import { receiptRows } from '@aparte/plugin-ask-user';
aparteGlobalConfig.registerToolRenderer('ask_user', {
render: (segment) => {
const list = document.createElement('ul');
for (const row of receiptRows({ input: segment.toolCall.input, result: segment.result, structuredResult: segment.structuredResult })) {
list.append(Object.assign(document.createElement('li'), { textContent: `${row.question}: ${row.answer}` }));
}
return list;
},
});
```
The tool returns the answer twice: as a sentence for the model, and as a value, `AskUserStructuredResult`, which the receipt reads. A skipped question returns the sentence `ASK_USER_DECLINED`; compare with the constant rather than a copy of it. ## API [Section titled “API”](#api) `` · class `AparteElicitation` **Methods** | Method | Description | | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `aparteConfigChanged(next: AparteConfig, previous: AparteConfig): void` | The boundary above us appeared, changed, or went away — move the registration with it. `connectedCallback` alone is not enough and cannot be: registering is a WRITE, and under all four wrappers it happens before `attachConfig` runs, so it lands on the global singleton. `requestUserInput()` then resolves the instance config, finds nothing, and rejects the request — the model hears the user refuse a question the user never saw. See AparteConfigAware. | **CSS variables** | Variable | Default | Description | | ----------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `--aparte-elic-gap` | `var(--aparte-space-3)` | Vertical gap between the panel’s rows (message, body, tabs). | | `--aparte-elic-padding` | `var(--aparte-space-3) var(--aparte-space-2)` | Padding inside the panel. | | `--aparte-elic-max-height` | `50vh` | Cap on the panel’s height; its body scrolls, the panel does not. | | `--aparte-elic-field-gap` | `var(--aparte-space-4)` | Space and separator padding between two fields of an object schema. | | `--aparte-elic-message-size` | `var(--aparte-font-size-base)` | Font size of the question text at the top of the panel. | | `--aparte-elic-title-size` | `var(--aparte-font-size-base)` | Font size of a field’s title. | | `--aparte-elic-desc-size` | `var(--aparte-font-size-sm)` | Font size of a field’s description. | | `--aparte-elic-option-padding` | `var(--aparte-space-4) var(--aparte-space-5)` | Padding of one enum/boolean option row. | | `--aparte-elic-option-radius` | `var(--aparte-radius-md)` | Corner radius of an option row. | | `--aparte-elic-option-title-size` | `var(--aparte-font-size-md)` | Font size of an option’s label, and of the text inputs. | | `--aparte-elic-option-desc-size` | `var(--aparte-font-size-sm)` | Font size of an option’s secondary line. | | `--aparte-elic-control-size` | `15px` | Size of the radio/checkbox control in an option row. | | `--aparte-elic-input-radius` | `var(--aparte-radius-md)` | Corner radius of the text inputs and of the Skip button. | | `--aparte-elic-textarea-min-height` | `64px` | Minimum height of a multi-line string field. | | `--aparte-elic-input-size` | `0.85rem` | Font size of the approval panel’s instruction field (the free-text note the user writes). | | `--aparte-elic-skip-size` | `var(--aparte-font-size-md)` | Font size of the corner “Skip” affordance (the decline). | | `--aparte-elic-step-size` | `var(--aparte-font-size-sm)` | Font size of a step tab, when the schema is asked one field at a time. | | `--aparte-elic-step-padding` | `var(--aparte-space-2) var(--aparte-space-1)` | Padding of a step tab. | | `--aparte-elic-step-gap` | `var(--aparte-space-7)` | Gap between step tabs. | | `--aparte-elic-step-underline` | `2px` | Thickness of the current step’s underline (a tab, not a pill). | | `--aparte-elic-dismiss-room` | `72px` | Space the tab rail and the question message keep clear for the corner escape. Widen it for a locale whose “Skip” word is wider. | | `--aparte-approval-gap` | `4px` | Gap between the stacked options of an approval request. | | `--aparte-approval-option-size` | `0.85rem` | Font size of an approval option button. | | `--aparte-approval-option-padding` | `8px 10px` | Padding of an approval option button. | | `--aparte-approval-option-radius` | `8px` | Corner radius of an approval option button. | | `--aparte-approval-args-max-height` | `8.5rem` | Height cap of the arguments block before it scrolls. | | `--aparte-approval-args-size` | `var(--aparte-font-size-sm)` | Font size of the arguments block. | `` is the same element under a name that says what it is for, registered by importing the plugin. Mount one or the other, never both. ## Accessibility [Section titled “Accessibility”](#accessibility) * A single choice is a radio group, several are a group of checkboxes, named by the question. * A form’s chips are a tab list: arrow keys move between questions. * “Other…” and a text answer are labelled fields; nothing in the panel is a bare input. * The panel takes the focus and gives it back when it closes (to the composer when there is nothing to go back to). From the chat’s own editor it focuses the panel itself, named by the question, so the next key does not pick an option. A reader writing in a field elsewhere on the page, or in another chat, keeps the focus, and a polite status says a question waits. Taken from nowhere, it does not scroll the page. ## Related [Section titled “Related”](#related) * [Approval](/components/approval/) — the same panel asking for a decision. * [Composer](/components/composer/) — where the panel opens.
# Tool call
> The row a reply shows when the model calls one of your tools — its name, what it was asked, what it returned, and whether it is running, waiting for a person or done.
[A tool call in a reply](/preview/demo/tool/)
```html
```
When the model calls a tool, the reply gets a `tool_call` segment and the bubble draws it as a quiet row: the tool’s name and its status. Opening the row shows the input the model chose and the result your handler returned. The row is drawn for you; a tool registered with `aparteGlobalConfig.registerTool()` produces it during a turn without any code of yours. ## Variants [Section titled “Variants”](#variants) ### Every status [Section titled “Every status”](#every-status) [Every status a tool call can be in](/preview/demo/tool-statuses/)
```html
```
A call is `pending` while your handler runs, `awaiting-approval` when the tool is marked `needsApproval` and a person has to decide (see [Tools and approval](/guides/tools/)), then `resolved`, `rejected`, `aborted` or `failed`. Only the first two are still open. ### A summary of what it ran on [Section titled “A summary of what it ran on”](#a-summary-of-what-it-ran-on) [A tool row that says what it ran on](/preview/demo/tool-summary/)
```html
```
`registerToolSummary(name, summary)` adds one line after the tool’s name, so `read_file` reads `read_file docs/release.md` without opening the row. It returns plain text; the row, its states and its disclosure stay the library’s. ### Your own rendering [Section titled “Your own rendering”](#your-own-rendering) [A tool drawn by a renderer of your own](/preview/demo/tool-renderer/)
```html
```
`registerToolRenderer(name, renderer)` replaces the row for one tool. Return an element rather than an HTML string: the input and the result come from the model and your tool, so a string would have to escape every value it interpolates. Add an `update()` to patch the element when the result lands, instead of rebuilding it. ## Usage [Section titled “Usage”](#usage)
```ts
import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.registerTool(
{
name: 'get_weather',
description: 'Current weather for a city.',
inputSchema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] },
},
async (call) => ({ toolCallId: call.id, content: `Cloudy, 14 °C in ${call.input.city}.` }),
);
```
The client offers the tool to the model, runs the handler when it is called and draws the row for each call. Building a segment by hand, as these demos do, is for a reply your own code assembles. ## Fields [Section titled “Fields”](#fields) `AparteToolCallSegment`, discriminated by `type: 'tool_call'`. | Field | Type | Required | Description | | ------------------ | --------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `toolCall` | `AparteToolCall` | yes | | | `status` | `'pending' \| 'resolved' \| 'aborted' \| 'awaiting-approval' \| 'rejected' \| 'failed'` | yes | `awaiting-approval` — paused for a human decision; `rejected` — the human (or a policy) declined to run it; `aborted` — stopped, timed out, or nothing could run it; `failed` — the handler threw, and `result` carries the crash’s one line. [More](#field-status) | | `result` | `string` | — | | | `structuredResult` | `unknown` | — | The handler’s `structuredContent`, when it returned one — the value behind `result`’s prose. | | `deferred` | `{ reason: 'approval' \| 'external' \| 'queued'; ticket?: string }` | — | Set when a server loop (`createAparteAgentHandler`) suspended its run on this call: why it waits — a person’s decision (`approval`), a tool the page runs (`external`), or nothing but its turn (\`queued [More](#field-deferred) | **`status`** — `awaiting-approval` — paused for a human decision; `rejected` — the human (or a policy) declined to run it; `aborted` — stopped, timed out, or nothing could run it; `failed` — the handler threw, and `result` carries the crash’s one line. **`deferred`** — Set when a server loop (`createAparteAgentHandler`) suspended its run on this call: why it waits — a person’s decision (`approval`), a tool the page runs (`external`), or nothing but its turn (`queued`) — and the server’s signed continuation. Saved with the conversation, so a reload can still answer it (`AparteClient.resumeDeferred`). ## Accessibility [Section titled “Accessibility”](#accessibility) * When there is an input or a result to show, the row is a native ``: its summary opens with Enter or Space and announces whether it is expanded. With nothing behind it, it is a plain row and takes no tab stop. * The status is written in words (locale keys such as `toolRunning`, `toolCompleted`, `toolFailed`), not only shown as a colour or an icon. ## Related [Section titled “Related”](#related) * [Tools and approval](/guides/tools/) — registering tools, what the row shows, and asking a person first.
# Ask the user a question
> Pause a run and get a choice, a yes or no, some text or a small form from the person — or a decision between options of yours — in a panel at the composer.
[A question with choices, asked at the composer](/preview/demo/question/)
```html
```
A question asked at the composer: a single choice, answered by its click. ## Ask one thing [Section titled “Ask one thing”](#ask-one-thing)
```ts
import { requestUserInput } from '@aparte/core';
const answer = await requestUserInput({
message: 'Which environment should I deploy to?',
schema: {
type: 'enum',
options: [
{ value: 'staging', label: 'Staging', recommended: true },
{ value: 'prod', label: 'Production', description: 'Live traffic' },
],
},
});
if (answer.action === 'accept') {
const env = answer.content as string; // 'staging', 'prod', or what the person wrote instead
}
```
`requestUserInput` asks in a panel at the composer and resolves with the answer. It needs no client: call it from a tool handler, a button, anywhere. `message` can be a function, so the question follows a change of language while it waits. | `schema.type` | Drawn as | `content` on accept | | ------------- | -------------------------------------------------- | ----------------------- | | `enum` | one choice, or several with `multiple: true` | `string`, or `string[]` | | `boolean` | two choices, labelled `trueLabel` and `falseLabel` | `boolean` | | `string` | one line, or a box with `multiline: true` | `string` | A single choice — an `enum` without `multiple` or `default`, a `boolean` without `default` — is a row of buttons, and a click answers it. With a `default`, or after `aparteGlobalConfig.setElicitationOptions({ answerOnClick: false })`, it is radios, which the composer’s button sends; `multiple: true` makes checkboxes. A field also takes a `title`, a `description` and a `default`; a `string` takes a `placeholder`, `required`, `minLength` and `maxLength`. An `enum` offers an **Other…** entry for an answer of the person’s own, which `allowOther: false` on the field closes; `aparteGlobalConfig.setElicitationOptions({ allowOther: false })` closes it everywhere, and a field’s own setting wins. ## Ask several things [Section titled “Ask several things”](#ask-several-things)
```ts
import { requestUserInput } from '@aparte/core';
const answer = await requestUserInput({
message: 'Open a pull request?',
schema: {
type: 'object',
properties: {
title: { type: 'string', header: 'Title', title: 'The title', placeholder: 'Fix the abort path' },
body: { type: 'string', header: 'Body', title: 'The description', multiline: true },
draft: { type: 'boolean', header: 'Draft', title: 'Open it as a draft?', default: true },
},
required: ['title'],
},
});
if (answer.action === 'accept') {
const { title, body, draft } = answer.content as { title: string; body: string; draft: boolean };
}
```
[Several questions in one request](/preview/demo/question-form/)
```html
```
Several questions, one at a time, with a chip for each. An `object` schema asks each of its fields, and `content` comes back keyed the same way. The questions come one at a time, with a chip above for each — its `header`, a word or two — that moves between them and marks the ones answered; the composer’s button sends the whole answer once every required question has one. `required` lists the questions that must be answered — leave it out and all of them must — and a text left empty comes back as `''`. `aparteGlobalConfig.setElicitationOptions({ layout: 'stacked' })` shows the questions all at once, as a form. ## When there is no answer [Section titled “When there is no answer”](#when-there-is-no-answer)
```ts
import { requestUserInput, AparteElicitationAbortError } from '@aparte/core';
try {
const answer = await requestUserInput({ message: 'Delete the branch?', schema: { type: 'boolean' } });
if (answer.action === 'decline') {
// the person chose not to answer
}
} catch (err) {
if (err instanceof AparteElicitationAbortError) {
// no answer at all: err.reason is 'aborted' or 'no-presenter'
}
}
```
Declining is an answer: the person pressed **Skip**, in the panel’s corner, and the request resolves `{ action: 'decline' }`. A [decision](#ask-for-a-decision) has no Skip: a refusal is one of its options, or what the person writes instead. A request that ends without an answer rejects with an `AparteElicitationAbortError` — its `name` is `'AbortError'` — whose `reason` is `'aborted'` when the turn was stopped, a signal fired or the question was taken away, and `'no-presenter'` when nothing on the page can ask. A request you start without awaiting it therefore needs a `.catch()`. ## Ask from a tool [Section titled “Ask from a tool”](#ask-from-a-tool)
```ts
import { requestUserInput } from '@aparte/core';
import type { AparteToolHandler } from '@aparte/core';
export const deleteBranch: AparteToolHandler = async (call, signal, context) => {
const branch = String(call.input['branch']);
const answer = await requestUserInput({
message: `Delete ${branch}? This cannot be undone.`,
schema: { type: 'boolean', trueLabel: 'Delete it', falseLabel: 'Keep it' },
signal,
target: context?.target,
});
if (answer.action !== 'accept' || answer.content !== true) {
return { toolCallId: call.id, content: 'The user did not confirm; nothing was deleted.' };
}
return { toolCallId: call.id, content: `Deleted ${branch}.` };
};
```
Register the handler as any [tool](/guides/tools/#register-a-tool), with `aparteGlobalConfig.registerTool(tool, deleteBranch)`. Pass it the handler’s `signal`, so a stopped turn or a timeout takes the question away, and the context’s `target`, so the right chat asks when a page has several. Answer a refusal with a result the model reads, rather than an error, so it can say what happened. ## Ask for a decision [Section titled “Ask for a decision”](#ask-for-a-decision)
```ts
import { requestUserInput } from '@aparte/core';
const answer = await requestUserInput({
kind: 'approval',
message: 'Run git status?',
details: JSON.stringify({ cmd: 'git status' }, null, 2),
options: [
{ value: 'allow', label: 'Approve', tone: 'affirm' },
{ value: 'allow-always', label: 'Approve, and always for git', description: 'git *', tone: 'affirm' },
{ value: 'deny', label: 'Reject', tone: 'deny' },
],
});
if (answer.action === 'accept') {
const { option, instruction } = answer.content as { option?: string; instruction?: string };
}
```
[An approval that can be remembered](/preview/demo/approval-scoped/)
```html
```
A decision between options of yours, one of them with a reach the person sees before choosing. A `kind: 'approval'` request offers your options, each answered by its click, and resolves with the chosen option’s `value` as `option` — or, when the person writes instead, with their `instruction`. Give an option that reaches further its own `value`, so your code knows to remember it, and say that reach in its `description`, under the label. `details` is shown as plain text under an **Arguments** heading, never as markup. The [tool approval](/guides/tools/#require-approval-human-in-the-loop) is this request, with two options. ## Let the model ask [Section titled “Let the model ask”](#let-the-model-ask)
```ts
import { setupAskUser } from '@aparte/plugin-ask-user';
setupAskUser({ maxOptions: 4, maxQuestions: 3 });
```
[The receipt a question leaves in the transcript](/preview/demo/question-receipt/)
```html
```
What the model asked and what the person chose, kept in the conversation. `@aparte/plugin-ask-user` gives the model an `ask_user` tool: it asks when it decides a choice is needed, and the answer, with what was asked, stays in the conversation as a receipt. Its options are the tool’s `name` and `description`, the `systemPrompt` that tells the model when to ask (`false` for none), the most options and questions in one call, and `receipt: false` to keep the receipt out. Ask from your own code when your code knows what to ask; let the model ask when it should decide. The [Question](/components/question/) page shows both. ## Draw it yourself [Section titled “Draw it yourself”](#draw-it-yourself) The panel comes from ``, which `` and the framework components already hold. A chat you compose in plain HTML writes it inside ``, beside the message list and the `` where the panel opens, as [the composer toolbar](/guides/customization/composer-toolbar/) shows. Most of the time a different look is one field:
```ts
import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.setElicitationFieldRenderer((field, ctx) => {
if (field.type !== 'enum' || field.multiple) return null; // the built-in draws the others
const el = document.createElement('div');
el.className = 'my-chips';
let picked = '';
for (const option of field.options) {
const chip = document.createElement('button');
chip.type = 'button';
chip.textContent = option.label ?? option.value;
chip.addEventListener('click', () => {
picked = option.value;
for (const c of el.children) c.setAttribute('aria-pressed', String(c === chip));
ctx.notifyChange();
});
el.append(chip);
}
return { el, getValue: () => picked, isComplete: () => picked !== '', focus: () => el.querySelector('button')?.focus() };
});
```
A field renderer returns a control — its element, its value, whether it is complete, and a `focus` — or `null` to leave the field to the built-in. It replaces the whole field, its **Other…** entry included, and a click on it answers nothing: the composer’s button sends. Call `ctx.notifyChange()` on every change, so the button knows when the answer is complete; `ctx.key` names the field in a form. To draw the whole request your way — a modal, a page of your own — take the built-in out first: set the framework component’s `elicitation` option to `false`, or leave `` out of a chat you compose. Then register a presenter:
```ts
import { aparteGlobalConfig, AparteElicitationAbortError } from '@aparte/core';
import type { AparteElicitationPresenter, AparteElicitationRequest, AparteElicitationResult } from '@aparte/core';
declare function openMyModal(request: AparteElicitationRequest): Promise;
const present: AparteElicitationPresenter = (request) => new Promise((resolve, reject) => {
request.signal?.addEventListener('abort', () => reject(new AparteElicitationAbortError('aborted')), { once: true });
openMyModal(request).then(resolve, reject);
});
aparteGlobalConfig.setElicitationPresenter(present);
```
A presenter gets every request, tool approvals included: a question carries its `schema`; a decision carries `kind: 'approval'` and its `options`, and resolves `{ action: 'accept', content: { option } }`. When the request’s `signal` fires, reject with an `AparteElicitationAbortError`, as above. The most recent presenter answers; on a page with several chats, pass an element of one as the second argument, and the requests that name that chat reach its presenter. `aparteGlobalConfig.removeElicitationPresenter(fn)` takes one back. `buildElicitationPanel` and `buildApprovalPanel` give you the built-in panel to place yourself. ## Next [Section titled “Next”](#next) * [Question The panel, the form, the receipt, and their options.](/components/question/) * [Tools and approval Tools, and the approval asked before one runs.](/guides/tools/) * [Questions and approvals Every type and function the panel is built from.](/reference/core/elicitation/)
# 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”](#register-a-tool)
```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](#custom-tool-renderer) 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”](#what-the-row-shows) [Every status a tool call can be in](/preview/demo/tool-statuses/)
```html
```
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**, as formatted JSON, coloured when a [highlighter](/guides/markdown/) is registered. | State | 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 | a person, or a [policy](#decide-in-code), refused it | | `aborted` | Stopped | the reply was stopped, or the call could not run — no handler, no one to ask, past its time or its turns — and the reply ends there | | `failed` | Failed | the handler threw | 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 row is `.aparte-segment-tool-call`: its line `.aparte-tool-summary`, rounded by `--aparte-tool-row-radius`, the word `.aparte-tool-state`, and the opened part `.aparte-tool-detail`. The [class reference](/reference/classes/) lists them all. ## Show what a call targets [Section titled “Show what a call targets”](#show-what-a-call-targets)
```ts
import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.registerToolSummary('read_file', (segment) =>
String(segment.toolCall.input['path'] ?? ''));
```
[A tool row that says what it ran on](/preview/demo/tool-summary/)
```html
```
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](#custom-tool-renderer) shows no summary. ## Require approval (human-in-the-loop) [Section titled “Require approval (human-in-the-loop)”](#require-approval-human-in-the-loop)
```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'])}` }),
);
```
[A tool call waiting for a person](/preview/demo/approval/)
```html
```
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 ``, which `` 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` (`Run {tool}?`), `approveTool`, `rejectTool` and `approvalInstructionPlaceholder`. * **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 — except with an [AG-UI agent](/providers/agent/ag-ui/), 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](/guides/server-loop/#approvals-across-the-network). Approval is not authorization The approval runs in the browser: it spares the person a surprise, it does not protect your system. A tool that acts on your backend is checked there, in the endpoint it calls, every time: who is calling, and whether they may. When the loop runs on your server, the handler reads the caller from `context.request` ([server loop](/guides/server-loop/)). ## Decide in code [Section titled “Decide in code”](#decide-in-code)
```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 modes people know from agent apps — plan, ask, auto-edit, auto — with a switch in the composer’s toolbar, are [`@aparte/plugin-approval`](/components/approval-mode/). 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, signal: AbortSignal): Promise;
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
}),
}).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. ## Custom tool renderer [Section titled “Custom tool renderer”](#custom-tool-renderer)
```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);
```
[A tool drawn by a renderer of your own](/preview/demo/tool-renderer/)
```html
```
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](/components/question/) and [artifact](/components/artifact/) plugins draw their cards so. 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;
}
```
## Next [Section titled “Next”](#next) * [Ask the user a question A choice, a yes or no, some text or a small form, in the middle of a run.](/guides/elicitation/) * [Run the loop on your server The same tools and approvals, with the loop and the key on your server.](/guides/server-loop/) * [Tool The row in every state, its parts and its options.](/components/tool/)