Status: Draft
This document specifies the labspace.yaml file that authors write to define a
Labspace: a self-contained, in-browser lab consisting of instructional
content, one or more mock terminals, a virtual filesystem, and the simulator
that scripts every command. It is the top-level entry point the app loads.
Where simulator.yaml (see the companion simulator.md specification) defines
command behaviour, labspace.yaml defines everything around it: what the
learner reads, how many terminals they see, what files the lab ships with, and
which simulator spec drives those terminals.
The same file also describes a slide deck, via kind: slides. Everything in
this document applies to both; the three differences (slides: as an alias of
sections:, an optional simulator:, and markdown split into slides on ---) are
specified in the companion slidedeck.md.
The lab is fully static and server-free: the browser fetches
labspace.yaml and everything it references (the simulator spec, section
markdown, and any seed content) as static assets, and runs the whole lab
locally. No backend is required.
┌──────────────────────────────────────────────┐
page load ─► │ 1. read labs.json; pick the lab (id, or sole) │
│ 2. fetch + parse labspace.yaml │
│ 3. fetch the referenced simulator: spec │
│ 4. fetch each section's contentPath markdown │
│ 5. seed the virtual filesystem from files: │
│ 6. render: instructions pane + terminal tabs │
└──────────────────────────────────────────────┘
Key properties:
- Single source. One
labspace.yamlfully describes a lab. Paths it references are resolved relative to thelabspace.yamlfile itself. - Self-contained directory. A lab lives in its own directory under
labs/(labs/<id>/, holdinglabspace.yamland everything it references) so it can be added, mounted, or swapped as a single unit without touching the app's own assets or other labs. - One simulator, many terminals. Every declared terminal tab is backed by the same simulator instance — one shared state tree and one shared virtual filesystem. A change made in one terminal is visible in the others, like two shells on the same machine.
- Content is markdown. Sections are plain markdown files, rendered with a
small set of lab-specific directives and
$$variable$$substitution applied at render time.
Every lab lives in labs/<id>/, and the app reads a generated labs.json
catalog to find them (see the companion catalog.md specification). With one
lab the app enters it directly; with several it shows a landing page and runs the
chosen lab under #/labs/<id>/…:
https://example.com/ → one lab: entered directly
many: the lab-selection page
https://example.com/#/labs/networking → loads ./labs/networking/labspace.yaml
Paths inside each labspace.yaml (simulator, sections[].contentPath) are
resolved relative to that file's location — so within labs/<id>/ they stay
simple (simulator.yaml, 00-intro.md). The catalog itself is resolved relative
to the app's base URI, so subpath deploys (e.g. GitHub Pages) work unchanged.
kind: lab # OPTIONAL. `lab` (default) or `slides`. See slidedeck.md
title: "Getting Started (Simulated)" # OPTIONAL. Document / header title.
description: "A fully in-browser lab." # OPTIONAL. Sub-title / summary.
version: "1.0.0" # OPTIONAL. Lab version (progress invalidation). §4.1
simulator: simulator.yaml # REQUIRED. Path to the scenario spec.
terminals: # OPTIONAL. Terminal tabs. §7
- id: host
title: Host
icon: dns
files: # OPTIONAL. Seed virtual FS. §8
app/server.js: |
console.log("hello");
sections: # OPTIONAL. Instruction pages. §5
- title: Introduction
contentPath: 00-intro.md
variables: # OPTIONAL. Substitution values. §6
containerName: web
services: # OPTIONAL. External-URL tabs. §9
- title: Docs
url: https://docs.example.com
features: # OPTIONAL. Feature flags (e.g. CI tab). §10
ci:
title: CI
icon: rocket_launch
tracking: false # OPTIONAL. Per-lab override/opt-out. §10.2Field summary:
| Field | Required | Purpose |
|---|---|---|
kind |
no | lab (default) or slides — which view runs it (slidedeck.md) |
title |
no | Header title; also sets document.title (default "Labspace") |
description |
no | Shown as the sub-title (default empty) |
version |
no | Lab version string; namespaces/invalidates stored progress (§4.1) |
simulator |
yes* | Path (relative to this file) to the simulator.yaml spec. *Optional when kind: slides |
terminals |
no | Terminal tabs, all sharing one simulator (default: one tab) |
files |
no | Seed files for the shared virtual filesystem |
sections |
no | Ordered instruction pages rendered in the left-hand panel |
variables |
no | Initial values for $$variable$$ substitution in content |
services |
no | External-URL tabs (iframes) in the right-hand pane |
features |
no | Feature flags that add built-in tabs (e.g. ci) (§10) |
tracking |
no | Per-lab tracking override; false opts out (§10.2) |
A labspace.yaml that does not parse to a mapping, or that omits simulator
(for a lab — a deck may omit it), is a hard load error surfaced to the learner.
title— shown in the workshop header and used as the base of the browser tab title ("<title> - <active section title>"). Defaults to"Labspace".description— a one-line summary shown as the header sub-title. Defaults to an empty string.
An optional version string for the lab. The app records a learner's completed
steps (§5.2) in localStorage, tagged with this version. When the stored
version no longer matches, the completion record is cleared (the lab's steps may
have changed) while the learner's anonymous handle is kept. Unversioned labs
(no version:) never auto-invalidate progress. Bump it whenever you add,
remove, or rename steps.
An ordered list of instruction pages shown in the left-hand panel. Each section is one markdown file.
sections:
- title: Introduction
contentPath: 00-intro.md
- title: Run a container
contentPath: 01-run.md| Field | Required | Purpose |
|---|---|---|
title |
yes | Page title, shown in the nav and as the section heading |
contentPath |
no | Path (relative to labspace.yaml) to the markdown file |
steps |
no | Progress checkpoints for this section (§5.2) |
Behaviour:
- Each section gets an id derived from its
titlevia slugification (lowercased; everything except letters, digits, whitespace and dashes stripped; whitespace runs collapsed to single dashes). The id is used in the route (/<section-id>), so keep titles unique. - Sections render in the order declared; the first section is the default when none is selected.
- A section with no
contentPathrenders as empty content. - Section markdown is fetched raw and stored as-is;
$$variable$$substitution (§6) happens at render time, so content reflects the current variable values.
Section content is GitHub-flavoured markdown with a few lab-specific extras.
Fenced code blocks support an info-string meta after the language:
```bash terminal-id=host highlight=1-2 no-copy-button
docker run --name $$containerName$$ -d nginx
```| Meta token | Effect |
|---|---|
terminal-id=<id> |
Run/Save buttons target the terminal with this id (§7); falls back to the primary terminal when omitted |
save-as=<path> |
Shows a Save button that writes the block's contents to <path> in the virtual filesystem |
highlight=<lines> |
Highlights the given line range (e.g. highlight=1-2) |
no-run-button |
Hides the Run button (shown by default) |
no-copy-button |
Hides the Copy button (shown by default) |
The Run button (types the block into the target terminal and executes it)
is shown for shell blocks — languages bash, sh, or console — and for
prompt blocks, unless no-run-button is set or the block has a save-as.
prompt language. A ```prompt block renders as plaintext (no syntax
highlighting) but keeps the Run button, so a learner can send a natural-
language prompt into the terminal — for example, into an AI agent session
(then.session in the simulator). Use it whenever the block's contents are a
prompt to type rather than a shell command.
```prompt terminal-id=agent
Refactor the server to read the port from an environment variable.
```Directives (via remark-directive) provide interactive elements:
| Directive | Purpose |
|---|---|
:filelink[label]{path="app/server.js"} |
Link that cats the file in a terminal |
:tablink[label]{...} |
Opens/focuses a tab (a declared service, terminal, or ad-hoc URL) |
:variabledefinition |
Defines/prompts for a $$variable$$ value inline |
:variablesetbutton |
Button that sets a variable to a fixed value |
:conditionaldisplay |
Shows/hides content based on a variable's value |
A section may declare an ordered list of steps: author-defined checkpoints
that make up the lab's progress model. A step is marked complete when a
scenario in simulator.yaml tagged with a matching completes: <step-id> fires
(see simulator.md §5.1) — i.e. when the learner actually ran the right command
in the right state, not merely viewed the page.
sections:
- title: The Docker CLI
contentPath: 01-docker.md
steps:
- id: run-container
title: "Run a container"
- id: stop-container
title: "Stop the container"| Field | Required | Purpose |
|---|---|---|
id |
no | Stable id referenced by a scenario's completes:. Defaults to slugify(title) |
title |
no | Human label for the step, shown in progress UI |
Behaviour:
- Opt-in and additive. A section with no
steps:tracks no progress; a lab with no steps anywhere behaves exactly as before (no recording, no network). - Completion is recorded in
localStorage(namespaced per lab, and perversion:§4.1) and survives the exercise Reset — retrying the sandbox doesn't erase what you completed. A separate "Reset progress" action clears it. - A section is treated as complete when all of its steps are complete; the section nav shows a check-mark. Informational sections (no steps) carry no completion signal.
- The mapping is lint-checked:
npm run validate-labflags a scenario whosecompletes:names an unknown step id (error) and a cataloged step that no scenario completes (warning).
A flat map of variable names to initial values. Anywhere $$name$$ appears in
section markdown, it is replaced with the current value of that variable at
render time.
variables:
containerName: webdocker run --name $$containerName$$ -d nginxSubstitution rules:
$$name$$→ the variable's current value. Whitespace inside the braces is trimmed ($$ name $$also works).- If the variable is unset or null, the bare name is left in place (so
$$containerName$$renders ascontainerName). \$\$escapes to a literal$$in the output.
Variables are held in memory and can be changed at runtime by the
:variabledefinition / :variablesetbutton directives (§5.1). The
labspace.yaml variables: map only provides the initial values.
A list of terminal tabs shown in the right-hand pane. All terminals share one simulator instance — the same state tree and the same virtual filesystem — so they behave like multiple shells on one machine.
terminals:
- id: host
title: Host
icon: dns
- id: agent
title: Agent
icon: smart_toy| Field | Required | Purpose |
|---|---|---|
id |
no | Stable id, referenced by terminal-id= in code blocks and by when.terminal in scenarios. Defaults to slugify(title), then terminal-<index> |
title |
no | Tab label (default "Terminal") |
icon |
no | Material Symbols name (default "terminal") |
Behaviour:
- If
terminalsis omitted or empty, a single default terminal is created:{ id: "terminal", title: "Terminal", icon: "terminal" }. - The first terminal is the default focus target and the fallback for code
blocks that omit
terminal-id. - Terminal tabs are permanent — they cannot be closed by the learner (only service/custom tabs can).
- A scenario in
simulator.yamlcan restrict itself to one terminal withwhen.terminal: <id>(seesimulator.md§6.6). Because state and filesystem are shared, a command run in one terminal affects all of them.
A map of file path → contents used to seed the shared in-memory virtual
filesystem before any command runs. The built-in ls and cat commands (see
simulator.md §10) reflect these files immediately.
files:
app/server.js: |
const express = require("express");
const app = express();
app.listen(3000);
config/settings.yaml: |
debug: true- Keys are paths relative to the lab root; values are the full file contents.
- Seed files are the initial state; scenario
files:effects andsave-ascode blocks mutate the same filesystem on top of this seed. - Pressing Reset re-seeds the filesystem (and state) to these initial values.
Optional tabs that embed an external URL (as an iframe) alongside the terminal tabs in the right-hand pane.
services:
- id: docs
title: Documentation
icon: menu_book
url: https://docs.example.com| Field | Required | Purpose |
|---|---|---|
id |
no | Stable id; referenced by :tablink directives. Defaults to slugify(title) |
title |
no | Tab label (defaults to the id) |
icon |
no | Material Symbols name (default "link") |
url |
yes | The URL loaded in the tab's iframe |
Behaviour:
- Declared services appear as tabs immediately after the terminal tabs.
- A
:tablinkdirective whose target matches a declared service'sidoverrides that service tab's URL and focuses it; otherwise it opens a new ad-hoc tab. Service and ad-hoc tabs are closeable; terminal tabs are not.
Note: external URLs are only reachable if the deployment/network policy allows them. Services are a presentation feature; the simulator itself never makes network calls.
features is an optional map that turns on built-in, engine-backed tabs in the
right-hand pane. Unlike services (arbitrary external URLs), a feature tab is a
first-class component that reads the shared simulator state.
Adds a CI tab that renders mock CI workflow runs, GitHub-Actions style. Runs
are triggered by scenarios via then.ci and defined by the workflows: catalog
— both owned by simulator.yaml (see simulator.md §15). The labspace only
declares that the tab exists and how it is labelled.
features:
ci:
title: CI # OPTIONAL tab label (default "CI")
icon: rocket_launch # OPTIONAL Material Symbols name (default "rocket_launch")| Field | Required | Purpose |
|---|---|---|
title |
no | Tab label (default "CI") |
icon |
no | Material Symbols name (default "rocket_launch") |
Behaviour:
- The CI tab appears immediately after the terminal tabs. It is permanent (not closeable), like a terminal tab.
- When a
git push(or any scenario withthen.ci) triggers a run, the CI tab is brought forward automatically so the learner sees the pipeline fire. - Runs live in
state.ci.runs, so they are deterministic and are cleared by Reset along with the rest of the state. - With no
features.ciblock, no CI tab is shown andthen.cieffects still writestate.ci.runsbut have no visible surface.
Live presence ("who's here now") and cumulative analytics are powered by a
pulse backend (see the pulse/ directory). Because one backend serves a
whole deployment, the connection is configured once, deployment-wide, in the
app's config.json — NOT per lab:
// config.json (served next to labs.json; the deploy pipeline writes it)
{
"tracking": {
"endpoint": "https://pulse.example.com",
"presence": true,
"identity": "optional-name"
},
"title": "Acme Labs"
}config.json's other field, title, is unrelated to tracking — see §10.3.
When that default is set, every lab is tracked automatically (bucketed by its
catalog id) — a lab needs no tracking: field at all. With no config.json
tracking default and no per-lab endpoint, the app makes no network calls.
The per-lab tracking: field is only an override, for the exceptions:
| Value | Effect |
|---|---|
| (omitted) | Inherit the deployment default (tracked when one is set) |
tracking: false |
Opt this lab out — no presence, no events |
tracking: { presence: false } |
Tracked, but hide the live-presence UI (still records analytics) |
tracking: { identity: anonymous } |
Never offer a display name for this lab |
tracking: { endpoint: … } |
Point this lab at a different backend (rarely needed) |
An override object is merged over the deployment default; labId still defaults
to the catalog id. See README.md (Deploying) for how config.json is provided
in dev vs. production, and pulse/README.md for the backend.
Behaviour:
- The lab reports anonymous events (
lab_started,section_viewed,step_completed,lab_completed,reset) plus periodic heartbeats, and polls back a live presence aggregate. Identifiers are random per-browser handles, not identities. - In-lab, only live presence is shown — never cumulative completion counts.
This is deliberate, not an oversight: a mid-lab "X of Y completed this step"
number reads as drop-off and can cause the drop-off it reports (a learner who
sees "3 of 500 got here" may quietly give up), whereas live "who's here"
presence is a positive, "you're not alone" signal. So cumulative data is kept
out of the running lab — it lives only in the instructor dashboard (
pulse's token-gated/stats) and as a catalog-page aggregate ("Completed by N people"). Do not surface a completion/drop-off count beside a step. - Requires a step catalog (§5.2) for step-level presence; the global "who's here" works from section navigation alone.
See pulse/README.md for the full backend model and deployment.
config.json's title field names the deployment, not a lab — it replaces
the default "Labspace" on the landing page (shown when the catalog has more
than one entry) and in that page's browser tab title ("<title> — <heading>").
It has no effect once inside a lab, where the tab title is the lab's own
title: field (§4) instead.
{ "title": "Acme Labs" }With no config.json, or no title in it, the default "Labspace" is used.
See README.md (Deploying) for how config.json is provided in dev vs.
production.
title: "Getting Started (Simulated)"
description: "A fully in-browser lab — every command is scripted and runs locally."
# Path to the simulator scenario spec (relative to this file). All terminals
# below share this one spec, plus one shared state + filesystem.
simulator: simulator.yaml
# Multiple terminal tabs, all backed by the shared simulator above. Code blocks
# can target a specific one with `terminal-id=<id>` in the fence info string;
# the first terminal is the default when a code block omits `terminal-id`.
terminals:
- id: host
title: Host
icon: dns
- id: agent
title: Agent
icon: smart_toy
# Seed files for the virtual filesystem. Built-in `ls`/`cat` reflect these.
files:
app/server.js: |
const express = require("express");
const app = express();
app.get("/", (_, res) => res.send("Hello from the lab!"));
app.listen(3000, () => console.log("listening on :3000"));
sections:
- title: Introduction
contentPath: 00-intro.md
- title: Run a container
contentPath: 01-run.md
- title: Edit and save a file
contentPath: 02-edit.md
- title: Work with the agent
contentPath: 03-agent.md
variables:
containerName: web| Concern | Owned by |
|---|---|
| Lab title, description | labspace.yaml |
| Instruction sections / content | labspace.yaml |
| Terminal tabs (ids, labels) | labspace.yaml |
| Initial virtual filesystem | labspace.yaml files: |
| External-URL (service) tabs | labspace.yaml |
| Built-in feature tabs (CI) | labspace.yaml features: |
| Content variables | labspace.yaml variables: |
| Command matching + effects | simulator.yaml (scenarios) |
| Runtime state tree | simulator.yaml state: |
| Command-facing controls/toggles | simulator.yaml controls: |
| CI workflow catalog + triggers | simulator.yaml workflows: / then.ci |
| Streaming/pacing | simulator.yaml settings: |
The two files meet at three points: labspace.yaml's simulator: selects the
spec, terminals[].id supplies the ids used by when.terminal, and files:
seeds the filesystem that scenario files: effects and the built-in ls/cat
commands operate on.
Presenting this content as slides rather than a scrolling page.Implemented askind: slides— seeslidedeck.md.- Per-terminal (non-shared) simulator instances or isolated filesystems.
- Declaring initial state / variable overrides per section.
- Schema
versionfield forlabspace.yamlitself (currently unversioned). Validation/lint for danglingImplemented bycontentPath,terminal-id, and service ids.app/scripts/validate-lab.ts(npm run validate-lab), which checkscontentPath,simulator, code-fenceterminal-id,:filelinktargets, and duplicate terminal/service ids.