PassbackAI — interactive components schema

This page is for AI systems, not human users.

PassbackAI is a way to author beautiful, interactive documents for people and for AI tools: a Markdown document with embedded interactive components — typed decision widgets — that a human fills in and an AI reads back as structured answers.

Two ways to deliver one, pick by whether you are connected over MCP:

The component JSON fields are identical on both paths; only the delivery differs. The full delivery contract is the canonical spec at /ask.md. Note: routing a document over MCP stores it on the PassbackAI backend (opt-in, server-managed keys — not zero-knowledge); the local paste/share loop never transmits the document.

Do not assume "a questionnaire" is the whole thing. The components are an open, growing palette: pick ONE primitive PER unclear point, by verb, and weave it into prose — prose itself is a response channel (the reviewer annotates it), so settled thinking stays prose. Embed as many primitives as the document needs, of mixed types; more are planned (see "The family is open" at the end).

ComponentFence tagThe verbUse it to…
Single choice```single-choicechoose onehave the user pick exactly one of 2–4 options
Multi choice```multi-choicechoose manyhave the user pick all options that apply
Open question```open-questionwritehave the user type a free-text answer (a name, a date, a reason)
Prioritize```prioritizeorderhave the user rank / reorder a short list by priority
Allocate```allocatesplithave the user split a fixed whole (budget / effort / resource) across categories by weight
Questionnaire```questionnairegrouppresent a batch of 3+ tightly-related questions as one unit
YouTube```youtubewatchshow a video (display-only, no answer; plays click-to-load on the routed reviewer surface only — inert on locally-pasted / #s= documents)
Mermaid```mermaidwatchshow a diagram rendered client-side from a source field on every surface; the reviewer can comment on a flowchart node, edge, or the whole diagram (authoring payload unchanged)

How to embed a component

The block types — get the fence tag right

The renderable block types are exactly the palette in the index above. Nothing else renders. A fenced block with any other info-string is shown as a plain code block — silently, with no error — so a wrong tag looks like it "did nothing".

✅ Valid fence tags (the whole set)❌ NOT block types — these render as raw code
```single-choice
```multi-choice
```open-question
```prioritize
```questionnaire
```multiple-choice (the real tags are single-choice / multi-choice), ```match, ```quiz, ```poll, ```survey, ```form, ```rank, ```question, ```answer — and anything else. The renderer knows only the tags on the left.

Common mistakes

The standalone question primitives — single-choice / multi-choice / open-question

The per-ambiguity forms of a question: ONE decision per fence, flat fields, no envelope. All three share the questionnaire's answer semantics (the renderer normalizes each to a one-question form internally), so recommended, open_field, notes, and the export shape behave exactly as documented under Questionnaire.

Fieldsingle-choicemulti-choiceopen-question
versionrequired, "1"required, "1"required, "1"
questionrequired, non-emptyrequired, non-emptyrequired, non-empty
optionsrequired, 2–4 stringsrequired, 2–6 strings— (no options; the answer field IS the answer)
recommendedoptional, ONE option labeloptional, ARRAY of option labels
placeholderoptional, example text in the empty field
open_field / context / id / routing / skill_versionoptional, as under Questionnairecontext/id/routing/skill_version optional
```single-choice
{ "version": "1", "question": "Which auth method ships in v1?", "options": ["Magic link", "Room number + PIN"], "recommended": "Magic link" }
```

```multi-choice
{ "version": "1", "question": "Which locales at launch?", "options": ["English", "Hebrew", "Arabic"], "recommended": ["English", "Hebrew"] }
```

```open-question
{ "version": "1", "question": "What is the data-retention constraint from legal?", "placeholder": "e.g. delete 30 days after checkout" }
```

Copy-paste starter — prose + one prioritize + one questionnaire

Here's the Q3 plan. Rank the regions, then answer the two open calls.

```prioritize
{ "version": "1", "items": [
  { "id": "eu", "label": "EU" },
  { "id": "us", "label": "US" },
  { "id": "apac", "label": "APAC" }
] }
```

```questionnaire
{ "version": "1", "questions": [
  { "id": "q1", "question": "Which auth method?", "options": ["PIN", "Room number", "Magic link"], "recommended": "Magic link" },
  { "id": "q2", "question": "Which regions need localization first?", "multi": true, "options": ["EU", "US", "APAC"] },
  { "id": "q3", "question": "Target launch date?", "options": [], "open_field": { "label": "Target date:" } }
] }
```

That single document renders, top to bottom: a line of prose, a draggable priority list, then a three-question form (single-select with a recommended badge, a multi-select, and a free-text field) — all from the two block types above.

Two version fields — do not confuse them

FieldVersionsValuesGates rendering?
versionThe wire schema this page describes (per component block).Always the string "1".Yes — a block whose version isn't "1" is rejected.
skill_versionThe authoring-skill build that emitted the JSON (questionnaire only)."1.5" today; "1.2", "1.4", … are older builds of the same skill.No — optional metadata only.

