Field Guide

The Instruqt Track Builder's Field Guide
Patterns, pitfalls, and hard-won lessons from production tracks

For track authors building production-grade Instruqt content

Patterns, pitfalls, and hard-won lessons from production tracks.

5
Parts
18
Chapters
5
Appendices
1
Field Guide

The official Instruqt documentation tells you what the platform does. It tells you that track.yml defines a track, that config.yml declares infrastructure, that check scripts validate learner work. It does not tell you that a stray null byte from a Windows-mounted volume will break instruqt track push with a misleading UTF-8 error, or that fail-message works fine on most workshop-published images but will exit 127 on a raw upstream Docker base, or that the difference between the lighter base image and the cloud-client base image will quietly cost you twenty minutes the first time you reach for aws and it is not there.

Those gaps are where good tracks are won and lost.

This book sits beside the official documentation, not on top of it. The docs are the manual; this is the field guide. It assumes you know what an Instruqt track is and that you have built at least one. From there, it walks the lifecycle of a real production track — discovery, design, scaffold, build, polish, ship, maintain — and at every step it tells you what works, what bites, and what experienced authors have stopped doing.

Every pattern in this book is grounded in a track that shipped. Where a pattern is described, it is described concretely enough that you can implement it from the description alone. Where a pitfall is named, the symptom is named too — the actual error message, the actual broken UI, the actual thing you will see at four o'clock on a Friday when you are trying to push for a Monday demo.

The book also carries cross-references to two companion artifacts. The Best Practices Booklet states the rules; this book explains the reasoning. The Pattern Cookbook carries the implementations; this book explains where they fit. References to either look like (see Booklet Principle N) or (see Cookbook Recipe N) and are worth following when you want depth in a different shape than the chapter is offering.

Read the book straight through the first time to absorb the lifecycle and the mental model. Come back to single chapters or sections when you have a specific problem in front of you. The book is structured so that any chapter can be read on its own, but the cumulative argument — that good tracks are the product of a small set of repeated, learnable habits — is best understood end to end.

Part I — Foundations
01
What an Instruqt Track Actually Is
A track is the unit of work in Instruqt.

A track is the unit of work in Instruqt. It is not a course, not a tutorial, not a page of documentation. It is an environment — real infrastructure, provisioned on demand — paired with a sequence of guided exercises that the learner performs inside that environment. The learner gets a real terminal, a real database, a real cloud account, a real web application. The track tells them what to do with it and verifies that they did it.

That definition is short, but every word matters. Real is the part that distinguishes a track from a screencast or a marketing demo. The infrastructure exists, briefly, just for the learner. They can break it. They can rebuild it. They can do things the author did not anticipate. A well-designed track guides them toward the lesson without removing the option to deviate.

The three-part anatomy

Every track, regardless of complexity, has three parts.

The first is infrastructure — the hosts, networks, cloud accounts, and external services that the learner will interact with. This is declared in config.yml, or skipped in favor of a sandbox preset when one fits. Infrastructure is the substrate of the track; it is what the learner is in.

The second is content — the assignments, the explanations, the screenshots, the quiz questions, the videos. This is what the learner reads. It lives in numbered challenge directories, each with its own assignment.md file containing both YAML frontmatter (configuration) and Markdown body (the human-readable text).

The third is lifecycle scripts — bash on Linux hosts, PowerShell on Windows VMs — that run at specific points in the track's life: when the environment provisions, before each challenge, when the learner clicks Check, when they click Solve, when the track ends. These scripts are the connective tissue between infrastructure and content. They turn declarative YAML into a functioning, testable lab.

A track that gets all three parts right is rare and valuable. Most tracks get one part right — usually content — and the other two suffer for it. This book is largely about giving equal craft to all three.

What a track is not

A track is not a tutorial in the documentation sense. Tutorials are read; tracks are performed. A reader who skims a tutorial gets some value. A learner who skims a track gets none, because the track expects them to type things, run things, fix things. The interactivity is not optional ornamentation; it is the medium.

A track is not a screencast or a demo. A demo is what the seller does to the audience. A track is what the learner does for themselves, with the seller's product in their hands. The transfer of agency is the point.

A track is not a presentation. Presentations move at the speed of the speaker. Tracks move at the speed of the learner — which is to say, slower than the author thinks, with more confusion than the author remembers from when they last looked at the material.

Holding these distinctions in mind shapes every decision downstream. The compute you provision, the assignments you write, the check scripts you implement — all of them should serve the learner's experience of doing the work themselves, not the author's experience of explaining the work.

The author's mental model

The most useful frame for a track author is this: you are not writing instructions. You are designing an experience that has instructions in it.

The infrastructure must boot reliably and quickly enough that the learner does not lose interest. The assignment must guide without hand-holding. The check scripts must teach when they fail, not just verdict. The polish — the loading messages, the prompts, the layout, the pacing — must feel like someone cared.

When all of those things land, the learner walks away thinking they had a productive hour with the product. They do not think about the track itself. They think about the thing they learned. That is the goal.

A track lives in a directory. The directory's name is the track's slug, and that slug is the only identifier some Instruqt tooling will use to reference the track. Inside the directory, the layout is conventional and small — most tracks have fewer than a dozen top-level files — but every file plays a specific role.

This chapter walks the standard layout. Later chapters cover the contents of each file in depth.

The standard layout

my-track/
├── track.yml              ← track-level metadata
├── config.yml             ← infrastructure declaration (optional if using a sandbox preset)
├── track_scripts/         ← track-level lifecycle scripts
│   ├── setup-{hostname}
│   └── cleanup-{hostname}
├── 01-first-challenge/
│   ├── assignment.md      ← challenge content
│   ├── setup-{hostname}   ← per-challenge setup
│   ├── check-{hostname}   ← validation
│   ├── solve-{hostname}   ← auto-solve for skip / testing
│   └── cleanup-{hostname} ← per-challenge teardown
├── 02-second-challenge/
│   └── ...
├── assets/                ← images, media
└── MEMORY.md              ← living build log (see Chapter 18)

Every challenge directory is numbered with a leading two-digit prefix, and the prefix order must match the challenge sequence in track.yml. Numbering gaps (01-, 02-, 04-) or mismatches between directory order and track.yml order produce undefined behavior at push time. (See Booklet Principle 20 for the prescriptive form.)

track.yml — what the platform sees

track.yml is the track's metadata sheet. It carries the slug, the title, the teaser, the description, the timing settings, the lab UI configuration. It is small — usually under 50 lines — but every field has consequences downstream.

The fields fall into rough groups:

  • Identity. slug (must match the directory name), id (UUID — auto-assigned on first push, never modify after), title, teaser, description, icon, checksum (auto-generated, do not edit).
  • Ownership and lifecycle. owner is a single string identifying the publishing organization. developers is a list of build-owner emails. instructors is a separate list of instructor-led delivery contacts; some workshop tracks list dozens. maintenance: true hides the track from the catalog. version is a free-form semver string and is independent of checksum.
  • Pacing. timelimit (seconds — the track-level clock), idle_timeout (seconds before idle teardown — set to 0 only on live-presenter tracks), pausable plus pausable_ttl (seconds — typically 604800 for seven days, 1209600 for multi-week workshops), extend_ttl (allow learner-initiated time extension), show_timer, skipping_enabled, enhanced_loading.
  • Catalog and visibility. level (basic / intermediate / advanced or empty), tags (an array, with structured prefixes supported — for example persona:consumer, time-extension, skip-testing).
  • lab_config. A nested map covering theme, default layout (AssignmentLeft or AssignmentRight), sidebar size, override flags, loading messages, hide-stop-button, and feedback toggles.
  • sandbox_preset (top-level, alternative to config.yml). When present, no infrastructure file is needed.

Two track.yml traps catch new authors regularly. The first is enhanced_loading. It is a top-level field, not a lab_config sub-field — and it must be set to false if the track uses notes: slides as overlay screens, otherwise notes silently degrade into a clickable tab. The second is idle_timeout: 0. Set it only on live-presenter tracks where the author is watching the session; an abandoned sandbox with idle disabled keeps running and accrues cost.

config.yml — what the platform builds

config.yml declares the actual infrastructure: containers, virtual machines, virtual browsers, cloud accounts, secrets, optional Custom Resources. The format starts with version: "3" and then a top-level key per resource type.

Resource types in brief — Chapter 5 covers each in depth:

  • containers — Docker containers. Lighter and faster than VMs; use for workstation hosts whenever possible.
  • virtualmachines — full Linux or Windows VMs. Required for kernel features, GUIs, nested virtualization, or heavy preinstalled dependencies.
  • virtualbrowsers — pre-pointed browser tabs. URL accepts ${_SANDBOX_ID} interpolation, internal hostnames, or external URLs.
  • aws_accounts, azure_subscriptions, gcp_projects — provisioned cloud accounts with IAM scoping and credential injection.
  • secrets — named secrets injected as environment variables. Always use this for credentials; never put credentials in environment: blocks. (See Booklet Principle 6.)
  • Custom Resources (Terraform modules) — Web-UI-managed, provisioned ahead of other resources.

Skip config.yml entirely when a sandbox_preset declared in track.yml covers the environment you need. A 90% preset fit is the threshold for adoption; below that, build the environment yourself in config.yml.

track_scripts/ — track-level lifecycle

The optional track_scripts/ directory holds two scripts per host: setup-{hostname} runs once before the first challenge, and cleanup-{hostname} runs once after the lab ends. The {hostname} segment must match a host name declared in config.yml exactly.

This is where the heavy lifting happens — image registration, cluster bootstrap, repo clone, helper-script seeding, shared certificate generation. Anything that needs to be done once per play, before the learner reaches the first challenge, lives here.

State that needs to reset between challenges does not live here. A challenge that needs fresh data each time it loads should use its own challenge-level setup-{hostname} instead, even if the track-level setup also touched the same data.

Per-challenge directories

Each numbered directory holds one challenge. The minimum is just assignment.md. Most challenges also have at least a check-{hostname} script for validating learner work, and many have a setup-{hostname} for state preparation and a solve-{hostname} for auto-completion when the learner clicks Skip or when an automated test runs.

Challenge slugs sometimes use a double-numbered format (01-01-, 01-02-) for sub-grouped challenges; this is fine as long as the prefix sort order matches the intended sequence. Some workshop families append a short random suffix (01-intro-8st6q0) to avoid cross-track collisions when many tracks share content templates.

assignment.md itself has two parts: YAML frontmatter (configuration) and Markdown body (the human-readable text). The frontmatter declares the challenge's identity, type, timing, tabs, and (for quizzes) answers. The body is the prose the learner reads. Chapter 9 covers the body in depth; Chapter 10 covers the tab configurations.

assets/ and other top-level files

assets/ is the conventional home for images, screenshots, video files, and any other media the assignment body references. Body Markdown reaches into it via relative paths (!Diagram), and the path style matters — forward slashes are portable, backslashes are a Windows authoring artifact that should be normalized before push.

MEMORY.md at the track root is a living build log. It is not required by the platform, but it is one of the highest-leverage files an author can maintain. Chapter 18 covers it in depth.

A few other files appear conditionally: a dispatch-prompts.txt for tracks produced via the Dispatch agentic-build feature, a README.md if the track wants one, and occasionally a Makefile or justfile for author conveniences.

Localized variants

Some tracks ship in multiple languages — an English original plus Spanish, Italian, Portuguese parallel tracks. The convention is <base-slug> for English and <base-slug>-es, <base-slug>-it, <base-slug>-pt for the localized variants. Each variant is its own track directory with its own track.yml and its own translated assignment.md files.

The maintenance discipline is to ship parallel tracks atomically: a fix or content change in the English original gets applied to the localized variants in the same change. Drift is what you want to avoid; the localized track that lags the English original by three months is the one that confuses learners the worst.

A track's life has six phases. Discovery, design, scaffold, build, polish, ship — and then maintain. This book is organized around them. This chapter sketches each one.

Discovery

Before any code is written, the author needs a clear answer to two questions: who is the learner, and what should they walk away knowing?

The learner is rarely a generalist. They are a sales engineer demoing your product to a prospect, a customer's platform team validating a new integration, a developer onboarding to a tool they have not used before, an exam candidate proving competence. Each of these has a different time budget, a different prior, a different failure tolerance. The track that tries to serve all of them serves none of them.

The lesson is rarely "use feature X." A useful lesson is closer to "this is how you would solve problem Y if you reached for our product, and here is what it costs." A track that lets the learner solve a real problem in their environment teaches the product better than a track that demonstrates the product on a contrived example.

Discovery is the right time to look at related tracks. What has been built already? What patterns work? What pieces of infrastructure can you reuse? An hour spent reading three existing tracks before scaffolding usually saves a day later.

Design

Design is where the architectural choices get made. Will the track use a sandbox preset, or a custom config.yml? Will compute be a container, a VM, or both? How many challenges? Are any of them quizzes, or are they all hands-on? Where does the learner spend their time — in a terminal, in a browser, in an IDE? Does the track need cloud accounts, and if so which ones?

These choices compound. A track that picks a preset early benefits from fast boots and zero infrastructure code; a track that picks a custom config early gets flexibility at the cost of about a day of plumbing. A track that picks containers gets sub-thirty-second boots; a track that picks VMs gets a real kernel but pays a minute of boot time per host.

The design phase produces a written sketch — not necessarily formal, but explicit enough that the build phase has a target. Chapter 4 walks the compute decision in depth; Chapters 5–7 walk the infrastructure declarations.

Scaffold

Scaffolding is the mechanical work of putting the directory structure in place. A track-builder skill, an internal template, or a simple mkdir ladder all work. The output is a directory with track.yml, an empty config.yml or a chosen preset reference, the right number of empty challenge directories, and a seeded MEMORY.md.

A few minutes spent scaffolding well saves time later. Creating a MEMORY.md at scaffold time, rather than as an afterthought, captures discovery decisions that would otherwise be lost. Numbering the challenge directories from the start, even if some are placeholders, locks the sequence early. Adding the assets/ directory before any image is ready prevents the small annoyance of forgetting it.

Build

Build is where the work feels like work. Lifecycle scripts get written, assignments get drafted, check scripts get implemented, infrastructure gets stood up. This is where most of a track's craft is exercised, and where most of its bugs are introduced.

The discipline that holds up best in the build phase is iterate against a real environment. Push early; push often; push in a non-production track copy if the real one is fragile. Every push that comes back working confirms a small piece of the design; every push that fails surfaces a problem at a price you can still afford.

Chapters 8–11 cover the build phase in detail.

Polish

Polish is where the track stops being a series of working pieces and starts feeling like a coherent product. Loading messages stop being default. Failure messages start teaching. The layout of each challenge starts considering what the learner is actually looking at. The pacing — challenge length, time limits, idle timeouts — starts matching the learner's actual rhythm.

Polish is the easiest phase to skip and the hardest one to retrofit. A polished track and an unpolished track have the same word count and the same infrastructure footprint, but they teach differently. The difference is felt, not articulated, by the learner — and it is the thing that turns a one-time training into something a customer actually recommends.

Chapters 12–15 cover polish.

Ship

Ship is the moment the track goes from author-controlled to learner-facing. The validation pass runs. The end-to-end test runs. The track gets pushed. Maybe it gets enrolled in a program — a workshop series, a certification track, a customer-onboarding flow.

Shipping is also where the most expensive bugs surface. A track that worked perfectly in the author's tests can break the moment it sees a learner who clicks Skip in the middle of challenge three. The end-to-end test from a fresh environment is the single highest-leverage validation in the entire build, and the one most authors skip because it takes the longest. (See Booklet Principle 23.)

Chapters 16–17 cover the ship phase.

Maintain

A shipped track is not a finished track. The product evolves; the underlying infrastructure rotates; the documentation it references gets restructured; the dependencies it pulls during setup release new versions. A track that worked in March can fail silently in May because of a change nobody told the author about.

The maintenance discipline is unglamorous: a periodic re-run, a glance at the deferred-work section of the track's MEMORY.md, a check of any external SaaS endpoints the track talks to. The reward for keeping up with maintenance is a track that quietly continues to work; the cost of skipping it is the track that becomes the team's least-favorite urgent fix when a customer hits the broken state during a demo.

