Skip to content

Elicitation Forms

Overview

Elicitation forms let the agent shape an exchange to fit the moment: instead of a wall of prose, it renders an interactive form whenever a structured turn communicates better than text. A multi-part question becomes one form you answer with a click; a recommendation arrives as weighable cards; a disagreement is shown side-by-side so you can overrule it in one tap. The goal is plainly to improve human/AI communication — make the back-and-forth faster, clearer, and less ambiguous.

Every form is one declarative artifact — a FormSchema of FormQuestions — built from plain data with form_from_dict, validated once with collect_form_response, and rendered by surface-specific renderers. The same artifact renders richly on a widget-capable client and degrades gracefully everywhere else; the answer is validated the same way regardless of surface.

On top of that shared substrate sits a small, growing communication grammar — a family of constructs, each a member of the one form model:

  • intake — gather several independent decisions as one (multi-select-capable) form;
  • decision — offer a recommended option with a rationale and per-option tradeoffs;
  • pushback — disagree with a stated approach and offer a concrete alternative;
  • progress — report a set of items by status and surface the blocked ones as a picker.

The substrate itself — the FormSchema models, the bridge, the surface renderers, the theme, the intake template engine, and the form_events telemetry — lives in the standalone attune-forms package (PyPI: attune-forms), which attune-ai depends on. The attune.elicitation import paths used throughout these docs remain fully supported: they alias the same module objects, so imports, monkeypatching, and class identity behave exactly as before the extraction. What lives in this repo is the attune-side integration — the host seams, the attune-specific intake templates, and the MCP tools that expose the forms. When a construct fires in a conversation is a judgment call governed by the agent's decision routine, not by this subsystem.

Concepts

One substrate, many constructs

A construct is not a parallel system — it is meaning and presentation layered on the single form model. Adding a construct almost never adds a new round-trip or a new validator; it adds a QuestionType and a few optional FormQuestion fields, and reuses the existing answer path.

Construct QuestionType Answer Extra fields
intake single_select / multi_select / … the picked value(s)
decision decision one option rationale, option_notes, recommended
pushback pushback one option user_position (+ reuses recommended, rationale, option_notes)
progress progress one blocked item progress_items (+ reuses recommended, rationale)

decision, pushback, and progress are all presentation-enriched single-selects: the answer is one option, validated by membership exactly like a plain single_select. What differs is the rendering and the framing.

Validation is never skipped (R4)

collect_form_response is the one validator. A missing required field with no default, or a value outside a select's options, raises FormValidationError naming every offending field so the caller can re-ask just those — it never silently accepts malformed input. This holds for every construct, because every construct's answer is a select answer underneath.

Surfaces, and graceful degradation

A form renders three ways, in order of richness:

  • Widget (form_to_widget_htmlshow_widget) — the default: cards, badges, the three-bucket progress board, dissent framing. Renders on widget-capable clients (claude.ai / Cowork). Answers post back through a sentinel-marked JSON block which you validate with collect_form_response.
  • AskUserQuestion (form_to_askuserquestion) — the fallback: batched payloads (≤4 questions each), recommendation-first. The constructs collapse to a recommendation-ordered single-select here. Works in a plain terminal.
  • Native MCP elicitation (form_to_elicitation_schema) — a JSON-schema form for clients with native elicitation. It does not render on Claude Code today and lacks multi-select, so it is not the default.

Choosing a surface

select_form_surface(form, widget_capable=…, keyboard_mode=…) decides, and the rich widget is what it decides by default. The axis is how much of the option space the reader can see at once, not how many tool calls it costs — folding three options and their tradeoffs into prose above a single-select turns a scan into a serial read.

Precedence, highest first:

  1. Client can't render widgetsAskUserQuestion. A constraint.
  2. A number / date / textarea field → widget, always. No AskUserQuestion control exists, so this outranks the opt-out and a field can never be silently dropped.
  3. Keyboard mode onAskUserQuestion.
  4. Trivial formAskUserQuestion. Trivial is narrow and mechanical: one single_select/boolean, ≤3 options, no option label over 120 characters. A long label means a tradeoff was folded into the text — that form wanted a card.
  5. Otherwise → widget.