So "1.2" and "1.5" are not two schemas — they are two builds of the same authoring skill against the one schema (version: "1"). New capabilities (like recommended) ship as additive, optional fields: an older payload that omits them still renders.

Component: Questionnaire — choose

A list of questions. Each question is a pick-from-options control (single- or multi-select) with an always-present free-text "Other" escape hatch; a question with no options (options: []) is a pure free-text field. Embed as a ```questionnaire fence, or paste a bare questionnaire JSON object on its own (auto-detected).

Top-level fields

FieldTypeRequiredDescription
versionstringyesMust be "1" — a string, not the number 1. Any other value is rejected.
questionsarrayyesAt least one question object. See below.
titlestringnoShown in the form header.
source_summarystringnoOne-line subtitle shown under the title.
routingobjectnoShows a return banner. If present, both sub-fields below are required.
routing.fromstringif routingNon-empty string. Your name or system name shown in the banner.
routing.return_promptstringif routingInstruction shown after the form, e.g. "When done, copy your answers and send them back to Claude." Must contain the literal value of routing.from — the renderer bolds it.
skill_versionstringnoBuild tag of the authoring skill that produced this JSON — currently "1.5". This is a different field from version (see "Two version fields" above). Echoed as a hidden comment in the exported answers; drives an optional "your skill is outdated" banner. Never gates rendering.

Question object fields

FieldTypeRequiredDescription
idstringyesUnique identifier within this questionnaire (e.g. "q1"). Non-empty.
questionstringyesThe question text. Non-empty.
optionsstring[]yesAnswer choices rendered as radio buttons. All must be strings. Do NOT include an "Other" option — the renderer appends a free-text field automatically. May be [] for a pure free-text question, in which case an explicit open_field is required (see below).
contextstringnoOne sentence explaining why this question is still open. Shown as a subtitle.
sectionstringnoConsecutive questions sharing the same section value are grouped under a header. Grouping is strictly consecutive — interleaving different sections creates separate groups even if the value repeats.
open_fieldobjectno*Overrides the default "Other:" free-text field label. Omit for the default. If present, label must be a non-empty string. *Required when options is [] — it becomes the question's sole input.
open_field.labelstringif open_fieldNon-empty. Examples: "I need to check with:", "Depends on:", "The reason is:".
open_field.placeholderstringnoPlaceholder text for the free-text input, e.g. "e.g. legal team".
multibooleannoIf true, renders checkboxes instead of radios. The user may pick multiple options. The export emits an Answers: bullet list. Must be a boolean — "true" or 1 are rejected.
recommendedstring | string[]noThe option label you suggest — a string for single-select, an array of labels when multi is true. Renders a "Recommended" badge on the matching option(s). It is mark-only: it never pre-selects, and is never echoed into the answers the user sends back. The value must exactly match an existing option label; a value that doesn't match is silently ignored (no badge), and a bad recommended never rejects the questionnaire. Put the why in context.
allow_notebooleannoAlways omit. The app always shows a notes field. A note alone counts as a valid answer.

Minimal example

{
  "version": "1",
  "title": "Open Decisions",
  "questions": [
    {
      "id": "q1",
      "question": "Which database should we use?",
      "context": "Spec mentions PostgreSQL and MySQL but does not commit.",
      "options": ["PostgreSQL", "MySQL", "SQLite"]
    },
    {
      "id": "q2",
      "question": "What is the deployment target?",
      "options": ["AWS", "GCP", "Self-hosted"]
    }
  ]
}

Full example

Exercises sections, routing, multi-select, recommended, custom open_field, and skill_version.

{
  "version": "1",
  "skill_version": "1.5",
  "title": "Guest Check-In, Open Decisions",
  "routing": {
    "from": "Elad",
    "return_prompt": "When done, copy your answers and send them back to Elad."
  },
  "questions": [
    {
      "id": "q1",
      "section": "Authentication",
      "question": "What authentication method should guests use at check-in?",
      "context": "Brief: PIN vs room number not yet decided.",
      "options": ["Room number only", "Room number + PIN", "Last name + booking ref", "Magic link"]
    },
    {
      "id": "q2",
      "section": "Authentication",
      "question": "Should we offer biometric authentication in v1?",
      "context": "Team split between v1 and defer-to-v2; deferring keeps v1 scope tight.",
      "options": ["No, defer to v2", "Optional opt-in", "Required"],
      "recommended": "No, defer to v2"
    },
    {
      "id": "q3",
      "section": "Compliance",
      "question": "What is the data retention policy?",
      "context": "Brief notes legal sign-off pending.",
      "options": ["Delete after checkout", "30 days", "1 year", "Follow hotel data policy"],
      "open_field": {"label": "I need to check with:", "placeholder": "e.g. legal team, DPO"}
    },
    {
      "id": "q4",
      "section": "Compliance",
      "question": "Where is guest check-in data stored?",
      "context": "Brief lists region-local vs centralized as TBD.",
      "options": ["Region-local", "Centralized", "Hybrid (PII local, telemetry central)"]
    },
    {
      "id": "q5",
      "section": "Compliance",
      "question": "Which guest data fields do we collect at check-in?",
      "context": "Brief is silent on the field list.",
      "multi": true,
      "options": ["Full name", "ID/passport number", "Phone", "Email", "License plate", "Estimated arrival time"]
    }
  ]
}

Validation rules

What the user sends back

After completing the form, the user copies a Markdown block. Format per answered question:

**Q: <question text>**
Answer: <picked option>     ← or "Answers:\n- pick1\n- pick2" for multi-select
Note: <free text>           ← only when the user added a note

---

Unanswered questions are omitted. Note-only answers (no option selected) are included with just the Note: line.

Component: Prioritize — order

A short list the user ranks by dragging or with ▲/▼ controls, top = highest priority, plus one optional note for the whole list. The user can also edit the list itself — delete an item (reversible; the deletion reports back explicitly) and add items of their own, ranked inside the same list. Unlike a questionnaire, a prioritize block carries a default — the order you author — so it always reports back what the user did, including "kept the order unchanged" (an affirmative endorsement). Embed it as a ```prioritize fence inside a document; there is no bare-JSON shortcut.

