Expected Parrot · A practical, evidence-first tutorial

Migrating Qualtrics and SurveyMonkey surveys to EDSL

Capture a rendered survey, recover its questions and routing, translate its visual language into Humanize CSS, audit the result, and hand off a portable EDSL Survey.

Tool: zugunruheTarget: EDSL / Expected ParrotWorked example: bundled local fixture

01 What Zugunruhe does

A hosted survey is more than a list of prompts. It is a small interactive program: pages, controls, validation rules, skip logic, randomized blocks, quotas, embedded fields, completion behavior, typography, spacing, colors, and assets all contribute to what a respondent experiences. Platform exports are useful when available, but researchers often receive only a respondent URL—or need an independent, inspectable reconstruction.

Zugunruhe drives the rendered instrument in Chromium and records what it can observe. It then converts that evidence into EDSL source, a JSON survey specification, synthetic respondent scaffolding, Humanize styling, and a side-by-side audit report. The output is designed for inspection and revision. It is not a claim that browser observation perfectly recovers proprietary survey logic.

Inspect source
Capture evidence
Migrate to EDSL
Translate style
Human review
Survey handoff
The workflow ends at a reviewed EDSL Survey artifact. Running models or fielding the reconstructed survey belongs to downstream tools.

What is observed

Visible questions, controls, answer labels, page transitions, alternate answer paths, computed visual properties, HTML, and screenshots.

What remains uncertain

Unvisited server-side branches, quotas, hidden embedded data, validation edge cases, randomization probabilities, and platform bookkeeping.

What is generated

Readable Python, portable JSON, EDSL question objects, rules, persona scaffolding, CSS, theme tokens, and an audit report.

What requires judgment

Semantic fidelity, acceptable visual differences, whether inferred routing is correct, and whether publication is authorized.

02 Install and diagnose

This tutorial assumes Python 3.10 or newer, a checkout of Zugunruhe, and Chromium installed through Playwright. The safe example is a local HTML file, so it does not send responses to Qualtrics, SurveyMonkey, or any other service.

git clone https://github.com/expectedparrot/zugunruhe.git
cd zugunruhe
python -m venv .venv
.venv/bin/pip install -e .
.venv/bin/playwright install chromium

.venv/bin/zugunruhe --version
.venv/bin/zugunruhe doctor

doctor is bounded and non-mutating. It reports whether the Playwright Python package and the ep executable are visible. It intentionally does not open a browser or test Expected Parrot authentication; those checks could be slower or involve external systems.

Representative doctor response
{
  "schema_version": "1.0",
  "command": "zugunruhe doctor",
  "status": "ok",
  "data": {
    "capabilities": {
      "playwright_python": true,
      "playwright_browser": "not_checked",
      "edsl_cli": "/path/to/ep",
      "publishing_authentication": "not_checked"
    },
    "mutated": false
  },
  "warnings": ["Browser installation and Expected Parrot authentication are checked only when their commands run."],
  "errors": [],
  "next_steps": []
}
Missing optional capabilities are local facts, not fatal global errors. You can read agent documentation or inspect an existing migration without an Expected Parrot login. A browser is needed only for capture; ep and authentication are needed only for Humanize preview or publication.

03 Let the package sequence the work

Zugunruhe has a package-owned agent control surface. A coding agent should not reproduce the workflow from memory. It asks for the guide, checks durable state, and executes the recommended structured action only after respecting its approval flag.

zugunruhe agent guide
zugunruhe agent docs list
zugunruhe agent docs show workflow
zugunruhe agent next -C work/safe-migration
zugunruhe agent status -C work/safe-migration
zugunruhe agent history -C work/safe-migration

For identical durable state, agent next is deterministic, local, cheap, and read-only. It returns a stable action ID, a working-directory array, an argument vector, inputs, outputs, reasons, warnings, and explicit booleans for local mutation, external effects, and required approval. It never launches a browser, performs inference, publishes, or appends history.

