Integrating Stagebook into Your Platform
July 24, 2026 · View on GitHub
This guide explains how to add Stagebook as a dependency and implement the platform-specific backend that powers Stagebook's rendering components. Stagebook provides the experiment description language (schemas + validation) and the rendering layer (React components). Your platform provides the state management, content delivery, and service integrations.
Installation
npm install stagebook
Peer dependencies (install if not already present):
npm install zod js-yaml react react-dom
Package Structure
Stagebook exports from two entry points:
// Schemas, validators, and utilities — no React dependency
import { treatmentFileSchema, compare, fillTemplates } from "stagebook";
// React components — requires React 18+
import {
StagebookProvider,
Element,
Markdown,
Button,
} from "stagebook/components";
Validating Treatment Files
The most basic integration is validation. Use this in build tools, CI pipelines, or editor extensions:
import { safeParseTreatmentFile, fillTemplates } from "stagebook";
import { load as loadYaml } from "js-yaml";
import { readFileSync } from "fs";
// Load and parse
const raw = loadYaml(readFileSync("study.stagebook.yaml", "utf-8"));
// Expand templates
const templates = raw.templates ?? [];
const { result: expanded } = fillTemplates({
obj: raw,
templates,
});
// Validate
const result = safeParseTreatmentFile(expanded);
if (!result.success) {
for (const issue of result.error.issues) {
console.error(`${issue.path.join(".")}: ${issue.message}`);
}
}
Prefer safeParseTreatmentFile over treatmentFileSchema.safeParse directly — it's a thin wrapper that rewrites Zod's unrecognized_keys issues with rich "Unrecognized key 'X'. Did you mean 'Y'? Valid keys: …" messages and splits multi-key issues so each squiggle lands on the specific bad key.
Strict validation (recommended for editor / pre-deploy)
treatmentFileSchema is strict-by-default as of v0.10 (see #283 + #334): the superRefine already catches cross-treatment reference leaks, forward-references between stages, storage-key collisions across phases, and references to keys produced in uninvoked templates. There's no separate "strict" mode to opt into.
For editor tooling and pre-deploy CI, the only extra step is the pre-hydration semantic check, which surfaces specific messages for the bugs that would otherwise fail mid-expansion with a generic "Template expansion failed" error:
import {
safeParseTreatmentFile,
fillTemplates,
parseTreatmentYaml,
resolveImportPath,
resolveImports,
collectPreHydrationIssues, // template-name resolution + circular invocations
type ParsedFile,
} from "stagebook";
import { load as loadYaml } from "js-yaml";
import { readFileSync } from "fs";
import { resolve, dirname } from "path";
const yamlPath = "study.stagebook.yaml";
const raw = loadYaml(readFileSync(yamlPath, "utf-8")) as ParsedFile;
// 1. Resolve `imports:` — load each imported file and merge its
// `templates:` into one flat list. `importedTemplates` is the
// post-merge list of just the *imported* template definitions.
const rootDir = dirname(yamlPath);
const loaded = new Map<string, ParsedFile>();
const queue = (raw.imports ?? []).map((p) => resolveImportPath(yamlPath, p));
while (queue.length > 0) {
const importPath = queue.shift()!;
if (loaded.has(importPath)) continue;
const importedRaw = readFileSync(resolve(rootDir, importPath), "utf-8");
const { parsed, imports } = parseTreatmentYaml(importedRaw);
loaded.set(importPath, parsed as ParsedFile);
for (const next of imports) {
queue.push(resolveImportPath(importPath, next));
}
}
const mergedTemplates = resolveImports({ main: raw, files: loaded });
const rootCount = (raw.templates ?? []).length;
const importedTemplates = mergedTemplates.slice(rootCount);
// 2. Pre-hydration semantic — catches "Template 'foo' is not defined"
// and "Templates form an invocation cycle" before hydration would
// throw a generic error.
const preHydration = collectPreHydrationIssues({
root: raw,
importedTemplates,
});
// 3. Hydrate.
const { result: expanded } = fillTemplates({
obj: raw,
templates: mergedTemplates,
allowUnresolved: true,
});
// 4. Schema validation. The schema's superRefine catches cross-
// treatment reference leaks, storage-key collisions across phases,
// forward references, and references into uninvoked templates.
const schemaResult = safeParseTreatmentFile(expanded);
If your file doesn't use imports:, the resolve step is a no-op (importedTemplates = [] and mergedTemplates = raw.templates ?? []). The example above runs end-to-end for either case.
Diff orchestrator (for live editor diagnostics)
The VS Code extension runs the same pipeline plus a diff orchestrator that distinguishes "real bug in both source and hydrated form" from "templating artifact that disappears after expansion." If your tooling needs the same fine-grained diagnostic routing (e.g., to display templating artifacts as warnings instead of errors), use runValidationDiff:
import { runValidationDiff } from "stagebook";
const sourceText = readFileSync(yamlPath, "utf-8");
const diff = runValidationDiff({
source: sourceText,
importedTemplates, // computed above
});
// diff.hydrationError — YAML-parse or hydration failure (string | null)
// diff.sourceIssues — schema issues from the source-pass run
// diff.hydratedIssues — schema issues from the hydrated-pass run
// diff.matched — issues appearing in BOTH passes — real bugs
// diff.sourceOnly — issues only in the source pass — templating artifacts
// diff.hydratedOnly — issues only in the hydrated pass — revealed by expansion
For build-time and pre-deploy checks the simpler "imports + pre-hydration + schema" sequence above is usually enough — the diff orchestrator pays for two schema runs to enable the artifact/real-bug distinction, which only matters when you're surfacing diagnostics live to an author.
Validating Prompt Files
import { promptFileSchema } from "stagebook";
const markdown = readFileSync("prompts/question.prompt.md", "utf-8");
const result = promptFileSchema.safeParse(markdown);
if (result.success) {
const { metadata, body, responseItems } = result.data;
// metadata: parsed YAML frontmatter
// body: markdown text
// responseItems: response options (prefix-stripped)
} else {
for (const issue of result.error.issues) {
console.error(issue.message);
}
}
Treatment Hydration Pipeline
Before passing treatment data to Stagebook's rendering components, the platform must hydrate it — resolve any imports:, expand templates, validate, and resolve all placeholders. Stagebook components expect fully resolved data with no template contexts or ${field} placeholders remaining.
import {
safeParseTreatmentFile,
fillTemplates,
parseTreatmentYaml,
resolveImportPath,
resolveImports,
} from "stagebook";
// 1. Parse the entry-point file. Stagebook bundles a safe YAML
// parser; surface `imports:` separately so the host can load them.
const { parsed: root, imports: rootImports } =
parseTreatmentYaml(rootYamlString);
// 2. Host-owned loading loop: read every (transitively) imported
// file. The host owns sync vs async, error handling, and any
// extra path canonicalization (symlinks, case folding); stagebook
// owns syntactic path normalization via `resolveImportPath`.
const loaded = new Map();
const queue = rootImports.map((p) => resolveImportPath(rootPath, p));
while (queue.length > 0) {
const path = queue.shift();
if (loaded.has(path)) continue; // dedup — prevents cycles
const text = await loadFile(path); // host's loader
const { parsed, imports } = parseTreatmentYaml(text);
loaded.set(path, parsed);
queue.push(...imports.map((p) => resolveImportPath(path, p)));
}
// 3. Stagebook merges templates + path-rewrites file references in
// imported templates so they resolve relative to the entry-point
// file's directory. After this step imported templates are
// indistinguishable from inline templates.
const mergedTemplates = resolveImports({ main: root, files: loaded });
// 4. Strip imports from root, attach merged templates, expand. The
// walker handles `introSequences` and `treatments` together;
// `fillTemplates` returns `{ result, unresolvedFields }`.
const { imports: _, ...rest } = root;
const merged = { ...rest, templates: mergedTemplates };
const { result: hydrated } = fillTemplates({
obj: merged,
templates: mergedTemplates,
});
// 5. Validate the expanded result. Prefer `safeParseTreatmentFile`
// over `treatmentFileSchema.safeParse` for rich unrecognized-key
// messages with did-you-mean suggestions.
const result = safeParseTreatmentFile(hydrated);
if (!result.success) throw new Error(result.error.message);
// 6. Pass resolved stages to Stagebook components.
If your study doesn't use imports:, steps 2-3 are no-ops (rootImports is empty, mergedTemplates is just root.templates). The pipeline above accommodates both shapes.
Hosts that prefer their own parser (JSON, TOML, DB-backed) can skip parseTreatmentYaml and feed already-parsed objects to resolveImports directly — just extract the imports: array yourself before recursing.
Important: The <Stage> component and <Element> component expect hydrated data. If you pass a stage that still contains { template: "..." } objects or ${field} placeholders, rendering will fail. Always run fillTemplates() before passing data to components.
The hydration step also resolves broadcast expansion — a single template with broadcast: { d0: [...], d1: [...] } may produce multiple stages or elements via cartesian product. This happens during fillTemplates(), not during rendering.
Launch-Time Pairing Guard
Every treatment declares which intro sequences it may follow (compatibleIntroSequences: — required; [] means "runs without one"). The host picks the actual pairing at batch launch: batch config selects an introSequenceName and a set of treatment names. That's where to call checkPairing — after hydration, before creating the batch:
import { checkPairing } from "stagebook/validate";
const diagnostics = checkPairing(
hydrated, // the expanded file from the pipeline above
{ introSequenceName: batchConfig.introSequenceName ?? null },
batchConfig.treatmentNames,
);
if (diagnostics.length > 0) {
// Refuse to launch. Diagnostics carry `range: null` (runtime check,
// no source positions) — render the messages.
throw new Error(diagnostics.map((d) => d.message).join("\n"));
}
Pass introSequenceName: null for a batch that launches without an intro sequence — only treatments declaring compatibleIntroSequences: [] may run that way (map any host-side "none" sentinel to null before calling). The check verifies that the selected sequence and treatments exist, that every selected treatment lists the selected sequence (the declaration is a constraint — even a treatment that references no intro data may not run after a sequence it doesn't list), and that every reference in each treatment resolves under that specific sequence. Design-time validation checks every declared pairing; checkPairing guards that the pairing the batch actually runs is one of them.
Consent and Debrief Placement
Treatment files declare first-class consent content (#481): a top-level consent: array of named arms. Debrief content is not a separate field — author it as the trailing steps of exitSequence (see below). Stagebook labels and provides the content; the host decides placement and attaches behavior. The seam is the same one you already use for intro and exit steps — the host wraps its own steps around the extracted stagebook steps:
[consent] → host checks (attention, equipment) → [introSteps] → [gameStages] → [exitSequence (trailing steps = debrief)] → host wrap-up (QC survey, completion code)
- Consent renders first, before the host's own onboarding checks. The host selects which arm to render by name — a
consentName-style batch-config field resolved against the top-levelconsent:collection. There is no pairing to validate: consent arms have no treatment-level link (consent is invariant across manipulations), which is whycheckPairingdeliberately takes noconsentNameparameter. Verify only that the configured name exists in the expanded file. - Host contract: consent completion is the proof of consent. Advancing past the consent steps is the recorded act of consent, and it authorizes everything downstream (intro, game, exit). The final advancement in the consent steps is the consent gate — reaching/activating it is the consent event, and the saved responses plus that advancement are the proof. That final gated "I consent" submit carries the weight of the whole study's consent; its conditions gating is not cosmetic.
- No consent, no proceed. The host must not render any downstream phase until the consent step's advancement has fired. A participant who doesn't — or can't — complete consent does not enter the study; there is no "partial consent → continue anyway."
- Consent is a mandatory, non-skippable precondition — stronger than ordinary intro/exit steps (which may be conditionally skipped). Treat the consent gate as hard.
- Consent withdrawal mid-study and granular opt-out are a separate, out-of-scope concern; this contract covers only the entry gate.
- The debrief is the tail of the exit sequence. There is no separate
debrief:field: author debrief content (study purpose, dehoaxing, any "may we use your data?" withdrawal choice) as the final steps ofexitSequence. The host renders the whole exit sequence before its QC survey and completion code, so the completion code stays gated behind the debrief — a participant can't collect the code and leave before finishing the exit sequence. Placing the QC survey after the exit sequence also lets it measure the participant's post-debrief exit state. - Responses ride the normal path. Consent steps save through the same
save()/get()machinery as every other step and flow into the normal export. There is no separate consent-audit artifact to produce: the study repo's version plus the saved responses (with their timestamps) are the consent record — the host only needs to keep recording which content version each batch ran. Note that consent keys are audit-only by policy — design-time validation already rejects references to them from any other phase, so nothing downstream can depend on them. - Consent is optional. A file without
consent:gets the host's existing behavior (its own consent markdown); keep that fallback. A treatment whoseexitSequencehas no debrief steps simply has no debrief — the host may still show its own closing / thank-you screen alongside the completion code.
Consent steps run pre-assignment for a single participant — render them with the same provider shape as intro steps (position / playerCount undefined, Date.now()-based elapsed time).
Implementing a StagebookProvider
To render Stagebook elements, your platform must implement the StagebookContext interface and wrap your component tree with <StagebookProvider>.
The Interface
import type { StagebookContext } from "stagebook/components";
const context: StagebookContext = {
// Look up raw stored values by storage key.
// Returns an array of values — exactly what was passed to save().
// "scope" controls whose data to return: "player", "shared", "all", "any", or index.
// Stagebook handles DSL reference parsing internally — platforms don't need to.
get(key: string, scope?: string): unknown[] {
// Look up `key` in your state store for the given scope.
},
// Write participant data under a DSL-derived key.
save(key: string, value: unknown, scope?: "player" | "shared"): void {
// scope "player" = individual state, "shared" = group-visible state
},
// Seconds elapsed since the current step started.
getElapsedTime(): number {
// Game stages: use your synchronized server timer
// Intro/exit steps: use Date.now() relative to step start
},
// Advance to the next step.
submit(): void {
// Intro/exit: call your next() function
// Game stages: signal readiness, wait for all participants
},
// Resolve an asset path to a renderable URL.
// Paths in treatment files are relative to the treatment file's location.
// The platform resolves them to actual URLs based on where assets are stored.
getAssetURL(path: string): string {
// CDN: resolve relative to treatment file dir, prepend CDN base URL
// Local dev: resolve relative to treatment file, return local server URL
// VS Code: resolve to webview URI
},
// Fetch text content by path (relative to treatment file).
// Platform handles resolution, caching, retries, error handling.
getTextContent(path: string): Promise<string> {
// CDN: resolve and fetch from CDN
// Local: resolve and read from filesystem
// Test: return fixture string
},
// Identity and progress
progressLabel: "game_0_discussion", // unique step identifier
playerId: "abc123",
position: 0, // undefined in intro steps
playerCount: 3, // undefined in intro steps
isSubmitted: false,
// Optional: platform-provided renderers for service-coupled elements
renderDiscussion: (config) => <YourVideoComponent {...config} />,
renderSharedNotepad: (config) => <YourNotepadComponent {...config} />,
};
Wiring It Up
Stagebook provides a Stage component that handles all element layout, conditional rendering, and discussion placement. The platform just provides the context and the hydrated stage config:
import { StagebookProvider, Stage } from "stagebook/components";
import type { StagebookContext } from "stagebook/components";
function GameStage({ stageConfig, onSubmit }) {
const context = useYourPlatformContext(); // your platform's hooks
const scoreContext: StagebookContext = {
get: (key, scope) => yourLookup(key, scope, context),
save: (key, val, scope) => yourSave(key, val, scope, context),
getElapsedTime: () => context.timer.elapsed,
submit: onSubmit,
getAssetURL: (path) => `${context.cdnBase}/${path}`,
getTextContent: (path) =>
fetch(`${context.cdnBase}/${path}`).then((r) => r.text()),
progressLabel: context.progressLabel,
playerId: context.player.id,
position: context.player.position,
playerCount: context.playerCount,
isSubmitted: context.player.isSubmitted,
renderDiscussion: (config) => <YourVideoComponent {...config} />,
};
return (
<StagebookProvider value={scoreContext}>
<Stage stage={stageConfig} onSubmit={onSubmit} />
</StagebookProvider>
);
}
The Stage component handles:
- Laying out elements top-to-bottom with appropriate spacing and max-widths
- Two-column layout when a discussion is present (discussion left, elements right)
- Wrapping each element in time, position, and condition-based conditional rendering
- Showing a "waiting for others" message after submission
If you need lower-level control, you can use the Element component directly to render individual elements, or the pure element components (e.g., Prompt, Display) with manual prop wiring.
The Three Phases
The same StagebookContext interface works across all three experiment phases. The platform adapts its implementation:
| Intro (async, solo) | Game (sync, group) | Exit (async, solo) | |
|---|---|---|---|
position | undefined | 0, 1, 2, ... | same as game |
playerCount | undefined | group size | group size |
get | single-player values only | multi-player values | multi-player values |
save(..., "shared") | not available | writes to group state | writes to group state |
getElapsedTime | client-side Date.now() | server-synced timer | client-side Date.now() |
submit | advance to next step | signal readiness | advance to next step |
Components don't need to know which phase they're in.
Host Platform Responsibilities
Stagebook draws a deliberate line between measurement instruments (its job) and the page around them (the host's job). Stage focuses on rendering elements consistently across platforms; everything about how <Stage> sits in your page — the surrounding chrome, the scroll model, where assets and state actually live — belongs to the host. This section gathers those responsibilities in one place so the next consuming platform doesn't have to rediscover them.
The boundary table at the end of this section is the canonical summary; the subsections below explain the tricky cases.
Page chrome
Stagebook does not render page-level chrome. The host is responsible for:
- Headers, branded backgrounds, footers, navigation. Render these around
<Stage>, not inside. - In-page progress indicators (step counters, "stage X of Y" bars). The host knows the global progression; Stage only knows the current step.
- Layout context.
<Stage>doesn't assume anything about its parent's height or scroll model. The host picks: a fixed-height column with internal scroll, or a min-height page that scrolls naturally.
Page scroll and bottom spacing
<Stage> accepts a scrollMode prop:
scrollMode="internal"(default). Stage owns its ownoverflow: autowrapper and renders a<ScrollIndicator>inside it. Convenient for hosts that want a fixed-height column with internal scroll out of the box.scrollMode="host". Stage drops the internal scroll container, the bottom padding, and the indicator. Content flows naturally; the host decides what scrolls (the page, a<main>element, a custom shell) and is free to mount the publicly exporteduseScrollAwareness+<ScrollIndicator>against its own ref.
For most hosts, host mode is the better default — it lets the page flow naturally and integrates with whatever surrounding chrome you already have. Use internal only when you genuinely need Stage to be a fixed-height column.
In host mode, the host is also responsible for bottom-of-stage breathing room: a small spacer below the stage so participants get a visual cue they've reached the end. Without it, long stages end at a hard scroll-stop and participants have no signal there isn't more content. ~6–8rem is typical; size to your own footer / page chrome.
Recommended host setup (host mode)
import { useRef } from "react";
import {
Stage,
StagebookProvider,
ScrollIndicator,
useScrollAwareness,
} from "stagebook/components";
function HostedStage({ stageConfig, context, onSubmit }) {
// Whatever element scrolls in your layout — could be <main>, the
// window (pass `null`/document.scrollingElement), or a custom shell.
// Match `useScrollAwareness`'s `RefObject<HTMLElement | null>` param.
const scrollRef = useRef<HTMLElement | null>(null);
const { showIndicator } = useScrollAwareness(scrollRef);
return (
<main ref={scrollRef} style={{ overflow: "auto" }}>
<StagebookProvider value={context}>
<Stage stage={stageConfig} onSubmit={onSubmit} scrollMode="host" />
</StagebookProvider>
{/* Bottom-of-stage breathing room. ~6–8rem is typical; size to
your own footer / page chrome. Without this, long stages end
at a hard scroll-stop and participants have no signal they've
reached the end. */}
<div aria-hidden="true" style={{ height: "8rem" }} />
{/* Sticky-bottom indicator that auto-shows when content grows
off-screen and auto-dismisses when the user scrolls to bottom.
Position-sticky inside the scroll container, no extra wiring. */}
<ScrollIndicator visible={showIndicator} />
</main>
);
}
scrollMode defaults to "internal", so existing integrations are unaffected. Migrate at your own pace: add a host-side scroll container + spacer + <ScrollIndicator>, then flip the prop.
Resource resolution (getAssetURL and getTextContent)
All paths in treatment files are relative to the treatment file's location. Stagebook never resolves them itself — the host's getAssetURL and getTextContent are the only window into where resources actually live (CDN, local filesystem, VS Code workspace, bundled imports). Two important contract details:
getAssetURLis synchronous. Stagebook calls it inline during render (e.g., to set<img src=...>, audio source URLs). The host must be able to return a renderable URL withoutawait— pre-resolve any async work (signed URL generation, blob URL creation, workspace URI lookup) before mounting the provider, or memoize the resolution so subsequent calls are sync.getTextContentis async. Returns aPromise<string>. This is where prompt files, transcripts, and other text content are loaded; the host owns fetching, caching, retries, and error handling.
The asymmetry matters: a host that does async work inside getAssetURL (e.g., calling a signed-URL service mid-render) will produce visible flicker, broken images on first render, or React render-loop warnings. If your storage layer is async-only, hydrate a path-to-URL map ahead of provider mount.
See platform-requirements.md §4 Content Delivery for the full description of both methods.
State persistence
Stagebook's save / get are the host's mailbox — Stagebook writes participant data to keys it derives from the treatment file, and reads them back for cross-element resolution and conditional rendering. The host decides what each store actually is:
- What's local-only vs. server-synced. Single-player tools may keep everything in React state; multiplayer platforms persist to a server-authoritative store and broadcast mutations to all connected clients.
- What survives a reload. State should survive page refreshes — if a participant disconnects and reconnects, their previous responses should still be present. For multiplayer experiments, other participants' state must also be available after reconnection.
- What's player-scoped vs. shared. For writes,
save(key, value, scope)is limited to"player"or"shared", and the host routes the write to the appropriate store. For reads,get(key, scope)accepts the same two plus"all"and a participant index as a string. See platform-requirements.md §1 State Management for the full scope semantics and storage-key patterns.
Stagebook handles DSL reference parsing internally — the host's get(key, scope) is a flat key-value lookup. The host doesn't need to understand reference syntax or nested-path traversal; it only needs to return whatever was last save()d under that key.
What the host owns vs. what Stage owns
| Host | Stage | |
|---|---|---|
The scroll container (overflow) | ✅ | — |
| Bottom-of-stage breathing room | ✅ | — |
| Page header / branded chrome / footers | ✅ | — |
| In-page progress indicators (step counter, etc.) | ✅ | — |
| Layout context (fixed-height vs. min-height page) | ✅ | — |
Asset URL resolution (getAssetURL, synchronous) | ✅ | — |
Text content fetching (getTextContent, async) | ✅ | — |
State store (get / save, scoping, persistence) | ✅ | — |
| Storage-key routing (player vs. shared vs. by index) | ✅ | — |
| Group formation and position assignment | ✅ | — |
| Stage timer and submission coordination | ✅ | — |
| Element rendering (prompts, separators, etc.) | — | ✅ |
| Per-element max-widths and spacing | — | ✅ |
| Conditional rendering (time / position / conditions) | — | ✅ |
| DSL reference parsing and nested-path traversal | — | ✅ |
| Submission overlay ("waiting for others") | — | ✅ |
Discussion two-column layout when discussion: is set | — | ✅ |
For the deeper specs behind each row, see platform-requirements.md.
Using Standalone Components
Form components work without StagebookProvider. Use them anywhere in your app:
import { Markdown, Button, Separator } from "stagebook/components";
function ConsentPage({ consentText, onAccept }) {
return (
<div>
<Markdown text={consentText} resolveURL={(path) => `/assets/${path}`} />
<Separator />
<Button onClick={onAccept}>I Agree</Button>
</div>
);
}
Components that display images or reference external files accept an optional resolveURL prop for path resolution. Inside the experiment flow, the Element router passes getAssetURL from the provider automatically.
Utilities Without React
Use schemas and utilities in Node.js, build tools, or server-side code — no React needed:
import {
treatmentFileSchema,
promptFileSchema,
compare,
getReferenceKeyAndPath,
fillTemplates,
} from "stagebook";
// Validate a treatment
treatmentFileSchema.safeParse(config);
// Evaluate a condition
compare(playerResponse, "isAtLeast", 0.75);
// Parse a reference string
const { referenceKey, path } = getReferenceKeyAndPath(
"survey.TIPI.result.score",
);
// Expand templates
const expanded = fillTemplates({ obj: treatments, templates });
Render Slots for Service-Coupled Elements
Some elements depend on external services or platform-specific libraries. Stagebook validates the config, manages layout and conditional rendering, and handles data storage — but your platform supplies the actual component via render props on the provider.
Survey
Deprecated.
type: surveyis pending removal once Stagebook's module-reuse pattern lands. The element still works (the host'srenderSurveyslot is still called); the runtime emits a one-timeconsole.warnpersurveyNameat parse time. New treatment files should prefer prompt-based patterns where the survey can be expressed as a sequence of prompt elements.
Surveys are rendered by the platform because they depend on a survey library (e.g., @watts-lab/surveys). Stagebook validates the element config, wraps the survey in conditional rendering, and handles data storage — but the platform provides the actual survey UI.
What the researcher writes
elements:
- type: survey
surveyName: TIPI # which survey to render
name: preTIPI # optional — overrides the storage key
- type: submitButton
What Stagebook does
When Stagebook encounters a type: "survey" element, it:
- Reads
surveyNameandnamefrom the element config - Computes the storage key:
survey_${name ?? surveyName}(e.g.,survey_preTIPI) - Calls your
renderSurveyfunction, passing{ surveyName, onComplete } - When
onComplete(results)is called, Stagebook saves the results:save("survey_preTIPI", results) - The results are then available to other elements and conditions via the reference
<position>.survey.preTIPI.result.<key>or<position>.survey.preTIPI.responses.<questionId>(<position>isself,shared,all, or a numeric slot index — required first segment per #298)
What the platform implements
import { getSurvey } from "@watts-lab/surveys"; // or your survey library
const context: StagebookContext = {
// ...other fields...
renderSurvey: ({ surveyName, onComplete }) => {
const SurveyComponent = getSurvey(surveyName);
return <SurveyComponent onComplete={onComplete} />;
},
};
Your survey component must:
- Render the survey questions and response controls
- Call
onComplete(results)when the participant finishes, passing the results object
That's it. Stagebook handles everything else: the storage key, making results available to display elements and conditions, and all the standard element wrapping (time gating, position visibility, conditional rendering).
The results object
The shape of results is determined by your survey library. Stagebook stores it opaquely — it doesn't inspect the contents. However, researchers will reference specific paths in conditions:
conditions:
- reference: self.survey.preTIPI.result.normAgreeableness
comparator: isAtLeast
value: 0.75
For this to work, the results object must have the structure that matches the reference path. If the reference is self.survey.preTIPI.result.normAgreeableness, then results.result.normAgreeableness must exist (the leading self. is the position selector required by #298; the remaining path resolves into the saved results object). This is a contract between the survey library and the treatment author — Stagebook just traverses the path.
Example: full data flow
- Researcher writes
surveyName: TIPI, name: preTIPIin treatment YAML - Participant completes the survey in the intro sequence
- Survey component calls
onComplete({ result: { normAgreeableness: 0.82, ... }, responses: { ... } }) - Stagebook saves under key
survey_preTIPI - Later, in a treatment's
groupComposition, a condition referencesself.survey.preTIPI.result.normAgreeableness - Stagebook's
resolve("self.survey.preTIPI.result.normAgreeableness")looks upsurvey_preTIPIin state, traverses.result.normAgreeableness, and returns0.82 - The condition
isAtLeast: 0.75evaluates totrue, and the participant is assigned to the matching position
Discussion
Video calls and text chat are tightly coupled to external services (Daily.co, Twilio, etc.). Stagebook handles the two-column layout, position-based visibility, and breakout room config, but the platform provides the actual communication component.
const context: StagebookContext = {
renderDiscussion: (config) => {
if (config.chatType === "video") {
return <DailyVideoCall {...config} />;
}
return <TextChat {...config} />;
},
};
The config parameter is the full discussion object from the treatment YAML, including chatType, showNickname, showTitle, rooms, layout, etc. Your component receives all the configuration and implements the service integration.
Shared Notepad
Collaborative text editors (e.g., a Yjs-backed CodeMirror, or Etherpad) are used by shared: true open-response prompts. (The standalone sharedNotepad element type was removed in #250.)
const context: StagebookContext = {
renderSharedNotepad: ({ padName, defaultText, rows }) => (
<YourCollaborativeEditor
padName={padName}
placeholder={defaultText}
rows={rows}
/>
),
};
defaultText carries the prompt file's > placeholder lines and is placeholder-only: render it as ephemeral hint text (e.g. a CodeMirror placeholder() extension) that disappears once anyone types. Do not seed it into the shared document — it is never part of the saved/exported value, matching how non-shared open-response prompts treat placeholder text.
Progressive adoption
All render slots are optional. If a slot is not provided, the element renders nothing (no error). This lets you progressively add service integrations — start with prompts and submit buttons, add video calls later.