Latency is not an input. needs_widget still exists as the low-level "does this lose fidelity on AskUserQuestion" check, but it no longer owns the decision.

Keyboard mode

Keyboard mode is the opt-out for people who would rather type than click. Turn it on with the CLI:

attune config set keyboard_mode true

It persists per project as keyboard_mode in ./attune.config.json, so it survives restarts and stays scoped to the repo you set it in. attune config show reports the current value. ATTUNE_KEYBOARD_MODE=1 (or 0) overrides it for one shell in either direction.

Nobody has to know the setting exists to find it: after ten answered forms, the next submission surfaces a one-time hint pointing at the command (D17's usage-triggered discovery — it reaches people who have felt the friction, and never fires for someone already opted in). The terse reply vocabulary (y / go / 1) answers any construct on any surface regardless — a form never blocks a keyboard-only user.

Inference-first — don't ask what you can already answer

Ceremony isn't caused by forms being rich. It's caused by being asked things you already answered. So fill in what the conversation already told you, and let the form confirm rather than interrogate.

A field carries inferred_from alongside its default — the value plus why you guessed it:

{
  "id": "scope",
  "type": "single_select",
  "text": "Which path?",
  "options": ["src", "tests"],
  "default": "src",
  "inferred_from": "you have been editing src/attune/elicitation/"
}

An inference without a value is a definition error, so a "guessed" badge can never appear over an empty control.

A guess must look like a guess. The widget badges every inferred field and shows the provenance under the label; the AskUserQuestion fallback folds it into help text (Guessed: src — you have been editing src/). Neither surface presents an inferred value as settled, which is what makes a wrong guess catchable instead of silently accepted.

When every field is inferred, the form still renders — as a one-tap confirmation with a "Confirm" button, not a skipped step. Skipping would be faster, and it is the one thing that must not happen: a correct- looking wrong guess the user never got to see is the only failure a form cannot recover from. Fields stay editable, so confirming is a review.

is_fully_inferred(form) drives that mode; inferred_field_count(form) reports how much a form inferred. attune.telemetry.form_events records both per routing decision, and inference_rate() reads them back — inference-first is authoring discipline, so counting it is the only way to know it is being followed rather than merely documented.

Collapsing an answered form

Once a form is submitted, the rendered markup has done its job and only the question/answer pairs still carry meaning. form_response_summary(form, response) returns a title line plus one bullet per answer, so a long session accumulates summaries instead of screenfuls of HTML.

The list render variant (list_style)

A single_select or multi_select can set list_style: "ordered" (numbered) or "unordered" (bulleted) to render its options as a familiar intro-sentence-plus-list shape — each item pickable by mouse or the 1 / 2 / 3 vocabulary — instead of a dropdown or checkboxes. This is presentation only: the answer and its validation are unchanged. It is a render option on the select types, not a separate construct.

Design & extension

The grammar's extension gate

A new construct is justified only when rendering or answer-meaning genuinely differs. If the answer is shaped like an existing one (a single-select pick), prefer reusing that answer path and adding a QuestionType plus optional fields — not a parallel validator. If only the look differs, it is a render variant of an existing type, not a new member. The list_style list render is the worked example: it ships the "intro + selectable list" shape as an option on the select types rather than as a fifth construct.

Adding a construct — the touch points

Even a construct that reuses the single-select answer path must register its QuestionType at each enumeration site: the model, the definition validator, the widget submit-script reader, the native-schema mapper, the MCP tool schema (and its count guard), and the driving skill's docs. A per-type "rejects out-of-option" test is the cheap guard that catches a missed validation site. Prove it with a non-mocked round-trip — render, submit, collect — not just unit tests.