First recommendation in an empty project
{
  "terminal": false,
  "recommended_action": {
    "id": "inspect_source",
    "stage": "inspect",
    "cwd": ["/absolute/path/work/safe-migration"],
    "argv": ["zugunruhe", "inspect", "SURVEY_URL", "--output", "/absolute/path/work/safe-migration"],
    "mutates_local_state": true,
    "external_side_effect": true,
    "requires_user_approval": true,
    "reason": "No source capture exists.",
    "input_artifacts": [],
    "resulting_artifacts": ["manifest.json", "page screenshots and HTML", "route evidence"],
    "safety_warnings": ["Live survey navigation may persist partial responses or submit a final page; inspection requires explicit user approval."]
  },
  "alternative_actions": []
}

The placeholder SURVEY_URL is deliberate. Resolving it requires task context and, for a live source, explicit user approval. The top-level zugunruhe guide and zugunruhe next commands remain compatibility aliases.

04 Capture the safe local fixture

The repository includes examples/safe-survey.html, a two-option survey that exercises the real browser capture path without contacting a third party. Use an absolute file:// URL so Chromium can open it regardless of the output directory.

mkdir -p work
FIXTURE="file://$PWD/examples/safe-survey.html"
zugunruhe inspect "$FIXTURE" --output work/safe-migration

Capture has no default page cap and no default branch cap. Zugunruhe maintains a traversal queue, replays answer paths from the beginning, and checkpoints the manifest as it works. Diagnostic limits exist for development, but a capped run is intentionally marked incomplete.

Do not add a cap to make a real migration finish faster. --max-pages and --max-branches are diagnostic escape hatches. If either limit prevents queue exhaustion, the resulting manifest cannot represent migration completion.

Each distinct observed page receives a stable fingerprint. Alongside the manifest, the capture stores a screenshot, rendered HTML, and collected inline CSS and JavaScript. These are evidence: keep them even after the EDSL reconstruction looks correct.

05 Audit capture completeness

Before converting anything, inspect state rather than inferring success from the presence of manifest.json.

zugunruhe agent status -C work/safe-migration
python -m json.tool work/safe-migration/manifest.json | less
Manifest fieldRequired stateWhy it matters
traversal_completetrueThe path queue was exhausted rather than interrupted.
failed_pathsEmptyA repeatedly failing replay can conceal a branch.
diagnostic_limits.max_pagesnullA page cap turns the run into a diagnostic sample.
diagnostic_limits.max_branchesnullA branch cap prevents an exhaustive answer probe.
pagesNonempty for a real surveyContains the actual observed page evidence.

If any completion criterion fails, agent next returns repair_capture, not migrate_to_edsl. Its alternative is a read-only status audit. This prevents an incomplete capture from becoming “complete” merely because later artifacts happen to exist.

06 Build the EDSL reconstruction

zugunruhe migrate work/safe-migration/manifest.json \
  --output work/safe-migration/edsl

zugunruhe agent next -C work/safe-migration

The migration writes both executable Python and inspectable JSON. This dual representation matters: Python is convenient for EDSL, while JSON is easier for audits, diffs, coding agents, and other tools.

work/safe-migration/ ├── manifest.json ├── page-001-…{.html,.png,-inline.css,-inline.js} ├── .zugunruhe/ │ └── history.jsonl └── edsl/ ├── survey.py ├── survey_spec.json ├── personas.py └── persona_spec.json

survey.py defines build_survey() and a module-level survey. It selects an EDSL class based on the observed control: QuestionMultipleChoice, QuestionCheckBox, QuestionNumerical, QuestionFreeText, or QuestionMatrix. Names are sanitized and made unique, while source_name preserves the original control identifier.

from pathlib import Path
import importlib.util

