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.
-
<aparte-elicitation> + 1 part -
@aparte/plugin-ask-user
<!-- import { requestUserInput } from '@aparte/core' in your app; with
@aparte/plugin-ask-user the MODEL asks it through its ask_user tool. -->
<aparte-chat style="height: 24rem; --aparte-elic-max-height: 20rem"></aparte-chat>
<script>
const { requestUserInput } = window.aparte;
requestUserInput({
message: 'Which format should the report use?',
schema: {
type: 'enum',
options: [
{ value: 'md', label: 'Markdown', description: 'For the repository', recommended: true },
{ value: 'pdf', label: 'PDF', description: 'To send around' },
{ value: 'html', label: 'Web page', description: 'To publish' },
],
},
}).catch(() => {});
</script>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, <aparte-elicitation> presents it, and
<aparte-chat> already contains one.
Variants
Section titled “Variants”Several questions at once
Section titled “Several questions at once”<aparte-chat style="height: 26rem; --aparte-elic-max-height: 22rem"></aparte-chat>
<script>
const { requestUserInput } = window.aparte;
// Each field's header names its tab; the last one makes the composer's button send.
requestUserInput({
message: 'Two things before I start:',
schema: {
type: 'object',
properties: {
audience: {
type: 'enum', header: 'Audience', title: 'Who will read it?',
options: [{ value: 'dev', label: 'Developers' }, { value: 'team', label: 'The whole team' }],
},
sections: {
type: 'enum', header: 'Sections', title: 'What should it cover?', multiple: true,
options: [{ value: 'fixes', label: 'Fixes' }, { value: 'features', label: 'Features' }, { value: 'upgrade', label: 'How to upgrade' }],
},
},
},
}).catch(() => {});
</script>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”<!-- setupAskUser() registers the receipt: once answered, the panel goes and this stays. -->
<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: 'resolved',
toolCall: {
id: 't1',
name: 'ask_user',
input: { questions: [{ question: 'Which format should the report use?', header: 'Format', options: [{ title: 'Markdown' }, { title: 'PDF' }] }] },
},
result: 'The user chose: Markdown',
structuredResult: { action: 'accept', answers: [{ question: 'Which format should the report use?', value: 'Markdown' }] },
}]);
</script>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.
Let the model ask:
import { setupAskUser } from '@aparte/plugin-ask-user';
setupAskUser(); // registers the ask_user tool and its receiptOr ask yourself:
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.
<aparte-elicitation></aparte-elicitation><aparte-elicitation></aparte-elicitation>The aparte-* tags are typed JSX intrinsics as soon as you import from @aparte/react.
<template>
<aparte-elicitation></aparte-elicitation>
</template>Declared through Vue’s GlobalComponents, so vue-tsc checks the tag in any template.
<aparte-elicitation></aparte-elicitation>Declared through SvelteHTMLElements, so svelte-check covers the attributes and the on: handlers.
import { AparteElicitationDirective } from '@aparte/angular';<aparte-elicitation></aparte-elicitation>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 · Vue · Svelte · Angular.
Customize
Section titled “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
ReceiptRows, one { question, answer, declined? } per question:
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.
<aparte-elicitation> · 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. |
<aparte-ask-user> 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”- 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.