Skip to main content
The question component (canvas.question) shows a multiple-choice question card during a conversation. The user taps an option (or types a free-text answer, when allowed) and the structured result goes to the PAL and your conversation webhook.

Triggering

The PAL shows the card by invoking canvas_show_question when the user should answer a structured question. Steer the timing in the PAL’s system prompt.
  • Renders in the safe-area-right slot by default.
  • A repeat canvas_show_question invocation replaces the card currently on screen with a new instance and a new tool_call_id; in-progress input is lost and interactions arrive under the new id.
  • Only one canvas card is on screen at a time. A new canvas_show_question invocation always replaces the current card, even when it targets a different side slot (e.g. layout.preferred_slot: "safe-area-left"); two cards never appear at once.
  • Every Canvas PAL also gets an update_component control action that patches the existing card without replacing it.
question has no component-specific settings beyond enabled. See Enabling and configuring components.

Arguments

The PAL passes these to canvas_show_question:
FieldTypeRequiredDescription
questionstringThe question text shown on the card. Must be non-empty.
optionsarrayThe selectable options. 2–10 items. Option ids must be unique.
options[].idstringStable identifier, echoed back verbatim in selected_option_ids.
options[].labelstringHuman-readable option text shown to the user.
options[].allow_free_textbooleanWhen true, selecting this option reveals a text input for extra detail on that specific choice. Independent of allow_other.
options[].free_text_placeholderstringPlaceholder for that option’s detail input.
allow_skipbooleanWhether the user may skip the question. Default false.
multi_selectbooleanWhether the user may select more than one option. Default false.
allow_otherbooleanAdds an “Other” affordance the user can expand to type a free-text answer. Single-select: mutually exclusive with the preset options. Multi-select: submitted alongside them. Default false.
other_labelstringLabel for the “Other” affordance (e.g. “None of the above”). A generic label is used when omitted.
other_placeholderstringPlaceholder for the “Other” text input.
correct_answerstringOption id of the correct answer for quiz-style questions. When set, the card briefly reveals the correct option against the user’s choice after they submit, then clears. Must match one of options[].id. Omit for surveys, preferences, and open-ended questions; use only when there is one objectively correct option.
Tavus injects two runtime controls on every Canvas action:
FieldTypeValues
layout.preferred_slotenumsafe-area-right, safe-area-left
display_modeenuminline

Example invocation

{
  "question": "Which database should we use for the new service?",
  "options": [
    { "id": "postgres", "label": "PostgreSQL" },
    { "id": "mysql", "label": "MySQL" },
    { "id": "sqlite", "label": "SQLite" }
  ],
  "allow_other": true,
  "other_label": "None of the above",
  "other_placeholder": "Tell us what you'd prefer"
}
Quiz example with answer reveal:
{
  "question": "Which planet is closest to the Sun?",
  "options": [
    { "id": "mercury", "label": "Mercury" },
    { "id": "venus", "label": "Venus" },
    { "id": "earth", "label": "Earth" }
  ],
  "correct_answer": "mercury"
}

Interactions

question is a submit-capable component emitting six interaction types: submit, skip, dismiss, clear, error, and heartbeat. Only submit and skip carry an answer value; the rest are lifecycle signals with no question payload. Each interaction reaches your conversation webhook as a canvas.interaction event with the answer in properties.value.

Value Shape (submit and skip)

FieldTypeRequiredDescription
selected_option_idsstring[]The chosen option ids, echoed exactly as the PAL set them. Empty on skip, or when the user answered only via “Other”.
skippedbooleantrue only on skip.
custom_textstringThe free-text answer from the “Other” affordance. Non-empty, at most 4000 characters.
option_textsobjectPer-option detail text, keyed by option id. Keys must be a subset of selected_option_ids. Each value is non-empty, at most 2000 characters.
Tavus rejects values with any other keys and enforces these rules before delivery:
  • skip: always skipped: true, with no selections, custom_text, or option_texts.
  • submit: always skipped: false, with at least one selected id or a non-empty custom_text; never an empty answer.
The hosted card offers Skip only when allow_skip is true. Tavus does not re-check an incoming skip against the original action arguments, so a custom renderer could post one regardless.

Example Payload

properties always carries the same nine keys. interaction_id uniquely identifies the interaction; tool_call_id ties it to the originating canvas_show_question call. created_at is a naive ISO-8601 timestamp with microseconds and no timezone suffix (no trailing Z); treat it as UTC.
Submit
{
  "event_type": "canvas.interaction",
  "properties": {
    "conversation_id": "c123456",
    "interaction_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
    "tool_call_id": "toolu_01A0B1C2D3E4F5G6H7J8K9L0",
    "component": "canvas.question",
    "component_version": "v1",
    "type": "submit",
    "value": {
      "selected_option_ids": ["postgres"],
      "skipped": false
    },
    "metadata": {},
    "created_at": "2026-06-09T21:14:03.123456"
  }
}

Webhook Handling

Branch on type first, then read the value:
app.post("/webhook", (req, res) => {
  const { event_type, properties } = req.body;
  if (event_type !== "canvas.interaction") return res.sendStatus(200);
  if (properties.component !== "canvas.question") return res.sendStatus(200);

  const { type, value } = properties;
  switch (type) {
    case "submit":
      // value.selected_option_ids: option ids the PAL defined
      // value.custom_text:        "Other" answer, if any
      // value.option_texts:       per-option detail, if any
      saveAnswer(properties.conversation_id, value);
      break;
    case "skip":
      // The user declined to answer.
      break;
    default:
      // dismiss / clear / error / heartbeat: lifecycle only, no answer payload.
      break;
  }
  res.sendStatus(200);
});
Key your logic on option ids, not labels. Labels may vary between conversations; ids are required to be unique and are echoed back verbatim.