Skip to content

Asking the user a typed question

Sometimes a tool cannot finish without something only the user knows. Which of these three files did you mean? Should I really delete the branch? What should the commit message say?

Elicitation is the primitive for that: pause the run, render a typed input in the composer, and resolve with what the user chose. It is a generalisation of the ask-user plugin — there, the kind of question was baked into a dedicated tool; here it is carried by a schema, so one call covers all of them.

The shape follows MCP elicitation (a message plus a requested schema, answered with accept or decline), but the mechanism is transport-agnostic: a presenter registered per config instance, never window events.

requestUserInput is a plain function — no AparteClient needed, no DOM wiring. Call it from a tool handler and await the answer.

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' | whatever "Other…" produced
void env;
}

Three fields cover most questions:

schema.typeRendered ascontent on accept
enumradios, or checkboxes with multiple: truestring, or string[]
booleantwo choices, labelled trueLabel / falseLabelboolean
stringone line, or a textarea with multiline: truestring

An enum also offers a free-text Other… entry by default; pass allowOther: false to close it.

A single choice asked on its own — an enum without multiple or a default, a boolean without a default — renders its options as buttons, and the click is the answer: one decision, one gesture. That is the host’s policy, and it has a switch:

import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.setElicitationOptions({ answerOnClick: false });

With it off, the same question keeps its radios and the composer’s button — select, then send — so a person can change their mind before committing, and every question on the page answers the same way. A form of several questions always collects and submits, whatever the switch says.

Wrap the fields in an object schema and you get a small form — one labelled input per property, and content comes back keyed the same way.

import { requestUserInput } from '@aparte/core';
const answer = await requestUserInput({
message: 'Open a pull request?',
schema: {
type: 'object',
properties: {
title: { type: 'string', title: 'Title', placeholder: 'Fix the abort path' },
body: { type: 'string', title: 'Description', multiline: true, required: false },
draft: { type: 'boolean', title: 'Open as draft', default: true },
},
required: ['title'],
},
});
if (answer.action === 'accept') {
const { title, body, draft } = answer.content as { title: string; body?: string; draft: boolean };
void [title, body, draft];
}

A form of two or more questions is presented one question at a time, with a chip per question above it. The chips are also how you go back: an answer you have already given is the thing you most want to revisit, and hunting for a “Back” button to do it is the frustrating half of every stepped form.

Give each question a short header — two or three words — because that is what the chip holds. Without one the chip falls back to the question’s position, which is honest and never truncates a sentence into nonsense.

import { requestUserInput } from '@aparte/core';
const answer = await requestUserInput({
message: '', // each question carries its own title
schema: {
type: 'object',
properties: {
engine: { type: 'enum', header: 'Engine', title: 'Which engine?', options: [{ value: 'chromium' }, { value: 'webkit' }] },
theme: { type: 'enum', header: 'Theme', title: 'Which theme?', options: [{ value: 'light' }, { value: 'dark' }] },
},
},
});
void answer;

The protocol does not change: the answer is still one object with every key, and the composer’s send button means submit throughout, enabled only once every required question has an answer. Moving between questions is the chips’ affordance — a click on a choice selects, a chip switches question, one button submits the lot — and an answered chip wears a check mark, so what is left is visible at a glance.

If what you actually want is a form — several fields filled in one go, which is what structured data collection looks like — ask for it:

aparteGlobalConfig.setElicitationOptions({ layout: 'stacked' });

That was the only shape until now, inherited from MCP elicitation without being examined. MCP describes a form for collecting structured data; asking a person two different questions in the middle of a conversation is not that, and no product does it by stacking them in one box. The form case is real, so it stays — it was just never the right default.

import { requestUserInput, AparteElicitationAbortError } from '@aparte/core';
try {
const answer = await requestUserInput({ message: 'Delete the branch?', schema: { type: 'boolean' } });
if (answer.action === 'accept') {
// The user answered — `answer.content` is the value.
} else {
// `decline`: the user said no to being asked at all.
}
} catch (err) {
if (err instanceof AparteElicitationAbortError) {
// No answer: the turn was stopped, a signal fired, the question was taken away,
// or nothing was mounted to ask it. `err.reason` is 'aborted' or 'no-presenter'.
}
}

