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>.
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 segmentThen mount <aparte-elicitation> (or the semantic <aparte-ask-user> alias, registered by importing
the package) in your chat to present the panel.
Shapes
Section titled “Shapes”- One question → an
enumfield. - Several questions → an
objectform, each field carryingmultiple,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.
What the conversation keeps
Section titled “What the conversation keeps”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 bounds the model is given
Section titled “The bounds the model is given”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 globalThe 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.
Wiring it by hand
Section titled “Wiring it by hand”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 stylesaparteGlobalConfig.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.
The <aparte-ask-user> element
Section titled “The <aparte-ask-user> element”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>