Sequences
Updated September 29, 2026
Sequences are an early preview. The shape of a definition file may still change between releases, so treat one you write today as something you may need to adjust later.
A sequence is a fixed pipeline of agent stages, written down in a file and run the same way every time. Where fanout spreads one task across many agents at once, a sequence runs stages in order and hands each one the output of the last. A stage ends by saying what it produced, and the next stage reads that.
Each sequence is in its own folder under .crystl/sequences/<name>/, with a sequence.json describing the trigger, the budget, and the stages.
The quickest start is a complete generated draft. Each trigger gets its own working example rather than an empty placeholder:
crystl sequence new "Manual review" --trigger manual
crystl sequence new "Daily review" --trigger schedule
crystl sequence new "Inbox watcher" --trigger file
crystl sequence new "Release feed" --trigger poll
Review the generated brief and example endpoint or path, then run crystl sequence validate <name> and crystl sequence publish <name>. Use crystl sequence schema when you need the exact schema compiled into your installed version.
Where to find sequences
Sequences are on out of the box. The sidebar drawer’s mode button cycles workbench → markdown → sequences, and that third view is the catalog: every definition in the open gem, whether it validates, and the gem’s twenty most recent runs (see seeing them). The switch is in settings, schedules under “sequences (early access)” if you want them out of the way.
Writing a definition is free — it is a file in your own repository, and you can write it in any editor. Running one needs a Guild membership, and so do publishing, drafting, and a schedule, file-watch, or poll trigger that starts one for you. Without one you can still read the catalog, check whether a definition validates, and read the record of any run.
If crystl sequence reports that this crystl has no sequences routes, compare the version in crystl status against the changelog: the commands arrive with the app, so an app that predates them has nothing to switch on.
A minimal sequence
{
"schema_version": 1,
"id": "hello",
"name": "hello",
"description": "Two stages that write a file each.",
"trigger": { "type": "manual" },
"budget": { "wall_clock": "20m", "tokens": 200000 },
"stages": [
{
"id": "draft",
"agent": "claude",
"size": "small",
"brief": "Write two sentences about this repository into draft.md in the directory named by $CRYSTL_STAGE_DIR. Then run: crystl stage complete",
"writes": ["draft.md"],
"accept": { "files": ["draft.md"], "non_empty": ["draft.md"] },
"timeout": "10m",
"effects": "pure",
"on_fail": "stop"
},
{
"id": "review",
"agent": "claude",
"size": "small",
"brief": "Read the draft you were handed, write one sentence of feedback into review.md in the directory named by $CRYSTL_STAGE_DIR, then run: crystl stage complete",
"reads": [{ "stage": "draft", "path": "draft.md" }],
"writes": ["review.md"],
"accept": { "files": ["review.md"], "non_empty": ["review.md"] },
"timeout": "10m",
"effects": "pure",
"on_fail": "stop"
}
]
}
Stages
A stage names an agent, a size, and a brief — the instruction the agent receives. It declares what it reads from earlier stages and what it writes, and crystl passes exactly those files between them. Nothing else crosses the boundary, so a stage cannot quietly depend on something the definition does not mention.
A stage can name a hero in place of an agent, and it then runs that hero’s persona. A hero stage takes its command from the gem’s own Default Agent, then from your configured command for the size it asks for (settings, agents), the same commands a hero summon uses. It never reads whatever agent happens to be running in the gem.
accept is the stage’s own gate. A stage that was asked for draft.md and produced nothing does not pass, and the run stops rather than handing the next stage an empty input. You can require files to exist, to be non-empty, or to match a JSON schema.
Stage artifacts are written to <run>/<stage>/<attempt-uuid>/. $CRYSTL_STAGE_DIR names that exact attempt directory, so use it to write artifacts and use declared artifact bindings to read them. Do not reconstruct or hardcode <run>/<stage> paths: a retry has a different attempt UUID.
What a stage can do
A stage with no permissions block runs the way any shard in that gem runs: your agent keeps its normal tools and your gem’s usual approval behaviour applies. That shape is allowed only under a manual trigger. A schedule, file, or poll trigger runs with nobody at the keyboard, so crystl sequence validate refuses an unattended sequence whose stages do not declare permissions.capabilities.
permissions.capabilities, on a stage, is that declaration:
"permissions": { "capabilities": ["read", "write-workspace"] }
The words you can use are read, write-workspace, run-tests, network, and push. crystl checks the shape when you write one, so an unknown capability or an unknown field is refused at validation time rather than accepted and ignored.
A declared set is enforced while the stage runs. Reading is the floor of every set, and the stage’s own artifact directory is always writable, so even a read-only stage can produce what it declared. Writing the workspace needs write-workspace (and never reaches .git or .crystl/sequences); the WebFetch and WebSearch tools need network. Shell commands stay available to every agent. A request outside the set is denied with the reason instead of parked on an approval card, so an unattended run never stalls waiting for a person who is not there — the agent can adapt, or declare itself blocked.
The gem’s approval mode is not part of this. That setting governs interactive work, and a stage a trigger started has nobody to ask.
A stage’s list is independent. The sequence-level permissions describes the work the whole definition asks for and is not intersected with a stage’s list — both are written by the same author in the same file, so neither silently rewrites the other. The one outside constraint is provenance: a synced definition is remote text, so past read its stages get only the capabilities you granted it locally.
An accept.command is confined separately. It runs under crystl’s acceptance sandbox, which denies by default: it can read the machine and run your toolchain, but it cannot write outside its own scratch directory, reach the Keychain, or talk to crystl’s bridge. Its own permissions.capabilities widen that sandbox — write-workspace to let it write the workspace, network to let it reach the network — and crystl runs the command inside exactly the sandbox you wrote, even when it asks for more than the stage around it.
Declaring a result
A stage’s agent ends by saying what happened. This is the one part of a sequence an agent runs itself:
crystl stage complete # done, artifacts are in place
crystl stage complete --result out.json # done, and here is the result artifact
crystl stage blocked "no credentials for the staging host"
crystl stage nothing "no flaky tests in this run"
blocked and nothing are different answers, and the difference matters. nothing is a successful stage that found nothing to do — the run keeps going instead of failing. blocked means the stage could not continue and needs a person, so the run parks until you deal with it.
nothing is a status, not a shortcut. The stage still goes through its own accept gate, exactly as complete does. So a stage that declares writes: ["todos.md"] and then reports nothing fails, because it declared a file it did not write. If a stage might legitimately find nothing to do, do not declare its output as a required write — write a flag artifact instead, and let the next stage decide with when.
Skipping a stage
when on a stage controls whether that stage runs at all. It reads one value out of an earlier stage’s artifact and compares it:
{
"id": "fix",
"when": "flags.json#/found == true",
"brief": "Fix the flaky tests listed in flags.json."
}
The shape is fixed: the artifact’s name as an earlier stage declared it in writes, a # and a JSON pointer, ==, and one scalar — true, false, null, a number, or a quoted string. Name the artifact exactly as it was written (flags.json), not the stage that produced it and not a path. It is deliberately not an expression language, so a condition either means one thing or is refused at validation time — including a name no stage writes, which crystl sequence validate rejects rather than quietly treating as false.
A false condition skips the stage: it is neither a success nor a failure, the run moves to the next stage, and a skipped final stage ends the run the same way an accepted one does. Anything the condition cannot answer is also false — a missing artifact, a document that will not parse, a pointer that does not resolve. The stage does not run when its input does not exist.
Put together with nothing, that is the “found nothing to do” shape end to end:
{
"stages": [
{
"id": "scan",
"brief": "Look for flaky tests. Write {\"found\": true|false} to flags.json in $CRYSTL_STAGE_DIR, then run: crystl stage complete",
"writes": ["flags.json"],
"accept": { "files": ["flags.json"] },
"timeout": "10m"
},
{
"id": "fix",
"when": "flags.json#/found == true",
"reads": [{ "stage": "scan", "path": "flags.json" }],
"brief": "Fix the flaky tests named in flags.json, then run: crystl stage complete",
"timeout": "10m"
}
]
}
The scan stage always writes the flag and always reports complete, so its accept gate is satisfiable either way. The fix stage runs only when there is something to fix.
Triggers
A sequence can start by hand, or on its own:
manual— only when you run itschedule— on a calendar recurrence at a wall-clock timefile— when a watched path changespoll— when a watched URL’s content changes
manual example
"trigger": { "type": "manual" }
A manual sequence needs no trigger-specific fields.
schedule timing
Schedule recurrences are daily, weekdays, weekly, monthly, quarterly, or yearly, each with a wall-clock time. Cron expressions, interval schedules, and sub-daily runs are not supported.
"trigger": {
"type": "schedule",
"recurrence": "weekdays",
"time": "09:00"
}
file watches
File triggers use gem-relative path prefixes. They can watch create, modify, and delete events; omitting the events uses create and modify. Set a debounce from 2 seconds through 1 hour.
"trigger": {
"type": "file",
"paths": ["inbox", "notes/todo.md"],
"events": ["create", "modify", "delete"],
"debounce": "30s"
}
paths is a non-empty array of literal gem-relative path prefixes. It is not a glob list, so use notes rather than notes/*.md — a path containing * or ? is refused when the definition loads. A directory watches every file under it, at any depth, so filter by name in the first stage when only some of them matter.
A polled source that keeps failing backs off and eventually disarms itself rather than retrying forever, and a trigger firing repeatedly is rate-capped, so a source that changes on every request cannot start an endless run of runs.
poll inputs
A poll definition requires exactly one entry in sequence.inputs. Its key is the input artifact name and its value is the filename of a JSON Schema in the sequence folder:
"trigger": {
"type": "poll",
"url": "https://example.com/releases/latest.json",
"interval": "5m",
"condition": "changed"
},
"inputs": {
"release.json": "release.schema.json"
}
The HTTP response must validate against that schema before the run starts. When an artifact schema declares a dialect, use JSON Schema 2020-12, for example "$schema": "https://json-schema.org/draft/2020-12/schema"; a draft-07 declaration is refused.
For the poll example, add "reads": ["release.json"] to the first stage and place this release.schema.json beside sequence.json:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object"
}
schedule, file, poll, and manual are the trigger types implemented today. The schema also recognizes webhook, email, and on_run_complete, but none of them has a dispatcher yet, so crystl sequence validate refuses a definition that uses one rather than accepting it and staying silent. There is no workaround: a definition using one of these does not load, so it cannot be run manually either.
Running one
crystl sequence list # what exists, and whether it can run
crystl sequence validate hello # resolve it without running anything
crystl sequence run hello # run, and follow it to the end
crystl sequence runs hello # prior run ids, states, and update times
crystl sequence runs --limit 0 # every run on record, not just the newest 50
crystl sequence status <run-id> # where a run got to
crystl sequence cancel <run-id> # stop it and release its workspace
runs is how you find a run id you no longer remember. It lists the newest 50 by default, newest first; pass a sequence id or display name to narrow it, --limit to change how far back it looks, and --json for the raw rows.
validate is worth running first. It resolves the whole definition — every stage, agent, size, and trigger — and reports what would happen without starting an agent or spending a token.
A manual run of a poll sequence is its test-now path. crystl fetches the current response without conditional change headers, validates it against the declared input schema, and seeds the input before stage 1 starts. Testing does not consume or change the scheduled poll baseline.
Drafts and publishing
A sequence is either a draft or published. Triggers and manual runs both refuse a draft, so a half-written definition cannot fire:
crystl sequence publish hello # make it live
crystl sequence draft hello # take it back out of service
Where runs happen
A stage runs on the local machine, in a gem you name. It can run in that gem’s checkout, or in its own isolated worktree so a run cannot disturb what you are working on. Remote hosts are not implemented yet — a definition naming one is refused at validate time rather than failing halfway through a run.
Runs hold a workspace lock, so two runs cannot occupy the same worktree at once. crystl sequence cancel releases it.
Seeing them
The drawer is the recent view. It keeps the twenty most recent runs of each gem, on disk. A run that failed stays on the list instead of disappearing, with the stage that failed and a plain-English reason beside it, so the question the list answers is whether your runs have been failing lately. The list is still there after you quit and reopen crystl.
Twenty runs are twenty runs however old they are. There is no age limit, so a gem you have not touched in months still shows its last twenty rather than an empty drawer.
crystl sequence runs is the fuller record. It reads the run records themselves rather than the drawer’s list, so it reaches further back: the 50 newest by default, and as far as you ask with --limit. Twenty in the drawer and fifty on the command line is the two views answering different questions. Neither surface deletes a run record; they stay on disk until you clear them yourself.
crystl sequence open slides the sequences panel into view, and crystl sequence open <run-id> opens a specific run. Like the other reveal commands, it acts on the gem you are looking at — it will not switch your window to a different gem.
Clearing run records
Run records only ever grow. Each one holds what every stage did, what it produced, the evidence behind each acceptance, and why a failed run failed, and nothing removes them on a timer.
storage in settings, under SEQUENCE RUNS, is where you clear them, in total or one gem at a time. A run that is still going is never cleared, and everything a run wrote into your project stays. See storage for the figures the page shows, what each scope reaches, and what you lose.
Related
- Fanout — many agents on one task at once, rather than stages in order
- Scheduling for one payload on a timer, a prompt or a shell command, without stages
- CLI reference — every flag