Chapter 18 covers maintenance, including the MEMORY.md discipline that makes maintenance possible without re-deriving everything from scratch every time.

Part II — Design
04
Choosing Your Compute: Container, VM, or Sandbox Preset
The compute choice is the first architectural decision a track makes, and it is the one that shapes every decision downstream.

The compute choice is the first architectural decision a track makes, and it is the one that shapes every decision downstream. Boot time, capability, available tooling, maintenance burden, the kinds of patterns that work cleanly — all of them flow from this one decision. Get it right and the rest of the build feels easy. Get it wrong and you spend the whole build fighting the platform.

The three compute models

The platform offers three compute primitives: containers, virtual machines, and sandbox presets. They sit on a spectrum that trades boot time and flexibility against capability and maintenance.

Containers boot in under thirty seconds. The platform's published container images ship with the Instruqt agent helpers — fail-message, set-status, agent variable, set-workdir — built in; raw upstream Docker bases (ubuntu:22.04, python:3.11-slim) do not, and that distinction matters at check time. Containers run a single process tree, share the host kernel, and cannot host anything that needs a real init system or kernel modules. For most workshop content — terminal labs, scripting tutorials, anything where the lesson lives in the shell — they are the right answer.

Virtual machines boot in over a minute. They give you a full operating system: real systemd, real kernel, the ability to install almost anything. The cost is the boot time and the larger maintenance surface (you choose the image, you keep the tooling current). VMs are required when you need a display server, nested virtualization, kernel modules, or a software stack heavy enough that installing at runtime would exceed your patience or the lab's idle budget.

Sandbox presets are pre-built environments published by the platform or by a vendor whose product you are demonstrating. When a preset matches your need closely, it lets you skip config.yml and track_scripts/ entirely — no infrastructure file, no boot timing to debug, no agent installation to wait on. The trade-off is that you cannot modify a preset. If you need an additional exposed port, a custom dataset, or a third-party CLI, you have to drop the preset and build the environment yourself.

Containers: the default for most work

Reach for a container first. The two most common base images are a minimal shell environment (small, fast, ships with bash, curl, jq, git, and the agent helpers) and a heavier cloud-tooling image that adds the AWS, Azure, and GCP CLIs plus kubectl, helm, terraform, and gomplate.

The boot-time difference between the two is real but usually less than the cost of installing tooling at runtime. The trap is reaching for the lighter image, wiring up an aws_accounts block, and watching the learner type aws s3 ls on a host that has no aws binary on the path. Either pick the heavier image and accept the longer pull, or install the CLI in the track-level setup script before any challenge runs. (See Booklet Principle 2.)

For containers that serve a web surface, declare the ports in config.yml's ports: field and reference them from a service tab. Use private: true on backend containers (databases, internal APIs) so they are not directly accessible to learners — they remain reachable from other declared hosts on the sandbox network but do not appear as terminal tabs.

Virtual machines: when you actually need one

VMs are the right answer when:

  • The lesson involves a display server (any GUI app, any noVNC or VNC pattern, any RDP-via-Guacamole setup).
  • The lesson involves nested virtualization (a Kubernetes cluster running inside a VM, OpenShift Local, kind / k3d / k3s patterns where the runtime needs KVM).
  • The dependency stack is heavy enough that a runtime install would exceed several minutes and you want the boot to absorb it instead.
  • The lesson is on Windows. Windows tracks always require a Windows VM — there is no Windows container option.

Sizing a VM is its own decision. The platform exposes many machine_type: strings — e2-standard-2 for light demos, n2-standard-4 for most serious work, n2-highcpu-32 for AI or LLM workloads, n2d-highcpu-32 for nested KVM, t2a-standard-1 for Arm64. (See Cheat Sheet 11 for the full sizing matrix and rules of thumb.) Default to machine_type: over memory: + cpus: — the named string is the canonical sizing knob across most workshop-image families and partner-published custom images.

A note on what VMs do not have

A VM is not a guarantee of helper availability. A VM running a stock cloud Linux image (Ubuntu, Debian, Rocky) acquires the agent helpers via the bootstrap process and behaves the same way a container does. A VM running a custom image built with the Instruqt tooling baked in (most workshop-published image families) also has them. A VM running a raw upstream image you grabbed without checking — or, more often, a container running a raw upstream Docker base like ubuntu:22.04 or python:3.11-slim — may not have helpers at all, and fail-message will exit 127 with no useful error.

The mental model that holds up: helper availability is a property of the image, not of containers vs VMs. (See Booklet Principle 15.) Chapter 12 covers the guard pattern when you cannot guarantee the helpers are present.

Sandbox presets: the escape hatch

For tracks where the lesson is purely click-through against a managed UI — a hosted observability dashboard, a managed-database console, a vendor's own SaaS portal — every minute spent declaring and provisioning hosts is wasted. The lesson is the UI; the infrastructure is overhead the learner does not see and does not benefit from.

Some presets pre-deploy an entire stack before the first challenge. A track built on those needs zero track_scripts/ and often zero per-challenge setup — pure click-through against a pre-warmed environment. Check what your preset has already done before writing any setup work; you will often discover the platform has already done it for you.

The threshold for adoption: if the preset is a 90% fit for your track, use it and design around the 10%. If it is a 60% fit, build the environment yourself. The middle ground tends to produce tracks that fight the platform — half-preset, half-custom, with an integration seam that turns into the source of every bug. (See Cookbook Recipe 6 for the sandbox-preset adoption pattern in full.)

A decision framework

The decision is rarely as binary as the options suggest. Most tracks land in one of a handful of common shapes:

  • Single-container workstation — terminal lessons, scripting tutorials, anything where the lesson is "type this command, observe the output." Uses one container, often the cloud-tooling image. (Cookbook Recipe 1.)
  • Single-VM workstation — like the above but the lesson involves something containers cannot do (a display server, kernel modules, a heavy local stack). Uses one VM. (Cookbook Recipe 2.)
  • Container plus managed service — workshop UI surfaces a managed product (a hosted dashboard, a SaaS-hosted database). Uses one container as the workstation; the managed product is a virtualbrowsers: entry. (Cookbook Recipe 5.)
  • Multi-host composition — the lesson involves multiple cooperating hosts (a workstation plus a database, a primary plus a replica, a control plane plus workers). Uses two or more containers / VMs declared in config.yml. (Cookbook Recipes 3 and 4.)
  • Sandbox preset — the lesson is purely click-through against a managed UI. No config.yml needed. (Cookbook Recipe 6.)

Decision Trees Tree 1 walks the question tree from "what is the lesson actually about" to a specific shape. When the answer is not immediately obvious, the tree resolves it in seconds. The Pattern Cookbook then carries the implementation in full.

config.yml is the file that turns a track from declarative metadata into a running environment. It declares what hosts the track needs, what cloud accounts to provision, what secrets to inject, what custom Terraform modules to run ahead of everything else. The format is YAML, the version directive is version: "3", and the resource types are stable across most tracks.

This chapter covers each resource type. Chapter 6 covers cloud-account permissions in depth.

The version directive

Every config.yml starts with version: "3". This is the format version for the file itself, not the version of any product the track demonstrates. Treat it as boilerplate — copy it from any working track.

Tracks that adopt a sandbox_preset in track.yml skip config.yml entirely. There is no version directive needed; the file does not exist.

Containers

containers: declares Docker containers. Each container is a map of identity and configuration:

containers:
  - name: workstation
    image: example/shell:latest
    shell: /bin/bash
    ports: [5000, 8080]
    memory: 1024
    environment:
      LOG_LEVEL: debug
    private: false

The fields you will use most:

  • name is the hostname inside the sandbox. Lifecycle scripts target it (track_scripts/setup-workstation runs on this container). Tab declarations reference it (hostname: workstation).
  • image is the registry path. Use the platform's published images for workstations; use any registry you can reach for application workloads. Avoid baking long-lived credentials into custom images.
  • shell sets the default shell for terminal tabs. /bin/bash is the common choice; /bin/zsh works if the image has it installed.
  • ports declares which ports the container exposes to the sandbox network. A service tab pointed at this container can only reach ports declared here. Other declared hosts can also reach these ports.
  • environment is for non-sensitive runtime values — log levels, feature flags, debug toggles. Never put credentials here. Anything in environment: lives in the source tree forever and is visible in the learner's debug log. Use secrets: instead. (See Booklet Principle 6.)
  • private: true marks a container as not directly accessible to learners. It still appears in the sandbox network for cross-host access; it just does not show up as a terminal tab.
  • entrypoint overrides the image's default entrypoint. Common pairing: entrypoint: bash with shell: /bin/bash for workstations that need an interactive bash session as their primary process.

Virtual machines

virtualmachines: declares VMs. The shape mirrors containers but with VM-specific fields:

virtualmachines:
  - name: server
    image: ubuntu-os-cloud/ubuntu-2404-lts-amd64
    machine_type: n2-standard-4
    shell: /bin/bash
    allow_external_ingress: [http, https, high-ports]
    nested_virtualization: true
    provision_ssl_certificate: true
    environment:
      ROLE: primary

The fields that catch new authors:

  • machine_type is the canonical sizing knob. Default to it over memory: + cpus:. (See Cheat Sheet 11 for the matrix.)
  • allow_external_ingress is the explicit allowlist of ingress channels. [http, https] is the standard for most web surfaces; add high-ports when the track needs to expose ports above the standard 80/443/8443. Without this, the learner's browser cannot reach the VM at all.
  • nested_virtualization: true enables KVM-on-the-VM. Required for kind / k3d / OpenShift Local / any pattern where Kubernetes runs inside the VM. Pair with the n2d-* machine family for best performance.
  • provision_ssl_certificate: true asks the platform to provision a real SSL certificate for the VM and rotate it out-of-band. Use this rather than running certbot at runtime; certbot at runtime hits the Let's Encrypt rate limit (5 certs per registered domain per week) under load.
  • environment: on a VM is also useful for cross-VM coordination — for example, setting LEADER_URL: http://server-1:8081 so worker VMs can curl the leader's published env at boot. As with containers, never put credentials here.

A VM can also use a custom image you published to a GCP project. Reference it as <project>/<image-name>. The trade-off: faster boot vs ongoing image maintenance. Worth it when install-at-setup-time would exceed several minutes; not worth it for tools that install in seconds. (See Cookbook Recipe 2 for the custom-image variant.)

Virtual browsers

virtualbrowsers: declares pre-pointed browser tabs. Each entry is a name and a URL:

virtualbrowsers:
  - name: app
    url: https://app-8080-${_SANDBOX_ID}.env.play.instruqt.com
  - name: cloud-console
    url: https://console.example.com

Three URL forms work:

  • Sandbox-public URLhttps://<host>-<port>-${_SANDBOX_ID}.env.play.instruqt.com routes through the platform's traffic proxy to a port on a declared host. Use this for in-sandbox web apps that the learner needs to reach in the browser.
  • Internal hostname URLhttps://server:8443/path resolves the hostname against the named VM in config.yml. Useful when one VM serves multiple paths and you want each as its own tab.
  • External URL — any HTTPS URL outside the sandbox. Use for cloud-provider consoles, vendor SaaS portals, or any public site the learner needs to reach.

The shell-style ${_SANDBOX_ID} interpolation works in virtualbrowsers: URLs but not everywhere. [[ Instruqt-Var ]] works in most other config fields (see Chapter 11).

Cloud account blocks

aws_accounts:, azure_subscriptions:, and gcp_projects: each declare a cloud account the platform provisions for the learner. Each block names the account, declares the services or resource providers needed, and specifies IAM policies or roles. The platform injects credentials as environment variables on every host. Chapter 6 covers the IAM and policy details in depth.

A trap worth naming up front: scripts that reference INSTRUQT_AWS_ACCOUNT_ (or the Azure / GCP equivalents) without a matching resource block in config.yml will receive empty strings at runtime, and the CLI calls will fail with cryptic auth errors rather than a clear "variable not set" message. (See Cheat Sheet 03 for the full env-var reference.)*

Secrets

secrets: declares named secrets the platform injects as environment variables on every host:

secrets:
  - name: GITHUB_TOKEN
  - name: VENDOR_API_KEY
  - name: WORKSHOP_LICENSE

The values are set in the Instruqt platform UI, never in the source tree. They are visible to lifecycle scripts as ordinary environment variables (${GITHUB_TOKEN}, etc.) but never appear in the file system or in the learner's debug log.

Use secrets: for every credential the track uses: SSH keys, API tokens, license keys, registry credentials, deploy keys, vendor authentication. The rule is absolute: credentials never go in environment: blocks; they always go in secrets:. (See Booklet Principle 6.)

Custom Resources

Custom Resources are Terraform modules the platform provisions ahead of containers, VMs, and cloud accounts. They are configured in the platform Web UI rather than in config.yml itself, so a track that uses one carries no syntactic marker — just a cluster of related secrets in the secrets: block and the assumption that the resource will exist by setup time.

The output convention: each Terraform output becomes an environment variable prefixed with the resource's uppercased name. A resource named sqldb with output endpoint is available to setup scripts as $SQLDB_ENDPOINT.

Reach for Custom Resources when the platform's built-in cloud accounts do not cover what you need — for example, a managed service that requires a Terraform-driven setup, or a pre-provisioned Azure subscription tied to a specific billing scope. Most tracks do not need them; the cloud-account blocks above are enough.

Putting it together

A typical mid-complexity config.yml declares one container as the workstation, one VM for the system the learner is operating on, one cloud account for any provider integrations, and a handful of secrets for credentials the track needs. A small config.yml declares one container and one secret. A complex config.yml declares half a dozen hosts, two cloud accounts, and a Custom Resource — readable and structured but worth a careful diagram before commit.

Chapter 6 covers the cloud-account blocks in depth.

A track that demonstrates a cloud product, or that operates inside a real cloud account at all, has to decide what permissions the learner gets. The platform's cloud-account blocks make this declarative — but the declarations are nuanced, and the failure modes are particular. This chapter walks them.

The two-account model

Most tracks need two permission contexts inside a single cloud account: an admin context for setup and an admin-managed context for the learner. The platform supports this directly. AWS uses managed_policies and admin_managed_policies (or iam_policy and admin_iam_policy for inline JSON). Azure uses roles and admin_roles. GCP uses roles and admin_roles likewise.

The pattern is: setup scripts use the admin context to provision resources (create S3 buckets, set up IAM users, deploy infrastructure), and the learner's terminal session uses the narrower context to do the lesson. The setup work happens once at track start; the learner never sees the admin credentials.

aws_accounts:
  - name: lab
    services: [ec2, s3, iam]
    regions: [us-west-2]
    managed_policies:
      - "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
    admin_managed_policies:
      - "arn:aws:iam::aws:policy/AdministratorAccess"

For inline policies (when a managed policy does not exist in exactly the shape you want), use iam_policy and admin_iam_policy with literal multi-line JSON block scalars. Setup scripts use the admin policy; the learner's role uses the narrower one.

AWS IAM patterns

The AWS block exposes the most flexibility. services is the list of AWS service slugs (ec2, s3, iam, cloudformation, etc.) the platform pre-enables for the account. regions is the list of regions the account is rooted in.

Beyond the basic two-account model, AWS supports:

  • scp_policy — a Service Control Policy that constrains what the account can do at the organizational level. Useful for security-focused tracks where the learner needs to experience working under guardrails.
  • Bring-your-own-credentials (BYOC) — pass AWS credentials in via secrets: (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION) instead of declaring an aws_accounts: block at all. Use this when the track is teaching against a customer's own AWS account, not a sandbox-provisioned one. The trade-off: no IAM scoping, no SCPs, no cleanup.

A common mistake: declaring an aws_accounts: block, then writing a script that references INSTRUQT_AWS_ACCOUNT_LAB_ACCOUNT_ID but spelling the resource name differently in the block. The auto-injected env var name uses the resource's name: field uppercased with hyphens replaced by underscores. name: my-lab becomes INSTRUQT_AWS_ACCOUNT_MY_LAB_*. Mismatches produce empty strings at runtime.