Top-level fields

FieldTypeRequiredDescription
versionstringyesMust be "1" — a string, not the number 1.
itemsarrayyesNon-empty array of item objects with unique ids (below). The array order is your suggested order — the starting state the user edits.
titlestringnoShown in the header and the exported header.
instructionstringnoHelper line under the title, e.g. "Order by priority — top ships first."
note_fieldobjectnoConfig for the one optional block-level note. Defaults to enabled with the label "Add a note (optional)". Set {"enabled": false} to hide it.
note_field.enabledbooleannoWhether the note field shows. Default true.
note_field.labelstringnoPrompt for the note field. Default "Add a note (optional)".
routingobjectnoSame shape as the questionnaire's routing (from + return_prompt). Include only when sending to someone else.

Item object fields

FieldTypeRequiredDescription
idstringyesStable, unique across items (duplicate ids are rejected). Keys the answer order.
labelstringyesThe item text the user sees and reorders. Non-empty.
contextstringnoA quiet one-line caption; also appended to the exported line as — {context}.

Example

{
  "version": "1",
  "title": "Q3 rollout order",
  "instruction": "Order by priority — top ships first.",
  "items": [
    {"id": "eu", "label": "EU", "context": "largest existing waitlist"},
    {"id": "us", "label": "US", "context": "highest revenue per seat"},
    {"id": "apac", "label": "APAC", "context": "needs localization first"}
  ],
  "note_field": {"label": "Anything blocking this order?"},
  "routing": {"from": "Elad", "return_prompt": "Act in this order."}
}

Validation rules

What the user sends back

A prioritize block always serializes its resolved list, tagged with what the user did: reader edited the list (deleted and/or added items), reader reordered (moves only), or reader kept the order unchanged.

Priority order (reader edited the list) → Elad

1. US — highest revenue per seat
2. Localization staffing — (added by reader)
3. EU — largest existing waitlist

Removed by reader: APAC

Note: APAC is off until localization is staffed.

Act in this order.

