> ## Documentation Index
> Fetch the complete documentation index at: https://docs.expectedparrot.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Human surveys

> EDSL comes with built-in methods for generating web versions of your surveys and collecting and analyzing responses from humans.

`humanize()` turns an EDSL Survey into a web questionnaire that people can complete. It preserves the questions and survey logic while adding a respondent interface, an administration page, and response collection. Start with [Human studies](https://docs.expectedparrot.com/en/latest/guides/human_studies.md) for a short end-to-end example; use this page to configure scenarios, respondent lists, and data retrieval.

> [!NOTE]
>
> This page covers code methods for launching surveys, gathering human responses, and analyzing results. For interactive survey building, see [Survey Builder](https://docs.expectedparrot.com/en/latest/survey_builder.md). For Prolific recruitment, see [Prolific studies](https://docs.expectedparrot.com/en/latest/prolific.md). For email notifications and scheduled reminders, see [Survey notifications](https://docs.expectedparrot.com/en/latest/humanize_notifications.md). To let AI agents take a survey, see [AI agent access](https://docs.expectedparrot.com/en/latest/humanize_agent_access.md).

## How it works

**Step 1**

**Create** [Surveys](https://docs.expectedparrot.com/en/latest/surveys.md) with desired types of [Questions](https://docs.expectedparrot.com/en/latest/questions.md).

**Step 2**

**Humanize** the survey to generate a web-hosted version on Expected Parrot with a respondent link and an admin dashboard.

**Step 3**

**Share** the respondent link with human participants (or recruit via [Prolific](https://docs.expectedparrot.com/en/latest/prolific.md)).

**Step 4**

**Collect** responses back as a standard EDSL `Results` object.

**Step 5**

**Analyze** your human results alongside any LLM results.

***

## Basic usage

The example collects three measures of workplace experience: work-from-home frequency, satisfaction, and a suggested improvement. After defining the questions, `.humanize()` publishes them using your [Expected Parrot account](https://docs.expectedparrot.com/en/latest/api_keys.md#log-in-from-a-notebook).

```python theme={null}
from edsl import Survey, QuestionMultipleChoice, QuestionFreeText, QuestionLinearScale

q1 = QuestionMultipleChoice(
    question_name="frequency",
    question_text="How often do you work from home?",
    question_options=["Never", "1-2 days a week", "3-4 days a week", "Every day"],
)
q2 = QuestionLinearScale(
    question_name="satisfaction",
    question_text="How satisfied are you with your current work arrangement?",
    question_options=[1, 2, 3, 4, 5],
    option_labels={1: "Very dissatisfied", 5: "Very satisfied"},
)
q3 = QuestionFreeText(
    question_name="improvement",
    question_text="What one change would most improve your day-to-day work experience?",
)

survey = Survey([q1, q2, q3])

info = survey.humanize(
    human_survey_name="Workplace Experience Survey",
    survey_description="A short survey about remote and in-office work preferences.",
    survey_visibility="unlisted",
)

info  # Contains the respondent link, admin link, and collection UUID.
```

### `.humanize()` parameters

| Parameter | Type | Default | Description |
| - | - | - | - |
| `human_survey_name` | str | `"New survey"` | Display name on Expected Parrot |
| `survey_description` | str \| None | None | Optional description |
| `survey_alias` | str \| None | None | URL-friendly alias |
| `survey_visibility` | str | `"private"` | `"private"`, `"public"`, or `"unlisted"` |
| `humanize_schema` | dict \| None | None | Controls question settings and survey options, including requiring Expected Parrot sign-in — see [Humanize schema](https://docs.expectedparrot.com/en/latest/humanize_schema.md) |

### Return value

`survey.humanize()` returns a `Scenario` (dict-like) with these keys:

| Key | Description |
| - | - |
| `uuid` | Human survey UUID — needed to fetch responses later |
| `respondent_url` | Public link for respondents |
| `admin_url` | Admin dashboard link |
| `name` | Survey name |
| `n_complete` | Completed responses (0 at creation) |
| `n_in_progress` | Responses started but not yet finished (0 at creation) |
| `survey_uuid` | UUID of the pushed survey object |
| `scenario_list_uuid` | UUID of the pushed scenario list (if any) |
| `asset_substitutions` | Old asset uuid → new one, when the schema named assets that were copied into your library. Empty when nothing was copied |

**Save the `uuid`** — you need it to retrieve responses.

***

## Previewing before deployment

Use `.preview()` to get a preview URL without creating a live survey:

```python theme={null}
preview_url = survey.preview()
# print() also shows output in scripts. In a notebook, you can instead
# put the value you want to inspect on the last line of a cell.
print(preview_url)

# With schema customization
schema = {"questions": {"improvement": {"optional": True}}}
preview_url = survey.preview(humanize_schema=schema)
```

***

## Present questions in groups

To request one group per page, define contiguous question groups on the survey and pass `humanize_schema={"survey": {"presentation": "group"}}` to `.humanize()`. See [Grouped surveys](https://docs.expectedparrot.com/en/latest/humanize_groups.md) for a complete example, branching behavior, and availability requirements.

***

## Add your logo

To show your own logo above the questions, upload the image once and reference it from the humanize schema:

```python theme={null}
from edsl import Coop

logo = Coop().upload_human_survey_asset("lab_logo.png")

survey.humanize(
    human_survey_name="Customer feedback",
    humanize_schema={
        "survey": {
            "branding": {
                "logo": {
                    "source": {"type": "asset", "asset_uuid": logo.uuid},
                    "alt": "Acme Research logo",
                }
            }
        }
    },
)
```

Uploading the same file again returns the asset you already have, so re-running a script does not fill your library with copies.

`Coop().list_human_survey_assets(page=1, page_size=20)` lists a page of image-library metadata. `Coop().get_human_survey_asset(asset_uuid)` returns a `HumanSurveyAsset`; its `download("logo.png")` method saves the image locally. `Coop().delete_human_survey_asset(asset_uuid)` removes an asset when you intend to delete it.

From the CLI, upload once and apply it to a survey that already exists:

```bash theme={null}
ep humanize assets upload lab_logo.png
ep humanize schema set <human-survey-uuid> --logo-asset <asset-uuid> --logo-alt "Acme Research logo"
```

Or do both in one step:

```bash theme={null}
ep humanize schema set <human-survey-uuid> --logo-file lab_logo.png --logo-alt "Acme Research logo"
```

See [Humanize schema](https://docs.expectedparrot.com/en/latest/humanize_schema.md) for the `branding` fields, and [Custom CSS](https://docs.expectedparrot.com/en/latest/custom_css.md) for resizing the logo or putting it on a colored banner.

***

## With scenarios

When using scenarios, call `.humanize()` on a `Jobs` object (not directly on the survey). You must specify a `scenario_list_method`.

Use scenarios for stimulus material or treatment conditions. This example assigns each respondent one of three policy descriptions and asks for a support rating and its reason. Random assignment can select the same policy for several respondents:

```python theme={null}
from edsl import Survey, QuestionFreeText, QuestionLinearScale, Scenario, ScenarioList

scenarios = ScenarioList([
    Scenario({"policy": "Four-day workweek with no change in pay"}),
    Scenario({"policy": "Flexible start times between 7am and 11am"}),
    Scenario({"policy": "Monthly remote-work stipend of $100"}),
])

q1 = QuestionLinearScale(
    question_name="support",
    question_text="How much do you support this policy: '{{ scenario.policy }}'?",
    question_options=[1, 2, 3, 4, 5],
    option_labels={1: "Strongly oppose", 5: "Strongly support"},
)
q2 = QuestionFreeText(
    question_name="reasoning",
    question_text="What is the main reason for your rating?",
)

survey = Survey([q1, q2])

info = survey.by(scenarios).humanize(
    human_survey_name="Workplace Policy Reactions",
    scenario_list_method="randomize",
)
```

### Set question options from an earlier answer

Use a Jinja template for `question_options` when a later question's choices depend on an earlier answer. Put the lookup data in a scenario; the survey server resolves the template after the respondent answers the first question and sends only the matching options to that page. A `QuestionDropdown` answer is a list, so `[0]` selects its single choice.

This example downloads O\*NET's [Task Statements CSV](https://www.onetcenter.org/dl_files/database/db_31_0_csv/task_statements.csv), builds a task list for each occupation, then shows the selected occupation's tasks in a checkbox question:

```python theme={null}
import csv
from collections import defaultdict
from io import TextIOWrapper
from urllib.request import urlopen

from edsl import (
    QuestionCheckBox,
    QuestionDropdown,
    Scenario,
    ScenarioList,
    Survey,
)

task_url = "https://www.onetcenter.org/dl_files/database/db_31_0_csv/task_statements.csv"
tasks_by_occupation = defaultdict(list)

with urlopen(task_url) as response:
    rows = csv.DictReader(TextIOWrapper(response, encoding="utf-8-sig"))
    for row in rows:
        choice = f"{row['Title']} [{row['O*NET-SOC Code']}]"
        tasks_by_occupation[choice].append(row["Task"])

occupation = QuestionDropdown(
    question_name="occupation",
    question_text="Which O*NET occupation best describes your work?",
    question_options=list(tasks_by_occupation),
    top_k=1,
)
tasks = QuestionCheckBox(
    question_name="tasks",
    question_text="Which tasks are part of your work?",
    question_options="{{ scenario.tasks_by_occupation[occupation.answer[0]] }}",
)

survey = Survey([occupation, tasks])
scenario = Scenario({"tasks_by_occupation": dict(tasks_by_occupation)})
info = survey.by(ScenarioList([scenario])).humanize(
    human_survey_name="Occupation and Tasks",
    scenario_list_method="single_scenario",
)

print(info["respondent_url"])
```

O\*NET 31.0 includes 18,838 task statements across 923 occupations. This example includes occupations present in the task file; taxonomy occupations without task statements are left out. For respondent-facing options, the scenario-backed Jinja lookup is the direct approach. The `QuestionCompute` variant below embeds the lookup in the survey when you need a self-contained definition.

#### Embed the lookup in a `QuestionCompute`

For a self-contained survey, you can put the lookup dictionary directly in a `QuestionCompute` template instead of attaching it as a scenario. The compute question reads the prior dropdown answer, and the checkbox receives its answer as its options:

```python theme={null}
import json

from edsl import QuestionCompute

# Reuse `tasks_by_occupation` and `occupation` from the example above.
lookup_json = json.dumps(
    dict(tasks_by_occupation), ensure_ascii=False, separators=(",", ":")
)

available_tasks = QuestionCompute(
    question_name="available_tasks",
    question_text="{{ " + lookup_json + "[occupation.answer[0]] }}",
)
tasks = QuestionCheckBox(
    question_name="tasks",
    question_text="Which tasks are part of your work?",
    question_options="{{ available_tasks.answer }}",
)

survey = Survey([occupation, available_tasks, tasks])
info = survey.humanize(human_survey_name="Occupation and Tasks")
print(info["respondent_url"])
```

The `QuestionCompute` is an intermediate question; respondents see the occupation dropdown and task checkbox. This avoids a scenario attachment, but serializes the full lookup into the question text. For O\*NET 31.0 that is about 1.9 MB, which worked in a local live survey. The scenario-backed version above keeps the question definition much smaller and is easier to maintain; embedding can be reasonable when a self-contained survey is more useful.

### Scenario list methods

| Method | Behavior |
| - | - |
| `"randomize"` | Each respondent gets a random scenario (with replacement) |
| `"ordered"` | Scenarios are assigned sequentially to respondents |
| `"loop"` | Survey is expanded — every question is repeated for every scenario |
| `"single_scenario"` | Exactly one scenario is used for all respondents (list must have length 1) |

**Rules:**

* If you attach scenarios, you **must** specify `scenario_list_method`.
* If you specify a method, you **must** attach scenarios.
* Do not attach language models to a human-survey job. An AgentList can supply participant metadata and delivery addresses, as described below; it does not generate simulated responses.

***

## With agent lists (for email notifications)

Attach an `AgentList` via `.by()` to associate respondents with email addresses. Pass a `DeliveryMap` to tell Coop which agent trait holds the email address.

```python theme={null}
from edsl import Survey, Agent, AgentList
from edsl.coop.coop_humanize_notifications import ChannelConfig, DeliveryMap

agents = AgentList([
    Agent(traits={"name": "Jordan", "email": "jordan@example.com"}),
    Agent(traits={"name": "Taylor", "email": "taylor@example.com"}),
    Agent(traits={"name": "Morgan", "email": "morgan@example.com"}),
])

info = survey.by(agents).humanize(
    human_survey_name="Panel Study — Wave 1",
    agent_list_description="Recruited panel participants",
    delivery_map=DeliveryMap(email=ChannelConfig(col_name="email")),
)
```

`col_name` must match the trait key that holds each respondent's email address. Once deployed, use [Survey notifications](https://docs.expectedparrot.com/en/latest/humanize_notifications.md) to send invitations, schedule reminders, and track delivery status.

***

## Retrieving responses

```python theme={null}
from edsl import Coop

coop = Coop()

results = coop.get_human_survey_responses("your-human-survey-uuid")
# In a notebook, the last expression displays the selected table.
# In a script, append .print() to display it.
results.select("answer.frequency", "answer.satisfaction", "answer.improvement")
```

### What the results contain

Each completed response is one result, oldest first. Retrieving data reads the recorded answers; it does not rerun the questionnaire. Skipped questions stay skipped. Column names match the responses table on the survey's page. Select `scenario.*` alongside answers when you need to compare the material or condition each respondent received.

| Field | Description |
| - | - |
| `answer.{question}`, `comment.{question}_comment` | The respondent's answer and comment. Empty for a question they didn't see. |
| `agent.response_uuid`, `agent.agent_name` | The response's ID. |
| `agent.response_status` | `complete`, or `in_progress` for a response still being taken (see below). |
| `agent.last_activity_at` | When the respondent last moved the response forward. |
| `agent.respondent_uuid`, `agent.agent_index` | The respondent's ID and position in the attached agent list. Empty without an agent list. |
| `agent.{trait}` | The traits of the respondent's agent, when an agent list is attached. |
| `agent.expectedparrot_username` | The respondent's Expected Parrot username, when the survey requires account sign-in. |
| `raw_model_response.{question}_started_at`, `_answered_at` | When the respondent reached and answered the question. |
| `raw_model_response.{question}_question_presented` | `False` if survey logic skipped the question, so a skipped question can be told from one left blank. |
| `question_options.{question}_question_options` | The options as the respondent saw them, after piping and any randomized order. |
| `agent.external_platform`, `agent.prolific_*` | The Prolific submission, study and participant IDs, when any response came through Prolific. |

Fields that show how a response was collected, such as `agent.collection_interface`, are listed under [AI agent access](https://docs.expectedparrot.com/en/latest/humanize_agent_access.md#in-results). The `model.*` columns describe a placeholder model: a human response has no model, but every result records one.

To get only some responses, pass `started_after` and/or `started_before` (inclusive; a response is "started" when the respondent opened the survey).

### In-progress responses

By default you get completed responses only. Pass `include_partial=True` to also get responses that are still being taken, for example to look at dropout or check on a survey that is still running:

```python theme={null}
results = coop.get_human_survey_responses(
    "your-human-survey-uuid", include_partial=True
)
results.filter("agent.response_status == 'in_progress'").select(
    "agent.response_uuid", "agent.last_activity_at"
)
```

An in-progress response holds the answers submitted so far; questions the respondent hasn't answered yet are empty. Filter to `agent.response_status == 'complete'` before computing anything that assumes a finished survey. A response that is in progress now can have more answers, or be complete, the next time you fetch, so combine downloads by `agent.response_uuid`. `include_partial` works together with `started_after` and `started_before`.

### Checking response count

```python theme={null}
status = coop.get_human_survey("your-human-survey-uuid")
print(f"Completed: {status['n_complete']}, in progress: {status['n_in_progress']}")
```

### Find surveys and inspect respondent progress

Use Python pagination when retrieving account records:

```python theme={null}
surveys = coop.list_human_surveys(search_query="Workplace", page=1, page_size=20)
respondents = coop.get_human_survey_respondents(
    "your-human-survey-uuid", page=1, page_size=50,
)
respondents
```

These calls return response dictionaries with records and pagination information; inspect the returned metadata before requesting another page. `coop.get_human_survey_agent_list(uuid)` retrieves the attached respondent list configuration. To change its delivery mapping or participation settings, use `coop.patch_human_survey_agent_list(...)`; see [notifications](https://docs.expectedparrot.com/en/latest/humanize_notifications.md) and [AI agent access](https://docs.expectedparrot.com/en/latest/humanize_agent_access.md).

For events logged by a survey's [custom JavaScript](https://docs.expectedparrot.com/en/latest/custom_javascript.md), `coop.get_human_survey_events(uuid, limit=200)` retrieves a batch. While `has_more` is true, pass the returned `next_cursor` as `after` to continue, or use `coop.get_all_human_survey_events(uuid)` to collect all batches. `coop.count_human_survey_events(uuid)` counts those events. Preview events are included and marked `is_preview`; exclude them from participant analyses. Use `get_human_survey_responses()` for answer data.

### Debugging thinking and image generation questions

Thinking questions and `QuestionImageGeneration` questions are answered by a model while the respondent waits on a "Working…" screen. Each time one is answered is a **run**. A run that fails for a reason a second try could fix, such as a rate limit, a provider error or a timeout, is retried once. If the retry fails too, or the error isn't one a retry could fix, the respondent sees "This survey can't continue right now", their answers so far are kept, and they can't continue.

To find out why a question is failing, start with the summary:

```python theme={null}
from edsl import Coop

coop = Coop()

summary = coop.get_human_survey_background_runs_summary("your-human-survey-uuid")
```

```bash theme={null}
ep humanize background summary your-human-survey-uuid
```

| Field | Description |
| - | - |
| `runs` | How many runs there were, retries included |
| `by_status` | Runs by status: `queued`, `running`, `succeeded`, `failed`, `timed_out` or `abandoned` |
| `failures` | Runs that ended without an answer, by error kind |
| `retries` | How many runs were automatic retries |
| `last_failure_at` | When the latest failure ended (ISO 8601, UTC) |
| `questions` | The same fields for each question, with its `question_name` and `run_type` (`thinking` or `image_generation`) |

Then list the runs that failed, and read one in full:

```python theme={null}
failed = coop.get_all_human_survey_background_runs(
    "your-human-survey-uuid", failed_only=True, question_name="classify_feedback"
)
run = coop.get_human_survey_background_run("your-human-survey-uuid", failed[-1]["uuid"])
```

```bash theme={null}
ep humanize background runs your-human-survey-uuid --failed --question classify_feedback
ep humanize background run your-human-survey-uuid your-run-uuid
```

Each run lists its question, `attempt` (2 for a retry), `status`, `model`, when it was queued, started and finished, and, if it failed, an `error` with its `kind`, the `stage` it failed at, a `message`, and whether it was `retryable`. `get_human_survey_background_runs()` returns one batch: while `has_more` is true, pass `next_cursor` as `after` to continue, or use `get_all_human_survey_background_runs()`. Filter by `question_name`, `response_uuid`, `status` (a list) or `failed_only`.

One run in full adds what the model was sent and returned: `system_prompt` and `user_prompt` after piping, so you can check that earlier answers were filled in as intended; the `files` attached, by name and type; the model's `service` and `model_parameters`; the reply (`generated_tokens`, `raw_output`, `reasoning_summary`); the `answer` and `comment` recorded; and token `usage`. For an image generation question, `raw_output` is a reference to the stored image, not the image.

| `error.kind` | Meaning |
| - | - |
| `parse_error` | The model's reply couldn't be read as an answer. Compare `generated_tokens` with what the question expects. |
| `invalid_answer` | The reply was read but didn't fit the question, such as an option that doesn't exist. |
| `provider_incomplete` | The reply was cut short: by the output token limit, a content filter or a refusal (`reason` says which). |
| `timeout` | The model didn't answer in time. |
| `rate_limited` | The model provider was busy. |
| `provider_error` | The model provider had an error or couldn't be reached. |
| `provider_rejected` | The model provider refused the request. |
| `provider_auth` | The model provider rejected Expected Parrot's credentials. |
| `invalid_model` | The question's model can't be run. |
| `insufficient_balance` | The survey owner's balance was empty. |
| `worker_lost` | The run stopped unexpectedly (status `abandoned`). |
| `internal` | Something went wrong on Expected Parrot's side. `message` includes a reference to quote when you contact us. |

`credits` appears on each run for the survey's owner only. Runs from preview links are left out unless `include_previews=True` (`--include-previews`); include them when testing through a preview link. To see the same runs in the browser, open `coop.get_human_survey_background_runs_url("your-human-survey-uuid")`.

### Working with file upload responses

If your survey includes a `QuestionFileUpload` question, each response's answer for that question is a list of file-info dicts. Use `FileStore.from_file_upload_answer` (single file) or `FileStoreList.from_file_upload_answers` (all files at once) to download and wrap them:

```python theme={null}
from edsl import Coop
from edsl.scenarios import FileStore, FileStoreList

coop = Coop()

results = coop.get_human_survey_responses("your-human-survey-uuid")

# Each answer is a list of uploaded file dicts
uploaded_files = results[0]["answer"]["my_upload_question"]

# Download all files as a FileStoreList
fsl = FileStoreList.from_file_upload_answers(uploaded_files)

# Or download a single file
fs = FileStore.from_file_upload_answer(uploaded_files[0])
```

The returned `FileStore` and `FileStoreList` objects behave like any other — you can inspect, filter, or pass them into further EDSL workflows.

***

## QR code

Generate a QR code for the respondent link using `get_human_survey_qr_code()`. Requires the `qrcode` library (`pip install "edsl[full]"` or `pip install "qrcode[pil]"`).

```python theme={null}
from edsl import Coop

coop = Coop()

qr = coop.get_human_survey_qr_code("your-human-survey-uuid")
qr.save("qr_code.png")  # save to file
```

From the CLI, generate a QR code for the shared respondent URL:

```bash theme={null}
ep humanize qr <human-survey-uuid> --output survey-qr.png
```

When the human survey has an attached agent list, export the roster together
with each respondent's personal link:

```python theme={null}
links = coop.get_human_survey_respondent_links(
    "your-human-survey-uuid", save_path="respondent-links.csv",
)
# links is a Dataset; the CSV contains the personal respondent URLs.
```

The CLI alternative is:

```bash theme={null}
ep humanize links <human-survey-uuid> --output respondent-links.csv
```

Add `--qr-dir` to generate one personal QR code per respondent and record each
PNG path in the CSV:

```bash theme={null}
ep humanize links <human-survey-uuid> \
  --output respondent-links.csv \
  --qr-dir respondent-qr
```

Personal links and QR codes contain access tokens and act as bearer credentials.
Store the export securely and share each link or code only with its intended
respondent. The CLI returns only file metadata on stdout, never the links.

***

## Prolific integration

For paid recruitment, see [Prolific recruitment](https://docs.expectedparrot.com/en/latest/prolific.md). That guide covers account limits, cost estimation, draft creation, preflight checks, publication, and response retrieval.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.