View as Markdown

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.

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). A path that exists is a file, anything else is a routine name, UUID or id prefix.

The loop

# 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 for what each check reports.

Files and directory layout

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. 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
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.
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 }
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.
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 }}.
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.
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, 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.

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:

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

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

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.

✓ .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:

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

With --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).

test

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.

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

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:

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:

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

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": "..."}.

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

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.

push

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:

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

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.

run

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, run limits, credits and the repository selection. See Run now for the rules. Run now’s own request limits are in 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) 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

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.

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):
{
  "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). 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:

# .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:

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.