Azure roles and admin_roles

Azure subscriptions use services for resource-provider namespaces (Microsoft.Compute, Microsoft.Network, Microsoft.Storage, etc.) which the platform pre-registers via az provider register before the subscription hands off to the learner. roles and admin_roles are RBAC role names (Contributor, Reader, User Access Administrator).

When the track host is the platform's cloud-tooling image and the track has an Azure subscription block, the platform injects Terraform-style aliases automatically: ARM_CLIENT_ID, ARM_CLIENT_SECRET, ARM_TENANT_ID, ARM_SUBSCRIPTION_ID, plus AZURE_LOCATION for the configured region. These aliases match the AzureRM Terraform provider's native env-var names, so a Terraform setup needs no explicit credential mapping.

For non-Terraform Azure work, the same image also supports az login --service-principal -u "$ARM_CLIENT_ID" -p "$ARM_CLIENT_SECRET" --tenant "$ARM_TENANT_ID" directly — typically wrapped in a retry loop because tenant edge propagation can lag credential injection by a few seconds.

GCP roles and service accounts

GCP projects have one extra wrinkle: each project gets two service account keys, both base64-encoded. The non-admin SERVICE_ACCOUNT_KEY is the learner's identity; the ADMIN_SERVICE_ACCOUNT_KEY is the setup identity. Both arrive as base64 strings in the environment; setup scripts decode and write them to disk before authenticating:

echo "$INSTRUQT_GCP_PROJECT_LAB_ADMIN_SERVICE_ACCOUNT_KEY" | base64 -d > /tmp/sa.json
gcloud auth activate-service-account --key-file=/tmp/sa.json
trap "rm -f /tmp/sa.json" EXIT

The trap ensures the SA JSON is wiped on script exit — normal, error, or signal. Do not let it persist; once the script is done, the credential should not be on disk.

For tracks that share the same setup script across multiple GCP project names, bash indirect expansion resolves the variable name dynamically:

SA_KEY_VAR="INSTRUQT_GCP_PROJECT_${PROJECT_NAME}_ADMIN_SERVICE_ACCOUNT_KEY"
echo "${!SA_KEY_VAR}" | base64 -d > /tmp/sa.json

Roles are GCP role names (roles/storage.objectCreator, roles/storage.objectUser, etc.). A common mistake worth naming explicitly: roles/writer is not a valid GCP IAM role. Use a real role name from the predefined list. Sandbox provisioning rejects unknown roles, sometimes silently.

Auto-injected credentials

Across all three clouds, the auto-injected variables follow the same pattern: INSTRUQT_<CLOUD>_<RESOURCE-TYPE>_<NAME>_<FIELD>, where <NAME> is the resource's name uppercased with hyphens replaced by underscores. (See Cheat Sheet 03 for the full enumeration of fields per cloud.)

A second set of unprefixed aliases appears on the platform's cloud-tooling image specifically — AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY for AWS, the ARM_* set for Azure. These match each cloud CLI's standard env-var names so commands work without explicit credential flags. On other images, only the prefixed form is available.

INSTRUQT_USER_EMAIL is not guaranteed in all play contexts — invite-link plays without registration receive an empty string. Guard with a fallback when the script depends on the email being set:

EMAIL="${INSTRUQT_USER_EMAIL:-guest@example.com}"

Common permission failures

Three failure modes account for most cloud-permission bugs:

  • Variable referenced without resource block. A setup script that references INSTRUQT_AZURE_SUBSCRIPTION_* with no azure_subscriptions: block in config.yml receives empty strings; the CLI then fails with cryptic auth errors. The fix is always to add the resource block; the diagnostic is always to confirm the block exists with the expected name.
  • Role name typo or invalid role. GCP roles/writer is the canonical example, but each cloud has its own version. Sandbox provisioning may silently fail or hand off without the intended permissions; the symptom shows up later when the learner's first cloud command returns Access Denied.
  • Credentials in environment: instead of secrets:. Anything in environment: lives in the source tree and the debug log forever. The fix is to move to secrets:. The diagnostic, if you push first and ask later, is to rotate the credential and treat it as compromised. (See Booklet Principle 6.)

The Pattern Cookbook covers AWS, Azure, and GCP single-cloud track shapes in Recipes 7, 8, and 9 respectively, and the multi-cloud wiring in Recipe 10.

A track that demonstrates a web application has to decide how the application appears to the learner: as a tab inside the lab UI, a popped-out browser window, an external link, or a remote-desktop session. The choice depends on what the application is, where it runs, and how the learner needs to interact with it.

The tab type spectrum

The platform supports six tab types, grouped by what they connect to:

  • terminal — a shell on a declared container or VM. Native PowerShell on Windows VMs (no Guacamole needed). Optional cmd: for a startup command (cmd: su - student to drop into a non-root user; cmd: screen -xRR to auto-attach to a multi-pane session).
  • code — a file or folder editor on a declared host. path: can be a single file or a directory tree; the latter renders a folder explorer. Optional workdir: opens the editor in a specific directory.
  • service — an HTTP server on a declared host port, routed through the platform's traffic proxy. The most common tab type for in-sandbox web applications.
  • browser — a cloud console browser tied to a virtualbrowsers: entry. Use for managed cloud consoles when the platform provisions the cloud account.
  • external — an embedded URL frame for external sites. Use for vendor SaaS the platform should not authenticate to.
  • website — an external URL tab. Supports ${_SANDBOX_ID} interpolation. The URL must be HTTPS — instruqt track push rejects http:// at validation time. (See Booklet Principle 19.)

Cheat Sheet 05 carries the full reference for each.

Service tabs and the proxy rule

The service tab is where most in-sandbox web work happens. A container exposes port 5000; a service tab points at hostname: workstation, port: 5000; the learner's browser opens the tab and the platform's traffic proxy routes the connection.

The proxy rule that catches new authors: client-side fetch() calls inside the served page must use relative paths. The browser sees the proxy URL, not the internal Docker hostname. A fetch('http://api:8080/items') call hardcodes an internal hostname the browser cannot resolve and the request fails with no useful error. A fetch('/api/items') call routes through the same proxy endpoint that served the page and works.

This generalizes to a stronger architectural rule: co-host the portal and the API on the same host and same port, route by URL prefix on the server side. A single uvicorn or Flask process can serve the portal at / and the API at /api/. Every fetch('/api/...') call is a relative path that just works. The cross-origin headaches disappear. (See Booklet Principle 5 and Cookbook Recipe 25 for the co-hosted portal-plus-API pattern.)*

When the served application has strict iframe-blocking headers (a X-Frame-Options: DENY or a Content-Security-Policy with frame-ancestors 'none'), the service tab can override them with custom_request_headers: and custom_response_headers: overrides directly in the tab declaration. (See Cookbook Recipe 43 for the CSP-stripping pattern.)

Browser tabs versus service tabs

A browser tab and a service tab look similar from the learner's perspective — both surface a web UI inside the lab — but they connect to different things. A service tab connects to a port on a declared host. A browser tab connects to a virtualbrowsers: entry, which can point at any URL (a sandbox-public URL via ${_SANDBOX_ID}, an internal hostname, or an external SaaS URL).

Use service for in-sandbox web apps. Use browser for provisioned cloud consoles where the platform is also handling the cloud-account provisioning. Use website for external public URLs that need ${_SANDBOX_ID} interpolation or new_window: true. Use external for embedded iframes of external sites where the platform should not authenticate.

Graphical applications in the browser

