Anatomy of a play
Where you are in the journey: you've created a play the guided way. This section slows down and names every part, so that when lint complains or a review asks "why is this one step?", you know exactly what's going on. Skim it now; return whenever you need the details.
A play is one file with two halves, plus a tiny manifest at its side:
01main.ts02├── /** @rote-frontmatter … */ ← the contract: what runs (YAML, machine-executed)03└── TypeScript below the comment ← the presentation: how results read (human-rendered)04deps.toml ← the tools the steps needThe runner executes the frontmatter. The presentation only renders what the steps produced. This split is load-bearing: execution is typed, deterministic, and resumable, while formatting stays free to be as human as you like.
The frontmatter contract
01name: dns-propagation-check02version: 1.1.003description: Compares authoritative DNS answers with Cloudflare, Google, and Quad9 …04provenance:05 author: chetan <[email protected]>06 workspace: refactor-dns-propagation-check-dag # the exploration that birthed it07metadata:08 status: released09 execution_model: steps_with_presentation10 flow_type: parallel11parameters:12- name: domain13 param_type: string14 required: false15 default: example.com16 description: DNS name to check, such as example.com17 example: example.com18 valid_values: null19steps:20 …Things worth knowing before lint teaches them to you the hard way:
versionis semver and immutable once pushed — every change means a bump.parametersuseparam_type, and non-string defaults are quoted strings (default: '20',default: 'false'). Integer and boolean params are parsed from those strings.provenance.workspacenames the exploration workspace the play was crystallized from — the paper trail from finished artifact back to the work that proved it.- The whole frontmatter lives in a JSDoc comment, so a literal
*/anywhere inside it (even in an embedded shell glob or code comment) ends the frontmatter early. Lint will point at it.
Steps: the DAG
The steps: map is the heart. Each step is an isolated process with an explicit place in a graph:
01steps:02 validate_input:03 type: process.exec04 timeout_ms: 1500005 argv: [python3, -c, "…", $domain, $record_type]06 07 query_cloudflare:08 type: process.exec09 timeout_ms: 3000010 depends_on:11 - validate_input12 argv:13 - python314 - -c15 - |216 17 import json, sys18 domain, rtype = sys.argv[1], sys.argv[2]19 …20 - '@validate_input{$.stdout.text | fromjson | .domain}'21 - '@validate_input{$.stdout.text | fromjson | .record_type}'Two kinds of edges, and the distinction matters:
| Edge | Written as | Meaning |
|---|---|---|
| Ordering | depends_on: [a, b] | run after — a barrier, no data implied |
| Value | '@step{$.stdout.text | fromjson | .field}' in argv | data flows — this arg is computed from that step's recorded output |
Value edges are the honest ones: they make dataflow inspectable (play-dag draws them with their exact jq paths) and they're how the runner knows a step truly needs another's output, not just its completion.
Layers fall out of the graph. Steps whose dependencies are all satisfied run together; [layer 2 — 4 parallel] in your run output is the runner exploiting exactly the independence you declared. You never write concurrency code — you write edges.
Rules of the value-edge road (learned so you don't have to):
- The jq expression must resolve to a scalar.
$.stdout.textalone won't pass; pipe to a field orjoin(","). - The jq dialect is a subset —
fromjson, field access,join,map/select,to_entrieswork;tojsondoes not. - To move a collection between steps, have the producer pack it into a delimited scalar field and the consumer unpack it. The convention across the modiqo plays uses ASCII separators, which no TXT record, PR title, or market question can collide with:
01FS, RS = chr(31), chr(30) # unit / record separators02packed = RS.join(FS.join([label, server, values]) for ...)03print(json.dumps({'ok': True, 'packed': packed}))01- '@discover_authoritative{$.stdout.text | fromjson | .packed}'Degrade, never die
A play that checks eight sources must not be killed by one flaky endpoint. The standard is a two-lane failure model:
| Situation | Behavior | How |
|---|---|---|
| Expected absence — endpoint down, feature not selected, nothing found | Step succeeds with a labeled unknown | print {"ok": true, "warning": "…"}, exit 0 |
| Hard fault — invalid input, missing required tool, unusable state | Step fails closed; dependents show BLOCKED; --resume offered | message on stderr, nonzero exit |
The user-visible payoff is the stage ledger every good play renders:
01 stages ██████████████████░░░░░░ 6/8 ok02 ████████ validate input ok03 █████░░░ page fetch degraded — site fetch failed: nodename nor servname …04 ░░░░░░░░ Lighthouse skipped — Lighthouse was not requestedFull bars, honest gaps. A degraded source is a visible unknown — the play completes and tells you exactly what it couldn't know.
Destructive plays add one more rule: mutations live in their own step, gated behind an explicit parameter (apply=true), so the default run is a dry-run whose ledger shows the destructive stage as labeled skipped.
The presentation
Below the frontmatter, TypeScript renders the recorded step outcomes:
01const { FlowOutput, loadPresentationContext, stepName } = await import("__ROTE_PRESENTATION_SDK__");02const out = new FlowOutput();03const ctx = await loadPresentationContext();04 05const step = ctx.step(stepName("compute_verdict")); // literal names only — lint enforces it06// step.outcome.status: completed | restored | skipped | blocked | failed07// step.outcome.output.body.stdout.text: what the step printed08 09out.human(reportText); // default view10out.summary(oneLine); // --output=summary11out.result(structured); // --output=jsonThree habits make presentations robust:
- Tolerate anything. Parse each step's stdout defensively and render a degraded row instead of throwing — the presentation also runs during
--resumeon partial results and during lint with synthetic bodies. stepName("literal")only. Lint rejects dynamically computed step references; the whole point is static knowledge of what reads what.- Representation parity.
human,summary, andresultare views of one run. The contract: no semantic fact silently disappears from a view that claims to be complete. Truncate in the human view only with a declared count ("… and 12 more in the JSON result"); makeresultthe canonical superset; mark the summary as intentionally lossy. The plays on the hub carry this declaration explicitly:
01out.result({02 …,03 representations: {04 human: "complete — ledger, verdict, and every check with detail",05 json: "canonical — adds raw fields the human view condenses",06 summary: "intentionally lossy — verdict and counts only",07 },08});deps.toml: honest requirements
01schema_version = 102 03[[tools]]04id = "python3"05command = "python3"06required = true07 08[[tools.install]]09manager = "brew"10package = "python@3"11 12[[tools]]13id = "lighthouse"14command = "lighthouse"15required = false # the play degrades gracefully without itDeclare exactly what the steps call — no more (phantom deps block users needlessly), no less (missing deps turn into runtime mysteries). Optional tools pair with a degrade path in the step that uses them.
Timeouts, fan-out, and other step powers
Every step carries an explicit timeout_ms, sized to its worst honest case: ~15s for local validation, 45–90s for a network read, 120s+ for batch API work. The runtime defaults to 30s when you omit it — a decision you didn't make.
For per-item work, the grammar has declarative fan-out:
01 process_items:02 type: process.exec03 for_each: '$.items' # one step instance per item04 max_concurrency: 4 # bounded parallelism for this group05 argv: [python3, -c, "…", $item, $item_index]And process.exec is only one step type — adapter steps (endpoint: adapter/<id> with method:/params:), adapter.auth.ensure, and the browser.* family exist too. Section 5 covers when each reach is the right one; rote grammar steps is the exhaustive reference.
The self-check
Before you ship, point the x-ray at your own play:
rote play run https://play.modiqo.ai/modiqo/play-dag play=./main.tsIf it shows 1 step · 1 layer for multi-source work, you've written a monolith — the runner can't parallelize, checkpoint, resume, or blame per source, because you hid the structure inside one script. If it shows steps but (no edges), your DAG is implicit and unprovable. The published exemplars to compare against: modiqo/hello (9 steps · 2 layers) and modiqo/play-dag itself (4 steps · 3 layers).