---
title: "Routines as code with the CLI"
description: "Write routine files, check them with validate, backtest and test, and sync them with push and pull."
section: "routines"
url: "https://forkline.dev/docs/routines/cli-workflow"
markdown: "https://forkline.dev/docs/routines/cli-workflow.md"
source: "src/content/docs/routines/cli-workflow.mdx"
---

# Routines as code with the CLI

Keep routines as files in your repository and sync them with the `forkline` CLI. There is no natural-language authoring in Forkline: you bring your own LLM (or write the file yourself), and the CLI checks the result against the server's rules before anything is saved. These docs are written for that LLM.

> [!TIP]
> **Agent hint**
> Follow this loop, in this order. Every step before `push` is read-only and needs only a token with the `routines:read` scope.
>
> 1. `forkline routine new --example <name>` or `forkline routine new --from-event <id>`: writes a local scaffold, does not touch the server.
> 2. Edit the file. One routine per file, `.forkline/routines/<name>.yaml`, file name equal to the `name` field.
> 3. `forkline routine validate <file>`: exit code 2 means the file is invalid; fix every `{file}:{line}: {message} ({pointer})` line and run it again.
> 4. `forkline routine backtest <file>`: how often it would have run in the last 7 days. 0 is almost always a filter that is too narrow.
> 5. `forkline routine test <file> --event <id>`: why one event runs or is skipped, plus the rendered prompt.
> 6. `forkline routine push [--publish]`: validates every file first and writes nothing if one is invalid. Files are matched to routines by `name`.
> 7. `forkline routine runs <name>`: what the routine did with each event.
>
> `forkline routine pull` downloads stored routines as files. Add `--json` (or `-o json`) to any command for machine output; on errors the JSON is on stdout and the exit code still tells you what failed (table [below](#exit-codes)). A path that exists is a file, anything else is a routine name, UUID or id prefix.

## The loop

```bash
# 1. scaffold: from an example, or from a recent event of your repositories
forkline routine new --example review-prs
forkline routine events --repo acme/api --since 3d        # find an event id
forkline routine new review-api-prs --from-event 0000000e-0000-4000-8000-000000000001

# 2. edit .forkline/routines/review-api-prs.yaml, then check it
forkline routine validate .forkline/routines
forkline routine backtest .forkline/routines/review-api-prs.yaml
forkline routine test .forkline/routines/review-api-prs.yaml --event 0000000e-0000-4000-8000-000000000003

# 3. send it to the server (draft), or publish at once
forkline routine push --dry-run
forkline routine push --publish

# 4. watch it
forkline routine runs review-api-prs --since 1d
```

Nothing is required between `validate` and `push`: a test or backtest is advice. The publish dialog and `push --publish` do not ask for one. See [Test, backtest, recent events, run history](https://forkline.dev/docs/routines/testing-and-activity) for what each check reports.

## Files and directory layout

```text
your-repo/
  .forkline/
    routines/
      review-prs.yaml
      weekly-deps.yaml
      deploy-hook.json
```

- **One routine per file.** The default directory is `.forkline/routines/`, and `push`, `pull` and `new` use `<directory>/<name>.yaml` there.
- **File shape.** `name` and (optional) `description` come first, then the spec keys (`on`, `repositories`, `filters`, `agent`, `concurrency`, `limits`) at the top level, exactly as in the [spec reference](https://forkline.dev/docs/routines/spec#the-routine-file). The file does not repeat the routine's status or ID.
- **YAML or JSON.** A path ending in `.json` is read as JSON, anything else as YAML. `validate` and `push` read every `*.yaml`, `*.yml` and `*.json` file of a directory (not subdirectories). `new` and `pull` always write YAML.
- **Name and file name.** `push` matches by the `name` field, not by the file name. Keep them equal so that `pull` and `push` stay in step.

## Starting points

### `new --example`

`forkline routine new --example <name>` writes one of six embedded examples. The first argument is optional and renames the routine (`forkline routine new my-review --example review-prs`). The repository selector is a placeholder (`your-org/your-repo`): change it before you push.

| Example | Event | What it does |
|---------|-------|--------------|
| `review-prs` | `pr.opened`, `pr.updated` | Reviews pull requests to `main` |
| `fix-ci` | `ci.failed` | Fixes failing CI and pushes to the same branch, at most 3 runs per pull request |
| `triage-issues` | `issue.opened` | Suggests labels and asks for missing details |
| `fix-command` | `comment.created` | Reacts to a `/fix` command in a comment |
| `weekly-deps` | schedule, Mondays 06:00 Europe/Berlin | Updates outdated dependencies and opens a pull request |
| `custom-webhook` | `on.webhook` | Investigates an alert your own system sends |

```yaml routine
name: review-prs
on: { events: [pr.opened, pr.updated] }
repositories: { include: [{ name: "your-org/your-repo" }] }
filters: { branches: { include: [main] } }
agent:
  prompt: |
    Review pull request #{{ pr.number }} "{{ pr.title }}" in {{ repository.fullName }}.
    Point out bugs and risky changes.
```

```yaml routine
name: fix-ci
on: { events: [ci.failed] }
repositories: { include: [{ name: "your-org/your-repo" }] }
agent:
  prompt: |
    CI failed on {{ ci.branch }} in {{ repository.fullName }}:
    {% for f in ci.failures %}- {{ f.name }}: {{ f.url }}
    {% endfor %}Fix the failures and push to the same branch.
limits: { maxRunsPerPr: 3 }
```

```yaml routine
name: triage-issues
on: { events: [issue.opened] }
repositories: { include: [{ name: "your-org/your-repo" }] }
agent:
  prompt: Triage issue #{{ issue.number }} "{{ issue.title }}" in {{ repository.fullName }}. Suggest labels and ask for missing details.
```

```yaml routine
name: fix-command
on: { events: [comment.created] }
repositories: { include: [{ name: "your-org/your-repo" }] }
filters: { command: { names: [fix] } }
agent:
  prompt: Handle the /fix command in {{ comment.url }} on {{ repository.fullName }}.
```

```yaml routine
name: weekly-deps
on: { schedule: { cron: "0 6 * * 1", timezone: Europe/Berlin } }
repositories: { include: [{ name: "your-org/your-repo" }] }
agent:
  prompt: Update outdated dependencies in {{ repository.fullName }} and open a pull request.
```

```yaml routine
name: custom-webhook
on: { webhook: {} }
repositories: { include: [{ name: "your-org/your-repo" }] }
agent:
  prompt: "Investigate this alert in {{ repository.fullName }}: {{ payload }}"
```

### `new --from-event`

`forkline routine new [NAME] --from-event <id>` drafts a routine from one of your [recent events](https://forkline.dev/docs/routines/testing-and-activity#recent-events), so the filters match what really happens in your repositories. Find the ID in the last column of `forkline routine events` (the command prints a hint with the same advice under the table). The facts you keep (`--fact`), the widen suggestion (`--widen`) and the pinned-first backtest are described in [With the CLI](https://forkline.dev/docs/routines/testing-and-activity#with-the-cli).

A file written from an event starts with a comment header that records where each part came from. The paths are JSON pointers into the spec:

```yaml
# Created by `forkline routine new --from-event 0000000e-…-0001` on 2026-10-16.
# from event: /on/events/0, /repositories/include/0, /filters/branches/include/0
# suggested: /filters/draft, /filters/bots, /agent/prompt
# widen: acme/api-* (covers 2 repositories, would have run 7 times in 7 days): rerun with --widen
```

`new` never overwrites: if the target file exists it exits with code 6 and changes nothing. Pass `--force` to replace it.

## Commands

All commands are `forkline routine <command>`. `<file|ref>` is a routine file when the path exists, otherwise a routine name, UUID or id prefix. A reference to a stored routine uses its stored spec.

| Command | Reads or writes | Endpoint | Scope |
|---------|-----------------|----------|-------|
| `new` | writes a local file | `POST /routines/drafts/from-event` (only with `--from-event`) | `routines:read` |
| `validate` | read-only | `POST /routines/validate` | `routines:read` |
| `test` | read-only | `POST /routines/test`, `POST /routines/{id}/test` | `routines:read` |
| `backtest` | read-only | `POST /routines/backtest`, `POST /routines/{id}/backtest` | `routines:read` |
| `events`, `event` | read-only | `GET /routines/events`, `GET /routines/events/{id}` | `routines:read` |
| `runs` | read-only | `GET /routines/{id}/runs` | `routines:read` |
| `push` | writes routines | see [push](#push) | `routines:write` |
| `pull` | writes local files | `GET /routines`, `GET /routines/{id}` | `routines:read` |
| `enable`, `disable` | writes a routine | `POST /routines/{id}/enable`, `POST /routines/{id}/disable` | `routines:write` |
| `run` | starts tasks (after a dry run and a confirmation) | `POST /routines/{id}/run` | `routines:execute` |
| `webhook` | read-only, rotates the secret with `--rotate-secret`, or sets a pasted one with `--set-secret` | `GET /routines/{id}`, rotate: `POST /routines/{id}/webhook/rotate-secret`, set: `PUT /routines/{id}/webhook/secret` | `routines:read`, `routines:write` to rotate or set |

### new

```text
forkline routine new [NAME] --example <name> [--out PATH] [--force]
forkline routine new [NAME] --from-event <id> [--fact KEY]... [--widen] [--out PATH] [--force]
```

| Flag | Meaning |
|------|---------|
| `[NAME]` | Routine name. Default: the example name, or the name the server suggests |
| `--example <name>` | One of `review-prs`, `fix-ci`, `triage-issues`, `fix-command`, `weekly-deps`, `custom-webhook`. Needs no network. Exactly one of `--example` and `--from-event` is required |
| `--from-event <id>` | A recent event UUID (calls the server) |
| `--fact KEY` | Repeatable. Only with `--from-event`. Omitted means the facts the server checks by default |
| `--widen` | Only with `--from-event`. Select the event's repository and also the first widen suggestion's pattern |
| `--out PATH` | File to write. Default `.forkline/routines/<name>.yaml`. A name with `/` needs `--out` |
| `--force` | Overwrite an existing file. Without it: exit code 6 |

Human output: the path that was written, then (for `--from-event`) widen hints on stderr. With `--json`: for `--example` `{"path": "...", "name": "...", "example": "..."}`; for `--from-event` the draft response (`name`, `spec`, `provenance`, `facts`, `widen`, `event`) plus `path`.

### validate

```text
forkline routine validate <file|dir|ref>...
```

Checks each argument against the server's rules without saving. A directory means every `*.yaml`, `*.yml` and `*.json` file in it. At least one argument is required. A stored routine (a ref) is validated by its stored spec.

```text
✓ .forkline/routines/review-prs.yaml: valid · 3 repositories
```

An invalid file prints one line per issue and the command exits with code 2 after checking every argument. Warnings go to stderr and do not change the exit code:

```text
.forkline/routines/review-prs.yaml:9: Unknown field `inlcude`. Allowed here: `include`, `exclude`. Did you mean `include`? (/filters/labels/inlcude)
```

With `--json`:

```json
{
  "results": [
    {
      "source": ".forkline/routines/review-prs.yaml",
      "name": "review-prs",
      "valid": true,
      "repositories": { "count": 3, "all": false },
      "warnings": [],
      "errors": []
    }
  ]
}
```

For an invalid file `valid` is `false`, `repositories` is `null` and `errors` lists `{pointer, message, code?}` items (see [Validation errors](https://forkline.dev/docs/routines/errors)).

### test

```text
forkline routine test <file|ref> --event <id|file> [--forge F --event-header H] [--delivery-id ID] [--prompt-only] [--expect run|skip]
forkline routine test <file|ref> --repository owner/name [--body FILE] [--prompt-only] [--expect run|skip]
```

Runs one event through every stage and writes nothing. Exactly one of `--event` and `--repository` is required; `--body` needs `--repository`. A UUID for `--event` is a recent event; a path is a webhook payload and then `--forge github|gitlab|forgejo` and `--event-header` are required, otherwise exit 2. `--delivery-id` is optional and only for a payload file: the delivery id the forge sent. Human output: one line per stage (`✓` passed, `⊘` skipped, `✕` error, `·` not checked), the predicted outcome with its reason, the rendered prompt and the ref. `--prompt-only` prints only the prompt. With `--json` the response is the full `TestRoutineResponse`. Inputs, stages and reasons are in [Test](https://forkline.dev/docs/routines/testing-and-activity#test).

`--expect run` exits with code 10 unless the outcome is `would_run` or `would_run_unverified`; `--expect skip` exits with code 10 when it is. The output is printed first.

### backtest

```text
forkline routine backtest <file|ref> [--since Nd] [--show would-run|skipped|all] [--limit N] [--fail-if-zero]
```

Replays the stored events of the last `--since` days (`1d` to `7d`, default `7d`) and estimates cost. `--limit` is 1 to 500 events (the server default is 100). Human output for an event routine: a summary line, then a table with the time, the event, the repository and title, the outcome and the reason. Events that failed in a stage count as skipped in the summary:

```text
Would run 9 (2 unverified) · skipped 31 · ≈ 9 sessions/week · up to 540 credits/week
```

A schedule routine has no events to list. Its summary line shows the runs per week, the repositories and the next run times; a webhook routine shows its repositories and the cost of one call:

```text
Runs 1/week on 3 repositories · next 2026-10-19 06:00 UTC, 2026-10-26 06:00 UTC · ≈ 3 sessions/week · up to 90 credits/week
Webhook on 3 repositories · up to 30 credits/call
```

With `--json` the response is the full `BacktestRoutineResponse`. `--fail-if-zero` exits with code 10 when nothing would run, which depends on the trigger:

| Routine | Exit 10 when |
|---------|--------------|
| Events | `would_run` plus `would_run_unverified` is 0 |
| Schedule | runs per week times repositories is 0 |
| Webhook | no repository is selected |

### events and event

```text
forkline routine events [--repo owner/name] [--event E[,E]] [--since Nd] [--reacted any|none|some] [--limit N]
forkline routine event <id>
```

`events` lists the recent events of your repositories (default 50, at most 1,000). `--json` gives `{"events": [...], "older_dropped": false, "next_cursor": "..."}`.

```text
TIME  EVENT            REPO                SUBJECT          ACTOR     REACTIONS                ID
8m    comment.created  testowner/testrepo  Comment on #103  testuser  fix-command: Skipped +2  a1d891fa-7fef-5507-9e27-17b7231fdd8a
9m    push             testowner/testrepo  Pushed to main   testuser  push-watch: Ran          591f7831-aa22-5a06-805d-1bd34e3f7cb3
Showing the first 2 events; more are available (raise --limit, up to 1000).
Draft a routine from one: forkline routine new --from-event <ID>
```

The last two lines go to stderr, so a pipe or `--json` output stays clean. The first appears only when more events are available, the second whenever the list is not empty (not with `--json`). A `FORGE` column (`GitHub`, `GitLab`, `Forgejo`) appears after `REPO` only when the listed events come from more than one forge; `-o wide` always shows it. `REACTIONS` shows what each routine did with the event. `event` shows one event with its facts and what each routine did; `--json` gives the full `RecentEventDetail`.

### runs

```text
forkline routine runs <ref> [--outcome X[,Y]] [--repo owner/name] [--pr N] [--since Nd] [--limit N]
```

A counts strip, then one row per run: time, event, target, outcome word, reason and task ID. The counts strip (`Ran 4 · Skipped 0 · Limited 0 · Errors 0 (last 7 days)`) follows `--since` and is not narrowed by the other flags; in it `skipped` does not include `limited`. `--outcome` takes the groups `ran`, `skipped`, `limited`, `errors` or exact outcomes; the group `skipped` includes `limited`. `--limit` defaults to 50 (at most 1,000; the CLI follows the paging cursor). `--json` gives `{"runs": [...], "counts": {...}, "next_cursor": "..."}`; `next_cursor` is present only when `--limit` stopped the listing early. Outcomes and reasons: [Run history](https://forkline.dev/docs/routines/testing-and-activity#run-history).

### push

```text
forkline routine push [PATH] [--dry-run] [--publish] [--yes]
```

`PATH` is a file or a directory (default `.forkline/routines`). Writing asks for confirmation; without a terminal, pass the global `--yes` or the command exits with code 2.

1. Every file is validated first. If any file is invalid, the command prints the issues, exits with code 2 and **writes nothing**, not even the valid files.
2. Each file is matched to a stored routine **by name**: no routine with that name means create (`POST /routines`), otherwise update (`PUT /routines/{id}`). An unchanged file is not sent.
3. With `--publish`, a draft routine is published and a disabled one is enabled.

`--dry-run` prints the plan and changes nothing. `--yes` skips confirmation prompts (for scripts). The plan table has one row per **step**: a file that is created and published shows two rows. The actions are:

| Action | Meaning |
|--------|---------|
| `create` | No routine with this name exists; it will be created as a draft |
| `update` | The stored spec differs from the file |
| `unchanged` | The stored routine already equals the file |
| `publish` | `--publish`: the routine is a draft and will be published |
| `enable` | `--publish`: the routine is disabled and will be enabled |

`STATUS` is the status of the routine after the step (`draft`, `published` or `disabled`). In a dry run it is the status the stored routine has now, and `—` for a routine that does not exist yet. For example, `push --publish`:

```text
FILE                                      NAME            ACTION     STATUS
.forkline/routines/review-prs.yaml        review-prs      update     published
.forkline/routines/weekly-deps.yaml       weekly-deps     create     draft
.forkline/routines/weekly-deps.yaml       weekly-deps     publish    published
.forkline/routines/fix-ci.yaml            fix-ci          unchanged  published
```

A new `on.webhook` routine prints its signing secret once, below the table: `Webhook secret for <name> (shown once): ...`.

With `--json`: `{"dry_run": false, "results": [{"file": "...", "name": "...", "id": "...", "action": "update", "status": "published"}]}`. `id` is absent for a routine that was not created yet (dry run), `status` is `null` there, and `webhook_secret` is present on the row that created or updated a webhook routine with a new secret. If a step fails half way, the same document is printed with the rows done so far and an `error` object, and the exit code tells what failed.

### pull

```text
forkline routine pull [REF]... [--dir PATH] [--force]
```

Downloads stored routines (all of them without refs) to `<dir>/<name>.yaml` (default dir `.forkline/routines`), in file shape: `name`, `description`, then the spec keys in spec order.

- A file that does not exist is written (`written`). A file with identical content is left alone (`unchanged`).
- A file that exists with **different** content is not overwritten (`skipped_conflict`). The command finishes the other files, then exits with code 6. Pass `--force` to overwrite.

With `--json`: `{"results": [{"name": "...", "id": "...", "path": "...", "action": "written"}]}` where `action` is `written`, `unchanged` or `skipped_conflict`.

### enable, disable, create, update, list

These keep working on single routines: `enable <ref>` and `disable <ref>` switch a published routine on and off (`enable` prints `Routine enabled.`), `create -f <file>` (alias `add`) and `update <ref> -f <file>` save one file without matching by name, `list` shows REPOS and LAST RUN columns. `new` is **not** an alias of `create`: it writes a local scaffold.

Output with `--json`: `list` prints `{"routines": [...]}`, `view` and `create` print the routine. See [CLI reference](https://forkline.dev/docs/reference/cli#routine-definitions).

### run

```text
forkline routine run <ref> [--payload FILE|-] [--event UUID] [--repository OWNER/NAME]... [--dry-run] [--show-prompt]
                           [--idempotency-key KEY] [--wait [--timeout 300s] [--poll-interval 5s]]
```

Starts a routine now, for real. It replaces the old `fire`, which no longer exists (`forkline routine fire` exits with code 2 and names `run`). The same limits apply as for a webhook call: [concurrency](https://forkline.dev/docs/routines/concurrency-and-limits), run limits, credits and the repository selection. See [Run now](https://forkline.dev/docs/routines/testing-and-activity#run-now) for the rules. Run now's own request limits are in [Webhook and Run now limits](https://forkline.dev/docs/routines/concurrency-and-limits#webhook-and-run-now-limits).

1. `run` reads the routine and checks the flags against its trigger. `--timeout` and `--poll-interval` are checked first: a bad value exits with 2 before anything is sent.
2. It does a **dry run** and prints a table of REPOSITORY, OUTCOME and REASON, then `Estimate: N runs, up to X credits (Y per run)`. `--show-prompt` adds the rendered prompts.
3. `--dry-run` stops here (exit 0). Otherwise it asks `Start 3 runs on acme/api, acme/web (+1)? about 90 credits [y/N]`. `--yes` skips the question; without a terminal (or with `--no-input`) and without `--yes` it exits with code 2 before sending anything. Answering no prints `aborted` and exits with 1.
4. It starts the runs, always on the repositories that passed the dry run, and prints the event, one row per repository, and `Task: <id> <session url>`.

| Flag | Meaning |
|------|---------|
| `--payload <file\|->` | Webhook routines only. A JSON body of the shape the webhook delivers (`-` for stdin) |
| `--event <uuid>` | Event routines only, and required for them. Replays that recent event |
| `--repository owner/name` | Repeatable. Run only on these repositories of the selection; an unknown one exits with 2 and lists the routine's repositories |
| `--dry-run` | Show the table and the estimate, start nothing. Conflicts with `--wait` |
| `--show-prompt` | Print the rendered prompt per repository |
| `--idempotency-key <key>` | A repeated request with the same key starts nothing new |
| `--wait`, `--timeout`, `--poll-interval` | Poll until every started task is done (default `300s` and `5s`). Exit 1 if a task failed, 8 on timeout |

`--event` cannot be combined with `--payload` or `--repository`. Repositories a dry run says are blocked (by `when`, for instance) are skipped: stderr says `skipping N blocked repositories`. If nothing can run, an event replay is blocked, or it reads fields the event store does not keep, `run` exits with 2 and prints the reason without sending a real request. A refused request (`422`: `filters_not_matched`, `event_fields_not_stored` and so on, see [errors](https://forkline.dev/docs/routines/errors)) also exits with 2. A `429` exits with 9. A draft exits with 6.

With `--json`, `run` prints the response of the API; `--dry-run` prints the dry-run response. With `--wait` the response gets one more key, `tasks`, with the final state of each task, only when at least one task started.

### webhook

```text
forkline routine webhook <ref> [--rotate-secret | --set-secret FILE|-]
```

Prints the webhook URL, the authentication method (`webhook_auth`: `type` and `header`, plus the `preset` for HMAC, or for a token a `value` such as `Bearer <secret>` or `<secret>`; a token in a custom header prints that header's name), whether a secret is configured, where it came from (`webhook_secret_origin`: `generated` or `provided`) and when it was last set. `--rotate-secret` makes a new generated secret and prints it once; it is refused for a pasted secret, with exit 2 before the rotate request is sent (nothing changes). `--set-secret` reads the secret a vendor gave you from a file or stdin, trims whitespace, stores it and never prints it. The two flags exclude each other. See [Custom webhooks](https://forkline.dev/docs/routines/spec#custom-webhooks).

## Global flags, errors and exit codes

- `--json` is a global flag, the same as `--output json`; `-o` is the short form of `--output` (`-o jsonl` prints one item per line for list commands). Machine output goes to stdout; notices go to stderr. `--json` cannot be combined with an explicit `--output` or `-o` (an `FORKLINE_OUTPUT` in the environment does not conflict: `--json` wins).
- The CLI decodes every response strictly. A response with an unknown enum value or a missing field fails with `failed to decode response` and exit code 1, so upgrade the CLI when the server is newer. The exception is `--json` on `runs`, `events`, `event`, `test`, `backtest` and `new --from-event`: it prints the document as the server sent it, even when this CLI cannot decode it, so scripts keep working against a newer server (`backtest --fail-if-zero` still needs a decodable response).
- With `--json`, an error is printed to stdout as one JSON object (the human `error:` line also goes to stderr):

```json
{
  "error": {
    "kind": "validation",
    "code": null,
    "message": "validation failed: Invalid routine spec: /filters/labels/inlcude: Unknown field `inlcude`. Allowed here: `include`, `exclude`. Did you mean `include`?",
    "http_status": 422,
    "request_id": null,
    "retry_after_seconds": null,
    "issues": [
      { "pointer": "/filters/labels/inlcude", "message": "Unknown field `inlcude`. Allowed here: `include`, `exclude`. Did you mean `include`?", "code": "unknown_field" }
    ]
  }
}
```

- **Issue lines** are printed as `{file}:{line}: {message} ({pointer})`. The line is best effort, taken from the YAML node of the pointer; without it the form is `{file}: {message} ({pointer})`, and an empty pointer prints no parentheses.
- Reasons (for example in `test` and `runs`) are printed verbatim.

### Exit codes

| Code | Meaning |
|------|---------|
| 0 | OK |
| 1 | Generic error: HTTP 5xx, `413` payload too large, undecodable response, IO, `run --wait` task failed, declined confirmation |
| 2 | Usage or config error, ambiguous ref, **422 validation** (`validate`, `push`, `create`, `update`, `test`, `backtest` with an invalid spec; `run` refused with a `422`, or with nothing it can start; `run` without `--yes` and without a terminal) |
| 3 | Unauthorized (bad or missing token) |
| 4 | Forbidden (missing scope) |
| 5 | Not found (also when routines are not enabled for your account) |
| 6 | Conflict: `pull` or `new` refusing to overwrite a file, or a 409 |
| 7 | Cannot connect |
| 8 | Timeout |
| 9 | Rate limited or overloaded (429), including `run` while Forkline is busy or over its limit of 10 real runs a minute (`retry after 5s`) |
| 10 | Expectation failed: `test --expect` does not match, or `backtest --fail-if-zero` finds nothing that would run (see the [rule per trigger](#backtest)). The bulk runner commands also use 10 for a partial failure |

## Validating in CI

`validate`, `backtest` and `test` have no side effects, so a CI job can use a token with only the `routines:read` scope. Store it as a secret named `FORKLINE_TOKEN` and fail the build when a routine file is invalid or would never run:

```yaml
# .github/workflows/routines.yml
name: routines
on:
  pull_request:
    paths: [".forkline/routines/**"]
jobs:
  check:
    runs-on: ubuntu-latest
    env:
      FORKLINE_TOKEN: ${{ secrets.FORKLINE_TOKEN }}
    steps:
      - uses: actions/checkout@v4
      - run: npm install -g @forkline/cli
      - run: forkline routine validate .forkline/routines
      - run: forkline routine backtest .forkline/routines/review-prs.yaml --fail-if-zero
```

The same commands work in any CI system:

```bash
npm install -g @forkline/cli
forkline routine validate .forkline/routines        # exit 2 if a file is invalid
forkline routine backtest .forkline/routines/review-prs.yaml --fail-if-zero   # exit 10 if it would not have run
```

For an event routine, `--fail-if-zero` fails a routine that would not have run once in the last 7 days. Use it with care on routines that react to rare events: they fail it legitimately. A schedule routine fails it only when its runs per week times its repositories is 0, and a webhook routine only when it selects no repository, since neither has events to replay. Run `validate` on the whole directory, and `backtest` on named files. Do not give the CI token `routines:write`: pushing from CI is a separate decision, and `push` needs that scope.
