# Sequences

> Run a fixed pipeline of agent stages from a definition file, started by hand or by a schedule, file change, or polled URL.

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](/docs/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:

```bash
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](/crystl-guild), 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](/changelog/): the commands arrive with the app, so an app that predates them has nothing to switch on.

## A minimal sequence

```json
{
  "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:

```bash
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:

```json
{
  "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](https://www.rfc-editor.org/rfc/rfc6901), ` == `, 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:

```json
{
  "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 it
- **`schedule`** — on a calendar recurrence at a wall-clock time
- **`file`** — when a watched path changes
- **`poll`** — when a watched URL's content changes

### manual example

```json
"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.

```json
"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.

```json
"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:

```json
"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:

```json
"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`:

```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

```bash
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:

```bash
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](/docs/fanout/) — many agents on one task at once, rather than stages in order
- [Scheduled agents](/docs/schedule-agents/) — a single prompt on a timer, without stages
- [CLI reference](/docs/cli/#sequence) — every flag

---
Source: https://crystl.dev/docs/sequences/
