Stagebook

July 9, 2026 · View on GitHub

Executable study protocols for small group conversation studies. A stagebook defines everything that happens in an experiment - what gets shown to whom, when, and under what conditions - to enable complete documentation and perfect replication.

What is Stagebook?

Stagebook defines a declarative language for specifying interactive group experiments: stages, elements (prompts, surveys, timers, discussion windows), conditional logic, templates, and participant positioning.

This repository provides supporting infrastructure for translating stagebook manifests into automated experiments:

  • Zod schemas that validate treatment files and prompt files
  • A template engine for parameterized experiment designs with broadcast expansion
  • Shared utilities for condition evaluation and reference resolution
  • React components that render Stagebook elements into participant-facing UI

Stagebook is platform-agnostic. Define your study protocol once, then run it on any compatible platform.

Try the Viewer — walk through any study from the participant's perspective. Paste a GitHub URL to a treatment file, or explore the built-in examples.

Installation

From GitHub (builds automatically on install):

npm install talkbench/stagebook

Peer dependencies: zod >= 3.23, js-yaml >= 4. React components additionally peer-depend on react >= 18 and react-dom >= 18.

Usage

Validating treatment + prompt files from the command line

The fastest way to check a file is the bundled CLI, which works in any directory — even study repos that have no JS toolchain at all (Node is the only requirement):

# One file
npx --package=stagebook stagebook validate study.stagebook.yaml

# Mixed inputs (treatments + prompts) + globs
npx --package=stagebook stagebook validate \
  'stagebook/**/*.stagebook.yaml' prompts/intro.prompt.md

# Stdin (for agents validating buffered content before writing)
cat study.stagebook.yaml | \
  npx --package=stagebook stagebook validate --type=treatment -

# Machine-readable for CI / agents
npx --package=stagebook stagebook validate --format=json study.stagebook.yaml

Default is expand-and-validate: the validator expands templates + resolves imports: before checking the schema, so errors that only appear after template substitution are caught. --no-expand skips that for faster pre-expansion checks.

Exit codes: 0 clean (warnings OK), 1 schema errors, 2 couldn't read a file / YAML unparseable / glob matched nothing (use --allow-empty to opt out of the last).

Diagnostics match what the VS Code extension shows in its Problems panel — same text, positions, severities — so an editor user and a CI bot see the same errors. The extension isn't on the Marketplace yet; install it from the latest release with a one-line curl — see apps/vscode/README.md.

Validating from TypeScript

For programmatic access (e.g. building tooling), import the validators directly:

import { treatmentFileSchema } from "stagebook";
import { load as loadYaml } from "js-yaml";

const config = loadYaml(yamlString);
const result = treatmentFileSchema.safeParse(config);

if (!result.success) {
  console.error(result.error.issues);
}

For rich diagnostics with source positions (the format used by the editor and CLI), import from the validate subpath:

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

const { diagnostics } = validateTreatmentSource(yamlString);
for (const d of diagnostics) {
  console.log(`${d.severity}: ${d.message} (line ${d.range?.startLine})`);
}

The same subpath exports checkPairing(file, { introSequenceName }, treatmentNames) — a launch-time guard hosts call where batch config pairs an intro sequence with treatments (pass introSequenceName: null for intro-less launches) — and getRequiredServices(file, { loadPrompt }), a provisioning primitive that walks an expanded treatment and reports which external services it needs (coedit, video, textChat, externalSurvey) — keyed by arm (overall, byTreatment, byIntroSequence, byConsent) so a host can provision exactly what the selected launch uses. See the API reference and integration guide.

Validating a prompt file

promptFileSchema takes raw markdown, parses it, and validates structure, metadata, response format, and slider labels in a single pass:

import { promptFileSchema } from "stagebook";

const result = promptFileSchema.safeParse(markdownString);

if (result.success) {
  const { metadata, body, responseItems } = result.data;
  // metadata: parsed and validated YAML frontmatter
  // body: the prompt text
  // responseItems: parsed response options (prefix-stripped)
} else {
  console.error(result.error.issues);
}

Evaluating conditions

import { compare } from "stagebook";

compare(5, "isAbove", 3); // true
compare("hello", "includes", "ell"); // true
compare(undefined, "exists"); // false
compare(undefined, "doesNotEqual", "x"); // true

The 16 canonical comparators: exists, doesNotExist, equals, doesNotEqual, isAbove, isBelow, isAtLeast, isAtMost, hasLengthAtLeast, hasLengthAtMost, includes, doesNotInclude, matches, doesNotMatch, isOneOf, isNotOneOf.

Parsing reference strings

