Skip to main content

Authoring experiments programmatically

The HyperStudy v3 API lets you create, read, update, and validate experiment definitions from code. This page shows how to build experiments with the Python SDK's typed builders — but the underlying REST endpoints are the same whether you call them from Python, JavaScript, R, or curl.

For the full endpoint reference, see the interactive API Reference. For data-fetching workflows (events, recordings, ratings), see the Python Guide.

Quickstart​

from hyperstudy import HyperStudy, Experiment, State, show_text

hs = HyperStudy() # reads HYPERSTUDY_API_KEY

exp = Experiment(
name="Welcome study",
required_participants=1,
states=[
State(id="intro", focus_component=show_text("Welcome to the study!")),
],
)

info = hs.create_experiment(experiment=exp)
print(info["id"]) # → exp_...

The typed builders convert snake_case Python fields to the camelCase shape the backend expects (required_participants → requiredParticipants). Unknown top-level fields are preserved as-is, so the SDK does not block on newly added backend features.

Building blocks​

Experiment​

The root object. Only name is required.

exp = Experiment(
name="My Study",
description="Short description",
required_participants=2,
randomize_states=False,
completion_screen_duration_ms=5000,
)

State​

A single screen/phase. Each state has an id and (usually) a focus_component that defines what the participant sees.

from hyperstudy import State, TransitionRules, show_video

s = State(
id="watch",
name="Watch the clip",
order=1,
focus_component=show_video("https://example.com/clip.mp4"),
transition_rules=TransitionRules(type="auto"),
)

FocusComponent​

One component per state. Use a factory helper (next section) or construct directly:

from hyperstudy import FocusComponent, ComponentType

c = FocusComponent(
type=ComponentType.SHOW_TEXT,
config={"text": "Hello", "fontSize": 24},
)

Role​

Used for multi-participant experiments.

from hyperstudy import Role

roles = {
"speaker": Role(name="Speaker", participant_count=1),
"listener": Role(name="Listener", participant_count=1),
}
exp = Experiment(name="Pair study", required_participants=2, roles=roles)

Waiting room and timeouts​

from hyperstudy import WaitingRoomConfig, DisconnectTimeout

exp = Experiment(
name="Multi-participant study",
required_participants=2,
waiting_room_config=WaitingRoomConfig(
max_wait_time_ms=60_000,
countdown_time_ms=3_000,
),
disconnect_timeout=DisconnectTimeout(
enabled=True,
duration_ms=30_000,
),
)

Component factories​

Each factory returns a FocusComponent with the standard config keys filled in. Pass extra keyword arguments to set any other config field the backend accepts.

All factories accept a keyword-only id= argument; if omitted, an 8-character ID is generated automatically. Directly-constructed FocusComponent(...) instances do NOT auto-generate an ID — set id= explicitly when bypassing the factories.

FactoryComponent typeRequired args
show_text(text, *, id=None, **extra)showtexttext
show_image(url, *, id=None, **extra)showimageurl
show_video(url, *, id=None, **extra)showvideourl
vas_rating(prompt, *, output_variable, id=None, **extra)vasratingprompt, output_variable
text_input(prompt, *, output_variable, id=None, **extra)textinputprompt, output_variable
multiple_choice(prompt, options, *, output_variable, id=None, **extra)multiplechoiceprompt, options, output_variable
likert_scale(prompt, *, output_variable, scale_points=7, id=None, **extra)likertscaleprompt, output_variable
ranking(prompt, options, *, output_variable, id=None, **extra)rankingprompt, options, output_variable
waiting(duration_ms, *, id=None, **extra)waitingduration_ms

For component types without a factory (code, continuousrating, videochat, textchat, rapidrate, audiorecording, sparserating, trigger, scannerpulserecorder, gazeoverlay), construct directly:

from hyperstudy import FocusComponent, ComponentType

video_chat = FocusComponent(
type=ComponentType.VIDEO_CHAT,
config={"durationMs": 120_000},
)

Keyword arguments other than id pass through to the component's config:

show_text("Hello", fontSize=32, color="navy", textAlign="center")
# → config = {"text": "Hello", "fontSize": 32, "color": "navy", "textAlign": "center"}

Use camelCase keys for these pass-through fields — they go to the backend unchanged. Factory-set keys (e.g. text for show_text) take precedence over extras with the same name.

Validation​

Two layers of validation:

  1. Client-side (Pydantic) at construction time — missing required fields and wrong types raise pydantic.ValidationError immediately. Field names are not checked here: an unknown field passes through to the server.
  2. Server-side via validate_experiment() — runs the checks create_experiment runs, without creating anything. A definition it calls valid is one create accepts; one it calls invalid is one create refuses, with the same messages. The server refuses any key that is not an experiment field, such as a misspelt one, rather than dropping it.
