View as Markdown

Spec reference

Agent hint

For agents writing routines. Follow these rules and the routine validates the first time:

  • Write one routine per file.
  • Unknown fields are rejected. A typo is an error, not an ignored setting.
  • Filters must apply to every listed event. When the events need different filters, write two routines.
  • Use only the template variables that every chosen event provides. See Prompt templates and variables.
  • Run forkline routine validate <file>, then forkline routine backtest <file>, before forkline routine push.
  • Schedule and webhook routines accept no filters except when. On a schedule routine when sees only repository; on a webhook routine it also sees payload.
  • limits.resetOn and the events and variables in Not supported yet are rejected.
  • filters.bots and filters.self judge the actor (sender, who pushed, commented or opened), not the author of the pull request. To skip pull requests written by a bot, use when: '!pr.authorIsBot' (pull request events only; on GitLab only when the merge request is first opened, see Authors and labels). The defaults are bots: exclude and self: exclude: other bots and Forkline’s own bot are skipped. To skip only Forkline’s own activity and keep other bots, set bots: include and leave self at its default. See self and bots.
  • limits.maxRunsPerPr defaults to 3 and counts every run for a pull request, replaced ones too, and never resets. A routine on pr.updated that must run on every commit needs a higher value (at most 50), or it stops after its third run with outcome run_limit.
  • ci.failed carries a pull request only when the failed run belongs to one. If the prompt or filters need it, add when: 'pr.number != null'; without a pull request pr.* is empty and maxRunsPerPr does not apply.
  • There is no changed-files count, diff or file list for pull requests. See Not available variables.

A routine is a file that says what starts it (on), which repositories it covers (repositories), which events to ignore (filters) and what the agent does (agent). This page lists every field. The other pages explain the parts in depth: Events, Filters, Prompt templates and variables, Repository selection, Concurrency and limits and Validation errors.

The routine file

A routine file is a YAML or JSON mapping. A file that ends in .json is read as JSON, any other file as YAML. It starts with name and description, followed by the spec keys.

Key Type Required Notes
name string yes 1 to 100 characters: letters, digits, - and _. forkline routine push matches files to routines by name. A bad name is refused with 400 (see Validation errors).
description string no Free text for people.
on object yes What starts the routine: on
repositories object yes repositories
filters object no filters
agent object yes agent
concurrency object no concurrency. Optional; the defaults apply when it is missing.
limits object no limits

Every object rejects unknown fields. All keys are camelCase. Saving an invalid routine returns 422 with one message and pointer per problem, see Validation errors. In the web editor, in the API and in the pointers below, name and description are separate fields next to the spec; the pointers start at the spec.

A complete example

name: review-prs                   # letters, digits, - and _
description: Review pull requests that target main
on:
  events: [pr.opened, pr.updated]  # 1 to 8 events
repositories:
  include:
    - name: "acme/*"               # owner/name, * and ** are wildcards
  exclude:
    - name: "acme/sandbox"
filters:                           # optional; every filter must fit every event
  branches:
    include: [main, "release/*"]   # the pull request base branch
  labels:
    exclude: [no-review]
  draft: exclude                   # exclude (default), include or only
  fork: exclude
  bots: exclude                    # the ACTOR is another bot (default), not the PR author
  self: exclude                    # the ACTOR is Forkline's own bot (default)
  when: pr.title.startsWith("WIP") == false   # a typed CEL condition
agent:
  prompt: |
    Review pull request #{{ pr.number }} "{{ pr.title }}" in {{ repository.fullName }}.
    Focus on correctness and tests. Post one summary comment.
  model: anthropic/claude-sonnet-4-5
  runnerSize: advanced             # basic, advanced or pro
  ref: "{{ pr.headBranch }}"       # what the agent checks out
  timeoutMinutes: 45               # 1 to 240
limits:
  maxRunsPerPr: 3                  # 1 to 50

on

Set exactly one of events, schedule and webhook.

