Writing a CRUMB tour
A CRUMB tour is one markdown file. Minimal frontmatter up top, then a body where every ## <surface> heading is a step. There is no separate steps array to keep in sync; the heading text is the step's target, and everything under it (up to the next heading) is that step's content. This page documents the grammar exactly as core/schema.ts parses it, with a real tour from this site as the worked example. See GETTING-STARTED.md for how a host mounts and serves tours.
The one design law
Tours are markdown data. CRUMB reads them and highlights. Where a tour changes anything in your app it goes through the app's own door, with a verb that exists, on a surface the page registered, and it never submits a form. It never writes back to the tour file either (a human-authored tour stays exactly what you wrote). Delete CRUMB and the tour files are still readable markdown.
Frontmatter
---
id: portfolio # optional; must match the filename stem or it's ignored with a warning
mode: demo # "demo" | "dev", defaults to "demo" if missing or invalid
title: "A tour of the desk"
route: / # the entry route the tour opens on; default "/"
---- The tour's id always comes from the filename, lowercased (
Review-Nav.md→review-nav), not from frontmatter. Anid:field is optional and purely a sanity check: if it's present and doesn't match the filename stem, the parser keeps the filename and reports the mismatch as a parse warning (crumb checksurfaces it). modeis one ofdemo(onboarding/marketing walkthrough) ordev(a post-change AI review, adds review/status/verify per step). Same tour component either way; only the mode attribute flips. Missing or invalid falls back todemowith a warning, never a hard failure.titledefaults to the id if omitted.routedefaults to/if omitted.
There is no steps: field in frontmatter, despite what you might see in older design notes. Steps live in the body (below): a step written as a frontmatter list is not how the shipping parser reads a tour.
The body: intro, then ## <surface> steps
Everything before the first ## heading is the tour's intro (its opening prose, shown on the tour's first "intro card"). Every ## heading after that starts a new step, in document order:
Welcome. This quick tour lights up a few pieces of the app, one at a time.
## nav:/notes
- status: changed
- review: The dock gained a Plans row and split into groups.
- verify: Hover each row; every one is a real link.
This is the app dock. Each row is an addressable navigation surface.
## note:ten-times-zero
- at: /notes
- status: new
- verify: Use Back in the sidebar; the tour keeps its place.
The flagship post, on how the whole stack got built with AI doing most of the typing.The heading text is the step's surface verbatim. It must be the exact data-surface="kind:id" address the lamp should light (e.g. nav:/notes, note:ten-times-zero, chat-input, or a bare kind like screen for a surface that isn't scoped to one instance).
Under a heading, lines are split into two buckets:
- A line matching
- key: value(or* key: value, case-insensitive key) wherekeyis one ofat,review,status,verifyis parsed as step metadata. - Every other line is appended to the step's
sayprose (joined and trimmed), the popover narration shown in demo mode.
| Meta key | Meaning |
|---|---|
at | The route this surface lives on. Omit it when the surface is present on the current/every page (nav, a chat input, screen) so the tour doesn't need to navigate to reach it. When set and it differs from the current route, the client does a real navigation (location.assign), not a fake SPA transition. |
review | Dev-mode-only narration: what changed here. Shown only when the tour is in (or switched to) dev mode. |
status | One of new, changed, needs-verification, verified, known-issue (CRUMB's verification vocabulary, deliberately separate from GRAIN's data-grade, which is provenance, not review state). An unrecognized value is dropped with a parse warning; it does not fail the tour. |
verify | The concrete action that confirms the step: "Open the drawer on mobile; the dock shouldn't clip it." Shown alongside review in dev mode. |
prefill | Text to stage into this step's surface. Legal only when the surface is a field: address; anywhere else it is dropped with a warning. \n becomes a real line break, the same as in template below. |
A step's at may carry query state, e.g. at: /mail?subject=grain. The client navigates once and settles: it decides by comparing pathnames, then assigns the whole target, so the query survives. A step whose at declares nothing about the query leaves the page's own parameters alone.
That is only half of a preset, and the other half is the page's. A tour can ask for any address it likes; whether the page arrives in the right condition is the page's own doing. The worked example on this site is the calendar's feed, where the filter tabs keep their choice in the URL, so at: /calendar?feed=notes lands on a feed already narrowed to notes (content/tours/review-calendar-feed-state.md).
The bar to hold when you add one is the same bar the prefill rules hold. The state must be one a person can reach without the tour, and the URL must be one ordinary use produces, which on the calendar means clicking a tab writes that tab into the address bar. A parameter only a tour would ever set is a back channel wearing a query string, and the tour is better off without it.
Staging a field: prefill
A demo of a form is a poor demo when the form is empty, so a step may arrive with the field already written in:
## field:contact-message
- at: /mail#compose
- prefill: Hi Tjakoen,\n\nI came in through the notes and ended up taking the tour.
Here is the compose window, and the draft in it is the tour's, not yours.Four things bound it, and all four are the design law above rather than politeness:
- It only works on a
field:surface. That is a real registration the page made, not an address a tour invented. On any other kind of surface the key stages nothing. - It goes through the door. The text is handed to grain's
field.set, the same verb the assistant writes with, on the same channel, with the same audit trail behind it. There is no direct DOM write and no second path into the page. - It never submits. No verb in the vocabulary presses a button, so the field can be filled and the send stays the human's move. That is the whole point of staging rather than demonstrating.
- It yields to the human. A field the person has already typed into is left alone. A tour that overwrote a half-written message would be worse than one that did nothing.
The client also renders a visible line on the step saying the text was staged by the tour, so a screen with words in it never reads as a screen the app arrived at by itself. Write the step's prose to say the same thing in its own words: the note about staging is a label, and a label is easy to skip past.
One practical trap, learned on this site's own pilot. The field has to be reachable on arrival, because CRUMB has no flow verbs and cannot click anything to reveal it. The compose panel on /mail starts collapsed, so the pilot only worked once /mail#compose opened the panel on a cold load. If the surface you want to prefill lives behind a click today, fix the deep link first. That fix is worth having on its own: a fragment in an href that only works after a click is a broken link for people too.
The honest limit, while it is true: this site pins a published CRUMB that predates the key, and an older parser reads the line as ordinary prose rather than as a staged write. The worked example is content/tours/say-hello.md, and it walks as a plain three-step tour until the pin moves.
The last card: ## prompt
One heading is reserved. A ## prompt section is not a step; it is the card the tour ends on, where it asks the reviewer what the walk could not check and hands back a prompt to paste into a session. prompt can never collide with a real address, because it is not one of GRAIN's surface kinds.
## prompt
Two things the walk above cannot check for me.
- ask: reads-wrong | Anything here that reads wrong?
- ask: next | What should change?
- template: Continue the {title} review (tour {tour}).\nWrong: {reads-wrong}\nNext: {next}
- handoff: https://claude.ai/new?q={payload}| Key | Meaning | |
|---|---|---|
ask | `<id> \ | <question>. The id is the token the answer fills; it must be token-safe (letters, digits, -, _`). A malformed, unlabelled or duplicate ask is dropped with a warning. |
template | The text composed from the answers. {id} per ask, plus {title} and {tour} for the tour's own title and id. \n becomes a real line break. Required: without it there is nothing to hand back. | |
handoff | Optional. A URL template with {payload}, the same contract as GRAIN's handoff.js, so one click opens the composed prompt in a session. The button only appears when the host has loaded that script; the text stands on its own without it. |
Prose in the section (any line that is not one of those keys) is the card's intro.
An answer left blank leaves its {token} visible in the composed text, so a half-answered card still reads as something a person can finish by hand. crumb check fails an ask the template never uses (its answer would be thrown away), and the parser reports a {token} that is not an ask id.
Nothing here is submitted. The answers live in memory for the length of the walk, compose into text in the browser, and go wherever the tour's own URL template says, which is the host's decision, not CRUMB's.
A tour with zero ## steps still parses, but is flagged by crumb check ("no steps, a tour needs at least one"). Nothing a parser encounters is silently dropped: an invalid mode, a mismatched id, an unrecognized status, or a heading with empty text all come back as warnings attached to the parsed tour, which crumb check prints.
A real tour, in full
This is content/tours/portfolio.md, the live "tour of the desk" on this site (trimmed for space; the shape is unedited):
---
id: portfolio
mode: demo
title: "A tour of the desk"
route: /
---
Welcome. This site is built like a small operating system, and every part of it is an addressable
surface that both a person and an AI can drive. This quick tour lights up the pieces one at a time.
## screen
- verify: Resize the window; the rail, tabs, and panel stay live and keep their addresses.
This whole window is the desk. It is a real app shell, not a marketing page.
## nav:/notes
- status: changed
- review: The dock gained a Plans row and split into an apps group and a meta group.
- verify: Hover each row; every one is a real link with its own destination.
This is the app dock. Each row is an addressable navigation surface.
## note:ten-times-zero
- at: /notes
- status: new
- review: The tour routed here with a real navigation, the same code path as clicking the dock.
- verify: Use Back in the sidebar; the tour navigates home and keeps its place.
Now we have moved for real. This is the flagship post, on how the whole stack got built with AI
doing most of the typing.Note the mix: screen and chat-input in the real file carry no at (they're present on every page), while note:ten-times-zero sets at: /notes because that surface only exists on that route. This one tour is authored once and works in both demo mode (just the say prose) and dev mode (adds review/status/verify per step) because the author filled in both sets of fields; a pure onboarding tour can simply leave review/status/verify out of every step.
How the live client walks a tour
crumb-live.js fetches the tour once (GET /crumb/tours/<id>.json), then walks steps in array order, tracking the current index in sessionStorage (-1 means the intro card, before step 0). For the current step it:
- Resolves the step's route (
step.at, or the tour'sroutefor the intro card). - If that differs from the current page, does a real navigation and resumes on load.
- Lights the element at
[data-surface="<step.surface>"]with GRAIN's traveling lamp, in passthrough mode, so the highlighted surface stays clickable while the tour runs. - Opens a popover (or, in framed mode, a sidebar) showing
say(and, indevmode,review,status, andverify).
Switching demo ↔ dev re-renders the same step in place with no navigation. A tour only offers the toggle at all when at least one step actually carries review, status, or verify; a pure demo tour with none of those fields shows no dev-mode switch, since it would be a no-op.
The auto-generated flagship: review tours from the audit trail
from-timeline.ts exports stepsFromTimeline/tourFromTimeline, a pure projection from GRAIN's LogEntry[] audit trail (every RenderOp an AI has emitted to an addressable surface) into exactly the Tour/Step shape above, grouped one step per surface touched. A step whose crossing failed (ok === false) becomes a known-issue step; everything else becomes needs-verification. toTourMarkdown(tour) serializes that back to the same frontmatter + ## <surface> grammar this page documents, so a generated review tour round-trips through the same parser a hand-authored tour uses and is a real file a human can edit afterward. This is the engine only: nothing in the package today automatically writes a tours/review/<session>.md file after an AI task finishes; wiring that end-to-end (the crumb init contract) is a later, planned piece.
Next steps
GETTING-STARTED.mdfor installing and mounting CRUMB, and the exact routes it serves.- CRUMB's
PLAN.mdfor the full design and the reuse-not-rebuild table against GRAIN's existing primitives. - Run
bunx crumb check <dir>against your owntours/folder before shipping a new tour.