` of every question | Always present |
| `edsl-question-text` | Question prompt `
` | Rendered from `question_text` |
| `edsl-question-image` | `
` embedded in a question | Image blocks in question text |
| `edsl-error` | Error message `
` | Shown only when the answer is invalid |
### Options
Questions answered by picking from a list share these classes.
| Class | Element | Notes |
| - | - | - |
| `edsl-options` | Container wrapping all of a question's options | |
| `edsl-option` | `` for one option | Wraps the control and its text, so a click anywhere in the row answers |
| `edsl-option-label` | `` next to the radio or checkbox | |
| `edsl-option-content` | `` wrapping an option's rendered label | Also used for budget labels, rank items, and matrix rows and columns |
| `edsl-option-text` | Text `` inside `edsl-option-content` | |
| `edsl-option-image` | ` ` inside `edsl-option-content` | When an option references an image |
| `edsl-option-media-unavailable` | `` shown in place of an option's file | When the file isn't an image or can't be found |
| `edsl-select` | `` | When the humanize format is `dropdown`. Its options are plain text |
```html theme={null}
…
Golden retriever
```
### Radios and checkboxes
Each radio and checkbox is drawn by a ``, with the ` ` inside it hidden from
view. Style the span, not the input.
```html theme={null}
…
```
| Class | Element | Notes |
| - | - | - |
| `edsl-radio-control` | `` drawn in the radio's place: the circle | Carries `data-checked` while selected and `data-unchecked` while not |
| `edsl-radio` | The ` ` | Hidden, so styling it has no visible effect |
| `edsl-radio-indicator` | `` for the dot | Carries `data-checked` or `data-unchecked`. Hidden while unselected |
| `edsl-checkbox-control` | `` drawn in the checkbox's place: the box | Carries `data-checked` while selected and `data-unchecked` while not |
| `edsl-checkbox` | The ` ` | Hidden, so styling it has no visible effect |
| `edsl-checkbox-indicator` | `` around the tick `` | Carries `data-checked` or `data-unchecked`. The tick is drawn in `currentColor` |
```css theme={null}
/* Green controls */
.edsl-radio-control[data-checked],
.edsl-checkbox-control[data-checked] {
border-color: #2e7d32;
}
.edsl-checkbox-control[data-checked] {
background: #2e7d32;
}
.edsl-radio-indicator {
background: #2e7d32;
}
```
To style the row of a selected option, use `.edsl-option:has(.edsl-radio:checked)`.
## Question-specific HTML
### Budget
```html theme={null}
Allocate $100 across categories.
…
```
### Checkbox
```html theme={null}
```
### Checkbox with other
```html theme={null}
Which tools do you use?
…
```
### EDSL object
```html theme={null}
Select the agent list to use.
Select an Agent List
Selected Agent List
My agents - Q1 2025
UUID: a1b2c3d4-…
View
Clear
…
```
### File upload
```html theme={null}
Upload supporting documents.
…
Upload files
Drag & drop or click to browse
All file types · Max 10 files · Up to 1 GB per file
…
doc1.pdf
24 KB
Uploading…
Done
Failed
…
doc1.pdf
24 KB
Done
Failed
…
```
### Free text
```html theme={null}
Tell us about yourself.
…
```
### Linear scale
**Radio**
When humanize format is `radio` (default):
```html theme={null}
```
**Dropdown**
When humanize format is `dropdown`:
```html theme={null}
Rate from 1 to 5.
Select...
1 - Very poor
2
3
4
5 - Excellent
…
```
### Likert five
**Radio**
When humanize format is `radio` (default):
```html theme={null}
```
**Dropdown**
When humanize format is `dropdown`:
```html theme={null}
How satisfied are you?
Select...
Strongly disagree
Disagree
Neutral
Agree
Strongly agree
…
```
### List
```html theme={null}
List your top hobbies.
Please enter one item per line.
…
```
### Matrix
**Table**
When humanize format is `table` (default):
```html theme={null}
```
Both views are always in the page, and CSS picks which one shows. To keep the table at
every width:
```css theme={null}
.edsl-matrix-stack-view { display: none; }
.edsl-matrix-table-view { display: block; }
```
Row labels take 25% of the table's width by default, and the option columns share the
rest. To change that, set `--edsl-matrix-item-column` to a percentage, on
`.edsl-matrix-table` or on `.edsl-survey-container`:
```css theme={null}
.edsl-matrix-table { --edsl-matrix-item-column: 40%; }
```
**Carousel**
When humanize format is `carousel`, rows are shown one at a time:
```html theme={null}
```
### Multiple choice
**Radio**
When humanize format is `radio` (default):
```html theme={null}
```
**Dropdown**
When humanize format is `dropdown`:
```html theme={null}
Which fruit do you prefer?
Select...
Apple
Banana
Cherry
…
```
### Multiple choice with other
```html theme={null}
How did you hear about us?
…
```
### Numerical
**Input**
When humanize format is `input` (default):
```html theme={null}
```
**Slider**
When humanize format is `slider`:
```html theme={null}
On a scale of 0 to 100, how likely are you to recommend?
…
```
### Rank
```html theme={null}
Rank these items from most to least preferred.
…
```
### Survey message
```html theme={null}
Thanks! The next few questions are about your work.
```
### Top K
```html theme={null}
```
### Yes/no
**Radio**
When humanize format is `radio` (default):
```html theme={null}
```
**Dropdown**
When humanize format is `dropdown`:
```html theme={null}
Do you agree?
Select...
Yes
No
…
```
## Comment field
If a question has `comment` configured in the humanize schema, an additional comment input is rendered under that question.
```html theme={null}
```
# Custom JavaScript
Source: https://docs.expectedparrot.com/en/latest/custom_javascript
Run your own JavaScript on a human survey's questions, and record what respondents do.
Custom JavaScript lets you attach a script to a question in a human survey. The script runs in the respondent's browser once the question is on the page, so you can build interactive tasks, measure timing and behaviour, and record events with the survey.
> [!NOTE]
>
> Custom JavaScript is available on approved accounts only. Write to [info@expectedparrot.com](mailto:info@expectedparrot.com) to request access. Until your account is approved, a humanize schema that includes a script is rejected when you save it, and no part of that schema is saved.
## Checking access
```python theme={null}
from edsl import Coop
coop = Coop()
coop.get_custom_js_access() # True once your account is approved
```
Access is per account: once yours is approved, the human surveys you create can include custom JavaScript.
## Adding a script
Scripts go in the humanize schema, under `questions[question_name].javascript.hooks`. A hook maps an event to the script that runs on it. The only event is `"question.ready"`, which fires once the question has been rendered and its inputs are on the page.
This example records milliseconds from the question appearing to the participant's first selection. It illustrates collecting a behavioral measure alongside a multiple-choice answer. It measures the first selection, including time spent reading; it does not measure time to final submission or subsequent answer changes.
```python theme={null}
from edsl import Survey, QuestionMultipleChoice
survey = Survey([
QuestionMultipleChoice(
question_name="choice",
question_text="Which option do you prefer?",
question_options=["A", "B", "C"],
),
])
time_to_first_answer = """
const container = this.getQuestionContainer();
const shownAt = performance.now();
container.addEventListener("change", () => {
ep.log("first_answer", { ms: Math.round(performance.now() - shownAt) });
}, { once: true });
"""
humanize_schema = {
"questions": {
"choice": {"javascript": {"hooks": {"question.ready": time_to_first_answer}}},
},
}
survey.humanize(humanize_schema=humanize_schema)
```
To add or change a script on a survey that already exists, use `Coop.patch_human_survey_humanize_schema`:
```python theme={null}
coop.patch_human_survey_humanize_schema(
"your-human-survey-uuid",
{"questions": {"choice": {"javascript": {"hooks": {"question.ready": time_to_first_answer}}}}},
)
```
Set `"javascript": None` on a question to remove its script.
Most question types support `javascript`, including survey messages; see [Humanize schema](/en/latest/humanize_schema) for the list. `compute` and `image_generation` questions do not: they run in the background and never appear on the page.
## Writing a hook
A hook is the *body* of a function, not a function. Write the statements you want to run; don't wrap them in `function () { ... }`.
Inside a hook you have:
| Name | What it is |
| - | - |
| `this.getQuestionContainer()` | The question's element on the page. Find your question's inputs and add your own elements inside it. |
| `context.questionName` | The name of the question the hook is running for. |
| `context.questionType` | The question's type, for example `"multiple_choice"`. |
| `context.isPreview` | `true` when the survey was opened through a preview link. A preview's answers don't appear in your results, but its `ep.log` events are kept, marked `is_preview`. |
| `ep.log(eventName, payload)` | Records an event with the survey. See below. |
`context` carries nothing that identifies the respondent.
Each question's hook runs once when that question appears. If the same script is attached to several questions, it runs once for each, and `context.questionName` tells them apart.
### Recording events with `ep.log`
`ep.log(eventName, payload)` records an event against the respondent's response. Use it for anything you want to analyse later: timings, clicks, which condition a respondent saw.
* `eventName` must be a non-empty string. Names longer than 200 characters are cut to 200.
* `payload` is optional and must be convertible to JSON: objects, arrays, strings, numbers, booleans and `null`. A payload that isn't (a page element, an object that refers to itself) is not recorded, and a warning appears in the browser console.
* A payload larger than 4,000 characters, measured as JSON, is not recorded.
* Each response records at most 10,000 events.
`ep.log` returns nothing. Events are sent in batches in the background, so don't wait on it.
### What a script may do
Scripts can build interface inside the survey, read and write answers, control when respondents can move on, measure behaviour, randomise conditions, read parameters from the page URL, load images, audio, video and other media to show, and use the camera, microphone or location for a task the respondent is told about.
A script can also load a well-known, widely used library, such as d3, Chart.js or lodash, from a public CDN like cdnjs, jsDelivr or unpkg, at a pinned version (for example `https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js`, not `d3@latest`). Your own code must still be plain JavaScript written in the script itself.
A script can't be saved if it:
* Loads code from anywhere else: an unfamiliar or personal site, a paste site, a little-known or custom package, source hosted somewhere other than a named library, or a URL put together while the script runs.
* Builds code at runtime, for example by passing a constructed string to `eval`, `new Function`, `setTimeout` or `setInterval`, or by inserting markup that contains a script.
* Makes any request to Expected Parrot, or sends respondent data anywhere else. `ep.log` is how data leaves a script.
* Reads cookies, tokens, credentials, or storage it didn't create.
* Is obfuscated or minified so that what it does can't be read from the source. This applies to your script, not to a well-known library it loads.
* Asks for passwords or payment details, or imitates a login or other dialog.
* Collects anything unrelated to taking the survey, or uses the camera, microphone or location covertly.
* Keeps running after the respondent has finished or left the survey.
* Misleads or traps the respondent, for example by opening popups, preventing them from leaving, or hiding the survey's own notices.
* Uses the respondent's device for something other than the survey.
If a script can't be saved, no part of that humanize schema is saved, and the survey keeps what it had. If you think a script was refused by mistake, write to [info@expectedparrot.com](mailto:info@expectedparrot.com).
### Limits
* Each hook can be at most 64,000 characters, and all hooks on one question at most 256,000.
* One save can add at most 25 different new scripts. A script used unchanged on several questions counts once, and scripts the survey already has don't count. Save larger surveys in smaller batches.
## Retrieving events
Use `Coop.get_all_human_survey_events` to get every event a survey has recorded, oldest first:
```python theme={null}
from edsl import Coop
coop = Coop()
events = coop.get_all_human_survey_events("your-human-survey-uuid")
respondent_events = [e for e in events if not e["is_preview"]]
```
Events from preview links are included and marked `is_preview`, so leave them out of an analysis. Events that arrive while the download runs are included, and no event comes back twice.
For the timing example above, turn the matching events into a table:
```python theme={null}
from edsl import ScenarioList
timings = ScenarioList.from_list_of_dicts([
{"response_uuid": event["response_uuid"],
"session_uuid": event["log_session_uuid"],
"first_answer_ms": event["payload"]["ms"]}
for event in respondent_events
if event["event_name"] == "first_answer" and event["question_name"] == "choice"
])
timings
```
Match `response_uuid` to `agent.response_uuid` in the [human response data](/en/latest/humanize#retrieving-responses) to examine timing alongside choices. A participant can reopen the survey and create another session, so inspect repeated response identifiers before treating the timing table as one row per participant.
To fetch new events later without downloading everything again, pass the `id` of the last event you downloaded as `after`. Take it from the full download, before filtering out previews:
```python theme={null}
last_id = events[-1]["id"] if events else None
new_events = coop.get_all_human_survey_events(
"your-human-survey-uuid", after=last_id
)
```
> [!NOTE]
>
> Fetching only new events is useful for checking your scripts while a survey is collecting, but it can occasionally miss an event that was still being saved when you downloaded. For analysis, download the full log once fielding has ended, at least an hour after the last response.
To fetch one batch at a time, use `Coop.get_human_survey_events`. It takes `after` and `limit` (at most 200, the default), and returns `{"events", "next_cursor", "has_more"}`. Pass `next_cursor` as `after` to get the next batch while `has_more` is `True`. `next_cursor` is returned even when there is nothing more, so you can keep it and use it later.
To see how many events a survey has without downloading them, use `Coop.count_human_survey_events`. The count is as of the call; on a survey still collecting, a download started afterwards can return more.
Each event includes:
| Field | Description |
| - | - |
| `id` | The event's unique id |
| `received_at` | When Expected Parrot received it (ISO 8601, UTC) |
| `client_ts` | When the respondent's browser logged it (ISO 8601, UTC) |
| `response_uuid` | The response the event belongs to |
| `is_preview` | Whether the event came from a preview link |
| `log_session_uuid` | The page load the event was logged in. Each time a respondent opens or reloads the survey starts a new session, so one response can have several. |
| `session_state` | Whether that session's log is whole. See below. |
| `client_seq` | The event's position in its session, counting from 0. Sort by it to get the order events were logged in. A gap means an event in between was not recorded. |
| `question_name` | The question whose script logged it |
| `event_type` | What kind of event it is. Always `"ep.log"` for now. |
| `event_name` | The `eventName` passed to `ep.log` |
| `hook_event` | The hook that was running when the event was logged, for example `"question.ready"` |
| `source` | What logged the event. Always `"author_script"` for now. |
| `payload` | The `payload` passed to `ep.log`, or `None` |
`session_state` is one of:
* `"complete"`: the session ended normally and no events are missing.
* `"truncated"`: some events from this session are known to be missing, for example because they broke the limits above.
* `"unknown"`: no events are known to be missing, but the session never signalled that it ended, for example because the respondent's browser closed or lost its connection. Its log may or may not be whole.
`session_state` describes the whole session, so every event from one session carries the same value.
## From the command line
The `ep` CLI covers the same steps. `--javascript` takes `QUESTION:HOOK=FILE`, naming the question, the hook, and a file holding the script:
```bash theme={null}
# Check access
ep humanize custom-js-access
# Attach a script while building a schema, or to a survey that already exists
ep humanize schema create --survey survey.ep --javascript choice:question.ready=choice.js --output humanize.json
ep humanize schema set --javascript choice:question.ready=choice.js
# Remove a question's script
ep humanize schema set --clear-javascript choice
# Get the first batch of events, or save every event to a file
ep humanize events
ep humanize events --all --output events.jsonl
# Later, save only the events since the last download
ep humanize events --all --after --output new.jsonl
# Count events without downloading them
ep humanize events --count
```
`--all` requires `--output`, which can end in `.json` or `.jsonl`. Every result includes `next_cursor`, the value to pass as `--after` next time. If a download fails part way, no output file is written. As with the Python methods, use `--after` for checking a survey while it collects, and a full download once fielding has ended for analysis.
# Dataset
Source: https://docs.expectedparrot.com/en/latest/dataset
Work with selected columns as tabular data and export them for analysis.
A `Dataset` is a table of columns. A `Results` object retains interview and study information; calling `results.select(...)` produces a Dataset containing the selected columns. Use Dataset methods or pandas to transform and export that table.
For example, select a treatment label and a rating from Results, filter to the observations you want, then export a CSV for your statistical software. You can do these steps directly in EDSL; converting with `.to_pandas()` is useful when you want pandas analyses or plotting. A [ScenarioList](/en/latest/scenarios) is the corresponding collection of named records used as inputs to questions.
## A complete local example
This example uses four fabricated scores and makes no model calls. It creates a Dataset from a DataFrame, filters and sorts rows, adds a derived column, and writes a CSV.
```python theme={null}
import pandas as pd
from edsl import Dataset
frame = pd.DataFrame({
"condition": ["short", "explicit", "short", "explicit"],
"score": [3, 5, 2, 4],
})
dataset = Dataset.from_pandas_dataframe(frame)
selected = dataset.filter("score >= 4").order_by("score", reverse=True)
with_flag = dataset.mutate("high_score = score >= 4")
# Display in a notebook: put selected on the last line of a cell.
# Display in a script: selected.print()
# Convert to pandas when you need DataFrame operations.
analysis_frame = selected.to_pandas()
selected.to_csv("selected_scores.csv")
```
`analysis_frame` contains the two `explicit` rows with scores 5 and 4, in that order. `with_flag` contains all four rows plus the Boolean `high_score` column. `selected_scores.csv` contains only the selected rows.
## Constructors and conversions
Use `Dataset.from_pandas_dataframe(frame)` for a pandas DataFrame. You can also construct a column-oriented table directly:
```python theme={null}
from edsl import Dataset
dataset = Dataset([{"record_id": ["r1", "r2"]}, {"score": [3, 5]}])
frame = dataset.to_pandas()
```
Keep columns the same length. `to_pandas()` preserves prefixed EDSL column names by default. Use qualified names when joining data so unrelated columns are not confused.
## Common methods
| Call | Arguments and result |
| - | - |
| `dataset.select("condition", "score")` | Return a Dataset with the selected columns. |
| `dataset.filter("score >= 4")` | Return rows matching a Boolean expression. |
| `dataset.order_by("score", reverse=True)` | Return rows sorted by the named column; descending when `reverse=True`. |
| `dataset.mutate("high_score = score >= 4")` | Return a Dataset with a new or updated column computed from the expression. |
| `dataset.to_list()` | Return a list of values for one column, or a list of row tuples for multiple columns. |
| `dataset.to_pandas(remove_prefix=False)` | Return a pandas DataFrame; set `remove_prefix=True` to strip namespaces such as `answer.`. |
| `dataset.to_csv("scores.csv")` | Write a CSV file and return a FileStore containing the CSV. Column prefixes are retained by default. |
| `dataset.print()` | Display the table explicitly in a script. A final notebook expression displays it automatically. |
This example filters a table and converts a single selected column to a Python list:
```python theme={null}
from edsl import Dataset
scores = Dataset([{"score": [3, 5, 2, 4]}])
high_scores = scores.filter("score >= 4").order_by("score", reverse=True)
high_scores
# In a script: high_scores.print()
# For further processing: high_scores.select("score").to_list() returns [5, 4].
```
Conversion is optional when you only want to inspect the table. See [Results](/en/latest/results) for selecting columns from a completed study.
## Reshape and inspect a table
| Task | Methods and behavior |
| - | - |
| Preview rows | `head(n=5)`, `tail(n=5)`, or `table(max_rows=5)` returns a smaller table. `first()` returns the first value in the first column. |
| Inspect structure | `keys()` lists column names; `summary()` describes the table. |
| Rename or remove columns | `rename({"old": "new"})`, `drop("column")`, and `remove_prefix()` return new Datasets with renamed, removed, or unprefixed columns. Check for collisions before removing prefixes. |
| Sample rows | `sample(n=2, with_replacement=False, seed=42)` returns a sample; `shuffle(seed=42)` returns a shuffled table. Dataset sampling defaults to replacement, so choose explicitly. |
| Deduplicate | `unique()` returns distinct rows. |
| Reshape lists | `expand("tags")` creates rows from list items; `collapse("text", separator="\n")` combines values while grouping by the other columns. |
| Reshape all columns | `long()` produces `row`, `key`, and `value` columns; `wide()` reconstructs that layout. |
| Join tables | `merge(other, by_x="record_id", by_y="id")` performs a left join. Check for duplicate keys first. |
| Format output | `to_markdown_table()` returns a Markdown table string; `latex()` returns a LaTeX table. `print()` explicitly displays the table in scripts. |
For example, inspect the long representation of a small table:
```python theme={null}
from edsl import Dataset
table = Dataset([{"record_id": ["r1", "r2"]}, {"score": [3, 5]}])
long_table = table.long()
long_table
# long_table.wide() reconstructs the original columns.
```
## Export and convert tables
These methods also apply to Results, ScenarioList, and AgentList. Select the fields you want before exporting:
| Output | Call |
| - | - |
| Row dictionaries | `table.to_dicts(remove_prefix=False)` |
| Python values | `table.select("score").to_list()` returns the values of one column. Multiple columns produce row tuples. |
| pandas or Polars | `table.to_pandas()` / `table.to_polars()`; prefixes are retained by default. |
| CSV | `table.to_csv("scores.csv")` |
| Excel | `table.to_excel("scores.xlsx", sheet_name="Scores")` |
| Word table | `table.to_docx("scores.docx")` |
| EDSL inputs | `table.to_scenario_list()` / `table.to_agent_list()`; these remove column prefixes by default. |
The CSV, Excel, and Word export methods above return `FileStore` objects. `table.write("scores.csv")` writes a file directly, choosing its format from the filename extension.
`table.to_json()` returns a Python list of column dictionaries. Use `json.dumps()` when you need JSON text:
```python theme={null}
import json
json.dumps(table.to_json())
```
This differs from `table.to_dict()`, which returns the EDSL object structure that `Dataset.from_dict()` can reconstruct.
Install the corresponding optional library for conversions such as `to_polars()` or Excel output. For database exports, convert to a pandas DataFrame and use its database-writing methods with your chosen connection and replacement policy.
## Embed text
Text embeddings represent passages as lists of numbers that can be used to search for similar passages or group related responses. `EmbeddingModel` creates these representations; `Dataset.embed()` and `ScenarioList.embed()` add them as a column beside the source text. Embedding the text does not itself assign themes or create groups. Use [qualitative coding](/en/latest/research/qualitative) when you want named themes from a codebook.
This example uses fixed test vectors to show the table structure without a provider call. Their values do not represent semantic similarity:
```python theme={null}
from edsl import Dataset, EmbeddingCache, EmbeddingModel
texts = Dataset([{"text": ["The tool is easy to use.", "Exporting takes too long."]}])
embedding_model = EmbeddingModel(service_name="test", dimensions=4)
embedding_cache = EmbeddingCache()
embedded = texts.embed("text", model=embedding_model, cache=embedding_cache)
embedded
```
For actual text embeddings, select an embedding model and service explicitly: `EmbeddingModel(model_name="EMBEDDING_MODEL_NAME", service_name="openai")` uses direct provider access by default. Configure the provider's credentials. The `sentence_transformers` service runs locally with its dependencies and model weights installed; loading a model may download weights. `remote=True` routes supported embedding requests through Expected Parrot. Embedding models are separate from the language models selected with `Model()`.
For text outside a Dataset, `embedding_model.embed(["first text", "second text"], cache=embedding_cache)` returns an `EmbeddingResult`. Its `embeddings`, `input`, `model`, `service_name`, `usage`, and `cache_used` attributes retain the vectors and available metadata. `to_dataset()` and `to_scenario_list()` convert it to EDSL tables; `to_dict()` and `EmbeddingResult.from_dict(data)` serialize and restore it. In asynchronous Python, use `await embedding_model.async_embed(texts, cache=embedding_cache)`.
`EmbeddingCache(filename="embeddings.jsonl")` loads existing embedding entries and writes new ones to that file. With `immediate_write=False`, call `flush()` to persist pending entries. This cache is separate from the response Cache used by survey runs. Embeddings and provider calls can incur costs; the test service above makes no paid calls.
# Discovering types and services
Source: https://docs.expectedparrot.com/en/latest/enums
Inspect supported question types and inference services in the installed version.
Discover types from the installed package instead of maintaining a copied list. This is useful for tools that construct EDSL objects or validate generated inputs.
## Question types
```python theme={null}
from edsl import Question
question_types = Question.list_question_types()
example = Question.example("multiple_choice")
parameters = example.to_dict()
```
These operations construct local objects and do not call models. To inspect a particular type's schema, use `ep schema show --question_type multiple_choice`. See [Questions](/en/latest/questions#question-type-details) for response formats and construction parameters.
## Inference services
```python theme={null}
from edsl import Model
services = Model.services()
```
A registered service is not a guarantee that every model is currently available to your account. Use [Language Models](/en/latest/language_models) and [Provider configuration](/en/latest/guides/providers) for current model discovery and access.
# FileStore
Source: https://docs.expectedparrot.com/en/latest/filestore
Represent files as EDSL inputs and distinguish file attachments from tabular rows.
`FileStore` represents a file and its contents for use in EDSL or storage on Expected Parrot. A scenario can contain a FileStore attachment, or rows extracted from a file can become separate scenarios. For complete examples of asking questions about PDFs, images, video, and audio, see [Files and media as scenarios](/en/latest/scenarios#data-and-files).
## Rows from a CSV and a whole-file attachment
This self-contained example writes a two-row CSV using Python's CSV writer, constructs a FileStore, and loads the rows as scenarios. It makes no model calls or uploads.
```python theme={null}
import csv
from pathlib import Path
from edsl import FileStore, Scenario, ScenarioList, QuestionMultipleChoice
path = Path("feedback.csv")
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=["record_id", "feedback"])
writer.writeheader()
writer.writerows([
{"record_id": "f1", "feedback": "The setup was easy."},
{"record_id": "f2", "feedback": "The export button is missing."},
])
file = FileStore(str(path))
# A CSV row becomes one scenario, retaining its record_id.
rows = ScenarioList.from_source("csv", str(path))
question = QuestionMultipleChoice(
question_name="sentiment",
question_text="Classify this feedback: {{ scenario.feedback }}",
question_options=["Positive", "Neutral", "Negative"],
)
job = question.by(rows)
# A whole file can instead be attached under a scenario key for a compatible model.
attachment = Scenario({"document": file})
```
`rows` has two scenarios, each with `record_id` and `feedback`. The `attachment` scenario instead holds the complete file under `document`. Loading a CSV into rows is useful for one classification per record; attaching a whole document is useful when the model needs the document itself.
## Images, PDFs, and other media
Use a FileStore value in a scenario and reference its field in the question. For example, `Scenario({"image": FileStore("image.png")})` attaches an image that the question can reference with `{{ scenario.image }}`. A plain path string carries only the filename.
The [Scenarios page](/en/latest/scenarios#data-and-files) contains worked examples for:
* [Images](/en/latest/scenarios#images), including [several attachments in one question](/en/latest/scenarios#multiple-attachments-in-one-scenario).
* [Whole PDFs](/en/latest/scenarios#pdf-attachments), [PDF text by page](/en/latest/scenarios#pdf-text), and [PDF pages rendered as images](/en/latest/scenarios#pdf-page-images).
* [Video](/en/latest/scenarios#video) and [audio](/en/latest/scenarios#audio).
* [A directory of files](/en/latest/scenarios#a-collection-of-files), with one scenario per file.
## Storage and sharing
Creating a FileStore reads local data. Calling `push()` uploads it to Expected Parrot and requires authentication. Set visibility explicitly and retain the returned identifier. See [Saving and sharing objects](/en/latest/guides/saving_objects) for posting and retrieving research materials.
For example, save a PDF on Expected Parrot and retrieve it as a scenario attachment:
```python theme={null}
from edsl import FileStore
report = FileStore("report.pdf")
info = report.push(description="Study report", visibility="private")
retrieved_report = FileStore.pull(info["uuid"])
document = retrieved_report.to_scenario(key_name="document")
```
Reference `{{ scenario.document }}` in your question, then pass `document` to `question.by(document)`. You can also pass a shared file URL to `FileStore.pull()`. Access follows the object's sharing permissions. To extract PDF text after retrieval, use `ScenarioList.from_source("pdf", retrieved_report.to_tempfile())` after importing `ScenarioList`.
## Common methods
| Call | Arguments and result |
| - | - |
| `FileStore("document.pdf")` | Read a local file and create an object containing its data. |
| `file.to_scenario(key_name="document")` | Return a Scenario with the file under the named key. |
| `file.to_scenario_list()` | Parse supported file formats into rows; available conversions depend on the file type. |
| `file.write("copy.pdf")` | Write the stored content to disk and return the filename. |
| `file.push(description="...", visibility="private")` | Upload the file object and return remote metadata. Requires an account. |
| `FileStore.pull(url_or_uuid)` | Retrieve a stored file object from Expected Parrot. |
Using the CSV created in the example above:
```python theme={null}
from edsl import FileStore
file = FileStore("feedback.csv")
attachment = file.to_scenario(key_name="document")
rows = file.to_scenario_list()
rows
# In a script: rows.print()
```
`attachment` contains one complete file, while `rows` contains the CSV records. Choose the input that matches what each respondent should evaluate.
## Read, preview, and convert files
| Task | Method and result |
| - | - |
| Download a file | `FileStore.from_url(url, download_path=absolute_path)` downloads content to the supplied path and returns a FileStore. |
| Extract text | `file.extract_text()` returns extracted text when the file handler supports it. Inspect the text for extraction errors. |
| Read through a file object | `file.open()` returns a Python file-like object; close it when finished. |
| Create a temporary copy | `file.to_tempfile()` writes the stored content to a temporary file. |
| Preview | `file.view()` displays supported content. `file.download_link()` creates a notebook download link. |
| Read a table | `file.to_pandas()` uses the file's supported tabular reader. |
| Convert Markdown | `file.to_docx(output_path="report.docx")` or `file.to_pdf(output_path="report.pdf")` converts a Markdown FileStore. Conversion dependencies must be installed. |
| Inspect media | `file.is_image()`, `is_video()`, `get_image_dimensions()`, and `get_video_metadata()` identify supported media and inspect dimensions or video metadata. |
`FileStore.from_file_upload_answer(file_info)` retrieves one uploaded file from a human survey answer; see [file upload responses](/en/latest/humanize#working-with-file-upload-responses).
## Generate image inputs
`ImageGeneration` produces FileStore objects that can be attached to scenarios. This example uses a local test service that returns a tiny placeholder image, without a provider request:
```python theme={null}
from edsl import ImageGeneration
generator = ImageGeneration(model="test", service_name="test")
image = generator.generate("A product concept illustration")
image.write("test-image.png")
```
For actual generation, choose a model supported by the image service, for example `ImageGeneration(model="MODEL_NAME", service_name="google")`, with the provider credential configured. Image generation can incur costs. Pass `input_images=[file]` when the chosen service supports editing or image-conditioned generation. Provider-specific parameters can be supplied at construction or to `generate()`.
In a notebook with an active event loop or an asynchronous function, use `await generator.async_generate(prompt)` instead of `generate()`. A single generated image returns one FileStore; multiple images return a list. With `return_type="generated_image"`, the return value instead contains `GeneratedImage` metadata. Use its `to_filestore()` to access the file, `token_usage()` for usage information, and `cost()` for the model-price-based cost calculation; missing usage can be estimated rather than measured.
# Background jobs
Source: https://docs.expectedparrot.com/en/latest/guides/background_jobs
Submit remote jobs, find runs, and retrieve results with Python or the CLI.
A background job starts a remote study and returns control to your notebook or script while Expected Parrot runs the interviews. Use it for a large set of documents, repeated simulations, or a comparison across several models when you want to continue other work and collect the results later.
The workflow has three steps: submit the job, keep its identifier, and retrieve the completed Results. The examples use Python's `Coop` client; CLI equivalents follow at the end. Remote execution requires [Expected Parrot access](/en/latest/api_keys).
## Submit from Python
Start with a Jobs object containing your survey, agents, scenarios, and models. You can construct it with `survey.by(...)` or load a saved package:
```python theme={null}
from edsl import Jobs
job = Jobs.load("jobs.ep")
pending = job.run(
background=True,
offload_execution=True,
remote_inference_description="Customer feedback pilot",
)
job_uuid = pending.job_info.job_uuid
job_uuid
# In a script: print(job_uuid)
```
This returns a Results object whose data is pending. Keep `job_uuid` so you can retrieve the run from another session. The description helps you find it later. Review the inputs and [estimated cost](/en/latest/costs) before submitting.
## Check status and retrieve results
```python theme={null}
from edsl import Coop
coop = Coop()
status = coop.remote_inference_get(job_uuid=job_uuid)
status
# In a new session, first set job_uuid to the saved job identifier.
# In a script: print(status)
```
Inspect `status["status"]` and `status["latest_job_run_details"]`. The job UUID identifies the execution; `status["results_uuid"]` identifies the stored Results object and may be absent while the job is pending.
After the status is `completed` and a results UUID is available, retrieve and save the Results object:
```python theme={null}
results = coop.pull(status["results_uuid"], expected_object_type="results")
results.save("results.ep")
results.select("answer.*")
# Alternative: Results.pull(status["results_uuid"]) after importing Results.
# In a script, append .print() to display the selected answers.
```
If you still have `pending` in the original session, `pending.fetch()` can also load the completed results into that object. Calling it before completion waits for the run. Use the status query above to check failed, cancelled, or partially failed runs explicitly.
For a failed or partially failed run, retrieve the error report with `coop.get_error_report_markdown(job_uuid)`. Compare completion counts with the planned study before analyzing any available results.
## Find jobs with filters and pagination
`remote_inference_list()` returns a `CoopJobsObjects` collection containing job metadata. For example, find completed runs whose description contains your study label:
```python theme={null}
completed_jobs = coop.remote_inference_list(
status="completed",
search_query="Customer feedback",
page=1,
page_size=20,
sort_ascending=False,
)
completed_jobs.select("uuid", "description", "status", "results_uuid")
# In a script, append .print() to display the selected metadata.
```
| Argument | Meaning |
| - | - |
| `status` | One status or a list, such as `["queued", "running"]`. Also accepts `completed`, `failed`, `cancelled`, `cancelling`, and `partial_failed`. |
| `search_query` | Search the job description. |
| `page` | Page number, starting at **1**. Increase it while keeping the same filters to retrieve subsequent pages. |
| `page_size` | Jobs per page, from 1 to 100. |
| `sort_ascending` | `False` returns the most recently created jobs first. |
Listing metadata does not download the study or its answers. Fetch the objects represented by the current page when you need them:
```python theme={null}
result_sets = completed_jobs.fetch_results() # A Python list of Results objects.
job_specs = completed_jobs.fetch() # A Python list of Jobs objects.
```
`fetch_results()` skips entries without a results UUID. Both methods operate on the current collection, so request additional pages explicitly. `completed_jobs.filter(...)` can narrow metadata already downloaded, but does not search other pages; use the arguments to `remote_inference_list()` for server-side filtering.
For saved surveys, agents, scenarios, and Results objects, use [`Coop.list()`](/en/latest/coop#find-objects-with-filters-and-pagination). That searches stored objects; `remote_inference_list()` searches execution records.
## Read interview data by page
To inspect a bounded part of a run without downloading its entire stored Results object, use the result manifest and page methods. With a completed `job_uuid`:
```python theme={null}
manifest = coop.remote_inference_results_manifest(job_uuid, page_size=100)
manifest
```
The manifest includes `status`, `total_interviews`, `page_size`, and `page_count`. If `page_count` is greater than zero, retrieve a page:
```python theme={null}
page_data = coop.remote_inference_results_page(
job_uuid,
page=0,
page_size=manifest["page_size"],
)
interviews = page_data["interviews"]
interviews
# In a script: print(interviews)
```
Interview pages start at **0**, unlike job and object listings. Use page numbers from `0` through `manifest["page_count"] - 1`, keeping the same page size. These methods return dictionaries of raw interview data, including `agent`, `scenario`, `model`, and `answers`; they do not return a Results object. Use `coop.pull()` when you want Results methods such as `select()` and `filter()`.
## Cancel a job
To stop an execution you no longer want, call `coop.cancel_remote_inference_job(job_uuid)`. Check its status again with `remote_inference_get()`; a cancellation request may take time to finish. Cancelling a job does not undo costs already incurred or delete saved Results objects. The CLI alternative is `ep jobs cancel JOB_UUID --yes` when cancellation is intended.
## Use the CLI
Save and validate a Jobs package before submitting:
```bash theme={null}
ep validate --file jobs.ep
ep jobs cost jobs.ep
ep run jobs.ep --background
```
Read `data.meta.remote_job.job_uuid` from the JSON response. Replace `JOB_UUID` below with that value:
```bash theme={null}
ep jobs status JOB_UUID
ep jobs results JOB_UUID --output results.ep
ep results columns --file results.ep
```
Retrieve results after completion. For failures, use `ep jobs errors JOB_UUID --output error.md`. A submission acknowledgment is not a completed dataset. Inspect the finished observation count and any partial results before analysis.
To wait and save in one command, use `ep run jobs.ep --background --wait --output results.ep`. `--background --output` without `--wait` is invalid because there are no completed results to save yet.
See [CLI Guide](/en/latest/cli#submit-a-background-job), [Jobs](/en/latest/jobs), and [Remote Inference](/en/latest/remote_inference).
# Label data with survey questions
Source: https://docs.expectedparrot.com/en/latest/guides/data_labeling
Generate labels from an existing codebook, or develop the labels through exploratory questions first.
In EDSL, **the answers to your survey questions become the labels for your data**. Write questions about the characteristics you want to identify, choose their answer formats, and ask a model to answer them about each item. For example, questions about topic, sentiment, and suggested improvements can produce three labels for each open-ended survey response.
The questions form a **Survey**, and the items being labeled become **Scenarios**. For one model and iteration, each item produces one result row, with an answer column for each question. The same approach works with text passages, documents, images, and other [scenario inputs](/en/latest/scenarios#data-and-files).
There are two starting points:
* **You already have a codebook:** turn its categories and definitions into structured questions, then apply them to your data.
* **You want to develop the labels from the data:** use exploratory questions first, organize their answers into a codebook, then use that codebook to construct structured labeling questions.
## Start with an existing codebook
A **codebook** names your labels and explains when each applies. For a multiple-choice question, its category names become `question_options`, and its definitions become part of `question_text`. A checkbox question can use the same approach when several labels may apply to one item.
This example labels four service requests as Billing, Access, Feature request, or Other. It also asks for a supporting quotation. The codebook distinguishes a payment problem from an access problem, including when the inaccessible page concerns billing. The sample requests have no preassigned labels:
```python theme={null}
from edsl import QuestionMultipleChoice, QuestionFreeText, Scenario, ScenarioList, Survey
# Sample inputs: no existing labels are needed to run the survey.
records = ScenarioList([
Scenario({"record_id": "t1", "text": "I was charged twice."}),
Scenario({"record_id": "t2", "text": "I cannot reset my password."}),
Scenario({"record_id": "t3", "text": "Please add a dark theme."}),
Scenario({"record_id": "t4", "text": "The invoice page won't open."}),
])
# Translate the codebook into allowed answers and instructions for using them.
codebook = {
"Billing": "A charge or payment issue.",
"Access": "Inability to enter or use a page, including a billing page.",
"Feature request": "A request for new functionality.",
"Other": "A request that does not fit any of the categories above.",
}
definitions = "\n".join(f"{label}: {meaning}" for label, meaning in codebook.items())
category = QuestionMultipleChoice(
question_name="category",
question_text="What kind of request is this: {{ scenario.text }}?\n" + definitions,
question_options=list(codebook),
)
evidence = QuestionFreeText(
question_name="evidence", question_text="Quote the words supporting your code: {{ scenario.text }}",
)
survey = Survey([category, evidence]).add_targeted_memory(evidence, category)
job = survey.by(records)
```
`QuestionMultipleChoice` produces the category label in `answer.category`; `QuestionFreeText` produces supporting text in `answer.evidence`. `add_targeted_memory(evidence, category)` supplies the category question and its answer to the evidence question, so it knows which choice to support. Add more questions to label other characteristics of the same request.
### Generate and inspect the labels
With [model access configured](/en/latest/api_keys), run the job and view each classification beside its source and evidence:
```python theme={null}
results = job.run()
results.select("scenario.record_id", "scenario.text", "answer.category", "answer.evidence")
```
This produces four rows, one per request. For example, an illustrative row would be:
| record\_id | text | answer.category | answer.evidence |
| - | - | - | - |
| t1 | I was charged twice. | Billing | charged twice |
Actual answers come from your chosen model. Read the labels and quotations alongside the source, especially where category boundaries matter. To save the labeled data:
```python theme={null}
results.select(
"scenario.record_id", "scenario.text", "answer.category", "answer.evidence"
).to_csv("labeled_requests.csv")
```
## Develop labels through exploratory questions
When you do not yet know which categories will be useful, start by asking open-ended questions about your material. Their answers help you decide what the later structured questions should ask and which choices they should offer. The code below reuses `records` from the example above; it does not use the existing codebook.
### 1. Explore the material
This first survey proposes issue categories and describes what each person needs. For a larger dataset, begin with a selection of records covering the range of material you want to label:
```python theme={null}
from edsl import QuestionList
candidate_labels = QuestionList(
question_name="candidate_labels",
question_text=(
"What issue categories would help organize this service request: "
"{{ scenario.text }}? Propose short category names grounded in the text."
),
max_list_items=3,
)
need = QuestionFreeText(
question_name="need",
question_text="What does this person need help with: {{ scenario.text }}?",
)
exploration = Survey([candidate_labels, need]).by(records).run()
notes = exploration.select(
"scenario.record_id", "scenario.text", "answer.candidate_labels", "answer.need"
)
notes
```
Each record now has proposed categories and a description of the need. Different records may produce overlapping names such as “Payment problem” and “Billing.” These suggestions help develop a common set of labels for the next stage.
### 2. Consolidate the suggestions into a codebook
You can choose and define the labels yourself, making this a two-stage workflow: explore, then label. Or add a question that considers the exploratory answers together. Here `QuestionExtract` asks for a draft with two named fields: a list of labels and their definitions, including rules for ambiguous cases:
```python theme={null}
from edsl import QuestionExtract
propose_codebook = QuestionExtract(
question_name="codebook",
question_text=(
"Develop a codebook for classifying service requests by their main issue. "
"Use these source records and exploratory answers: {{ scenario.notes }}. "
"Merge overlapping suggestions into 2 to 4 categories, including Other. "
"Give each label a definition and explain how to choose between overlapping "
"categories. Other should cover requests outside the named categories."
),
answer_template={
"labels": ["Category name", "Other"],
"definitions": "Definitions and boundary rules for all labels.",
},
)
proposal = propose_codebook.by(Scenario({"notes": notes.to_list()})).run()
draft_codebook = proposal.select("answer.codebook").to_list()[0]
draft_codebook
```
Here the answers from one survey become the input to another question. Review the draft against the source material and your research question. You can edit `draft_codebook["labels"]` and `draft_codebook["definitions"]`, ask further questions to refine them, or explore additional records. For large collections, consolidate suggestions in batches before combining them into one codebook.
### 3. Use the chosen labels in structured questions
The next question takes its answer options directly from the codebook you developed. The definitions explain how to choose among those options. Apply this shared question to all records, including any used during exploration:
```python theme={null}
category_from_draft = QuestionMultipleChoice(
question_name="category",
question_text=(
"What kind of request is this: {{ scenario.text }}?\n"
+ draft_codebook["definitions"]
),
question_options=draft_codebook["labels"],
)
labeling_survey = Survey([category_from_draft, evidence]).add_targeted_memory(
evidence, category_from_draft
)
labeled_results = labeling_survey.by(records).run()
labeled_results.select(
"scenario.record_id", "scenario.text", "answer.category", "answer.evidence"
)
```
The exploratory questions helped determine the labeling scheme; this final survey generates labels using that scheme consistently across the dataset. You can use `QuestionCheckBox` instead when several discovered themes may apply, or construct several questions for different characteristics. For example, exploration might reveal that topic and urgency should be labeled separately.
## Choose the right labeling question
The response type determines what kind of labeling you can do:
| Labeling task | EDSL approach |
| - | - |
| Exactly one category per record | `QuestionMultipleChoice`, as above. Include an Other option if your categories do not cover every case. |
| Several categories can apply | `QuestionCheckBox`; see [Qualitative coding](/en/latest/research/qualitative) for themes and supporting quotations. |
| Extract named fields such as dates or organizations | `QuestionExtract` with an answer template; see [Question types](/en/latest/questions). |
| Rate an attribute such as intensity or relevance | `QuestionLinearScale` with defined scale values. |
For a larger dataset, [import records from a file or DataFrame](/en/latest/guides/importing_data) and reuse the same survey. [Qualitative coding](/en/latest/research/qualitative) develops the example of assigning overlapping themes to interview excerpts.
## Evaluate the labels
Generating labels does not require already-labeled examples. When you have independent reference labels, you can compare them with the answers to assess how well the questions and model perform your task. [Human validation](/en/latest/research/human_validation) shows how to collect or import those labels, match them by record identifier, and calculate agreement.
To test whether the labeling depends on the model, attach a ModelList and [compare answers on the same records](/en/latest/guides/model_comparisons). To refine the instructions themselves, use the [Prompt sensitivity](/en/latest/research/prompt_sensitivity) pattern. If you revise the codebook after reviewing disagreements, evaluate it on additional independently labeled records.
# Experimental conditions
Source: https://docs.expectedparrot.com/en/latest/guides/experimental_conditions
Compare messages across respondent profiles and understand which combinations EDSL runs.
An experimental condition is a version of the material or situation you want to compare: a different message, price, policy, or product description. In EDSL, scenarios hold those versions, while a shared question defines how each version will be evaluated. Keeping the question and response options the same makes the answers easier to compare.
This page builds a small message test: **does an instruction that names the file format receive different clarity ratings from a shorter instruction?** Four agent profiles evaluate both messages. You will learn how to combine scenarios and agents, run all their combinations, and keep the condition and profile information alongside each answer.
## Define what varies
The example represents two kinds of variation separately. The `messages` ScenarioList supplies what is being evaluated: a short message and a more explicit message. The AgentList supplies the perspective used to evaluate it: new or experienced users, each acting as an individual contributor or team lead.
```python theme={null}
from edsl import Agent, AgentList, QuestionMultipleChoice, Scenario, ScenarioList
agents = AgentList([
Agent(traits={"experience": experience, "role": role},
instruction="Answer using only the supplied profile and message.")
for experience in ["new user", "experienced user"]
for role in ["individual contributor", "team lead"]
])
messages = ScenarioList([
Scenario({"condition": "short", "message": "Export your report."}),
Scenario({"condition": "explicit", "message": "Download your report as a CSV file."}),
])
question = QuestionMultipleChoice(
question_name="clarity",
question_text="How clear is this instruction: {{ scenario.message }}?",
question_options=["Clear", "Unclear", "Unsure"],
)
job = question.by(agents).by(messages)
# Four profiles crossed with two messages gives eight interviews per model.
```
## Execute the design
Chaining `.by(agents).by(messages)` gives every profile both messages, in separate interviews. Four profiles times two messages produces eight interviews per model and iteration, each answering the same `clarity` question. This is a **crossed design**: EDSL runs every combination of the supplied lists.
With [model access configured](/en/latest/api_keys), the following code uses the default model. You can replace `Model()` with a [model you select](/en/latest/language_models). First view the answers together with the variables that produced them:
```python theme={null}
from edsl import Model
results = job.by(Model()).run()
table = results.select("scenario.condition", "agent.experience", "agent.role", "answer.clarity")
table
```
The resulting table has eight rows. `scenario.condition` identifies the message, `agent.experience` and `agent.role` identify the profile, and `answer.clarity` contains its rating. To count the ratings for each message in pandas:
```python theme={null}
frame = table.to_pandas()
counts = frame.groupby(["scenario.condition", "answer.clarity"]).size()
counts
```
These counts summarize the four configured profiles for each message. Keep the profile columns when you want to see whether a difference is concentrated among new users or team leads, rather than looking only at the totals. The responses describe these model-generated evaluations; the profiles are not recruited human participants.
## Choose a different assignment pattern
Crossing is useful when every profile should evaluate every condition. Other designs call for a different relationship between the inputs:
| What you want to compare | EDSL approach |
| - | - |
| Conditions without specified respondent profiles | Use `question.by(messages)` and let the selected model answer without the custom agents. |
| One scenario for each corresponding agent | Use `job.zip_assign(over=("agents", "scenarios"))` with equally sized lists; see [Job assignments](/en/latest/jobs). |
| Only particular agent–scenario combinations | Use `job.assign(...)` for explicit assignments or `job.include_when(...)` for a condition on their fields. |
| Several message versions within one interview | Use `question.loop(messages)` to create a separate question for each message, then put those questions in a Survey. Add memory if later answers should see earlier ones. |
| Human participants assigned to scenarios | Publish with `survey.by(messages).humanize(...)` and choose a scenario assignment method; see [Human studies](/en/latest/guides/human_studies). |
For additional factors such as price and message wording, construct scenarios containing each combination you want to test. To compare the same design across models, attach a ModelList as shown in [Comparing models](/en/latest/guides/model_comparisons). Each added profile, condition, or model increases the number of interviews, which you can inspect and [estimate costs for](/en/latest/costs) before running.
# AI-led interviews with people
Source: https://docs.expectedparrot.com/en/latest/guides/human_interviews
Personalize AI interview guides and combine conversations with structured survey questions.
An AI-led interview uses a guide to ask a human participant questions and relevant follow-ups. The participant supplies the responses; the AI acts as interviewer. In EDSL, this is a `QuestionInterview`: a question type that contains a conversation and returns its transcript.
Use it when you want participants to describe experiences in their own words and clarify details as the conversation develops. An interview can be the whole survey or sit alongside [multiple-choice questions, ratings, and other question types](/en/latest/questions). This lets you collect comparable measures and explore the experiences behind them in the same study.
## Combine an interview with structured questions
This survey first asks how often the participant exports reports, then explores their most recent experience in an interview, and finishes with an ease rating. The interview guide uses the first answer to put the conversation in context:
```python theme={null}
from edsl import QuestionInterview, QuestionLinearScale, QuestionMultipleChoice, Survey
frequency = QuestionMultipleChoice(
question_name="export_frequency",
question_text="How often do you export reports?",
question_options=["Daily", "Weekly", "Less often"],
)
interview = QuestionInterview(
question_name="export_experience",
question_text="Describe your experience exporting a report.",
interview_guide=(
# Pipe the participant's earlier answer into the interviewer's guide.
"The participant exports reports {{ export_frequency.answer }}. "
"Ask about the participant's most recent attempt to export a report. "
"Explore how that experience fits their usual reporting routine. "
"Ask what they tried, where they encountered difficulty, and what helped. "
"Use neutral follow-ups to clarify details; do not suggest problems."
),
max_turns=8,
)
ease = QuestionLinearScale(
question_name="export_ease",
question_text="Overall, how easy was your most recent attempt to export a report?",
question_options=[1, 2, 3, 4, 5],
option_labels={1: "Very difficult", 5: "Very easy"},
)
# The interview is one question in the same survey as the structured questions.
survey = Survey([frequency, interview, ease])
humanize_schema = {
"questions": {"export_experience": {"interview_mode": "text"}}
}
```
The interview occupies one position in the survey, but can contain multiple conversational turns. `question_text` introduces the topic and `interview_guide` directs the interviewer. `max_turns=8` limits interviewer turns; it does not define eight fixed questions. After the conversation, the participant proceeds to the rating question.
The schema selects text participation. See [Humanize schema](/en/latest/humanize_schema) for voice and other interview options.
## Personalize the interview guide
Piping works inside `interview_guide` as well as `question_text`. In this example, `{{ export_frequency.answer }}` inserts the participant's answer to the earlier question. Someone who selects “Weekly” gives the interviewer the context “The participant exports reports Weekly.” The guide then directs the interviewer to explore how the experience fits that person's routine.
You can use the same approach with an earlier free-text answer, such as a task the participant wants to discuss. Place questions whose answers you pipe before the interview. [Survey logic and piping](/en/latest/guides/survey_logic) explains how to combine this with branching, for example to ask an interview question only of participants with relevant experience.
Guides can also use information supplied by your study through [Scenarios](/en/latest/scenarios). For example, `"Ask about exporting reports in {{ scenario.product }}."` makes the product a variable in the guide. Attach the scenarios with `survey.by(scenarios)` and choose how participants receive them when publishing; see [Human surveys with scenarios](/en/latest/humanize#with-scenarios). This lets you reuse a guide across products, settings, or experimental conditions.
## Preview and publish
The following calls use Expected Parrot and require authentication. AI interviewing can incur usage costs. First obtain a preview and pilot the conversation with people:
```python theme={null}
preview_url = survey.preview(humanize_schema=humanize_schema)
```
Check that the guide uses the earlier answer, follow-ups stay on topic, and participants reach the final rating. When the instrument is ready, create the hosted survey:
```python theme={null}
info = survey.humanize(
human_survey_name="Report export interviews",
humanize_schema=humanize_schema,
)
respondent_url = info["respondent_url"]
human_survey_uuid = info["uuid"]
```
Preserve the UUID and distribute the respondent URL through the planned recruitment workflow. See [Human surveys](/en/latest/humanize#retrieving-responses) for response retrieval and [Prolific](/en/latest/prolific) for recruitment.
## Analyze the responses
After participants have responded, retrieve the structured answers and interview transcript together:
```python theme={null}
from edsl import Coop
results = Coop().get_human_survey_responses(human_survey_uuid)
results.select(
"agent.response_uuid",
"answer.export_frequency",
"answer.export_experience",
"answer.export_ease",
)
```
Each participant's transcript sits alongside their frequency and rating answers, so you can compare themes across frequent and occasional users or relate interview accounts to ease ratings. The interview answer contains messages with interviewer and respondent roles. Keep those roles when extracting passages so interviewer questions are not counted as participant statements. The [Qualitative coding example](/en/latest/research/qualitative) shows how to analyze excerpts with themes and supporting quotations; use the response identifier to connect excerpts back to their interview.
For help developing the interview guide from a brief or source material, use the [Research Agent](/en/latest/research_agent), then inspect and refine the EDSL study it produces.
# Human studies
Source: https://docs.expectedparrot.com/en/latest/guides/human_studies
Publish a survey, collect responses, and retrieve data for analysis.
Use EDSL to collect responses from people by turning a Survey into a hosted web questionnaire. Participants answer through a link; you retrieve their responses as data in Python. You can conduct a human study on its own or use the same instrument to compare human and model responses.
This example asks about an export feature. Participants who have not used it finish after the first question; those who have used it describe a problem and what would resolve it. The code first defines that questionnaire:
```python theme={null}
from edsl import QuestionYesNo, QuestionFreeText, Survey
used = QuestionYesNo(question_name="used", question_text="Have you used the export feature?")
problem = QuestionFreeText(
question_name="problem",
question_text="What problem, if any, did you encounter when exporting?",
)
followup = QuestionFreeText(
question_name="followup",
question_text="What would resolve this problem: {{ problem.answer }}?",
)
survey = (
Survey([used, problem, followup])
.add_stop_rule(used, "{{ used.answer }} == 'No'")
.add_targeted_memory(followup, problem)
)
```
## Publish and collect
With [Expected Parrot login configured](/en/latest/api_keys#log-in-from-a-notebook), publish it and retain the two identifiers used in the collection workflow:
```python theme={null}
info = survey.humanize(human_survey_name="Export feature feedback")
respondent_url = info["respondent_url"]
human_survey_uuid = info["uuid"]
```
`respondent_url` is the link participants open. `human_survey_uuid` identifies this hosted collection when you retrieve responses later. Calling `humanize()` creates the survey; it does not recruit participants or generate their answers. Use `survey.preview()` to inspect the questionnaire before sharing its respondent link. See [Humanize](/en/latest/humanize) for presentation settings and [Prolific](/en/latest/prolific) for recruitment.
## Retrieve responses
After responses have been collected:
```python theme={null}
from edsl import Coop, Results
human_results = Coop().get_human_survey_responses(human_survey_uuid)
if not isinstance(human_results, Results):
raise TypeError("Inspect returned response data before applying Results methods.")
table = human_results.select("answer.*")
table
```
Each completed response is a row, with columns such as `answer.used`, `answer.problem`, and `answer.followup`. Participants routed past a question have no answer to it. Use `table.to_csv("human-responses.csv")` to export the selected answers, or `table.to_pandas()` for analysis in a notebook. The type check handles the service's fallback to raw response data when conversion to Results is unavailable.
To show different material to participants, attach scenarios with `survey.by(scenarios).humanize(...)` and choose an [assignment method](/en/latest/humanize#scenario-list-methods). To invite a known panel, attach [respondent profiles and email addresses](/en/latest/humanize#with-agent-lists-for-email-notifications). The [Human validation example](/en/latest/research/human_validation) demonstrates matching human classifications with model predictions.
For adaptive interviews with human participants, use [Human interviews](/en/latest/guides/human_interviews).
# Importing data
Source: https://docs.expectedparrot.com/en/latest/guides/importing_data
Import tables, documents, and web content into scenarios and respondent profiles.
Importing data turns material you already have—such as spreadsheet rows, interview excerpts, or respondent profiles—into objects you can use in an EDSL survey. You do not need to copy each record into a separate question. Instead, write one question with a placeholder and let EDSL fill it with a different record for each interview.
The first choice is what the imported data represents. Use **scenarios** for material a model will read or evaluate, such as a customer message. Use **agents** for the traits of the respondent the model should portray, such as their experience with a product. A file containing the questions themselves is a third case, covered under [Existing questionnaires](#existing-questionnaires).
## Import messages and respondent profiles
The example creates two small CSV files so you can try the imports without downloading a dataset. `messages.csv` has one message per row; `profiles.csv` has one respondent profile per row. The last lines read them into a ScenarioList and an AgentList. If you already have CSV files, start with those import calls and substitute your filenames.
```python theme={null}
import csv
from pathlib import Path
from edsl import AgentList, ScenarioList
def write_fixture(path, rows):
with Path(path).open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
write_fixture("messages.csv", [
{"record_id": "m1", "message": "Your report is ready to download."},
{"record_id": "m2", "message": "Download your report with the Export button."},
])
write_fixture("profiles.csv", [
{"profile_id": "p1", "experience": "new user"},
{"profile_id": "p2", "experience": "frequent user"},
])
records = ScenarioList.from_source("csv", "messages.csv")
agents = AgentList.from_source("csv", "profiles.csv", name_field="profile_id")
assert len(records) == 2
assert len(agents) == 2
```
Each row of `messages.csv` becomes one Scenario in `records`. Its column names become fields: `record_id` identifies the original row, and `message` supplies text to a question. Each row of `profiles.csv` becomes one Agent; `name_field="profile_id"` uses the identifier as the agent's name, while the experience field describes the profile. Importing profiles prepares agents for model responses; it does not collect answers from the people described in the file.
## Use an imported column in a question
The following question evaluates the messages from the CSV. The placeholder `{{ scenario.message }}` reads the `message` field from each scenario. Attaching both lists asks each profile to evaluate each message:
```python theme={null}
from edsl import QuestionMultipleChoice
question = QuestionMultipleChoice(
question_name="clarity",
question_text="How clear is this message: {{ scenario.message }}?",
question_options=["Clear", "Unclear", "Unsure"],
)
job = question.by(records).by(agents)
# With model access configured, collect and display the ratings.
results = job.run()
results.select("scenario.record_id", "agent.agent_name", "answer.clarity")
```
Two messages times two profiles gives four result rows for one model and iteration. The identifiers let you match each rating back to its message and profile. Calling `by()` crosses the lists; it does not pair their rows by position. If each profile should receive only its corresponding record, use `zip_assign()` as described in [Experimental conditions](/en/latest/guides/experimental_conditions). If the task needs no custom profiles, use `question.by(records)` alone.
## Other Python input formats
The same approach works when your data comes from a spreadsheet, database, or Python object. `ScenarioList.from_source()` reads the source into scenarios so the question and job code can stay the same. For tabular data, each row becomes a scenario:
| Source | Python call |
| - | - |
| CSV or TSV | `ScenarioList.from_source("csv", "records.csv")` or `from_source("tsv", "records.tsv")` |
| Excel sheet | `ScenarioList.from_source("excel", "records.xlsx", sheet_name="Responses")` |
| pandas DataFrame | `ScenarioList.from_source("pandas", frame)` |
| Dictionary of columns | `ScenarioList.from_source("dict", {"record_id": ["r1", "r2"], "text": ["A", "B"]})` |
| List of row dictionaries | `ScenarioList.from_list_of_dicts([{"record_id": "r1", "text": "A"}])` |
| SQLite table | `ScenarioList.from_source("sqlite", "records.db", table="responses")` |
| Parquet or Stata file | `ScenarioList.from_source("parquet", "records.parquet")` or `from_source("dta", "records.dta")` |
`from_source()` normalizes column names to snake case by default. Pass `snakify=False` to preserve the source names. Some readers require optional format-specific dependencies. `ScenarioList.from_jsonl("scenarios.jsonl")` restores EDSL's serialized JSONL representation; it is not a general JSON row reader. For arbitrary JSON records, read them with Python's `json` module and use `from_list_of_dicts()`.
`AgentList.from_source()` supports the same source selection and accepts `instructions`, `codebook`, and `name_field` for profile imports. Instructions tell agents how to respond; a codebook explains what their trait fields mean. `AgentList.from_csv()` is a direct CSV alternative. To turn rows you have already prepared into profiles, use `AgentList.from_scenario_list(records)` or `records.to_agent_list()`.
Choose the import method based on where your data already lives. For example, use the pandas source if you have cleaned a DataFrame in a notebook; there is no need to save it to CSV and read it back. See [Scenarios](/en/latest/scenarios) for selecting, renaming, filtering, and transforming fields after import.
## Documents and web content
For documents, decide what one interview should receive: the text of a whole document, one page at a time, or the original file with its visual content. This choice determines both the scenario you construct and what the model can evaluate. Extracted text is useful for analyzing wording; the original PDF or page images are useful when charts, layout, or scanned content matter.
For documents, `Scenario.from_pdf("report.pdf")` and `Scenario.from_docx("report.docx")` extract text into one Scenario. `ScenarioList.from_source("pdf", "report.pdf")` extracts one scenario per PDF page. The [PDF text examples](/en/latest/scenarios#pdf-text) show the resulting fields, page filtering, and how to run a question over the pages.
To send the actual file to a model, use `Scenario.from_file(path, field_name="document")` or put a FileStore in a Scenario. See the worked examples for [whole PDFs](/en/latest/scenarios#pdf-attachments), [images](/en/latest/scenarios#images), [video](/en/latest/scenarios#video), and [audio](/en/latest/scenarios#audio). For scanned pages and visual analysis, [render PDF pages as images](/en/latest/scenarios#pdf-page-images).
`Scenario.from_url(url)` retrieves one URL, `Scenario.from_html(url)` retains HTML and extracted text, and `ScenarioList.from_urls(urls)` retrieves several. These make network requests. `ScenarioList.from_source("directory", "materials", recursive=True)` creates file-backed scenarios from a directory; see [A collection of files](/en/latest/scenarios#a-collection-of-files) for filtering file types and retaining filenames with answers.
Keep filenames, page numbers, or source URLs as scenario fields when you want them alongside the answers. These fields make it possible to trace a finding back to the source passage or file. [FileStore](/en/latest/filestore) explains how EDSL represents file attachments; the [Scenarios examples](/en/latest/scenarios#data-and-files) show how to reference them in questions.
## Existing questionnaires
If your source contains questions to ask rather than records to analyze, you want to create a Survey. [Builder](/en/latest/survey_builder) supports questionnaire creation and import through a visual interface. Importing an existing questionnaire requires its question types, wording, options, and routing to be represented in the resulting survey.
If your ScenarioList contains EDSL question definitions, `definitions.to_survey()` converts each row into a question. Supply fields such as `question_type`, `question_name`, `question_text`, and the parameters required by that type. For example:
```python theme={null}
from edsl import ScenarioList
definitions = ScenarioList.from_list_of_dicts([
{"question_type": "free_text", "question_name": "feedback", "question_text": "What should improve?"},
{"question_type": "multiple_choice", "question_name": "priority",
"question_text": "Which matters most?", "question_options": ["Speed", "Reliability"]},
])
survey = definitions.to_survey()
```
Here each row defines a question, so the result is a two-question Survey rather than two interviews about source records. The questions follow the row order; add any branching or memory with the [Survey methods](/en/latest/surveys). Use this route when question definitions already live in a table, or construct Question objects directly when writing a survey in Python.
# Comparing models
Source: https://docs.expectedparrot.com/en/latest/guides/model_comparisons
Apply selected models to matched inputs and inspect disagreement.
Different models can interpret the same request differently, even when they must choose from the same answer options. A model comparison sends matched inputs to several models and puts their answers side by side. This is useful when choosing a model for a labeling task, examining whether a finding depends on the model, or identifying records that deserve closer review.
Here you will ask two models to classify the sentiment of the same three feedback messages. EDSL handles the repeated interviews; the comparison table lets you see where their labels agree or differ.
## Prepare one task for every model
The example below creates a multiple-choice question and three scenarios. Each scenario carries a `record_id` so its answers can be matched across models, and a `feedback` field that fills the placeholder in the question. The `job` asks the question separately for each record:
```python theme={null}
from edsl import QuestionMultipleChoice, Scenario, ScenarioList, Survey
question = QuestionMultipleChoice(
question_name="sentiment",
question_text="Classify the sentiment of this feedback: {{ scenario.feedback }}",
question_options=["Positive", "Neutral", "Negative"],
)
feedback = ScenarioList([
Scenario({"record_id": "f1", "feedback": "The setup was easy."}),
Scenario({"record_id": "f2", "feedback": "I couldn't find the export button."}),
Scenario({"record_id": "f3", "feedback": "It worked as expected."}),
])
job = question.by(feedback)
# by(): one interview per input, with the same answer.sentiment column.
# loop(): three distinct questions in one survey, with the text substituted now.
variants = question.loop(feedback)
variant_survey = Survey(variants)
```
Use `job` for the comparison below. The final lines also demonstrate `question.loop(feedback)`, which creates a `variant_survey` containing three distinct questions in one interview. That alternative is useful when the records belong together in one questionnaire; the separate-interview design here keeps one answer column and one row per record and model.
## Select two available models
With [model access configured](/en/latest/api_keys), display the model catalog:
```python theme={null}
from edsl import Model, ModelList
Model.available()
```
Choose two model identifiers and their inference services from the catalog, then replace the placeholders below. The models can be from the same service or different services. A ModelList groups them so you can attach both to the existing job in one call:
```python theme={null}
models = ModelList([
Model("MODEL_A", service_name="SERVICE_A"),
Model("MODEL_B", service_name="SERVICE_B"),
])
comparison_job = job.by(models)
```
You can also discover models from the terminal with `ep models --text`. See [Language models](/en/latest/language_models) for catalog filters and model parameters.
## Compare answers for each record
Three records times two models gives six interviews for one iteration. The following code runs them, selects the record identifier, model name, and answer, then rearranges those six rows into a table with one column per model:
```python theme={null}
results = comparison_job.run()
frame = results.select("scenario.record_id", "model.model", "answer.sentiment").to_pandas()
comparison = frame.pivot(index="scenario.record_id", columns="model.model", values="answer.sentiment")
comparison
```
Use two distinct model names and one iteration for this pivot. Its three rows line up the answers for `f1`, `f2`, and `f3`, so a disagreement is visible on the same row. Read those rows alongside the original feedback: differences may reveal ambiguous text, different interpretations of the categories, or a model error. Agreement tells you whether the models made the same choice; [human reference labels](/en/latest/research/human_validation) let you assess whether that choice matches your intended classification.
## Adapt the comparison
You can reuse this pattern with any EDSL survey, including numerical ratings, free-text critiques, and questions about [files or images](/en/latest/scenarios#data-and-files). Choose models that support the input types your survey uses.
To compare configurations of one model, put multiple instances of that model in a ModelList with different supported parameters. Retain the parameter columns in the results: the model name alone will no longer identify a configuration. Similarly, `comparison_job.run(n=3)` produces three iterations for each record and model; retain the `iteration` field instead of using the single-answer pivot above. [Results](/en/latest/results) explains selecting and grouping these fields.
If the question is how instructions affect an answer, keep the model fixed and use the [Prompt sensitivity](/en/latest/research/prompt_sensitivity) design. If you simply want to run one chosen model, attach it directly with `job.by(model)`; a ModelList is only needed for the multi-model comparison.
# Provider and model configuration
Source: https://docs.expectedparrot.com/en/latest/guides/providers
Discover current services and configure model-specific parameters.
A provider supplies access to a model; EDSL's `service_name` selects the connection to that provider. Use this page when configuring model-specific settings or an institutional deployment such as Azure. For ordinary model selection, start with [Language models](/en/latest/language_models); [Model access](/en/latest/api_keys) explains Expected Parrot access and using your own provider keys.
After choosing access, inspect the available services and models, then the configuration you will use:
```python theme={null}
from edsl import Model
Model.services()
Model.available()
model = Model()
{"model": model.model, "parameters": model.parameters}
```
Catalog discovery may use network access. Creating a configuration does not generate answers. For an explicit model, use `Model(model_name, service_name=provider)` with identifiers selected from the catalog.
## Reasoning parameters
These settings matter when your task needs a different generation budget or behavior—for example, a lengthy document analysis rather than a short category label. Set supported parameters on `Model(...)`; they apply to the model calls in the job, rather than changing your survey questions.
A provider's supported parameters depend on the model. For OpenAI reasoning models, `reasoning_effort` is forwarded for provider validation. The Responses adapter also accepts a `reasoning` dictionary; an explicitly supplied effort overrides the effort inside that dictionary. Check the adapter and provider documentation for the selected model before choosing values. Do not assume temperature or token-limit semantics are shared across models.
## Azure deployments
The current Azure adapter reads `AZURE_ENDPOINT_URL_AND_KEY` as comma-separated `endpoint:key` pairs. Configure this credential securely without printing it. For Azure OpenAI, the endpoint includes the deployment path and its `api-version` query parameter. Select the `azure:` identifier reported for that configuration and pass `service_name="azure"`.
Deployment names and endpoint versions come from your Azure account. See the [Azure adapter source](https://github.com/expectedparrot/edsl/blob/main/edsl/inference_services/services/azure_ai.py) for the format supported by your installed EDSL version.
## Local and cloud execution
Local orchestration can still make network calls to a provider. See [Connect to your own model server](/en/latest/language_models#connect-to-your-own-model-server) to point EDSL at a local server. For Azure or other cloud-hosted models, use the service and credential configuration documented by the installed adapter and [Managing Keys](/en/latest/api_keys); endpoints and deployment identifiers are account-specific.
[Model comparisons](/en/latest/guides/model_comparisons) keeps matched inputs across providers. Use [Costs](/en/latest/costs) before expanding a run, and inspect recorded prompts and errors when troubleshooting adapter behavior.
# Saving and sharing objects
Source: https://docs.expectedparrot.com/en/latest/guides/saving_objects
Save, reopen, and share EDSL objects.
Save EDSL objects to continue a study in another session or give a collaborator the materials needed to inspect and reuse it. An `.ep` file reopens as the same kind of EDSL object; a CSV is a table for analysis in other software. Choose what to save based on what you want to do next:
| What you want to preserve | Save | Reopen with |
| - | - | - |
| The questionnaire and its logic | `survey.save("survey.ep")` | `Survey.load("survey.ep")` |
| The full planned model run, including its inputs | `job.save("job.ep")` | `Jobs.load("job.ep")` |
| Collected answers, prompts, and study information | `results.save("results.ep")` | `Results.load("results.ep")` |
| Selected columns for a report or statistical software | `results.select("answer.*").to_csv("answers.csv")` | Your usual CSV reader. |
Loading a saved Results object lets you analyze the collected answers without making new model calls. Saving a job preserves what to run; it does not substitute for saving the completed results.
## Save and reopen a survey
This example writes a local package and reads it back. It makes no model calls or uploads.
```python theme={null}
from edsl import QuestionFreeText, Survey
survey = Survey([QuestionFreeText(question_name="feedback", question_text="What should improve?")])
survey.save("feedback.ep")
restored = Survey.load("feedback.ep")
restored.question_names # ['feedback']; the loaded survey is ready to use.
```
Use explicit `.json` or `.json.gz` filenames when you need JSON instead of a package. For keeping named versions such as a pilot and a final instrument, see [Survey version history](/en/latest/survey_ep_format). Basic saving and loading do not require Git commands.
## Share through Expected Parrot
With authentication configured, `survey.push(description="Feedback survey", visibility="private")` uploads the survey and returns its identifier. Choose visibility explicitly and retain the returned identifier for retrieval or updates. Sharing a survey does not automatically share every referenced dataset or grant access to all related material.
Use [Expected Parrot objects](/en/latest/coop) and the [CLI Guide](/en/latest/cli) for remote operations. See [Notebooks](/en/latest/notebooks) for sharing a notebook or script as supporting material.
## Preserve a directory of study materials
`Study` captures files in a directory, including subdirectories. Use it when a study includes scripts, notes, and other files alongside individual EDSL objects:
```python theme={null}
from edsl import Study
study = Study("study-materials", exclude=[".git/*", "__pycache__/*", "*.tmp"])
study.to_jsonl("study-materials.jsonl")
restored = Study.from_jsonl("study-materials.jsonl")
restored.extract("restored-materials")
```
This assumes `study-materials` already exists. The archive contains file contents; it does not execute scripts or install dependencies. `exclude` uses relative-path glob patterns and no exclusions are automatic. Review the included files before sharing the archive. `extract()` writes beneath the original directory name (`restored-materials/study-materials` here) and overwrites existing files with matching paths. `study.to_yaml()` returns a readable inventory of the captured files.
# Survey logic and piping
Source: https://docs.expectedparrot.com/en/latest/guides/survey_logic
Build surveys that choose relevant questions and use earlier answers in follow-ups.
Survey logic lets an interview respond to the answers it receives. You can screen out respondents who have not used a product, ask a follow-up about a problem they described, or repeat a question for each item they name. These patterns are useful whenever asking everyone the same fixed sequence would produce irrelevant questions.
Three EDSL features work together here. **Branching** decides which question comes next. **Piping** inserts an earlier answer into a later question's wording. **Memory** gives the model earlier questions and answers as context when it answers a later question. A survey can use any combination of these features.
## Build a follow-up interview
The example below asks whether a respondent has used an export feature. A “No” ends the interview. A “Yes” leads to a question about problems, followed by a question about how to resolve the reported problem. It demonstrates all three features in a three-question survey:
```python theme={null}
from edsl import QuestionYesNo, QuestionFreeText, Survey
used = QuestionYesNo(question_name="used", question_text="Have you used the export feature?")
problem = QuestionFreeText(
question_name="problem",
question_text="What problem, if any, did you encounter when exporting?",
)
followup = QuestionFreeText(
question_name="followup",
question_text="What would resolve this problem: {{ problem.answer }}?",
)
survey = (
Survey([used, problem, followup])
.add_stop_rule(used, "{{ used.answer }} == 'No'")
.add_targeted_memory(followup, problem)
)
```
## Check the paths
`add_stop_rule()` evaluates the answer to `used` after that question is answered. If the answer is `No`, neither `problem` nor `followup` is asked. Otherwise, the survey continues in its original order.
In the last question, `{{ problem.answer }}` is replaced by the respondent's actual answer. If they answered “The export button is hidden,” the question becomes “What would resolve this problem: The export button is hidden?” `add_targeted_memory(followup, problem)` also supplies the preceding question and its answer as context. This illustrates both mechanisms; piping alone would be enough if you only wanted to insert the problem text.
With [model access configured](/en/latest/api_keys), run the survey and view the answers together:
```python theme={null}
results = survey.run()
results.select("answer.used", "answer.problem", "answer.followup")
```
This returns one interview with an answer column for each question; skipped questions have no substantive answer. To ask the same survey of several profiles or about several products, attach [agents](/en/latest/agents) or [scenarios](/en/latest/scenarios) with `survey.by(...)` before running it. For a closing message on an early-exit path, see [Survey messages](/en/latest/survey_messages).
## Choose which prior answers a model sees
Use memory when a later answer should take earlier exchanges into account without copying them into the question text. For example, a final recommendation can consider several earlier answers while keeping its wording simply “What should we improve first?” Choose how much context to include:
| Method | Context supplied to later questions |
| - | - |
| `survey.add_targeted_memory("followup", "problem")` | Include one earlier question and answer. |
| `survey.add_memory_collection("followup", ["used", "problem"])` | Include several named earlier questions and answers. |
| `survey.set_lagged_memory(lags=2)` | Include the preceding two questions and answers for each question. |
| `survey.set_full_memory_mode()` | Include all preceding questions and answers. |
These methods modify the survey and return it for chaining. Targeted memory is useful for a specific dependency, lagged memory for a moving conversation, and full memory for an interview in which every earlier exchange is relevant. More memory also means more text in later model prompts.
## Jump to another question
Ending the interview is only one routing choice. In a longer survey, you might want non-users to skip the feature questions but still answer a final summary question. If that survey contains a later question named `summary`, use `survey.add_rule("used", "{{ used.answer }} == 'No'", next_question="summary")` to jump to it.
Use `add_skip_rule()` to skip a particular question before it is asked, `add_rule()` to choose a destination after an answer, and `add_stop_rule()` to end the interview. Conditions can refer to available scenario values or earlier answers; an answer from a question that has not been asked cannot supply the intended routing information. `survey.clear_non_default_rules()` removes custom routing and restores the default forward sequence. See [Surveys](/en/latest/surveys) for the complete routing methods.
## Ask follow-ups for items in a list answer
Sometimes you do not know the follow-up topics in advance. `Survey.loop_over()` can take a list answer and ask the same follow-up about each item. The example below first requests up to three features, then asks what would improve each named feature. The follow-up template must not already be in the survey:
```python theme={null}
from edsl import QuestionFreeText, QuestionList, Survey
topics = QuestionList(
question_name="topics",
question_text="List up to three features you use.",
max_list_items=3,
)
detail = QuestionFreeText(
question_name="detail",
question_text="What would improve {{ item }}?",
)
survey = Survey([topics]).loop_over("topics", ask=detail, max_items=3)
survey.question_names
```
`{{ item }}` receives one list item at a time. If the answer lists “exports” and “search,” the respondent gets a follow-up about each; the unused third position is skipped. EDSL builds the three possible follow-up questions in advance, so each answer has its own question name in the results. `survey.loop_question_groups()` reports which questions belong to each loop. For conditional follow-ups within the loop, attach `detail.skip_when(expression)` or `detail.jump_when(expression, to="another_template_name")` before calling `loop_over()`.
For a fixed set of multiple-choice options, `survey.add_followup_questions(reference_question, followup_template, answer_template_var="answer")` creates a follow-up for each option and routes according to the selected answer. Use `{{ answer }}` in that template for the option text.
If you already know the items before the interview, `question.loop(scenarios)` instead creates a question for each supplied scenario. To ask each item in a separate interview, use `question.by(scenarios)`. See [Experimental conditions](/en/latest/guides/experimental_conditions) for choosing between those designs.
## Compute scores within a survey
A follow-up can also use a calculated value. For a survey containing numeric questions named `score_a` and `score_b`, `survey.add_summation_question(question_name="total", include=["score_a", "score_b"])` adds a question that computes their sum. `add_weighted_linear_scale_sum()` creates a score from linear-scale questions with specified weights. These calculations use the earlier answers directly and do not ask a model to do the arithmetic. Use them when the score belongs in the interview itself; use [Results transformations](/en/latest/results) when you only need it during analysis.
# Human surveys
Source: https://docs.expectedparrot.com/en/latest/humanize
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](/en/latest/guides/human_studies) 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](/en/latest/survey_builder). For Prolific recruitment, see [Prolific studies](/en/latest/prolific). For email notifications and scheduled reminders, see [Survey notifications](/en/latest/humanize_notifications). To let AI agents take a survey, see [AI agent access](/en/latest/humanize_agent_access).
## How it works
**Step 1**
**Create** [Surveys](/en/latest/surveys) with desired types of [Questions](/en/latest/questions).
**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](/en/latest/prolific)).
**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](/en/latest/api_keys#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 styling and optionality — see [Humanize schema](/en/latest/humanize_schema) |
### 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_responses` | Response count (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](/en/latest/humanize_groups) 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 --logo-asset --logo-alt "Acme Research logo"
```
Or do both in one step:
```bash theme={null}
ep humanize schema set --logo-file lab_logo.png --logo-alt "Acme Research logo"
```
See [Humanize schema](/en/latest/humanize_schema) for the `branding` fields, and [Custom CSS](/en/latest/custom_css) 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",
)
```
### 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](/en/latest/humanize_notifications) 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.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. |
| `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](/en/latest/humanize_agent_access#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).
### Checking response count
```python theme={null}
status = coop.get_human_survey("your-human-survey-uuid")
print(f"Responses received: {status['n_responses']}")
```
### 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](/en/latest/humanize_notifications) and [AI agent access](/en/latest/humanize_agent_access).
For events logged by a survey's [custom JavaScript](/en/latest/custom_javascript), `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.
### 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 --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 --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 \
--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](/en/latest/prolific). That guide covers account limits, cost estimation, draft creation, preflight checks, publication, and response retrieval.
# AI agent access
Source: https://docs.expectedparrot.com/en/latest/humanize_agent_access
Let AI agents take a human survey through an agent link, and control who answers and what guidance they get.
A human survey can let AI agents take it: a participant hands their AI agent the survey's agent link, and the agent answers through Expected Parrot's API instead of a browser. Agent access is off until the survey's owner turns it on, and only the owner can read or change it.
The agent link is the respondent link with `/respond/human-surveys/` replaced by `/respond/agent/`:
```text theme={null}
https://www.expectedparrot.com/respond/agent/
```
It serves the agent its instructions. Each agent attempt is recorded as a response, alongside browser responses.
Choose this workflow when participants want to use their own assistant to complete a hosted survey, or when you deliberately want autonomous assistant responses. This is separate from defining an EDSL `Agent` profile and running a simulation with `survey.by(agents).run()`. The participation mode below determines whether answers come from the participant, authorized context, or the assistant itself; keep that mode in comparisons of the collected responses.
***
## The config
| Field | Type | Default | Description |
| - | - | - | - |
| `enabled` | bool | `false` | Whether the agent link accepts new attempts. |
| `participation_mode` | string | `"human_assisted"` | Who produces the answers (see below). |
| `instructions` | string \| null | `null` | Guidance for agents on the whole survey, at most 4,000 characters. In autonomous mode, this is how the agent should answer. Advisory: nothing enforces it. |
| `question_settings` | dict | `{}` | Per-question settings, keyed by question name. Each may have `instructions` (at most 2,000 characters). |
### Participation modes
| Mode | Who answers |
| - | - |
| `human_assisted` | The participant. The agent asks each question and records their answers, and doesn't answer for them. |
| `authorized_context` | The agent, from context the participant has authorized it to use, asking them whenever it's unsure. |
| `autonomous` | The agent alone, as described in `instructions`. |
In `autonomous` mode there's no participant, so:
* The agent can't hand a page to a browser (see [What agents can't answer](#what-agents-cant-answer)).
* A start with Prolific parameters is refused: autonomous responses can't be used for a paid panel.
***
## Reading and changing the config
```python theme={null}
from edsl import Coop
coop = Coop()
access = coop.get_human_survey_agent_access("your-human-survey-uuid")
# {"configured": True, "enabled": True, "participation_mode": "human_assisted",
# "instructions": None, "question_settings": {}}
```
`configured` is `False` if agent access has never been set; the other fields are then their defaults.
To change it, send only the fields you want to change:
```python theme={null}
coop.patch_human_survey_agent_access(
"your-human-survey-uuid",
{
"enabled": True,
"participation_mode": "autonomous",
"instructions": (
"Keep free-text answers to one or two sentences, and use the "
"comment box to flag any answer that's an estimate."
),
"question_settings": {
"improvements": {
"instructions": "Name at least one specific change, not a general comment."
}
},
},
)
```
This example assumes the hosted survey has a question named `improvements`. Pass `survey=your_survey` when you also have the local Survey and want to validate the question names before sending the patch.
The patch is merged into the stored config:
* Fields left out are unchanged.
* `question_settings` merges by question name: naming `improvements` changes only its settings.
* `"instructions": None` clears the survey-wide instructions.
* A question set to `None` in `question_settings` has its settings removed, e.g. `{"question_settings": {"improvements": None}}`.
It returns the stored config, in the same shape as `get_human_survey_agent_access`.
From the CLI, `ep humanize agent-access get ` prints the config, and `patch` changes it:
```bash theme={null}
ep humanize agent-access patch --enabled --participation_mode autonomous
ep humanize agent-access patch --question_instructions "improvements=Name at least one specific change, not a general comment." --survey survey.ep
```
`patch` also takes the patch as a JSON file with `--config`, and `--survey` checks question names as `survey=` does.
***
## Validation
EDSL checks a patch before sending it, and raises `AgentAccessValidationError` if:
* It has a key the config doesn't define.
* A value is the wrong type or out of range, or `participation_mode` isn't one of the three modes.
* `enabled`, `participation_mode` or `question_settings` is `None`. Only `instructions` and a single question's settings can be cleared that way.
* With `survey=` (or `--survey`), it gives settings to a question that isn't in the survey.
***
## What agents can't answer
Some questions need the participant themselves:
* **Voice-only interviews.** In `human_assisted` and `authorized_context` mode, the agent hands the page to the participant: it gives them a one-time link to answer that page in their browser, then carries on. In `autonomous` mode there's no one to hand it to, so the survey can't be completed by an agent.
* **Timed questions.** A question with a fixed time limit, in a survey that shows one question per page (where the browser runs the timer), can't be answered by an agent: a time limit can't be enforced over the API. The agent link tells the agent to stop and give the participant the browser link instead.
Text interviews and file uploads are answered by the agent itself.
***
## In results
Every response carries these fields, so you can tell agent responses apart:
| Field | Description |
| - | - |
| `agent.collection_interface` | `browser`, or `agent_http` for an agent response. |
| `agent.participation_mode` | The survey's mode when the attempt started. Empty for browser responses. |
| `agent.client_name_self_reported` | The agent's client, as the agent reported it (e.g. `Claude Code`). Not verified. |
| `agent.model_name_self_reported` | The agent's model, as the agent reported it. Not verified. |
| `agent.handed_off_questions` | The questions the participant answered in their browser through a handoff. Empty for browser responses. |
The survey's page on Expected Parrot also has an **AI agents** tab showing the current config.
# Grouped surveys
Source: https://docs.expectedparrot.com/en/latest/humanize_groups
Define question groups and request one group per page in a human survey.
Grouped presentation puts related questions on the same page—for example, background questions together and feedback questions on the next page. Participants can review the questions in a group before submitting that page. Define the page boundaries on your `Survey`, then pass `{"survey": {"presentation": "group"}}` to `.humanize()`.
> [!NOTE]
>
> Grouped presentation requires support in both your installed EDSL version and the hosted survey interface. Preview with the same schema to check that the intended questions appear together.
## Create a two-page survey
This example puts two background questions on the first page and two feedback questions on the second.
```python theme={null}
from edsl import Survey, QuestionFreeText, QuestionMultipleChoice
survey = Survey([
QuestionMultipleChoice(
question_name="work_location",
question_text="Where do you usually work?",
question_options=["At home", "In an office", "A mix of both"],
),
QuestionFreeText(
question_name="role",
question_text="What is your role?",
),
QuestionMultipleChoice(
question_name="satisfaction",
question_text="How satisfied are you with your work arrangement?",
question_options=["Satisfied", "Neutral", "Dissatisfied"],
),
QuestionFreeText(
question_name="improvement",
question_text="What would improve your work arrangement?",
),
])
# First question, last question (inclusive), group name.
survey.add_question_group("work_location", "role", "background")
survey.add_question_group("satisfaction", "improvement", "feedback")
humanize_schema = {
"survey": {"presentation": "group"},
"questions": {"improvement": {"optional": True}},
}
```
| Page | Group name | Questions |
| - | - | - |
| 1 | `background` | `work_location`, `role` |
| 2 | `feedback` | `satisfaction`, `improvement` |
Preview with the same schema you intend to use when creating the survey:
```python theme={null}
preview_url = survey.preview(humanize_schema=humanize_schema)
# 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)
```
When ready, create the human survey:
```python theme={null}
info = survey.humanize(
human_survey_name="Workplace experience",
humanize_schema=humanize_schema,
)
print(info["respondent_url"])
print(info["admin_url"])
```
The groups live on the survey; the schema selects how to present them. Adding groups alone does not enable grouped presentation. Omit `presentation`, or set it to `"question"`, for the default question-by-question mode.
## Set group boundaries
`survey.add_question_group(start_question, end_question, group_name)` accepts question names or question objects and returns the survey for chaining.
* Each group includes both endpoints and every question between them.
* Groups must not overlap. A one-question page uses the same question for both endpoints.
* Names must be unique Python identifiers, such as `background`, and must not match a question name.
* Pages follow question order in the survey.
* Assign every question you want displayed to a group. With groups defined, the current grouped navigator skips questions outside those groups.
For example, to make a page containing only `work_location`, define its group with `survey.add_question_group("work_location", "work_location", "location_page")` on a survey where that question is not already grouped.
## Inspect navigation locally
Use `survey.next_group()` to inspect which questions belong on the next page for a given set of answers, without publishing the survey. This is useful when checking a route that should skip a page. For a visual check of the participant experience, use `survey.preview()` instead.
With the two-page survey constructed above:
```python theme={null}
first_page = survey.next_group()
assert first_page["group_name"] == "background"
assert first_page["question_names"] == ["work_location", "role"]
second_page = survey.next_group(
current_group="background",
answers={
"work_location.answer": "At home",
"role.answer": "Researcher",
},
)
assert second_page["group_name"] == "feedback"
```
Pass all answers collected so far, including those from the just-submitted group. Use `include_instructions=False` to return questions only.
| Returned field | Meaning |
| - | - |
| `group_name` | Selected group name, or `None` at the end |
| `items` | Question and instruction objects; `[EndOfSurvey]` at the end |
| `question_names` | Names of the questions selected for this page |
| `is_end` | `True` when the survey has finished |
See [Humanize](/en/latest/humanize) for retrieving responses and [Humanize schema](/en/latest/humanize_schema) for styling and other presentation options.
## Plan branching around pages
The navigator uses the answers supplied when a page is requested to filter skipped questions. After a page is submitted, it applies the normal navigation rules from that group's **last renderable question** to choose the next page. Put page-level jump or stop rules on that question.
If a later question depends on an earlier answer, put the dependent question on a later page so that answer is available when the page is selected. The navigation API does not establish live, within-page updates to conditional questions.
For example, starting with the two-page survey above, omit the improvement question for satisfied respondents:
```python theme={null}
# Keep the controlling answer on an earlier page.
survey.question_groups.clear()
survey.add_question_group("work_location", "satisfaction", "background")
survey.add_question_group("improvement", "improvement", "improvements")
survey.add_skip_rule(
"improvement",
"{{ satisfaction.answer }} == 'Satisfied'",
)
```
Now the first page collects work location, role, and satisfaction. A satisfied respondent skips the improvements page and reaches the end; other respondents continue to it. Add the rule before previewing or creating the human survey.
Additional navigation behavior:
* A group with no remaining questions is skipped.
* A jump into the middle of a group starts at the target question; earlier questions in that group are omitted.
* Relevant instructions before or within the selected group are included by default.
# Survey notifications
Source: https://docs.expectedparrot.com/en/latest/humanize_notifications
Send emails, schedule deliveries, and react to respondent events for human surveys.
Use survey notifications to invite a known respondent list, remind people who have not started, or send a transcript after completion. An immediate **delivery** sends messages now, a **schedule** sends them at a chosen time, and a **callback** sends them in response to an event such as survey completion.
`HumanSurveyNotificationHandler` keeps these operations associated with one hosted survey. Creating the handler does not send a message; the send, schedule, and callback methods below perform the corresponding action.
> [!NOTE]
>
> The survey must have an agent list with an email delivery channel configured before any notifications can be sent. Build a job with `survey.by(agents)` and pass `delivery_map` to its `.humanize()` method. See [Respondent lists](/en/latest/humanize#with-agent-lists-for-email-notifications) for the construction workflow.
The examples below operate on an existing hosted survey. Send and schedule calls contact the service and can deliver real messages to its respondents. Replace placeholder identifiers with values returned by your own survey workflow.
## Getting a handler
```python theme={null}
from edsl.coop import HumanSurveyNotificationHandler
handler = HumanSurveyNotificationHandler("your-human-survey-uuid")
```
A `Coop` client is created automatically. Pass one explicitly if you need a specific API key or base URL:
```python theme={null}
from edsl import Coop
from edsl.coop import HumanSurveyNotificationHandler
coop = Coop()
handler = HumanSurveyNotificationHandler("your-human-survey-uuid", coop=coop)
```
***
## Statuses
A delivery can contain one email task per selected respondent. Its dispatch and delivery statuses describe the message; the respondent's response status describes participation. A delivered invitation therefore does not mean a completed survey.
### Dispatch status
Controls whether the platform has attempted to hand off the message to the downstream channel (e.g. the email provider).
| Value | Meaning |
| - | - |
| `pending` | The task has been created but not yet processed. |
| `dispatched` | The message was successfully handed off to the channel. |
| `failed` | The dispatch attempt failed (e.g. missing address, provider error). |
### Delivery status
Reflects what the email provider reports back after dispatch.
| Value | Meaning |
| - | - |
| `pending` | Waiting for a delivery event from the provider. |
| `sent` | The provider accepted the message for delivery. |
| `delivered` | The provider confirmed the message reached the recipient. |
| `bounced` | The message was rejected by the recipient's mail server. |
| `failed` | The provider reported an error sending the email. |
### Response status
Tracks whether a respondent has engaged with the survey itself.
| Value | Meaning |
| - | - |
| `not_started` | The respondent has not opened the survey link. |
| `completed` | The respondent submitted a complete response. |
***
## Quickstart examples
### Send an email right now
Trigger an immediate delivery to all respondents.
```python theme={null}
result = handler.send_respondent_email(name="Initial outreach")
# 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(result)
# {"delivery_uuid": "...", "routes": [...]}
```
Pass a custom subject, HTML email body, or restrict which respondents receive it:
```python theme={null}
from edsl.coop.coop_humanize_notifications import (
HumanizeRespondentFilter,
RespondentCondition,
ResponseStatus,
)
# Only email respondents who haven't started yet
condition = RespondentCondition(
response_status=[ResponseStatus.not_started]
)
respondent_filter = HumanizeRespondentFilter(conditions=[condition])
result = handler.send_respondent_email(
name="Reminder to non-starters",
subject="Don't forget to complete our survey",
delivery_template='Please complete our survey .
',
respondent_filter=respondent_filter,
)
```
***
### Schedule a one-time email
Schedule a single delivery at a specific time. `run_at` must be a timezone-aware datetime or an ISO 8601 string.
```python theme={null}
from datetime import datetime, timedelta, timezone
run_at = datetime.now(timezone.utc) + timedelta(days=1)
schedule = handler.create_one_time_schedule(
name="Reminder tomorrow",
run_at=run_at,
)
print(schedule["schedule_uuid"])
```
Update the time before it fires:
```python theme={null}
handler.update_one_time_schedule(
schedule_uuid=schedule["schedule_uuid"],
run_at=run_at + timedelta(days=1),
)
```
***
### Schedule recurring emails (cron)
Send emails on a repeating schedule using standard cron syntax. Provide either `max_jobs` (fire at most N times) or `deadline` (stop firing after this datetime) as the termination condition. Use `start_at` to delay when the schedule begins firing; omit it to start immediately.
```python theme={null}
from datetime import datetime, timedelta, timezone
# Every Monday at 9 AM Eastern, up to 4 times
schedule = handler.create_cron_schedule(
name="Weekly Monday reminder",
cron_expression="0 9 * * MON",
timezone="America/New_York",
max_jobs=4,
)
```
With a deadline instead:
```python theme={null}
schedule = handler.create_cron_schedule(
name="Daily reminders for one week",
cron_expression="0 8 * * *",
timezone="America/Chicago",
deadline=datetime.now(timezone.utc) + timedelta(days=7),
)
```
With a delayed start:
```python theme={null}
schedule = handler.create_cron_schedule(
name="Weekly reminders starting next week",
cron_expression="0 9 * * MON",
timezone="America/New_York",
max_jobs=4,
start_at=datetime.now(timezone.utc) + timedelta(days=7),
)
```
Pause and resume a schedule:
```python theme={null}
handler.deactivate_schedule(schedule["schedule_uuid"])
handler.activate_schedule(schedule["schedule_uuid"])
```
***
### React when someone completes the survey (callback)
A **respondent** is a person with a corresponding entry in the survey's agent list. Each respondent has a unique survey link and a tracked response status. Submissions from people outside the agent list (e.g. anonymous links) are not associated with a respondent.
`create_callback` sends emails to respondents in your agent list. Two respondent event types are supported:
| Event | When it fires |
| - | - |
| `"human_survey_respondent.completed"` | When a respondent's status is set to completed. |
| `"human_survey_respondent.response_submitted"` | Each time a respondent submits a response. |
```python theme={null}
callback = handler.create_callback(
name="Completion thank-you",
callback_type="human_survey_respondent.completed",
)
print(callback["callback_uuid"])
```
Limit how many times the callback fires (e.g. only notify for the first 100 completions):
```python theme={null}
callback = handler.create_callback(
name="First 100 completions",
callback_type="human_survey_respondent.completed",
max_fires=100,
)
```
### Notify the owner on every new response
Use `create_owner_response_callback` to email the survey owner each time a response is submitted. This fires for all responses, including anonymous ones — not just respondents in the agent list.
```python theme={null}
callback = handler.create_owner_response_callback(name="New response notification")
print(callback["callback_uuid"])
```
### Send a transcript after each submission
Use `create_transcript_callback` to automatically email a transcript each time a response is submitted. Pass `recipient` to control who receives it:
* `"respondent"` (default) — emails each respondent a copy of their own answers.
* `"owner"` — emails the survey owner a copy of the answers. Fires for all submissions, including anonymous ones.
```python theme={null}
# Email the respondent their own transcript
callback = handler.create_transcript_callback(
name="Send respondent transcript on submission",
recipient="respondent",
)
# Email the owner a transcript on every submission
callback = handler.create_transcript_callback(
name="Send owner transcript on submission",
recipient="owner",
)
print(callback["callback_uuid"])
```
Activate and deactivate:
```python theme={null}
handler.deactivate_callback(callback["callback_uuid"])
handler.activate_callback(callback["callback_uuid"])
```
***
### Filter which respondents receive a delivery
`HumanizeRespondentFilter` lets you compose conditions to target specific subsets of respondents. Conditions can be nested with `and` / `or` operators.
```python theme={null}
from edsl.coop.coop_humanize_notifications import (
HumanizeRespondentFilter,
RespondentCondition,
DeliveryStatus,
DispatchStatus,
ResponseStatus,
FilterOperator,
)
# Target respondents who were never contacted
never_contacted = RespondentCondition(never_contacted=True)
# OR respondents whose last email bounced
bounced = RespondentCondition(
most_recent_delivery_status=[DeliveryStatus.bounced]
)
# Combine: never contacted OR previously bounced
respondent_filter = HumanizeRespondentFilter(
operator=FilterOperator.or_,
conditions=[never_contacted, bounced],
)
handler.send_respondent_email(
name="Re-engagement",
respondent_filter=respondent_filter,
)
```
**Available condition fields:**
| Field | Type | Filters to respondents where… |
| - | - | - |
| `respondent_uuids` | `list[str]` | UUID is in the provided list |
| `response_status` | `list[ResponseStatus]` | Survey response status matches |
| `never_contacted` | `bool` | Has never received a delivery |
| `any_dispatch_status` | `list[DispatchStatus]` | Any past task has this dispatch status |
| `any_delivery_status` | `list[DeliveryStatus]` | Any past task has this delivery status |
| `most_recent_dispatch_status` | `list[DispatchStatus]` | Most recent task has this dispatch status |
| `most_recent_delivery_status` | `list[DeliveryStatus]` | Most recent task has this delivery status |
***
### Check delivery status
List all delivery jobs for the survey:
```python theme={null}
deliveries = handler.list_deliveries()
for d in deliveries["deliveries"]:
print(d["delivery_uuid"], d["status"], d["sent_count"], d["failed_count"])
```
Inspect a specific delivery job:
```python theme={null}
delivery = handler.get_delivery(delivery_uuid="...")
print(delivery)
# {
# "delivery_uuid": "...",
# "status": "completed",
# "total_respondents": 50,
# "processed_respondents": 50,
# "sent_count": 48,
# "failed_count": 2,
# "started_at": "...",
# "completed_at": "..."
# }
```
Drill into individual tasks (one per respondent per delivery):
```python theme={null}
tasks = handler.list_delivery_tasks(delivery_uuid="...")
for task in tasks["tasks"]:
print(task["identifier"], task["dispatch_status"], task["delivery_status"])
```
Get a single delivery task by UUID:
```python theme={null}
task = handler.get_delivery_task(task_uuid="...")
print(task)
# {
# "task_uuid": "...",
# "channel": "email",
# "identifier": "respondent@example.com",
# "dispatch_status": "dispatched",
# "delivery_status": "delivered",
# "respondent": {"respondent_uuid": "...", "response_status": "completed"},
# ...
# }
```
***
## Route types
Each delivery or schedule sends email to survey respondents via a `RespondentEmailRouteConfig` route. A delivery template is always required; convenience methods like `send_respondent_email` default to the built-in invitation template when none is provided.
`RespondentEmailRouteConfig` options:
| Field | Description |
| - | - |
| `delivery_template` | The email body. Pass an HTML string to use a custom template, or omit to use the built-in invitation template. |
| `respondent_filter` | `HumanizeRespondentFilter` to restrict which respondents receive the email. |
| `subject` | Email subject line (1-200 characters). Uses the default subject when omitted. |
### Template variables
Custom HTML templates support the following variables, which are filled in per-respondent at send time:
| Variable | Value |
| - | - |
| `{{ survey_name }}` | The name of the human survey. |
| `{{ url }}` | The respondent's unique survey link (includes their access token). |
Example:
```python theme={null}
handler.send_respondent_email(
name="Initial outreach",
subject="You're invited to take our survey",
delivery_template="""
Hi there,
You've been invited to take {{ survey_name }} .
Click here to begin
""",
)
```
Update the subject and template on an existing respondent email route on a schedule:
```python theme={null}
handler.patch_schedule_respondent_email_route(
schedule_uuid="...",
route_uuid="...",
subject="A quick reminder about our survey",
delivery_template='Please complete our survey .
',
)
```
Update the subject and template on an existing respondent email route on a callback:
```python theme={null}
handler.patch_callback_respondent_email_route(
callback_uuid="...",
route_uuid="...",
subject="Thanks for completing our survey",
delivery_template="Hi! Thank you for completing {{ survey_name }} .
",
)
```
***
## Find and revise schedules and callbacks
The handler keeps the human survey UUID, so these methods only need the identifier of the schedule, callback, or route:
| Task | Method |
| - | - |
| List schedules or callbacks | `handler.list_schedules(page=1, page_size=50)` / `handler.list_callbacks(page=1, page_size=50)` |
| Inspect one | `handler.get_schedule(schedule_uuid)` / `handler.get_callback(callback_uuid)` |
| Reschedule | `handler.update_one_time_schedule(schedule_uuid, run_at=timestamp)`; for recurring schedules, `handler.update_cron_schedule(schedule_uuid, cron_expression=expression, timezone=timezone)` |
| Rename a callback | `handler.patch_callback(callback_uuid, name="New name")` |
| Add a route | `handler.add_schedule_route(schedule_uuid, route_config)` / `handler.add_callback_route(callback_uuid, route_config)` |
| Remove a route | `handler.delete_schedule_route(schedule_uuid, route_uuid)` / `handler.delete_callback_route(callback_uuid, route_uuid)` |
| Remove a schedule or callback | `handler.delete_schedule(schedule_uuid)` / `handler.delete_callback(callback_uuid)` |
Deactivating a schedule or callback pauses future triggers without deleting its configuration. Deletion removes it. Adding or changing active routes affects future deliveries, so review recipients and templates first. Listing methods return one page; use their pagination metadata to request subsequent pages. `list_deliveries()`, `list_delivery_tasks()`, and `get_respondents()` also accept `page` and `page_size`.
## Managing respondents
List all respondents and their current response status:
```python theme={null}
respondents = handler.get_respondents()
for r in respondents["respondents"]:
print(r["respondent_uuid"], r["response_status"])
```
Check and update the agent list delivery configuration (the trait column that holds each respondent's email address):
```python theme={null}
from edsl.coop.coop_humanize_notifications import ChannelConfig, DeliveryMap
# See current config
config = handler.get_agent_list()
print(config["agent_list_config"])
# Update the email column mapping
handler.patch_agent_list(
delivery_map=DeliveryMap(email=ChannelConfig(col_name="email_address"))
)
```
# Humanize schema
Source: https://docs.expectedparrot.com/en/latest/humanize_schema
Reference for the humanize schema: a dict that configures how survey questions are presented in web surveys with human respondents.
The **humanize schema** is a Python dictionary of settings for the participant experience: which questions are optional, how answer controls look, and whether to offer comments, timers, or voice interviews. Your Survey still defines question wording, answer choices, and routing. Include only the settings you want to change; the rest use their defaults.
## Make a question optional and offer a comment
This example keeps the rating required, adds a place to explain it, and makes the final free-text question optional. Settings under `questions` use the question names from the Survey:
```python theme={null}
from edsl import QuestionFreeText, QuestionMultipleChoice, Survey
survey = Survey([
QuestionMultipleChoice(
question_name="rating", question_text="How was your experience?",
question_options=["Good", "OK", "Bad"],
),
QuestionFreeText(question_name="feedback", question_text="What should improve?"),
])
humanize_schema = {
"questions": {
"rating": {"comment": {"label": "What led to your rating?"}},
"feedback": {"optional": True},
}
}
preview_url = survey.preview(humanize_schema=humanize_schema)
preview_url
```
Previewing requires [Expected Parrot access](/en/latest/api_keys). Pass the same `humanize_schema` to `survey.humanize(...)` when you publish. The rating and optional feedback remain answer columns; the accompanying explanation is recorded as `comment.rating_comment`. Use the field reference below for additional settings, or [Custom CSS](/en/latest/custom_css) for colors and spacing.
## Fields
**questions** `dict`
Map of question name (string) -> `HumanizeQuestionSchema`.
**survey** `dict`
Optional survey-level options (e.g. custom CSS).
**properties**
**survey.presentation** `string` (default: question)
`"question"` requests question-by-question presentation (the default); `"group"` requests one question group per page. Define groups on the survey with `add_question_group()` and include every question you want displayed. See [Grouped surveys](/en/latest/humanize_groups) for setup and branching between pages. Question time limits (`time_limit`) are ignored under `"group"`.
**survey.custom\_css** `string`
Optional custom CSS for the survey. Can be `null` or omitted. See [Custom CSS](/en/latest/custom_css) for the `edsl-*` CSS classes and HTML structure per question type.
**survey.progress** `dict`
How the respondent is shown their position in the survey. Omit to keep the progress bar, which is what surveys render by default.
**properties**
**type** `string` (default: bar)
Which progress indicator to render.
* `"bar"` — a filled bar, optionally labelled (default).
* `"hidden"` — no progress indicator anywhere in the survey.
* `"steps"` — markers joined by a connector, one per step, with the current step highlighted.
**label** `dict`
**`bar` only.** The label beneath the bar. Set to `null` to keep the bar and drop the label.
**properties**
**type** `string` (default: percent)
Must be `"percent"` — the share of the survey completed, e.g. `"33%"`.
**marker** `string` (default: number)
**`steps` only.** Whether each marker carries its step number (`"number"`) or is a bare dot (`"dot"`).
**steps** `array` *required*
**`steps` only.** The steps, in order. At least two are required.
A step is a *boundary*, not a bucket: it covers every survey item from the end of the previous step through its `complete_after`. Questions can be added or removed inside a step without touching this config, and items a respondent skips past inside a step don't move the marker.
Each step is an object with:
* `label` (string, optional) — the word under the marker, 1–60 characters. Omit or set to `null` to render the marker alone.
* `complete_after` (string, optional) — the name of the survey item (question or instruction) after which this step is complete. Only the final step may omit it, which runs that step to the end of the survey; every earlier step must name an item. The same item must not be named by two steps.
**survey.branding** `dict`
The author's brand on the respondent page. Omit or set to `null` for no banner, which is how a survey without this field renders.
**properties**
**logo** `dict`
The logo drawn in the survey banner, above the questions. Set to `null` to remove it.
Upload the image first, with `Coop().upload_human_survey_asset("lab_logo.png")` or `ep humanize assets upload lab_logo.png`, and reference the uuid it returns. A schema never carries image bytes.
There is no size field: the banner picks a default height. Style `.edsl-logo` in `custom_css` to change it. See [Custom CSS](/en/latest/custom_css).
**properties**
**source** `dict` *required*
Where the image comes from. Must be `{"type": "asset", "asset_uuid": ""}`, naming an image in your asset library.
**alt** `string` *required*
Alt text for screen readers, up to 200 characters. Required, so that leaving it out is a decision rather than an accident. Use an empty string to mark the logo decorative, which renders it with `alt=""`.
**position** `string` (default: left)
Where the logo sits in the banner: `"left"`, `"center"`, or `"right"`.
## HumanizeQuestionSchema (per question)
Each `questions[question_name]` value is a `HumanizeQuestionSchema` object.
**optional** `boolean` (default: false)
Whether the question is optional. Default is `false` when omitted.
**Supported question types:** `free_text`, `budget`, `checkbox`, `checkbox_with_other`, `file_upload`, `interview`, `likert_five`, `linear_scale`, `list`, `matrix`, `multiple_choice`, `multiple_choice_with_other`, `numerical`, `rank`, `top_k`, `yes_no`.
Note: `optional` currently has no effect for `interview` and `rank`.
**time\_limit** `dict`
A limit on how long the respondent may spend answering the question. Omit or set to `null` for no limit.
While the limit runs, a countdown is shown above the question. When it reaches zero the answer locks: the respondent can no longer change it and clicks **Next** to continue. They are not moved on automatically. Whatever they had entered is submitted as their answer, and a question they had not answered is recorded as unanswered, even if it is not `optional`.
A time limit applies only while the question is alone on its page. When `survey.presentation` is `"group"`, every question's `time_limit` is ignored, including a question that is the only one in its group. The countdown runs in the respondent's browser, so reloading the page starts it again.
**Supported question types:** `free_text`, `budget`, `checkbox`, `checkbox_with_other`, `file_upload`, `likert_five`, `linear_scale`, `list`, `matrix`, `multiple_choice`, `multiple_choice_with_other`, `numerical`, `rank`, `top_k`, `yes_no`.
**properties**
**duration** `dict` *required*
How long the respondent has. Must be `{"type": "fixed", "seconds": }`, the same number of seconds for every respondent. `seconds` must be between 30 and 7200 (two hours). `type` is required, even though `"fixed"` is its only value.
**format** `dict`
Display format. Varies by question type:
* **MC-style** (`likert_five`, `linear_scale`, `multiple_choice`, `yes_no`): `"radio"` (list of radio buttons) or `"dropdown"` (single ``). Default is `"radio"`. Use `{"type": "radio"}` or `{"type": "dropdown"}`.
* **Numerical** (`numerical`): `"input"` (number input field) or `"slider"` (range slider). Default is `"input"`. Use `{"type": "input"}` or `{"type": "slider", "min": 0, "max": 100, "step": 1}`. For slider, `min` must be less than `max`, `step` must be positive and not exceed `(max - min)`.
* **Matrix** (`matrix`): `"table"` (the whole grid at once) or `"carousel"` (one item at a time). Default is `"table"`. Use `{"type": "table"}` or `{"type": "carousel"}`.
Other question types do not support `format`.
**matrix carousel properties**
**advance\_on\_select** `boolean` (default: true)
**`carousel` only.** Whether answering an item moves to the next one on its own, rather than waiting for the respondent to click Next.
Every answer advances, corrections included — the same rule respondents will have met in Qualtrics. Set to `false` when respondents are expected to revise as they go, since under `true` a correction costs the way back as well.
**preselection** `dict`
**Matrix only** (`matrix`). Cells checked before the respondent touches the grid. Omit or set to `null` for an empty grid, which is what matrices render by default.
Use preselection when respondents should review and change a supplied starting answer. A row they leave untouched is recorded as the preselected option, so the resulting answer alone does not distinguish accepting that value from leaving the row unexamined. Omit preselection when each row should require a fresh choice.
**properties**
**rules** `array` *required*
The rules, in order. At least one is required.
Rules are tried in order and **the first match wins**, so the list reads like a routing table: the specific rows first, the catch-all last. A rule that matches every item must therefore be last — anything behind it could never be reached, and a list in the wrong order raises a validation error rather than silently dropping the rules it shadows.
Each rule is an object with:
* `match` (dict, required) — which rows the rule fills. Must be `{"type": "all"}`, every row of the grid.
* `option` (dict, required) — which column those rows are set to. Must be `{"type": "text", "value": ""}`, naming an option by its exact text in the question's `question_options`. Matching is literal, so surrounding whitespace is not trimmed for you.
**If the option you name is not in the list as served, the rule fills nothing.** It does not raise, and the survey still runs. A matrix's columns can be piped from an earlier answer, so the text you name may not exist until the question reaches the respondent — and a preselection must not be the thing that breaks a survey in front of them. Where the columns are a literal list, a name that matches none of them is caught when the survey is saved, while you can still fix it.
Those rows are then left empty; they do not fall through to the next rule. A row belongs to the first rule that matches it whether or not that rule finds its option, so a row singled out by an early rule cannot quietly pick up the catch-all that rule was written to override.
**custom\_validation** `dict`
**Multiple choice only** (`multiple_choice`). Optional custom validation rules.
**properties**
**select\_exact\_answer** `string`
If set, the human survey will require the respondent to select this exact option. Use for attention checks or forcing a particular choice. Omit or set to `null` to allow any selection.
**select\_all** `dict`
**Checkbox only** (`checkbox`; not `checkbox_with_other`, which shows no such box). The Select all box beneath the options: ticking it selects every option, unticking it clears them. Omit it to keep the box, which every checkbox question shows by default, or set it to `null` to remove it.
Exclusive options are left out of "all", since checking one clears every other selection.
**properties**
**label** `string`
The box's wording. `"Select all"` is the only accepted value, so setting it changes nothing.
**exclusive\_options** `array`
**Checkbox only** (`checkbox`, `checkbox_with_other`). Options that stand alone: checking one clears every other selection — including any other exclusive option, and for `checkbox_with_other` the respondent's "other" entries — and selecting anything else clears it. Use for options like `"None of the above"` or `"Prefer not to say"`.
Each entry must be the exact text of an option in the question's `question_options` — matching is literal, so surrounding whitespace is not trimmed for you. Entries must be non-empty and unique. Because an exclusive option is an ordinary member of the option list, the submitted answer looks like any other selection. Defaults to an empty list, meaning no option is exclusive.
**initial\_distribution** `dict`
**Distribution only** (`distribution`). What the chart shows before the respondent touches it. Respondents answer a distribution question by painting probability across its outcomes or bins; the percentages always add up to 100%.
**properties**
**type** `string` (default: uniform)
Which starting state to show. Required, even though `"uniform"` is the default when `initial_distribution` is omitted.
* `"uniform"` — equal probability for every outcome or bin (default). This is equal probability *per bin*, not per unit of width, so bins of unequal width still start equal. It is the answer's starting value: a respondent who agrees with it can click **Next** without painting.
* `"empty"` — nothing allocated. The respondent must paint at least one bar before they can continue.
**distribution\_summary** `dict`
**Distribution only** (`distribution`). Statistics implied by the respondent's distribution, shown beneath the chart and updated as they paint. Omit or set to `null` for no summary, which is the default.
The statistics assume probability is spread evenly within each bin, and the chart says so. They appear only for finite numeric bins: categories have no values to average, and an open-ended bin such as `[10,Inf)` has no midpoint. They are a display aid and do not change the submitted answer.
**properties**
**statistics** `array`
Which statistics to show, in the order to show them. Each entry is an object with a `type`:
* `{"type": "mean"}` — the implied mean: each bin's midpoint weighted by its probability.
* `{"type": "variance"}` — the implied variance, including each bin's own spread (width² / 12), in squared units of the bins.
Must be non-empty (omit `distribution_summary` instead of passing an empty list) and must not repeat a type. Defaults to mean, then variance.
**interview\_mode** `string` (default: text)
**Interview only** (`interview`). Controls the input mode offered to respondents.
* `"text"` — text-based interview (default).
* `"voice"` — voice-based interview.
* `"both"` — respondent can choose between text and voice.
**intro\_screen** `dict`
**Interview only** (`interview`). The intro screen shown to the respondent before the interview begins. The intro screen is always shown; this field customizes its text. Omit or set to `null` to use the default text (`"This will be a conversation with an AI agent."`).
**properties**
**type** `string` (default: default)
Must be `"default"`.
**instruction** `string` *required*
The intro screen body text. Must be between 1 and 2500 characters.
**voice\_interview\_config** `dict`
**Interview only** (`interview`). Optional configuration for voice-mode interviews. Ignored when `interview_mode` is `"text"`.
**properties**
**language** `string` (default: english)
The spoken language for the voice interview. A lowercase language id from the supported set below. Case and surrounding whitespace are normalized (e.g. `" French "` becomes `"french"`); omit or set to `null` to use the default (`"english"`). An unsupported value raises a validation error.
**Supported languages:** `english`, `french`, `german`, `spanish`, `portuguese`, `chinese`, `japanese`, `hindi`, `italian`, `korean`, `dutch`, `polish`, `russian`, `swedish`, `turkish`, `tagalog`, `bulgarian`, `romanian`, `arabic`, `czech`, `greek`, `finnish`, `croatian`, `malay`, `slovak`, `danish`, `tamil`, `ukrainian`, `hungarian`, `norwegian`, `vietnamese`, `bangla`, `thai`, `hebrew`, `georgian`, `indonesian`, `telugu`, `gujarati`, `kannada`, `malayalam`, `marathi`, `punjabi`, `multilingual`.
**turn\_taking** `dict`
How the interviewer decides the respondent has finished speaking. Omit to use the default (`{"initial_speed": "default"}`).
**properties**
**initial\_speed** `string` (default: default)
How long the interviewer waits after the respondent pauses before it speaks, at the start of the call. The respondent can change it from the interview screen.
* `"fastest"` — jumps in almost as soon as the respondent pauses.
* `"faster"`
* `"default"` — a natural pause (default).
* `"slower"`
* `"slowest"` — waits a long time, for respondents who pause mid-thought.
**text\_interview\_config** `dict`
**Interview only** (`interview`). Optional configuration for text-mode interviews. Ignored when `interview_mode` is `"voice"`.
**properties**
**language** `string` (default: english)
The language for the text interview. A lowercase language id from the supported set below. Case and surrounding whitespace are normalized (e.g. `" French "` becomes `"french"`); omit or set to `null` to use the default (`"english"`). An unsupported value raises a validation error.
**Supported languages:** `english`, `french`, `german`, `spanish`, `portuguese`, `chinese`, `japanese`, `hindi`, `italian`, `korean`, `dutch`, `polish`, `russian`, `swedish`, `turkish`, `tagalog`, `bulgarian`, `romanian`, `arabic`, `czech`, `greek`, `finnish`, `croatian`, `malay`, `slovak`, `danish`, `tamil`, `ukrainian`, `hungarian`, `norwegian`, `vietnamese`, `bangla`, `thai`, `hebrew`, `georgian`, `indonesian`, `telugu`, `gujarati`, `kannada`, `malayalam`, `marathi`, `punjabi`, `multilingual`.
**interviewer\_name** `string`
Label shown above the interviewer's messages. Must be between 1 and 200 characters. Defaults to `"AI Interviewer"`. Omit or set to `null` to use the default.
**end\_interview\_message** `string`
Message shown to respondents when the interview ends. Must be between 1 and 2500 characters. Defaults to `"Thank you for your time. This interview has been ended. Your responses have been recorded and will be valuable for our research."` Omit or set to `null` to use the default.
**checklist** `dict`
A checklist of items the interviewer works through during the interview. Omit or set to `null` to run without a checklist.
**properties**
**initial** `dict`
The starting set of checklist items, written by the survey author. Defaults to an empty list.
**properties**
**type** `string` (default: manual)
Must be `"manual"`.
**items** `array` (default: \[])
The list of checklist items. Each item is an object with:
* `id` (string, required) — a unique identifier, 1–64 characters, restricted to letters, digits, underscores, and hyphens (regex pattern `^[A-Za-z0-9_\-]+$`). Item ids must be unique within the checklist.
* `label` (string, required) — participant-facing text shown in the checklist UI, 1–200 characters.
* `instructions` (string, required) — model-facing text describing the condition under which the model should check the item off, 1–2000 characters.
**participant\_visibility** `string` (default: visible)
Whether the participant sees the checklist. The model always sees it (it's in the system prompt) regardless of this setting.
* `"visible"` — the floating checklist panel is shown to the participant during the interview (default).
* `"hidden"` — the participant never sees the checklist; it acts as a pure interviewer instrument (status is still tracked internally).
**structured\_questions** `dict`
Lets the interviewer ask multiple-choice and numerical questions mid-interview, rendered as input widgets rather than prose. Omit or set to `null` to disable structured questions.
**properties**
**allowed\_types** `array`
Which question types the model may use. Each entry must be `"multiple_choice"` or `"numerical"`. Defaults to both. Narrowing this restricts the model to the listed types. Must be non-empty (omit `structured_questions` instead of passing an empty list) and must not contain duplicates.
**end\_policy** `dict`
Controls how the interview is allowed to end. Omit to use the default (`{"control": "respondent"}`).
**properties**
**control** `string` (default: respondent)
Who decides when the interview is over.
* `"respondent"` — the participant can end at any time; the End Interview button is always available (default). No other fields apply.
* `"interviewer_gated"` — the End Interview button is hidden until the model has signalled that its goals are met; only then can the participant end. This mode also accepts `interview_marked_complete_message` and `participant_chat_after_complete`.
**interview\_marked\_complete\_message** `dict`
**`interviewer_gated` only.** A message to surface on the turn the interviewer first marks the interview complete. If omitted or set to `null`, the LLM will generate text for that turn instead.
**properties**
**message** `string` *required*
The message text. Must be between 1 and 2500 characters.
**method** `string` (default: replace)
How `message` relates to the model's text on the marking turn.
* `"replace"` — show this `message` for that one turn instead of using a model to generate text.
**participant\_chat\_after\_complete** `string` (default: open)
**`interviewer_gated` only.** Whether the participant can keep sending messages after the interviewer has marked the interview complete.
* `"open"` — the message composer stays live; marking the interview complete only unlocks the End Interview button (default).
* `"locked"` — the composer closes as well, so the marking turn is the last thing the participant reads and ending the interview is their only remaining action.
**participant\_chat\_during\_reply** `string` (default: open)
Whether the participant can type while the interviewer's reply is still being generated.
* `"open"` — the message composer stays live, so the participant can compose ahead of a reply they have not read yet (default).
* `"locked"` — the composer closes until the reply arrives, so each reply is read before anything is written against it.
Unlike `end_policy.participant_chat_after_complete`, this lock is per-turn and reopens; it applies to every turn under either end policy.
**comment** `dict`
Optional comment input shown with the question.
Submitted comment text appears in survey results under `comment.{question_name}_comment`.
**Supported question types:** `free_text`, `budget`, `checkbox`, `checkbox_with_other`, `likert_five`, `linear_scale`, `list`, `matrix`, `multiple_choice`, `multiple_choice_with_other`, `numerical`, `rank`, `top_k`, `yes_no`.
**properties**
**label** `string` *required*
Label text displayed for the comment field.
**submitting\_indicator** `dict`
Optional indicator shown after the respondent clicks **Next**, while the following question runs. Omit or set to `null` for no indicator.
**Supported question types:** `free_text`, `budget`, `checkbox`, `checkbox_with_other`, `file_upload`, `likert_five`, `linear_scale`, `list`, `matrix`, `multiple_choice`, `multiple_choice_with_other`, `numerical`, `rank`, `top_k`, `yes_no`.
Note: not supported for `compute` or `image_generation` (both run in the background and auto-advance, so neither is ever the question a respondent submits), or for `interview`.
**properties**
**type** `string` (default: callout)
How the indicator is rendered. Must be `"callout"` — a callout box shown while the next question runs, with the subtitle `"We'll continue automatically when it's ready."`
**title** `string`
Optional title for the callout, 1–200 characters. Omit or set to `null` to use the frontend's default label for the indicator type.
**javascript** `dict`
Optional JavaScript that runs on the respondent's page for this question. Omit or set to `null` to run no script. See [Custom JavaScript](/en/latest/custom_javascript) for how to write a hook, what a script may do, and how to retrieve the events it logs.
**Supported question types:** `free_text`, `budget`, `checkbox`, `checkbox_with_other`, `distribution`, `file_upload`, `interview`, `likert_five`, `linear_scale`, `list`, `matrix`, `multiple_choice`, `multiple_choice_with_other`, `numerical`, `rank`, `top_k`, `yes_no`, `survey_message`.
Note: not supported for `compute` or `image_generation`. Both run in the background and are dropped from the page, so there is no rendered question for a script to attach to.
This feature is available on approved accounts only. Write to [info@expectedparrot.com](mailto:info@expectedparrot.com) to request approval; until your account is approved, a schema carrying a hook is rejected when you push it.
**properties**
**hooks** `dict`
Maps a lifecycle event to the JavaScript source that runs on it. An unrecognized event name raises a validation error rather than being stored and silently never run.
Supported events:
* `"question.ready"` — fires once the question has been rendered and its inputs are in the DOM.
Each hook's source must be at most 64,000 characters, and all hooks on one question at most 256,000 characters in total.
## Example
The fuller example below combines a rating with comments, a probability distribution over waiting times, optional feedback, and a lab logo. The logo upload requires a local image and account access; use the simpler example above if you only need question settings.
```python theme={null}
from edsl import (
Coop,
Survey,
QuestionDistribution,
QuestionFreeText,
QuestionMultipleChoice,
)
# Upload the logo once; the schema references it by uuid
logo = Coop().upload_human_survey_asset("lab_logo.png")
survey = Survey([
QuestionMultipleChoice(
question_name="rating",
question_text="Rate your experience.",
question_options=["Good", "OK", "Bad"],
),
QuestionDistribution(
question_name="wait",
question_text="How many minutes do you expect to wait next time?",
min_value=0, max_value=30, bucket_size=5,
),
QuestionFreeText(
question_name="feedback",
question_text="Any additional feedback you'd like to share?",
),
])
humanize_schema = {
"questions": {
"rating": {
"optional": False,
"format": {"type": "dropdown"},
"comment": {"label": "Why did you choose this rating?"},
"submitting_indicator": {"type": "callout", "title": "Analyzing your answer…"},
},
"wait": {
"initial_distribution": {"type": "empty"},
"distribution_summary": {"statistics": [{"type": "mean"}]},
},
"feedback": {
"optional": True,
"time_limit": {"duration": {"type": "fixed", "seconds": 300}},
},
},
"survey": {
"custom_css": None,
"progress": {"type": "bar", "label": {"type": "percent"}},
"branding": {
"logo": {
"source": {"type": "asset", "asset_uuid": logo.uuid},
"alt": "Acme Research logo",
"position": "center",
}
},
},
}
# Validation runs before creating the human survey
survey.humanize(humanize_schema=humanize_schema)
```
## Retrieving a deployed schema
To read back the schema stored on a survey you already deployed:
```python theme={null}
from edsl import Coop
schema = Coop().get_human_survey_humanize_schema("your-human-survey-uuid")
```
It returns the schema as stored, or `None` if the survey was created without one.
In Python, use `Coop().patch_human_survey_humanize_schema(human_survey_uuid, partial_schema=changes)` to update an existing survey. Nested dictionaries are merged by key; lists, scalars, and explicit `None` replace the existing value. The complete merged schema is validated. Review the changed fields before updating a live instrument.
From the CLI, `ep humanize schema get ` prints it, and `--out schema.json`
writes it to a file you can edit and send back with `ep humanize schema patch`.
Give `--out` a `.json.gz` suffix to write the schema gzipped; any other suffix
writes plain JSON. Both are readable by the commands that take `--schema`.
## Validation
If the schema is invalid, Expected Parrot raises `HumanizeSchemaValidationError`. Common causes:
* A key in `questions` is not a question name in the survey, or is an instruction.
* A question's type is not supported for humanize schema (e.g. `demand`, `dropdown`).
* A question's entry has the wrong shape for its type (e.g. wrong field types or extra fields that aren’t allowed).
* A `time_limit` has `seconds` outside 30–7200, or a `duration` without its `"type": "fixed"`.
* Top-level structure is invalid (e.g. `questions` not a dict, or an entry not a dict).
You can also pass the schema to `survey.preview(humanize_schema=...)` to get a preview URL.
Ensure your humanize schema matches the parameters above for each question type in your survey.
# EDSL Documentation
Source: https://docs.expectedparrot.com/en/latest/index
The open-source Python package for research with AI and human participants.
EDSL (Expected Parrot Domain-Specific Language) lets you design and run surveys, experiments, and other research workflows in Python. Define questions, AI agents, and scenarios; run simulations across language models; label and analyze data; and compare simulated and human responses.
EDSL can be used independently as an open-source Python package or with the hosted services provided by the [Expected Parrot](https://www.expectedparrot.com) platform. These services include access to language models, remote execution, web surveys, AI-led interviews, and tools for storing and sharing research materials. The platform also includes a Research Agent that lets you design, run, and analyze studies through natural-language instructions, with access to the underlying EDSL code.
This documentation covers working directly with the EDSL Python package.
## Start here
1. [Install EDSL](/en/latest/installation) in a Python environment, or use [Google Colab](/en/latest/colab_setup).
2. [Run your first survey](/en/latest/starter_tutorial). Build a survey, vary its inputs, and inspect local test responses without an account or model credentials.
3. [Connect a language model](/en/latest/api_keys) when you are ready to generate real responses. Check the selected model and estimated [cost](/en/latest/costs) before running.
The tutorial's fixed test responses verify that the code works; they are not research findings.
For command line workflows and coding agent automation, see the [CLI guide](/en/latest/cli).
## Explore the docs
* [EDSL Overview](/en/latest/overview) — Understand the main components and workflows.
* [Label data with survey questions](/en/latest/guides/data_labeling) — Turn an existing codebook into labeling questions, or use exploratory questions to develop the labels first.
* [Human surveys](/en/latest/humanize) — Publish a web survey and collect responses from participants.
* [Language models](/en/latest/language_models) — Find and configure models for your study.
* [Results](/en/latest/results) — Analyze and export your data.
## Research Agent
For help designing, running, and analyzing studies through conversation, use the [Research Agent](https://www.expectedparrot.com/research-agent). Start with a research question, existing survey, or dataset and describe what you want to accomplish. The Research Agent uses EDSL to carry out research tasks and provides code you can inspect, modify, reuse, and share.
## Research and teaching
We support academic researchers, instructors, and students using Expected Parrot for research and coursework. Sign up with your academic email and contact [info@expectedparrot.com](mailto:info@expectedparrot.com) for academic access and support.
Explore [Research with Expected Parrot](/en/latest/papers), the [Teaching guide](/en/latest/teaching), and [citation instructions](/en/latest/citation).
## Source code and support
EDSL is available under the MIT License. Find the package on [PyPI](https://pypi.org/project/edsl/) and the source code on [GitHub](https://github.com/expectedparrot/edsl).
For questions and feedback, email [info@expectedparrot.com](mailto:info@expectedparrot.com).
# Installation
Source: https://docs.expectedparrot.com/en/latest/installation
Install EDSL in a Python environment and verify the installation.
EDSL requires Python 3.10–3.13. The steps below install it on your computer so you can work in a Python notebook or script. If you prefer a browser-based notebook without local setup, use [Google Colab](/en/latest/colab_setup).
Run the following commands in a terminal. A virtual environment keeps the project's Python packages separate from those used by other projects.
## Create an environment
```bash theme={null}
python3 -m venv .venv
```
Activate it on macOS or Linux:
```bash theme={null}
source .venv/bin/activate
```
On Windows PowerShell:
```powershell theme={null}
.venv\Scripts\Activate.ps1
```
## Install and verify
```bash theme={null}
python -m pip install edsl
ep info
```
`ep info` reports the installed version and configuration, with credentials redacted. If `ep` is unavailable, use `python -m edsl info` in the same environment.
You can now construct EDSL objects and use the test model. Continue to the [Run your first survey](/en/latest/starter_tutorial) tutorial or [CLI Guide](/en/latest/cli). [Model access](/en/latest/api_keys) is needed for real model calls; installation itself does not require an Expected Parrot account.
## Upgrade EDSL
Upgrade an existing installation with:
```bash theme={null}
python -m pip install --upgrade edsl
```
# Jobs
Source: https://docs.expectedparrot.com/en/latest/jobs
Combine study components, inspect the planned run, and execute it.
A `Jobs` object combines a survey with agents, scenarios, and models. Calling `by()` constructs the execution specification; calling `run()` executes it.
Think of a job as the complete set of interviews you want to collect. Each interview runs the survey with one agent, one scenario, and one model and produces one result record containing its answers. EDSL runs the combinations for you, so you can compare conditions without writing a separate loop for each one.
## Construct a job
This example crosses four respondent profiles with two message conditions. It constructs eight interviews per model and iteration without making model calls.
```python theme={null}
from edsl import Agent, AgentList, QuestionMultipleChoice, Scenario, ScenarioList
agents = AgentList([
Agent(traits={"experience": experience, "role": role},
instruction="Answer using only the supplied profile and message.")
for experience in ["new user", "experienced user"]
for role in ["individual contributor", "team lead"]
])
messages = ScenarioList([
Scenario({"condition": "short", "message": "Export your report."}),
Scenario({"condition": "explicit", "message": "Download your report as a CSV file."}),
])
question = QuestionMultipleChoice(
question_name="clarity",
question_text="How clear is this instruction: {{ scenario.message }}?",
question_options=["Clear", "Unclear", "Unsure"],
)
job = question.by(agents).by(messages)
# Four profiles crossed with two messages gives eight interviews per model.
```
Before execution, choose a model, inspect the prompts with `job.by(model).prompts()`, and estimate [costs](/en/latest/costs). A `ModelList` expands the job across the selected configurations.
## Execute and inspect
After configuring credentials and model access:
```python theme={null}
from edsl import Model
results = job.by(Model()).run()
results.select("scenario.condition", "agent.experience", "answer.clarity")
```
This step makes model calls and can incur costs. Use `disable_remote_inference=True` for local orchestration; provider calls may still be remote. Use a test model for an offline smoke test as shown in the [Run your first survey](/en/latest/starter_tutorial) tutorial.
With one model and iteration, this example returns eight rows. Each row contains a profile's clarity rating for one message. The selected columns retain the condition and experience level needed to compare those ratings. See [Results](/en/latest/results) for filtering, grouping, and exporting them.
## Background runs and persistence
Save a job as an `.ep` package to retain the full run specification. Use [Background jobs](/en/latest/guides/background_jobs) for long remote work and the [CLI Guide](/en/latest/cli) to submit, poll, and retrieve results from scripts or coding agents.
## Pair inputs or specify assignments
By default, `by()` crosses agents, scenarios, and models. Use `zip_assign()` when each agent should receive the scenario at the same position:
```python theme={null}
from edsl import Agent, AgentList, Model, QuestionFreeText, ScenarioList
agents = AgentList([Agent(traits={"role": "editor"}), Agent(traits={"role": "reader"})])
messages = ScenarioList.from_list("message", ["First draft", "Second draft"])
question = QuestionFreeText(
question_name="feedback", question_text="Review {{ scenario.message }}."
)
paired_job = question.by(agents).by(messages).by(Model("test"))
paired_job.zip_assign(over=("agents", "scenarios"))
len(paired_job.interviews()) # Two paired interviews, instead of four crossed interviews.
```
The paired collections must have equal lengths by default (`strict=True`). Each pair is still crossed with the remaining models. `job.assign([{"agent": 0, "scenario": 1, "model": 0}, ...])` instead specifies exact combinations using zero-based indices into the job's component lists.
For a condition over combinations, use `job.include_when("{{ agent.role == scenario.required_role }}")`. The named fields must exist in your inputs. Calling `assign()` or `zip_assign()` replaces an inclusion expression; calling `include_when()` replaces an assignment plan. Inspect `job.interviews()` after choosing the design and before running it.
## Reuse a cache or run asynchronously
`job.using(cache)` or `job.using_cache(cache)` attaches a Cache to the job; passing `cache=cache` to `run()` is the explicit per-run alternative. `survey.using(cache)` and `question.using(cache)` construct Jobs with that cache attached.
In an asynchronous function or a notebook that supports top-level `await`, use `await job.run_async(...)`. It accepts run options such as `cache`, `n`, `disable_remote_inference`, and `disable_remote_cache`. Background submission is a different workflow: see [Background jobs](/en/latest/guides/background_jobs).
## Common methods
| Call | Arguments and result |
| - | - |
| `survey.by(agents).by(scenarios).by(models)` | Build a Jobs object from study components. |
| `job.interviews()` | Return the planned interview objects so you can inspect the combinations before execution. |
| `job.prompts(iterations=1)` | Return a Dataset of rendered prompts for the requested number of iterations. |
| `job.estimate_job_cost(iterations=1)` | Return estimated costs and token counts; see [Costs](/en/latest/costs#estimate-a-prepared-job). |
| `job.estimate_remote_job_cost(...)` | Return a detailed estimate with question and model breakdowns; see [Detailed estimates](/en/latest/costs#detailed-estimates). |
| `job.run(...)` | Execute the configured interviews and return Results for a completed blocking run. |
| `job.save("job.ep")` / `Jobs.load("job.ep")` | Save and restore the full job specification locally. |
## Execution options
Pass these keywords directly to `job.run()`. A real model run can incur costs; review [model access](/en/latest/api_keys) and [costs](/en/latest/costs) first.
| Keyword | Use |
| - | - |
| `n=1` | Number of iterations for each combination of agents, scenarios, and models. |
| `cache=cache` | Use a specific local Cache object for response reuse. |
| `fresh=True` | Request fresh responses from hosted execution. For direct local runs, use an empty Cache and disable remote caching to avoid existing entries. |
| `disable_remote_inference=True` | Run orchestration locally with direct model access. Provider calls can still be remote. |
| `offload_execution=True` | Request hosted execution on Expected Parrot. |
| `disable_remote_cache=True` | Disable access to the hosted response cache. |
| `max_concurrency=10` | Bound concurrent interviews in locally orchestrated execution; choose a value appropriate to the workload and provider quotas. |
| `progress_bar=True` | Show execution progress. |
| `stop_on_exception=True` | Stop on an interview exception. |
For a small local run using the eight combinations constructed above:
```python theme={null}
from edsl import Cache, Model
results = job.by(Model("test", canned_response="Clear")).run(
n=2,
cache=Cache(),
disable_remote_inference=True,
disable_remote_cache=True,
progress_bar=False,
)
results.select("scenario.condition", "agent.experience", "answer.clarity")
# In a script, append .print() to display the table.
```
`n=2` repeats each of the eight combinations twice, producing 16 interview records with fixed test answers here. It does not create new profiles or conditions. Select `iteration` alongside the answers when comparing repetitions. Use [background jobs](/en/latest/guides/background_jobs) for submission and later retrieval, and [remote execution](/en/latest/remote_inference#python-execution-settings) for hosted execution settings.
## Package a reusable workflow
A `Macro` combines an input survey, a job that uses those inputs as scenario fields, and named output formats. Use one when other users should supply a few parameters to a repeatable EDSL workflow.
```python theme={null}
from edsl import Model, QuestionFreeText, Survey
from edsl.macros import Macro, OutputFormatter
inputs = Survey([
QuestionFreeText(question_name="text", question_text="Which text should be reviewed?"),
])
review_job = QuestionFreeText(
question_name="feedback",
question_text="Suggest one improvement to: {{ scenario.text }}",
).by(Model("test", canned_response="Use a more specific heading."))
macro = Macro(
application_name="text_review",
display_name="Text review",
short_description="Suggest an improvement to a text.",
long_description="Review supplied text and return feedback as a table.",
initial_survey=inputs,
jobs_object=review_job,
output_formatters={
"answers": OutputFormatter(output_type="Dataset").select("answer.feedback"),
},
default_formatter_name="answers",
)
```
Construction does not execute the job. The test model demonstrates the structure; replace it with your selected provider model for actual feedback. Then supply values keyed by the input survey's question names:
```python theme={null}
output = macro.output(
params={"text": "Our tool improves work."},
disable_remote_inference=True,
)
output.answers
# output.results gives the underlying Results object.
```
`macro.output()` returns a `MacroRunOutput`; accessing a named format applies the corresponding OutputFormatter to its Results. Provider execution requires credentials and may incur costs. `macro.by({"text": "..."}).run()` and `macro.run(text="...")` are parameter-setting shortcuts using the default execution settings; use `output()` when specifying execution options.
`macro.with_output_formatter("rows", OutputFormatter(output_type="ScenarioList").select("answer.feedback").to_scenario_list())` returns a macro with another named output. A macro instance can reuse previously generated results for the same prepared job, so repeated calls are not automatically independent model draws.
For sharing, `macro.push(visibility="private")` uploads the workflow; `Macro.pull(identifier)` retrieves a full macro when you have access. `Macro("owner/alias")` creates a client for a deployed macro and executes it remotely when called. Access to a deployed macro and its underlying definition can differ.
# Language Models
Source: https://docs.expectedparrot.com/en/latest/language_models
Discover model availability, configure providers, and compare model outputs.
A `Model` selects the language model that will generate answers to your survey. Its **service** identifies where EDSL sends the requests; its **parameters** control supported generation settings, such as response length. An [Agent](/en/latest/agents) supplies a respondent profile, while the Model supplies the language model answering from that profile.
Use one Model to run a study or a ModelList to compare several model configurations on the same inputs. Select from the current catalog because model availability and supported settings change.
## Discover and select models
```python theme={null}
from edsl import Model
Model.services()
Model.available()
```
`Model.services()` lists inference services; `Model.available()` lists models you can select. Run each expression in its own notebook cell to display both tables. Discovery may contact provider or Expected Parrot services. The [model catalog](https://www.expectedparrot.com/models) includes availability and pricing. See [Managing Keys](/en/latest/api_keys) for hosted and provider credentials.
`Model()` selects the configured default. Inspect `model.model` and `model.parameters` and save the configuration with your study. For a named selection, use `Model(model_name, service_name=provider)` with values you have verified in the catalog.
## Compare models on matched inputs
Construct a `ModelList` from your selected configurations and apply it with `job.by(models)`. Hold the questions, scenarios, and agent instructions constant so differences can be traced to model configuration. See [Model comparisons](/en/latest/guides/model_comparisons).
Do not assume settings such as temperature, token limits, or reasoning effort have the same meaning or valid values across providers. Unsupported values can fail at execution time even when a configuration serializes successfully.
## Reasoning and provider configuration
OpenAI adapters accept reasoning settings, with request formatting depending on the adapter. Explicit `reasoning_effort` is forwarded for provider validation; with the Responses adapter, an explicit effort overrides the effort inside a `reasoning` dictionary. Supported values depend on the selected model.
See [Provider configuration](/en/latest/guides/providers) for selecting services and inspecting parameters, and the [adapter source](https://github.com/expectedparrot/edsl/tree/main/edsl/inference_services) for implementation details. Record effective settings with results and check provider documentation before adopting a model-specific option.
## Connect to your own model server
Use EDSL's `openai_compatible` service to connect to a server that exposes an OpenAI-compatible chat-completions endpoint. Start the server first, then replace `MODEL_NAME` with a model identifier it serves and `SERVER_BASE_URL` with its API base URL, including `/v1` if required by that server.
```python theme={null}
from edsl import Model
model = Model(
"MODEL_NAME",
service_name="openai_compatible",
base_url="SERVER_BASE_URL",
# For an authenticated server, also set:
# api_key_env="MY_MODEL_API_KEY",
)
```
If the server requires authentication, set an environment variable containing its key and pass that variable's name as `api_key_env`. EDSL saves the variable name with the model configuration, without including its secret value.
With a `survey` you have already constructed, run it through the configured endpoint:
```python theme={null}
results = survey.by(model).run(
disable_remote_inference=True,
disable_remote_cache=True,
)
results.select("answer.*")
# In a script, append .print() to display the answers.
```
`disable_remote_inference=True` runs orchestration on your machine; requests go to the configured server, which may be local or remote. `disable_remote_cache=True` disables Expected Parrot's remote cache. The CLI equivalent for local orchestration is `--local`.
Supported model parameters and response formats depend on the server and model. Use the server's own documentation for installation and tuning—for example, the [llama.cpp server guide](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md).
For an offline execution check, use the test model in the [Run your first survey](/en/latest/starter_tutorial) tutorial. Test responses demonstrate the data flow and do not measure model performance.
## Common methods
| Call | Arguments and result |
| - | - |
| `Model()` | Create a configuration for your default model. |
| `Model(model_name, service_name=provider, **parameters)` | Choose a model and provider, with provider-specific parameters. |
| `Model.available(search_term=None, service_name=None)` | Return a ModelList from the available catalog, optionally filtered by name or service. Discovery can contact remote services. |
| `Model.services()` | Return a ScenarioList describing registered inference services. |
| `ModelList([model, ...])` | Collect configurations for a comparison. |
| `model.model` / `model.parameters` | Inspect the selected name and parameter dictionary. |
You can practice configuring multiple models locally:
```python theme={null}
from edsl import Model, ModelList
models = ModelList([
Model("test", canned_response="Clear"),
Model("test", canned_response="Unclear"),
])
models
# In a script: models.print()
```
These test models return fixed answers. Replace them with configurations from the catalog for a real model comparison, then pass the list to `job.by(models)`.
## Narrow the catalog and reuse selections
`Model.available(service_name=provider)` restricts discovery to a service; add `search_term` to filter names. `Model.search_models(search_term)` provides name search. To inspect only services for which you have configured provider keys, use `Model.services_with_local_keys()`; `Model.available_with_local_keys()` lists their models. These are discovery methods, not guarantees that a particular API call will succeed.
`Model.check_working_models(works_with_text=True, works_with_images=True)` retrieves Expected Parrot's working-model catalog with capability filters. It reads catalog information rather than running your survey. Use `output_format="scenario_list"` when you want catalog rows instead of model configurations.
For PDF, video, and audio inputs, choose a model that supports the specific media type; the image filter only describes image support. See [Files and media as scenarios](/en/latest/scenarios#data-and-files) for examples, including Google video and audio inputs.
A ModelList can be inspected with `to_scenario_list()` or `to_dataset()`, filtered with `filter(expression)`, and saved with `save("models.ep")` for later `ModelList.load("models.ep")`. `ModelList.from_names(names)` constructs configurations from a list of model names; use explicit `Model(name, service_name=provider)` objects when the provider needs to be unambiguous.
# Notebooks and scripts
Source: https://docs.expectedparrot.com/en/latest/notebooks
Share a notebook or script as supporting material for a study.
A `Notebook` wraps an `.ipynb` file or Python script so it can be stored and shared through Expected Parrot. Reading or uploading a notebook does not execute its cells or verify its findings. Save the study objects and completed data separately with [Saving objects](/en/latest/guides/saving_objects).
## Read a saved file
```python theme={null}
from edsl import Notebook
notebook = Notebook(path="study.ipynb")
```
Replace the path with a saved file. For a Python script, use `Notebook.from_script("study.py")`. For Colab, first [download the notebook](/en/latest/colab_notebooks).
For an object constructed from a notebook path, `notebook.reload()` rereads the saved file after edits. `notebook.has_outputs()` reports whether any code cells contain outputs. `Notebook.from_current_script()` wraps the currently executing Python script; use it from a script file rather than a notebook cell.
To construct an object directly from notebook-format data:
```python theme={null}
from edsl import Notebook
notebook = Notebook(data={
"nbformat": 4,
"nbformat_minor": 4,
"metadata": {},
"cells": [{
"cell_type": "markdown",
"metadata": {},
"source": "# Study notes",
}],
})
```
The example above contains only a Markdown cell and requires no credentials. Construction may format Python code when linting is enabled; inspect the resulting notebook before sharing.
## Upload and retrieve
Review code, outputs, and embedded data before uploading. With Expected Parrot authentication configured:
```python theme={null}
info = notebook.push(
description="Supporting notebook for the study",
visibility="private",
)
notebook_uuid = info["uuid"]
```
Use the returned identifier to retrieve that object, subject to its access permissions:
```python theme={null}
downloaded = Notebook.pull(notebook_uuid)
downloaded.to_file("downloaded-study.ipynb")
```
These operations contact Expected Parrot; `to_file()` writes the downloaded notebook locally. For sharing permissions and metadata updates, use [Expected Parrot objects](/en/latest/coop) and the [CLI Guide](/en/latest/cli).
# EDSL Overview
Source: https://docs.expectedparrot.com/en/latest/overview
Understand how to structure a study, run it with AI or human participants, and work with the resulting data.
EDSL lets you design and run studies in Python, specifying what to ask, what information to provide, who or what should respond, and how to organize the results. You can vary individual elements of a study, such as question wording, respondent profiles, or language models, and examine how those changes affect the findings.
## The main components
| Component | What it does |
| - | - |
| [Questions](/en/latest/questions) | Specify question text and the response format, such as free text, multiple choice, numerical, or matrix. |
| [Surveys](/en/latest/surveys) | Combine questions with ordering, branching, and dependencies between answers. |
| [Scenarios](/en/latest/scenarios) | Supply data or experimental conditions to questions, allowing the same question to be used with different inputs. |
| [Agents](/en/latest/agents) | Specify traits and instructions for simulated respondents. |
| [Models](/en/latest/language_models) | Select and configure the language models that generate responses. |
| [Jobs](/en/latest/jobs) | Combine the survey and inputs into a study ready to run. |
| [Results](/en/latest/results) | Organize responses and associated study information for analysis. |
For example, a message-testing study might use a survey to ask about clarity and persuasiveness, scenarios to supply different messages, agents to describe audience profiles, and several models to generate responses.
(*Note: In EDSL, an `Agent` represents a simulated respondent, not an AI assistant that performs tasks. Coding agents and Expected Parrot’s Research Agent can use EDSL to design and run studies.*)
## Running studies with AI
Combine a survey with the agents, scenarios, and models you want to use. EDSL handles model calls, parallel execution, survey logic, and response collection.
EDSL calls one pass through a survey an **interview**. In a model run, each interview uses one agent, one scenario, and one model. Its result contains the answers to all questions reached on that pass. A job can contain many interviews, such as one for every message and audience combination in a message test.
This makes it possible to repeat a study across experimental conditions without writing separate loops or provider integrations. For [data labeling](/en/latest/guides/data_labeling), ask survey questions about each input and use the answers as its labels. You can translate an existing codebook into structured questions, or first use exploratory questions to develop the categories those questions will offer. Scenarios supply the items being labeled.
You can run model calls from your own environment or use Expected Parrot's remote execution. See [Managing Keys](/en/latest/api_keys), [Remote Inference](/en/latest/remote_inference), and [Costs](/en/latest/costs) for configuration and usage details.
## Collecting human responses
You can also publish an EDSL survey for human participants. Calling `survey.humanize()` creates a hosted web survey and returns a respondent link. After participants complete it, you can retrieve their responses for analysis in Python.
Use this workflow to evaluate simulated findings against human evidence or to conduct a human survey directly. When comparing human and AI responses, retain their source so that the analysis clearly distinguishes observed responses from simulations.
The [Humanize guide](/en/latest/humanize) covers publishing surveys and retrieving data. See [Prolific studies](/en/latest/prolific) for participant recruitment.
## Working with data
EDSL provides methods for selecting columns, filtering responses, sorting observations, and comparing groups. You can export data to CSV or a pandas DataFrame for further analysis.
Responses remain associated with study information such as agent traits, scenarios, and model settings. This lets you compare conditions and trace observations back to the inputs used to generate them.
## Inspecting and reproducing a study
A study defined in code makes its questions, logic, inputs, and model choices available for inspection. EDSL's open-source implementation also lets you examine and extend the underlying functionality.
EDSL jobs can be saved, shared, and rerun. Caching lets a rerun reuse the same model responses when the inputs match. See [Saving objects](/en/latest/guides/saving_objects) and [Remote caching](/en/latest/remote_caching).
For a complete example, follow the [Run your first survey](/en/latest/starter_tutorial) tutorial.
# Research with Expected Parrot
Source: https://docs.expectedparrot.com/en/latest/papers
A selection of research papers using or citing Expected Parrot and EDSL.
**Academic access**
We support academic researchers, instructors, and students using Expected Parrot for research and teaching. Academic accounts include access to the platform and an initial allocation of usage credits for running studies. Sign up with your academic email to get started and contact us for support.
**Publications**
**[Economic Policy for AGI](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=7470000)** \
[Julian Jacobs](https://www.juliandjacobs.com/), [Alex Imas](https://www.aleximas.com/) \
September 2026
**[Interpreting and Steering LLM Agents for Social Simulations](https://arxiv.org/abs/2609.16436)** \
Jiayue Gaveal Fan, [Arul Murugan](https://arulmurugan.me/) ([Scholar](https://scholar.google.com/citations?user=7rHwscYAAAAJ)), [Shreyas Krishnan](https://sk160902.github.io/Shreyas_Krishnan/), [Abhishek Nagaraj](https://www.abhishekn.com/) ([Scholar](https://scholar.google.com/citations?user=BU4EilMAAAAJ)) \
September 2026
**[CentaurBench: Benchmarking LLM Capabilities on Augmenting vs. Automating Real-World Work Tasks](https://www.nber.org/papers/w35663)** \
[Pattaraphon Kenny Wongchamcharoen](https://kennywong524.github.io/) ([Scholar](https://scholar.google.com/citations?user=TCONkswAAAAJ)), [Kris Gulati](https://sites.google.com/view/kris-gulati/home), Min Min Fong, [Abhishek Nagaraj](https://www.abhishekn.com/) ([Scholar](https://scholar.google.com/citations?user=BU4EilMAAAAJ)) \
August 2026
**[Graph-Based Alternatives to LLMs for Human Simulation](https://aclanthology.org/2026.acl-long.2157/)** \
[Joseph Suh](https://josephsuh.org/) ([Scholar](https://scholar.google.com/citations?user=2ILhE0MAAAAJ)), [Suhong Moon](https://suhongmoon.github.io/) ([Scholar](https://scholar.google.com/citations?user=k4TJRc0AAAAJ)), [Serina Chang](https://serinachang5.github.io/) ([Scholar](https://scholar.google.com/citations?user=ADxG8S4AAAAJ)) \
July 2026
**[Simulating strategic interactions with AI agents](https://doi.org/10.1002/smj.70112)** \
[Matteo Tranchero](https://www.matteotranchero.com/), Cecil-Francis Brenninkmeijer, [Arul Murugan](https://arulmurugan.me/) ([Scholar](https://scholar.google.com/citations?user=7rHwscYAAAAJ)), [Abhishek Nagaraj](https://www.abhishekn.com/) ([Scholar](https://scholar.google.com/citations?user=BU4EilMAAAAJ)) \
July 2026
**[Synthetic Audiences as Proxies for Human Users: A Systematic Literature Review](https://doi.org/10.1109/ACCESS.2026.3703706)** \
[Theodoros Lappas](https://tedlappas.com/) ([Scholar](https://scholar.google.com/citations?user=2KCcLFYAAAAJ)), [Apostolos Filippas](https://www.apostolos-filippas.com/) ([Scholar](https://scholar.google.com/citations?user=0BebkaYAAAAJ)) \
June 2026
**[Demystifying the Attribution Ambiguity: A Computational Framework of Agency in Human-GenAI Innovation](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=6763583)** \
Rawan Qadri, Inbal Yahav, [Moran Lazar](https://www.moranlazar.com/), [Hila Lifshitz-Assaf](https://hilalifshitz.com/) \
May 2026
**[Marginal Ideas](https://amirsariri.com/assets/documents/sv/marginalideas.pdf)** \
[John Horton](https://john-joseph-horton.com/) ([Scholar](https://scholar.google.com/citations?user=L_O2kH0AAAAJ)), [Amir Sariri](https://amirsariri.com/) \
May 2026
**[ValidAgent – An Open Repository of Validated Generative Agents for Behavioral Simulations and Multi-Agent Systems](https://emas-workshop.github.io/2026/papers/025_ValidAgent_An_Open_Repository_of_Validated_Generative_Agents.pdf)** \
Leon Lührs, Christian Stindt von Dohm, Anika Bittner, [Timo Heinrich](https://scholar.google.com/citations?user=piongfMAAAAJ), Matthias Meyer \
May 2026
**[LLM-Agent Interactions on Markets with Information Asymmetries](https://arxiv.org/abs/2603.08853)** \
Alexander Erlei, [Lukas Meub](https://scholar.google.com/citations?user=6Gv77qoAAAAJ) \
March 2026
**[Strategic Tradeoffs Between Humans and AI in Multi-Agent Bargaining](https://doi.org/10.1145/3742413.3789078)** \
[Crystal Qian](https://cjqian.github.io/) ([Scholar](https://scholar.google.com/citations?user=8_sq3IgAAAAJ)), [Kehang Zhu](https://kehang-zhu.github.io/) ([Scholar](https://scholar.google.com/citations?user=Q7gA1vAAAAAJ)), [John Horton](https://john-joseph-horton.com/) ([Scholar](https://scholar.google.com/citations?user=L_O2kH0AAAAJ)), [Benjamin S. Manning](https://www.benjaminmanning.com/) ([Scholar](https://scholar.google.com/citations?user=teSF3_AAAAAJ)), [Vivian Tsai](https://viviantsai.com/) ([Scholar](https://scholar.google.com/citations?user=DsEfSZcAAAAJ)), James Wexler, Nithum Thain \
March 2026
**[Chaining Tasks, Redefining Work: A Theory of AI Automation](https://www.nber.org/papers/w34859)** \
[Mert Demirer](https://www.mertdemirer.com/) ([Scholar](https://scholar.google.com/citations?user=sh0k7woAAAAJ)), [John J. Horton](https://john-joseph-horton.com/) ([Scholar](https://scholar.google.com/citations?user=L_O2kH0AAAAJ)), [Nicole Immorlica](https://immorlica.com/), [Brendan Lucier](https://scholar.google.com/citations?user=r44N6h8AAAAJ), [Peyman Shahidi](https://peymanshahidi.github.io/) ([Scholar](https://scholar.google.com/citations?user=AjAI4xoAAAAJ)) \
February 2026
**[The Value of Organizational Learning Technologies](https://www.frbsf.org/wp-content/uploads/3.-Beraja.pdf)** \
[Martin Beraja](https://www.martinberaja.com/) ([Scholar](https://scholar.google.com/citations?user=hpm030sAAAAJ)), [Eduard Talamàs](https://www.eduardtalamas.com/) ([Scholar](https://scholar.google.com/citations?user=swoJiIcAAAAJ)) \
February 2026
**[Large Language Models as Simulated Economic Agents: What Can We Learn from Homo Silicus?](https://arxiv.org/abs/2301.07543v2)** \
[John J. Horton](https://john-joseph-horton.com/) ([Scholar](https://scholar.google.com/citations?user=L_O2kH0AAAAJ)), [Apostolos Filippas](https://www.apostolos-filippas.com/) ([Scholar](https://scholar.google.com/citations?user=0BebkaYAAAAJ)), [Benjamin S. Manning](https://www.benjaminmanning.com/) ([Scholar](https://scholar.google.com/citations?user=teSF3_AAAAAJ)) \
February 2026
**[Generating Inflation Expectations with Large Language Models](https://doi.org/10.1016/j.jmoneco.2025.103859)** \
[Ali Zarifhonarvar](https://alizarif.github.io/) ([Scholar](https://scholar.google.com/citations?user=PGgdRAUAAAAJ)) \
January 2026
**[Fairness principles across contexts: evaluating gender disparities of facts and opinions in large language models](https://doi.org/10.1007/s43681-025-00876-5)** \
[Sofie Goethals](https://sofiegoethals.com/) ([Scholar](https://scholar.google.com/citations?user=3yM14pcAAAAJ)), [Lauren Rhue](https://www.laurenrhue.com/), [Arun Sundararajan](https://pages.stern.nyu.edu/~asundara/) \
2026
**[Differences in the Moral Foundations of Large Language Models](https://arxiv.org/abs/2511.11790)** \
[Peter Kirgis](https://peterkirgis.github.io/) \
November 2025
**[Two-Faced Social Agents: Context Collapse in Role-Conditioned Large Language Models](https://arxiv.org/abs/2511.15573)** \
[Vikram K Suresh](https://www.vikramsuresh.com/) \
November 2025
**[The Potential of Synthetic Twin Agents for Personalized Behavioural Interventions at Scale](https://osf.io/preprints/psyarxiv/vs2mk_v3)** \
Julian Ustiyanovych, Dario Krpan \
September 2025
**[From Digital Distrust to Codified Honesty: Experimental Evidence on Generative AI in Credence Goods Markets](https://arxiv.org/abs/2509.06069)** \
Alexander Erlei \
September 2025
**[General Social Agents](https://arxiv.org/abs/2508.17407)** \
[Benjamin S. Manning](https://www.benjaminmanning.com/) ([Scholar](https://scholar.google.com/citations?user=teSF3_AAAAAJ)), [John J. Horton](https://john-joseph-horton.com/) ([Scholar](https://scholar.google.com/citations?user=L_O2kH0AAAAJ)) \
August 2025
**[LLM Bidders Preserve the Mechanism-Level Orderings of Human Bidders](https://arxiv.org/abs/2507.09083)** \
[Anand Shah](https://avshah1.github.io/), [Kehang Zhu](https://kehang-zhu.github.io/) ([Scholar](https://scholar.google.com/citations?user=Q7gA1vAAAAAJ)), [Yanchen Jiang](https://yanchenjiang.github.io/), [Jeffrey G. Wang](https://jeffreygwang.com/research/) ([Scholar](https://scholar.google.com/citations?user=gF8ZG3cAAAAJ)), [Arif K. Dayi](https://keremdayi.com/) ([Scholar](https://scholar.google.com/citations?user=HfiPj7oAAAAJ)), [John J. Horton](https://john-joseph-horton.com/) ([Scholar](https://scholar.google.com/citations?user=L_O2kH0AAAAJ)), [David C. Parkes](https://parkes.seas.harvard.edu/) \
July 2025
**[The next generation of experimental research with LLMs](https://www.nature.com/articles/s41562-025-02137-1)** \
Gary Charness, [Brian Jabarian](https://brianjabarian.org/) ([Scholar](https://scholar.google.com/citations?user=kh2enZIAAAAJ)), [John A. List](https://voices.uchicago.edu/jlist/) \
March 2025
**[Linear Representations of Political Perspective Emerge in Large Language Models](https://arxiv.org/pdf/2503.02080v1)** \
[Junsol Kim](https://junsol.kim/) ([Scholar](https://scholar.google.com/citations?user=O__9HOwAAAAJ)), [James Evans](https://scholar.google.com/citations?user=kV4N4zoAAAAJ), [Aaron Schein](https://aaronschein.com/) ([Scholar](https://scholar.google.com/citations?user=CaHuRsgAAAAJ)) \
March 2025
**[Social Science Production with LLMs](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=5133210)** \
Eric Vansteenberghe \
February 2025
**[Simulating the Survey of Professional Forecasters](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=5066286)** \
[Anne Artrip](https://anneartrip.com/) ([Scholar](https://scholar.google.com/citations?user=s7TTl6EAAAAJ)), [John J. Horton](https://john-joseph-horton.com/) ([Scholar](https://scholar.google.com/citations?user=L_O2kH0AAAAJ)), [Sophia Kazinnik](https://www.sophiakazinnik.com/), [Daniela Puzzello](https://sites.google.com/view/daniela-puzzello/home), [Ali Zarifhonarvar](https://alizarif.github.io/) ([Scholar](https://scholar.google.com/citations?user=PGgdRAUAAAAJ)) \
February 2025
**[Repo2Run: Automated Building Executable Environment for Code Repository at Scale](https://proceedings.neurips.cc/paper_files/paper/2025/hash/2f2b1d6bbd50865eca40e2774a057eef-Abstract-Conference.html)** \
[Ruida Hu](https://kinesiatricssxilm14.github.io/) ([Scholar](https://scholar.google.com/citations?user=DQtBFXsAAAAJ)), [Chao Peng](https://chao-peng.github.io/) ([Scholar](https://scholar.google.com/citations?user=HoVGmNMAAAAJ)), [Xinchen Wang](https://eshe0922.github.io/) ([Scholar](https://scholar.google.com/citations?user=QG5jMcYAAAAJ)), [Junjielong Xu](https://siyuexi.github.io/) ([Scholar](https://scholar.google.com/citations?user=e-zP90wAAAAJ)), [Cuiyun Gao](https://cuiyungao.github.io/) ([Scholar](https://scholar.google.com/citations?user=9I2hTmQAAAAJ)) \
2025
**[Generative AI for Economic Research: LLMs Learn to Collaborate and Reason](https://www.nber.org/papers/w33198)** \
[Anton Korinek](https://www.korinek.com/Home) ([Scholar](https://scholar.google.com/citations?user=zcleqRcAAAAJ)) \
November 2024
**[ChatGPT vs Social Surveys: Probing the Objective and Subjective Human Society](https://arxiv.org/abs/2409.02601v1)** \
Muzhi Zhou, Lu Yu, Xiaomin Geng, Lan Luo \
September 2024
**[Automated Social Science: Language Models as Scientist and Subjects](https://arxiv.org/abs/2404.11794)** \
[Benjamin S. Manning](https://www.benjaminmanning.com/) ([Scholar](https://scholar.google.com/citations?user=teSF3_AAAAAJ)), [Kehang Zhu](https://kehang-zhu.github.io/) ([Scholar](https://scholar.google.com/citations?user=Q7gA1vAAAAAJ)), [John J. Horton](https://john-joseph-horton.com/) ([Scholar](https://scholar.google.com/citations?user=L_O2kH0AAAAJ)) \
April 2024
**[UnexpectedParrots: Iterating Behavioral Experiment Design with In-Silico Simulations](https://sanchaitahazra.com/papers/Hazra_simulations_Ai2.pdf)** \
[Sanchaita Hazra](https://sanchaitahazra.com/) ([Scholar](https://scholar.google.com/citations?user=jdK24_YAAAAJ)), [Bodhisattwa Prasad Majumder](https://www.majumderb.com/) ([Scholar](https://scholar.google.com/citations?user=cEM1a5gAAAAJ)), [Pao Siangliulue](https://paoponder.com/) ([Scholar](https://scholar.google.com/citations?user=_ojsG2QAAAAJ)), [Peter Clark](https://pclark425.github.io/), [Amy X. Zhang](https://homes.cs.washington.edu/~axz/index.html) ([Scholar](https://scholar.google.com/citations?user=thZJZaYAAAAJ)), [Joseph Chang](https://josephcc.com/) ([Scholar](https://scholar.google.com/citations?user=MY2W9d4AAAAJ))
**[A Retrieval Augmentation Approach for Aligning to Pluralistic Values](https://openreview.net/pdf?id=mX0QrIco2B)**
# Prolific recruitment
Source: https://docs.expectedparrot.com/en/latest/prolific
Prepare a human survey, create a recruitment draft, and check it before launch.
Use Prolific to recruit participants for a hosted human survey. This workflow requires an Expected Parrot account and recruitment funding. You do not need to run a simulation first.
There are two linked objects: the **human survey** contains the questionnaire and collected answers, while the **Prolific study** defines recruitment, participant numbers, and payment. The examples keep both `human_survey_uuid` and `study_id` because recruitment operations refer to that pair. If you already have participants and will distribute the survey link yourself, use [Human studies](/en/latest/guides/human_studies) directly.
## Prepare and preview the survey
Construct and pilot your survey with [Human surveys](/en/latest/humanize). Save the reviewed EDSL survey as `survey.ep` and retain the UUID returned by `.humanize()`. The examples below assume that hosted survey already exists:
```python theme={null}
from edsl import Coop, Survey
coop = Coop()
survey = Survey.load("survey.ep")
human_survey_uuid = "YOUR_HUMAN_SURVEY_UUID"
```
Replace the UUID with the actual returned identifier. Check the respondent experience and completion flow before recruitment.
## Participant limit
Check the account configuration that will apply to a new study:
```python theme={null}
settings = coop.get_prolific_study_settings()
```
`key_source` identifies Expected Parrot's account (`ep`) or your own configured Prolific key (`user_key`). Inspect `charged_in_credits`, `participant_limit_applies`, and `participant_limit` before selecting the sample size. Billing and limits depend on this configuration; do not assume a fixed account-wide allowance.
## Estimate and create a draft
The following example requests ten participants, estimates five minutes per participant, and offers 100 cents each. Replace these values with the pilot-based duration, payment, and sample size for your study. Estimation and draft creation contact the service but do not publish the study.
```python theme={null}
cost = coop.calculate_prolific_study_cost(
participant_payment_cents=100,
num_participants=10,
estimated_completion_time_minutes=5,
)
```
Review the estimate and account settings before creating the draft:
```python theme={null}
study = coop.create_prolific_study(
human_survey_uuid=human_survey_uuid,
name="Workplace experience",
description="A five-minute survey about work arrangements.",
num_participants=10,
estimated_completion_time_minutes=5,
participant_payment_cents=100,
approval_mode="manual",
)
study_id = study["study_id"]
```
The returned `admin_url` and `preview_url` let you review the draft. Use `coop.list_prolific_filters()` to discover supported screening filters; its returned object provides `find()` and `create_study_filter()` for constructing the `filters` argument. Select filters from the current catalog rather than copying historical IDs.
## Check and publish
Check the deployed survey and funding before publishing:
```python theme={null}
check = coop.preflight_prolific_study(
human_survey_uuid,
study_id,
expected_survey=survey,
required_questions=survey.question_names,
)
if not check["ready"]:
raise RuntimeError(check["blockers"])
```
A successful check does not reserve funds or test the full respondent experience. If the survey includes paid AI interviews or other AI work, include those costs in `required_credits` as well.
**Publishing starts recruitment and can incur charges.** Run the following only when the instrument, sample, payment, and budget are ready for launch:
```python theme={null}
published = coop.publish_prolific_study(
human_survey_uuid,
study_id,
expected_survey=survey,
required_questions=survey.question_names,
)
```
Publishing repeats the preflight check. Inspect the returned status rather than treating a successful request as completed data collection.
## Monitor and retrieve responses
```python theme={null}
status = coop.get_prolific_study(human_survey_uuid, study_id)
responses = coop.get_prolific_study_responses(human_survey_uuid, study_id)
```
Inspect the returned data type and response count before applying [Results](/en/latest/results) methods. Retain the study identifier, participant identifiers, collection dates, and any exclusions in the analysis record.
## Submission approval
`approval_mode="automatic"` approves a submission when the participant enters the completion code. `"manual"` requires you to review and manage submissions; Prolific's own automatic-approval rules still apply. Choose the mode before publishing and monitor submissions through the study's admin page.
Use the study's admin page to manage submissions and changes to recruitment. Check the current study state before making changes.
## Manage an existing study from Python
`coop.list_prolific_studies(human_survey_uuid)` lists recruitment studies for a human survey. `coop.update_prolific_study(human_survey_uuid, study_id, ...)` updates supported settings; allowed changes depend on the study's current state.
| Action | Method |
| - | - |
| Pause recruitment | `coop.pause_prolific_study(human_survey_uuid, study_id)` |
| Resume recruitment | `coop.resume_prolific_study(human_survey_uuid, study_id)` |
| Stop recruitment | `coop.stop_prolific_study(human_survey_uuid, study_id)` |
| Approve a reviewed submission | `coop.approve_prolific_study_submission(human_survey_uuid, study_id, submission_id)` |
| Reject a reviewed submission | `coop.reject_prolific_study_submission(human_survey_uuid, study_id, submission_id, reason=reason_code, explanation=explanation)` |
| Delete a study | `coop.delete_prolific_study(human_survey_uuid, study_id)` |
These methods change the live recruitment or submission state. Approval and rejection affect participant payment; use them for deliberate review decisions under Prolific's applicable rules. The rejection reason must be a supported code, such as `FAILED_INSTRUCTIONS` or `OTHER`, and the explanation must contain at least 100 characters. Inspect `get_prolific_study()` before and after a change; the service can reject actions that are invalid for the current state.
# Prompts and instructions
Source: https://docs.expectedparrot.com/en/latest/prompts
Inspect rendered prompts and trace substitutions, instructions, and memory.
The prompt is the full set of instructions and information a model receives to answer a question. EDSL assembles it from question text, answer-format instructions, scenario values, agent instructions, and any prior answers supplied through piping or memory. Reading it helps you see the task from the model's perspective—for example, whether a document passage or an earlier answer is actually present.
## Inspect before execution
This example builds a classification job and renders its prompts without generating answers. Prompt cost information may depend on available model-pricing metadata.
```python theme={null}
from edsl import Agent, Model, QuestionMultipleChoice, Scenario
question = QuestionMultipleChoice(
question_name="category",
question_text="Classify this request: {{ scenario.request }}",
question_options=["Billing", "Access", "Other"],
answering_instructions="Return one of the listed options. Use Other when neither applies.",
)
agent = Agent(instruction="Apply the coding scheme literally; do not infer missing facts.")
scenario = Scenario({"record_id": "r1", "request": "Please reset my password."})
job = question.by(agent).by(scenario).by(Model())
# Rendering prompts does not generate model responses.
prompts = job.prompts()
```
```python theme={null}
prompts.select("user_prompt", "system_prompt")
```
The table contains `user_prompt` and `system_prompt`: the question-specific material and the surrounding instructions EDSL prepares for the model. Read them together with the row's question and input identifiers. For a classification task, you should see the source text and allowed categories; reference labels kept only for later evaluation should not appear.
## Change instructions deliberately
Use `Agent(instruction=...)` to change respondent instructions. Use a question's `answering_instructions` for answer requirements, and scenario values for varying inputs. Preserve the required response format when customizing instructions.
Piped answers and memory depend on earlier responses. A preview cannot show answers that have not yet been generated. Use the recorded prompts after execution to see the actual earlier answers supplied to a follow-up question. Providers may format these instructions differently, so compare prompts with the selected model in mind.
## Inspect after execution
After running the `job` above with configured model access:
```python theme={null}
results = job.run()
results.select("prompt.*", "answer.*")
```
This executes model calls and may incur costs. Keep the prompts and model settings with your exported answers so that a comparison can be audited.
See [Prompt sensitivity](/en/latest/research/prompt_sensitivity) for comparing alternative wording.
## Common methods
| Call | Arguments and result |
| - | - |
| `job.prompts(iterations=1)` | Return a Dataset of prompts for the planned interviews. |
| `job.show_prompts(all=False)` | Print the user and system prompts; use `all=True` to include every column, including estimated cost. |
| `results.select("prompt.*", "answer.*")` | Inspect the prompts recorded with completed responses. |
| `Prompt("Review {{ message }}")` | Create a reusable text template. |
| `prompt.template_variables()` | List the variable names referenced by the template. |
| `prompt.render({"message": "Export your report"})` | Return a Prompt with values substituted from the dictionary. |
To try a template locally:
```python theme={null}
from edsl import Prompt
prompt = Prompt("Review this message: {{ message }}")
rendered = prompt.render({"message": "Export your report"})
rendered.text
# In a script: print(rendered.text)
```
Rendering this template makes no model call. Use `job.prompts()` to inspect the full instructions that EDSL assembles for a study.
`Prompt.from_txt("instructions.txt")` loads prompt text from a local file. `Prompt.from_template("instructions.jinja2", path_to_folder="templates", **values)` loads and renders a template. Before rendering, `prompt.undefined_template_variables(values)` identifies missing variables and `prompt.unused_traits(values)` identifies supplied keys that the template does not use.
# Questions
Source: https://docs.expectedparrot.com/en/latest/questions
Choose a response format, construct a question, and validate its inputs.
A question defines what is asked and the expected answer format. Choose the format before writing the prompt so responses can be compared and analyzed consistently.
For data labeling, a question's answer becomes a label: “What topic does this passage discuss?” can return a category, while “Extract the dates and organizations” can return named fields. Put the passages or documents in [scenarios](/en/latest/scenarios) to reuse the same questions across your material. [Label data with survey questions](/en/latest/guides/data_labeling) shows how to use an existing codebook or develop labels through exploratory questions first.
## Choose a question type
| Type | Use when you need | Answer shape and constraints |
| - | - | - |
| `free_text` | An explanation or quotation | String; correctness still needs evaluation. |
| `multiple_choice`, `yes_no` | One category or decision | One permitted option. |
| `checkbox`, `top_k` | Several categories | List of permitted options; set selection bounds. |
| `linear_scale`, `likert_five` | An ordered rating | One configured scale value. |
| `numerical` | A count or measurement | Number; optionally set minimum and maximum. |
| `rank` | An ordering | Ordered list of options with a configured selection count. |
| `list` | Open-ended items | List; optionally limit its length. |
| `extract` | Fields extracted from supplied material | Dictionary following an answer template. |
| `dict` | Named fields with declared types | Dictionary with configured keys and value types. |
| `matrix` | Ratings for several items | Mapping from each item to an allowed option. |
| `interview` | A guided interview | Structured transcript; see [Human interviews](/en/latest/guides/human_interviews). |
These are common choices. The [type details below](#question-type-details) explain their parameters and include examples. Use `ep schema list` and `ep schema show --question_type TYPE` to inspect required parameters.
## Construct questions
The example asks for three complementary measures of a message: a clarity category, an explanation, and a numerical score. It constructs the questions and survey without generating answers. A scenario supplies the message text when the survey runs.
```python theme={null}
from edsl import QuestionFreeText, QuestionMultipleChoice, QuestionNumerical, Survey
clarity = QuestionMultipleChoice(
question_name="clarity",
question_text="How clear is this message: {{ scenario.message }}?",
question_options=["Clear", "Unclear", "Unsure"],
)
reason = QuestionFreeText(
question_name="reason",
question_text="Explain your assessment of the message.",
)
score = QuestionNumerical(
question_name="score",
question_text="Rate the clarity of this message from 0 to 10: {{ scenario.message }}",
min_value=0,
max_value=10,
)
survey = Survey([clarity, reason, score]).add_targeted_memory(reason, clarity)
```
The answers will be a category, a string, and a number from 0 to 10. Validation checks response structure and configured constraints; it does not establish whether an answer is true or a classification is correct. Invalid model answers can lead to retries or interview errors, which should be inspected with the results.
## Names, inputs, and instructions
Use unique `question_name` values within a survey; a question named `clarity` produces the column `answer.clarity`. The name identifies the variable in your analysis, while `question_text` contains the wording to answer. Put varying source material in [Scenarios](/en/latest/scenarios), respondent traits in [Agents](/en/latest/agents), and answer dependencies in [Surveys](/en/latest/surveys).
Use `question_text` for the research question and `answering_instructions` for how to answer it. Inspect the generated [Prompts](/en/latest/prompts) before changing formatting instructions supplied by the question type.
## Run a question
Questions can run individually or as part of a survey. Follow the [Run your first survey](/en/latest/starter_tutorial) tutorial for an executable local example and model setup.
## Common methods
| Call | Arguments and result |
| - | - |
| `Question(type_name, question_name=..., question_text=..., ...)` | Construct a question of the named type with its specific parameters. You can also use classes such as `QuestionFreeText` directly. |
| `question.to_survey()` | Return a one-question Survey. |
| `question.by(scenarios).by(model)` | Return a Jobs object configured to ask the question for those inputs. |
| `question.loop(scenarios)` | Return separate question objects with scenario values substituted and names adjusted. |
| `question.to_dict()` | Return the serialized question definition, including its type and parameters. |
| `question.from_dict(data)` | Reconstruct a question from its serialized dictionary. |
| `Question.list_question_types()` | List the types supported by the installed EDSL version. |
| `Question.example(type_name)` | Construct a local example of the named type without running it. |
For example, inspect a multiple-choice question's definition:
```python theme={null}
from edsl import Question
question = Question(
"multiple_choice",
question_name="clarity",
question_text="How clear is this message?",
question_options=["Clear", "Unclear", "Unsure"],
)
question.to_dict()
# In a script: print(question.to_dict())
```
## Preview and vary a question
Use `render()` to substitute known values without calling a model. It returns a new question:
```python theme={null}
from edsl import QuestionFreeText
template = QuestionFreeText(
question_name="feedback",
question_text="What would improve {{ scenario.product }}?",
)
preview = template.render({"product": "the search tool"})
preview.question_text
# For the assembled model instructions: template.prompt_preview(scenario={"product": "the search tool"})
```
`question.copy()` makes an independent copy. For questions with a fixed list of options, `question.draw(random_seed=42)` returns a copy with shuffled options; `pin_options=["Other"]` keeps those options in their original positions. `question.option_permutations()` returns every ordering as separately named questions, so its size grows factorially with the number of options.
Use `question.add_question(other_question)` to build a Survey, or `question.to_jobs()` to prepare a job directly. `question.run(...)` is a one-question shortcut for execution; `await question.run_async(...)` is available in asynchronous Python. Both accept run options described under [Jobs](/en/latest/jobs#execution-options).
## Question type details
Most types share `question_name` and `question_text`. The table lists additional parameters for common response formats. Constructing these questions makes no model calls.
| Type | Parameters to set | Response |
| - | - | - |
| `free_text` | No additional required fields. | A string. |
| `multiple_choice` | `question_options`: allowed choices. | One choice. |
| `yes_no` | Built-in Yes/No options. | One of the built-in choices. |
| `checkbox` | `question_options`; optional `min_selections` and `max_selections`. | A list of choices within the bounds. |
| `top_k` | `question_options`; set `min_selections` and `max_selections` to the same positive count. | A list with exactly that many choices. |
| `linear_scale` | `question_options`: consecutive integers; optional `option_labels`. | One scale value. |
| `likert_five` | Built-in five-point agreement scale. | One agreement label. |
| `numerical` | Optional `min_value` and `max_value`. | A number within any specified bounds. |
| `rank` | `question_options`; optional `num_selections`. | An ordered list of choices. |
| `list` | Optional `max_list_items`. | A list of open-ended items. |
| `extract` | `answer_template`: keys and example values. | A dictionary with the requested fields. |
| `dict` | `answer_keys`, with optional `value_types` and `value_descriptions`. | A dictionary with named, typed values. |
| `matrix` | `question_items`, `question_options`, and optional `option_labels`. | A mapping from each item to a rating. |
| `distribution` | `question_options` and optional `tolerance`. | A probability for each option, summing to one within the tolerance. |
For option-based types that support `use_code`, setting it to `True` asks for option indices; setting it to `False` asks for option values. The examples below use values explicitly where needed.
> **Select several options or rank them**
>
> ```python theme={null}
> from edsl import Question
>
> features = ["Search", "Export", "Sharing"]
> selected = Question(
> "checkbox", question_name="features",
> question_text="Which features do you use?",
> question_options=features, min_selections=1, max_selections=3,
> use_code=False,
> )
> priorities = Question(
> "rank", question_name="priorities",
> question_text="Rank your two highest priorities, most important first.",
> question_options=features, num_selections=2, use_code=False,
> )
> ```
>
> `checkbox` records a selection; `rank` preserves the requested ordering. Use `top_k` when you need a fixed selection count without ranking.
> **Collect ratings or measurements**
>
> ```python theme={null}
> from edsl import Question
>
> rating = Question(
> "linear_scale", question_name="ease",
> question_text="How easy was this task?",
> question_options=[1, 2, 3, 4, 5],
> option_labels={1: "Very difficult", 5: "Very easy"},
> )
> minutes = Question(
> "numerical", question_name="minutes",
> question_text="How many minutes did the task take?",
> min_value=0, max_value=120,
> )
> ```
>
> The rating must be one of the configured scale values. The measurement can be any number within its bounds.
> **Extract structured fields**
>
> ```python theme={null}
> from edsl import Question
>
> details = Question(
> "extract", question_name="details",
> question_text="Extract the product and issue from: {{ scenario.feedback }}",
> answer_template={"product": "product name", "issue": "reported issue"},
> )
> summary = Question(
> "dict", question_name="summary",
> question_text="Summarize: {{ scenario.feedback }}",
> answer_keys=["issue", "urgent"],
> value_types=["str", "bool"],
> value_descriptions=["The reported issue", "Whether it requires urgent attention"],
> )
> ```
>
> Supply `feedback` through a Scenario when running these questions. Use `dict` when you need to declare value types explicitly. Neither format establishes the factual accuracy of an extracted value.
> **Rate several items together**
>
> ```python theme={null}
> from edsl import Question
>
> ratings = Question(
> "matrix", question_name="feature_ratings",
> question_text="Rate your satisfaction with each feature.",
> question_items=["Search", "Export", "Sharing"],
> question_options=[1, 2, 3, 4, 5],
> option_labels={1: "Very dissatisfied", 5: "Very satisfied"},
> )
> ```
>
> The response contains one configured rating per item. Use separate questions if the items need different scales or survey logic.
## Additional types
EDSL also includes specialized types for Other responses (`multiple_choice_with_other`, `checkbox_with_other`), long choice lists (`dropdown`), structured validation (`pydantic`), and formats such as `markdown` and `url`. Inspect an example and its fields before adapting it:
```python theme={null}
from edsl import Question
example = Question.example("multiple_choice_with_other")
example.to_dict()
# In a script: print(example.to_dict())
# Question.list_question_types() lists all types in your installed version.
```
For a machine-readable schema, use `ep schema show --question_type multiple_choice_with_other`.
Choose a specialized type for the behavior you need:
| Type | Purpose and configuration |
| - | - |
| `multiple_choice_with_other`, `checkbox_with_other` | Allow supplied choices and custom Other responses. Set `question_options` and optionally `other_option_text`; the checkbox type also supports selection bounds. |
| `dropdown` | Search a large option set. Supply `question_options`, optionally `question_options_details`, and `max_options_shown` / `top_k` to bound the displayed or retrieved choices. |
| `pydantic` | Validate a structured answer against a Pydantic class supplied as `pydantic_model`. |
| `markdown`, `url` | Collect Markdown text or a valid absolute URL. URL validation does not establish whether the destination exists or supports a claim. |
| `compute` | Render a template and record its value without an LLM call; useful for deterministic calculated fields. |
| `functional` | Run a supplied Python `func(scenario, agent_traits)` to produce the answer. Preserve the function with the study. |
| `random` | Generate a pseudo-random number between 0 and 1 without asking a model. |
| `thinking` | Send question text directly to a specified `model` and optional `system_prompt`, and record the response. |
| `demand` | Collect quantities demanded at the supplied `prices`. |
| `edsl_object` | Collect an EDSL object representation with a specified `expected_object_type`, such as `"ScenarioList"`. Useful for workflows accepting EDSL objects as inputs. |
| `file_upload` | Collect a respondent's file; see [file upload responses](/en/latest/humanize#working-with-file-upload-responses) for retrieving FileStore objects. |
| `image_generation` | Return a generated image FileStore. Set its image `model`, `service_name`, and `generation_parameters`; these are separate from the language model used by other questions. See [image generation](/en/latest/filestore#generate-image-inputs). |
| `diagram` | Generate a Graphviz DOT diagram and return a FileStore. Set `output_format` and `engine`; rendering requires Graphviz. |
| `interview` | Conduct a guided human interview; see [Human interviews](/en/latest/guides/human_interviews). |
| `group_discussion` | Simulate discussion among configured `participants`, with `max_rounds`, `turn_order`, and optional decision settings. Several participant turns can incur model costs. |
| `survey_message` | Display a routable message without asking for an answer; see [Survey messages](/en/latest/survey_messages). |
For example, define a structured answer and a deterministic calculation:
```python theme={null}
from pydantic import BaseModel
from edsl import Question
class FeedbackCode(BaseModel):
issue: str
urgent: bool
coding = Question(
"pydantic", question_name="coding",
question_text="Code this feedback: {{ scenario.feedback }}",
pydantic_model=FeedbackCode,
)
double_score = Question(
"compute", question_name="double_score",
question_text="{{ scenario.score * 2 }}",
)
```
Construction makes no model calls. `coding` requires model execution to generate an answer; `double_score` computes its answer from a scenario's `score` when run. Validate specialized workflows with representative inputs before using them in a study.
# Remote caching
Source: https://docs.expectedparrot.com/en/latest/remote_caching
Understand hosted response reuse and when to request new draws.
A remote cache stores model responses on Expected Parrot so matching calls can reuse them. It is separate from a [local Cache](/en/latest/cache), a saved Results object, and the service that [executes a job](/en/latest/remote_inference).
Use it to reuse responses across sessions without carrying a local cache file between environments. If your aim is simply to reopen completed data for analysis, retrieve the Results object; use the cache when running a job that may contain matching calls.
A matching question alone is insufficient for a cache hit. Model configuration, rendered system and user prompts, and iteration information matter. Changing a scenario, agent instruction, or model parameter can change the call even if the question object is unchanged.
## Control caching in Python
Remote caching follows your Expected Parrot account settings unless disabled for the run. With [authentication configured](/en/latest/api_keys), inspect the setting in Python:
```python theme={null}
from edsl import Coop
coop = Coop()
coop.edsl_settings.get("remote_caching")
```
To run a prepared job with an explicit local cache and remote caching disabled:
```python theme={null}
from edsl import Cache, Jobs
job = Jobs.load("jobs.ep")
cache = Cache()
results = job.run(
cache=cache,
disable_remote_inference=True,
disable_remote_cache=True,
)
results.select("answer.*", "cache_used.*")
# In a script, append .print() to display the table.
```
`disable_remote_inference=True` runs orchestration locally; a provider model still makes API calls and can incur costs. `disable_remote_cache=True` controls hosted cache access separately. Omitting it allows the account setting to apply. A `cache_used` value records response reuse; it does not identify whether the entry originally came from a local or remote cache.
## Request fresh responses
To request new model responses instead of reusing cached responses, use `fresh=True` with hosted execution:
```python theme={null}
fresh_results = job.run(offload_execution=True, fresh=True)
fresh_results.save("fresh-results.ep")
```
For local orchestration, start with a new `Cache()` and set `disable_remote_cache=True`, as in the example above. In this version, `fresh=True` alone does not bypass entries already in a supplied local cache.
## Download cache entries for a job
Use the remote **job UUID** from [Background jobs](/en/latest/guides/background_jobs), rather than a Results object UUID. These calls require Expected Parrot access:
```python theme={null}
from edsl import Cache, Coop
coop = Coop()
entries = coop.remote_cache_get(job_uuid="JOB_UUID") # Replace with your job UUID.
downloaded_cache = Cache(data={entry.key: entry for entry in entries})
downloaded_cache.write_jsonl("job-cache.jsonl")
downloaded_cache.select("model", "user_prompt", "output")
```
The return value from `remote_cache_get()` is a list of `CacheEntry` objects, not a Results object or a Cache. Constructing the Cache as above lets you inspect it, save it, or pass it as `cache=downloaded_cache` to a later run. Only matching model calls can reuse those entries. Save the full Results separately to retain the survey, answers, and study metadata.
## Download entries by cache key
When you already know the keys, retrieve a specific set instead of all entries for a job:
```python theme={null}
entries = coop.remote_cache_get_by_key(select_keys=["CACHE_KEY"])
# Replace CACHE_KEY with an actual key, for example one from cache.keys().
selected_cache = Cache(data={entry.key: entry for entry in entries})
```
`select_keys` must be a nonempty list. To merge retrieved entries into an existing local cache, use `cache.add_from_dict({entry.key: entry for entry in entries})`. Conflicting entries with the same key raise an error. Remote availability and account access still apply; do not assume every key can be retrieved.
## CLI alternatives
```bash theme={null}
ep settings
ep run jobs.ep --fresh --output fresh-results.ep
```
`ep settings` shows account settings. `--fresh` requests new responses for a run. These CLI commands complement the Python retrieval and cache-management methods above.
See [Jobs execution options](/en/latest/jobs#execution-options) for controlling caching during a run.
# Remote execution
Source: https://docs.expectedparrot.com/en/latest/remote_inference
Run a saved study on Expected Parrot and retrieve its results.
Remote execution runs your EDSL job on Expected Parrot's servers. Your questions, scenarios, agents, and models still define the study in Python; the hosted service carries out the interviews and returns Results for analysis. This is useful for running a study across many inputs or models through your Expected Parrot account.
Start with a [Jobs](/en/latest/jobs) object and [model access configured](/en/latest/api_keys). You can run a job you have just constructed or load one from a saved package, as below.
## Run and save
Load a prepared job, inspect its prompts, and estimate the workload before running it:
```python theme={null}
from edsl import Jobs
job = Jobs.load("jobs.ep")
job.prompts()
# Optional: job.estimate_job_cost() to estimate model costs before execution.
```
With account access configured, request hosted execution explicitly:
```python theme={null}
results = job.run(offload_execution=True)
results.save("results.ep")
results.select("answer.*")
# In a script, append .print() to display the answers.
```
This call waits for results before the next line saves them. For long jobs, use [Background jobs](/en/latest/guides/background_jobs) to submit and return immediately, then inspect status and retrieve the answers later.
## Python execution settings
With a prepared `job`, use `job.run(offload_execution=True)` to request hosted execution explicitly. To orchestrate the job locally with direct provider access, use `job.run(disable_remote_inference=True)`. Local orchestration may still call remote model APIs.
Remote execution, API proxying, and remote caching are separate settings. Set `disable_remote_cache=True` when a local run must avoid the remote cache. Use `disable_remote_inference=True` for direct provider access from your machine; `offload_execution=False` alone does not establish that execution mode.
See [Jobs execution options](/en/latest/jobs#execution-options) for accepted execution options and [Remote caching](/en/latest/remote_caching) for response reuse.
## Failed or partial remote runs
A submitted job is not necessarily a completed dataset. Inspect the final status, completed interview count, and errors before analysis:
```python theme={null}
from edsl import Coop
coop = Coop()
status = coop.remote_inference_get(job_uuid="JOB_UUID")
status
# If the job has an error report:
# report = coop.get_error_report_markdown("JOB_UUID", save_path="error.md")
```
Replace `JOB_UUID` with your submitted job's identifier. See [Background jobs](/en/latest/guides/background_jobs) for finding jobs, retrieving completed Results, and reading results by page.
## CLI alternatives
```bash theme={null}
ep validate --file jobs.ep
ep jobs cost jobs.ep
ep run jobs.ep --output results.ep
ep results select --file results.ep --column "answer.*" --limit 5
```
Read each command's JSON `status` before continuing. The blocking run saves completed data in `results.ep`; stdout contains execution metadata. For an existing remote job:
```bash theme={null}
ep jobs status JOB_UUID
ep jobs errors JOB_UUID --output error.md
```
Replace `JOB_UUID` with the identifier returned at submission. The CLI rejects a failed or partially failed remote run by default. If you deliberately want its successful observations, use `--allow-partial` on the run command and inspect the missing observations before using the output:
```bash theme={null}
ep run jobs.ep --allow-partial --output partial-results.ep
```
This command submits or runs the job; it is not a download command for an existing job. A run with no successful observations still fails. To retrieve a previously submitted job, use `ep jobs results JOB_UUID --output results.ep` and inspect its returned status and warnings.
# Validating classifications with humans
Source: https://docs.expectedparrot.com/en/latest/research/human_validation
Collect independent labels and compare them with model predictions.
Human validation compares model-generated labels with labels people assign using the same definitions. It helps answer a practical question: does the model classify your records in the way your task requires? Disagreements can reveal a model error, an unclear category boundary, or a record that people also interpret differently.
This page takes the service-request task from [Data labeling](/en/latest/guides/data_labeling) through three stages: generate model predictions, collect independent human classifications, and match both by record identifier. You can use EDSL to collect the human responses, or bring in labels you already have.
## Start with the same records and codebook
The example below defines four sample requests, a codebook, a category question, and a supporting-evidence question. The requests have no preassigned labels. Both models and human reviewers will answer the same questions using the same category definitions.
```python theme={null}
from edsl import QuestionMultipleChoice, QuestionFreeText, Scenario, ScenarioList, Survey
# Sample inputs: no existing labels are needed to run the survey.
records = ScenarioList([
Scenario({"record_id": "t1", "text": "I was charged twice."}),
Scenario({"record_id": "t2", "text": "I cannot reset my password."}),
Scenario({"record_id": "t3", "text": "Please add a dark theme."}),
Scenario({"record_id": "t4", "text": "The invoice page won't open."}),
])
# Translate the codebook into allowed answers and instructions for using them.
codebook = {
"Billing": "A charge or payment issue.",
"Access": "Inability to enter or use a page, including a billing page.",
"Feature request": "A request for new functionality.",
"Other": "A request that does not fit any of the categories above.",
}
definitions = "\n".join(f"{label}: {meaning}" for label, meaning in codebook.items())
category = QuestionMultipleChoice(
question_name="category",
question_text="What kind of request is this: {{ scenario.text }}?\n" + definitions,
question_options=list(codebook),
)
evidence = QuestionFreeText(
question_name="evidence", question_text="Quote the words supporting your code: {{ scenario.text }}",
)
survey = Survey([category, evidence]).add_targeted_memory(evidence, category)
job = survey.by(records)
```
## Generate model predictions
With [model access configured](/en/latest/api_keys), run the survey on the records:
```python theme={null}
predictions = job.run()
predictions.select("scenario.record_id", "scenario.text", "answer.category", "answer.evidence")
```
For one model and iteration, this gives one row per request. `record_id` is the link between a prediction and a later human judgment. Keeping that identifier avoids relying on row order, which may change when responses arrive or data is filtered.
## Collect human labels
EDSL can present the same questions to people through a hosted survey. The code below attaches the source records to the survey and publishes it with random scenario assignment. Each participant receives a request and answers the category and evidence questions:
```python theme={null}
info = survey.by(records).humanize(
human_survey_name="Service request coding",
scenario_list_method="randomize",
)
respondent_url = info["respondent_url"]
human_survey_uuid = info["uuid"]
```
Calling `humanize()` creates the hosted survey using your Expected Parrot account. Give `respondent_url` to the people who will label the requests. They receive the source text and codebook, without the model's chosen categories or evidence. This lets the comparison reflect independent judgments.
Random assignment can give some records more responses than others, so inspect which records have been labeled. [Human studies](/en/latest/guides/human_studies) and [Humanize](/en/latest/humanize) cover collection and other scenario assignment options.
## Retrieve and review the human responses
After participants have responded, retrieve the completed responses with the survey UUID:
```python theme={null}
from edsl import Coop
human_results = Coop().get_human_survey_responses(human_survey_uuid)
human_results
```
The method normally returns a Results object, so you can use `human_results.select("scenario.record_id", "answer.category", "answer.evidence")` to inspect the annotations. If responses cannot be converted to Results, it returns a ScenarioList of response data instead; inspect those fields before selecting columns.
When several people label one request, first decide how their judgments will become a reference label. For example, reviewers can discuss disagreements and agree on a final category, a process called **adjudication**. Keep the individual judgments if you also want to examine how often the human reviewers agree with one another. A disagreement between reviewers may point to a codebook definition that needs clarification.
## Match predictions to reviewed labels
For the comparison below, prepare `human_labels.csv` with two columns: `record_id` and `human_label`. Include one reviewed label for each record that has a completed review. You can prepare this file from the collected responses, or start here with labels from an existing annotation project.
```python theme={null}
from edsl import ScenarioList
labels = ScenarioList.from_source("csv", "human_labels.csv").to_pandas()
predicted = predictions.select("scenario.record_id", "answer.category").to_pandas()
comparison = predicted.merge(
labels,
left_on="scenario.record_id",
right_on="record_id",
how="left",
validate="many_to_one",
)
reviewed = comparison.dropna(subset=["human_label"]).copy()
reviewed["matches_human"] = reviewed["answer.category"] == reviewed["human_label"]
agreement = reviewed["matches_human"].mean()
reviewed
```
The left join keeps every model prediction, including records that have no human label yet. `validate="many_to_one"` checks that the reference file contains at most one label for each record. `reviewed` includes only the records with a reference label, and `agreement` is the fraction of those records with an exact match. If no records have been reviewed, there is no agreement estimate yet.
Inspect unmatched records in `comparison` to see what still needs human review. Inspect disagreements in `reviewed` alongside the original text and evidence to understand their cause. Looking separately at Billing, Access, and Feature request can show a category-specific problem hidden by the overall agreement.
## Adapt the validation to your task
For multiple-choice classifications, exact agreement is a simple starting point. For checkbox themes, compare the selected sets or each theme separately; for numerical ratings, compare the size and direction of the differences. For free-text outputs, create evaluation questions that capture what you want people to judge, such as factual correctness or usefulness.
You can also [compare several models](/en/latest/guides/model_comparisons) against the same reviewed labels. If the disagreements lead you to revise the prompt or codebook, use additional independently labeled records to assess the revised version. This separates the examples that helped you improve the task from the ones you use to evaluate it.
# Model behavior across contexts
Source: https://docs.expectedparrot.com/en/latest/research/model_behavior
Vary context while holding the decision and response format constant.
A model's answer can depend on the situation described around a question. The same action may appear different when it affects one person or a team, when a resource is scarce, or when additional consequences are explained. EDSL lets you represent these situations as scenarios and ask the same question in each one.
This example investigates a small context change: **does a model choose differently when the last available export belongs to an individual or a team?** It holds the decision text and answer options constant, changes the contextual sentence, and retains the context with the answer. The pattern is useful for exploring how models respond to framing, roles, and constraints in a decision task.
## Separate the context from the decision
The ScenarioList below contains two cases. `context` describes who has one export left; `message` describes the consequence of exporting now. The question combines those fields and asks the model to choose Export now, Wait, or Need more information:
```python theme={null}
from edsl import QuestionMultipleChoice, Scenario, ScenarioList
cases = ScenarioList([
Scenario({"case_id": "a", "context": "An individual has one export left.",
"message": "Exporting now uses the last export."}),
Scenario({"case_id": "b", "context": "A team has one export left.",
"message": "Exporting now uses the last export."}),
])
question = QuestionMultipleChoice(
question_name="choice",
question_text="{{ scenario.context }} {{ scenario.message }} What would you choose?",
question_options=["Export now", "Wait", "Need more information"],
)
job = question.by(cases)
```
The two scenarios produce separate interviews. The model answering the team case does not receive the individual case as an earlier exchange. The option “Need more information” allows it to express that the description does not supply enough information to choose between exporting and waiting.
## Examine choices alongside the contexts
With [model access configured](/en/latest/api_keys), run both cases and select the fields needed to interpret the choices:
```python theme={null}
results = job.run()
results.select("scenario.case_id", "scenario.context", "scenario.message", "answer.choice")
```
For one model and iteration, the result has two rows. If the choices differ, you have an example of a model responding differently to the two contextual sentences. If they match, that tells you how it answered these two cases; it does not establish that the distinction never matters. Additional cases let you explore when the difference appears—for example, by varying whether another export will become available soon.
The current survey collects a choice, not an explanation. To ask why the model chose that option, add a free-text question and give it targeted memory of `choice`, as shown in [Survey logic and piping](/en/latest/guides/survey_logic). That follow-up produces a model-generated explanation you can read alongside the choice.
## Extend the design
Add scenarios to vary the situation while keeping the decision question fixed. Preserve separate fields for each factor, such as who controls the resource and whether it can be replenished, so results can be grouped by those factors. [Experimental conditions](/en/latest/guides/experimental_conditions) explains how to cross scenarios with agent profiles.
Use scenarios for what is happening in the described situation. Use agents when you also want to specify who the model should answer as, such as a team lead with a particular responsibility. Use [Prompt sensitivity](/en/latest/research/prompt_sensitivity) when your main interest is the effect of instructions rather than facts about the situation.
Attach a ModelList to [compare models](/en/latest/guides/model_comparisons), or use `job.run(n=3)` for repeated responses to each case. Keep those model and iteration identifiers when comparing the outputs. These designs characterize the configured models' responses to your descriptions. If your question concerns how people make the same decision, collect corresponding responses through [Human studies](/en/latest/guides/human_studies).
# Pretesting survey instruments
Source: https://docs.expectedparrot.com/en/latest/research/pretesting
Compare item wording and inspect the issues models identify.
Pretesting means examining survey questions before using them to collect the responses you intend to analyze. You may discover that a phrase is ambiguous, that one question asks about two things at once, or that the wording suggests a preferred answer. EDSL can apply the same review questions to every item in an instrument and organize the suggested revisions for you.
In this example, the model acts as a reviewer of a survey item. It identifies a possible wording problem and proposes a revision. It does not answer the item as a participant. This is useful for a first pass over a questionnaire or for comparing candidate wordings before trying them with people.
## Review two versions of an item
The example compares “Do you use this service regularly?” with “On how many of the past 7 days did you use this service?” The first leaves “regularly” open to interpretation. The second specifies a **recall period**—the span of time a respondent should think about—and asks for a count of days. These are two ways to ask about usage, with different response formats as well as different wording.
Each version becomes a scenario. The review survey asks for its main issue and a suggested revision:
```python theme={null}
from edsl import QuestionMultipleChoice, QuestionFreeText, Scenario, ScenarioList, Survey
variants = ScenarioList([
Scenario({"variant": "vague", "item": "Do you use this service regularly?"}),
Scenario({"variant": "bounded", "item": "On how many of the past 7 days did you use this service?"}),
])
issue = QuestionMultipleChoice(
question_name="issue",
question_text="Review this survey item: {{ scenario.item }}. What is its main issue?",
question_options=["Ambiguous timeframe", "Double-barreled", "Leading", "None identified"],
)
revision = QuestionFreeText(
question_name="revision",
question_text="Suggest a revision and explain what it measures: {{ scenario.item }}",
)
survey = Survey([issue, revision]).add_targeted_memory(revision, issue)
job = survey.by(variants)
```
The `issue` question makes the reviews comparable by using a fixed list of problems. “Double-barreled” means asking about two things in one item; “Leading” means wording that steers the answer. “None identified” lets the model say that none of the listed problems applies. The `revision` question supplies a more open-ended suggestion, and targeted memory gives it the issue identified in the preceding answer.
## Read the issue and revision together
With [model access configured](/en/latest/api_keys), run the review and display the original wording beside both answers:
```python theme={null}
results = job.run()
results.select("scenario.variant", "scenario.item", "answer.issue", "answer.revision")
```
Two variants and two review questions produce two result rows, with four question answers in total for one model and iteration. Compare whether the model identifies different issues for the two versions and whether its suggested revisions still measure what you care about. For example, a question about days of use may help measure frequency, but it does not necessarily measure how important the service is to the respondent.
Treat a proposed revision as an editorial suggestion to assess. This example does not establish that one version works better for human respondents; it helps you identify wording to investigate in a human pretest. In that pretest, you can ask people what they understood the question to mean and how they chose an answer.
## Adapt the review to your instrument
For a questionnaire with many items, import a ScenarioList containing an item identifier and the question text, then attach it to this review survey. Each original item receives its own issue and revision fields. Include answer options or relevant instructions in the scenarios and question templates when the review depends on them.
Use `QuestionCheckBox` if you want the reviewer to flag several issues per item. Use separate review questions for distinct concerns, such as unclear terms, unavailable answer options, or assumptions about the respondent. Those choices give you more detail than the single “main issue” question used here.
You can also attach a [ModelList](/en/latest/guides/model_comparisons) to obtain reviews from several models. To investigate how different simulated respondents answer the original instrument, run that instrument with [agent profiles](/en/latest/agents) instead of this reviewer survey. To collect actual participant feedback on it, use [Human studies](/en/latest/guides/human_studies).
# Prompt sensitivity
Source: https://docs.expectedparrot.com/en/latest/research/prompt_sensitivity
Measure how instruction choices change responses to matched inputs.
Prompt sensitivity is the extent to which answers change when you change how you ask for them. A classification may depend on whether the model is told to read words literally, infer the writer's intent, follow a supplied definition, or consider earlier answers. Testing those choices helps you make your task instructions more precise and understand which records are sensitive to interpretation.
This example compares two approaches to sentiment classification: **classify only explicit statements** and **classify the writer's likely intent**. Both approaches see the same feedback and choose from Positive, Neutral, and Negative. You will use agents to hold the two instruction configurations, then align their answers by record.
## Represent instructions as agents
An Agent can specify instructions without representing a demographic persona. Here each agent supplies one instruction and an `instruction_id` trait identifying the condition. The first feedback message, “Great, another unexpected charge,” allows a literal or sarcastic reading; the second, “The receipt arrived today,” offers a simpler comparison.
```python theme={null}
from edsl import Agent, AgentList, QuestionMultipleChoice, Scenario, ScenarioList
agents = AgentList([
Agent(traits={"instruction_id": "literal"}, instruction="Classify only explicit statements."),
Agent(traits={"instruction_id": "inference"}, instruction="Classify the likely intent of the writer."),
])
records = ScenarioList([
Scenario({"record_id": "r1", "text": "Great, another unexpected charge."}),
Scenario({"record_id": "r2", "text": "The receipt arrived today."}),
])
question = QuestionMultipleChoice(
question_name="sentiment", question_text="Classify: {{ scenario.text }}",
question_options=["Positive", "Neutral", "Negative"],
)
job = question.by(agents).by(records)
```
Chaining `.by(agents).by(records)` gives each instruction configuration both records. The question and response options stay the same, so the four interviews compare the two ways of instructing the model on matched inputs. Agent traits also appear in the prompt, so these configurations include their identifying traits as well as the instruction text.
## Put the answers side by side
With [model access configured](/en/latest/api_keys), run the job and arrange its answers into one row per record:
```python theme={null}
results = job.run()
frame = results.select("scenario.record_id", "agent.instruction_id", "answer.sentiment").to_pandas()
comparison = frame.pivot(index="scenario.record_id", columns="agent.instruction_id", values="answer.sentiment")
comparison
```
For one model and iteration, the table has two rows and columns named `literal` and `inference`. A difference within a row means the two configurations produced different labels for the same text. The example does not assume which labels the model will choose: agreement on both records is also a possible result.
To see the actual instructions and question sent for each interview, select the recorded prompts:
```python theme={null}
results.select("scenario.record_id", "agent.instruction_id", "prompt.*")
```
This connects an observed difference to the full prompt the model received. Add more records to find out whether differences recur on a particular kind of input, such as sarcasm, short messages, or mixed positive and negative statements. This comparison measures changes in labels; deciding which instruction better fits your task requires a clear labeling rule or [human reference labels](/en/latest/research/human_validation).
## Vary a different part of the prompt
Use agent instructions when a general way of answering should apply across a survey. For question-specific wording, put the alternatives in scenarios and substitute them into a question template; [Experimental conditions](/en/latest/guides/experimental_conditions) shows that pattern. A question's `answering_instructions` can change the guidance for producing its answer, while [survey memory](/en/latest/guides/survey_logic#choose-which-prior-answers-a-model-sees) changes which earlier exchanges are supplied as context.
To examine whether the same instruction difference appears across models, attach a ModelList. To examine repeated responses, use `job.run(n=3)`. In either case, keep the model or iteration fields with the answers: the simple pivot above assumes exactly one answer for each record and instruction condition.
# Qualitative coding with a codebook
Source: https://docs.expectedparrot.com/en/latest/research/qualitative
Apply themes across documents and retain evidence quotations.
Qualitative coding assigns themes to passages of text so you can examine patterns across interviews, open-ended survey responses, or documents. A passage can receive more than one theme: someone describing difficulty finding a feature may also mention getting help from a colleague. A **codebook** names the themes and explains when each applies.
This example uses EDSL to apply a small, predefined codebook to two interview excerpts about an export feature. You will collect a list of themes and supporting quotations for each excerpt, while keeping the identifiers needed to return to the original source. This is useful when you want to organize material by recurring topics without losing the text behind each assignment.
If you want to develop themes from your material first, follow [Develop labels through exploratory questions](/en/latest/guides/data_labeling#develop-labels-through-exploratory-questions). That workflow uses initial answers to develop a codebook, then turns its labels into the options for structured questions like the one below.
## Define themes and retain their evidence
The codebook has three themes: Discovery for finding a control, Format for selecting a file format, and Assistance for seeking or receiving help. The two excerpts below are made-up examples. Each is stored in a scenario with both a `document_id` and an `excerpt_id`, allowing a longer interview to contribute several separately identifiable passages.
```python theme={null}
from edsl import QuestionCheckBox, QuestionFreeText, Scenario, ScenarioList, Survey
excerpts = ScenarioList([
Scenario({"document_id": "d1", "excerpt_id": "e1",
"text": "I found the export button, but I didn't know which format to choose."}),
Scenario({"document_id": "d2", "excerpt_id": "e2",
"text": "I couldn't find exports. A colleague showed me the menu."}),
])
themes = QuestionCheckBox(
question_name="themes",
question_text=("Apply this codebook to the excerpt: {{ scenario.text }}. "
"Discovery: finding a control; Format: selecting a file format; "
"Assistance: seeking or receiving help. Select only supported themes."),
question_options=["Discovery", "Format", "Assistance"],
min_selections=0, max_selections=3,
)
evidence = QuestionFreeText(question_name="evidence",
question_text="Quote evidence for each selected theme: {{ scenario.text }}")
survey = Survey([themes, evidence]).add_targeted_memory(evidence, themes)
job = survey.by(excerpts)
```
`QuestionCheckBox` lets the model select several themes for one excerpt. With `min_selections=0`, it can also return an empty list if none is supported; it does not have to force every passage into a category. The second question asks for evidence, and targeted memory supplies the selected themes so the model can connect its quotations to those choices.
This codebook captures topics mentioned in an excerpt. For example, mentioning that a control was easy to find and mentioning that it was hard to find can both concern Discovery. If your research question is specifically about barriers, change the definition to require difficulty, or add a separate question about whether the experience was positive or negative.
## Read themes with the original passage
With [model access configured](/en/latest/api_keys), run the coding survey and display the source, assigned themes, and evidence together:
```python theme={null}
results = job.run()
results.select(
"scenario.document_id", "scenario.excerpt_id", "scenario.text",
"answer.themes", "answer.evidence",
)
```
Two excerpts and two questions produce two rows, with four answers in total for one model and iteration. `answer.themes` contains a list, while `answer.evidence` contains free text. The evidence question requests quotations for each theme, but it does not enforce a separate structured field for every quotation. Read the response with the excerpt to see whether the quoted words support the assigned themes.
To work with the coding in pandas, convert the selected columns with `.to_pandas()`. When counting themes, decide whether you want the number of excerpts mentioning a theme or the number of documents containing it. An interview with five coded excerpts contributes five passages but only one document; the two identifiers let you make either calculation.
## Choose how much structure your coding needs
Use this checkbox-and-evidence design when themes may overlap and you want a compact set of answers. Other EDSL question types support different coding needs:
| Research task | Alternative |
| - | - |
| Assign one primary theme per passage | Use `QuestionMultipleChoice`, as in [Data labeling](/en/latest/guides/data_labeling). |
| Assess every theme explicitly | Ask a separate `QuestionYesNo` for each theme, optionally followed by an evidence question. This produces one answer column per theme. |
| Collect specific information in named fields | Use `QuestionExtract` with an answer template, such as fields for a stated problem and a supporting quotation. |
| Explore themes before choosing a codebook | Use the [exploration-to-labeling workflow](/en/latest/guides/data_labeling#develop-labels-through-exploratory-questions) to turn initial answers into structured question options. |
For a larger collection, [import excerpts from a table](/en/latest/guides/importing_data) or extract text from [documents and PDF pages](/en/latest/scenarios#pdf-text). Choose the amount of text in each scenario to suit the question: a short excerpt supports passage-level coding, while a whole document provides broader context. To compare the coding with independent human judgments, follow [Human validation](/en/latest/research/human_validation); for this multi-theme design, compare the assigned themes rather than using a single-category match.
# Research Agent
Source: https://docs.expectedparrot.com/en/latest/research_agent
Design, run, analyze, and share research through conversation, with access to study files and deliverables.
The Research Agent helps you develop a study from a research question, project brief, existing questionnaire, or dataset. It can create surveys and interview guides, run AI simulations, help field studies with human participants, analyze results, and prepare reports.
[Open the Research Agent](https://www.expectedparrot.com/research-agent) after signing in to your Expected Parrot account. You do not need to install Python to get started.
## Start a session
Describe your research goal, the population or material you want to study, and the outputs you need. Include relevant source material and constraints such as sample size, model choices, and budget. State whether you want human responses, AI simulations, or both.
For example:
> Help me design a study of how students choose courses. Draft a short survey and an interview guide for human participants. Explain what each question measures and let me review the instruments before launching the study.
For a simulation:
> Compare reactions to these three messages across two audience profiles. Propose the questions, models, and analysis plan, estimate the model costs, and start with a small pilot. Label all simulated responses clearly in the report.
For [data labeling](/en/latest/guides/data_labeling), provide your codebook or ask the Research Agent to help develop one:
> Use exploratory questions to identify themes in these open-ended responses. Consolidate the suggestions into a codebook, then create structured EDSL questions whose answers will label each response. Produce a CSV with the original responses and labels, and include the codebook and labeling survey in the files.
## Design and run a study
**1. Develop the research plan**
Explain the question, inputs, and desired outputs. Review the proposed method, respondent definitions, questions, and analysis before running the study.
**2. Review the study objects**
Open the survey, scenarios, agent profiles, or interview guide from the files pane on the right. Request revisions in the conversation or open the study in the [Builder](/en/latest/survey_builder) for direct editing.
**3. Choose how to collect responses**
For AI simulations, specify models and respondent profiles and review estimated [costs](/en/latest/costs). For human research, review the participant-facing survey or interview and decide how participants will access it. AI-led interviews follow an interview guide and can ask follow-up questions based on participants' answers.
**4. Inspect and analyze the results**
Inspect the response data alongside charts and conclusions in the files pane. Specify the comparisons and exports you want, and request a report that identifies the study settings, limitations, and whether each result came from humans or AI.
See [Humanize](/en/latest/humanize) for human survey workflows in code and [Prolific studies](/en/latest/prolific) for participant recruitment.
## Access files and deliverables
The files pane on the right automatically provides access to your uploads, underlying data files, study materials, and deliverables created during the session. Open it to browse and inspect the files at any time. Certain files, such as the research plan or a final report, also open automatically when they are created.
Tell the Research Agent what deliverables or other files you want it to create. You can specify these at the start or as the study develops. For example:
> Create a CSV of the responses, a chart comparing the groups, and a report explaining the findings. Include the questionnaire as a separate file.
You can ask the agent to explain any file, result, or decision, or request changes in the conversation.
## Review and reuse EDSL code
The underlying EDSL code and study objects let you inspect the questions, model choices, agent traits, and scenarios alongside the output. You can reuse or extend the code in a Python notebook. The [EDSL Overview](/en/latest/overview) explains the objects, and [first steps](/en/latest/index#start-here) covers installation and model access. Use the [Builder](/en/latest/survey_builder) when you want to edit study objects visually.
## Share a session
Use the session's **Share** control to create a link for collaborators. Review the visibility and selected files before sharing. Include the research plan, study materials, results, and analysis needed for someone else to understand the work.
A session link shares the research workflow; use the study's respondent link when inviting people to complete a survey or interview.
## Using playbooks
Playbooks are reusable instructions for a particular research method, project, or assignment. They help specify the inputs to collect, steps to follow, and outputs to produce. You can start from a playbook supplied by an instructor or collaborator, or ask the Research Agent to help write reusable instructions for your workflow.
When using a playbook, provide the project-specific inputs and review its assumptions. For example, a message-testing playbook might ask for the messages, audience definitions, models, and comparison criteria. An instructor can use the same playbook across a class while students supply different research questions or data.
See the [Teaching guide](/en/latest/teaching) for an assignment example and [Research Agent resources](https://www.expectedparrot.com/products/research-agent/use-cases) for example workflows.
## Next steps
* [Builder](/en/latest/survey_builder): edit surveys and interview guides directly.
* [Results](/en/latest/results): explore analysis and export methods in EDSL.
* [Research with Expected Parrot](/en/latest/papers): see research using or citing Expected Parrot and EDSL.
# Results
Source: https://docs.expectedparrot.com/en/latest/results
Turn responses and study metadata into analysis-ready tables.
A `Results` object contains interview records and associated study information. Use it to inspect completion, trace answers to inputs, and select data for analysis. `Results.select()` returns a tabular `Dataset`; it does not rerun the study.
One result record holds all answers from one interview, together with its scenario, agent, and model. A survey with five questions therefore normally produces five answer columns per interview, rather than five rows. This is the object returned by `job.run()`; the same selection and export methods also work with retrieved human responses.
## Inspect and compare data
The example below creates four fabricated records so the analysis can run without credentials or model costs. It is a demonstration of data operations, not a research finding.
```python theme={null}
from edsl import Agent, Model, QuestionMultipleChoice, Result, Results, Scenario, Survey
question = QuestionMultipleChoice(
question_name="clarity", question_text="Is the message clear?",
question_options=["Clear", "Unclear"],
)
survey = Survey([question])
# Fabricated observations for demonstrating data operations, not research findings.
results = Results(survey=survey, data=[
Result(agent=Agent(), scenario=Scenario({"record_id": key, "condition": condition}),
model=Model("test"), iteration=0, answer={"clarity": answer}, survey=survey)
for key, condition, answer in [
("r1", "short", "Unclear"), ("r2", "explicit", "Clear"),
("r3", "short", "Clear"), ("r4", "explicit", "Clear"),
]
])
table = results.select("scenario.record_id", "scenario.condition", "answer.clarity")
# Display in a notebook: put table on the last line of a cell.
# Display in a script: table.print()
# Convert to pandas when you need DataFrame operations.
frame = table.to_pandas()
counts = frame.groupby(["scenario.condition", "answer.clarity"]).size()
clear = results.filter("clarity == 'Clear'")
table.to_csv("clarity.csv")
```
The counts contain one Clear and one Unclear answer for `short`, and two Clear answers for `explicit`. The filtered Results contains three records. `clarity.csv` retains the record identifier and condition beside each answer.
## Select columns and retain provenance
Use `results.columns` to inspect available columns. The part before the dot tells you where a value came from:
| Column in this example | What it tells you |
| - | - |
| `answer.clarity` | The answer to the question named `clarity`. |
| `scenario.condition` | Which message condition was evaluated. |
| `scenario.record_id` | Which source record the answer belongs to. |
| `model.model` | Which model generated it. |
`agent.*` selects profile information, `prompt.*` selects recorded prompts, and `"answer.*"` selects all answer columns. Keep the fields needed for your comparison beside the answers—for example, the condition when comparing messages and the model when comparing models.
Inspect prompts and raw responses when diagnosing validation failures. Report missing or failed interviews alongside completed observations; a completed process does not guarantee that every planned observation is present.
## Filter, export, and reshape
Filter a Results object before selecting columns when you need to preserve its study context. Use a Dataset or pandas DataFrame for grouping and reshaping. Export to CSV with `results.select(...).to_csv("answers.csv")` or use the [CLI Guide](/en/latest/cli) for bounded previews and file exports.
Use [Dataset](/en/latest/dataset) for tabular transformations. Put record identifiers and treatment labels in scenarios before execution; they remain available in results even if they are not inserted into question text. [Human validation](/en/latest/research/human_validation#match-predictions-to-reviewed-labels) demonstrates matching external labels by record identifier and checking for duplicate matches.
## Human responses and saved work
Human responses can be retrieved through [Humanize](/en/latest/humanize). Keep collection source and study identifiers when comparing human and simulated data. Save full Results objects as well as analysis tables when you need prompts, settings, and cache information later; see [Saving objects](/en/latest/guides/saving_objects).
## Common methods
| Call | Arguments and result |
| - | - |
| `results.columns` | List available column names, including their namespaces. |
| `results.select("answer.clarity", "scenario.condition")` | Return a Dataset of the named columns. Pass each column separately; `"answer.*"` selects all answers. |
| `results.filter("clarity == 'Clear'")` | Return Results containing only interviews matching the expression. Use qualified names when a name occurs in more than one namespace. |
| `results.sort_by("scenario.record_id", reverse=False)` | Return Results sorted by one or more columns; `reverse=True` gives descending order. |
| `results.save("results.ep")` / `Results.load("results.ep")` | Save and restore the complete Results object, including study metadata. |
Using the four fabricated records above:
```python theme={null}
results.filter("clarity == 'Clear'").select(
"scenario.record_id", "scenario.condition", "answer.clarity"
)
# In a notebook, the last expression displays the three matching rows.
# In a script, append .print() to display the selected table.
```
For export, select the columns you want to share:
```python theme={null}
table = results.select("scenario.record_id", "answer.clarity")
table.to_csv("answers.csv")
# Optional: table.to_pandas() for DataFrame operations.
# Optional: results.select("answer.clarity").to_list() for a Python list.
```
See [Dataset methods](/en/latest/dataset#common-methods) for display, conversion, and further table transformations.
## Derive answers and summarize groups
`mutate()` returns Results with an additional answer column. It does not rerun the model:
```python theme={null}
scored = results.mutate("is_clear = clarity == 'Clear'")
scored.select("scenario.record_id", "answer.is_clear")
# Count combinations without converting to pandas:
results.tally("scenario.condition", "answer.clarity")
```
`results.rename("clarity", "message_clarity")` returns Results with the answer column renamed. `sample(n=2, seed="review")` and `shuffle(seed="review")` return new Results collections; sampling is without replacement by default. `results.bucket_by("scenario.condition")` returns a dictionary mapping group-key tuples to lists of Result records. `results.sql(query)` runs a query over a table named `self`; `remove_prefix=False` retains qualified column names, which need quoting in SQL.
## Inspect individual interviews
Indexing Results or calling `first()` gives a single `Result`:
```python theme={null}
interview = results.first()
interview.get_answer("clarity")
# Equivalent list-oriented access across interviews: results.get_answers("clarity")
interview.q_and_a()
```
| Method | Use |
| - | - |
| `interview.get_question_names()` | List the questions recorded for the interview. |
| `interview.get_question_text("clarity")`, `get_question_type("clarity")`, `get_question_options("clarity")` | Inspect question definitions associated with an answer. |
| `interview.get_value("scenario", "record_id")` | Read a value from a particular namespace. |
| `interview.transcript()` / `results.transcripts()` | Display question-and-answer transcripts for one or several interviews. |
| `interview.score_with_answer_key({"clarity": "Clear"})` | Return per-question correctness scores against an explicit reference key. |
| `interview.score(scoring_function)` | Apply a Python scoring function whose parameter names correspond to fields in the record. |
## Long tables, new inputs, and cache inspection
`results.long(scenario_fields=["record_id", "condition"])` returns a ScenarioList with one row per interview-question pair. `results.q_and_a(include_scenario=True)` returns question text, answers, comments, and scenario fields for reading or further analysis. `results.agent_answers_by_question()` returns a dictionary grouped by agent.
To use responses as inputs to another study, select the fields first: `results.select("scenario.record_id", "answer.clarity").to_scenario_list()`. Use `to_agent_list()` instead when the selected fields should become respondent traits. See [table exports](/en/latest/dataset#export-and-convert-tables) for files and DataFrames.
Inspect `results.select("cache_used.*")` for reuse indicators and `results.cache` for retained cache entries. `results.relevant_cache(cache)` returns the entries in a supplied Cache referenced by these Results. `results.show_exceptions()` displays recorded interview exceptions, when available; `traceback=True` includes traceback details. A saved Results object may not include all execution history.
# Scenarios
Source: https://docs.expectedparrot.com/en/latest/scenarios
Use text, tables, PDFs, images, video, and audio as inputs to EDSL questions.
A `Scenario` holds the inputs to a question: text, numbers, experimental conditions, or files such as PDFs, images, video, and audio. Give each input a name and refer to it in the question with `{{ scenario.name }}`. A `ScenarioList` supplies multiple sets of inputs so you can ask the same questions about each one.
Put the material being evaluated in a scenario; put simulated respondent characteristics in an [Agent](/en/latest/agents).
## Keep the question fixed and vary the data
The example asks the same sentiment question about three feedback messages. Each scenario provides the message and a record identifier; the question supplies the shared Positive, Neutral, and Negative categories. It prepares the tasks without generating answers:
```python theme={null}
from edsl import QuestionMultipleChoice, Scenario, ScenarioList, Survey
question = QuestionMultipleChoice(
question_name="sentiment",
question_text="Classify the sentiment of this feedback: {{ scenario.feedback }}",
question_options=["Positive", "Neutral", "Negative"],
)
feedback = ScenarioList([
Scenario({"record_id": "f1", "feedback": "The setup was easy."}),
Scenario({"record_id": "f2", "feedback": "I couldn't find the export button."}),
Scenario({"record_id": "f3", "feedback": "It worked as expected."}),
])
job = question.by(feedback)
# by(): one interview per input, with the same answer.sentiment column.
# loop(): three distinct questions in one survey, with the text substituted now.
variants = question.loop(feedback)
variant_survey = Survey(variants)
```
`record_id` is retained in results even though it is not inserted into the question text. Use stable identifiers to join predictions with source data and human annotations.
## Crossing inputs versus creating question variants
`question.by(feedback)` asks the same question in separate interviews for each scenario. The answer column remains `answer.sentiment`, and scenario values identify the input row.
`question.loop(feedback)` returns separate question objects with scenario values substituted into their text. Placing those questions in a survey creates multiple answer columns in each interview. Choose this when respondents should see several fixed versions within one survey. It is not a substitute for assigning treatments between respondents.
## Controlled experiments
Cross scenario conditions with agent profiles and models using successive `by()` calls. EDSL runs every combination: two treatments, four profiles, and three models produce 24 interviews per iteration. Human participant assignment is configured separately when publishing the survey.
Use [Experimental conditions](/en/latest/guides/experimental_conditions) to retain condition labels and [Human studies](/en/latest/guides/human_studies) for scenario assignment in hosted surveys.
## Data and files
A scenario can hold extracted text or a complete file. For a complete file, EDSL uses a [FileStore](/en/latest/filestore) to carry its contents. Reference the file's scenario field in every question that should receive it.
| Input | Create scenarios with | Example below |
| - | - | - |
| Images | `Scenario.from_image(path, image_name="image")` | [Images](#images) |
| Whole PDF | `Scenario.from_file(path, field_name="document")` | [PDF attachments](#pdf-attachments) |
| PDF text, one scenario per page | `ScenarioList.from_source("pdf", path)` | [PDF text](#pdf-text) |
| PDF pages as images | `ScenarioList.from_source("pdf_to_image", path)` | [PDF page images](#pdf-page-images) |
| Video or audio | `Scenario.from_file(path, field_name="video")` or `field_name="audio"` | [Video](#video) and [Audio](#audio) |
| CSV, Excel, pandas, and other tables | `ScenarioList.from_source(source_type, data)` | [Importing data](/en/latest/guides/importing_data) |
| A directory of files | `ScenarioList.from_source("directory", path, pattern=...)` | [A collection of files](#a-collection-of-files) |
The examples below use local filenames; replace them with your files. Loading them constructs EDSL objects locally. Calling `run()` sends the referenced content to your selected model. Use [notebook login or provider credentials](/en/latest/api_keys) to configure access.
For attachments, select a model that accepts that media type. Replace `MODEL_NAME` and `SERVICE_NAME` with current identifiers from [Language models](/en/latest/language_models). `Model.check_working_models(works_with_images=True)` helps find image-capable models; that filter does not establish PDF, video, or audio support.
## Images
Create an image scenario, reference its `image` field, and ask a question:
```python theme={null}
from edsl import Model, QuestionFreeText, Scenario
image_scenario = Scenario.from_image("image.png", image_name="image")
image_scenario["filename"] = "image.png"
image_question = QuestionFreeText(
question_name="description",
question_text="Describe the main objects in this image: {{ scenario.image }}",
)
image_model = Model("MODEL_NAME", service_name="SERVICE_NAME") # Choose an image-capable model.
image_results = image_question.by(image_scenario).by(image_model).run()
image_results.select("scenario.filename", "answer.description")
```
`image_name="image"` sets the field used in the question. Without it, `from_image()` uses the filename before its first dot. The `filename` field above is ordinary metadata retained in the results.
If you already have a FileStore, use `Scenario({"image": file})` or `file.to_scenario(key_name="image")`. This is also how you attach an image retrieved from Expected Parrot or returned by [image generation](/en/latest/filestore#generate-image-inputs).
## PDF attachments
Attach the whole PDF when the question should use its text, charts, images, and layout together. Select a model with PDF input support:
```python theme={null}
from edsl import Model, QuestionFreeText, Scenario
pdf_scenario = Scenario.from_file("report.pdf", field_name="document")
pdf_scenario["filename"] = "report.pdf"
pdf_question = QuestionFreeText(
question_name="findings",
question_text="Summarize the main findings and supporting charts in this report: {{ scenario.document }}",
)
pdf_model = Model("MODEL_NAME", service_name="SERVICE_NAME") # Choose a PDF-capable model.
pdf_results = pdf_question.by(pdf_scenario).by(pdf_model).run()
pdf_results.select("scenario.filename", "answer.findings")
```
This gives the model the document as one attachment. Use the following text or image workflows when you want a separate response for each page.
## PDF text
`ScenarioList.from_source("pdf", ...)` extracts text into one scenario per page. Each row has `filename`, `page`, and `text`; page numbers start at **1**. Text extraction uses PyMuPDF (`python -m pip install pymupdf`).
```python theme={null}
from edsl import Model, QuestionFreeText, ScenarioList
pages = ScenarioList.from_source("pdf", "report.pdf")
pages.select("filename", "page", "text")
# To use only the first page: pages = pages.filter("page == 1")
```
Ask the same question about each page's text:
```python theme={null}
page_question = QuestionFreeText(
question_name="summary",
question_text="Summarize this page: {{ scenario.text }}",
)
page_results = page_question.by(pages).by(Model()).run()
page_results.select("scenario.filename", "scenario.page", "answer.summary")
```
For all extracted text in a single Scenario, use:
```python theme={null}
from edsl import Scenario
document_text = Scenario.from_pdf("report.pdf")
document_text["text"]
# To ask the same question about the complete text:
# page_question.by(document_text).by(Model()).run()
```
`Scenario.from_pdf()` returns `filename` and `text`. These text workflows can use a text model. For scanned pages or questions about visual layout, use a PDF attachment or page images.
## PDF page images
Render each PDF page as an image when you want to ask about diagrams, layout, or scanned content page by page. This conversion uses `pdf2image` and its system dependency Poppler.
```bash theme={null}
python -m pip install pdf2image
```
Install Poppler for your operating system as described in the [pdf2image installation guide](https://pdf2image.readthedocs.io/en/latest/installation.html). Then create page scenarios and use an image-capable model:
```python theme={null}
from edsl import Model, QuestionFreeText, ScenarioList
page_images = ScenarioList.from_source("pdf_to_image", "report.pdf")
# This converter's page index starts at 0; add a familiar page number for results.
page_images = page_images.mutate("page_number = page + 1").add_value("filename", "report.pdf")
visual_question = QuestionFreeText(
question_name="visual_summary",
question_text="Describe the charts and layout on this page: {{ scenario.content }}",
)
visual_model = Model("MODEL_NAME", service_name="SERVICE_NAME")
visual_results = visual_question.by(page_images).by(visual_model).run()
visual_results.select("scenario.filename", "scenario.page_number", "answer.visual_summary")
```
The image attachment is in `content`. It contains the image data even after the converter's temporary files are cleaned up. To save a page image yourself, use `page_images[0]["content"].write("page-1.jpg")`.
`Scenario.from_pdf_to_image("report.pdf")` is an alternative that returns **one** Scenario with separate attachment fields `page_0`, `page_1`, and so on. Reference the particular page fields you want the question to receive, as in the [multiple-attachment example](#multiple-attachments-in-one-scenario).
## Video
Use a video file as a scenario input and refer to it in the question. This example uses EDSL's `google` service; choose a current model that supports [video input](https://ai.google.dev/gemini-api/docs/video-understanding) and replace `GOOGLE_MODEL_NAME`:
```python theme={null}
from edsl import Model, QuestionFreeText, Scenario
video_scenario = Scenario.from_file("clip.mp4", field_name="video")
video_scenario["filename"] = "clip.mp4"
video_question = QuestionFreeText(
question_name="events",
question_text="Describe the main events in this video in chronological order: {{ scenario.video }}",
)
video_model = Model("GOOGLE_MODEL_NAME", service_name="google")
video_results = video_question.by(video_scenario).by(video_model).run()
video_results.select("scenario.filename", "answer.events")
```
EDSL passes the video attachment through the Google service's file-upload flow. You do not need to call a separate upload method before running the question. See the provider's guide for supported formats and duration limits.
## Audio
Audio uses the same attachment pattern. For example, transcribe an MP3 using a Google model with [audio input support](https://ai.google.dev/gemini-api/docs/audio):
```python theme={null}
from edsl import Model, QuestionFreeText, Scenario
audio_scenario = Scenario.from_file("interview.mp3", field_name="audio")
audio_scenario["filename"] = "interview.mp3"
audio_question = QuestionFreeText(
question_name="transcript",
question_text="Transcribe the speech in this recording: {{ scenario.audio }}",
)
audio_model = Model("GOOGLE_MODEL_NAME", service_name="google")
audio_results = audio_question.by(audio_scenario).by(audio_model).run()
audio_results.select("scenario.filename", "answer.transcript")
```
You can instead ask about speakers, topics, or other features of the recording by changing the question. To analyze an existing transcript as text, put the transcript string in a Scenario and reference that field, as in the [qualitative coding example](/en/latest/research/qualitative).
## A collection of files
Create one scenario per file to apply the same questions across a collection. For example, reuse the image question and model from [Images](#images) with a directory of PNGs:
```python theme={null}
from edsl import ScenarioList
images = ScenarioList.from_source(
"directory", "images", pattern="*.png", recursive=True,
).rename({"file": "image"})
images.select("file_name", "file_path")
collection_results = image_question.by(images).by(image_model).run()
collection_results.select("scenario.file_path", "answer.description")
```
The directory source stores each FileStore under `file` and includes metadata such as `file_name` and `file_path`. Renaming `file` to `image` matches the field referenced by `image_question`. Change the pattern and question to work with PDFs, videos, or audio files instead. Use `metadata=False` if you only want the attachment field.
## Multiple attachments in one scenario
Put several files in the same Scenario when the model should compare them in one response. Reference each attachment explicitly:
```python theme={null}
from edsl import FileStore, Model, QuestionFreeText, Scenario
comparison = Scenario({
"record_id": "design-1",
"before": FileStore("before.png"),
"after": FileStore("after.png"),
})
comparison_question = QuestionFreeText(
question_name="changes",
question_text="Compare the original image {{ scenario.before }} with the revised image {{ scenario.after }}. What changed?",
)
comparison_model = Model("MODEL_NAME", service_name="SERVICE_NAME")
comparison_results = comparison_question.by(comparison).by(comparison_model).run()
comparison_results.select("scenario.record_id", "answer.changes")
```
A ScenarioList of two images produces separate interviews for those images. Two image fields in one Scenario let a question receive both together. The same pattern works for other combinations of files supported by the selected model.
For retrieving attachments from Expected Parrot, previewing them, or writing them to disk, see [FileStore](/en/latest/filestore#storage-and-sharing).
## Common methods
| Call | Arguments and result |
| - | - |
| `Scenario({"message": "Hello"})` | Create one row of named inputs. |
| `ScenarioList([scenario, ...])` | Collect rows for a study. |
| `ScenarioList.from_list("message", values)` | Create one scenario per value, stored under the specified field name. |
| `scenarios.select("record_id", "message")` | Return a ScenarioList with the selected fields. |
| `scenarios.filter("condition == 'short'")` | Return rows matching a Boolean expression over field names. |
| `scenarios.mutate("length = len(message)", functions_dict={"len": len})` | Return rows with a field computed from an expression. Supply custom functions explicitly. |
| `scenarios.times(other)` | Return every combination of rows from the two ScenarioLists. Use distinct field names to retain both inputs. |
This constructs four conditions without running a model:
```python theme={null}
from edsl import ScenarioList
messages = ScenarioList.from_list("message", ["Save time", "Reduce errors"])
tones = ScenarioList.from_list("tone", ["formal", "casual"])
conditions = messages.times(tones)
conditions
# In a script: conditions.print()
```
## Clean and transform rows
ScenarioList transformations let you prepare inputs before building a job. The following operates entirely on local data:
```python theme={null}
from edsl import ScenarioList
records = ScenarioList.from_list_of_dicts([
{"record_id": "r1", "message": " Save time ", "score": 4},
{"record_id": "r2", "message": "Reduce errors", "score": 2},
])
cleaned = records.transform("message", str.strip)
prepared = cleaned.transform("message", len, new_name="length").rename({"message": "text"})
prepared.select("record_id", "text", "length")
```
Assign the returned collection when using these transformations:
| Task | Methods |
| - | - |
| Select fields | `select("record_id", "text")` keeps fields; `drop("score")` removes them. `keep()` is an alias for `select()`. |
| Add fields | `add_value("source", "pilot")` adds one value to all rows; `add_list("label", labels)` adds one value per row. |
| Compute values | `mutate("doubled = score * 2")` evaluates an expression; `transform("text", str.lower, new_name="normalized")` applies a Python function to a field. Pass `functions_dict` to `mutate()` when the expression calls a function. |
| Handle missing data | `replace_values({"NA": None})` replaces matching values across fields; `fillna("")` fills missing values; `filter_na(["text"])` removes rows missing required fields. |
| Normalize imported data | `snakify()` converts field names to Python identifiers; `numberify()` converts numeric strings where possible. Inspect the result before analysis. |
| Choose rows | `filter("score >= 3")`, `order_by("score", reverse=True)`, and `slice("0:10")` filter, sort, and slice. |
| Sample or split | `sample(n=2, seed="pilot")` samples without replacement; `shuffle(seed="pilot")` changes order; `split(frac_left=0.5, seed=42)` returns two ScenarioLists. |
| Remove duplicate rows | `unique()` returns distinct rows. |
| Disambiguate repeated labels | `uniquify("label")` keeps rows and appends suffixes to repeated field values. Preserve the original record identifier separately when it is needed for joins. |
For a single Scenario, use `select()`, `drop()`, `rename({"old": "new"})`, and `replace_value("field", value)` to transform the mapping. `scenario.replicate(n)` returns a ScenarioList of copies.
## Join, reshape, and summarize
Use `left_join(other, by="record_id")`, `inner_join(...)`, or `right_join(...)` for matching records by a key, rather than `times()` for all combinations. Check key uniqueness and resulting row counts when joining.
```python theme={null}
labels = ScenarioList.from_list_of_dicts([
{"record_id": "r1", "label": "benefit"},
{"record_id": "r2", "label": "benefit"},
])
labeled = records.left_join(labels, by="record_id")
labeled.tally("label")
```
| Task | Method and result |
| - | - |
| Split long text | `scenarios.chunk("text", num_words=200, include_original=True)` returns one scenario per chunk, retaining other fields. `Scenario.chunk()` also works on a single record. |
| Turn lists into rows | `scenarios.expand("tags")` creates a row for each list item. |
| Turn nested values into columns | `unpack_dict("metadata", prefix="meta_")` expands dictionary keys; `unpack("scores", new_names=["first", "second"])` expands list positions. |
| Combine rows | `collapse("text", separator="\n")` groups on the remaining fields and combines text. |
| Convert wide to long | `unpivot(id_vars=["record_id"], value_vars=["before", "after"])` produces `variable` and `value` fields. `pivot(id_vars=["record_id"])` reverses that layout when keys are unique. |
| Aggregate | `group_by(id_vars=["condition"], variables=["score"], func=lambda scores: {"total": sum(scores)})` aggregates each group. The function receives one list per selected variable and must return a dictionary. |
| Count or query | `tally("condition")` returns counts; `sql("SELECT condition, COUNT(*) AS n FROM self GROUP BY condition")` returns a Dataset. |
`to_agent_list()` converts rows to respondent profiles; `to_dataset()` returns a table. Use `to_dicts()` for ordinary row dictionaries and `to_list()` after selecting the fields you need. For pandas, Polars, CSV, and Excel outputs, see [table conversions](/en/latest/dataset#export-and-convert-tables).
`scenarios.embed("text", model=embedding_model, cache=embedding_cache)` adds vectors for a text field. See [text embeddings](/en/latest/dataset#embed-text) for model selection, caching, and a local example.
# Run your first survey
Source: https://docs.expectedparrot.com/en/latest/starter_tutorial
Construct and run a small EDSL survey, inspect its responses, and extend it to AI or human studies.
This tutorial takes you from a single feedback question to classifying several messages and exporting the answers. You will see how questions, scenarios, models, and results fit together. It begins with a fixed local test response, then shows how to choose a language model for the same workflow.
## Install EDSL
Follow [Installation](/en/latest/installation) for supported Python versions and environment setup:
```bash theme={null}
pip install edsl
```
Run the code in a notebook or Python script. The [CLI Guide](/en/latest/cli) provides an equivalent command line workflow.
## Construct a survey and run a local test
The example asks for a service improvement and uses a test model configured to return a fixed answer. You can run it immediately without an account or model credentials.
```python theme={null}
from edsl import Cache, Model, QuestionFreeText, Survey
question = QuestionFreeText(
question_name="feedback",
question_text="What would improve this service?",
)
survey = Survey([question])
model = Model("test", canned_response="Make the export button easier to find.")
job = survey.by(model)
results = job.run(
cache=Cache(),
disable_remote_inference=True,
disable_remote_cache=True,
progress_bar=False,
)
# In a notebook, leave this as the last expression to display the table.
# In a script: results.select("answer.feedback").print()
# For a Python list: results.select("answer.feedback").to_list()
results.select("answer.feedback")
```
The displayed table contains one `answer.feedback` value: “Make the export button easier to find.”
The `Survey` defines the questions, the `Model` supplies an execution configuration, `by()` creates a job, and `run()` returns a Results object. `select()` extracts a Dataset with the requested answer column. This fixed response tests the workflow; it is not a research observation.
## Vary inputs with scenarios
Replace the single free-text task with a classification question applied to several feedback records. Construction alone makes no model calls:
```python theme={null}
from edsl import QuestionMultipleChoice, Scenario, ScenarioList, Survey
question = QuestionMultipleChoice(
question_name="sentiment",
question_text="Classify the sentiment of this feedback: {{ scenario.feedback }}",
question_options=["Positive", "Neutral", "Negative"],
)
feedback = ScenarioList([
Scenario({"record_id": "f1", "feedback": "The setup was easy."}),
Scenario({"record_id": "f2", "feedback": "I couldn't find the export button."}),
Scenario({"record_id": "f3", "feedback": "It worked as expected."}),
])
job = question.by(feedback)
# by(): one interview per input, with the same answer.sentiment column.
# loop(): three distinct questions in one survey, with the text substituted now.
variants = question.loop(feedback)
variant_survey = Survey(variants)
```
`job` contains three interviews, one for each feedback record. `variant_survey` demonstrates the alternative of constructing multiple distinct questions within one survey; see [Scenarios](/en/latest/scenarios) for when to use each approach.
## Log in to Expected Parrot
To use Expected Parrot's model access from a notebook, run:
```python theme={null}
from edsl import login
login()
```
Follow the sign-in link in the cell output. EDSL retrieves and stores your API key automatically. For login options or direct provider access, see [Model access and API keys](/en/latest/api_keys).
## Choose a language model
Inspect the current model catalog with `Model.available()` or `ep models --text`. Select a model appropriate for the task and review [costs](/en/latest/costs).
Calling `Model()` without a name selects your configured default model. You can inspect its name and parameters:
```python theme={null}
from edsl import Model
model = Model()
{"model": model.model, "parameters": model.parameters}
# In a script, wrap this expression in print() to display it.
```
To choose a specific model instead, pass its name to `Model()` and its provider's service name as `service_name`. Replace both placeholders below with the corresponding identifiers from the catalog:
```python theme={null}
model = Model("MODEL_NAME", service_name="SERVICE_NAME")
```
Model names and availability change, so use the current catalog values. You can also pass parameters supported by your chosen model as keyword arguments; see [Language models](/en/latest/language_models) for configuration details.
Once you have chosen either the default or a specific model, apply it to the job and run the survey. This makes model calls and may incur usage costs:
```python theme={null}
results = job.by(model).run()
```
Expected output is three completed interview records, each with an `answer.sentiment` value from the allowed options. Actual labels depend on the model and inputs; inspect errors and completion counts before analysis.
## Inspect and export responses
```python theme={null}
table = results.select("scenario.record_id", "scenario.feedback", "answer.sentiment")
# Display in a notebook: put table on the last line of a cell.
# Display in a script: table.print()
# Convert to pandas when you need DataFrame operations.
frame = table.to_pandas()
table.to_csv("feedback_labels.csv")
```
Keep the record identifier beside each answer so you can join classifications to source data or human labels. See [Results](/en/latest/results) and [Dataset](/en/latest/dataset) for filtering, comparison, and export.
## Extend the study
* [Survey logic and piping](/en/latest/guides/survey_logic): branch on answers and supply previous responses as context.
* [Creating and varying agents](/en/latest/agents): specify controlled respondent profiles.
* [Model comparisons](/en/latest/guides/model_comparisons): apply different models to matched inputs.
* [Human studies](/en/latest/guides/human_studies): publish a survey and collect responses from people.
* [Saving and sharing objects](/en/latest/guides/saving_objects): preserve a reusable study specification and outputs.
# Builder
Source: https://docs.expectedparrot.com/en/latest/survey_builder
Create, import, and edit surveys and interview guides visually, using the same research objects as the Research Agent and EDSL.
The Builder is Expected Parrot's visual interface for creating and editing studies. Use it to build a survey or interview guide, import an existing questionnaire, or review and refine studies created by the [Research Agent](/en/latest/research_agent).
[Open the Builder](https://www.expectedparrot.com/polly) after signing in to your Expected Parrot account.
## Create or import a study
Start with a new study, import an existing questionnaire, or open a saved study. When importing, review the resulting questions, response options, and logic against the original questionnaire before collecting responses.
You can also ask the Research Agent to draft a study, then use the Builder to edit its structured objects directly. Both use [EDSL](/en/latest/overview), so you can export the study to code for further customization.
## Edit and preview
Review question wording, response types, answer options, order, and conditional logic. Configure the respondent settings for your study and preview the participant experience. Test any branching or follow-up behavior before launch.
Use the Builder to make targeted changes, such as revising a scale, changing answer options, or adjusting a question in a study drafted by the Research Agent.
## Choose your research workflow
### Human surveys
Create a web survey for people to complete and share its respondent link with your participants. You can conduct a human study without first running a simulation. For code-based workflows and recruitment options, see [Humanize](/en/latest/humanize) and [Prolific studies](/en/latest/prolific).
### AI-led interviews
Create or edit an interview guide that specifies topics, prompts, and follow-up instructions. The AI interviewer follows the guide and can ask relevant follow-up questions based on a participant's answers. Preview the interview and review the response modes supported by your study settings before sharing it with participants.
The [Research Agent](/en/latest/research_agent) can help draft the interview guide and analyze the resulting material.
### AI simulations
Define AI respondent profiles, select language models, and run your study with simulated respondents. Review [estimated costs](/en/latest/costs) and pilot the study before scaling it up. Keep simulated responses distinguishable from human observations when analyzing or reporting results.
## Analyze and share
Review collected responses, export data for further analysis, or ask the Research Agent to help compare results and prepare a report. Use EDSL's [Results](/en/latest/results) methods when you want to work programmatically.
Share study materials with collaborators so they can inspect the design. Use the respondent link to invite participants to complete the study.
## Continue in EDSL
Export studies to EDSL code when you need custom analyses, automation, or integration with other tools. Follow [first steps](/en/latest/index#start-here) to set up Python and the [Run your first survey](/en/latest/starter_tutorial) tutorial to learn how questions, surveys, agents, scenarios, and models fit together.
For questions or feedback about the Builder, email [info@expectedparrot.com](mailto:info@expectedparrot.com).
# Versioning Surveys with .ep Files
Source: https://docs.expectedparrot.com/en/latest/survey_ep_format
A hands-on tour of the Git-backed .ep format for EDSL Surveys: package contents, commits, diffs, history, and efficient collaboration.
An EDSL `Survey` can be saved either as serialized JSON or as an `.ep`
package. JSON is useful for a quick interchange. An `.ep` package is intended
for work that will evolve: it keeps the survey's current state and its Git
history together in one portable file.
Use version history when you want to compare pilot and final wording, recover
an earlier instrument, or combine collaborators' edits. A **commit** is a saved
version, a **diff** shows what changed, a **tag** names a milestone, and a
**branch** keeps an alternative version alongside the main one.
This tutorial saves an example survey, revises a question, and retrieves an
earlier version. The package internals and Git collaboration sections are
optional detail; [Saving objects](/en/latest/guides/saving_objects) covers
ordinary saving and loading without version-management commands.
## The format in one picture
```text theme={null}
example-survey.ep one portable ZIP-compatible file
└── unpacked contents a complete Git working tree
├── .git/ commits, trees, blobs, refs, and configuration
├── manifest.json package type, format version, and question order
├── questions/
│ ├── 000001.json one question per file
│ ├── 000002.json
│ └── 000003.json
└── metadata/
├── memory_plan.json
├── question_groups.json
└── rule_collection.json
```
There are two layers to keep straight:
* On disk, `example-survey.ep` is a single ZIP-compatible archive. It is easy
to attach, copy, archive, or deposit in a replication package.
* Inside the archive is a real Git repository. EDSL uses that repository for
commits, history, diffs, branches, tags, and synchronization.
The `.ep` suffix therefore does not mean “a JSON file with another extension.”
It means “a portable EDSL object with its version history.”
## Create the example Survey
Start with EDSL's built-in example:
```python theme={null}
from edsl import Survey
survey = Survey.example()
survey
```
The example has three multiple-choice questions named `q0`, `q1`, and `q2`.
Save it as a package with an informative commit message:
```python theme={null}
first_save = survey.git.save(
"example-survey.ep",
message="Create example survey",
)
first_save
```
A successful save returns package and commit information:
```python theme={null}
{
"status": "ok",
"path": "example-survey.ep",
"commit": "65801c7e03eb93c0f31c8bee6b2a29cc9152be14",
"branch": "main",
"message": "Create example survey",
}
```
Your commit hash will differ. Git derives it from the commit and its contents,
so it is an identifier for that particular version.
Calling `save()` again without changing the survey does not create an empty
commit:
```python theme={null}
survey.git.save(message="Save again")
```
```python theme={null}
{
"status": "unchanged",
"path": "example-survey.ep",
"commit": "...",
"branch": "main",
"message": "no changes",
}
```
This makes repeated saves safe: history records substantive package changes,
not every call to `save()`.
## Inspect the package as an EDSL object
For ordinary use, let EDSL inspect and load the file:
```bash theme={null}
ep inspect example-survey.ep
```
The CLI writes a JSON envelope to stdout:
```json theme={null}
{
"status": "ok",
"data": {
"object_type": "Survey",
"length": 3,
"question_count": 3,
"question_names": ["q0", "q1", "q2"],
"question_types": [
"multiple_choice",
"multiple_choice",
"multiple_choice"
]
},
"warnings": []
}
```
Load the latest committed version in Python with:
```python theme={null}
loaded = Survey.git.load("example-survey.ep")
loaded == survey
```
```python theme={null}
True
```
Loading binds the object to its package. A later
`loaded.git.save(message="...")` writes a new version back to the same `.ep`
file unless a different path is supplied.
## Unpack the Git repository
You do not need to unpack a package to use it. Unpacking is useful when you
want to audit the representation or use lower-level Git commands:
```bash theme={null}
ep unpack example-survey.ep
```
The returned JSON includes a temporary directory in `data.path` and a
`next_step` command. Change into that directory:
```bash theme={null}
cd /tmp/path-returned-by-ep-unpack
git status
git log --oneline --decorate
```
> [!WARNING]
>
> The directory returned by `ep unpack` is a temporary inspection copy. Editing
> it does not rewrite the original `.ep` archive. Make durable changes through
> the loaded EDSL object and call `save()`.
Because `.ep` is ZIP-compatible, a standard archive utility can also confirm
what is present:
```bash theme={null}
unzip -l example-survey.ep
```
The listing includes `.git/HEAD`, `.git/objects/`, `.git/refs/heads/main`, the
manifest, question files, and survey metadata. EDSL omits transient Git files
such as hooks when packing the archive.
## Read the working-tree files
The manifest identifies the format and reconstructs question order:
```json theme={null}
{
"edsl_class_name": "Survey",
"edsl_version": "1.0.8.dev1",
"format": "edsl.survey.git_package",
"format_version": 1,
"n_questions": 3,
"object_type": "Survey",
"question_order": [
"000001",
"000002",
"000003"
]
}
```
The exact `edsl_version` reflects the installed version that wrote the
package.
Each question is stored separately. For example,
`questions/000001.json` contains:
```json theme={null}
{
"question_name": "q0",
"question_options": [
"yes",
"no"
],
"question_text": "Do you like school?",
"question_type": "multiple_choice"
}
```
The split representation is deliberate. If a survey has 500 questions and
one changes, Git does not need to treat one giant serialized survey document
as the unit of review. The diff can focus on the affected question and any
metadata that actually changed.
The files under `metadata/` preserve behavior that is not contained in an
individual question:
* `rule_collection.json` stores survey flow, including skip and stop rules.
* `memory_plan.json` records which earlier answers are made available later.
* `question_groups.json` records named groups.
Optional survey properties, when present, are stored in additional metadata
files such as `name.json`, `questions_to_randomize.json`, and
`options_to_pin.json`.
## Make and commit a change
Change the wording of the first question:
```python theme={null}
survey = Survey.git.load("example-survey.ep")
survey.questions[0].question_text = "Do you enjoy school?"
second_save = survey.git.save(
message="Clarify the first question",
)
second_save
```
The package remains one file, but it now contains two commits. View them
without unpacking:
```python theme={null}
survey.git.log()
```
```python theme={null}
[
{
"commit": "d1f44ff624288e48e8835597d247c039ceffeb48",
"message": "Clarify the first question",
"timestamp": "2026-07-29 07:47:56 -0400",
},
{
"commit": "65801c7e03eb93c0f31c8bee6b2a29cc9152be14",
"message": "Create example survey",
"timestamp": "2026-07-29 07:47:34 -0400",
},
]
```
Again, hashes and timestamps will differ.
Now ask Git what changed:
```python theme={null}
# 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(survey.git.diff("HEAD~1", "HEAD"))
```
The essential part of the diff is:
```diff theme={null}
diff --git a/questions/000001.json b/questions/000001.json
index 353b419..33b47da 100644
--- a/questions/000001.json
+++ b/questions/000001.json
@@
- "question_text": "Do you like school?",
+ "question_text": "Do you enjoy school?",
```
Three identifiers are doing different jobs:
* `0` is the question's current position in the Survey.
* `q0` is its EDSL `question_name`. Prompts, rules, and results use this name
to refer to the logical question.
* `000001` is its stable package file ID. It is not a position and it is not
the public question name.
The manifest and the other two question files do not change. A genuinely new
question receives the next available package ID; deleting a question removes
its file from the current tree while retaining it in Git history.
The package ID also survives a pure `question_name` rename:
```python theme={null}
survey.git.branch("rename-demo")
survey.git.switch("rename-demo")
survey = survey.with_renamed_question("q0", "school_enjoyment")
survey.git.save("example-survey.ep", message="Rename q0")
```
```diff theme={null}
diff --git a/questions/000001.json b/questions/000001.json
- "question_name": "q0",
+ "question_name": "school_enjoyment",
```
That is why the package needs an identity separate from `question_name`:
renaming a question is a one-line edit inside a stable file, not a file rename
and not a new question.
Return to the unchanged main line before continuing:
```python theme={null}
survey.git.switch("main")
```
## What Git stores
The `.git` directory does not contain a second full copy of the survey for
every version. Git stores content-addressed objects:
* **blobs** hold file contents;
* **trees** describe directory snapshots;
* **commits** point to a tree, a parent commit, and commit metadata;
* **refs** such as `main` and tags give memorable names to commits.
From the unpacked repository, inspect the current commit:
```bash theme={null}
git cat-file -p HEAD
```
Representative output:
```text theme={null}
tree
parent
author EDSL ...
committer EDSL ...
Clarify the first question
```
Follow the tree to a stored file:
```bash theme={null}
git ls-tree HEAD
git ls-tree HEAD:questions
git show HEAD:questions/000001.json
git show HEAD~1:questions/000001.json
```
The last two commands read different survey versions directly from Git
without changing the working tree.
Git addresses every stored object by a hash of its contents. Files that did
not change are reused by the new tree instead of being stored as new logical
versions. When repositories are packed or transferred, Git can also delta
compress similar objects. This is why Git can communicate a small survey
revision without treating the entire history as an opaque replacement file.
The `.ep` archive itself is repacked after a save, so copying the file with a
generic file-copy tool still copies the whole archive. The transfer
efficiency comes into play when the embedded repository is synchronized with
a Git remote through EDSL's push and pull operations.
## Load or render an older version
Save the first commit ID before making further changes:
```python theme={null}
first_commit = first_save["commit"]
```
Load that exact historical survey:
```python theme={null}
original = Survey.git.load(
"example-survey.ep",
ref=first_commit,
)
original.questions[0].question_text
```
```python theme={null}
"Do you like school?"
```
The current version remains:
```python theme={null}
current = Survey.git.load("example-survey.ep")
current.questions[0].question_text
```
```python theme={null}
"Do you enjoy school?"
```
The CLI can also render a historical version to HTML:
```bash theme={null}
ep open example-survey.ep --ref --output original-survey.html
```
This is useful in review or replication: a commit ID identifies the exact
survey being discussed, even after the package has moved forward.
## Name important versions
Commit IDs are precise but not memorable. Add a Git tag to a milestone:
```python theme={null}
survey.git.tag(
"pilot-v1",
message="Survey used for the first pilot",
)
```
List tags:
```python theme={null}
survey.git.tags()
```
```python theme={null}
["pilot-v1"]
```
Tags are helpful for statements such as “the preregistered instrument was
`pilot-v1`” or “production used `field-v2`.” A verifier can resolve the tag to
the exact commit and inspect the difference between milestones.
## Explore alternatives with branches
Branches let collaborators develop alternative instruments without
overwriting the main line:
```python theme={null}
survey.git.branch("without-q1")
survey.git.switch("without-q1")
survey.delete_question("q1")
survey.git.save(message="Create variant without q1")
```
Deleting one question changes more than one file:
```python theme={null}
print(survey.git.diff("--stat", "HEAD~1", "HEAD"))
```
```text theme={null}
manifest.json | 3 +--
metadata/memory_plan.json | 2 --
metadata/rule_collection.json | 6 +++---
questions/000002.json | 9 ---------
4 files changed, 4 insertions(+), 16 deletions(-)
```
`questions/000002.json` is deleted, not renumbered. The other question files
retain their IDs. The manifest changes because the question order no longer
includes `000002`; the memory plan and flow rules change because they describe
relationships among the remaining questions. This is why metadata is split
into named files rather than buried in one survey blob: Git shows exactly
which parts of survey behavior changed.
Methods that return a `changed` list use “changed” in Git's broad sense:
touched paths, including additions, modifications, and deletions.
This branch represents a genuine alternative instrument, so do not merge it
merely because it exists. Keep it parallel, field or review it independently,
and return to `main`:
```python theme={null}
survey.git.switch("main")
```
`switch()` refreshes the in-memory `Survey` from the selected branch. EDSL
requires a clean package before switching, pulling, or checking out another
version, which protects uncommitted changes.
## Merge independent work
Merging is appropriate when reviewed work belongs back on the main survey.
The useful case is not a sequential edit forced into a merge commit. It is two
authors making divergent commits from the same base.
Alice and Bob begin with the current `main`. Create Bob's branch before either
person edits:
```python theme={null}
survey.git.branch("bob-options")
```
Bob changes only the options for `q2`:
```python theme={null}
survey.git.switch("bob-options")
survey.questions[2].question_options.append("not sure")
survey.git.save(message="Bob: add a q2 response option")
```
Meanwhile, Alice changes only the wording of `q0` on `main`:
```python theme={null}
survey.git.switch("main")
survey.questions[0].question_text = "Do you enjoy attending school?"
survey.git.save(message="Alice: clarify q0 wording")
```
The histories now diverge. Neither commit is an ancestor of the other, and the
edits live in different question files:
```text theme={null}
* Alice: clarify q0 wording questions/000001.json
| * Bob: add a q2 response option questions/000003.json
|/
* Clarify the first question
```
Now perform a genuine three-way merge:
```python theme={null}
merge_info = survey.git.merge(
"bob-options",
message="Merge Bob's reviewed options",
)
merge_info
```
```python theme={null}
{
"status": "ok",
"path": "example-survey.ep",
"commit": "",
"branch": "main",
"merged_ref": "bob-options",
"fast_forward": False,
"changed": ["questions/000003.json"],
"conflicts": [],
"conflict_contents": {},
"aborted": False,
"message": "Merge Bob's reviewed options",
}
```
No `no_ff=True` is needed: Alice's and Bob's commits are genuinely divergent,
so Git must reconcile them. `changed` lists the paths introduced to Alice's
pre-merge `main`; her `q0` edit was already there, while Bob's `q2` edit arrives
through the merge.
The method completes all three layers of the operation: it merges the embedded
repository, reloads the merged files into the in-memory `Survey`, and repacks
the repository into `example-survey.ep`. The object is ready to use
immediately:
```python theme={null}
survey.question_names
```
```python theme={null}
["q0", "q1", "q2"]
```
Both authors' changes survive:
```python theme={null}
survey.questions[0].question_text
survey.questions[2].question_options
```
```python theme={null}
"Do you enjoy attending school?"
["**lack*** of killer bees in cafeteria", "other", "not sure"]
```
From an unpacked inspection copy, Git can display the topology:
```bash theme={null}
git log --graph --oneline --all
```
```text theme={null}
* Merge Bob's reviewed options
|\
| * Bob: add a q2 response option
* | Alice: clarify q0 wording
|/
* Clarify the first question
```
This is the payoff from storing questions separately. Git performs a real
three-way merge, sees that Alice and Bob changed disjoint files, and retains
both changes without human intervention. With one large `survey.json`, Git
would be reconciling edits inside the same 4,000-line blob.
### What happens on a conflict
Now repeat the experiment with both authors editing `q0`. Starting from a
one-question package makes the conflict especially clear:
```python theme={null}
from edsl import QuestionFreeText, Survey
wording = Survey([
QuestionFreeText(
question_name="q0",
question_text="Original wording",
)
])
wording.git.save("wording.ep", message="Shared wording")
wording.git.branch("bob-wording")
wording.git.switch("bob-wording")
wording.questions[0].question_text = "Bob's wording"
wording.git.save(message="Bob: revise q0")
wording.git.switch("main")
wording.questions[0].question_text = "Alice's wording"
wording.git.save(message="Alice: revise q0")
conflict = wording.git.merge("bob-wording")
```
```python theme={null}
{
"status": "conflict",
"path": "wording.ep",
"commit": "",
"branch": "main",
"merged_ref": "bob-wording",
"fast_forward": False,
"changed": [],
"conflicts": ["questions/000001.json"],
"conflict_contents": {
"questions/000001.json": ""
},
"aborted": True,
"message": "merge of bob-wording aborted due to conflicts",
}
```
EDSL captures the conflicted file before aborting. Its `question_text` conflict
is local and legible:
```diff theme={null}
{
"question_name": "q0",
<<<<<<< HEAD
"question_text": "Alice's wording",
=======
"question_text": "Bob's wording",
>>>>>>> bob-wording
"question_type": "free_text"
}
```
This is a conflict that a reviewer can understand and resolve. It is isolated
to one small question file rather than buried among thousands of unrelated
survey lines. The marker lines are indented one space above only so Git does
not mistake this documentation example for an unresolved repository conflict.
EDSL then aborts the merge, leaving the current branch and `.ep` archive
unchanged and clean. A researcher can use `conflict_contents` to inspect the
two alternatives, revise one branch, and try again. The API never silently
chooses a survey design or leaves the portable package in a half-merged state.
## Communicate only the changes
An `.ep` file is a self-contained handoff: its recipient gets both the current
Survey and its history. For ongoing collaboration, attach a GitHub remote and
push:
```python theme={null}
survey.git.remote_add("origin", "git@github.com:team/research-survey.git")
survey.git.push("origin")
```
What lands on GitHub is the unpacked tree of readable JSON, not an opaque ZIP
diff. A collaborator can review a one-line question-wording change as an
ordinary pull request. Git negotiates which objects the receiver already has
and transfers the missing objects, while EDSL repacks the history into `.ep`
when it returns to the portable-file workflow. Zipping does not defeat the
point: the archive is the transport artifact; the embedded repository is the
collaboration artifact.
## Working inside another Git repository
An `.ep` archive is a single file, so an outer Git repository sees it as a binary file. It does not treat the archive itself as an embedded repository. Tracking that file preserves snapshots but does not expose its internal question-level diffs in the outer repository.
The unpacked working tree contains a `.git` directory. If that working tree is inside another repository, decide deliberately whether to ignore it or manage it as a submodule. Do not mistake edits in a temporary unpacked copy for saved changes to the original package.
Use exported JSON when ordinary text review in an outer repository is the goal. Use the package's own Git history when you need versioned EDSL objects and their internal diffs.
## Why this design is useful
For survey research, a Git-backed object provides several practical
guarantees:
1. **Exact references.** A commit hash identifies the complete survey,
including questions, order, rules, memory, and groups.
2. **Readable review.** Questions and metadata are formatted JSON, so a
reviewer can see wording and logic changes as ordinary diffs.
3. **Focused changes.** Unchanged questions retain their stored objects;
revisions affect the relevant question and dependent metadata.
4. **Recoverable history.** Old instruments remain loadable even after the
working version changes.
5. **Named milestones.** Tags can identify preregistration, pilot, field, and
publication versions.
6. **Parallel development.** Branches can hold experimental variants.
7. **Efficient synchronization.** Git remotes exchange missing objects rather
than treating every revision as an unrelated dataset.
8. **Portable provenance.** Copying one `.ep` file carries the current object
and its complete embedded history.
The result works at two levels. Researchers use a normal EDSL `Survey`;
reviewers and collaborators see ordinary Git history and readable diffs.
Agents can understand and operate the versioning plumbing so researchers do
not have to—but the repository remains available whenever a human needs to
audit exactly what changed.
## Command reference
```python theme={null}
from edsl import Survey
# Create and save
survey = Survey.example()
info = survey.git.save("example-survey.ep", message="Initial survey")
# Load latest or historical state
latest = Survey.git.load("example-survey.ep")
older = Survey.git.load("example-survey.ep", ref=info["commit"])
# Inspect history and changes
latest.git.status()
latest.git.log()
latest.git.diff() # Working changes; use two commit IDs for a historical comparison.
# Mark and navigate versions
latest.git.tag("pilot-v1", message="Pilot instrument")
latest.git.branch("alternative")
latest.git.switch("alternative")
# Edit and save the alternative, then merge it into main.
latest.git.switch("main")
latest.git.merge("alternative", message="Merge alternative", no_ff=True)
# Validate and synchronize
latest.git.validate()
latest.git.remotes()
latest.git.push()
latest.git.pull()
```
```bash theme={null}
# Inspect without writing
ep inspect example-survey.ep
# Unpack a temporary audit copy
ep unpack example-survey.ep
# Render the latest or a historical version
ep open example-survey.ep --output survey.html
ep open example-survey.ep --ref --output old-survey.html
# Use the package in a job
ep run --survey example-survey.ep --model test --output results.ep
```
# Survey messages
Source: https://docs.expectedparrot.com/en/latest/survey_messages
Use SurveyMessage to add display-only, routable pages such as introductions, consent text, transitions, eligibility notices, and completion messages to an EDSL Survey.
`SurveyMessage` adds a page of information to a survey without asking for an answer. It has a `question_name` and a position in the survey, so you can show it at the start, between sections, or on a particular branch.
Use it for introductions, consent or privacy information, section transitions, eligibility notices, and thank-you pages.
When you need a recorded choice, such as agreement to participate, use a question with explicit answer options alongside the information. A message's continuation value records that the step was visited, rather than a substantive answer.
## Basic usage
```python theme={null}
from edsl import QuestionFreeText, Survey, SurveyMessage
welcome = SurveyMessage(
question_name="welcome",
question_text="Welcome. This survey takes about two minutes.",
)
feedback = QuestionFreeText(
question_name="feedback",
question_text="What could we improve?",
)
thanks = SurveyMessage(
question_name="thanks",
question_text="Thank you for your feedback.",
)
survey = Survey([welcome, feedback, thanks])
```
This defines a welcome page, one feedback question, and a closing page. A supported human survey interface displays the messages with **Continue** or **Finish**, without an answer box.
## Routing to a message
Because a message is a normal survey node, rules can route respondents to it by name. This example sends ineligible respondents to a terminal message while eligible respondents continue to a follow-up question:
```python theme={null}
from edsl import EndOfSurvey, QuestionFreeText, QuestionYesNo, Survey, SurveyMessage
eligible = QuestionYesNo(
question_name="eligible",
question_text="Are you at least 18 years old?",
)
follow_up = QuestionFreeText(
question_name="experience",
question_text="Tell us about your experience.",
)
ineligible = SurveyMessage(
question_name="ineligible",
question_text="You are not eligible for this survey. Thank you for your time.",
)
survey = Survey([eligible, follow_up, ineligible])
survey.add_rule(
"eligible",
"{{ eligible.answer }} == 'No'",
next_question="ineligible",
)
survey.add_rule("experience", "True", next_question=EndOfSurvey)
```
The message participates in ordinary navigation, including back navigation and resume behavior in clients that support those features.
## Running with language models
The following uses the routing example above and a fixed test response of `No`; it makes no paid model calls. When an EDSL job reaches a `SurveyMessage`, EDSL records a deterministic response and continues without calling the language model:
```python theme={null}
from edsl import Cache, Model
results = survey.by(Model("test", canned_response="No")).run(
cache=Cache(), disable_remote_inference=True,
disable_remote_cache=True,
)
results.select("answer.ineligible")
```
If the message was visited, its recorded answer is `"continued"`. If routing skipped it, it has no response. The deterministic value makes the visited path observable in `Results` without spending inference tokens on display-only content.
## `SurveyMessage` versus `Instruction`
| Use | `SurveyMessage` | `Instruction` |
| - | - | - |
| Show a standalone page to a respondent | Yes | No |
| Route to or from the item by question name | Yes | No |
| Record that the item was visited | Yes, as `"continued"` | No |
| Supply context to later model questions | No | Yes |
Use `Instruction` when text should become model context for later questions. Use `SurveyMessage` when the text is itself a visible step in the survey experience.
For example, put an instruction before the questions it should affect:
```python theme={null}
from edsl import Instruction, QuestionFreeText, Survey
context = Instruction(name="context", text="Evaluate the proposed service as a new customer.")
feedback = QuestionFreeText(question_name="feedback", question_text="What is unclear?")
survey = Survey([context, feedback])
```
When assembling a survey incrementally, `survey.add_instruction(instruction)` appends an instruction at the current point; add the subsequent questions after it. Instructions do not produce answer columns.
## Human survey clients
Preview the survey with `survey.preview()` to check that the hosted interface displays the messages and follows the intended routes. Human survey interfaces must support the `survey_message` type. Messages need no answer-format settings in the Humanize schema because they collect no respondent input.
# Surveys
Source: https://docs.expectedparrot.com/en/latest/surveys
Assemble questions, control survey flow, and prepare surveys for AI or human respondents.
A `Survey` is your research instrument: it groups questions and defines their order, branching, and dependencies. It can collect a rating and explanation, apply several codes to a document, or guide a longer interview. The survey holds the questions and rules; completed answers belong to a [Results](/en/latest/results) object.
You can reuse the same survey with different scenarios, agent profiles, or models, or publish it for human participants. These choices determine who answers and what material they see without requiring you to rewrite the instrument.
For [data labeling](/en/latest/guides/data_labeling), a survey might ask about each item's topic, sentiment, and urgency; the answers become its labels. An exploratory survey can also help you discover useful categories before you construct a structured labeling survey.
## Assemble questions
Construct questions before passing them to `Survey([...])`. Each question must have a unique name. See [Questions](/en/latest/questions) for choosing types; the survey controls how those questions work together.
## Logic, piping, and memory
This example stops after the first question for respondents who have not used a feature. Otherwise, it pipes their description into a follow-up and supplies the earlier answer as memory. Constructing the survey makes no model calls.
```python theme={null}
from edsl import QuestionYesNo, QuestionFreeText, Survey
used = QuestionYesNo(question_name="used", question_text="Have you used the export feature?")
problem = QuestionFreeText(
question_name="problem",
question_text="What problem, if any, did you encounter when exporting?",
)
followup = QuestionFreeText(
question_name="followup",
question_text="What would resolve this problem: {{ problem.answer }}?",
)
survey = (
Survey([used, problem, followup])
.add_stop_rule(used, "{{ used.answer }} == 'No'")
.add_targeted_memory(followup, problem)
)
```
A stop rule is evaluated after its question. A skip rule can bypass an individual question before it is asked. Write expressions using named answers, and test each possible route. Piping inserts an answer in question text; memory includes a previous question and answer in the context supplied to the model.
See [Survey logic and piping](/en/latest/guides/survey_logic) for testing routes and [Survey messages](/en/latest/survey_messages) for instructions shown between questions.
## Prepare a run
Combine a survey with scenarios, agents, and models using `by()` to construct a [Jobs](/en/latest/jobs) object. For example, `survey.by(messages).by(model)` prepares one interview per message scenario; calling `.run()` collects the answers. Use `survey.run()` when you want to ask it once with the default model and no custom profiles or scenarios. See [Model access](/en/latest/api_keys) for connecting a model.
For human participants, use [Human studies](/en/latest/guides/human_studies). A web survey requires an Expected Parrot account.
## Edit and inspect a survey
Access a question by name with `survey.get("feedback")` or `survey["feedback"]`. Use `survey.question_names` to inspect the order. To revise wording while keeping the original survey:
```python theme={null}
from edsl import QuestionFreeText, Survey
survey = Survey([
QuestionFreeText(question_name="feedback", question_text="What should improve?"),
])
revised = survey.with_edited_question(
"feedback", {"question_text": "What one change would help you most?"}
)
renamed = revised.with_renamed_question("feedback", "priority")
renamed.codebook()
```
`with_renamed_question()` also updates references to the question. `survey.rename({"old_name": "new_name", ...})` renames multiple questions. `codebook()` returns a dictionary of names and question text.
| Task | Method and behavior |
| - | - |
| Move or remove a question | `survey.move_question("feedback", new_index=0)` and `survey.delete_question("feedback")` modify the survey. |
| Build a smaller questionnaire | `survey.subset("q1", "q2")` keeps those questions; `survey.drop("q3")` removes named questions. Both return a new Survey. Reapply and check routing, instructions, and memory for the new instrument. |
| Inspect question definitions as rows | `survey.to_dataset()` or `survey.to_scenario_list()`; `survey.select("question_name", "question_text")` selects columns of a table, not a smaller Survey. |
| Inspect routing | `survey.show_rules()` displays rules; `survey.show_flow()` renders a flow diagram and may require Graphviz. |
| Export for review | `survey.to_markdown()` returns text; `survey.to_docx("survey.docx")` and `survey.to_html(filename="survey.html")` return FileStore objects and write the files. |
| Prepare execution without added components | `survey.to_jobs()` returns Jobs. `survey.run(...)` and `await survey.run_async(...)` execute the survey with run options. |
For grouped human survey pages, use `survey.add_question_group(start_question, end_question, group_name)`; `get_question_group(question)` reports a question's group. See [Question groups](/en/latest/humanize_groups) for grouping constraints.
## Import, save, and reuse
Use the [Importing data](/en/latest/guides/importing_data) guide to load data for questions and respondent profiles. Review imported questionnaire text, answer types, and branching before fielding it. See the [Builder guide](/en/latest/survey_builder) for visual questionnaire import; use the installed CLI's `ep surveys --help` for available survey operations.
Save a survey as an `.ep` package and reopen it in another session. See [Saving and sharing objects](/en/latest/guides/saving_objects) and [Survey package format](/en/latest/survey_ep_format) for the basic workflow and format details.
## Common methods
| Call | Arguments and result |
| - | - |
| `Survey([question, ...])` | Create a survey with questions in the supplied order. |
| `survey.add_question(question, index=None)` | Insert a question at the given index, or append it when the index is omitted; return the survey. |
| `survey.add_skip_rule(question, expression)` | Skip the named question when the expression is true before it is asked. |
| `survey.add_stop_rule(question, expression)` | End the survey when the expression is true after the named question. |
| `survey.add_targeted_memory(focal_question, prior_question)` | Include a prior question and answer in the focal question's model context. |
| `survey.by(agents).by(scenarios).by(model)` | Return a Jobs object configured with those study components. |
| `survey.save("survey.ep")` / `Survey.load("survey.ep")` | Save and restore the survey definition locally. |
Rule and memory methods accept question names or question objects. They return the survey so you can chain calls. For example, this constructs a two-question survey with a stop rule:
```python theme={null}
from edsl import QuestionYesNo, QuestionFreeText, Survey
used = QuestionYesNo(question_name="used", question_text="Have you used this service?")
feedback = QuestionFreeText(question_name="feedback", question_text="What should improve?")
survey = Survey([used, feedback]).add_stop_rule("used", "{{ used.answer }} == 'No'")
```
For model runs, call `run()` on the configured job; see [Jobs](/en/latest/jobs#execution-options) for execution options. For a participant link, follow [Human surveys](/en/latest/humanize).
# Teaching guide
Source: https://docs.expectedparrot.com/en/latest/teaching
Teach study design, simulations, human research, and reproducible analysis with EDSL.
EDSL assignments let students make study design choices explicit in code, run AI simulations, and compare findings with human evidence. Students can also collect human responses as the primary source of data.
Start with [first steps](/en/latest/index#start-here) and the [Run your first survey](/en/latest/starter_tutorial) tutorial. The tutorial covers a first survey and test responses; the object and task guides extend it to agents, experimental conditions, survey logic, and model comparisons. [Colab setup](/en/latest/colab_setup) provides a notebook environment without a local installation.
## Set up your course
Local construction and test-model exercises require no account. For hosted model execution or human data collection, students need Expected Parrot access. Contact [info@expectedparrot.com](mailto:info@expectedparrot.com) about academic support and usage credits for your class.
Define the learning objectives, required deliverables, and limits on model usage or study size. Have students estimate [costs](/en/latest/costs) and run a small pilot before scaling up a simulation.
## Use a playbook for an assignment
A [playbook](/en/latest/research_agent#using-playbooks) gives students reusable instructions for a project or method. Specify the inputs students should provide, the decisions they must justify, and the evidence they should submit. Students can follow the same process while choosing different topics or datasets.
For example, provide the following instructions for a message-comparison assignment:
> Ask me for three advertising messages, a product description, and two audience definitions. Help me formulate a research question and design a short survey to compare the messages. Explain what each question measures. Let me review the survey and proposed models, then estimate the cost of a small AI pilot. After the pilot, compare responses by message and audience, identify limitations, and propose a human study to test the hypotheses. Produce a report, the underlying data, and reusable EDSL code. Label simulated responses clearly.
Require students to review and revise the design, explain their choices, and evaluate the evidence behind the final report. For a human research assignment, have students design a survey or interview guide and collect human responses as the primary data source.
## Use the Research Agent alongside EDSL
1. Start a [Research Agent session](https://www.expectedparrot.com/research-agent) with the assignment or playbook and project-specific inputs.
2. Review the research plan and draft instrument. Use the [Builder](/en/latest/survey_builder) to inspect wording, response options, and logic.
3. Run the planned study and inspect the resulting data. Distinguish human responses from simulated responses in every comparison.
4. Ask for the EDSL code and check that the analysis matches the research question.
5. Share the session and required study materials with the instructor using the session's **Share** control.
## Suggested deliverables
* A research question and explanation of the study design.
* The survey or interview guide, scenarios, and any AI respondent definitions.
* Model selections and execution settings for simulations.
* Data and analysis code supporting the findings.
* A report distinguishing observations, simulated results, and hypotheses, with limitations and proposed next steps.
* A shared session or notebook that lets the instructor inspect the workflow.
## Discuss what the evidence supports
AI simulations can help students explore hypotheses, test instruments, and examine model behavior. They do not automatically measure the attitudes or behavior of real populations. Have students compare results across conditions and models and explain what human evidence would be needed to evaluate their conclusions.
Human surveys and interviews are also complete research projects in their own right. Choose the method based on the course's learning objectives and research question.
For examples, see [Research with Expected Parrot](/en/latest/papers), the [Run your first survey](/en/latest/starter_tutorial) tutorial, and [Research Agent resources](https://www.expectedparrot.com/products/research-agent/use-cases). See [Citation](/en/latest/citation) for acknowledging EDSL in student work.
# Token limits and rate limits
Source: https://docs.expectedparrot.com/en/latest/token_usage
Separate request size, throughput, and usage costs when configuring model runs.
A token is a unit a language model uses to process text, often a word or part of a word. A request includes the question, supplied material, instructions, and any earlier answers used as memory. Models also limit how much they can generate and how many requests an account can make over time.
These limits affect different research choices: a long transcript can exceed a single request's capacity, while many short interviews can exceed an account's processing rate. Use the table to distinguish them before changing your design or execution settings.
| Limit | What it constrains | What to check |
| - | - | - |
| Context window | Tokens in a single request and response, according to the model's rules | Prompt length, attached content, prior-answer memory, and reserved output budget. |
| Output limit | Tokens the model may generate for a response | Selected model's output parameter and any reasoning-token budget. |
| Requests per minute (RPM) | Request throughput | Account/provider quota and concurrent workload. |
| Tokens per minute (TPM) | Token throughput across requests | Account/provider quota and aggregate request sizes. |
TPM is a rate limit, not a price. Billing depends on the provider's usage accounting and [pricing](/en/latest/costs).
## Inspect the configured model
After selecting a model through [Provider configuration](/en/latest/guides/providers), inspect its parameters and EDSL's scheduling limits:
```python theme={null}
from edsl import Model
model = Model()
{"model": model.model, "parameters": model.parameters,
"rpm": model.rpm, "tpm": model.tpm}
```
This may retrieve model metadata. Values depend on the model and account; no universal default RPM, TPM, or output limit applies.
You can set `model.rpm` and `model.tpm` to tell EDSL about your applicable limits. These settings do not raise the provider's quota. For a prepared job, bound local concurrency in Python:
```python theme={null}
results = job.run(disable_remote_inference=True, max_concurrency=10)
# Choose a limit appropriate to your provider quota and workload.
```
The CLI alternative is `ep run jobs.ep --local --max-concurrency 10 --output results.ep`. Concurrency controls how many interviews can run at once; it is separate from the model's RPM and TPM limits.
## Reduce the request deliberately
Inspect [rendered prompts](/en/latest/prompts) before reducing their size. Remove irrelevant scenario fields from the question text and avoid unnecessary prior-answer memory. Preserve information required by the research design.
When a task can be answered passage by passage, use [scenario chunking](/en/latest/scenarios#join-reshape-and-summarize) or [PDF pages](/en/latest/scenarios#pdf-text) to create smaller inputs. Each then receives its own answer; that changes the unit of analysis from a whole document to a passage or page.
For question types that support it, `include_comment=False` omits the request for an explanatory comment. `use_code=True` requests coded choices rather than their full text. These settings change the response instructions, so check the rendered prompt and test the resulting format.
An explanatory comment is model-generated text, not a guaranteed account of the model's internal reasoning. Token log-probabilities, where a provider exposes them, describe token predictions under that request; they are not probabilities of a human respondent selecting an answer.
## Diagnose a failed request
For context or output-limit errors, check the selected model's limits and adapter parameter names. For rate-limit errors, check the account quota and reduce the throughput. Provider parameters and reasoning budgets differ across models.