Skip to content

ask_user tool

The built-in ask_user tool: it lets the AI ask the user a structured question (title + optional description), as single (radio) or multiple (checkbox) choice. The handler is a thin adapter over core’s elicitation primitive — it maps the tool input to an elicitation schema and awaits requestUserInput, presented by <aparte-elicitation>.

Terminal window
npm install @aparte/plugin-ask-user @aparte/core

@aparte/core is the only peer dependency.

import { setupAskUser } from '@aparte/plugin-ask-user';
setupAskUser(); // registers the tool + hides its bubble segment

Then mount <aparte-elicitation> (or the semantic <aparte-ask-user> alias, registered by importing the package) in your chat to present the panel.

  • One question → an enum field.
  • Several questions → an object form, each field carrying multiple, allowOther, and a default.

accept returns the chosen answer to the model, decline returns a model-usable note, and cancel aborts the tool call. The result carries the answer twice: as the prose the model reads (content), and as a value (structuredContent — MCP’s name): { action: 'accept', answers: [{ question, value }] }, value a string[] for a multiple choice, or { action: 'decline' } (AskUserStructuredResult). It lands on the transcript’s segment as structuredResult, which is what the receipt reads — a host that wants the answer does not parse the sentence. Options improvised by smaller models (bare strings, or label/value/text keys instead of title) are normalised so the panel always renders real choices.

The panel lives in the composer, so once it is answered it is gone. setupAskUser() therefore registers a tool renderer that leaves a receipt in the thread: one question → answer row per question asked. Without it, scrolling back showed nothing — no question, no answer, no sign the assistant had asked anything, which is not what a conversation is for.

It is rendered as DOM rather than as an HTML string, because every value in it is model-chosen or user-typed.

To render the record yourself — a different card, a framework component — take the pairing and skip the markup:

import { aparteGlobalConfig } from '@aparte/core';
import { receiptRows } from '@aparte/plugin-ask-user';
aparteGlobalConfig.registerToolRenderer('ask_user', {
render: (segment) => {
const rows = receiptRows({ input: segment.toolCall.input, result: segment.result, structuredResult: segment.structuredResult });
const el = document.createElement('div');
for (const row of rows) {
const line = document.createElement('p');
line.textContent = `${row.question}${row.answer}`; // textContent, not innerHTML
el.appendChild(line);
}
return el;
},
});

receiptRows takes the questions from the tool INPUT rather than from the formatted result, because the input is authoritative: an answer a user typed can contain anything, including an arrow. It returns [] while the call is still pending, which is why the default renderer shows nothing until it settles — the live UI is the panel, and two places to read the same open question is one too many. buildReceipt is the default card if you want it verbatim.

Both types are exported: ReceiptSource is the argument ({ input, result?, structuredResult? }, the three fields a tool-call segment carries) and ReceiptRow is one line — { question, answer, declined? }. receiptRows and ASK_USER_DECLINED are on the server entry too, so a backend that renders a transcript to HTML or writes it to a log reads the same rows the card does; buildReceipt is browser-only, since it returns an element.

A declined question ends the call with one fixed sentence as the tool result, exported as ASK_USER_DECLINED ('The user declined to answer.'). receiptRows recognises it and marks the row declined; a host that turns tool results into prose, or looks for a declined question in its own transcript, should import the constant rather than copy the sentence — the wording is the plugin’s to change, and a copy would break without a word.

The schema caps what the model may ask for: 2 to 4 options per question and 5 questions per call. Four because six options plus the free-text escape is seven rows in a composer — a form that escaped into a chat — and because a model asked for four writes better options than one asked for six: it has to choose.

Those are defaults, not laws. They are the host’s to move:

import { setupAskUser } from '@aparte/plugin-ask-user';
setupAskUser({ maxOptions: 6, maxQuestions: 2 }); // the config last, like every setup*: it defaults to the global

The name and the words are the host’s too. A backend that already exposes an ask_user, or a product that wants the model to read another policy in another language, passes them in; the receipt in the transcript follows the name, and receipt: false keeps the transcript silent for a UI that records the exchange itself:

setupAskUser({
name: 'clarify',
description: 'Pose une question à choix à l’utilisateur.',
systemPrompt: 'Tu disposes de l’outil clarify. Utilise-le avant d’écrire un fichier.',
receipt: false,
});

The tool is built rather than imported for exactly this reason — createAskUserTool() with no argument is the normal call, and the system prompt states whatever bounds you chose, because a prompt asking for what the schema does not enforce is how the original defect got in.

systemPrompt: false registers the tool with no system message at all — for a model trained on a fixed contract that must not read any added prose. It is a distinct value rather than '': an empty string is still a field, and the field is really sent.

Two option types, one inside the other: AskUserToolOptions is what createAskUserTool takes — the name, description, system prompt and bounds, everything the model sees — and AskUserSetupOptions extends it with receipt, which only setupAskUser acts on, because the receipt is a renderer and the tool alone registers none.

Instead of setupAskUser():

import { aparteGlobalConfig, registerSegmentRenderer } from '@aparte/core';
import { createAskUserTool, askUserHandler, buildReceipt, questionReceiptRenderer } from '@aparte/plugin-ask-user';
aparteGlobalConfig.registerTool(createAskUserTool(), askUserHandler);
registerSegmentRenderer(questionReceiptRenderer); // the receipt card's styles
aparteGlobalConfig.registerToolRenderer('ask_user', {
render: (segment) => buildReceipt({ input: segment.toolCall.input, result: segment.result, structuredResult: segment.structuredResult }),
});

Passing { render: () => '' } instead is what this plugin used to do, and it is what left the transcript empty.

Generated from the plugin’s own custom-elements manifest — the same file that feeds editor autocomplete when you install the package.

Example

<!-- Identical to <aparte-elicitation>; mount either one, never both. -->
<aparte-chat>
<aparte-chat-viewport></aparte-chat-viewport>
<aparte-ask-user></aparte-ask-user>
<aparte-composer></aparte-composer>
</aparte-chat>