path = Path("work/safe-migration/edsl/survey.py")
spec = importlib.util.spec_from_file_location("migrated_survey", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
survey = module.build_survey()
print(survey)

The generated personas are scaffolding for synthetic testing, not survey-derived population estimates. Read persona_spec.json before using them and replace generic dimensions with study-appropriate traits when needed.

07 Preserve matrices and inspect routing

A grid should remain one semantic question. Zugunruhe recognizes matrix structure, records row items separately from column options, and emits QuestionMatrix. Turning every row into an unrelated multiple-choice question would lose the instrument’s grouping and produce a poorer respondent experience.

{
  "name": "session_rating",
  "text": "Please rate each part of the program",
  "type": "matrix",
  "items": ["Presentations", "Discussion", "Logistics"],
  "options": ["Poor", "Fair", "Good", "Excellent"]
}

Routing requires more caution. Zugunruhe infers a rule only when alternate answers observed from the same parent page reach different destinations. Forward branch exits can rejoin after sibling branches; backward jumps are discarded because EDSL rejects them. A single observed transition is ordinary navigation, not enough evidence for skip logic.

Inferred routing is evidence-based, not authoritative. Compare every rule in survey_spec.json with the source instrument. Check display logic, termination, embedded data, quotas, randomization, and validation separately; some of these behaviors may never appear in rendered HTML.

08 Recreate the survey’s visual language

zugunruhe style work/safe-migration/manifest.json \
  --output work/safe-migration/edsl

During capture, Zugunruhe samples computed properties for semantic roles: body, container, question, question text, option label, input, primary button, and progress indicator. Styling selects representative values across pages and maps only a safe property allowlist. Potentially executable CSS values such as url(...), expression(...), and javascript: are rejected.

OutputPurpose
theme.jsonReadable representative tokens and captured branding metadata.
humanize.cssGenerated CSS targeting EDSL Humanize selectors.
humanize.jsonHumanize schema containing the same CSS under survey.custom_css.

The NBER/SurveyMonkey case

The motivating migration was the 2026 Summer Institute participant survey. Its source pages use a restrained NBER visual system: a muted gray surround, white survey sheet, dark text, a blue primary accent, generous spacing, and organization branding. Zugunruhe reads SurveyMonkey CSS variables when available and applies the captured title color, font size, weight, page background, and primary accent. It also generates a compact NBER-style header when branding metadata is present.

Assets are a separate problem. Logos and background images can involve expiring URLs, access controls, licensing, content hashing, and upload APIs. The current Humanize path handles CSS but does not claim to migrate binary assets. Preserve discovered asset URLs in evidence and resolve asset publication deliberately rather than embedding unstable links.

Button hover behavior

The browser may inherit a Humanize default hover rule even when the source button is blue. CSS can control it explicitly. Add a hover and focus-visible rule to humanize.css, then ensure humanize.json contains the revised CSS before previewing:

.edsl-survey-container form button:hover,
.edsl-survey-container form button:focus-visible {
  background-color: rgb(0, 92, 185);
  border-color: rgb(0, 92, 185);
  filter: brightness(.94);
}

Keep focus visible for keyboard users. Matching a source design does not justify removing accessibility cues.

09 Build the audit report

zugunruhe report \
  work/safe-migration/manifest.json \
  work/safe-migration/edsl/survey_spec.json \
  --output work/safe-migration/edsl/survey-report.html

The report is the principal human-review surface. Open it locally and compare captured source screenshots with reconstructed question metadata and inferred routing. The report should make omissions visible; it is not a celebratory “conversion succeeded” page.

  • Read every prompt aloud and compare punctuation, interpolation, and instructional text.
  • Count answer options and verify labels, order, exclusive choices, comments, and “other” behavior.
  • Check matrix row items and scale columns as a single structured question.
  • Trace each branch using concrete answers and verify where paths rejoin or terminate.
  • Record validation that the browser could not infer: required fields, ranges, formats, and cross-question constraints.
  • Compare typography, spacing, controls, progress, and hover/focus states on desktop and mobile.

10 Save the portable .ep artifact

The durable EDSL interchange format is produced with survey.git.save(), not the older generic survey.save(). Run this after reviewing or editing the generated Python:

cd work/safe-migration/edsl
python - <<'PY'
from survey import build_survey

survey = build_survey()
survey.git.save("survey.ep")
print("saved survey.ep")
PY

Inspect the resulting object with the EDSL CLI before sending it downstream:

ep inspect survey.ep
Why keep both files? survey.py is a readable, reviewable source representation. survey.ep is the portable serialized artifact used by Expected Parrot workflows. Retain both, along with the JSON specification that explains how the source capture became EDSL.

11 Preview and tune Humanize CSS

Previewing lets you compare the rendered EDSL instrument against source screenshots without treating it as an authorized publication.

zugunruhe preview \
  --survey work/safe-migration/edsl/survey.ep \
  --schema work/safe-migration/edsl/humanize.json

A successful preview also audits generated selectors and representative computed styles against Zugunruhe's versioned Humanize respondent-DOM contract. It writes .zugunruhe/preview-verification.json and a local audit screenshot, binding the result to hashes of the current survey and schema. Publication is blocked if this verification is missing, failed, or stale; --allow-unverified-preview is reserved for an explicit reviewed override.

Open the returned URL at a wide and narrow viewport. Iterate in small, explainable changes. When an existing Humanize survey needs only a CSS update, patch it and reload:

zugunruhe patch-css HUMAN_SURVEY_UUID \
  --file work/safe-migration/edsl/humanize.css

Common visual differences include browser-default form controls, selector specificity, hover/focus styles, container widths, inherited line heights, and mobile padding. Prefer a narrow selector adjustment over global !important rules. Verify contrast and keyboard navigation after every visual change.

Preview is distinct from publication; publication is externally visible and requires explicit user approval. A preview URL is for comparison. Do not distribute it as if approval and fielding checks had occurred.

12 Record review and reach terminal handoff

Once capture, migration, styling, and report artifacts exist, agent next returns review_migration with a JSON schema and valid example. Review wording, options, validation, routing, embedded data, randomization, quotas, and termination behavior before publication.

mkdir -p work/safe-migration/.zugunruhe
cat > work/safe-migration/.zugunruhe/review.json <<'JSON'
{
  "approved": true,
  "reviewed_at": "2026-08-10T12:00:00Z",
  "checks": [
    "wording", "options", "validation", "routing",
    "embedded_data", "randomization", "quotas", "termination"
  ]
}
JSON

zugunruhe agent next -C work/safe-migration

A valid review produces the explicit terminal action handoff_edsl_survey. Its exact input artifact is edsl/survey.py; Zugunruhe does not invent optional cleanup, run models, or perform downstream survey testing. If the review omits a required check, state reports INVALID_REVIEW and remains nonterminal.

Review records are claims. Do not copy the example unchanged merely to advance state. Set the timestamp and checks only after the corresponding work is complete, and add project-specific notes beside the record when uncertainty remains.

13 Move from the fixture to a live survey

After the local workflow is familiar, substitute an authorized respondent URL and a fresh output directory:

zugunruhe agent next -C work/participant-survey

# Run only after explicit approval for live navigation:
zugunruhe inspect 'https://www.surveymonkey.com/r/EXAMPLE' \
  --output work/participant-survey
Live survey navigation may persist partial responses or submit a final page; inspection requires explicit user approval. Prefer a test collector, preview link, cloned instrument, or disposable response path. Zugunruhe avoids controls explicitly labeled Submit or Finish, but a generic Next control can still finalize a platform survey.

Use a new capture directory for every substantive attempt. Do not overwrite the only evidence from a previous traversal. If the survey requires authentication, invitation tokens, CAPTCHA, uploads, payments, or personally identifying responses, stop and agree on a safe plan before automation.

Qualtrics and SurveyMonkey differences

Both platforms render familiar form controls, but markup, accessibility roles, progress indicators, and navigation conventions differ. SurveyMonkey often exposes theme variables that improve branding recovery. Qualtrics may combine question text and answer labels in containers that require cleanup. Neither platform’s DOM should be treated as a stable public API, so always inspect the artifacts and keep extraction tests anchored in semantics rather than opaque class names.

14 Artifact contract and provenance

Durable evidence produced by a migration.

ArtifactRoleKeep?
manifest.jsonTraversal state, pages, paths, failures, limits, and source URL.Always
Page screenshots and HTMLRendered evidence for visual and semantic comparison.Always
Inline CSS and JavaScriptSource evidence and debugging context; never execute blindly.Always
edsl/survey_spec.jsonNormalized questions and inferred rules.Always
edsl/survey.pyReadable EDSL source and downstream handoff.Always
edsl/survey.epPortable EDSL serialization created with survey.git.save().For delivery
Humanize schema, CSS, and themeVisual translation and editable design tokens.Always
edsl/survey-report.htmlHuman audit surface.Always
.zugunruhe/history.jsonlSuccessful mutation ledger.Always
.zugunruhe/review.jsonExplicit completion gate.Always
.zugunruhe/preview-verification.jsonHash-bound selector and computed-style audit used by the publication gate.For publication
.zugunruhe/humanize-preview-audit.pngRendered current-DOM contract screenshot for visual regression review.For publication

Preserve the source manifest, screenshots, HTML, route evidence, EDSL source, styling artifacts, and audit report as provenance. Avoid committing live response tokens, cookies, credentials, personally identifiable data, or proprietary assets without permission.

15 Troubleshooting and recovery

Chromium is missing

python -m playwright install chromium
zugunruhe doctor

If Chromium exists but cannot launch in a restricted environment, run the browser step where OS sandbox and IPC services are available. Read-only agent commands should continue working.

The capture never becomes complete

Inspect remaining_paths and failed_paths. Repeated failures can arise from expiring collector tokens, randomization, rate limits, validation, or a path that depends on prior free text. Preserve the failed manifest, diagnose the cause, and recapture into a new directory. Do not edit traversal_complete by hand.

The matrix became separate questions

Open the captured HTML and confirm that rows and column headers have accessible grid semantics or stable grouping. Add a focused extraction fixture before changing selectors. The desired output is one QuestionMatrix with row items and column options.

The styling looks like the EDSL default

Check that the manifest pages contain theme data, then inspect theme.json. If the source uses CSS variables, confirm they were present in captured HTML. Compare selectors in browser developer tools; Humanize markup may require a more specific rule.

The Next button changes color on hover

Add explicit :hover and :focus-visible declarations, update both the CSS file and schema, then patch or recreate the preview. Do not remove the focus indicator.

survey.ep is missing

migrate generates source; serialize the reviewed object explicitly with survey.git.save("survey.ep"). This makes the handoff visible and avoids confusing it with legacy survey.save().

Expected Parrot preview fails

Validate that ep is installed and authenticated, the survey path is a real .ep artifact, and humanize.json contains valid JSON. The CLI returns expected configuration failures as structured errors without a traceback.

16 Safety boundaries

Rules that agents must preserve.

  1. Live survey navigation may persist partial responses or submit a final page; inspection requires explicit user approval.
  2. A capped capture, failed path, or manifest with traversal_complete=false is not a completed migration.
  3. Review wording, options, validation, routing, embedded data, randomization, quotas, and termination behavior before publication.
  4. Preview is distinct from publication; publication is externally visible and requires explicit user approval.
  5. Preserve the source manifest, screenshots, HTML, route evidence, EDSL source, styling artifacts, and audit report as provenance.

Review readiness

A human or supervising agent records review without Zugunruhe publishing anything.

Publication is intentionally absent from the agent’s terminal migration recommendation. If a user separately authorizes publication, use the reviewed .ep file and Humanize schema:

zugunruhe publish \
  --survey work/participant-survey/edsl/survey.ep \
  --schema work/participant-survey/edsl/humanize.json \
  --name "Reviewed participant survey"

17 Completion checklist

  • □ Live navigation was explicitly approved and used a safe collector or test path.
  • □ The capture is uncapped, traversal_complete is true, and failed_paths is empty.
  • □ Every prompt, option, matrix, input type, and validation rule was compared with source evidence.
  • □ Every inferred branch, rejoin, and termination path was reviewed.
  • □ Embedded data, randomization, quotas, and platform-only behavior were checked separately.
  • □ Humanize CSS matches typography, spacing, color, controls, hover/focus behavior, and mobile layout closely enough.
  • □ Asset limitations are recorded; no unstable or unauthorized asset was silently embedded.
  • □ The audit report and provenance artifacts are retained.
  • survey.py is readable and survey.ep was created with survey.git.save().
  • .zugunruhe/review.json truthfully records completed checks.
  • zugunruhe agent next returns terminal handoff_edsl_survey with edsl/survey.py.
  • □ Any publication has separate, explicit authorization.
Migration workflow. Inspect, migrate, style, report, review, then hand the EDSL Survey to downstream testing.

At this point the migration is complete. Downstream testing can import the exact EDSL Survey artifact, construct appropriate agents or scenarios, and validate behavior without Zugunruhe broadening its role.