from hyperstudy import HyperStudy, Experiment, State, show_text
from pydantic import ValidationError

hs = HyperStudy()

# Catches obvious errors before any HTTP traffic.
try:
exp = Experiment(name="", required_participants=0)
except ValidationError as e:
print(e)

# Catches structural issues the schema can't model.
exp = Experiment(name="Check me", states=[State(id="s1", focus_component=show_text("Hi"))])
result = hs.validate_experiment(exp)
print(result) # → {"valid": True, "errors": [], "warnings": []}
# or {"valid": False, "errors": ["State 0 uses unsupported component type: ..."], "warnings": [...]}

Use validate_experiment in a CI pipeline to catch breaking schema changes before you publish.

Round-trip workflow​

Fetch an existing experiment, modify it, and push it back:

hs = HyperStudy()

# Read. Returns the camelCase wire-format dict.
config = hs.get_experiment_config("exp_abc123")

# Mutate. Because the dict is in wire format, mutations use camelCase keys —
# writing config["randomize_states"] would *add a new field* rather than
# update the existing one.
config["name"] = "Renamed"
config["randomizeStates"] = True

# Write. You can pass the raw dict back via **config…
hs.update_experiment("exp_abc123", **config)

# …or re-parse it into Experiment for type-checked edits in snake_case.
from hyperstudy import Experiment
exp = Experiment.model_validate(config)
exp.completion_screen_duration_ms = 8000
hs.update_experiment("exp_abc123", experiment=exp)

Experiment.model_validate accepts both snake_case and camelCase keys, so an exported config from the UI imports cleanly.

Schema reference​

The most-used top-level fields:

Python (snake_case)Wire (camelCase)TypeRequiredDescription
namenamestr✓Experiment name.
descriptiondescriptionstrFree-text description.
required_participantsrequiredParticipantsint >= 1Number of participants needed to start.
runtimeruntimestr"v2" or "legacy". New experiments default to "v2".
randomize_statesrandomizeStatesboolRandomize state order. The legacy runner shuffles each participant's order; the V2 runner draws one order per room from its clusters, and keeps the clusters unused while this is False.
statesstateslist[State]Ordered list of states.
rolesrolesdict[str, Role]Role definitions (multi-participant).
global_componentsglobalComponentsdict[str, dict]Persistent components running throughout.
waiting_room_configwaitingRoomConfigWaitingRoomConfigPre-experiment lobby behavior.
disconnect_timeoutdisconnectTimeoutDisconnectTimeoutHow long to wait on a disconnect before dropping.
completion_screen_duration_mscompletionScreenDurationMsint >= 0Final screen duration.
consent_form_enabledconsentFormEnabledboolShow consent form?
consent_form_titleconsentFormTitlestrConsent form title.
consent_form_contentconsentFormContentstrConsent form HTML.
instructions_enabledinstructionsEnabledboolShow instructions?
instructions_titleinstructionsTitlestrSingle-page instructions title.
instructions_contentinstructionsContentstrSingle-page instructions HTML body.
instructions_pagesinstructionsPageslist[InstructionsPage]Multi-page instructions (alternative to title/content).
post_experiment_questionnairepostExperimentQuestionnairePostExperimentQuestionnairePost-task survey.
variable_definitionsvariableDefinitionslist[VariableDefinition]Constants, random and computed variables (V2 runner). See Variables. The older variables field was never stored and is refused.

For the authoritative schema (including per-component config shapes), see the interactive API Reference and search for Experiment.

Troubleshooting​

ValidationError: 1 validation error for Experiment / name / String should have at least 1 character

You called Experiment(name="") or omitted name entirely. name must be a non-empty string.

ValidationError: ... type / Input should be 'showtext', 'showimage', ...

You passed an unrecognized component type. Check ComponentType for the supported values, or construct with FocusComponent(type=ComponentType.X, config=...).

{"valid": false, "errors": [...]} from validate_experiment

The definition passed Pydantic but the server would refuse it — most often a required config key missing for a specific component type, a misspelt field, or an agent role without a persona. errors is a list of messages, each naming the field or state at fault; cross-reference the API Reference for that component's config schema.

ValidationError: ... "xyz" is not a writable experiment field

The body has a key that is not an experiment field, usually a misspelling (requiredParticipant for required_participants) or a field only the server sets, such as ownerId. The server refuses it instead of silently dropping it. A config fetched with get_experiment_config can still be sent back whole to update_experiment: server-owned fields that never change (id, ownerId, createdAt, ...) are accepted when they match what is stored, and the fields the server rewrites on every save (updatedAt, version, updatedBy, nameLower) are ignored, so a config fetched before later saves still goes back. Changing a server-owned field, such as ownerId, is refused.

TypeError: create_experiment requires a 'name' ...

Either pass name="..." directly or include it on the Experiment you pass via experiment=.

Next steps​