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.
<!-- 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>Ask one thing
Section titled “Ask one thing”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”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 };
}<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>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”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 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”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, 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”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 };
}<!-- The panel takes at most --aparte-elic-max-height (half the window by default). -->
<aparte-chat style="height: 26rem; --aparte-elic-max-height: 22rem"></aparte-chat>
<script>
const { requestUserInput } = window.aparte;
// Offer "always" only when your app has somewhere to remember it, and say what it reaches.
requestUserInput({
kind: 'approval',
message: 'Run run_command?',
details: 'git status',
options: [
{ value: 'approve', label: 'Allow once' },
{ value: 'approve-always', label: 'Always allow', description: 'git status, in this workspace' },
{ value: 'reject', label: 'Reject', tone: 'deny' },
],
}).catch(() => {});
</script>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 is this request, with two
options.
Let the model ask
Section titled “Let the model ask”import { setupAskUser } from '@aparte/plugin-ask-user';
setupAskUser({ maxOptions: 4, maxQuestions: 3 });<!-- 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>@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 page shows both.
Draw it yourself
Section titled “Draw it yourself”The panel comes from <aparte-elicitation>, which <aparte-chat> and the framework components
already hold. A chat you compose in plain HTML writes it inside <aparte-chat>, beside the message
list and the <aparte-composer> where the panel opens, as
the composer toolbar shows. Most of the time a different
look is one field:
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 <aparte-elicitation> out
of a chat you compose. Then register a presenter:
import { aparteGlobalConfig } from '@aparte/core';
import type { AparteElicitationPresenter, AparteElicitationRequest, AparteElicitationResult } from '@aparte/core';
declare function openMyModal(request: AparteElicitationRequest): Promise<AparteElicitationResult>;
declare function closeMyModal(): void;
const present: AparteElicitationPresenter = (request) => {
request.signal?.addEventListener('abort', () => closeMyModal(), { once: true });
return openMyModal(request);
};
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 — the turn was
stopped — the request is rejected for you whatever the presenter does; listen to it only to take
your UI down, 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.