Questionnaires
MCP tools for creating, editing, and triaging in-app questionnaires.
Questionnaires are structured multi-question surveys shown in-app via the Swift SDK's OwlQuestionnaireView or the auto-trigger .owlQuestionnaire(slug:trigger:…) view modifier. The auto-trigger opens at a small "Have a minute for feedback?" detent with three actions (take the survey, maybe later, never ask again) and expands to a step-through flow on consent — one question per page with a progress bar, non-swipe-dismissible so users finish or cancel deliberately. Each questionnaire has an immutable slug, a versioned schema of up to 30 questions (text, single/multi choice, rating, NPS), and a one-and-done global dismiss path so users who reject are never asked again. Responses store a snapshot of the schema they were submitted against, so historical answers always render correctly even after the parent definition has been edited.
Drafts are first-class responses. The SDK saves the current answer set on every Next tap (not just on Submit), so a user who quits halfway leaves a row with submitted_at = null and status = 'draft'. The next eligible launch resumes them at the first unanswered question with prior answers pre-filled (consent is skipped on resume). The final Submit flips submitted_at to non-null, snapshots the schema onto the row, transitions status to new, and fires the team notification exactly once. Per-question analytics and response lists include drafts by default so abandonment shows up naturally as a drop-off curve; pass submitted_only to scope to completed submissions. Abandoned drafts untouched for 90 days are soft-deleted by the daily questionnaire_draft_cleanup job.
The questionnaire tool surface mirrors the CLI — agents can create + edit + triage, but soft-deleting a questionnaire is user-only (mirroring the dashboard behaviour).
Tools
| Tool | Description |
|---|---|
list-questionnaires | List questionnaire definitions — pass project_id for one project, or team_id for every questionnaire across the team's projects (mutually exclusive). Optional data_mode (production, development, all) filters the rolled-up response_count / submitted_count / last_response_at on each row; default is all modes mixed |
get-questionnaire | Fetch a single definition with response_count + last_response_at. Optional data_mode (production, development, all) filters those rollups; default is all modes mixed |
create-questionnaire | Create a new questionnaire with slug + schema |
update-questionnaire | Update name, description, schema, is_active, app_id pin |
delete-questionnaire | ⚠️ User-only — agent calls receive 403 |
list-questionnaire-responses | List responses with filters + pagination. Drafts (status: "draft", submitted_at: null) are included by default — pass submitted_only: true to hide them, or status: "draft" to scope to drafts only |
get-questionnaire-response | Fetch a single response with comments + schema_snapshot (snapshot is null on drafts) |
update-questionnaire-response-status | Triage state transition. Statuses: draft, new, in_review, addressed, dismissed |
add-questionnaire-response-comment | Annotate a response |
get-questionnaire-analytics | Pre-aggregated per-question distribution. Drafts contribute to the rollups by default — a Q1-only draft adds to Q1's count and naturally drops out of Q2+, so abandonment shows up as a drop-off curve. Pass submitted_only: true to scope rollups to fully-submitted responses |
Schema shape
create-questionnaire and update-questionnaire accept the same JSON schema:
{
"version": 1,
"questions": [
{ "id": "q_rating", "type": "rating", "title": "Rate us", "required": true, "scale": 5 },
{
"id": "q_role",
"type": "single_choice",
"title": "How would you describe yourself?",
"required": true,
"options": [
{ "id": "hobby", "label": "Hobbyist" },
{ "id": "indie", "label": "Indie developer" }
]
},
{ "id": "q_nps", "type": "nps", "title": "How likely to recommend?", "required": false },
{ "id": "q_text", "type": "text", "title": "Anything else?", "required": false, "multiline": true }
]
}Constraints:
slugmatches^[a-z0-9-]+$, max 64 chars, immutable after creation- Up to 30 questions per questionnaire
- Question id matches
^[a-z0-9_]+$, max 32 chars, unique per questionnaire single_choiceandmulti_choicerequire 2–20 optionsrating.scaleis fixed at5in V1npsis implicit 0–10textdefaults to a single-line input. Add"multiline": true(as onq_textabove) for paragraph-style answers — the SDK renders a tall, growing text editor. Same 4000-character cap either way.
Typical workflow
- Create a survey definition —
create-questionnairewith a slug + schema (agent-friendly: agents can build short market-research surveys autonomously). - Wait for responses — the iOS app's
.owlQuestionnaire(slug:trigger:…)modifier fetches and presents the survey on the configured trigger, saving a draft after every Next tap. - Aggregate analytics —
get-questionnaire-analyticsreturns counts/averages/NPS score per question; the agent can summarize trends without paging through individual responses. Usesubmitted_only: trueif you want completion-rate-style headline numbers without draft contributions; leave it off to see where users drop out. - Drill into interesting answers —
list-questionnaire-responsesthenget-questionnaire-responsefor context.session_idon the response links to the user's event timeline — pass it toinvestigate-eventto see what they were doing. Filter to drafts (status: "draft") to study abandonment, or passsubmitted_only: truefor completed submissions only. - Annotate or triage —
add-questionnaire-response-commentandupdate-questionnaire-response-statuscooperate with human teammates on the dashboard.
Eligibility model (read-only)
The eligibility envelope (already_responded / globally_dismissed / inactive) is enforced by the SDK ingest endpoints and isn't directly exposed via MCP. To understand why a particular user wouldn't see a survey, check:
- The questionnaire's
is_activeflag (get-questionnaire) - The user's
app_users.properties._questionnaires_dismissed_at(visible via the user page in the dashboard; soon via aget-userMCP tool) - Whether the user already has a response (
list-questionnaire-responsesfiltered to theiruser_id)