A track that needs to expose a graphical application (an IDE running with a real GUI, a vendor's desktop client, a Windows-only tool) has three viable patterns:

  • Guacamole RDP / VNC — run Guacamole as a service tab pointed at a Windows VM (RDP on port 3389) or a Linux VM with a VNC server (port 5901). The learner gets a desktop in the browser. Pre-configure the connection in /config/guacamole/user-mapping.xml during setup. (Cookbook Recipe 16.)
  • noVNC — run a VNC server on a Linux VM with xfce4 or another lightweight window manager, then expose noVNC's WebSocket gateway via a service tab on port 8443. The learner gets a desktop in the browser without Guacamole's overhead. (Cookbook Recipe 17.)
  • KasmVNC for a single application — run the application inside a Docker container using a KasmVNC-based image, expose the container's port via a service tab. The learner sees just the application, not a full desktop. (Cookbook Recipe 18.)

Display server constraint: containers do not support a virtual display server. Any GUI that needs X11, Xvfb, or a real graphics stack must run inside a VM, not a container. Web-based IDE servers like code-server are unaffected because they are web servers, not GUI applications.

Private network exposure

A track that needs to demonstrate a service running on a VM with no public IP — a private database, a service in an isolated VPC, an internal API — uses GCP IAP (Identity-Aware Proxy) tunneling. The pattern is:

  1. The setup script on a workstation container runs gcloud compute start-iap-tunnel against the target VM and target port, mapping it to a local port (port 22 → localhost:2222 for SSH, port 80 → localhost:8888 for HTTP).
  2. Terminal tabs use cmd: ssh root@localhost -p 2222 ... to enter the VM.
  3. Service tabs point at the local tunnel port to expose the VM's web surface.

The IAP tunnel runs in the background of the workstation container; nohup ... & disown keeps it alive across the setup script's exit. The trade-off: the workstation must have gcloud installed (so the cloud-tooling image is the right base) and must have iap.tunnelResourceAccessor on both user and admin roles. (See Cookbook Recipe 11 for the IAP tunnel pattern in full.)

Choosing between patterns

When the application is a web app already running on a port, use a service tab. When the application is a graphical desktop or full GUI, use Guacamole, noVNC, or KasmVNC depending on whether the learner needs a full desktop or just one app. When the application is an external SaaS, use external or website depending on the iframe behavior. When the application lives on a private VM, use an IAP tunnel.

Decision Trees Tree 2 walks the choice. The Pattern Cookbook covers each shape in full.

Part III — Build
08
Lifecycle Scripts: Setup, Check, Solve, Cleanup
Lifecycle scripts are the connective tissue between declarative YAML and a working lab.

Lifecycle scripts are the connective tissue between declarative YAML and a working lab. They run at specific points — when the environment provisions, before each challenge, when the learner clicks Check, when they click Solve, when the track ends. A track without lifecycle scripts is rare and usually small; a track with ten challenges has thirty or forty scripts working in concert.

This chapter walks the four script types, the conventions that make them robust, and the traps that catch authors most often.

The four script types

Every lifecycle script is one of four kinds:

  • Setup scripts prepare the environment. Track-level setup runs once before the first challenge; per-challenge setup runs before each individual challenge starts. Setup writes files, installs tools, seeds data, provisions resources, starts services.
  • Check scripts validate learner work. They run when the learner clicks the Check button. exit 0 means the challenge is complete; exit 1 means it is not. The script's job is not to determine pass or fail but to teach the learner what they got wrong. Chapter 12 covers check scripts in depth.
  • Solve scripts auto-complete the challenge. They run when the learner clicks Skip, or when an automated test runs the track end-to-end. A solve script's job is to produce the exact end state the check script validates — no more and no less, regardless of the host's current state.
  • Cleanup scripts undo what setup did. Track-level cleanup runs once after the lab ends; per-challenge cleanup runs after each challenge. Cleanup deletes external resources, releases SaaS tenants, returns pool-checkout accounts. Cleanup is mandatory for any external resource the track provisions. (See Booklet Principle 12.)

Linux hosts use bash; Windows VMs use PowerShell. The script for a Linux host opens with #!/bin/bash (or #!/usr/bin/env bash), then set -euo pipefail, then a bootstrap wait. The script for a Windows VM has no shebang line at all — Windows lifecycle scripts are dispatched as PowerShell directly.

Naming and hostname suffixes

Each script's filename encodes which host it runs on: setup-workstation runs on a host named workstation, check-server runs on a host named server. The hostname segment must match a name: field in config.yml exactly. A typo silently produces a script that never runs.

The two locations are:

  • track_scripts/ at the track root for track-level scripts. track_scripts/setup-workstation runs once before the first challenge; track_scripts/cleanup-workstation runs once at the end of the track.
  • <NN-challenge>/ inside each challenge directory for per-challenge scripts. 01-first-challenge/setup-workstation runs before the first challenge starts; 01-first-challenge/check-workstation runs when the learner clicks Check during that challenge.

Where work belongs is its own decision. Bootstrap, image registration, cluster create, repo clone, helper-script seeding, shared certificate generation — all track-level. State that needs to reset between challenges, per-challenge data seed, "trigger this outage now," progressive sparse-checkout expansion — all per-challenge. Putting one-time work in per-challenge setup wastes startup time on every challenge. Putting per-challenge state in track-level setup means later challenges cannot reset it.

The bootstrap wait

The first non-comment line of every track-level setup script is a wait for the agent's bootstrap sentinel:

#!/bin/bash
set -euo pipefail

until [ -f /opt/instruqt/bootstrap/host-bootstrap-completed ]; do
  sleep 1
done

# Real work starts here.

Without the wait, the script can race the agent's own initialization and fail in non-obvious ways — variables not yet injected, helpers not yet on path, Docker not yet started. The wait is cheap (one second granularity) and removes an entire class of flaky behavior.

The cloud-tooling base image uses a different sentinel file: /opt/instruqt/bootstrap/gcp-bootstrap-completed. Same directory, different name. Match the sentinel to the image you are using. (See Cheat Sheet 16 for the full readiness-and-retry pattern reference.)

For services the setup is going to use — a Postgres database, an HTTP API, a Kubernetes cluster — wait on readiness, not just on running. A container is "running" seconds before it is ready to accept queries; an HTTP service binds its port milliseconds before it can serve a request. Replace sleep 30 and similar fixed waits with polling loops that exercise the actual interface:

# For Postgres:
until pg_isready -h db -U app -d app; do sleep 1; done

# For HTTP services — accept all "responsive" status codes:
until curl -s -o /dev/null -w "%{http_code}" http://localhost:8000 \
  | grep -qE "^(200|302|401)$"; do sleep 3; done

The script exits the wait only when the next thing that touches the service can actually succeed. (See Booklet Principle 7. Cookbook Recipe 21 covers bootstrap wait, Recipe 22 covers wait-for-port readiness.)

set -euo pipefail and its traps

set -euo pipefail belongs at the top of every lifecycle script. It tells bash to abort on the first non-zero exit (-e), to treat unset variables as errors (-u), and to propagate failures through pipelines (pipefail). Without it, intermediate failures pass through unflagged and a script can complete with the wrong end state.

There are three silent failure modes worth naming explicitly because they look fine on a quick read.

The malformed shebang. A line like #set -euxo pipefail is a comment. The leading # makes it invisible to bash. The set builtin never executes; the abort-on-failure behavior never engages. The script appears to complete; the actual end state is whatever happened to survive the failures. This bug copy-pastes easily because the line above is #!/bin/bash. (See Booklet Principle 13.)

The missing -o flag. A line like set -eux pipefail reads pipefail as a positional parameter, not as the option name. Only -e, -u, and -x are applied. Pipeline failures (curl ... | jq ... where curl fails) pass through unflagged. The fix is one character: set -euxo pipefail. (Same Booklet Principle 13.)

The unguarded subprocess call. Inside a check script with set -euo pipefail, an unguarded python3 -c '...', curl ..., or psql ... that exits non-zero will trigger an immediate, silent bash exit. The platform then renders a generic "Something went wrong" modal that points at no specific cause. The fix is to wrap each subprocess call in an explicit error handler:

RESULT=$(curl -fsS "$API/healthz") || {
  fail-message "API health check failed. Is the service running on port 8000?"
  exit 1
}

The cost is roughly half a line per call. The payoff is that a check script surfaces a real error message when something fails, instead of silently dropping the learner into an undifferentiated UI. (See Booklet Principle 8.)

A defensive grep before every push catches the first two:

grep -rn "^#set -" track_scripts/ */setup-* */check-*
grep -rn "set -eux pipefail$" track_scripts/ */setup-* */check-*

Both should return zero matches.

Helper modules

The platform's images ship a small set of agent helpers — short commands every script can use:

  • fail-message "..." renders a learner-facing failure message in the UI. Pair with exit 1.
  • set-status "..." is the success-side counterpart. Render at the end of a passing check, before exit 0.
  • agent variable set/get stores or reads named values that bridge between scripts and the assignment body. Chapter 11 covers this in depth.
  • set-workdir /path sets the default working directory for terminal tabs on the host where it runs.

Helper availability is not universal. The platform's published container images and most workshop-published custom images ship them. Stock cloud Linux images (Ubuntu, Debian, Rocky) acquire them during bootstrap. Raw upstream Docker images (ubuntu:22.04, python:3.11-slim, node:22-bookworm) often do not have them, and fail-message on those exits 127 with no useful error.

The mental model: helper availability is a property of the image, not of containers vs VMs. When you can't guarantee the helpers are present, guard with || true:

fail-message "API not reachable. Run: ./start-api.sh" || true
exit 1

Or fall back to plain echo and exit 1. The styled UI hint is lost, but the message surfaces in the platform's debug log and the script fails clean instead of mysteriously. (See Booklet Principle 15. Cheat Sheet 14 carries the full helper reference.)

A second class of helpers ships with some workshop-image families: a sourceable bash function library at /usr/local/bin/<workshop-functions>.sh with project-specific helpers like retry, wait_for_dpkg, or domain-specific provisioning functions. Source the library at the top of setup; call helpers by name. When you build on top of a workshop-image family, check whether such a library exists before reimplementing the same patterns from scratch.

set-workdir

set-workdir changes the default directory terminal tabs open in. By default, terminal tabs open in /root (or the home directory of whatever user the shell starts as). A track that wants every terminal to land in /workspace runs set-workdir /workspace once during track-level setup.

The per-tab alternative is to set workdir: on the terminal tab itself in assignment.md. Use the per-tab form when different challenges need different working directories. Use set-workdir when the working directory is the same across the whole track.

Cross-host state with agent variable

Lifecycle scripts on different hosts share state through the agent variable store. A setup script on host A can run agent variable set DB_URL "postgresql://..."; a setup script on host B can later run URL=$(agent variable get DB_URL) and read the same value. The variables are also visible to the assignment body via [[ Instruqt-Var ]] interpolation.

Use this for short, structured-string values. For multi-field state, use a JSON broker sidecar (a small HTTP service on a port that returns JSON) — it scales better than concatenating fields into one string and parsing them out later. Chapter 11 covers both patterns and the sandbox-public URL exposure pattern in depth.

Cleanup strategy

Cleanup is the script most authors forget. Track-level cleanup runs once after the lab ends; it is the place to delete every costed external resource the track provisioned: SaaS tenants, LLM API keys, GCP GPU VMs, managed-service trial orgs, per-participant identity-provider users.

The discipline is to write the cleanup script in the same session you write the provisioning code, not later. The provisioning script captures the resource's identifier with agent variable set; the cleanup script reads it with agent variable get and issues the delete:

# In setup, after creating the resource:
PROJECT_ID=$(curl -fsS -X POST "$API/projects" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"name":"lab-'"$INSTRUQT_PARTICIPANT_ID"'"}' | jq -r '.id')
agent variable set ES_PROJECT_ID "$PROJECT_ID"

# In cleanup-{hostname}:
PROJECT_ID=$(agent variable get ES_PROJECT_ID)
curl -X DELETE "$API/projects/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN"

If the cleanup script is hard to write — branchy, conditional on which provisioning step succeeded, dependent on parsing complex API responses — the design has accumulated complexity that will hurt future maintenance. A well-shaped cleanup is mechanical: read N agent variables, issue N delete calls, exit zero. (See Booklet Principle 12.)

Cleanup is the single highest-frequency forgotten step. A track that provisions external resources without cleanup leaves orphans behind every play. The orphans accumulate quietly, costs accrue, quotas fill, and eventually somebody traces a strange budget alert back to a track that has been bleeding for weeks. Write the cleanup before the provisioning, even if you swap which one you implement first.

The assignment is the part of the track the learner reads. It is also the part most authors over-write. The default failure mode is too many words, too much tutorial framing, too many "as you can see" sentences. The track that teaches well does the opposite — it sets up the move, gets out of the way, and lets the learner do the work.

This chapter covers the structure of assignment.md, the body conventions that hold up, and the specific Markdown extensions Instruqt provides.

Anatomy of assignment.md

Every challenge has one assignment.md file. The file has two parts:

---
slug: install-the-database
id: 0123456789ab
type: challenge
title: Install the database
teaser: Set up Postgres and verify it boots cleanly.
difficulty: basic
timelimit: 0
tabs:
  - title: Terminal
    type: terminal
    hostname: workstation
notes:
  - type: text
    contents: |
      ![Splash](../assets/splash.png)
---

The body of the challenge is everything below the second `---`.
The learner reads this; the YAML above is configuration.

The block above the second --- is YAML frontmatter. It declares the challenge's identity, type, timing, tabs, and (for quizzes) the answers. The block below is Markdown — the prose, the code, the images, the buttons, the embedded media that the learner sees.

Frontmatter fields

The frontmatter fields you will use most:

  • slug — the challenge's identifier. Should match the directory name (minus the leading number prefix).
  • id — UUID. Auto-assigned on first push; never modify after.
  • typechallenge for hands-on challenges (the common case) or quiz for multiple-choice questions.
  • title and teaser — the challenge's name and one-line summary.
  • difficultybasic / intermediate / advanced, or empty.
  • timelimit — usually 0, meaning the track-level limit applies. Use a non-zero value when the challenge needs its own per-challenge clock (rare).
  • tabs — the tab declarations. Chapter 10 covers tabs in depth; Cheat Sheet 05 carries the type reference.
  • notes — pre-challenge slides shown before the challenge starts. Three types: text (Markdown), video (YouTube embed or local .mp4), image (URL). Notes only render as overlays when track-level enhanced_loading: false.

For quiz challenges, frontmatter also carries answers (an array of strings) and solution (an array of zero-based indices identifying the correct answers — multi-select supported by listing multiple indices).

A challenge can override track-level lab_config with a challenge-level lab_config. The most useful override fields are default_layout, default_layout_sidebar_size, and custom_layout (a JSON-encoded layout tree for precise multi-pane splits). Chapter 10 covers the layout overrides.

The body: voice and structure

The body is plain Markdown with a handful of Instruqt-specific extensions. The extensions are covered in the next sections; the prose is yours.

The voice that holds up:

  • Address the learner directly. "You will install Postgres" reads better than "The user is going to install Postgres." Tracks are performed; performances have a person doing them.
  • Use imperatives for actions. "Run psql. Enter the password when prompted." reads better than "The next step involves running psql, after which you will enter the password."
  • Set up the move, get out of the way. A paragraph of context, then the runnable code block, then a sentence about what to expect. Not three paragraphs of theory before the first command.
  • Trust the reader. Adult learners do not need every concept defined twice. A track that re-explains foreign keys at every challenge irritates them; a track that uses foreign keys without belaboring them respects their time.
  • Show the failure. When a step has a likely failure mode, name it. "If you forgot to start the database, this command returns connection refused — start the database with systemctl start postgresql and try again." A track that anticipates the learner's stumbles teaches better than one that pretends nothing can go wrong.

The step-based body convention

Some authoring families use a standardized step-based body structure that holds up across many tracks:

---
[Optional: pre-work section with downloads, prerequisites, or context]
---

<h2 style="color: #37C980;">Step 1: Action title</h2>

[Step content — set up the move, runnable block, expected output.]

---

<h2 style="color: #37C980;">Step 2: Action title</h2>

[Step content.]

---

✅ [Completion statement. Move on to **Challenge N** to [next action].]

The conventions: open with a horizontal rule (---) only if there is pre-work before Step 1. Separate every step with a horizontal rule. Number steps sequentially within each challenge (never skip, never reuse). Close each challenge with ✅ <sentence> to visually separate the closer from step content; use inline as a "wrong path" marker within step content.

The colored <h2> is one team's visual style; the underlying rule is "explicit numbered steps separated by visible dividers, ending with a clear completion marker." Use the convention or do not, but do not mix conventions within a single track.

Code block modifiers

Fenced code blocks in assignment.md are more than syntax highlighting. The language tag plus the modifier syntax tells the platform what to do with the block:

  • ```bash,run — click-to-run in the active terminal. Most common form.
  • ```run — click-to-run, no syntax highlighting. Language-less runnable block.
  • ```copy — adds a copy-to-clipboard button. Works without a language tag.
  • ```nocopy — disables the default copy button. Use on output blocks.
  • ```bash,wrap,copy — wrap long lines plus copy. Useful for long one-liners.
  • ```bash,nocopy,run — run-only, no copy button.
  • ```bash — plain. No run button. Use for snippets containing placeholders the learner must edit before running.

Match the language tag to what is actually executing. ```python,run on a bash command tells the platform to run it as Python — produces a SyntaxError, confuses the learner. The visual rendering is unchanged; the bug is invisible until execution. (See Booklet Principle 22.)

A second trap worth flagging: ```bash {"run": "execute"} looks like it should work but is not valid Instruqt syntax. The block renders but no run button appears. The modifier syntax is comma-separated after the language tag, never JSON-style. Always use the comma form.

For snippets that contain angle-bracket UPPER-DASH-CASE placeholders (<DATABASE-NAME>, <USER-EMAIL>), use ```copy rather than ```bash,run. The learner is forced to paste, edit, and execute manually — they cannot accidentally run a placeholder-containing command. Pair with an opt-in <details> hint when the placeholder is non-obvious.

Cheat Sheet 06 carries the full modifier reference.

Tab buttons and inline navigation

Tab buttons let the body reference tabs by index:

[button label="Open the editor"](tab-1)

Tab indices are zero-based and follow the order in tabs:. Optional parameters control appearance: variant= (success / warning / danger / outline), block=true for full-width, color overrides for custom palettes. The href can be empty ((#)), point at another challenge ((section--02-deploy)), or use any other anchor. (See Cheat Sheet 07 for the full reference.)

Variable interpolation

The platform interpolates two kinds of variables in the body:

  • [[ Instruqt-Var key="..." hostname="..." ]] reads from the agent variable store. Both attributes are required. Resolves before the field's content is parsed — works inside fenced code blocks without escaping.
  • ${_SANDBOX_ID} is the sandbox identifier as a shell-style variable. Works in tab url: and virtualbrowsers: url:, not in body Markdown.

Auto-injected cloud credentials (INSTRUQT_AWS_ACCOUNT_, etc.) are in the agent variable store automatically — reference them directly in [[ Instruqt-Var ]] without a bridge. Two exceptions: _SANDBOX_ID and _SANDBOX_DNS are shell-only env vars, not in the variable store. Bridge them with agent variable set SANDBOX_ID "$_SANDBOX_ID" if the body needs them. (See Cheat Sheet 13 for the full coverage map.)*

Admonitions and callouts

GitHub-style admonitions render as colored callouts:

> [!NOTE]
> A tip the learner should know.

> [!WARNING]
> What can go wrong.

> [!IMPORTANT]
> A point you really do not want them to miss.

> [!TIP]
> A shortcut or hint.

Use sparingly. A track that puts every paragraph in a [!NOTE] block is a track where nothing stands out as important.

Notes and pre-challenge slides

notes: in frontmatter is a list of slides that render as full-screen overlays before the challenge starts. Three types — text, video, image — and slides chain in order. A common idiom: first slide is a single splash image, second slide is a welcome text. Set track-level enhanced_loading: false for notes to render as overlays; without that, notes silently degrade into a clickable tab.

Video slides can clip a section of a YouTube video with ?start=N&end=M&autoplay=1&modestbranding=1&rel=0. Useful for embedding a 30-second extract from a longer talk.

Notes can also embed iframes and inline <style> blocks for per-slide CSS. Cheat Sheet 07 covers the markdown extensions and notes-slide options in full.

The learner spends every minute of a track inside a fixed field of view: the assignment panel on one side, one or more tabs on the other. The pacing of that view — what tab is in front, how the panel is sized, what the learner has to switch between — shapes the experience as much as the prose itself.

This chapter covers the layout choices and the per-challenge overrides.

Tabs recap

Six tab types: terminal, code, service, browser, external, website. Chapter 7 covered each in the context of exposing applications; Cheat Sheet 05 is the full reference.

The decision a track makes per challenge is which tabs are visible, in what order, and which is active. Tab order is the display order. The button syntax in the body uses zero-based indices.

A common pattern: pair an editable code tab with a second code tab pointing at a completed reference implementation. Stuck learners can open the reference, see the shape of the answer, and return to their own implementation. The pattern works because the reference is visible but not forced. (See Booklet Principle 18; Cookbook Recipe 31.)

Layout: AssignmentLeft vs AssignmentRight

The track's lab_config.default_layout is one of two values: AssignmentLeft puts the assignment text on the left of the screen with tabs on the right; AssignmentRight is the inverse. The choice matters because the learner's eye flows left to right, and a layout that puts the action on the side opposite to the natural reading flow creates friction.

The convention that holds up: default to AssignmentRight. It is used by the majority of production tracks. Use AssignmentLeft only when the learner spends more time reading than typing — long-form conceptual chapters, document-style explanations where the assignment is the focus and the terminal is reference.

Sidebar size

default_layout_sidebar_size sets the assignment panel's width as a percentage of screen width. Typical values:

  • 30 — narrow panel, wide tools. For code-heavy labs where the learner is mostly typing.
  • 33 — balanced. The default if you do not set it.
  • 40–45 — wider panel, narrower tools. For reading-heavy SaaS demos and virtual-browser tracks.
  • 70 — very wide panel. For content-heavy tracks where written instructions are the primary surface and the tool area is supplementary.

Pick deliberately. A 30/70 split with content-heavy prose is hard to read; a 70/30 split with terminal-heavy work is hard to type in.

Override patterns at challenge level

A challenge can override track-level layout settings in its own lab_config block. The most useful overrides are default_layout, default_layout_sidebar_size, and custom_layout for precise multi-pane control.

custom_layout is a JSON-encoded layout tree that lets a challenge define exactly which tabs go in which pane and how the panes are sized:

lab_config:
  custom_layout: |
    {"root":{"children":[
      {"leaf":{"tabs":["assignment"],"activeTabId":"assignment","size":29}},
      {"leaf":{"tabs":["editor","terminal"],"activeTabId":"editor","size":71}}
    ],"orientation":"Horizontal"}}

The tab IDs "assignment" and "feedback" are reserved built-ins — they refer to the assignment panel and feedback tab respectively. Any other tab ID matches the title of a tab declared in tabs:. Use custom_layout when a challenge needs a specific spatial arrangement that a simple left/right + sidebar size cannot express.

For override_challenge_layout: true to take effect, set it at the track level. Otherwise per-challenge overrides are ignored.

enhanced_loading and the pre-challenge experience

enhanced_loading is a top-level track.yml field, not a lab_config sub-field. The placement matters because authors who put it inside lab_config: find it silently ignored, with the symptom that notes: slides render as a clickable tab instead of overlay screens.

The rule: set track-level enhanced_loading: false whenever the track uses notes: slides as overlays. A challenge can opt back in to enhanced loading on its finale by setting challenge-level enhanced_loading: true — useful for a closing screen that wants the polished overlay experience without changing the rest of the track's behavior.

Loading messages

loadingMessages controls what the learner sees during the lab's boot phase. Three options:

  • true — the platform's default rotating messages. Generic but functional.
  • false — disable the loading overlay entirely. Useful for live-presenter tracks where the boot is invisible to the audience anyway.
  • A list of strings — custom messages, rotated through the boot phase. Useful for branded or workshop-specific tracks where the platform's defaults do not match the tone.
loadingMessages:
  - "Spinning up your project..."
  - "Configuring the environment..."
  - "Almost ready..."

Keep custom messages short and varied. Five strings is a reasonable upper bound; the boot is not long enough to read more.

hideStopButton and other niceties

hideStopButton: true removes the lab stop button. Use on certification tracks where the learner should not be able to abandon mid-exam, or on workshop tracks where the audience would otherwise wonder what the button does.

Two feedback toggles round out the lab UI:

  • feedback_recap_enabled: true shows a one-time end-of-track feedback recap. Low-friction; leave on by default.
  • feedback_tab_enabled: true renders a persistent sidebar feedback tab. More invasive; reserve for certification, SDK, or partner-training tracks where free-form feedback is genuinely valuable.

The most common pairing is feedback_recap_enabled: true plus feedback_tab_enabled: false. The persistent tab clutters the UI on tracks that do not need it.

Multi-host tracks coordinate. A workstation hands a connection string to a database VM; a setup script generates a token that a check script later validates; a body paragraph quotes a value the platform injected at boot. The platform exposes three mechanisms for this coordination, and choosing among them is its own architectural decision.

Auto-injected environment variables

The platform injects a set of environment variables on every host at boot. The full reference lives in Cheat Sheet 03; the names worth remembering:

  • Identity: INSTRUQT_PARTICIPANT_ID, INSTRUQT_USER_ID, INSTRUQT_USER_NAME, INSTRUQT_USER_EMAIL (the last may be empty on invite-link plays — guard with a fallback).
  • Track context: INSTRUQT_TRACK_SLUG, INSTRUQT_TRACK_ID, _SANDBOX_ID, _SANDBOX_DNS.
  • Cloud credentials (when declared): INSTRUQT_AWS_ACCOUNT_<NAME>_, INSTRUQT_AZURE_SUBSCRIPTION_<NAME>_, INSTRUQT_GCP_PROJECT_<NAME>_*. The <NAME> segment is the resource's name: field uppercased with hyphens replaced by underscores.
  • Auth: INSTRUQT_AUTH_TOKEN is an ephemeral JWT for the platform's REST API, available to every script.

The cloud-tooling base image also injects unprefixed CLI-friendly aliases — AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY for AWS, ARM_CLIENT_ID / ARM_CLIENT_SECRET / ARM_TENANT_ID / ARM_SUBSCRIPTION_ID for Azure. Match each cloud CLI's standard env-var names so commands work without explicit credential flags.

Secrets

secrets: in config.yml declares named values the platform injects as environment variables but never writes to the file system or the debug log. Use secrets: for every credential the track needs. Use environment: for non-sensitive runtime values only. The rule is absolute. (See Booklet Principle 6 and Chapter 5.)

In lifecycle scripts, secrets are ordinary environment variables — ${GITHUB_TOKEN}, ${VENDOR_API_KEY}. In the assignment body, reference them via [[ Instruqt-Var ]] only after explicitly bridging with agent variable set — secrets are not in the variable store by default.

A specific trap worth naming: secrets delivered via the platform UI may carry trailing CRLF if the value was authored on Windows. Strip before use in HTTP headers:

APIKEY=$(echo "$APIKEY" | tr -d '\r\n')

Without this, curl requests with the key in a header are rejected with a malformed-header error that does not name the cause.

agent variable: state between hosts and stages

agent variable set KEY VALUE stores a value in a key-value store the platform maintains for the play. agent variable get KEY reads it back. The store is visible to every host and to the assignment body. Use it to pass values between scripts on different hosts, between scripts at different stages, and between scripts and body prose.

# In track_scripts/setup-server, after generating a token:
TOKEN=$(generate_cluster_token)
agent variable set CLUSTER_TOKEN "$TOKEN"

# In a per-challenge setup-worker script later:
TOKEN=$(agent variable get CLUSTER_TOKEN)
join_cluster --token "$TOKEN"

The store is appropriate for short, structured-string values. For multi-field state (a struct of database credentials, a complex provisioning result), the broker pattern below scales better.

The Instruqt-Var template tag

The body of assignment.md reads agent variables via the template tag:

The cluster join token is `[[ Instruqt-Var key="CLUSTER_TOKEN" hostname="server" ]]`.

Both key and hostname attributes are required. The hostname is the host where the value was set — agent variables are per-host, and the platform routes the lookup to that host's store.

The template tag works inside any string field: body Markdown, fenced code blocks (no escaping needed — the tag resolves before the fence is parsed), frontmatter fields, tab cmd:, tab url:, tab path:, button labels, image alt-text, Markdown table cells.

Two specific exceptions are worth flagging. _SANDBOX_ID and _SANDBOX_DNS are shell-only env vars; they are not in the agent variable store by default. To surface either in the body, bridge explicitly:

# In track_scripts/setup-workstation:
agent variable set SANDBOX_ID "$_SANDBOX_ID"

# Then in assignment.md:
URL: `https://app-8080-[[ Instruqt-Var key="SANDBOX_ID" hostname="workstation" ]].env.play.instruqt.com`

The shell-style ${_SANDBOX_ID} interpolation works in virtualbrowsers: url: and tab url: config fields without bridging — but it does not work in body Markdown. (See Cheat Sheet 13 for the full coverage map.)

Cross-host coordination patterns

Three patterns cover almost all cross-host coordination needs. They are not mutually exclusive — a complex track might use all three for different purposes — but understanding when each is the cleanest answer matters.

Agent variable is the lightest. Setup on host A runs agent variable set DB_URL "..."; setup on host B runs URL=$(agent variable get DB_URL). Best for short, scalar values: a connection string, a generated token, a resource ID. Awkward for multi-field state. (See Cookbook Recipe 34.)

JSON broker sidecar runs a small HTTP server on a chosen port (8081 by convention) that returns structured JSON. Host A's setup writes a config file and starts the broker; host B's setup curls the broker's JSON endpoint and parses the values it needs. Best for multi-field state where parsing concatenated strings would get awkward. (See Cookbook Recipe 40.)

Cross-VM env passthrough uses config.yml's per-VM environment: block to declare cross-VM hostnames the agent injects automatically. A worker VM's environment: block declares LEADER_URL: http://server-1:8081; the worker's setup script can then curl "$LEADER_URL" to pull state from the leader. Best when the relationship is fixed at config time and does not change per play.

The decision rule: agent variable for one or two scalar values; JSON broker for structured multi-field state; environment passthrough for fixed cross-VM URLs that the agent should know at boot. Most tracks need only one of the three. Tracks that need cluster bootstrap with credential propagation often use all three at once for different purposes.

Privileges and visibility

A note on what is visible where. environment: block contents are visible in the learner's debug log on the play page. Anything you put there is effectively public to the learner. secrets: are not — the platform injects them at runtime and they never appear in the source tree, the file system, or the debug log.

The agent variable store is also not in the debug log, but values are visible to every host's setup and check scripts. Treat agent variables as you would treat values you intentionally surface to the body — anything you do not want the learner to see should not go through the agent store.

For credentials that never need to reach a script — a license key consumed only by a binary at runtime, an API key used inside a service that the learner never interacts with — declare them in secrets: and let the platform inject them as environment variables. They never enter the agent variable store, and the learner never sees them.

Part IV — Polish
12
Check Scripts That Teach
A check script's job is not to determine pass or fail.

A check script's job is not to determine pass or fail. Its job is to teach the learner what they got wrong and how to fix it. The Check button is the most-clicked button in the whole platform; the script behind it is some of the highest-leverage prose a track contains.

This chapter walks the patterns that make check scripts useful and the traps that produce silent or unhelpful failure modes.

The lazy pattern

The default failure mode is a chained assertion that emits one generic message:

if ! all_conditions_met; then
  echo "Configuration is incorrect."
  exit 1
fi

The script satisfies the platform's contract — it returns the right exit code — but it fails the learner. They see "Configuration is incorrect," they re-read the assignment, they run the same commands, they hit the same generic message. This is where learners abandon tracks.

A track that takes the time to write specific failure messages is a different product than one that does not. The cost is real (five well-written assertions take longer to write than one chained expression), and it is paid back every time a learner clicks Check and gets a useful response instead of a wall.

The granular pattern

The shape that holds up: each assertion is independent, each failure emits a specific remediation message that names the exact next thing to do.

#!/bin/bash
set -euo pipefail

if ! psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='analyst'" | grep -q 1; then
  fail-message "User 'analyst' does not exist. Run: CREATE USER analyst WITH PASSWORD 'changeme';"
  exit 1
fi

if ! psql -tAc "SELECT has_table_privilege('analyst', 'orders', 'SELECT')" | grep -q t; then
  fail-message "User 'analyst' lacks SELECT on orders. Run: GRANT SELECT ON orders TO analyst;"
  exit 1
fi

# ... more assertions ...

set-status "User analyst is configured correctly."
exit 0

Two pieces matter beyond the structure. First, the message names the exact command the learner needs to run, not a vague description of what is wrong. "Run: GRANT SELECT ON orders TO analyst;" is teaching; "User does not have the right privileges" is verdict. Second, the success path uses set-status to surface a positive message — the same UI surface as fail-message, but on the pass side. Consistency on both sides reads better than success that goes silent. (See Booklet Principle 14. Cookbook Recipe 36 carries the full pattern with auto-counted assertions.)

The auto-counted form prefixes each fail-message with [Check N/$TOTAL] so the learner sees their progress through the assertions. Useful when a check has many independent steps and the learner benefits from knowing how many remain.

The fail-message trap

fail-message is image-dependent. The platform's published container images ship it; most workshop-published custom images ship it; stock cloud Linux images acquire it during bootstrap. Raw upstream Docker images (ubuntu:22.04, python:3.11-slim, node:22-bookworm) often do not.

A check script that calls fail-message on a host without the helper exits 127 — command not found — and the platform renders a generic "Something went wrong" modal that points at no specific cause. The diagnostic time when this hits production can run a quarter of a day; the script ran fine in development because the development environment had the helper.

The fix is to know your image. When availability is uncertain, guard with || true:

fail-message "API not reachable. Run: ./start-api.sh" || true
exit 1

Or fall back to plain echo and exit 1. The styled UI hint is lost, but the message surfaces in the platform's debug log and the script fails clean. (See Booklet Principle 15.)

The set -euo pipefail trap

set -euo pipefail at the top of a check script is good defensive hygiene — until you hit an unguarded subprocess. Any inline python3 -c '...', curl ..., or psql ... that exits non-zero triggers an immediate, silent bash exit. The script does not crash; it exits cleanly on a subprocess failure that bash decided to propagate. The learner sees the generic modal and gets no useful information.

The fix is to wrap every subprocess call in an explicit error handler:

RESULT=$(curl -fsS "$API/healthz") || {
  fail-message "API health check failed. Is the service running on port 8000?"
  exit 1
}

The cost is roughly half a line per call. The payoff is a check script that tells the learner what went wrong instead of dropping them into a void. (See Booklet Principle 8.)

The two malformed-shebang traps from Chapter 8 also apply here. #set -euxo pipefail is a comment; the abort behavior never engages. set -eux pipefail reads pipefail as a positional parameter; pipeline failures pass through. Run the pre-push grep against /check- as well as /setup-.

Validating intent, not intermediate state

Resources in many tracks progress through multiple states: ingested → classified → enriched → governed; provisioned → configured → tested → deployed. A check script that asserts a specific intermediate state — for example, status == "enriched" — produces false negatives on resources that have legitimately advanced further. The learner's correct work fails the check because it advanced too far.

The fix is to validate the intent of the assertion. Accept all valid terminal states for the check's intent. If the check verifies that enrichment happened, accept any state at or beyond enrichment; do not insist on the exact label enriched.

case "$STATUS" in
  enriched|governed)
    # both indicate enrichment occurred
    ;;
  *)
    fail-message "Resource has not been enriched yet."
    exit 1
    ;;
