Sequences
Updated August 26, 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 lives 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 sequences live
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 record of any run. The switch is in Settings → agents → 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 does 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.
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 live at <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.
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 promised 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 decides 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, 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"] }
},
{
"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"
}
]
}
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. A stage may omit permissions and use the gem’s interactive approval behavior.
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 and permissions
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.
Because a poll runs unattended, declare permissions.capabilities explicitly on the sequence and on every stage. The sequence’s declaration is the outer envelope. A stage may narrow that list, but it cannot widen it.
Schedule, file, and poll examples all need an explicit unattended envelope at both levels. This read-only form is the smallest valid one:
"permissions": { "capabilities": ["read"] },
"stages": [
{
"id": "start",
"agent": "claude",
"brief": "Inspect the trigger input and report the result.",
"timeout": "10m",
"permissions": { "capabilities": ["read"] }
}
]
For the poll example, add "reads": ["release.json"] to that 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
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.
Related
- Fanout — many agents on one task at once, rather than stages in order
- Scheduled agents — a single prompt on a timer, without stages
- CLI reference — every flag