Pointer Type Required Default Constraint Message when violated
/on object yes Exactly one of events, schedule, webhook set exactly one of `events`, `schedule` and `webhook`
/on/events string[] one of three 1 to 8 event names list between 1 and 8 events
/on/events/{i} string One of the 8 event names, each at most once Unknown event `{name}`. Did you mean `{near}`? Events: `pr.opened`, `pr.updated`, `pr.merged`, `pr.closed`, `issue.opened`, `comment.created`, `ci.failed`, `push`. (the suggestion only when one name is close), `{name}` is listed twice, `{name}` is not supported yet
/on/schedule object one of three Exactly one of cron and at set exactly one of `cron` and `at`
/on/schedule/cron string one of cron, at Standard cron: five fields (minute hour day month weekday), or six or seven with leading seconds. The weekday is 0 or 7 for Sunday, 1 for Monday up to 6 for Saturday, or MON to SUN in any case, with ranges, lists and steps. When both the day of the month and the day of the week are restricted (neither starts with * nor is ?), the routine runs when either matches. When either day field starts with * or is ?, both must match `{expression}` is not a cron expression: {detail}
/on/schedule/timezone string no UTC An IANA name such as Europe/Berlin. Only with cron `{timezone}` is not an IANA timezone, use a name like `Europe/Berlin`, `timezone` only applies to `cron`; `at` carries its own offset
/on/schedule/at string one of cron, at An RFC 3339 timestamp such as 2026-12-01T09:00:00Z. Runs once, then the routine is disabled. A time in the past is not rejected `{at}` is not an RFC 3339 timestamp: {detail}
/on/webhook object one of three {} is allowed and means a bearer token in Authorization. Other fields: auth, idempotencyHeader, example Unknown field `{key}`. Allowed here: `auth`, `idempotencyHeader`, `example`. (plus “Did you mean” for a near miss) at /on/webhook/{key}
/on/webhook/auth object no {} (token in Authorization) type is token (default) or hmac `{value}` is not allowed here. Use `token` or `hmac`. at /on/webhook/auth/type
/on/webhook/auth/header string no Authorization Only with type: token. 1 to 64 bytes: letters, digits and ``!#$%&’*+-.^_` ~``. Not a header the platform sets: Host, Cookie, Connection, Transfer-Encoding, X-Real-IP, Forwarded, Traceparent, Content-*, X-Forwarded-* and X-Auth-Request-*. Names are matched without regard to case
/on/webhook/auth/preset string with type: hmac forkline, github or sentry. Only with type: hmac `preset` is required with `type: hmac`. Presets: ..., Unknown preset `{name}`. Did you mean `{near}`? Presets: ..., `preset` is only for `type: hmac`.
/on/webhook/idempotencyHeader string no Idempotency-Key Same header-name rules. Not the header that carries the credential. Names are matched without regard to case `{name}` already carries the credential; use another header for the idempotency key.
/on/webhook/example any JSON no A sample body for the Run drawer. At most 16 KiB, measured as compact JSON. Never read when a call arrives The example is {size} bytes, over the 16 KiB limit.

events

on:
  events: [pr.opened, pr.updated]

The events are pr.opened, pr.updated, pr.merged, pr.closed, issue.opened, comment.created, ci.failed and push. The list of what each one means on GitHub, GitLab and Forgejo, and the fields each one carries, is on the Events page.

schedule

on:
  schedule:
    cron: "0 6 * * 1"          # five fields, every Monday at 06:00
    timezone: Europe/Berlin    # IANA name, default UTC
on:
  schedule:
    at: "2026-12-01T09:00:00Z" # once, RFC 3339; the routine is then disabled

The weekday follows standard cron: 0 or 7 is Sunday, 1 is Monday and 6 is Saturday. Names work too (MON-FRI, SUN,SAT, case does not matter), and so do ranges, lists and steps (1-5, */2). When both the day of the month and the day of the week are restricted (neither starts with * nor is ?), the routine runs when either matches: 0 8 1 * 1 runs on the 1st of each month and on every Monday, and only once when the 1st is a Monday. When either day field starts with * (*, */2) or is ?, both must match: 0 9 */10 * 1 runs only on Mondays that are the 1st, 11th, 21st or 31st, and 0 9 1 * */2 only on a 1st that is a Sunday, Tuesday, Thursday or Saturday.

