Ask tool contract
August 12, 2026 ยท View on GitHub
ask_user is a pi-native clarification tool for cases where implementation depends on user preference or missing requirements.
This document defines the stable external behavior. It does not explain internal helper-by-helper implementation.
Input
{
title?: string;
questions: Array<{
id: string;
label?: string;
prompt: string;
type?: "single" | "multi" | "preview";
required?: boolean;
options: Array<{
value: string;
label: string;
description?: string;
preview?: string;
recommended?: boolean;
}>;
}>;
}
Input rules
- at least one question is required
- every question must have non-empty trimmed
idandprompt - every question must have at least one option
- question ids must be unique within one tool call
- option
values must be unique within a question - blank optional
title, questionlabel, optiondescription, and optionpreviewfields are treated as omitted - question
labelfalls back toQ1,Q2, ... - option
labelis required in the public schema; before schema validation, a missing or blank string label is derived from a non-emptyvalueby replacing hyphens and underscores with spaces and capitalizing the first character recommendedis optional presentation metadata; zero, one, or multiple options may set it totrue- recommended options render warning-colored
(recommended)followed by muted| <description>, or only(recommended)when no description exists, but are not preselected typedefaults tosinglerequireddefaults tofalserequiredis metadata only; it never blocks submission- preview questions require preview text for every declared option; option descriptions do not satisfy this requirement, and invalid preview payloads report a fix hint to add preview text or switch to
type: "single" - all questions get an internal
Type your ownoption
Output
{
content: [{ type: "text"; text: string }];
details: {
title?: string;
cancelled: boolean;
error?: {
kind: "invalid_input";
issues: Array<{
path: string;
message: string;
}>;
};
mode: "submit" | "elaborate";
questions: Array<{
id: string;
label: string;
prompt: string;
type: "single" | "multi" | "preview";
presentedType?: "single" | "multi" | "preview";
}>;
answers: Record<
string,
{
values: string[];
labels: string[];
indices: number[];
customText?: string;
note?: string;
optionNotes?: Record<string, string>;
}
>;
continuation?: {
strategy: "refine_only" | "resume";
affectedQuestionIds: string[];
preservedAnswers: Record<string, {
values: string[];
labels: string[];
indices: number[];
customText?: string;
note?: string;
optionNotes?: Record<string, string>;
}>;
questionStates: Record<string, {
status: "answered" | "needs_clarification" | "unanswered";
}>;
};
elaboration?: {
instruction: string;
nextAction: "clarify" | "clarify_then_reask";
items: Array<
| {
target: { kind: "question" };
question: {
id: string;
label: string;
prompt: string;
type: "single" | "multi" | "preview";
presentedType?: "single" | "multi" | "preview";
options: Array<{
value: string;
label: string;
description?: string;
preview?: string;
recommended?: boolean;
}>;
};
answered: boolean;
answer?: {
values: string[];
labels: string[];
indices: number[];
customText?: string;
note?: string;
optionNotes?: Record<string, string>;
};
note: string;
}
| {
target: { kind: "option"; optionValue: string };
question: {
id: string;
label: string;
prompt: string;
type: "single" | "multi" | "preview";
presentedType?: "single" | "multi" | "preview";
options: Array<{
value: string;
label: string;
description?: string;
preview?: string;
recommended?: boolean;
}>;
};
option: {
value: string;
label: string;
description?: string;
preview?: string;
recommended?: boolean;
};
selected: boolean;
answered: boolean;
answer?: {
values: string[];
labels: string[];
indices: number[];
customText?: string;
note?: string;
optionNotes?: Record<string, string>;
};
note: string;
}
>;
};
};
}
Output rules
cancelled: truemeans the user dismissed the flow, UI was unavailable, or the payload was invalid before UI opened- semantically invalid payloads that reach tool execution return
error.kind === "invalid_input"with structuredissuesand a transcript-friendlyInvalid ask_user payload:message; their rendered status isInvalid tool payload - payloads missing schema-required fields fail Pi's schema validation before tool execution and use Pi's standard tool-error result without structured
details mode: "submit"is normal completion;mode: "elaborate"means the user asked the agent to continue with follow-up clarification based on notes- unanswered questions without notes are omitted from
answers; note-only entries remain inanswersto carry their notes, but all non-cancelled submitted result text includes<label>: (no answer)in summary mode and? <label>: (no answer)in transcript rendering - in
mode: "elaborate",answerscontains only committed answers; note-only entries move toelaboration.items continuation.strategy === "refine_only"means the next ask should refine the current flow rather than restart itcontinuation.preservedAnswerscontains previously committed answers that should be kept as context and not re-askedcontinuation.affectedQuestionIdslists the only questions that should be revisitedcontinuation.questionStatesmarks each question asanswered,needs_clarification, orunanswered- single-select answers still use arrays
- recommendation markers never change canonical submitted labels or values
- when
behaviour.presentSingleAsMultiis enabled, requested single-select questions are presented and handled as multi-select in future/replayed ask flows; result question metadata keeps the requestedtype, addspresentedTypewhen final presentation differs, and result text uses one compact note when any answered questions were presented differently indicesare 1-based rendered option positionscustomTextstores the free-form answer- on single-select questions, saving free-form text clears selected options for that question
- on multi-select questions,
valuesandlabelsinclude both selected options andcustomTextwhen both are present - on multi-select questions, selected options keep their original order and
customTextis appended last - submitting free-form text on a multi-select question stays on the same question tab and marks the custom row selected
- on multi-select questions, toggling an empty custom row opens the free-form editor, while toggling a custom row with saved free-form text selects or deselects it without opening the editor or clearing the text
- saving or clearing free-form text on a multi-select question does not clear other selected options
notestores a question-level noteoptionNotesincludes only notes for selected options- question notes may exist without a selected answer
elaboration.itemsincludes all question notes and all option notes, even for unselected options- every elaboration item includes the full normalized question and option list for that question so referential notes like
aboveremain understandable to the agent - option-targeted elaboration items include the specific noted option plus whether it is currently selected
- question-targeted elaboration items include whether the question already has a committed answer
elaboration.instructiontells the agent to answer the clarification directly first, then re-ask only the affected questions if a choice is still needed- after clarification, agents should prefer another structured follow-up over plain-text multiple choice when a decision is still unresolved
- once prior answers narrow the branch, agents should bundle the next 2-3 related unresolved questions into one follow-up ask when possible, instead of using a long sequence of single-question asks
elaborationis only present whenmode === "elaborate"- elaborate
contenttext and transcript rendering describe each note directly using the full question prompt and option label, and include the current committed answer text when available, instead of a generic elaboration banner - when the user selects
Elaboratewithout adding notes, elaboratecontenttext and transcript rendering still include the committed answer text so the agent can elaborate on that answer directly
Supported UX
- tabbed multi-question flow
- single-select, multi-select, and preview questions
- optional warning-colored recommendation subtitles in standard and preview option lists without automatic selection
- active question type changes via configurable
main.changeQuestionTypehotkey, defaultt; non-preview questions togglesingle <-> multi; preview questions togglepreview <-> multi - inline free-form answers for all question types
- native pi-style
@file path autocomplete inside free-form answer and note editors - question notes via
Shift+N - option notes via
n - number-key quick selection
- submit/elaborate/cancel review tab
- on the review tab,
SubmitandCancelpreview notes only for answered questions - on the review tab,
Elaboratepreview expands to all question notes and all option notes, including notes on unselected options - transcript-friendly call and result rendering
/answercommand to convert the latest completed assistant message into anAskParamsform through a syntheticask_usertool call and open the ask UI/answerextraction may use an internalfreeform: trueoption for open-ended questions with no explicit choices; these render as user-input-only questions with the labelType your answer:, no numbered option row, and no selection caret; this marker is not part of the publicask_usertool contract/answer:againcommand to replay the latest/answer-extracted form on the current branch/ask:replaycommand to replay the latest realask_userform on the current branch- automatic recovery of the newest unresolved
ask_userform on startup, resume, or fork - ask settings list with binary behaviour/notification toggles and a guarded reset-to-defaults action
?in the ask flow and/ask-settingsin pi open the same lightweight ask settings overlay- settings attempt to persist immediately when changed:
Auto-submit when answered without notes,Confirm dismiss when dirty,Double-press review shortcuts,Notifications, andShow footer hints;Present single-select as multi-selectpersists immediately when saving succeeds but applies only to new/replayed ask flows; save failures revert the setting and show a manual-edit message; resetting config to defaults requires pressing the reset action twice within a short confirmation window Keymapsis a persisted, context-aware config section for global, main-flow, editor, note-editor, and settings-modal actions- the settings list shows the absolute config file path for customizing keymaps, notifications, and extraction settings
- if the flow is already on the review tab, all questions are answered, and no notes exist, enabling auto-submit can complete the current ask flow immediately
- elaborate results are phrased as direct follow-up instructions, for example:
User asked to elaborate on question "Which option would you like to select?" option "Option A" with note "why this one?"
Keyboard behavior
Main flow:
global.settingsopens ask settings; default:?global.dismissdismisses the active ask surface; default:Ctrl+Cmain.nextTab/main.previousTabmove between tabs; defaults:Tab/Right,Shift+Tab/Leftmain.nextOption/main.previousOptionmove between options or review actions; defaults:Down,Upmain.confirm,main.cancel, andmain.toggleconfirm, cancel, or toggle; defaults:Enter,Esc,Spacemain.changeQuestionTypechanges the active question type (non-preview:single <-> multi; preview:preview <-> multi); default:t; destructivemulti -> singlechanges require pressing the type hotkey again, with no timeout, and the pending confirmation clears on other navigation/actionsmain.optionNoteandmain.questionNoteopen option/question notes; defaults:n,Shift+N1..9is fixed and selects or toggles the matching option; on the review tab,1,2, and3triggerSubmit,Elaborate, andCancel- when
Double-press review shortcutsis enabled, review-tab1,2, and3require the same key twice without a timeout, and the review screen shows an inline hint for the pending action
Editing flow:
editor.submitsubmits the current custom-answer editor input and closes the editor; default:EnternoteEditor.savesaves the current note editor and keeps the ask flow open; default:Entereditor.close/noteEditor.closesave draft and close the editor; default:Escglobal.dismissdismisses the entire flow immediately without saving the current editor draft when no dirty-dismiss confirmation is pendingglobal.settingsopens ask settings when the editor is empty; otherwise the key is delegated to the editor as text/input- when editor has text, arrow keys and
Tabstay in the editor so the cursor can move while typing - when editor is empty, editor-context
*WhenEmptynavigation actions move options or tabs without requiring the editor close binding first @remains a fixed file-reference affordance in editors
Settings modal:
settingsModal.closecloses settings; defaults:Esc,Ctrl+C,?settingsModal.nextOption/settingsModal.previousOptionmove between settings; defaults:Down,UpsettingsModal.toggletoggles the highlighted setting and attempts to save immediately; if saving fails, the setting reverts and an error is shown; on the reset action, the same binding must be pressed twice within a short confirmation window; defaults:Enter,Space
Dirty dismiss:
- when
Confirm dismiss when dirtyis enabled, cancelling or dismissing a dirty ask flow requires the same action a second time - the dirty-dismiss warning stays visible until the user changes tabs in the ask flow
Non-TUI and non-interactive modes
The rich ask flow uses ctx.ui.custom() and opens only in TUI mode. In print, JSON, RPC, or any other non-TUI mode, the tool returns a Needs user input: ask_user requires interactive TUI mode. message in content and a cancelled result in details instead of opening custom UI.
The public tool schema requires question id and prompt plus option value and label, and it restricts question type to single, multi, or preview, so malformed structural fields fail before execution. The tool still validates trimmed text, uniqueness, option counts, and preview requirements during execution and returns structured issues for those failures. Result rendering falls back to Pi's raw tool-error text when schema validation prevents execution.
The ask flow subscribes to runtime settings updates while open. In practice, this means changing Auto-submit when answered without notes, Confirm dismiss when dirty, Double-press review shortcuts, Notifications, Show footer hints, resetting config to defaults, or reloading config-backed keymaps can affect the in-progress ask flow immediately instead of only future asks when the change is saved or otherwise applied in memory. Load-time migrations and invalid config handling do not rewrite, rename, or back up the config file; invalid files load defaults for the session and show a notice. Present single-select as multi-select is applied when an ask flow is created and does not rewrite question semantics for an already-open flow; use main.changeQuestionType for live per-question changes.
Notifications
When enabled, pi-ask emits one best-effort external notification per ask session after the ask UI opens and waits for input. The default title is pi ask; the message is Question waiting: <label or prompt>. Channels run in configured order and failures never fail or cancel the ask flow.
Remote inter-extension events
pi-ask exposes a local pi.events contract for trusted Pi extensions. It does not expose a network API and does not automate terminal keystrokes. RPC or headless integrations should use a trusted in-process bridge extension that consumes these events rather than expecting the TUI-only custom surface to open.
Channels:
@eko24ive/pi-ask:started@eko24ive/pi-ask:completed@eko24ive/pi-ask:submit@eko24ive/pi-ask:submit-result
Remote submissions must be explicit { kind: "answer" } or { kind: "cancel" } responses. Remote answers use question ids and normalized option values from the started event. pi-ask validates ids and values, recomputes labels/indices, and does not infer approval semantics from labels.
See remote-events.md for payload shapes, examples, and a local smoke test.
Slash command replay/extraction
- valid
ask_userpayloads are persisted as branch custom entries before the UI opens, so/ask:replaycan reopen them after cancel,/resume, or/tree /answerscans the current branch for the latest assistant message; if that message did not finish withstop, extraction is refused/answersends the preceding user message as context with the latest assistant text and asks the extractor for one syntheticask_usertool call- missing or invalid tool calls are retried according to
answer.extractionRetries; raw or fenced JSON text remains supported as a last-resort fallback { "questions": [] }from extraction means no questions were found and is not treated as an invalid ask payload- command-flow cancellation closes with a notification and does not send a message to the agent
- submitted or elaborated command-flow results are sent back with user-message semantics
- replay commands scan only
ctx.sessionManager.getBranch(), ignore sibling/future branch payloads, and revalidate stored payloads before opening the UI
Interrupted ask resume
- on
session_startwith reasonstartup,resume, orfork, pi-ask finds the newestask_usertool call on the active branch that has neither a tool result nor anask:pending-dismissedentry - recovery does not run for
new,reload, or non-TUI sessions - the matching valid
ask:payloadsupplies the form; if it is missing or invalid, pi-ask validates and uses the original tool call arguments instead - the recovery flow is detached from
session_start, so an open form does not block other lifecycle handlers - because the interrupted
executepromise no longer exists, submit sends the result with the same user-message semantics as replay commands - submit and cancel both append
ask:pending-dismissed, which prevents another automatic reopen;/ask:replaystill works - recovered flows emit remote lifecycle events with source
ask:resume
The fallback message includes normalized pending questions and options so the caller can re-ask them manually. details.questions still contains normalized question metadata, while details.answers stays empty until a user responds.
Skill alignment (advisory)
The auto-bundled skill profile at skills/ask-user/SKILL.md defines agent-side decision-gate guidance for when to call ask_user. It is enabled by default when the package is installed, but can be disabled via pi config.
It is advisory only. If there is any conflict, contract + tests win.
Source of truth
Behavior should be verified against:
src/types.tsand exported state/result helperstests/*.test.ts- this contract