API Reference

July 24, 2026 · View on GitHub

Schemas

All schemas are Zod objects. Use .safeParse(data) for validation or .parse(data) to throw on invalid input.

Treatment File

ExportDescription
treatmentFileSchemaTop-level schema for .stagebook.yaml files
treatmentSchemaSingle treatment (name, playerCount, compatibleIntroSequences, gameStages, exitSequence)
stageSchemaGame stage (name, duration, elements, discussion)
elementSchemaAny element type (discriminated union on type)
promptSchemaPrompt element specifically
discussionSchemaDiscussion configuration
conditionSchemaSingle condition (reference, comparator, value, position)
conditionsSchemaArray of conditions
referenceSchemaReference string validator (parses and validates type.name.path)
introSequenceSchemaIntro sequence with named steps
introExitStepSchemaSingle intro or exit step
consentArmSchemaSingle consent arm (name, own locale, steps) — #481
consentSchemaTop-level consent: array of arms (names unique within the collection)
templateSchemaTemplate definition (name, contentType, content)
templateContextSchemaTemplate usage (template, fields, broadcast)

Prompt File

ExportDescription
promptFileSchemaParses raw markdown → { metadata, body, responseItems, sliderPoints } with full validation
promptMetadataSchemaDiscriminated-union schema for the YAML frontmatter (one strict branch per type:)
metadataTypeSchema / metadataRefineSchema / metadataLogicalSchemaBack-compat aliases for promptMetadataSchema (#243 — the parallel pre-refine pair was unified into one schema)
validateSliderLabels(metadata, items)No-op shim retained for back-compat — slider points and labels share the same body lines after #243, so this check is structurally impossible to fail

Types

Every schema has a corresponding TypeScript type:

import type {
  TreatmentFileType,
  TreatmentType,
  StageType,
  ElementType,
  DiscussionType,
  ConditionType,
  ConsentArmType,
  MetadataType,
  PromptFileType,
} from "stagebook";

Utilities

compare(lhs, comparator, rhs?)

Evaluate a condition comparator.

import { compare, type Comparator } from "stagebook";

compare(5, "isAbove", 3); // true
compare(undefined, "doesNotEqual", "x"); // true (undefined != anything)
compare(undefined, "equals", "x"); // undefined (can't determine yet)
compare("hello", "matches", "\\d+"); // false

Returns: true, false, or undefined (when comparison can't be made yet, e.g., undefined lhs).

Comparators: exists, doesNotExist, equals, doesNotEqual, isAbove, isBelow, isAtLeast, isAtMost, hasLengthAtLeast, hasLengthAtMost, includes, doesNotInclude, matches, doesNotMatch, isOneOf, isNotOneOf.

getReferenceKeyAndPath(reference)

Parse a DSL reference string into a storage key and nested path. The StagebookProvider uses this internally to convert DSL references into flat key lookups — platforms don't need to call this for basic integration. It remains exported for advanced tooling (e.g., state inspectors, debugging tools).

import { getReferenceKeyAndPath } from "stagebook";

// Every reference string starts with a position selector — `self`,
// `shared`, `all`, or a non-negative integer slot index (#298).
// getReferenceKeyAndPath strips the position to return just the
// storage key and path; un-prefixed strings throw at parse time.

getReferenceKeyAndPath("self.survey.bigFive.result.score");
// { referenceKey: "survey_bigFive", path: ["result", "score"] }

getReferenceKeyAndPath("self.prompt.myQuestion");
// { referenceKey: "prompt_myQuestion", path: ["value"] }

getReferenceKeyAndPath("self.entryUrl.params.condition");
// { referenceKey: "entryUrl", path: ["params", "condition"] }

Supported namespaces: survey, submitButton, qualtrics, prompt, trackedLink, timeline, discussion, entryUrl, attributes. (urlParams was renamed to entryUrl in #246; the connectionInfo / browserInfo / participantInfo bags were merged into a single flat attributes source in #473.) entryUrl references must use the params subpath, e.g. self.entryUrl.params.condition — bare entryUrl.<key> is rejected.

getNestedValueByPath(obj, path?)

Traverse a nested object by path array.

import { getNestedValueByPath } from "stagebook";

getNestedValueByPath({ a: { b: { c: 42 } } }, ["a", "b", "c"]); // 42
getNestedValueByPath({ a: 1 }, ["x"]); // undefined
getNestedValueByPath({ a: 1 }); // { a: 1 }

fillTemplates({ obj, templates })

Expand all template references in a structure.

import { fillTemplates } from "stagebook";

const expanded = fillTemplates({
  obj: rawTreatments,
  templates: templateDefinitions,
});

Throws if any ${field} placeholders remain unresolved.

Also exported: expandTemplate, substituteFields, recursivelyFillTemplates for lower-level control.

Validation (stagebook/validate)

The stagebook/validate subpath exports the position-aware validators shared by the CLI, the VS Code extension, and the viewer: validateTreatmentSource, validatePromptSource, loadAndMergeImports, expandAndValidateWithImports, the Diagnostic type, and position-mapping helpers.

checkPairing(file, { introSequenceName }, treatmentNames)

Launch-time guard for the treatment-level compatibleIntroSequences: declaration (#499). Hosts call it at batch launch — the point where batch config selects an intro sequence and a set of treatments.

import { checkPairing, type Diagnostic } from "stagebook/validate";

const diagnostics: Diagnostic[] = checkPairing(
  expandedFile, // post fillTemplates / import merge
  { introSequenceName: "prolific_en" }, // or null for an intro-less launch
  ["negotiation_high_stakes", "control"],
);

Returns: Diagnostic[] — empty means the pairing is valid. Checks, in order:

  1. The named intro sequence exists (when one is selected).
  2. Every named treatment exists.
  3. Every treatment lists the selected sequence in its compatibleIntroSequences: — or declares [] when launching without one. The declaration is a constraint, not just a data dependency: a treatment that references no intro data still may not run after a sequence it doesn't list.
  4. Every reference in each treatment resolves under that specific sequence.

Expects expanded input (e.g. the output of expandAndValidateWithImports or the host's own hydration pipeline); an unresolved ${...} placeholder in a selected treatment's declaration is reported as an error rather than guessed around. Diagnostics carry range: null — this is a runtime check with no source-position mapping, so hosts render messages only. Deliberately intro-only: consent arms have no pairing relationship, so there is no consentName parameter.

getRequiredServices(mergedFile, { loadPrompt })

Host provisioning primitive (#508): walk an expanded treatment and report which external services it requires, so a host provisions exactly those and nothing more. The third member of the host-facing analysis family alongside getReferencedAssets (→ asset mirror) and checkPairing (→ intro pairing).

import {
  getRequiredServices,
  mergeRequiredServices,
  type RequiredServicesReport,
} from "stagebook/validate";

const report: RequiredServicesReport = await getRequiredServices(
  expandedFile, // post fillTemplates / import merge
  { loadPrompt: (path) => readPromptFile(path) }, // same injection shape as loadAndMergeImports
);

// Whole-file default — provision for any arm the file could launch:
if (report.overall.coedit) spawnPairedCoeditPod();

// Or narrow to the selected launch (treatments × intro sequence — the
// same selection you pass to checkPairing — × consent arm) and provision
// precisely:
const needs = mergeRequiredServices(
  ...selectedTreatmentNames.map((t) => report.byTreatment[t]),
  report.byIntroSequence[selectedIntroSequenceName],
  report.byConsent[selectedConsentName],
);
if (needs.video) ensureDailyKeyForwarded();
if (needs.externalSurvey) requireQualtricsCreds();

Returns: Promise<RequiredServicesReport>{ overall, byTreatment, byIntroSequence, byConsent }, where each value is a RequiredServices = { coedit, video, textChat, externalSurvey } of booleans. overall is the whole-file union; byTreatment / byIntroSequence / byConsent are keyed by arm name (built with a null prototype, so a schema-valid but hostile arm name like __proto__ stays an ordinary, enumerable key). Trigger → service mapping (walk of the expanded tree):

ServiceTrigger
coeditprompt element, shared: true, referenced prompt file type: openResponse
videostage discussion block, chatType: video or audio (→ Daily / WebRTC)
textChatstage discussion block, chatType: text
externalSurveytype: qualtrics element (the native type: survey needs no external service)

Async because the coedit signal is split across files: shared: true lives in the treatment YAML but type: openResponse lives in the separate .prompt.md, so shared prompts' frontmatter is resolved via loadPrompt — the same loader-injection shape loadAndMergeImports uses (the host owns path resolution and I/O). loadPrompt is only called for prompts flagged shared: true (its file: path skipped if it still holds a ${...} placeholder), and every referenced shared prompt is loaded at most once across all arms; loader errors propagate rather than silently under-provisioning.

Expects a fully hydrated tree — imports merged and templates expanded (fillTemplates run), e.g. parseTreatmentSource(...).data or your own hydration pipeline. A merely import-merged tree (loadAndMergeImports().merged) is not enough: it still carries templates: definitions and unsubstituted ${...} fields. Service triggers are read only from real DSL positions (elements: items and a stage's discussion: block), so an element-shaped object sitting in an opaque config bag (e.g. a discussion layout feed's options) is never mistaken for an element.

Keyed by arm. A launch selects (treatment set) × (one intro sequence) × (one consent arm) — the treatment/intro axes are exactly checkPairing's inputs; the top-level consent: collection (#481) is selected separately by consentName. A file that keeps pilot/control variants together can be provisioned for just the selected arms via byTreatment / byIntroSequence / byConsent + mergeRequiredServices, instead of the whole-file overall union (which over-provisions in the safe direction). coedit, video and textChat are game-stage-only, so they only ever surface under byTreatment; externalSurvey is the one need that can also come from an intro sequence or a consent arm (a Qualtrics consent/demographics step), which is why those are separate keyed axes. overall is a genuine whole-file walk (not merely the union of the maps), so stray or unnamed service-bearing content is still caught. mergeRequiredServices(...services) OR-combines any number of RequiredServices (skipping undefined, so an unknown arm key contributes nothing).

React Components

StagebookProvider

import { StagebookProvider, type StagebookContext } from "stagebook/components";

<StagebookProvider value={context}>{children}</StagebookProvider>;

Hooks

HookReturnsRequires Provider
useStagebookContext()Full StagebookContext objectyes
useResolve(reference, position?)unknown[]yes
useSave()save functionyes
useElapsedTime()number (seconds)yes
useTextContent(path){ data, isLoading, error }yes

Stage

import { Stage, type StageConfig } from "stagebook/components";

<Stage stage={stageConfig} onSubmit={handleSubmit} scrollMode="host" />;

Requires StagebookProvider. Renders a complete stage: lays out elements with conditional rendering (time, position, conditions), handles two-column layout when a discussion is present, and shows a waiting message after submission. This is the primary rendering API — prefer Stage over manually rendering Element components.

StageConfig has: name (string), duration? (number), elements (ElementConfig[]), discussion? (DiscussionType).

scrollMode?: "internal" | "host" (default "internal") — controls who owns the scroll container around Stage's elements. internal keeps the existing overflow: auto wrapper + internal <ScrollIndicator>; host drops both, lets content flow naturally, and lets you mount your own scroll container with the publicly exported useScrollAwareness + <ScrollIndicator>. See Page Chrome and Scroll Model in the integration guide for the host-mode setup pattern.

Scroll Awareness

import { useScrollAwareness, ScrollIndicator } from "stagebook/components";

const scrollRef = useRef<HTMLElement>(null);
const { showIndicator, dismissIndicator } = useScrollAwareness(scrollRef);

return (
  <main ref={scrollRef} style={{ overflow: "auto" }}>
    {/* … your stage … */}
    <ScrollIndicator visible={showIndicator} />
  </main>
);

useScrollAwareness(containerRef, { threshold? }) watches the container for new content appearing below the viewport. If the user is near the bottom (within threshold px, default 120) it auto-"peeks" the new content into view; otherwise it sets showIndicator to true and clears it when the user scrolls to the bottom.

<ScrollIndicator visible> is a position: sticky; bottom: 0 chevron that pulses to draw attention. It auto-renders nothing when visible is false, so you can leave it mounted unconditionally.

These are the primitives Stage's internal mode uses internally; in host mode you mount them yourself against your own scroll container.

Element Router

import { Element, type ElementConfig } from "stagebook/components";

<Element element={elementConfig} onSubmit={handleSubmit} stageDuration={300} />;

Requires StagebookProvider. Dispatches to the appropriate element component based on element.type. Use this for lower-level control when Stage doesn't fit your needs.

Form Components (standalone)

ComponentKey Props
ButtononClick, children, primary?, disabled?
Separatorstyle? ("thin", "regular", "thick")
RadioGroupoptions, value, onChange, label?
CheckboxGroupoptions, value, onChange, label?
Selectoptions, value, onChange, label?, placeholder?
TextAreavalue, onChange, rows?, minLength?, maxLength?, showCharacterCount?, onDebugMessage?
Slidermin, max, interval, value?, onChange, labelPts? (parallel to labels?, sourced from promptFileSchema.parse(...).sliderPoints after #243), labels?
ListSorteritems, onChange
Markdowntext, resolveURL?

Element Components (pure props)

ComponentKey Props
Promptmetadata, body, responseItems, name, save, getElapsedTime, value, progressLabel
Displayreference, values, position?
SubmitButtononSubmit, name, save, getElapsedTime, buttonText?
AudioElementsrc
ImageElementsrc, width?
KitchenTimerstartTime, endTime, getElapsedTime, warnTimeRemaining?
TrackedLinkname, url, displayText, save, getElapsedTime, progressLabel, resolvedParams?
TrainingVideourl, getElapsedTime, onComplete
Qualtricsurl, resolvedParams?, stableParticipantId?, sampleId?, onContractViolation?, save, onComplete

Render Slots (platform-provided)

SlotConfigWhen Used
renderSurvey{ surveyName, onComplete }type: "survey" element (deprecated — pending removal once a module-reuse pattern lands)
renderDiscussionFull DiscussionType configStage with discussion block
renderSharedNotepad{ padName, defaultText?, rows? }shared: true open-response prompt. defaultText is placeholder-only: hint text, never seeded into the shared document or saved value

Conditional Components

ComponentKey Props
TimeConditionalRenderdisplayTime?, hideTime?, getElapsedTime, children
PositionConditionalRendershowToPositions?, hideFromPositions?, position, children
ConditionsConditionalRenderconditions, resolve, children, fallback?
SubmissionConditionalRenderisSubmitted, playerCount, children

Viewer harness (stagebook/viewer)

The stagebook/viewer subpath is the reusable preview harness — the code behind the standalone viewer app, the VS Code extension's preview, and any external host embedding a participant-perspective preview over its own study files. It wraps the stagebook/components rendering contract (a StagebookProvider fed by a mock state store) and adds the dev chrome (treatment/intro pickers, stage navigation, position selector, timeline scrubber, state inspector). Peer-depends on React. The harness itself does no I/O — PreviewHost/Viewer read content only through host-supplied callbacks; the createUrlContentFns helper below is an optional fetch-backed convenience, while createStaticContentFns keeps the whole flow I/O-free.

Rule of thumb: components render, validate diagnoses, viewer harnesses.

PreviewHost

Batteries-included harness: give it a parsed treatment file plus two content callbacks and it owns template expansion, unresolved-${field} prompting, the mock state store, and the full dev chrome.

import { PreviewHost, createStaticContentFns } from "stagebook/viewer";

const { getTextContent, getAssetURL } = createStaticContentFns({
  "prompts/q1.prompt.md": "# Your view\n\nWrite a sentence.",
});

<PreviewHost
  treatmentFile={parsedTreatment}
  getTextContent={getTextContent} // async (path) => Promise<string>; must be stable
  getAssetURL={getAssetURL} // sync (path) => string; must be stable
  selectedIntroIndex={0}
  selectedTreatmentIndex={0}
/>;

getTextContent/getAssetURL must be referentially stable (memoize them) or the harness re-fetches on every render.

Content-fn helpers

HelperFor
createStaticContentFns(map)An in-memory path → text map (tests, fixtures, hosts holding files in memory). getTextContent rejects for an absent path; getAssetURL returns the path unchanged.
createUrlContentFns(base)Fetch-backed loading from a base URL (e.g. raw.githubusercontent.com), with per-path caching.

Other exports

ExportPurpose
ViewerThe rendering component PreviewHost wraps — for hosts that resolve ${field}s themselves.
ViewerStateStore / createViewerStateStore()The simulated response store (resettable), for custom harnesses.
createViewerContext(opts)Builds the mock StagebookContext bridging the store to stagebook/components.
flattenSteps, extractStageReferences, extractTimeBreakpointsStructural introspection over a treatment (steps, references, timeline breakpoints).
expandTreatmentFile(file, fields?)Expand templates: and report unresolved ${field}s (no import merge, no js-yaml).
StageNav, StateInspector, TimeScrubber, TreatmentPicker, FieldForm, SkeletonPlaceholderThe individual dev-chrome components, for hosts assembling bespoke chrome.