timezone only applies to cron. A cron routine fires when a scheduled time has passed since it last fired. If Forkline was unavailable, the routine fires once as soon as it is back, for the most recent time it missed; earlier missed times are not replayed. A routine never fires twice for the same scheduled time, and it is not owed times from before it was last edited or published. If a scheduled time cannot be started because of a temporary error, it is retried at the next check, about a minute later. If the routine can no longer be read as a valid routine, the scheduled time is recorded as an error run at stage fan_out and is not retried. A scheduled time is only retried while it has not been started: if a server restart lands exactly between claiming the scheduled time and starting its run, that time is skipped rather than retried, and the routine fires at its next scheduled time. A schedule runs once for each selected repository, see Repository selection.

webhook

on:
  webhook:
    auth: { type: hmac, preset: sentry }

A custom webhook routine runs when your system calls its URL. webhook: {} is valid and means a bearer token in Authorization. See Custom webhooks for the authentication options.

repositories

Which of your connected repositories the routine covers. The result is everything in include that is not in exclude. See Repository selection.

Pointer Type Required Default Constraint Message when violated
/repositories object yes `repositories` is required.
/repositories/include all or selector[] yes The word all, or 1 to 50 selectors `{value}` is not valid here, use "all" or a list of selectors, use "all" or list at least one selector, at most 50 selectors, this has {n}
/repositories/exclude selector[] no none 1 to 50 selectors at most 50 selectors, this has {n}
/repositories/{include or exclude}/{i} selector Exactly one of id and name set exactly one of `id` and `name`
.../{i}/id string one of id, name The UUID of a repository of yours UUID parsing failed: {detail}
.../{i}/name string one of id, name owner/name, at most 256 bytes. * and ? match inside one segment, a whole segment ** matches several. Case-insensitive use the form `owner/name`, with `*` and `**` as wildcards, the pattern is longer than 256 bytes
repositories:
  include: all                   # or a list
repositories:
  include:
    - name: "acme/api"
    - name: "acme/web-*"
    - id: 3f2b8c1e-1c2d-4e5f-8a9b-0c1d2e3f4a5b
  exclude:
    - name: "acme/web-legacy"

For schedule and webhook routines the selection must match between 1 and 200 of your repositories when you save. See Errors that need more than the file.

filters

Optional. All filters must pass for a run to start. Filters only apply to routines started by events; a schedule or webhook routine can use only when. There when sees repository, and on a webhook routine also payload. Each filter has its own page section in Filters.

Pointer Type Default Applies to
/filters/branches {include?, exclude?} of globs none PR events (base branch), push (pushed branch), ci.failed (branch)
/filters/paths {include?, exclude?} of globs none push
/filters/labels {include?, exclude?} of strings none PR events, issue.opened, comment.created
/filters/authors {include?, exclude?} of strings none PR events, issue.opened, comment.created, push
/filters/draft exclude, include or only exclude PR events
/filters/fork exclude, include or only exclude PR events
/filters/bots exclude, include or only exclude every event except ci.failed
/filters/self exclude, include or only exclude every event except ci.failed
/filters/command {names?, mention?} none comment.created
/filters/when CEL string none every event; on a schedule routine only repository is visible, on a webhook routine repository and payload

Rules shared by all filters:

Rule Message
Every filter must apply to every listed event (pointer /filters/{name}) `{name}` does not apply to {events}; every filter must apply to every listed event, so split the routine
A schedule or webhook routine cannot use event filters other than when `{name}` only applies to routines started by events
include and exclude lists: at least one of the two, not empty, at most 50 entries, each entry 1 to 256 bytes set `include`, `exclude` or both, must not be empty, remove it instead, at most 50 entries, this has {n}, must not be empty, is longer than 256 bytes
command: names (1 to 20, each starts with a letter, has letters, digits, - and _, is at most 64 characters, and is written without the /) and mention: true, at least one of them set `names` or `mention: true`, list between 1 and 20 command names, a command name starts with a letter and has only letters, digits, `-` and `_`, written without the leading `/`, `{name}` is listed twice

For the command matching semantics (line-start requirement, mention interaction), see Command. | draft, fork, bots, self must be one of the three words | `{value}` is not allowed here. Use one of: `exclude`, `include`, `only`. |

when