Declining is an answer. Ending without one is not, and that is why it rejects rather than resolving a third action. It used to resolve { action: 'cancel' }, and a value is easy to handle as though it were an answer — which is exactly what happened one level up: the tool-approval gate read that cancel as a refusal and told the model the user had rejected a tool they had merely stopped. A rejection cannot be mistaken for a decision by a caller that forgot a branch.

name is 'AbortError', so a handler that already tests err.name === 'AbortError' needs no change.

One consequence worth knowing: a request you start and never await will surface an unhandled rejection when it ends without an answer, because that is what an ignored failed promise is. Attach a .catch() if you genuinely do not care about the outcome — the noise is the point, and it is the price of an ending that cannot be mistaken for a decision.

A kind: 'approval' request asks the user to pick one of the options you supply, and resolves with which one — plus anything they typed instead. It is the same mechanism, the same panel slot and the same queue; only what is on screen differs, because a decision is not a value and a schema there would be a form with nothing in it.

import { requestUserInput } from '@aparte/core';
const answer = await requestUserInput({
kind: 'approval',
message: 'Run delete_files?',
// What is being approved, under the question. Rendered as text, never as markup —
// the panel is the surface where the user clicks, so the arguments belong on it.
details: JSON.stringify({ paths: ['src/legacy/old-client.ts'] }, null, 2),
options: [
{ value: 'allow', label: 'Approve', tone: 'affirm' },
// Two options may share a `value` and differ only in reach: this is what
// "Yes" and "Yes, and always" are. YOU write the label, because only you can
// honour it — core has nowhere to remember a grant. Say what the reach IS in
// `description`: it is drawn under the label, so a user sees the difference
// between "this command" and "any git command" before clicking.
{ value: 'allow', label: 'Approve, and always for this command', description: 'git status', tone: 'affirm' },
{ value: 'allow', 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 };
// `instruction` is the free-text arm — "no, do this instead". It is a refusal that
// carries words, which is only useful because a refusal hands the model a turn.
void [option, instruction];
}

An option is answered by its own click — approving is the most frequent act in the feature, and spending two gestures on it to reuse the composer’s send button would be the tail wagging the dog. Written text goes through that button instead, which is exactly the act it already means.

The built-in tool-approval gate is one caller of this, with two options and a question built from the tool’s name. Anything richer — a scope option, a third choice — is the host’s to supply, and buildApprovalPanel is exported for a presenter of your own.

Two options matter when the request comes from a tool handler:

  • signal — pass the handler’s own AbortSignal. When the turn is stopped or the per-tool timeout fires, the panel is torn down and the promise rejects with an AbortError, instead of leaving an orphan form in the composer.
  • target — any element inside the chat that should present the request. It resolves which instance answers (its config, its composer). Omit it for a single-chat page; pass it when several chats share a page.
import { requestUserInput } from '@aparte/core';
import type { AparteTool, AparteToolHandler } from '@aparte/core';
export const deleteBranchTool: AparteTool = {
name: 'delete_branch',
description: 'Delete a git branch after confirming with the user.',
inputSchema: { type: 'object', properties: { branch: { type: 'string' } } },
};
export const deleteBranchHandler: AparteToolHandler = async (call, signal) => {
const { branch } = call.input as { branch: string };
const answer = await requestUserInput({
message: `Delete \`${branch}\`? This cannot be undone.`,
schema: { type: 'boolean', trueLabel: 'Delete it', falseLabel: 'Keep it' },
signal,
});
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}.` };
};

Returning a result rather than throwing on a refusal matters: the model needs to read what happened so it can say so, and a thrown error would surface as a failed turn.

<aparte-elicitation> is the default presenter, and it has to be in your markup. It registers itself the moment it connects — but nothing creates it for you, so put it inside your <aparte-chat>:

<aparte-chat>
<aparte-chat-viewport></aparte-chat-viewport>
<aparte-elicitation></aparte-elicitation>
<aparte-composer>
<aparte-composer-input></aparte-composer-input>
<aparte-composer-send></aparte-composer-send>
</aparte-composer>
</aparte-chat>

It then mounts its panel inside the composer of the resolved chat, so the question appears where the user is already typing.

To render it yourself, register a presenter on the config:

import { aparteGlobalConfig } from '@aparte/core';
import type { AparteElicitationRequest, AparteElicitationResult } from '@aparte/core';
aparteGlobalConfig.setElicitationPresenter(
async (request: AparteElicitationRequest): Promise<AparteElicitationResult> => {
// Your modal, your form, your framework. Resolve with what the user did.
void request;
return { action: 'decline' };
},
);

buildElicitationPanel is also exported if you want the built-in panel’s DOM without its placement. It returns a BuiltElicitationPanel — the element plus everything a presenter needs to drive it:

MemberWhat it is for
elthe panel’s root, for you to place
dismissthe corner control that declines the whole request, not the current question
getContent()the current response, shaped to the schema
isComplete()every required field has a usable value
focus()focus the first input, after you mount it
mode()what the composer’s one button means here: 'submit', or 'none' when the panel has nothing for it to do
onSettle(cb)the answer arrived without the button — a single-choice question settles on the click, and this is the only path by which that reaches you
canProceed()whether that button is enabled
proceed()kept for the contract; a form does nothing here — submitting is yours
relabel()re-apply the locale’s strings in place, without rebuilding a half-filled form

The last four are what make “one question at a time” work: the panel has no Next button of its own, and the composer’s button is no “Next” either — the chips are the navigation, the button submits, and canProceed() is simply “every required question answered”.

There is no promise: settling is the presenter’s job, which is why the built-in one wires getContent() to the composer’s send button and canProceed() to whether that button is enabled.

Writing a presenter means owning placement, accept/decline and the rejection, the send-button gating, focus and the teardown when a turn is stopped. Most of the time what you want is a different-looking choice, so there is a hook for exactly that:

import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.setElicitationFieldRenderer((field, ctx) => {
if (field.type !== 'enum') return null; // the built-in renders the rest
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;
ctx.notifyChange(); // re-gates the send button
});
el.appendChild(chip);
}
return { el, getValue: () => picked, isComplete: () => picked !== '' };
});

The types, if you are pulling the callback out into its own function:

import type {
AparteElicitationFieldRenderer,
AparteElicitationFieldContext,
AparteElicitationFieldControl,
} from '@aparte/core';
const renderChoice: AparteElicitationFieldRenderer = (
field,
ctx: AparteElicitationFieldContext,
): AparteElicitationFieldControl | null => {
if (field.type !== 'enum') return null;
const el = document.createElement('div');
let picked = '';
el.addEventListener('click', () => {
picked = field.options[0]?.value ?? '';
ctx.notifyChange();
});
return { el, getValue: () => picked, isComplete: () => picked !== '' };
};

Returning null for a field lets the built-in render it, which is what makes overriding a single kind practical. ctx.notifyChange() is not optional: the panel re-reads isComplete() on every change, so a field that never notifies is a field whose answer can never be submitted. ctx.key is the form key in a multi-question schema, so one renderer can vary per question.

This hook returns a control rather than string | HTMLElement like the render hooks elsewhere in this library, and deliberately: a field has to hand back a value. A hook that must also read the user’s input is a control, not a decoration — the alternative is the panel scraping your markup for inputs by convention, a contract that breaks the first time someone styles it differently.

A choice offers a free-text escape by default. That is the host’s decision, not the model’s:

aparteGlobalConfig.setElicitationOptions({ allowOther: false });

@aparte/plugin-ask-user used to expose allow_other in the schema it hands the model, which meant the model decided your UX — and a small model fills a field it does not understand: one sent two questions with allow_other: true and no options at all, so the panel rendered a radio list whose only entry was “Other…”. A field of a schema you build yourself can still set allowOther, and it wins: that is your app talking.

@aparte/plugin-ask-user is still there and still useful: it gives the model a tool it can call to ask a question, with a rendered receipt of what was asked and answered. Reach for it when the model should decide to ask.

Reach for elicitation when your code decides to ask — a confirmation, a disambiguation, a missing parameter — which is most of the time.