Every reference begins with a position selector — self, shared, all, or a non-negative integer slot index (#298). The selector becomes part of the parsed ReferenceType; getReferenceKeyAndPath strips it to return the storage key and path:

import { getReferenceKeyAndPath } from "stagebook";

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

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

Un-prefixed strings ("survey.bigFive.result.score") throw at parse time with an error suggesting the migration.

Supported namespaces: survey, submitButton, qualtrics, prompt, trackedLink, timeline, discussion, entryUrl, attributes. (urlParams was renamed to entryUrl in #246; connectionInfo / browserInfo / participantInfo were merged into a single flat attributes source in #473.) entryUrl references must use the params subpath, e.g. getReferenceKeyAndPath("self.entryUrl.params.foo").

Expanding templates

import { fillTemplates } from "stagebook";

const result = fillTemplates({
  obj: treatmentConfig,
  templates: treatmentConfig.templates,
});

The template engine supports field substitution (${fieldName}), nested templates, and multi-dimensional broadcast expansion.

API Reference

Schemas

ExportDescription
treatmentFileSchemaTop-level schema for a treatment YAML file (templates, consent, introSequences, treatments)
treatmentSchemaSingle treatment with playerCount, compatibleIntroSequences, gameStages, exitSequence
consentArmSchemaSingle named consent arm with its own locale and steps (#481)
consentSchemaTop-level consent: array of arms — the host selects one by name
stageSchemaGame stage with name, duration, elements, discussion; validates element time bounds against duration
elementSchemaAny DSL element (prompt, display, survey, timer, etc.) with conditional rendering support
promptSchemaPrompt element with file reference and optional shared flag
discussionSchemaDiscussion config (chat type, layout, rooms, visibility)
conditionSchemaCondition with reference, comparator, value, and position
referenceSchemaDSL reference string validator
promptFileSchemaParses and validates a complete prompt markdown file
metadataTypeSchemaPrompt metadata field types and constraints
metadataRefineSchemaCross-field metadata validation (e.g., slider requires min/max/interval)
templateContextSchemaTemplate reference with fields and broadcast dimensions
templateSchemaNamed template definition with content type

All schemas export corresponding TypeScript types (e.g., TreatmentType, StageType, ElementType).

Utilities

ExportDescription
compare(lhs, comparator, rhs?)Evaluate a condition. Returns boolean | undefined
ComparatorString literal union type of the 16 canonical comparator names
getReferenceKeyAndPath(reference)Parse a DSL reference string into storage key + nested path
getNestedValueByPath(obj, path?)Traverse a nested object by path array

Templates

ExportDescription
fillTemplates({ obj, templates })Expand all template references and validate no placeholders remain
expandTemplate({ templates, context })Expand a single template context with fields and broadcast
substituteFields({ content, fields })Replace ${key} placeholders with values

Documentation

For Researchers (designing experiments)

For Engineers (integrating Stagebook)

  • Integration Guide — implementing a StagebookProvider backend
  • Platform Requirements — what the host platform must provide (state, orchestration, group formation, services)
  • API Reference — all exports, types, and component props
  • Architecture — StagebookProvider design, three-layer component model, render slots, CSS theming

Styling and fonts

Stagebook components ship their visual styling as inline styles backed by CSS custom properties, so they render correctly on any host. Importing the optional stylesheet adds the --stagebook-* variable defaults at :root and registers the Inter webfont:

import "stagebook/styles";

Inter is bundled inside the package (OFL-1.1) rather than fetched from a CDN, so every participant gets the same font regardless of network conditions — the measurement-instrument guarantee. When you import stagebook/styles through a bundler with built-in asset handling (Vite, webpack), the font's relative url() is resolved automatically; no consumer action is needed.

esbuild: esbuild has no preconfigured loader for .woff2, so an app that bundles stagebook/styles with plain esbuild fails on the font url until you add a font loader, e.g. --loader:.woff2=file (or dataurl/copy). This is the one bundler that needs a one-line config to consume the bundled font.

If your host serves the stylesheet statically (a raw <link> to the file, or a copy step into a public/ dir) the relative url() won't resolve, because nothing rewrites it. For that case — or if you maintain your own typography and don't import our CSS at all — resolve the bundled file from the stable subpath export, copy it into your own static assets, and point your @font-face at your path:

// In a build/copy step: resolve the bundled file through package exports,
// then copy it into, e.g., public/fonts/InterVariable.woff2.
// ESM — import.meta.resolve() consults the package "exports" map:
import.meta.resolve("stagebook/assets/InterVariable.woff2");
// ESM without import.meta.resolve (older runtimes):
import { createRequire } from "node:module";
createRequire(import.meta.url).resolve("stagebook/assets/InterVariable.woff2");
// CommonJS:
require.resolve("stagebook/assets/InterVariable.woff2");

Note: new URL("stagebook/assets/…", import.meta.url) does not work here — new URL does plain URL resolution and treats the bare specifier as a path relative to the current module, ignoring package exports. Use a real resolver (import.meta.resolve / require.resolve) as above.

/* @font-face pointing at the copy you placed in your own static dir. */
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url("/fonts/InterVariable.woff2") format("woff2");
}

A bare package specifier inside a CSS url()url("stagebook/assets/…") — is not a portable shortcut: browsers and raw <link> serving don't resolve it, and only some bundlers (Vite, webpack) rewrite specifiers in url() while others (esbuild) don't. Resolve the path in JS as above and serve your own copy.

To override the font entirely, set --stagebook-font on any parent element (or :root) — the components fall back through "Inter", ui-sans-serif, system-ui, sans-serif.

License

MIT — see LICENSE.

The bundled Inter font (packages/stagebook/src/assets/InterVariable.woff2) is © The Inter Project Authors and licensed under the SIL Open Font License 1.1; see Inter-OFL.txt.