when is one boolean CEL expression over the typed fields of the event, for example pr.labels.size() > 0 && !pr.draft. It is checked when you save: a field that none of the listed events carries, an unsupported function, a result that is not a boolean or an expression that is too expensive is an error at /filters/when.

Limit Value Message
Empty expression not allowed must not be empty
Size 4096 bytes filter is {n} bytes, the limit is 4096
Nesting 16 levels filter nests expressions more than 16 levels deep
Expression count 512 filter is too large, it may use at most 512 expressions
Estimated worst-case cost 50,000 units the filter's estimated worst-case cost {n} exceeds the budget of 50000 by a factor of {f}; ...

The full list of fields and functions is in Filters.

agent

What the agent does when the routine fires.

Pointer Type Required Default Constraint Message when violated
/agent object yes `agent` is required.
/agent/prompt template string yes Not empty, at most 32 KiB (32768 bytes). Every variable must exist for every listed event `prompt` is required., must not be empty, is {n} bytes, the limit is 32768, the template does not parse: {detail}, `{path}` isn't available for `{event}`., `{path}` isn't available for {events}; every chosen event must provide it.
/agent/model string no the runner default 1 to 200 characters. Only the length is checked must be between 1 and 200 characters
/agent/runnerSize basic, advanced or pro no the runner default (basic) One of the three `{value}` is not allowed here. Use one of: `basic`, `advanced`, `pro`.
/agent/ref template string no the natural ref of the event, see below 1 to 1024 characters. Checked like the prompt. A ref that renders empty means no ref must be between 1 and 1024 characters
/agent/timeoutMinutes integer no 30 1 to 240 must be between 1 and 240
agent:
  prompt: |
    CI "{{ ci.name }}" failed on {{ pr.headBranch }}. Fix it and push to the PR branch.
  model: anthropic/claude-sonnet-4-5
  runnerSize: advanced
  ref: "{{ pr.headBranch }}"
  timeoutMinutes: 45

When ref is not set, the agent works on:

Event Default ref
pr.* events The pull request head branch. For a pull request from a fork, the base branch, because the agent cannot push to the fork
push The new commit of the push
ci.failed The head branch of the pull request the run belongs to, otherwise the branch of the CI run
everything else The default branch of the repository

concurrency

concurrency is enforced for every run: events, schedule ticks, Run now and custom webhooks. It is written like this:

name: concurrency-shape
description: Every concurrency key with its default
on:
  events: [pr.opened, pr.updated]
repositories:
  include: all
agent:
  prompt: Review pull request #{{ pr.number }}.
concurrency:
  group: pr            # pr, issue, repository or routine
  onConflict: replace  # replace or skip
  maxActive: 10        # 1 to 50
Pointer Type Required Default Constraint Message when violated
/concurrency/group pr, issue, repository or routine no derived from the events, see below pr needs events that all belong to a pull request (pr.*, comment.created, ci.failed). issue needs issue.opened and comment.created only. repository and routine always work group `{group}` needs a routine that triggers on {allowed events}, use `repository` or `routine` instead
/concurrency/onConflict replace or skip no replace when the group is pr or issue, skip when it is repository or routine One of the two an unknown value is an enum error at this pointer
/concurrency/maxActive integer no 10 1 to 50 a value outside the range is a range error at this pointer

The default group is pr when every event is pr.*, comment.created or ci.failed and at least one is pr.* or ci.failed. Otherwise it is issue when every event is issue.opened or comment.created, and otherwise repository. Schedule and webhook routines default to repository.

maxActive does not count tasks from the same occurrence (one event, schedule tick, Run now or custom webhook call). On replace, a run that cannot start because the owner’s pending-run cap is reached cancels nothing. Details: Concurrency and limits.

limits

limits:
  maxRunsPerPr: 3
Pointer Type Required Default Constraint Message when violated
/limits/maxRunsPerPr integer no 3 1 to 50 must be between 1 and 50
/limits/resetOn reserved not supported yet `limits.resetOn` is not supported yet

maxRunsPerPr is the most runs one routine starts for the same pull request, in total: replaced runs count and it never resets. It stops loops between a routine and a bot or CI. The default 3 also stops a routine on pr.updated after the pull request’s third run (outcome run_limit); raise it, up to 50, to run on every commit. Events without a pull request are not counted.

