Owlmetry
SDKsSwift SDK

Questionnaires

Render in-app surveys with OwlQuestionnaireView and the .owlQuestionnaire view modifier.

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.

Prerequisite

Create a questionnaire with a slug first — via the dashboard, CLI (owlmetry questionnaires create), or MCP. The SDK only reads and saves — it doesn't define questionnaires.

How the sheet behaves

When the trigger fires AND the user is eligible (not already responded, not globally dismissed), the SDK opens a sheet at a small detent showing a consent prompt ("Quick favor?" + body + three buttons):

  • Sure, happy to help → expands the sheet to full size and starts the step flow
  • Maybe later → closes the sheet, no server write; the trigger re-evaluates on the next foreground
  • Don't ask again → confirmation dialog, then writes the global-dismiss flag (Owl.dismissQuestionnaires()); never asked again

Once accepted, questions render one per page with a top progress bar and Back / Next / Submit buttons at the bottom. Every Next tap saves the current answer set as a draft (is_complete: false); the final Submit sends is_complete: true and flips the server-side submitted_at. The sheet is non-swipe-dismissible — users exit by tapping Cancel (toolbar), finishing the flow, or choosing a consent option. On successful submit, an in-sheet success page (checkmark + "Thanks!" + Done) replaces the questions.

Resuming an unsubmitted draft

Owl.fetchQuestionnaire(slug:) returns an OwlQuestionnaireFetchResult that bundles the spec with an optional inProgress: OwlQuestionnaireDraft — populated when the current user already has a draft response on the server (submitted_at still null). The auto-trigger modifier and the manual OwlQuestionnaireView both accept the draft and resume the flow at the first unanswered question with the prior answers pre-filled; the consent detent is skipped on resume because the user already opted in earlier. Abandoned drafts are soft-deleted after 90 days of inactivity by the questionnaire_draft_cleanup background job.

Short vs long text answers

text questions render a single-line TextField by default. Add "multiline": true in the question schema to render a tall, rounded TextEditor (roughly five lines, grows as the user types) — use it when you expect a paragraph-style answer rather than a quick one-liner:

{
  "id": "q_takeaway",
  "type": "text",
  "title": "One-line takeaway?",
  "placeholder": "Optional"
}
{
  "id": "q_details",
  "type": "text",
  "title": "Anything else you'd like to share?",
  "multiline": true,
  "placeholder": "Optional — tell us as much as you'd like"
}

The flag is set at authoring time on the server (dashboard / CLI / MCP); the SDK simply honours whatever the schema says.

Auto-trigger view modifier