esac

The same logic applies to data-driven checks. A query that compares the learner's submitted answer to a hardcoded constant breaks when the seeded dataset is rebaked or the source data drifts. Use the live-truth pattern instead: re-execute the same query the learner ran, compare to their submitted value, accept any answer that matches the live data:

EXPECTED=$(psql -tAc "SELECT count(*) FROM orders WHERE status='pending'")
SUBMITTED=$(cat /tmp/answer)
if [[ "$EXPECTED" != "$SUBMITTED" ]]; then
  fail-message "Got $SUBMITTED, expected $EXPECTED."
  exit 1
fi

The check is now self-correcting against any future change to the source. (See Booklet Principle 16.)

Scored rubrics for certification tracks

Most check scripts emit a binary verdict. For certification or assessment-style tracks, this is the wrong abstraction. A learner who has met seven of eight criteria gets the same response as a learner who has met none, and they have no way to identify what specifically remains.

A scored rubric replaces the binary verdict with a transparent point-based tally. Each assertion is worth N points; the script accumulates the score as it runs; the final output is a color-coded scorecard before the script exits 0 or 1 based on a passing threshold:

SCORE=0
TOTAL=100

if check_assertion_one; then
  SCORE=$((SCORE + 15))
  echo "[PASS] +15 — Resource was tagged correctly"
else
  echo "[FAIL]   0 — Resource was not tagged. See section 2."
fi

# ... repeat for each criterion ...

echo
echo "Final score: $SCORE / $TOTAL"
[ "$SCORE" -ge 70 ] && exit 0 || exit 1

Certification becomes a coachable scorecard rather than a black box. Learners who fall short know precisely which criteria are missing and can target their second attempt instead of starting from scratch. (See Booklet Principle 17. Cookbook Recipe 37 covers a full scored rubric.)

Solve scripts as mirrors

A solve script's job is to produce the exact end state the corresponding check validates — no more, no less, regardless of the host's current state. This means it must be idempotent (succeed on a second run) and complete (establish every condition the check tests for).

Idempotency is the more common failure. A solve script that runs useradd analyst fails on its second run because the user already exists. The fix is to write every solve step as either "create if absent" or "ensure desired state" rather than "create":

id analyst >/dev/null 2>&1 || useradd analyst

Completeness is more often missed. A check that verifies five separate properties needs a solve that establishes all five. A solve that establishes four out of five passes informally on the author's machine and fails the actual check, often after a long debugging session.

A useful self-test: run the solve twice on a fresh environment, then run the check. If the check passes both times, the solve is correctly idempotent and complete. (See Booklet Principle 9.)

Trap-based cleanup in checks

Some checks need to provision temporary state to validate something — open a session, create an environment, drop a sentinel file. Cleanup of that state must run on every exit path, not just the happy path:

cleanup() { rm -f /tmp/check-* ; ctm env delete check-env >/dev/null 2>&1 || true; }
trap cleanup EXIT

# Idempotent re-create of any prior state
ctm env delete check-env >/dev/null 2>&1 || true
ctm env add check-env "$URL" "$TOKEN"

# ... assertions follow ...

The trap ... EXIT runs the cleanup function on every exit — normal, error, or signal. Without it, a check that creates state and fails partway through leaves the state behind, and re-clicking Check finds the leftover and behaves unpredictably. (See Cookbook Recipe 42.)

Synthesis

A check script that follows all of the above feels different from one that does not. The learner who hits a failure gets an actionable message naming the exact next step. The author can read the script months later and understand what each assertion is checking. The platform's debug log shows enough detail to diagnose any unexpected behavior.

The pattern adds maybe 30% to the line count of a naive check script. The return is a track that holds up against the messy reality of how learners actually move through it.

A track with technically perfect content can still feel cheap if the surface details ignore the customer's brand. The shell prompt that says root@instance-12345:~#, the loading messages that read like a Linux package install, the page titles that show generic platform copy — these signal "this is a generic demo environment," when the goal is "this is a hands-on session with our product."

This chapter covers the small, low-cost details that turn a working track into one that feels custom.

The shell prompt

Most learners spend most of their time in a terminal. The shell prompt is the single most-seen surface in the track. The default prompt — root@hostname:~# — gives the learner a hostname they will never see again and a # that means "you are root" to people who already know it does. A branded prompt costs almost nothing and improves every minute of the lab:

# In a setup script or in /root/.bashrc:
export PS1='acme:\w$ '