Limits at a glance

What Limit
Routine name 1 to 100 characters
Events in on.events 8
Selectors in repositories.include and in .exclude 50 each
Repositories of one schedule or webhook run 1 to 200
Entries in one filter list 50, each at most 256 bytes
Command names in filters.command.names 20
filters.when 4096 bytes, 16 levels, 512 expressions, worst-case cost 50,000, 256 bytes per matches() pattern
Fields filters.when reads from payload weight 4,096, 16 levels, 1,000 items, 64 KiB of text
Prompt 32 KiB (32768 bytes); the rendered prompt at most 512 KiB
agent.model 1 to 200 characters
agent.ref 1 to 1024 characters
agent.timeoutMinutes 1 to 240, default 30
limits.maxRunsPerPr 1 to 50, default 3
concurrency.maxActive 1 to 50, default 10
Routines per owner 25 published, 100 in total
Webhook body 5 MiB
Run now and test payload 5 MiB (request 6 MiB)
Run now 10 real runs a minute per owner

Sizes, rates and how calls behave at them: Webhook and Run now limits.

Not supported yet

These names are reserved. Forkline refuses them when you save, with the message shown, instead of ignoring them.

Name Pointer Message
limits.resetOn /limits/resetOn `limits.resetOn` is not supported yet
Event pr.conflicted /on/events/{i} `pr.conflicted` is not supported yet
Event ci.succeeded /on/events/{i} `ci.succeeded` is not supported yet
Event pr.labeled /on/events/{i} `pr.labeled` is not supported yet
Event issue.labeled /on/events/{i} `issue.labeled` is not supported yet
Event issue.assigned /on/events/{i} `issue.assigned` is not supported yet
Event pr.review_requested /on/events/{i} `pr.review_requested` is not supported yet
Variable comment.prompt /agent/prompt or /agent/ref `comment.prompt` is not supported yet
Variable comment.path /agent/prompt or /agent/ref `comment.path` is not supported yet
Variable comment.requestedModel /agent/prompt or /agent/ref `comment.requestedModel` is not supported yet
Variable thread.comments /agent/prompt or /agent/ref `thread.comments` is not supported yet

Custom webhooks

A routine with on.webhook runs when another system calls its URL:

POST https://<your Forkline host>/webhooks/custom/<routine id>

The routine must be published. The call proves who it is with a secret that belongs to this routine. There is no token in the URL: access logs record URLs, so a secret there would end up in logs. The secret goes in a header.

To start a routine yourself instead, use Run now (web, CLI or API). The same limits apply to both.

on.webhook.auth

auth The call carries Secret
{} or omitted Authorization: Bearer <secret> Generated by Forkline
{type: token, header: X-Alert-Token} The secret itself, as the raw value of that header Generated by Forkline
{type: hmac, preset: forkline} X-Signature-256: sha256=<hex> Generated by Forkline
{type: hmac, preset: github} X-Hub-Signature-256: sha256=<hex> Pasted by you
{type: hmac, preset: sentry} Sentry-Hook-Signature: <hex> Pasted by you
  • Token. The default. With Authorization the scheme Bearer is required (any letter case). With a custom header the value is the secret alone.
  • HMAC. The sender signs the raw request body with HMAC-SHA256 and sends the hex digest. <hex> can be lower or upper case. The preset fixes the header and the prefix, so header is not allowed with type: hmac.
  • Which preset. forkline is for your own code. github and sentry match what those senders already send: set the Forkline secret to the signing secret the sender holds (GitHub: the webhook secret; Sentry: the integration’s client secret). For GitHub, set the webhook’s Content type to application/json: with application/x-www-form-urlencoded, the body arrives as form text in payload.raw_body and the event’s fields are not in payload. Sentry sends no idempotency key: each delivery starts a call.
  • A wrong or missing secret answers 401: Missing credentials or Invalid credentials for a token, Missing signature or Invalid signature for HMAC. Nothing is started. A routine that has no secret yet answers the same Missing message.