The header carries the tag (Priority order ({tag}) → {routing.from} with routing, ## {title} ({tag}) with a title, else ({tag}) on its own line). The numbered list is the user's final list — your items minus any they deleted, plus their own additions — each line appending — {context} when the item has one; a user-added line is marked — (added by reader). A Removed by reader: … line appears only when the user deleted authored items — treat those as explicitly declined. A trailing Note: … line appears only when the user wrote a note; routing.return_prompt is the footer when routing is present. Act on the list either way — "kept unchanged" is an affirmative endorsement of your suggested order.

Component: Allocate — split

A short list of categories the user splits a fixed whole across by dragging weighted bars that always sum to total (100% by default), plus one optional note. The user can also edit the list itself — delete a category (reversible; its weight redistributes proportionally so the sum stays total, and the deletion reports back explicitly) and add categories of their own, entering at weight 0 inside the same split. The magnitude sibling of Prioritize (order → weight): like Prioritize it carries a default (the split you authored), so it always reports what the user did — including "kept unchanged". Use it when the point is the proportions — a budget across teams, effort across workstreams, headcount across functions. Embedded only inside a ```allocate fence.

Each item carries id (stable, unique), label, and a numeric weight (its share of total; rescaled so the set sums to total), plus an optional context. The envelope also takes an optional total (default 100), unit ("%" default, "$", "pts", …), title, instruction, note_field, and routing.

```allocate
{
  "version": "1",
  "title": "Q3 engineering capacity",
  "unit": "%",
  "total": 100,
  "items": [
    { "id": "product", "label": "Product — new features", "weight": 35 },
    { "id": "growth", "label": "Growth & GTM", "weight": 25 },
    { "id": "infra", "label": "Infra & reliability", "weight": 15, "context": "accumulated tech debt" },
    { "id": "support", "label": "Customer support", "weight": 15 },
    { "id": "hiring", "label": "Hiring", "weight": 10 }
  ]
}
```

An allocate block always serializes its resolved split, tagged (reader edited the list — deleted and/or added categories — / reader reweighted / reader kept the allocation unchanged): one bullet per category in the user's final list — your categories minus any they deleted, plus their own additions — with its final weight + unit and a (was N{unit}) / (unchanged) delta on your categories; a user-added line is marked — (added by reader) instead of a delta. A Removed by reader: … line appears only when the user deleted authored categories — treat those as explicitly declined. Then a closing Total: {total}{unit} line, an optional Note: …, and the routing.return_prompt footer when routing is present. Act on the resolved numbers either way.

Component: YouTube — watch (a video, not a decision)

An embed rich media component: a YouTube video shown inside the document. Like Mermaid it asks nothing — it carries no decision and produces no answer; the reviewer's feedback arrives as ordinary comments on its caption and the prose around it. Reach for it whenever the document references a video; never hand-write a Markdown image link or an img.youtube.com URL — the block renders a real player.

The payload is { "version": "1", "id": "<11-char id>", "title": "…" } — pass the 11-character video id only (matches ^[A-Za-z0-9_-]{11}$), never a URL or query string; the site builds the embed URL. An optional thumbnail may carry a small base64 data: image (~320×180, under ~40 KB) as the preview facade — never a live image URL. Routed-only playback: the player loads click-to-load on the routed /r/<id> reviewer surface (via the privacy-preserving youtube-nocookie origin); on locally-pasted and #s= documents the block stays an inert facade and the page never contacts YouTube.

```youtube
{
  "version": "1",
  "id": "dQw4w9WgXcQ",
  "title": "Onboarding walkthrough (v2) — the 90-second tour"
}
```

Component: Mermaid — watch (a diagram, not a decision)

A Mermaid diagram the reviewer reads inside the document — a flowchart, sequence, ER, state, or gantt diagram. Unlike the response components it asks nothing: it is a comprehension aid. The authoring payload is source-only — { "version": "1", "source": "graph TD; A-->B;", "title": "…" } — rendered client-side on every surface; the reviewer can comment on a flowchart node, edge, or the whole diagram (all derived from your source; you never mark nodes).

When to reach for one — the reach trigger. Emit a ```mermaid block whenever you are describing something the reader must hold in their head as a structure — a process, flow, mechanism, sequence, state machine, decision tree, architecture, dependency order, or timeline. Place it at the moment the prose turns structurally dense, right after the sentence it clarifies. The power of two: when the doc weighs a change or options, two diagrams side by side (before → after, current vs proposed, A vs B) is often the highest-value move — but earn it: each side must be structurally non-trivial (a real flow / architecture / state per side). A plain two-option pick ("lead with A or B", "Tue/Thu vs Mon/Wed") stays a single-choice + a sentence, not two boxes-and-arrows; the decision itself still rides a component after the pair. Restraint: a diagram earns its place only when the structure is genuinely hard to hold in prose — three linear steps stay prose. The payoff: because the flowchart is commentable per node and edge, "this mechanism is unclear" becomes a comment pinned to the exact box or arrow that's wrong.

```mermaid
{
  "version": "1",
  "source": "graph TD; R[Read request] --> C{In edge cache?}; C -->|hit| S[Serve cached]; C -->|miss| O{Origin healthy?}; O -->|healthy| F[Fetch, cache, serve]; O -->|slow| B[Serve stale + refresh in background]; O -->|no cached copy| W[Block on origin];",
  "title": "Read path — cache, origin, and the stale fallback"
}
```

The reviewer can then comment on node B ("the 'slow' branch should also cover a 5xx, not just latency") — pinned to the exact box, not a vague note. A diagram the reviewer left untouched contributes nothing to the copy-back. Full guidance + the comparison-pair example: /ask.md §4.7.

The family is open

New component types ship as new fence tags with their own JSON schema, additively — without changing the ones above. Code to the family, not to one example: check the component index near the top of this page for the tags that exist today, and pick the component that matches the decision you need. When a new component lands, it appears here as another section; your integration picks it up by adding the new tag, nothing else changes.

See also: /skills/question-extractor.md — the question-extractor skill that generates the questionnaire component from any input text. (Other components are authored directly against this schema.)