The result is acme:/root$ , which says "you are working with Acme" in the foreground and stays out of the way of the actual work. A few design rules hold up:

  • Drop the username and hostname. They add noise and make copy-pasted commands ugly.
  • Avoid ANSI color codes in the prompt itself. Color works fine in the in-browser terminal but corrupts the platform's debug log when setup echoes $PS1.
  • Use \w for the current directory. The learner appreciates knowing where they are.
  • End with $ (not # ). The # reads as "this is root" to a new learner who may not know it.

For multi-VM tracks where the learner needs to know which host they are on, embed the IP in the prompt:

export PS1="[\u@\h \$(hostname -I | awk '{print \$1}')] \w\$ "

The IP changes per host but the format is stable.

The non-root user pattern

For a more realistic terminal feel, create a non-root user during setup and have the terminal tab drop into it:

# In track_scripts/setup-workstation:
useradd -m -s /bin/bash iggy
echo "iggy ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers.d/iggy
# Copy or create dotfiles for the new user as needed.

# In assignment.md frontmatter:
tabs:
  - title: Terminal
    type: terminal
    hostname: workstation
    cmd: su - iggy

The learner now operates as a non-root user with sudo. They can sudo when they need to (as they would in production) and the lab feels less like a demo box. (See Cookbook Recipe 29.)

For tracks running code-server or other web-IDE surfaces, the same effect lives in the IDE's settings — terminal.integrated.defaultProfile.linux: "Student" plus a profiles.linux entry that runs su - student. Cleaner than per-tab cmd: overrides in every challenge.

Profile.d plus .bashrc dual-write

Login shells (those started with su - <user>) source /etc/profile.d/*.sh. Non-login interactive shells source ~/.bashrc. A track that needs a custom env var or alias to be available in both contexts has to write to both:

# In setup, write the env var to /etc/profile.d for login shells:
cat > /etc/profile.d/acme.sh <<'EOF'
export ACME_API_URL="https://api.acme.example.com"
EOF
chmod +x /etc/profile.d/acme.sh

# Also append to .bashrc for non-login interactive shells:
echo 'export ACME_API_URL="https://api.acme.example.com"' >> /root/.bashrc
echo 'export ACME_API_URL="https://api.acme.example.com"' >> /home/iggy/.bashrc

Skipping one or the other is a common bug. The terminal that opens via cmd: su - iggy reads from /etc/profile.d; the terminal that opens with no cmd: reads from ~/.bashrc. A track that only writes to one will have env vars present in some terminals and missing in others. (See Cookbook Recipe 28.)

Custom CLI wrappers

Some tracks demonstrate a SaaS product that has a REST API but no friendly local CLI. A small Python wrapper using httpx for HTTP and rich for terminal output bridges that gap:

# /opt/acme-cli/bin/acme
#!/usr/bin/env python3
import sys, httpx
from rich import print
from rich.table import Table

# ... two-level argparse: acme <resource> <verb> ...
# ... httpx.Client with base_url and Authorization baked in ...
# ... rich.Table or rich.JSON for output ...

Install under /opt/<product>-cli/bin/ and prepend that bin path to PATH in the container's environment: block. The learner runs acme asset list and sees a styled table; they have not had to think about which API endpoint to curl or how to parse the JSON.

The wrapper is not a real CLI replacement — it covers the two or three commands the lesson needs. The polish is that the lesson reads like "you ran a command and got a table," not like "you constructed a curl with the right headers and parsed the JSON yourself." (See Cookbook Recipe 30.)

Branded portal pages

Some tracks pair a workstation container with a branded portal page that shows credentials, status, or a dashboard. Serve the page from the same container the workstation runs on (avoids the cross-origin headaches from Chapter 7). Template variables come from the agent variable store via a small Go template script run during setup. (See Cookbook Recipe 55 for a branded credential dashboard pattern in full.)

The polish: the learner sees the customer's logo, the customer's color palette, the credentials pre-rendered into a clean grid. The track stops feeling like a generic environment with the customer's product on it; it feels like the customer's environment.

Loading messages

The platform's default loading messages are generic — "Spinning up your environment..." and similar. Custom messages match the track's tone:

lab_config:
  loadingMessages:
    - "Provisioning the Acme platform..."
    - "Loading sample data..."
    - "Warming up the cluster..."

Keep messages short and three to five total. The boot is not long enough to read more.

For live-presenter tracks where the boot is invisible to the audience anyway, set loadingMessages: false to disable the overlay entirely.

Theme choices

lab_config.theme.name is "original" or "modern-dark". The choice is largely aesthetic. Modern-dark reads better on projector screens for live demos. Original matches the platform's older default and feels familiar to learners who have used the platform before.

Pick one and commit. Switching theme mid-track is jarring and there is no good reason to do it.

The polish threshold

Polish is the easiest phase to skip and the hardest one to retrofit. A polished track and an unpolished track have the same word count and the same infrastructure footprint, but they teach differently. The difference is felt, not articulated, by the learner — and it is the thing that turns a one-time training into something a customer recommends.

A reasonable polish budget for a serious track is half a day. Spent across shell prompt, loading messages, custom CLI wrapper if applicable, and branded portal page if applicable, the half-day produces a track that feels like it was made on purpose. The track that skips polish saves half a day at the cost of every learner thinking "this feels generic" without quite knowing why.

The learner experiences time differently from the author. The author knows what is coming and can wait through a slow setup with confidence; the learner is staring at a loading screen wondering if anything is happening. The author tested the track in pieces and never sat through the full flow; the learner is sitting through the full flow at the speed of their typing, not the author's typing.

This chapter covers the time-related settings, the conventions that match learner expectations, and the trade-offs each setting forces.

timelimit and the track-level clock

timelimit is the total seconds the learner has to complete the track. Track-level clock; counts down from the moment the lab starts. When it hits zero, the lab tears down whether the learner is ready or not.

Pad the value 25–50% over expected completion time. A track that takes the author 30 minutes to walk through takes the average learner 45–60 minutes. The track that ends with a torn-down lab and a confused learner halfway through challenge five is a track whose timelimit was set to "what the author needs," not "what the learner needs."

Common values by track scope:

  • 5400 (90 min) — concept / instructional tracks where typing is light.
  • 7200 (2 hr) — short academy or single-product workshops. The most common value.
  • 28800 (8 hr) — long-form workshops with cloud provisioning.

Cheat Sheet 15 carries the full convention matrix.

show_timer

show_timer: true lets the learner see the countdown. show_timer: false hides it. The trade-off:

  • Show for self-paced tracks where pace awareness helps the learner.
  • Hide for instructor-led tracks where the timer is invisible to the learner anyway, or for unstructured exploration where a visible clock would create artificial pressure.

Default to show unless you have a specific reason to hide. The countdown is a minor feature for the learner who is on pace and a useful nudge for the learner who is dawdling.

skipping_enabled

skipping_enabled: true lets the learner click Skip to advance past a challenge they cannot solve. The platform runs the solve script behind the scenes and moves the learner to the next challenge.

Enable on workshop and demo tracks where the learner should not get stuck. Disable on certification or exam tracks where the learner is being assessed. When disabled, also consider setting skipping-disabled in tags: so the platform UI hides the Skip button entirely.

idle_timeout

idle_timeout is the seconds of inactivity after which the platform tears down the lab. Different from timelimit: this clock resets whenever the learner does anything; it triggers only when they walk away.

Common values:

  • 300 (5 min) — too aggressive; a learner thinking about a problem will time out.
  • 600 (10 min) — default; works for most.
  • 900 (15 min) — for tracks with long synchronous waits (cloud provisioning, Helm install).
  • 3600 (1 hr) — for long-form workshops where the learner may step away for tea.
  • 0 — disables idle timeout entirely. Use only on live-presenter tracks where the author is watching the session. An abandoned sandbox with idle disabled keeps running and accrues cost.

The cost calibration is the most important constraint. idle_timeout is the primary lever for releasing abandoned sandboxes; setting it to zero on a self-paced track quietly burns budget every time a learner walks away.

pausable and pausable_ttl

pausable: true lets the learner pause the lab and resume it later. pausable_ttl sets the maximum pause duration in seconds — typically 604800 (7 days), 1209600 (14 days) for multi-week workshops where the learner may progress across multiple weekend sessions.

The operational consequence is that any track state must survive an arbitrary pause. Avoid in-memory caches, processes started under tmux, or anything else that does not outlast a sandbox suspend / resume cycle. Long-form certification tracks often pair pausable: true with a cron-based pause tracker — a cron entry that pings an external endpoint every minute while paused, enforcing TTL limits and emitting telemetry.

enhanced_loading and pre-challenge notes

enhanced_loading: false at the track level enables the overlay rendering of notes: slides. Without it, notes slides silently degrade into a clickable tab — the slides exist but never appear as the full-screen overlay the author intended.

The placement is the trap. enhanced_loading is a top-level track.yml field, not a lab_config sub-field. Authors who put it under lab_config: find it ignored, and the symptom is "my notes slides aren't showing as overlays" with no obvious cause.

A challenge can opt back into enhanced loading on its finale by setting challenge-level enhanced_loading: true. Useful for a closing screen that wants the polished overlay treatment without changing the rest of the track's behavior.

The pacing problem

Most pacing bugs are not about time — they are about expectations. A learner who is told "this should take 30 minutes" and is still on challenge two at the 30-minute mark feels behind even if the track is genuinely 90 minutes. A learner who is told nothing about expected pace either races (fearing they are slow) or dawdles (assuming there is more time than there is).

A useful default: include the expected duration in the track's teaser or first challenge intro. Round up rather than down. A learner who finishes early feels good; a learner who runs over feels bad even if they finished correctly.

For multi-product or workshop-format tracks, the longer time conventions (28800/7200, 28800/28800) signal explicitly that the track is designed for multiple sessions. The pause + resume flow is part of the design, not a fallback.

The bug that will reach a learner is not the bug the author found while building. The bugs that reach learners are the bugs that emerge only when the track runs from start to finish in a fresh environment, with the assignment driving every step instead of the author manually triggering pieces.

This chapter covers the test pass that matters most and the smaller validations that complement it.

Why challenge-by-challenge is insufficient

Authors who push frequently and test challenge by challenge eventually ship a track that works for every individual challenge but fails when run end to end. The cause is almost always state — challenge two assumes something challenge one set up, and the learner hits the discontinuity even though the author never did, because the author tested challenge two starting from a state they manually established.

Challenge-by-challenge testing is not wrong; it is necessary but insufficient. The end-to-end pass catches a different class of bugs that the per-challenge pass cannot.

The fresh-environment test

Before any push that will reach a learner, run the full track from start to finish in a fresh environment. Click through every challenge in order. Use the Skip button on each one if you have to, but make sure the entire sequence completes without manual intervention between challenges.

The bugs you find this way are almost never the bugs you find by testing in pieces. Setup scripts that work the first time fail on the second because they are not idempotent. Check scripts that work in the author's environment fail in fresh environments because they assumed state the author had created interactively. Solve scripts that produce the right end state on a clean environment fail when the previous challenge's solve left something unexpected behind.

This is the single highest-value test you can run. It is also the test most authors skip because it takes the longest. Skip it only when you are willing to ship a track you have not actually finished. (See Booklet Principle 23.)

Testing solve scripts

The solve script is the part of the track an automated end-to-end test exercises directly. A solve script that produces an incomplete or wrong end state fails the check that follows it, and the failure looks like a check-script bug when it is actually a solve-script bug.

The discipline: run the solve script twice on a fresh environment, then run the check. If the check passes both times, the solve is idempotent and complete. If it fails the second time, the solve is not idempotent. If it fails the first time, the solve is not complete. Both are fixable; both have to be diagnosed.

For multi-challenge tracks, the test also catches solve scripts that run cleanly in isolation but produce a state that breaks the next challenge. A solve at challenge three that drops a database the challenge four setup expects is the kind of bug only the full sequence can find.

Smoke-testing checks

A smoke test for a check script is to run it against a known-good state (the solve completed) and a known-bad state (the solve did not run). The known-good run should pass; the known-bad run should fail with a useful message.

The trap is the in-between state. A check script that passes when the solve completed and fails when nothing has happened can still produce a false positive when the solve completed partially — the kind of state a learner might leave behind by clicking Check before they have finished typing. The granular check pattern from Chapter 12 reduces this trap; the live-truth pattern eliminates it for data-driven cases.

Edge cases

A few edge cases catch authors regularly:

  • Windows-side edits. Edits made on a Windows-mounted filesystem can introduce trailing null bytes that break instruqt track push with a misleading UTF-8 error. Re-test after every Windows-side edit; strip nulls if needed. (See Booklet Principle 24.)
  • Skipping mid-track. A learner who clicks Skip on challenge two may end up with a state that challenge three did not anticipate. The defensive challenge-level setup pattern from Chapter 8 (and Cookbook Recipe 32) idempotently re-creates expected state, but the test for it is to actually click Skip in your end-to-end pass and see what breaks.
  • Pause and resume. For tracks with pausable: true, the test is to pause partway through, wait long enough that the lab actually suspends, and resume. State that does not survive the suspend / resume cycle (in-memory caches, tmux sessions) breaks the resume.
  • Time-limit overrun. Test what happens if the learner runs over timelimit. The track should tear down cleanly, not leak resources.

Push-time validation

instruqt track push runs a schema validation pass before accepting the upload. Most validation failures are mechanical — a misspelled field, an http:// URL on a website tab, a duplicated challenge ID — and the platform names the cause directly. A few failures are less obvious:

  • The UTF-8 / null-byte error. "invalid byte sequence for encoding UTF8: 0x00." The platform names UTF-8 but the actual cause is a stray null byte in track.yml, config.yml, or an assignment.md. Strip nulls and retry.
  • Slug mismatches. The slug: field in track.yml must match the directory name. The platform rejects mismatches but the message can read as a generic schema error.
  • Missing or duplicate IDs. The platform assigns id: fields automatically on first push. Subsequent pushes that have a different id: than the platform expects produce an error that names the wrong field.

The cure for most push-time errors is to read the message carefully and check the field the message names. Chapter 17 covers the failures in detail.

The test plan in practice

A test plan for a serious track has three phases:

  1. Per-challenge test during build. Push, run, fix. Iterate fast. The goal is each challenge in isolation.
  2. End-to-end test before any production push. Click through every challenge in a fresh environment. Use Skip if needed but complete the full sequence without manual intervention. The goal is the whole track in sequence.
  3. Edge-case spot-checks. Skip mid-track, pause and resume, push from a Windows host, run over the time limit. Once each is enough; the goal is to catch the failure modes the happy path misses.

The plan takes a couple of hours on top of build time. The bugs it catches are bugs that would otherwise reach the learner — and the learner who hits a track-breaking bug at four o'clock on a Friday in front of a customer remembers it for a long time.

Part V — Ship
16
Pushing, Versioning, and the checksum Field
A track exists in two places: as files on disk in the author's working tree, and as a published version on the platform.

A track exists in two places: as files on disk in the author's working tree, and as a published version on the platform. The two are kept in sync by instruqt track push, which uploads the working tree, validates it, and atomically replaces the published version.

This chapter walks the push command, the platform-side fields it manages, and the version-handling conventions.

The instruqt track push command

instruqt track push is the only way to publish a track. It runs from the track directory and uploads the entire working tree:

cd my-track
instruqt track push

The command does three things in order: validates the file structure and content (schema validation, HTTPS check on website tabs, null-byte detection), uploads the directory contents to the platform, and replaces the published track version with the uploaded one.

If validation fails, no upload happens. The platform returns the validation error and the working tree on disk is unchanged. Push is atomic: either the new version is published in full or the old version remains in place.

For tracks with a Dispatch agentic build pipeline or external CI integration, a --branch flag pushes to a non-production branch. Useful for testing before promoting to the production track.

What happens during a push

Schema validation runs first. The platform parses every YAML file (track.yml, config.yml, frontmatter in every assignment.md) and reports any field type mismatches, required-field omissions, or invalid enum values. Most validation failures stop here and the platform names the field that failed.

After schema validation, the platform reads the structural metadata: how many challenges, in what order, with what IDs. Mismatches between directory order and track.yml order, gaps in the numeric prefixes, or duplicate slugs all surface here.

If validation passes, the upload proceeds. The platform receives the directory, computes a checksum, and stages the new version. The atomic replacement happens last.

The id field

track.yml has an id: field that is a UUID. The platform assigns it on the first push and writes it into the file. Do not modify id after assignment. Modifying it creates a new platform record disconnected from the local files; the original published track is orphaned, and the next push goes to the new record.

Per-challenge id: fields work the same way. The platform assigns them on first push; subsequent pushes use the assigned IDs to map local challenges to platform records. Modifying a challenge's id: is the most common way to accidentally orphan a challenge — the platform sees a new id and treats it as a new challenge, deleting the original.

When adding a new challenge, omit id:. The platform assigns one on push. When duplicating a challenge directory to use as a template, delete the id: field from the duplicate before push. Otherwise both directories try to claim the same platform record, and the platform rejects the push.

The checksum field

track.yml carries a checksum: field that the platform manages. The platform computes a hash over the track's content and writes it into track.yml after each successful push. Subsequent pushes compare the local checksum to the platform's checksum to detect drift.

Do not edit the checksum manually. The field is auto-managed; manual edits produce mismatches that fail the next push.

version: is a separate field, free-form semver. It is independent of checksum and is the author's tag for human-readable versioning. Some teams bump version: per push; others tag at significant milestones. The platform does not interpret it.

Production vs development versions

A track can have both a production version (visible to learners enrolled in the platform's catalog) and a development version (for the author's own testing). The development version updates on every push; the production version updates only when the author explicitly promotes.

The convention varies by team. Some teams push to development, test, then promote in a separate step. Others push directly to production for small fixes and use a parallel -dev track slug for risky changes.

For tracks under active iteration, a parallel <slug>-dev track is a useful pattern. The development copy gets every push; the production copy gets vetted releases. The two are unrelated platform records, so changes to one do not affect the other.

Track versions and history

The platform retains a version history per track. Old versions remain accessible for a period after replacement; the platform can roll back to a previous version if a push introduces a regression.

The history is not infinite. Old versions eventually age out. For tracks that need long-term version retention (audited certification tracks, regulatory compliance demonstrations), explicit version tagging is the better discipline — name a track copy <slug>-2025-q1 for the version frozen at that point.

What push does not do

Push validates structure and uploads files. It does not exercise the lifecycle scripts. A track with a syntactically valid setup script that fails on first run will push successfully and break at runtime. The end-to-end test from Chapter 15 catches this; push validation alone does not.

Push also does not provision the cloud accounts declared in config.yml. Those provision when the first learner starts the lab. A track with a misconfigured aws_accounts: block will push successfully and fail when the first learner clicks Start.

The mental model: push tells the platform "here is what to run when a learner starts the lab." It does not tell the platform "run it now and see what happens." That is the learner's job — or the author's, when running the end-to-end test.

When instruqt track push rejects the upload, the platform's error message names the cause but not always in the words the author expects. This chapter walks the common failures, what they look like, and what to fix.

The UTF-8 / null byte error

Symptom: instruqt track push returns invalid byte sequence for encoding "UTF8": 0x00.

Cause: a stray null byte (0x00) in track.yml, config.yml, or an assignment.md. Files edited on a Windows-mounted volume can accumulate trailing nulls that no normal editor view shows. The error names UTF-8, but the actual cause is a stray null.

Fix: a quick Python pass that strips nulls from every YAML and Markdown file:

import pathlib
for p in pathlib.Path(".").rglob("*"):
    if p.suffix in (".yml", ".md") and p.is_file():
        b = p.read_bytes()
        if b'\x00' in b:
            p.write_bytes(b.replace(b'\x00', b''))
            print(f"stripped: {p}")

Run after every editing session on a Windows host. The fix takes seconds; chasing the bogus UTF-8 error without knowing about it can take a quarter of an afternoon. (See Booklet Principles 11 and 24.)

Schema validation errors

Symptom: a structured error message naming a specific field — for example, field 'timelimit' must be an integer or unknown field 'sandbox-preset' in track.yml (did you mean 'sandbox_preset'?).

Cause: a typo in a field name, a wrong type for a field value, a required field omitted, or a field value outside the allowed enum set.

Fix: read the message carefully and check the named field. Most schema errors are cheap to fix once the field is named — the platform's error messages are accurate even when they read briskly.

A common subtle case: a list field declared with the wrong YAML structure. tags: production is a string; tags: - production is a list with one element. The schema usually expects the list form; the string form fails validation.

HTTPS validation on website tabs

Symptom: validation failed for tab 'Vendor docs': url must be HTTPS.

Cause: a website tab with an http:// URL.

Fix: change the URL to HTTPS. If the destination genuinely cannot serve HTTPS, change the tab type to external (which is more permissive) or service (which uses the platform's certificate provisioning). Or fix the destination if you control it.

The validation is silent at the platform level — the failure mode is not always obvious from the error output, so authors who do not know the rule can spend time chasing other causes. (See Booklet Principle 19.)

Slug mismatches

Symptom: slug 'my-track' does not match directory name 'my_track'.

Cause: the slug: field in track.yml does not match the track directory's name.

Fix: align them. The slug must match the directory exactly, including hyphens vs underscores. The convention is hyphens (my-track), not underscores (my_track).

Missing or duplicate IDs

Symptom: challenge id 'abc123' is duplicated or challenge at 03-deploy is missing required field id.

Cause: a track.yml or assignment.md id: field has been hand-edited, copy-pasted between files, or deleted incorrectly.

Fix: for missing id: on a new challenge, omit the field entirely — the platform assigns it on push. For a duplicate, find the source of the duplication (usually a copy-pasted directory) and remove the id: from the duplicate so the platform assigns a fresh one.

When duplicating a challenge to use as a template, always delete the id: from the duplicate before any push. Forgetting is the most common cause of "missing or duplicate IDs" on push.

Number prefix gaps and mismatches

Symptom: challenge order in track.yml does not match directory order or undefined behavior on push.

Cause: gaps in the numeric prefixes (01-, 02-, 04-) or mismatches between directory order and track.yml order.

Fix: renumber every subsequent challenge to keep the sequence dense, and make sure the order in track.yml matches the directory prefix order. (See Booklet Principle 20.)

When inserting a challenge in the middle of an existing track, all subsequent challenges renumber. When deleting, the rest renumber too. The id fields are unaffected — only the directory names change.

Network and authentication failures

Symptom: timeout, 401, 403, or authentication failed from the push command itself.

Cause: not the track — the network or the author's authentication state. The platform CLI uses cached credentials; expired credentials produce 401. Misconfigured proxy settings produce timeouts.

Fix: re-authenticate with instruqt auth login (or whatever the local equivalent is), confirm the proxy environment variables (HTTP_PROXY, HTTPS_PROXY) match what the network requires.

Permission failures on cloud account blocks

Symptom: push succeeds, but the first learner who starts the lab gets cryptic auth errors when running cloud commands.

Cause: a misconfigured aws_accounts:, azure_subscriptions:, or gcp_projects: block — typically a role that does not exist (roles/writer is the canonical example for GCP), a managed policy ARN that is wrong, or a cloud env var referenced in a script with no matching resource block in config.yml.

Fix: validate the block against the cloud provider's actual role and policy names. The push validates the YAML structure, not the cloud-side validity of the role names. (See Chapter 6 for the patterns; the Booklet anti-pattern entries cover the specific traps.)

Recovery strategies

When a push fails in production and the team needs the track working again immediately, the recovery options are:

  • Roll back. The platform retains version history; rolling back to the previous version restores the track.
  • Re-push the previous working state. If the team has it tagged in source control, check out the tag and push. The platform sees the older content and accepts it.
  • Push a hotfix. For urgent breakage (a broken external dependency, a vendor product change), push a minimal change that papers over the issue. Track the underlying fix in MEMORY.md and address it properly later.

Most push failures are not production emergencies. The pattern that holds up is: keep production stable, push to a development copy when iterating, promote vetted changes deliberately.

A shipped track is not a finished track. The product evolves; the underlying infrastructure rotates; the documentation it references gets restructured; the dependencies it pulls during setup release new versions. A track that worked in March can fail silently in May because of a change nobody told the author about.

This chapter covers the discipline that keeps tracks working between active development cycles.

When to refactor

Tracks accumulate cruft. A pattern that worked when the track was three challenges long becomes awkward when it is ten challenges long. A custom helper script that was useful in version 1 becomes a maintenance liability when the underlying tool changes its CLI. A challenge that was clear when the audience was sales engineers becomes confusing when the audience grows to include developers.

The signals that a track is due for refactoring:

  • Multiple bug-fix pushes in a short window without any feature work. The track is fighting its own structure.
  • New challenges that have to work around existing infrastructure to land cleanly.
  • A MEMORY.md deferred-work section that has grown for months without items being closed.
  • Author cognitive load: the author finds themselves re-deriving the same pattern every time they touch the track.

The refactor pass is not free; it is half a day to a week depending on the track. The signal that it is overdue is not the calendar — it is the friction.

Updating dependencies

Tracks pull dependencies in two places: at setup time (image versions, package versions, vendor binaries downloaded from upstream URLs) and as architectural anchors (the cloud-tooling base image, the Kubernetes pre-baked image family, the vendor's published image namespace).

Setup-time dependencies are the most volatile. A vendor product release every six weeks means the setup script that worked in February probably installs a slightly different binary in March, and the track may or may not still work.

The discipline is to pin when stability matters and track latest when discovery is the lesson:

  • Pin specific versions of vendor binaries the track demonstrates against. A curl from a release URL with an explicit version tag is more stable than a curl from a "latest" URL that drifts.
  • Track latest for cloud CLIs and platform images that update more conservatively. The cloud-tooling base image gets occasional updates; pinning a specific version creates a maintenance bill nobody pays.

Architectural dependencies (base images, image families) update on a slower cadence and are usually safe to track. When they do update, the breaking changes are usually documented; subscribe to the vendor's release notes for the families you depend on.

Handling vendor product changes

The hardest maintenance work is when the vendor whose product the track demonstrates ships a change that breaks the track's lesson. A new UI flow that does not match the track's screenshots. A renamed CLI subcommand that breaks the setup script. A deprecated API the track relies on.

Three options:

  • Update the track. New screenshots, new commands, new API. Half a day to a day depending on the scope. The right answer when the vendor change is the new normal.
  • Pin to the old behavior. Use an older image, a frozen tarball, an older API endpoint. Buys time when the team cannot do the update right away. The risk is that the pin eventually breaks too — vendors deprecate older versions on their own schedule.
  • Sunset the track. When the underlying lesson is no longer accurate at all (the vendor removed the feature, the integration is gone), the track is lying to learners. Mark it maintenance: true to hide it from the catalog, then formally retire when the team is ready.

The track that gets neither updated nor sunset is the worst outcome. Learners hit it, get confused, and walk away with a worse impression than no track at all.

Track memory as a living document

The single highest-leverage maintenance habit is a MEMORY.md file at the track root, treated as a living build log. Not a README (which describes the track to a reader), not commit messages (which are tied to specific changes), not inline comments (which live inside code) — a file that captures the why behind decisions and the what of unfinished work.

The shape that holds up:

# MEMORY — <track-slug>

Status: <one-line>
Owner: <email>

---

## Session History

### Session N — <date>
- One bullet per meaningful event: decision made, fix shipped, pattern discovered, deferred work identified.

## Deferred Work
- Items noticed but not yet shipped. Patterns to revisit, fixes to defer, follow-ups identified during the build.

## Track Architecture Notes
- Infrastructure topology, key file references, gotchas baked into this track's design — anything a future contributor (including future you) should know before editing.

Update it after every meaningful change. The cost is a few sentences. The benefit is that picking up a paused track three weeks later starts from a real briefing rather than a cold scroll through commit history. (See Booklet Principle 25.)

Session History

The Session History section captures the why behind decisions, so future-you can audit your own reasoning rather than re-derive it. One bullet per meaningful event:

  • "Switched from heredoc-based project generation to a pre-baked image because cold-start exceeded 90 seconds."
  • "Added defensive challenge-level setup at challenges 03 and 05 because Skip-mid-track was producing inconsistent state."
  • "Pushed v1.4 — first version with the cleanup-at-provision pattern fully wired."

The bullets are not commit messages. A commit message says what changed; a Session History entry says why it changed and what it took to land. Some entries map to commits; many do not. A "decided not to do X because Y" entry is just as valuable as one that maps to actual code.

Deferred Work

Deferred Work captures things you noticed but did not fix in this session. The discipline matters because once you write things down, you stop re-discovering them. Items you encountered in the build but did not address — a fragile retry loop in Chapter 6 you want to fix later, a check-script trap you should harden, a comment in assignment.md that needs a screenshot — go here.

Clear entries as you address them. The section should grow during active development and shrink during maintenance passes. A deferred-work section that grows for months without items being closed is a signal the track has accumulated more debt than the team is paying down.

Track Architecture Notes

Track Architecture Notes is the section a future contributor (including future you) reads before editing. It captures the things that would not be obvious from the file structure alone:

  • "Workstation runs uvicorn on port 5000 serving both the portal and the API. The portal is at /; the API is at /api/*. Co-host pattern from Cookbook Recipe 25."
  • "Cleanup script depends on agent variable get ES_PROJECT_ID set during setup. Do not delete the agent-variable line in setup-workstation."
  • "Challenge 04's solve script intentionally creates a customer record that challenge 05 expects. Removing it will break challenge 05 setup."

The section is not exhaustive documentation. It captures the gotchas — the things where a future edit could accidentally break something not immediately visible.

The audit cadence

A periodic audit catches drift before learners do. The cadence depends on the track's exposure: a heavily-used certification track wants weekly audits; a customer-onboarding track running quarterly wants quarterly audits; an internal demo run only at events wants ad-hoc audits before the next event.

The audit is mechanical: run the track end to end, click through every challenge, watch for anything that has drifted. Look at the deferred-work section in MEMORY.md and decide whether any items have aged into "ship now" priority. Check that any external SaaS endpoints the track talks to are still responsive.

The reward for keeping up with audits is a track that quietly continues to work; the cost of skipping them is the track that becomes the team's least-favorite urgent fix when a customer hits the broken state during a demo.

Knowing when to sunset

A track has a useful life. The question is when to retire it.

The signs that a track has aged out:

  • The lesson is no longer accurate. The product changed; the integration is gone; the architecture the track demonstrates is no longer recommended.
  • The audience is gone. The customer the track was built for has moved on; the program it served is over.
  • The maintenance burden exceeds the value. The track requires more work to keep running than it produces in learner outcomes.

The retirement options:

  • Mark maintenance: true to hide the track from the catalog. Existing learners can still complete it; new learners cannot find it.
  • Delete from the platform. Final retirement. Source files stay in the repo as historical reference; the platform record is gone.

The middle ground — letting a stale track linger in the catalog because nobody made the call — is the worst outcome. Make the call.

The long tail of a good track

A well-built track keeps producing value long after the active build is done. The infrastructure runs reliably; the lesson lands consistently; the lifecycle scripts hold up against the messy reality of how learners actually move through them. The maintenance work is bounded.

The discipline that produces this is not magic. It is MEMORY.md, kept current. End-to-end tests, run when changes happen. Cleanup scripts, written at provision time. Cross-references between the track and its companion artifacts, so a change in one surfaces in the others. None of these is hard; none of them is automatic; all of them compound.

The track that ships well and maintains well looks easy from the outside. From the inside, it is the product of a small set of repeated, learnable habits — applied during the build, applied during the polish, and applied during the long quiet phase after the launch when nobody is watching.

Appendices
A
Pattern Catalog
This appendix is a name index of the patterns this book references.

This appendix is a name index of the patterns this book references. The implementations live in the Pattern Cookbook; this index lists them by canonical name and by Field Guide chapter that introduces them. When a chapter says (see Cookbook Recipe N), this is the back-reference.

Infrastructure patterns (Recipes 1–19)

Recipe Name See Chapter
1 Single container track 4, 5
2 Single VM track 4, 5
3 Multi-container composition 4
4 Multi-VM composition 4
5 Container plus managed VM 4
6 Sandbox preset adoption 4
7 Container plus AWS account 6
8 Container plus Azure subscription 6
9 Container plus GCP project 6
10 Multi-cloud track wiring 6
11 Container plus private VM behind IAP tunnel 7
12 Container plus FastAPI service 5
13 Container plus mock REST API and custom CLI 13
14 Container plus Postgres with a live web dashboard 5, 7
15 VM plus virtual browser 7
16 VM plus Guacamole desktop 7
17 VM plus noVNC Linux desktop 7
18 VM plus KasmVNC single-app 7
19 VM plus Docker plus ttyd terminal 7

Technique patterns (Recipes 20–43)

Recipe Name See Chapter
20 Pre-pull Docker images in setup 8
21 Bootstrap wait 8
22 Wait-for-port readiness probe 8
23 Background process launch with log redirection 8
24 Heredoc-based project generation 8
25 Co-hosted portal plus API 7
26 Helper Python module for tests 12
27 Idempotent binary wrapper 8
28 Profile.d plus .bashrc dual-write 13
29 Non-root user pattern 13
30 Custom CLI wrapper 13
31 Companion code-tab pair 10
32 Defensive challenge-level setup 8, 15
33 Idempotent solve script 12
34 Agent variable cross-host hand-off 11
35 Cloud SDK auth credential override 6
36 Granular check messaging 12
37 Scored certification rubric 12
38 Pre-baked k3s VM with control-plane auto-join 4
39 Sandbox-public URL exposure 5, 7
40 JSON broker sidecar for cross-VM payload 11
41 Sparse-checkout from a private repo with a short-lived token 8
42 Trap-based error trace and check cleanup 12
43 Service-tab CSP and header overrides 7

Author patterns (Recipes 44–55)

Recipe Name See Chapter
44 Step-based body structure 9
45 Pre-challenge notes overlay 9
46 Tab buttons for inline navigation 9
47 Variable interpolation in body 9, 11
48 Agent-as-teacher 9
49 File-based quiz answers 9
50 Failure-message anatomy 12
51 Layered hint 9
52 Mid-track recap challenge 9
53 Read-only intro challenge 9
54 Opt-in details collapsible 9
55 Branded credential dashboard 13

The Cookbook is the source of truth for recipe names. If a name in this appendix differs from the Cookbook, the Cookbook is canonical and this appendix is out of date.

The platform injects a set of environment variables on every host at boot. They are available to every lifecycle script (setup / check / solve / cleanup) without configuration. Cloud credentials inject only when the matching resource block is declared in config.yml.

General

Variable Meaning
INSTRUQT_PARTICIPANT_ID Stable per-play identifier.
INSTRUQT_USER_ID Stable user identifier.
INSTRUQT_USER_NAME Display name.
INSTRUQT_USER_EMAIL Email. May be empty on invite-link plays — guard with a fallback.
INSTRUQT_TRACK_SLUG The track's slug string.
INSTRUQT_TRACK_ID Platform-assigned UUID for the track.
INSTRUQT_AUTH_TOKEN Ephemeral JWT for the platform's REST API.
_SANDBOX_ID Sandbox identifier. Shell env only — not in the agent variable store.
_SANDBOX_DNS Sandbox DNS suffix. Useful for FQDNs.

AWS account — INSTRUQT_AWS_ACCOUNT_{NAME}_*

The {NAME} segment is the resource's name: field uppercased with hyphens replaced by underscores.

Variable Meaning
..._{NAME}_ACCOUNT_ID AWS account number.
..._{NAME}_USERNAME Console username (provisioned).
..._{NAME}_PASSWORD Console password.
..._{NAME}_AWS_ACCESS_KEY_ID CLI access key.
..._{NAME}_AWS_SECRET_ACCESS_KEY CLI secret key.

Azure subscription — INSTRUQT_AZURE_SUBSCRIPTION_{NAME}_*

Variable Meaning
..._{NAME}_ADMIN_SPN_ID Service principal app ID.
..._{NAME}_ADMIN_SPN_PASSWORD SP secret.
..._{NAME}_TENANT_ID Tenant UUID.
..._{NAME}_SUBSCRIPTION_ID Subscription UUID.
..._{NAME}_SUBSCRIPTION_NAME Subscription display name.
..._{NAME}_USERNAME Provisioned learner UPN.
..._{NAME}_PASSWORD Learner password.

GCP project — INSTRUQT_GCP_PROJECT_{NAME}_*

Variable Meaning
..._{NAME}_PROJECT_ID GCP project ID.
..._{NAME}_USER_EMAIL Provisioned learner email.
..._{NAME}_USER_PASSWORD Learner password.
..._{NAME}_SERVICE_ACCOUNT_KEY SA key, base64-encoded. Decode with base64 -d.
..._{NAME}_ADMIN_SERVICE_ACCOUNT_KEY Admin SA key, base64. For setup operations.

Cloud-tooling base image auto-aliases

When the host is the platform's cloud-tooling image and a cloud account block is declared, the platform also injects unprefixed CLI-friendly aliases:

Variable Origin
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY AWS CLI standard names.
AWS_ACCOUNT_ID, AWS_ACCOUNT_USERNAME, AWS_ACCOUNT_PASSWORD Console fields without the long prefix.
ARM_CLIENT_ID, ARM_CLIENT_SECRET, ARM_TENANT_ID, ARM_SUBSCRIPTION_ID Terraform AzureRM-style aliases.
AZURE_LOCATION Subscription's configured region.

Notes

  • The <NAME> substitution is the resource's name: field uppercased with hyphens replaced by underscores. name: my-lab produces variables prefixed INSTRUQT_AWS_ACCOUNT_MY_LAB_. Forgetting to uppercase or mishandling hyphens is a common foot-gun.
  • _SANDBOX_ID is a shell env var only. To surface it in the body via [[ Instruqt-Var ]], bridge with agent variable set SANDBOX_ID "$_SANDBOX_ID" in track-level setup.
  • When the host is not the cloud-tooling image, only the prefixed forms are available. The unprefixed aliases only appear on hosts using that base image.
  • INSTRUQT_USER_EMAIL is not guaranteed in all play contexts. Guard with EMAIL="${INSTRUQT_USER_EMAIL:-guest@example.com}".

For the cheat-sheet form of this reference, see Cheat Sheet 03.

Six tab types. Each has a specific shape, a specific use case, and one or two quirks that catch new authors.

terminal

A shell on a declared container or VM. Native PowerShell on Windows VMs (no Guacamole needed for command-line work). The tab opens in /root by default; set-workdir on the host or workdir: per-tab changes the default directory. cmd: runs at tab open — cmd: su - student drops into a non-root user; cmd: screen -xRR auto-attaches to a multi-pane session.

- title: Terminal
  type: terminal
  hostname: workstation
  cmd: su - iggy   # optional
  workdir: /root/workshop   # optional

Quirk: without cmd:, the shell opens as root.

service

An HTTP server on a declared host port. Routes through the platform's traffic proxy.

- title: App
  type: service
  hostname: workstation
  port: 5000
  path: /docs   # optional
  custom_response_headers:
    - "Content-Security-Policy:"   # strip iframe-blocking header

Quirk: client-side fetch() calls inside the served page must use relative paths — the browser cannot resolve internal Docker hostnames. For iframe-blocking apps, use custom_response_headers: to strip X-Frame-Options and Content-Security-Policy.

code

A file or directory editor on a declared host.

- title: Edit
  type: code
  hostname: workstation
  path: /root/app.py   # file or directory
  workdir: /root   # optional

Quirk: path: as a directory renders a folder-tree explorer. path: as a file opens that file directly. Pair an editable file with a second code tab pointing at a completed reference for the companion-code-tab pattern.

browser

A cloud console browser tied to a virtualbrowsers: entry.

- title: Cloud Console
  type: browser
  hostname: aws-console   # matches a virtualbrowsers: entry

Quirk: requires a matching virtualbrowsers: entry in config.yml.

external

An embedded URL frame.

- title: Vendor docs
  type: external
  url: https://docs.example.com

Quirk: for sites that block iframe embedding, the embed will fail silently. Prefer service (with header overrides) or website (with new_window: true) for iframe-hostile destinations.

website

An external URL tab. Supports ${_SANDBOX_ID} interpolation in the URL.

- title: Sandbox app
  type: website
  url: https://app-8080-${_SANDBOX_ID}.env.play.instruqt.com
  new_window: true   # optional

Quirk: URL must be HTTPS. instruqt track push rejects http:// at validation time.

Across all types

new_window: true opens any tab in a separate browser window instead of the lab's tab strip. Useful for popping a cramped service tab into a real window.

Tab order in tabs: is the display order. Index references in body Markdown (button label="X") are zero-based. Reserved tab IDs "assignment" and "feedback" are built-in and can be referenced in custom_layout without declaring.

For the cheat-sheet form of this reference, see Cheat Sheet 05.

The body of assignment.md is plain Markdown with a small set of platform-specific extensions. This appendix is the reference.

Code block modifiers

Modifiers go after the language tag, comma-separated. Order does not matter.

Modifier Effect
```bash,run Click-to-run in the active terminal. Most common form.
```run Click-to-run, no syntax highlighting. Language-less runnable block.
```copy Copy-to-clipboard button. Works without a language tag.
```nocopy Disables the default copy button. Use on output blocks.
```bash,wrap Wraps long lines instead of horizontal scroll.
```line-numbers Shows line-number gutter. Combinable.
```bash,wrap,copy Wrap plus copy. Useful for long one-liners.
```bash,nocopy,run Run-only, no copy button.
```bash Plain. No run button. Use for snippets with placeholders.

Trap: ```bash {"run": "execute"} looks like it should work but is not valid syntax. The block renders, but no run button appears. Always use the comma form.

Trap: match the language tag to what is actually executing. ```python,run on a bash command tells the platform to run it as Python. (See Booklet Principle 22.)

Supported language identifiers

bash · sh · shell · python · javascript · js · typescript · ts · yaml · yml · json · dockerfile · hcl · aql · esql · http · graphql · markdown · md · ruby · go · rust · click · mongodb · logs · plaintext · text.

sh and shell are synonyms for bash. click is for terminal-pasted code that is not a literal bash invocation (inside a kubectl exec shell, for example).

Tab buttons

[button label="Open the editor"](tab-0)
[button label="Run check" variant="success"](#)
[button label="Stop" variant="danger"](#)
[button label="Next" variant="outline"](section--02-deploy)

Indices are zero-based. variant= accepts success / warning / danger / outline. block=true renders the button full-width. section--<slug> jumps between challenges. Empty (#) works for nav-only buttons.

Variable interpolation

[[ Instruqt-Var key="DB_URL" hostname="workstation" ]]

Resolves before the field's content is parsed — works inside fenced code blocks without escaping. Both key and hostname are required.

${_SANDBOX_ID} (shell-style) works in tab url: and virtualbrowsers: url: config fields. It does not work in body Markdown — bridge _SANDBOX_ID to the agent variable store first.

Admonitions

> [!NOTE]
> A useful tip.

> [!WARNING]
> Something that can go wrong.

> [!IMPORTANT]
> A point not to miss.

> [!TIP]
> A shortcut.

Render as colored callouts.

Headings

ATX (## Title) and Setext (underlined) both render identically.

## ATX-style

Setext H1
=========

Setext H2
---------

Text\n--- with no blank line is Setext H2. \n---\n with blank lines is a horizontal rule.

Inline HTML

The body and notes: slide content accept raw HTML. Common patterns: <details><summary> for opt-in hints, <span style="..."> for inline color emphasis, <video src="..."> for embedded media, <iframe> for external interactive content.

For the cheat-sheet form of this reference, see Cheat Sheets 06 and 07.

A short glossary of platform-specific terms used throughout this book.

Term Definition
Track The unit of work in Instruqt. A directory containing track.yml, optional config.yml, optional track_scripts/, and one or more numbered challenge directories. Identified by its slug (the directory name) and its id (a UUID assigned by the platform on first push).
Challenge A single unit of guided work inside a track. Lives in a numbered directory (01-, 02-, etc.) at the track root. Has a frontmatter-plus-Markdown assignment.md and optional lifecycle scripts (setup-{hostname}, check-{hostname}, solve-{hostname}, cleanup-{hostname}).
Lab A running instance of a track. The lab provisions when a learner starts the track and tears down when the learner finishes, when idle_timeout expires, or when timelimit is reached.
Sandbox The infrastructure environment a track runs in. The hosts, networks, cloud accounts, and external services declared by config.yml (or provided by a sandbox_preset) are all part of the sandbox.
Sandbox preset A pre-built environment published by the platform or a vendor. Used in track.yml as an alternative to declaring config.yml explicitly.
Lifecycle script A bash (Linux) or PowerShell (Windows) script that runs at a specific point in a lab's life. Four types: setup (provisions state), check (validates learner work), solve (auto-completes the challenge), cleanup (undoes setup).
Setup script Runs before a challenge starts. Track-level setup runs once at the start of the lab; per-challenge setup runs before each individual challenge.
Check script Runs when the learner clicks the Check button. exit 0 = pass, exit 1 = fail. Should teach the learner what they got wrong, not just verdict.
Solve script Runs when the learner clicks Skip or when an automated end-to-end test is exercising the track. Produces the same end state the check validates.
Cleanup script Runs after the lab ends or after a challenge ends. Required for any external resource the track provisions.
Agent variable A key-value store the platform maintains per play. Set with agent variable set KEY VALUE; read with agent variable get KEY. Visible to every host's lifecycle scripts and to the assignment body via [[ Instruqt-Var ]].
Instruqt-Var The Markdown extension that interpolates an agent variable into the body or other string fields. Requires both key and hostname attributes.
Tab A surface in the lab UI: terminal, code editor, web app, browser console, or external URL. Declared in assignment.md frontmatter under tabs:.
Frontmatter The YAML block at the top of assignment.md (between the two --- markers). Declares the challenge's identity, type, timing, tabs, and (for quizzes) the answers.
Body The Markdown text below the frontmatter in assignment.md. The prose the learner reads.
Sandbox-public URL The traffic-proxied URL pattern for in-sandbox web apps reachable from the learner's browser: https://<host>-<port>-${_SANDBOX_ID}.env.play.instruqt.com.
Bootstrap The platform's host initialization process. Each lifecycle script's first line is typically a wait on the bootstrap sentinel file (/opt/instruqt/bootstrap/host-bootstrap-completed for most images).
Helper A small command shipped with the platform's images: fail-message, set-status, agent variable, set-workdir. Image-dependent — not all images ship them.
Custom resource A Terraform module the platform provisions ahead of containers, VMs, and cloud accounts. Configured in the platform Web UI rather than in config.yml directly.
Pattern A named, reusable shape. Implementations live in the Pattern Cookbook; the Field Guide references them by canonical name.
Principle A prescriptive rule. Stated in the Best Practices Booklet; explained in the Field Guide.
MEMORY.md A track-level living build log at the track root. Captures session history, deferred work, and architecture notes that would otherwise be lost between active development cycles.

This book is long because the platform is rich. Eighteen chapters and five appendices is a lot of words. The hope is not that anyone reads all of them at once but that the chapter you need is in here when you need it.

The chapters were written to be readable in any order. Read straight through the first time to absorb the lifecycle and the mental model — discovery through maintenance, the same arc every track follows. After that, the book is a reference. When a check script is misbehaving, Chapter 12 has the patterns. When an end-to-end test surfaces a state bug, Chapters 8 and 15 between them have the fix. When a Windows-side edit produces a UTF-8 error at push time, Chapters 11 and 17 name the cause.

The book lives alongside two companion artifacts. The Best Practices Booklet states the rules; this book explains the reasoning. The Pattern Cookbook carries the implementations; this book explains where they fit. References to either are scattered throughout — (see Booklet Principle N) and (see Cookbook Recipe N) — and worth following when you want depth in a different shape than the chapter is offering.

The platform has more in it than any book can cover, and the platform keeps evolving. Patterns you do not find here will appear in production tracks before they appear in any documentation. The discipline that makes this book stay useful is the same discipline that makes individual tracks stay useful: maintain the source it draws from, capture new patterns when they emerge, retire ones that no longer hold. The book is alive in the same way a good track is alive.

If you build tracks long enough, you will find your own patterns — habits you grew into through your own scars. Add them. Cross out the ones here you have outgrown. The chapters are not sacred; the discipline of having a place to put the lessons is.