name: alert-triage
on:
  webhook:
    auth: { type: hmac, preset: forkline }
    idempotencyHeader: X-Delivery-Id
    example:
      title: High error rate
      level: error
repositories:
  include: [{ name: "acme/api" }]
agent:
  prompt: |
    Investigate this alert in {{ repository.fullName }}: {{ payload.title }}
BODY='{"title":"High error rate"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.* //')
curl -X POST https://<host>/webhooks/custom/<routine id> \
  -H "X-Signature-256: sha256=$SIG" \
  -H "X-Delivery-Id: alert-4711" \
  -d "$BODY"

With the default auth: {}, send the secret instead:

curl -X POST https://<host>/webhooks/custom/<routine id> \
  -H "Authorization: Bearer $WEBHOOK_SECRET" \
  -H "Idempotency-Key: alert-4711" \
  -d '{"title":"High error rate"}'

The secret

  • Generated (token and forkline). Forkline creates it when you create the routine and returns it once in that response (webhook_secret). It never appears in the spec. Reading the routine later only shows webhook_secret_configured, webhook_secret_origin (generated) and webhook_secret_updated_at.
  • Rotate it with forkline routine webhook <routine> --rotate-secret or POST /v1/routines/{id}/webhook/rotate-secret. The new secret is shown once and the old one stops working immediately. There is no grace period.
  • Pasted (github, sentry). The sender owns the secret, so you give it to Forkline with forkline routine webhook <routine> --set-secret <file|-> or PUT /v1/routines/{id}/webhook/secret with {"secret": "..."} (16 to 512 bytes, surrounding whitespace trimmed). Forkline never shows it back, and a routine without it cannot be published or enabled until it is set. Rotate is refused for a pasted secret: rotate it at the sender and paste the new value.
  • Changing auth. Switching between a token and forkline keeps the secret. Moving to github or sentry, or between them, clears it: paste the new one. Moving from a pasted secret to a token or forkline generates one, shown once. A routine that is already published stays published, and answers 401 Missing signature until you paste the secret.
  • {} on an older routine was rewritten to {type: hmac, preset: forkline}, so existing senders keep working.

Other settings and limits

  • idempotencyHeader. The header that carries the retry key (default Idempotency-Key). Send it so a repeated delivery starts no second task. Without it, every call starts a run. The key is trimmed and at most 200 bytes; a longer key is ignored, so every call runs. Forkline keeps the key with the run, so a repeat is skipped however late it comes. Sentry sends no key. Run now does not use this setting: it always reads Idempotency-Key.
  • example. A sample body, at most 16 KiB serialized as compact JSON. The Run drawer fills its payload box with it. A real call never reads it.
  • The body. Any JSON (anything else is passed as payload.raw_body). It is available as payload in templates, and in when within the limits listed there.
  • Size. The body is at most 5 MiB. A larger body is refused with 413 PAYLOAD_TOO_LARGE (The request body is larger than 5 MiB) and starts nothing.
  • Errors. An unexpected failure answers 500 with the usual JSON envelope, {"error": "Internal server error", "code": "INTERNAL_ERROR"}; nothing is said about the cause. Retry later.
  • Response. An accepted call answers 200 with task_ids and session_urls. Both are empty when nothing starts, and reason says why. event_id finds the call in Recent events. An unknown routine, a draft, a disabled routine and a routine without a webhook trigger answer 404.
  • Concurrency. The routine’s concurrency applies: with the defaults, a second call while a run is active starts nothing and is recorded as skipped_active.
  • Rate limit. A routine accepts 600 calls a minute (bursts of 200), and one sender IP address 1,200 a minute (bursts of 400), counted per Forkline replica. A sender above either gets 429 with {"success": false, "message": "Webhook rate limit exceeded; retry later"} and a Retry-After header. Nothing is started: retry after the delay.
  • Overload. While Forkline is overloaded the endpoint answers 429 with {"error":"Forkline is busy; retry shortly","code":"TOO_MANY_REQUESTS","retry_after_secs":5} and a Retry-After: 5 header. Nothing was started: retry after the delay.
  • All limits. Webhook and Run now limits lists every size and rate in one place.

forkline routine webhook <routine> prints the URL, the authentication method, and whether a secret is configured.