A flow is a YAML file under .scribe/flows/. It is ordinary versioned content:
it lives on a branch, changes with a submit, and can be reviewed in a proposal
like any other file. The web interface reads the definitions at a branch head,
composes them, draws each flow as a graph of jobs, starts runs, and keeps the
history of every run with its logs. Runners are machines that execute jobs;
they register with the API and claim work through the REST endpoints below.
This document covers the definition format, composition, runs, runners, the
roles each action needs, and the REST API. The runner is scribe-runner, one
binary with the Scribe client compiled in; its command line is in the
scribe-runner reference.
Files and kinds
Every *.yml or *.yaml under .scribe/flows/ (at any depth) is read. A
file's kind says what it is:
kind | Purpose | Runnable |
|---|---|---|
flow (default) | A runnable flow: triggers and jobs | yes |
job | A reusable job a flow includes with uses: | no |
steps | A reusable step sequence a job includes with uses: | no |
A flow's identity is its path under .scribe/flows without the extension:
ci/build.yml is the flow ci/build. Names are for display; the ID is what
runs and URLs carry.
Files that do not parse are reported on the Flows tab next to the flows that
did, with the file and the first problem. One broken file never hides the
others. Bounds: 64 files per revision, 64 KiB per file, 50 jobs per flow,
100 steps per job after expansion, uses: chains at most 8 deep.
A flow
# .scribe/flows/build.yml
name: Build
description: Compile and package the game.
on:
manual:
submit:
branches: [main]
inputs:
configuration:
description: Build configuration
default: Release
env:
CI: "1"
jobs:
build:
runs-on: [windows, unreal]
steps:
- uses: scribe/checkout
- name: Compile
run: ./build.ps1 -Configuration ${{ inputs.configuration }}
test:
needs: build
uses: parts/test.yml
with:
suite: unit
package:
needs: [build, test]
runs-on: windows
timeout-minutes: 30
steps:
- uses: ./parts/steps/archive.yml
with:
target: ${{ inputs.configuration }}-
on:declares triggers.manualshows a Run flow button;submitrecords that the flow should run when a change lands on a branch matching one of the listed patterns (any branch when the list is empty). A pattern is a branch name, or one with*standing for any run of characters, slashes included:release/*.on: manual,on: [manual, submit]and the mapping form are equivalent spellings. A flow with noon:is manual. -
inputs:are values a manual run may pass. Each has an optionaldescription,default,requiredandoptions(a list the value must come from; the run dialog then offers a choice, and the default must be one of them). A free-text input may instead carrysuggestions, values the run dialog offers as pills that fill the field without ruling out typing something else; withlist: truethe value is a comma-separated list and the pills add and remove items (the first pill replaces a default that is not itself a suggestion, sodefault: allgives way to the first choice). They are referenced as${{ inputs.name }}anywhere a string appears in the flow and substituted when the run is created; an optional input without a default reads as empty. -
env:at the flow level applies to every job; a job'senv:adds to it. -
workspaces:names folders that runs on certain branches share on a runner, instead of the default of one workspace directory per branch:yaml workspaces: ps5: branch: feature/ps5/* # one pattern or a list trunk: branch: [main, develop] folder: main # defaults to the rule's nameA run whose branch matches a rule is checked out in that rule's folder, and a runner switches that one workspace between the matching branches; runs on branches no rule matches get a directory each. The first matching rule wins. Names and folders are single path segments (letters, digits,
.,_,-). Use it where a branch switch is cheap, or where a long-lived branch should have a stable path on the build machine; leave it out for projects whose build caches make switching expensive. -
bundles:names what runs publish and how long it is kept; see Bundles. A job may add abundles:list of its own. -
jobs:is a mapping of job key ([a-z][a-z0-9_-]*) to job. Jobs run in parallel unlessneeds:orders them.needs:must name jobs in the same flow, and cycles are refused. -
Every job needs
runs-on:: one label or a list. A runner may take the job only when it carries every label listed. Labels are lower-cased. -
timeout-minutes:defaults to 60. -
A step has exactly one of
run:(a shell command) oruses:. Auses:ending in.yml/.yamlincludes akind: stepsfile; any otheruses:(scribe/checkout) is a built-in action the runner implements. Steps also acceptname,with,env,shell,working-directoryandcontinue-on-error.
${{ ... }} accepts only inputs.<name>. Anything else is a definition
problem, so a typo never silently becomes literal text.
Reusable pieces
Duplicated YAML is what pieces are for. A kind: job file is a whole job a
flow includes; a kind: steps file is a step sequence a job splices in.
# .scribe/flows/parts/test.yml
kind: job
name: Test ${{ inputs.suite }}
inputs:
suite:
required: true
runs-on: linux
env:
SUITE: ${{ inputs.suite }}
steps:
- uses: scribe/checkout
- run: ./test.sh ${{ inputs.suite }}# .scribe/flows/parts/steps/archive.yml
kind: steps
inputs:
target:
default: Debug
steps:
- name: Archive ${{ inputs.target }}
run: zip out-${{ inputs.target }}.zip build/
working-directory: dist- A piece declares its own
inputs:; the caller passes them withwith:. Unknown names, and missing required ones, are refused. - A piece's inputs are substituted at composition time. A caller may pass one
of the flow's own inputs through (
with: { target: "${{ inputs.configuration }}" }); that expression stays until the run is created. - Paths in
uses:are relative to.scribe/flows(parts/test.yml), or to the file that contains them when they start with./or../. A path may not leave.scribe/flows. - A job that
uses:a piece may setneeds,name,env(added to the piece's),runs-onandtimeout-minutes(overriding the piece's), but notsteps. - A
stepspiece may include otherstepspieces. Cycles are refused.
Composition happens in the API. What the Flows tab shows, what a run freezes
and what a runner receives is the composed form: only run: commands and
built-in actions remain, with a source on every step naming the file it came
from.
Runs
Starting a flow freezes its composed definition at one branch revision, with
the run's inputs substituted. Later edits to the YAML never change a run that
already exists. Each run has a per-repository number (#12), a trigger (who
started it, or the change that did), and one job per definition job. A job has
steps; each step keeps its own status, timing, exit code and log.
Job states: waiting (dependencies not finished), queued (ready for a
runner), running, succeeded, failed, skipped (a dependency did not
succeed, or the run was cancelled first) and cancelled. A run is queued
until a job starts, running while any job is unfinished, then succeeded,
failed or cancelled. The first failed job and step are recorded as the
run's failure and shown in the run list.
A runner that stops reporting loses its job: leases last five minutes and are
renewed by every report, log append and heartbeat. An expired lease fails the
job with "runner stopped reporting" and the run settles accordingly. Expiry is
applied when a runner next asks for work in that repository and, so that a
lone runner's death is never invisible, by every tick of the worker's submit
watcher. timeout-minutes is enforced by the runner on the job it executes.
Logs are stored per step, in the order the runner sent them, up to 1 MiB per step. A runner streams a step's output as it happens, and the run page follows it over an event stream: a line shows the moment the step prints it, with no polling on either side (see live streams).
Live streams
Nothing about a running job is polled. The runner holds one text/plain
request open per step and writes output into it as the step produces it —
each pipe read goes out as one chunk, so a line is on its way the moment it
is printed. The API stores the text in chunks as it arrives (when 64 KiB
waits, or a quarter second after the first byte of a quiet step's line) and
announces every stored chunk and every change to the run on Valkey, where any
API replica's viewers hear it. The stream ends when the step ends, after 20 s
of silence, or once it has carried a megabyte or run five minutes; the
reply then confirms how far the log reaches. A stream lost mid-way is
reopened from the offset the runner last had confirmed, with what it sent
since resent, and the API skips what it already stores, so a lost connection
loses nothing and repeats nothing.
A browser tab holds one event stream for everything it watches:
GET /api/v1/events (text/event-stream) answers hello { stream } and
the tab then adds and removes topics with
POST /api/v1/events/:stream/subscriptions { add: [{ key, topic }], remove: [key] },
each topic under a key of the tab's choosing. Every event on the stream is
{ topic, data } under the topic's own event name; end says a topic is
over, error (with a code) that it was refused. The topics:
| Topic | Events | What it follows |
|---|---|---|
{ kind: "run", repo, run } | run (the whole FlowRun), then end | one run as it changes — a step starting, a job ending, a bundle landing |
{ kind: "log", repo, run, job, step, after? } | chunk ({ seq, at, text }, id = seq), then end | one step's log: what is stored past after, then each chunk as it lands |
{ kind: "runs", repo } | run (a FlowRunSummary), gone | the repository's runs as they are created, change and are deleted, for a list to update in place |
{ kind: "notifications" } | sync when followed, changed ({ read }) | the signed-in person's notifications: read: false when one arrived, true when another tab read some; the list itself comes over REST |
Each topic is checked against a token's scope when it opens — the run topics
need flows:read, notifications notifications:read — and refused on the
stream with insufficient_scope; the stream routes themselves need no scope.
A subscription request may reach any API replica; it travels over the
live-event bus to the process holding the stream, which opens the topic with
the stream's own session, so permission is checked where the stream lives.
An open stream is checked again about once a minute, and before anything is
emitted once a minute has passed since the last check: the session must still
be live (not signed out, revoked or expired; a token's credential still
accepted), and every topic is opened again with it, so withdrawn repository
access or a narrowed token ends that topic with an error. A session that is
refused ends the stream with an error for every topic; a check that could not
be made (the store or identity service down) ends the stream alone, and the
tab reconnects. A stream never outlives its session. One process keeps one Valkey subscription per channel however many tabs
follow it. The tab reopens a lost stream with backoff and asks for
everything again, each log from its last chunk; the run page falls back to
polling only while the stream is down. The run page uses the run and log
topics, the Runs tab and a flow's recent runs the runs topic. The per-run
streams GET …/runs/:run/events and GET …/jobs/:job/logs/stream remain
for a single-purpose client (they are plain EventSource streams with
Last-Event-ID). nginx passes every stream through unbuffered and holds it
open for an hour; the API sends a comment line every 25 s so nothing between
the two closes an idle one.
Bundles
A bundle is what a job publishes from its workspace once its steps are done: the build, its symbols, a report, the logs of a crash. A flow declares them with rules in the TeamCity artifact-path form, one per line:
bundles:
paths:
- build/${{ inputs.configuration }}/** => game.zip # pack into an archive
- build/**/*.pdb => symbols/ # loose files under symbols/
- -:**/*.tmp # exclude, from every rule
- report.html # one file at the root
- Saved/Logs => logs.zip!/Logs # a directory inside the archive
retention:
runs: 10 # keep the bundles of the newest 10 runs of this flow
days: 30 # and none older than 30 days
jobs:
build:
runs-on: windows
steps:
- uses: scribe/checkout
- run: ./build.ps1
bundles:
- Intermediate/Build.log => logs # this job onlyA rule is [+:|-:]pattern [=> target]. The pattern is relative to the
workspace: * and ? match within one path segment, ** any number of
segments, and an exact path names a file, or a directory with everything in
it. Matches keep their path below the pattern's literal prefix
(build/Release/**/*.dll publishes build/Release/a/b.dll as a/b.dll) and
land under the target: a directory in the run's bundles, the root when there
is none, or a .zip the matches are packed into, with archive.zip!/inner
placing them under inner/ inside it. Several rules may fill one archive.
-: excludes apply to every include. Rules may use ${{ inputs.name }}.
Targets are portable paths (no .., :, *, ? and the like), so what is
published unpacks anywhere. bundles: can also be a bare list, which means
paths with the default retention.
The flow's rules apply to every job; a job's own bundles: list adds to
them. A job with any rule ends with a Publish bundles step that the
composer appends, so publishing has a status, a duration and a log like any
step, and it runs after a failed step too: a failed build's logs and dumps
are exactly what its bundles are for. A cancelled, aborted or timed-out job
publishes nothing. Archives are packed, not compressed: packing runs at disk speed and
game builds barely compress; a step that wants a small archive makes one
itself and names it in a rule. Zips carry Unix modes, so executables come
back executable.
Limits: 64 rules per job, 1 000 loose files per run (pack trees into an
archive past that), one part of 16 MiB per upload request, and a total per
run the deployment sets (BUNDLES_RUN_LIMIT_MB, 16 GiB by default). Uploads
resume: a lost connection costs one part, and the content process keeps a few
uploads streaming at once (BUNDLES_UPLOADS), telling further runners to wait
their turn; a runner publishes three files at a time, so archives pack while
other files upload. Every file is hashed by the runner as it is sent
(BLAKE3, the repository's own algorithm) and the digest travels with the
last part; the worker then reads the stored file back and compares, and the
run page marks a file verified or integrity check failed. Nothing
about the bytes is trusted until that check has run.
Retention is per flow: the newest runs runs of the flow that have
bundles keep them, and bundles older than days go; leaving one out uses the
deployment default (20 runs, 90 days). The rule that counts is the one in the
flow at the head of the repository's main branch, not the one frozen in the
run, so changing retention on main changes what is kept without a new run;
a flow main no longer defines, or a branch-only flow, gets the default. The
worker applies it once a day, removing the files but not the list: a run
still shows what it published, marked removed with the date. Deleting a run
removes its bundles at once.
Access follows the repository: whoever may read the repository may
download its runs' bundles, so a public repository's bundles are public
links. They are served from /api/v1/repositories/:repo/runs/:run/bundles/<path>
and listed on the run page with sizes and, when several jobs published, the
job that produced each.
Storage is the bundles bucket of object storage (OBJECTS_URL, the
objects service in Compose: SeaweedFS behind an S3 gateway), a file per
published path under runs/<run id>/. Uploads and downloads are handled by
the content process (content in Compose, apps/content in the source),
which streams each uploaded part straight into object storage as it arrives
and never holds a file in memory or on a disk, so a farm publishing at line
rate never competes with the interactive API and the process keeps no state.
A file sent as one part is stored as one object; a larger one is an S3
multipart upload whose parts (every one but the last exactly the announced
part size, 16 MiB) are listed in a small record under
staging/<run id>/, so an interrupted upload resumes even after the content
process restarts, and nothing is visible under the file's path until its last
part lands. Downloads go out with X-Accel-Redirect: the content process only
authorizes, and nginx fetches the object, passing HTTP range requests through
for resuming; the object carries the type and download name it was uploaded
with. Retention and the worker's BLAKE3 check read the same bucket; the
sweep lists stored objects, never folders, so its cost follows what retention
keeps. Without object storage the publish step fails with a message saying
so.
Submit triggers
The worker Compose service (the same process that follows proposals) runs a
submit watcher. Every PROPOSAL_CHECK_INTERVAL_SECONDS (30 by default) it
reads each repository's branch heads with its reader credential — one call per
repository — and, for every branch that advanced since the previous tick, the
submits on that branch since then. Each submit is matched against the flows
as defined at that change, so a submit that adds or edits a flow already
counts, and a run is created for every flow whose submit trigger names the
branch (or names no branch). The run's trigger records the change number and
its author; .scribe/flows edits that break a definition show up as problems
on the Flows tab, never as failed runs.
Runs are keyed by change and flow, so a restarted or repeated tick never starts
a second run for the same submit. The first time the watcher sees a repository
or a branch it records the head and replays nothing; a restored or recreated
repository (new incarnation) starts over the same way. A branch that advanced
by more than 50 submits between ticks triggers on the newest 50 and logs
flow.watch.truncated. A flow with a required input and no default cannot
start from a submit; the worker logs flow.watch.inputs and skips it.
The watcher needs the worker's reader account to have read access to the repositories it should follow; a repository it cannot read is skipped and logged. There is no push path yet: latency is the poll interval, which is adequate for builds. A server-side change feed can later wake the same watcher without changing what it does.
Runners
A runner is bound to one or more repositories and carries labels. When it asks
for work it receives the oldest queued job, among the repositories it is bound
to, whose runs-on labels it satisfies. The job assignment is complete: the
composed job, the run's branch and revision, its trigger and environment. The
runner needs nothing else from the definition.
Registration and tokens
A repository Maintainer registers a runner on the Flows tab (Runners →
Register a runner) or with POST /repositories/{repo}/runners: a name,
labels, and further repositories to bind. Registration involves no secret; it
returns the runner with its ID.
The token that drives a runner is the operator's own: any API token carrying
runners:write and flows:read, created at /settings/api or with the
one-click shortcut in the registration guide (which creates a token with
exactly those scopes and marks it as made for runners). One token may drive
any number of runners. The process is started with the web address, the
runner ID and the token, from a file or the environment and never as an
argument:
scribe-runner run --server https://scribe.example --runner <id> --token-file ~/.scribe-runner-token
SCRIBE_RUNNER_TOKEN=scribe_pat_… scribe-runner run --server https://scribe.example --runner <id>service install in place of run registers it with the machine's service
manager (systemd, launchd or the Windows service manager) so it starts with
the machine; see the runner below. Either way, a running
runner keeps scribe-runner.pid in the root of its work directory and
removes it when it exits.
A token may operate a runner when its owner is the runner's owner, or a Maintainer of every repository the runner serves — the same rule as disabling or removing it. That is what lets a team keep runners on a shared CI account while an individual Maintainer can still start one from a laptop with a token of their own.
The web address must be https://; http:// is accepted only for a
loopback host (localhost, 127.0.0.1, [::1]), and any other is refused
with "… is plaintext on a network address; use https:// (http:// is accepted
only for localhost)", because the token and the job scripts travel on that
link. The runner offers the hybrid post-quantum key exchange X25519MLKEM768
first and also accepts classical X25519, since a proxy in front of the web API
may offer only that.
Every web token is backed by a key-backed Scribe automation credential that
auth issued with a role ceiling derived from the token's scopes; for
runners:write plus flows:read that ceiling is reader (see
web automation credentials). The token
itself is a web-level secret for the HTTP API only; it never reaches the
storage servers.
The runner has a device key of its own, device-key.pem, created on first use
in a file only its account may read (mode 0600 on Unix): beside the token file
when the runner is started with --token-file, otherwise in
runner-credentials/<id>/ under the user configuration root (always there
for service install, whose check-in certifies the key the service will
use). The private key never leaves the machine. At check-in (POST /runners/{id}/checkin) the
runner sends the public key, and the API has auth derive a credential for that
key from the token's automation credential (the auth verb
automation-derive). The derived credential has the same account, role
ceiling and expiry as the token's, and is revoked with it. The API answers
with the credential certificate auth issued, together with the storage and
auth endpoints and the CA bundles that verify them (plus the server-name
overrides the deployment uses, when any); the answer carries no secret. The
runner then fetches its hour-long access certificates from auth itself and
checks repositories out over mutual TLS as the token's identity,
with nothing configured but the three arguments (see
identity). The runner reached
the API over HTTPS, so this is where trust in the deployment's private PKI is
established; a tcp:// storage endpoint carries no CA. A deployment whose web
API has no tls:// auth endpoint and auth CA configured cannot certify a
runner: check-in answers 409 runner_auth_unconfigured. The certificates stay in
memory; a restarted machine checks in again.
The CA bundles from check-in are the runner's only trust, and check-in stays authoritative for them: nothing is saved. During a CA rotation the runner follows the handover chain storage or auth present (see CA handover) in memory. A server whose CA changed in any other way is refused; the job fails with both fingerprints, and before its next job the runner checks in again and uses the CAs the API delivers then.
A stolen runner token, or the runner's device-key.pem with its certificate,
therefore yields read access, never a write, an unlock or an administrative
action; but the token also lets its holder act as that account's runners —
check in, claim their jobs, report results and upload bundles — for every
repository the account can reach through them. Choose the token's account
with the trust boundary below in mind.
Revoking the token at /settings/api stops every runner started with it:
their derived credentials are revoked with the token's.
Removing a runner deletes its registration; a disabled runner is refused
check-in and work until a Maintainer enables it again. A runner is online
while it heartbeats, busy while it holds a job, offline after 90 seconds
of silence, and disabled when turned off. The registration guide assumes
scribe-runner is already installed and on PATH and links to no download:
take the scribe-runner-<platform> archive from the manual build workflow,
or let scripts/install.sh (scripts/install.ps1 on Windows) build it beside
the other executables.
The runner
scribe-runner speaks the routes below and nothing else; it holds no YAML and
needs no scribe on the machine. Its loop is check in, then ask for work every
few seconds while idle (5 by default, with jitter; doubling up to a minute
while the API is unreachable) and execute what it gets, one job at a time.
A heartbeat every 30 seconds keeps it listed and renews the lease of the job
it holds. A runner that is disabled, whose token was rejected, or whose
registration is gone says so once in its log and keeps asking at a slower
pace, because the fix is on the other side; it never exits over a state the
API named. Run several runners on one machine for parallel jobs, each with
its own --workdir.
Workspaces. By default the runner keeps one workspace per repository
and branch under <workdir>/workspaces/<repository>/<branch>-<id>, and
never switches a workspace between branches. Switching rewrites much of a
game project and invalidates what the build left beside the sources (Unity's
Library, Unreal's Intermediate), so alternating branches in one directory
would pay the expensive part of every build again. A job on another branch
simply gets another directory; a job on an older or newer change of the same
branch syncs that branch's workspace to it, backwards or forwards. A flow's
workspaces: rules override this per branch: the matching runs share
<workdir>/workspaces/<repository>/<folder>, which the runner switches to the
run's branch and revision in one sync. The folder arrives resolved in the job
assignment; the runner never reads the rules. Disk is bounded per repository
by --keep-workspaces (8 by default): beyond it the least recently used
workspaces are retired, their server registrations with them. Every
registration a runner makes is labelled <runner name>@<host> and tagged
runner and runner-id=<id>, so scribe workspace list --tag runner-id=<id>
and the web Workspaces page find one runner's workspaces, and a machine that
was retired without service uninstall can be cleaned up from anywhere.
A workspace the sync cannot bring into order (a job crashed mid-operation, the
repository was restored under a new incarnation, the requested view differs)
is retired and recreated; the runner's copies hold no local work worth
protecting. A workspace whose sync merely failed to reach storage is kept for
the next attempt. Restrict a flow's submit trigger to the branches it should
build so a runner's disk holds the branches that matter.
Steps. uses: scribe/checkout brings the branch workspace to the run's
revision; with: clean: true retires it first for a build from nothing, and
with: view: Content, Source limits it to those inclusion prefixes. A run:
step is written to a script file and executed by the shell: bash (with
-eo pipefail) or sh on Unix, pwsh or powershell (with
$ErrorActionPreference = 'Stop') on Windows, unless shell: names one of
bash, sh, pwsh, powershell, cmd, python, or a command line with
{0} for the script path. Steps run in the workspace directory, or
working-directory under it, with the flow's and job's env and these
variables: SCRIBE_WEB, SCRIBE_RUNNER, SCRIBE_RUNNER_ID, SCRIBE_RUN_ID,
SCRIBE_RUN_NUMBER, SCRIBE_FLOW, SCRIBE_FLOW_NAME, SCRIBE_JOB,
SCRIBE_REPOSITORY, SCRIBE_REPOSITORY_ID, SCRIBE_BRANCH,
SCRIBE_BRANCH_ID, SCRIBE_REVISION, SCRIBE_WORKSPACE, SCRIBE_TRIGGER
(manual or submit), SCRIBE_ACTOR, SCRIBE_CHANGE (submit triggers),
SCRIBE_INPUT_<NAME> for each manual input, and CI=true. Output streams to
the step's log as it appears, over one request held open for the step; the
runner's own lines are marked with ». timeout-minutes and a
cancellation stop the step's whole process tree (a process group on Unix, a
job object on Windows), so a build script's compiler does not outlive it.
Nothing else from the runner's own environment reaches a step: a step
starts from an empty environment plus a fixed list of harmless host
variables (PATH, HOME, USER, LOGNAME, SHELL, LANG, LANGUAGE,
LC_*, TZ, TERM, TMPDIR, TEMP, TMP, and on Windows the system,
profile and program-directory variables its shells and toolchains expect;
the full list is in the runner reference).
The runner's token, its certificates and any other variable its operator
exported stay with the runner; a build that needs a host variable sets it in
the flow's env.
Stopping. Ctrl+C or SIGTERM (a systemd or launchd stop) lets the current job finish and then exits; a second signal aborts the job, which is reported failed with "the runner was stopped". The generated service units allow an hour for that. A Windows service stop behaves like the first signal.
Trust boundary
A runner executes code written by anyone who can write the flows of a
repository it is bound to: a Writer can push a run: step and start a run.
That code runs as the runner's OS account, so binding a runner to a
repository gives the repository's Writers what that account has.
- Steps get no runner credentials: not the token, not the certificates the
runner holds, not the runner's environment (see Steps above). A service
installation keeps the token and
device-key.pemoutside the work directory, where job workspaces and scripts live. - That is protection against accidents, not against a step written to look.
Job steps run as the runner's OS user, so they can read its token file and
its
device-key.pemexactly as the runner does, and inspect the runner process itself. The device key does not make the runner's credentials safer from its own jobs than the token alone. The OS account is the boundary. Run runners under a dedicated low-privilege account that holds nothing else of value — never a developer's or an administrator's. - Drive runners with the token of an account whose read access you are comfortable exposing to the Writers of every repository those runners are bound to (a shared CI account, say), since such a Writer can take the token and the runner's key, read what that account reads, and act as that account's runners elsewhere. Bind the runners of one token only to repositories with the same trusted Writers.
Who may do what
Flows reuse the repository roles from auth; there is no separate permission model.
| Action | Requires |
|---|---|
| See flows, runs, logs and runners | read access to the repository (anonymous on a public one) |
| Start a run, cancel a run | Writer |
| Delete a run and its logs | Maintainer |
| Register a runner for a repository | Maintainer of that repository and of every further repository bound |
| Enable, disable or remove a runner | Maintainer of every repository the runner is bound to |
| Check in, heartbeat, claim, report, append logs and upload bundles | an API token with runners:write and flows:read (either alone is refused with 403 insufficient_scope) whose owner is the runner's owner or a Maintainer of every repository it serves |
A runner only receives jobs from repositories its owner can still read; the decision to bind it was a Maintainer's, and revoking the owner's read grant stops the jobs without touching the registration.
Token scopes: flows:read (definitions, runs, logs, runners), flows:write
(start and cancel runs, writer ceiling) and runners:write (register and
operate runners, reader ceiling).
REST API
All routes are under /api/v1 and accept a browser session or a personal
access token. The generated reference at /developer/rest and
/api/v1/openapi.json carries the exact schemas.
Repository routes (:repo is the percent-encoded <namespace>/<name>; :flow
is the percent-encoded flow ID):
| Method and path | Scope | Purpose |
|---|---|---|
GET /repositories/:repo/flows?branch=&revision= | flows:read | Composed catalog at a revision (head when omitted), with problems and the caller's permissions |
GET /repositories/:repo/flows/:flow?branch= | flows:read | One composed flow |
GET /repositories/:repo/runs?flow=&branch=&status=&since=&until=&cursor= | flows:read | Runs, newest first; status is a comma-separated list of run statuses, since/until a creation range in ms (inclusive / exclusive) |
POST /repositories/:repo/runs | flows:write | Start a manual flow: { flow, branch, revision?, inputs, operationId? }; revision defaults to the branch head, and a retry with the same operationId returns the same run |
GET /repositories/:repo/runs/:run | flows:read | A run with its frozen definition, jobs and steps |
POST /repositories/:repo/runs/:run/cancel | flows:write | Cancel |
DELETE /repositories/:repo/runs/:run | flows:write | Delete a finished run and its logs |
GET /repositories/:repo/runs/:run/jobs/:job/logs?step=&after= | flows:read | One page of a step's log; continue with after while complete is false. Own rate budget of 300/min; a live viewer follows the event stream below instead |
GET /events | per topic | The tab's event stream (text/event-stream): hello { stream }, then every followed topic's events as { topic, data }; see live streams |
POST /events/:stream/subscriptions | per topic | { add: [{ key, topic }], remove: [key] }; answers 202 at once, refusals arrive on the stream as error events |
GET /repositories/:repo/runs/:run/events | flows:read | The run as text/event-stream: a run event carrying the whole run now and after every change, then end once it is over |
GET /repositories/:repo/runs/:run/jobs/:job/logs/stream?step=&after= | flows:read | One step's log as text/event-stream: a chunk event ({ seq, at, text }, id = seq) for each stored chunk past after and then for each as it lands, then end once the step is over. Last-Event-ID on a reconnect stands in for after |
GET /repositories/:repo/runs/:run/bundles/<path> | flows:read | Download one published file; 410 once retention removed the run's bundles |
GET /repositories/:repo/runners | flows:read | Runners bound to the repository with live status |
GET /repositories/:repo/runners/:runner | flows:read | One runner bound to the repository, with live status and activity: the run, job and running step it holds when that job is in this repository, else null |
POST /repositories/:repo/runners | runners:write (Maintainer) | Register a runner: { name, labels, repositories }; returns it with its id |
POST /repositories/:repo/runners/:runner/enabled | runners:write (Maintainer) | { enabled } |
PATCH /repositories/:repo/runners/:runner | runners:write (Maintainer) | { labels }; the next claim uses the new set |
DELETE /repositories/:repo/runners/:runner | runners:write (Maintainer) | Remove the registration |
The repository routes are also MCP tools, generated from the same
declarations with the same scopes and error shapes: list_flows, read_flow,
list_runs, start_run, read_run, cancel_run, delete_run,
read_run_log, list_runners, read_runner, register_runner,
update_runner, set_runner_enabled and remove_runner. An agent starts a
flow with just flow, branch and inputs, reads the run until its status
is terminal, and follows a step with read_run_log (after from the previous
nextCursor). Bundle downloads are
bytes, not a tool; the run lists their paths and GET .../bundles/<path>
serves them. The runner protocol below is a machine interface and is not
exposed to agents.
Runs also reach the home feed: every sampled repository's runs merge into it beside its submits and proposal events, for anyone who may read the repository — a public repository's runs are on the anonymous feed too. See the web interface guide.
Runner routes, used by the runner process with a token carrying
runners:write and flows:read that may operate the runner (see above):
| Method and path | Purpose |
|---|---|
POST /runners/:runner/checkin | { platform, version, publicKey } (publicKey is the runner's device public key, SubjectPublicKeyInfo DER in hex); returns the runner and credential: { certificate, endpoint, ca, serverName, authEndpoint, authCa, authServerName }: the credential certificate auth derived from the token's automation credential for that key, with what verifies the endpoints; 409 runner_auth_unconfigured when the deployment has no TLS auth configured |
POST /runners/:runner/heartbeat | { job?: { runId, jobId, lease } }; renews the lease, reports idle when absent |
POST /runners/:runner/claim | The next job as a JobAssignment (run.workspace is the workspaces: folder for the run's branch, or null), or { job: null } |
POST /runners/:runner/runs/:run/jobs/:job/events | { lease, events } with step_started, step_finished and job_finished events, at most 64 per call |
POST /runners/:runner/runs/:run/jobs/:job/logs | { lease, step, text }, at most 64 KiB per call: one append, for a runner that cannot hold a request open |
GET /runners/:runner/runs/:run/jobs/:job/logs/stream?step= | With X-Scribe-Lease: where the step's log stands, { received, full, continue } |
POST /runners/:runner/runs/:run/jobs/:job/logs/stream?step=&offset= | With X-Scribe-Lease: the step's output as one text/plain (UTF-8) request, chunked, held open and written as the step writes; offset is the count the runner last had confirmed, and what is already stored past it is skipped. The reply, { received, full, continue }, comes when the stream ends; 409 log_offset when offset runs past what is stored, 413 log_limit when the step is already full |
POST /runners/:runner/runs/:run/jobs/:job/bundles | { lease, path, size }: announce a file; returns { upload, received, partBytes }, with received past zero when an earlier attempt got that far |
PUT /runners/:runner/runs/:run/jobs/:job/bundles/:upload?offset= | One application/octet-stream part with X-Scribe-Lease and Content-Length; offset must equal received; the file is recorded on the run when the last part lands ({ received, complete }), and that part may carry X-Scribe-Blake3, the hex digest of the whole file the worker verifies |
The runner's own routes are rate limited per runner (address and runner id
together), each with its own bucket, not under the per-address limit the rest
of the API shares: a runner sits behind the same address as the browsers
around it, and its traffic must not be refused because a run page is open on
the same network. The limits are 60 check-ins and 60 heartbeats, 120 claims,
300 event reports, 600 log appends and 120 log streams a minute. The bundle
upload routes are served by the content process and have no rate limit;
bundle downloads there allow 600 requests a minute per address.
Every reply to a runner carries { continue }. False means stop the job: it
was cancelled, its lease expired, or it already finished. A report against a
job the lease does not hold is not an error; the runner simply learns to stop.
Reports that contradict the recorded state (a step starting twice, events
after job_finished) are refused whole with 409, so a job never records a
half-applied report.
A runner checks in at start, polls claim while idle, heartbeats at least
once a minute while executing, sends events as they happen, streams log
output, and publishes bundles in resumable parts; scribe-runner does
exactly this, and another implementation would speak the same routes. The assignment's job.steps are executed in
order; continueOnError steps do not end the job when they fail.
Every field of an assignment is always sent: unset values as null
(run.workspace, a step's run, uses, shell and workingDirectory, a
bundle rule's archive) and maps as {} when empty (run.env, the manual
trigger's inputs, job.env, a step's with and env). The one exception is
job.bundles, which a run started before jobs published bundles never froze;
such a job publishes nothing. scribe-runner refuses an assignment that lacks
any other field rather than filling it in, and unknown fields are ignored.
What is not here yet
- Runner distribution: the manual
buildworkflow producesscribe-runner-<platform>archives as workflow artifacts, or attached to a release when given a tag; the registration guide assumesscribe-runneris installed and on PATH and does not link to a download. - A push path for submits; the watcher polls at the worker interval.
- Secrets: a flow has no secret store;
envvalues are plain text in the versioned YAML. - Other triggers:
on:accepts onlymanualandsubmit; there are no scheduled or proposal triggers. - Check reporting: run results are not reported on proposals.
- Re-running a finished run; start a new run instead.