The easiest path is to attach .owlQuestionnaire(slug:…) to a long-lived view (often the root of your app or a tab's content):

import SwiftUI
import Owlmetry

struct ContentView: View {
    var body: some View {
        TabView { ... }
            .owlQuestionnaire(
                slug: "post-onboarding",
                trigger: .afterLaunches(3)
            )
    }
}

The consent prompt is shown by default. Pass showsConsent: false to skip it and open straight to the first question.

Composable triggers

All conditions are ANDed:

.owlQuestionnaire(
    slug: "weekly-checkin",
    trigger: .when(
        .launches(atLeast: 3),
        .daysSinceFirstLaunch(atLeast: 7)
    )
)

Available conditions:

  • .launches(atLeast: Int) — total Owl.configure(...) calls since install (one bump per process)
  • .foregrounds(atLeast: Int) — total foreground transitions since install
  • .daysSinceFirstLaunch(atLeast: Int)
  • .hoursSinceFirstLaunch(atLeast: Int)

Shortcuts:

  • .afterLaunch — equivalent to .launches(atLeast: 1)
  • .afterLaunches(n) — equivalent to .launches(atLeast: n)
  • .manual — never auto-trigger; consumer presents OwlQuestionnaireView directly

OR-logic isn't built in. If you need it, use the isEligible closure or attach two .owlQuestionnaire(...) modifiers with different slugs.

Gating with isEligible

.owlQuestionnaire(
    slug: "free-user-survey",
    trigger: .afterLaunch,
    isEligible: { !user.isPaid }
)

isEligible runs synchronously on the main thread before the SDK fetches the spec. Return false to suppress this trigger evaluation; the modifier will re-evaluate on the next foreground.

Previewing in debug

Set forceShow: true to bypass every local gate (trigger conditions, isEligible closure, per-process dedup) and ask the server to also ignore alreadyResponded and globallyDismissed. Useful for seeing how the sheet renders without rigging up launch counts or resetting state. The server still respects inactive — there's no spec to return for a paused questionnaire.

#if DEBUG
@State private var previewQuestionnaire = false
#endif

var body: some View {
    SomeRootView()
        .owlQuestionnaire(
            slug: "post-onboarding",
            trigger: .when(.daysSinceFirstLaunch(atLeast: 7)),
            #if DEBUG
            forceShow: previewQuestionnaire
            #endif
        )
}

Wire previewQuestionnaire to a debug-menu toggle, an env-var check, or a launch argument. Under forceShow: true the SDK also skips the per-process "shown" mark, so toggling off-then-on after dismissing the sheet re-presents it without restarting the app. Production users never trip the bypass because the parameter defaults to false and the gating is yours to wire.

Tint

The view inherits the SwiftUI environment tint. Override with the tint: parameter:

.owlQuestionnaire(slug: "...", trigger: .afterLaunch, tint: .orange)

The Submit button, rating stars, single-choice checkmarks, and multi-choice toggles all pick up the tint.

Callbacks

.owlQuestionnaire(
    slug: "post-onboarding",
    trigger: .afterLaunches(3),
    onSubmitted: { receipt in
        print("Response id: \(receipt.id)")
    },
    onCancel: {
        print("User cancelled or tapped Maybe later")
    },
    onDismissed: {
        print("User opted out of all future questionnaires")
    }
)

onCancel fires when the user taps Cancel mid-flow or "Maybe later" on the consent prompt — answers entered up to that point persist as a draft on the server, but no onSubmitted fires. onDismissed fires when the user taps "Don't ask again" → confirm, after the server-side global-dismiss flag is written. onSubmitted fires only on the final Submit; the OwlQuestionnaireReceipt carries wasSubmitted: Bool which is true for that one call (it flipped server-side submitted_at from null to non-null) and false for the per-question draft saves leading up to it.

Manual presentation

For ad-hoc triggers ("show this survey after the user finishes the import wizard"), call Owl.fetchQuestionnaire and present OwlQuestionnaireView yourself. fetchQuestionnaire returns an OwlQuestionnaireFetchResult whose .questionnaire is nil when the user is ineligible (.ineligibleReason carries alreadyResponded, globallyDismissed, or inactive) and whose .inProgress carries any unsubmitted draft to resume. The public init defaults showsConsent: false — the consumer is already deciding to show it, so the step flow opens directly. Pass showsConsent: true if you want the small-detent prompt in front:

@State private var presentation: (OwlQuestionnaire, OwlQuestionnaireDraft?)?
@State private var showing = false

// ...

Button("Take the post-import survey") {
    Task {
        do {
            let result = try await Owl.fetchQuestionnaire(slug: "post-import")
            if let q = result.questionnaire {
                presentation = (q, result.inProgress)
                showing = true
            } else {
                // already responded, globally dismissed, or inactive
            }
        } catch {
            print("Fetch failed: \(error)")
        }
    }
}
.sheet(isPresented: $showing) {
    if let (spec, draft) = presentation {
        NavigationStack {
            OwlQuestionnaireView(
                questionnaire: spec,
                inProgress: draft,
                onSubmitted: { _ in showing = false },
                onCancel: { showing = false }
            )
        }
    }
}

Pass the draft through inProgress: so the flow container pre-fills the prior answers and resumes at the first unanswered question.

Programmatic dismissal

To opt the user out of all future questionnaires from code (e.g. a settings toggle), call Owl.dismissQuestionnaires():

Toggle("Show in-app surveys", isOn: $surveysEnabled)
    .onChange(of: surveysEnabled) { _, newValue in
        if !newValue {
            Task { try? await Owl.dismissQuestionnaires() }
        }
    }

Localization

OwlQuestionnaireView and the gate modifier both accept an OwlQuestionnaireStrings parameter. Every user-facing string is a LocalizedStringResource resolving against the SDK's bundled Localizable.xcstrings catalog. Override individual strings with .default.with(...):

.owlQuestionnaire(
    slug: "...",
    trigger: .afterLaunch,
    strings: .default.with(
        submitButton: "Send",
        consentTitle: "Got a minute?",
        consentBody: "Your feedback shapes the roadmap.",
        consentAccept: "I'm in",
        consentLater: "Not right now",
        consentNever: "Stop asking me"
    )
)

Every consent button + step-flow button label is independently overridable. The full overridable set (passed to .with(...)): title, submitButton, skipButton, nextButton, backButton, doneButton, cancelButton, consentTitle, consentBody, consentAccept, consentLater, consentNever, doNotShowAgain, successTitle, successBody, errorTitle, errorRequiredMissing, errorGeneric, npsLowLabel, npsHighLabel.

If the questionnaire's server-side description field is non-empty, it overrides consentBody so different surveys can carry their own context without per-call string overrides.

Trigger state accessors

Owl exposes the persistent counters that drive the trigger evaluator — useful for debugging or building your own gates:

let launches = Owl.launchCount       // total Owl.configure(...) calls
let foregrounds = Owl.foregroundCount // total foreground transitions
let installedAt = Owl.firstLaunchAt   // Date? — first Owl.configure(...) timestamp

Saving and submitting from custom UI

If you're rendering your own survey UI instead of OwlQuestionnaireView, call Owl.saveQuestionnaireResponse(slug:answers:isComplete:) to persist progress. Pass isComplete: false after each answer the user provides to save a draft (idempotent — the server upserts by (project, slug, user_id), so the SDK doesn't track a response id across calls); pass isComplete: true on the final submit to flip submitted_at and fire the team notification:

// Save a draft after the user answers Q2
_ = try await Owl.saveQuestionnaireResponse(
    slug: "post-import",
    answers: ["q1": .rating(5), "q2": .text("Nice!")],
    isComplete: false
)

// Final submit — accumulate everything and flip submitted_at
let receipt = try await Owl.saveQuestionnaireResponse(
    slug: "post-import",
    answers: allAnswers,
    isComplete: true
)
assert(receipt.wasSubmitted)

Every save sends the full accumulated answer set; the server merges, so going Back and re-answering an earlier question just overwrites that key in the saved row. OwlQuestionnaireReceipt.wasSubmitted is true exactly once per response — on the call that flipped submitted_at — making it safe to gate success-state UI on without separate bookkeeping.

Errors

Owl.fetchQuestionnaire and Owl.saveQuestionnaireResponse throw OwlQuestionnaireError:

  • .notConfiguredOwl.configure(...) hasn't been called yet
  • .slugNotFound — the slug doesn't exist in the project (developer error — check spelling)
  • .invalidAnswers(detail) — answers don't conform to the questionnaire's schema
  • .serverError(statusCode:body:) / .transportFailure(message:) — network / 5xx

fetchQuestionnaire doesn't throw for ineligible users — it returns an OwlQuestionnaireFetchResult whose .questionnaire is nil and whose .ineligibleReason (alreadyResponded / globallyDismissed / inactive) explains why.

Ready to get started?

Connect your agent via MCP or CLI and start tracking.