Runs
Submit runs of published build files, upload source files, and the limits and environment runs execute with.
A run is a request to execute tasks of a published build file. The server writes every run
down in storage (records/, readable by the user of the server only), so a restart keeps them with their
logs, see Limits and restarts. Every call needs a token, and a run can be
seen and cancelled only by the caller that submitted it: for anyone else its id is
404, the same as an id that does not exist.
From the command line, remote approvals lists the runs
that wait for you. Submitting a run from the command line (remote run, --on) is not
there yet; a run is a POST /v1/runs.
Submitting a run
| Call | What it does |
|---|---|
POST /v1/runs | Submit a run. Answers 202 with the run in state queued, or awaiting-approval when the caller only has the account on request. |
GET /v1/runs | The caller's runs, newest first. |
GET /v1/runs/<id> | One run: state is awaiting-approval, queued, running, succeeded, failed, cancelled or rejected; a failed or rejected run has an error. |
POST /v1/runs/<id>/cancel | Cancel a run: one that is queued or waits for an approval at once, a running one when its tasks have stopped. |
GET /v1/runs/<id>/events | The run's events as server-sent events, see below. |
{
"definition": { "name": "payments", "hash": "sha256:9f2c..." },
"tasks": ["build", "test"],
"source": { "type": "ref", "repo": "github.com/corp/payments", "ref": "main" },
"options": { "env": "staging", "cache": "checksum", "timeout": "30m" }
}definitionnames the published build file and the version to run: a hash, orlatestwhen left out. The run is pinned to the version the server chose, which the answer shows, so what it executes does not change under it. A name or version that does not exist is400.tasksare task names as on the command line, up to 100. A task the build file does not have is400. Instead of names, labels inoptions.filtercan select the tasks.- There is no account in the request. The run uses the accounts of the secrets of the
tasks it executes, with their dependencies and the services they need, derived from
the pinned version, and the answer lists them as
accounts. A caller who has no level on one of them gets403naming the account, one who may onlyrequestit gets a run inawaiting-approval, and a caller withoutbuildslevel may not run. sourcesays where the code comes from:{ "type": "ref", "repo": "github.com/corp/payments", "ref": "main" }for a branch or tag of a repository (or a full commit hash) that the server checks out itself, or{ "type": "upload", "snapshot": "sha256:..." }for the caller's working tree, announced and sent beforehand, see Source files. A snapshot that is unknown, or that still lacks files, is400. The kinds the accounts of the run list insource(and, forrefs, the branches) are the only ones allowed, anything else is403.optionsare the settings ofhammerkit runthat make sense on a server, all optional:env,filterandexclude(labels),cache(checksum,modify-dateornone),cacheReadOnly,timeout(for example30m) andskipDeps. How many tasks run in parallel and how much of the machine they use stay with the server, andwatchand the log mode are for terminals only, so they are not accepted.
The request names the work, it does not contain it: what the tasks do comes from the
published build file, and the files they work on come from source. A ref is
fetched by the server. An upload refers to a snapshot of the working tree that is
sent separately, so the request stays small however large the project is.
The body is JSON up to 64 KiB, and unknown fields are rejected. Each submitted run is logged with who submitted it, the accounts and the source.
What POST /v1/runs answers, in the order the server checks:
| Status | When |
|---|---|
202 | The run is accepted, queued or awaiting-approval. |
400 | The body is malformed or has unknown fields, the build file or version does not exist, a task is unknown, the repository is not readable by the server or the ref is not one branch or tag, or the snapshot is unknown or incomplete. |
401 | The token is missing or invalid. |
403 | The caller has no builds level, may not run from that repository, has no level on an account of the run, or the account refuses that source or ref. |
409 | The version was yanked, was published before accounts were recorded, or nobody may approve an account the caller can only request. |
429 | The caller is over their limits. |
501 | The run is from an upload, which the executor does not support yet. |
502 | The server could not reach the repository to resolve the ref. |
503 | The server is shutting down, or too many runs wait for an approval. |
Events
The events carry the sequence number as their id, so a client that lost the
connection continues with the Last-Event-ID header (or ?after=<n>) and misses
nothing. The stream replays what happened and then follows the run until it is over;
a finished run's events are replayed and the stream ends. Event types are state
(awaiting-approval, queued, running and the final state, with the error of a failed run), approval
(who approved or rejected the run), task (a task started or finished)
and log (a line of a task's output). A run that
produces more than 5000 events is cut off with one truncated event, the state
changes are always kept.
Source files
A run from an upload works on the caller's files. They are sent in two steps so
that only what the server does not have yet travels: the caller announces the tree
as a list of files with their digests, the server says which digests it lacks, and
the caller sends those. The server takes the files already, but does not execute runs from
an upload yet: such a run is refused with 501.
POST /v1/sources{
"files": [
{ "path": "package.json", "sha256": "9f2c...", "size": 812 },
{ "path": "bin/run.sh", "sha256": "41ab...", "size": 233, "mode": "exec" }
]
}The answer names the tree, a snapshot, and says what is missing:
{
"snapshot": "sha256:5d1e...",
"files": 2,
"bytes": 1045,
"createdAt": "2026-10-11T11:00:00.000Z",
"missing": ["9f2c...", "41ab..."],
"complete": false
}The snapshot is the sha256:<hex> of the JSON [[path, sha-256, mode], ...] with
the paths sorted, the same construction as the version of a build file, so the same
files have the same name anywhere. It answers 201 for a new tree and 200 for
one the server already knows. Then each missing file is sent on its own:
| Call | What it does |
|---|---|
POST /v1/sources | Announce a tree. Up to 20,000 files, 32 MiB per file, 1 GiB per tree. |
PUT /v1/sources/blobs/<sha256> | Send the content of one file as the request body. Answers 201, or 200 when the server had it. |
GET /v1/sources | The trees the caller keeps, what they take and the limit. |
GET /v1/sources/<snapshot> | One tree and the digests still missing; complete when none is. |
DELETE /v1/sources/<snapshot> | Forget a tree and the content no other tree of the caller needs. |
A second run after one edited file announces the tree again and sends that file
only. A client can also skip the announcement when the snapshot it computes is the
one it used last and GET /v1/sources/<snapshot> says it is complete.
- Only the caller's. Trees and content belong to the caller who sent them;
another caller's snapshot is
404. Content is never shared between callers: a server that answers "I already have this file" to anybody would tell them what other people work on. - Only announced content. A file is taken only if a tree the caller announced
lists its digest, with the size announced, and only if the content has that
digest. Anything else is
400. - Credentials do not leave. A tree with a path that looks like a credential is
refused with
400that names it, even when asrcpattern reaches it:.env(not.env.example,.env.sample,.env.template),.npmrc,.netrc,.pypirc,.git-credentials, private keys (id_rsaand its kin,*.pem,*.p12,*.pfx) and anything below.git/,.ssh/,.aws/,.gnupg/or.kube/. Secrets belong in a secret provider; there is no override. - Quota. A caller may keep 4 GiB; a file that would go over is
413. Nothing is removed on its own, a caller deletes what they no longer need. - Who may. Callers with a
buildslevel; others get403. An account can further restrict itssource.
Paths are relative to the root of the project, forward slashes, nothing that climbs
out; inputs of a build file version say which files a run needs.
Executing a run
The server runs the tasks of an accepted run on its own machine, with the same engine
as hammerkit run, so the local and docker runtimes and the task cache work as
they do there. Each run executes in a process of its own (hammerkit server-run, started
by the server):
- It fetches the ref of the repository, as pinned to its commit when the run was submitted (see Pinning), into a directory made for the run. A ref that is not a plain branch, tag or commit name is refused.
- It writes the files of the published version over the checkout, so the tasks are
the published ones whatever the build file in the repository says. A file that would
land in
.git(in any spelling a case-insensitive file system reads as that) or behind a symbolic link of the checkout is refused. - It starts the process of the run in the checkout and runs the tasks in the order they
were named (or the ones the labels select), stopping at the first one that fails, and
reports each task and its output as events. Cancelling the run stops the process with
SIGTERM, so the engine stops its tasks and removes its containers, and kills it after 15 seconds. - It removes the directory, whether the run succeeded, failed or was cancelled.
The process starts with an environment the server builds, not with its own: the variables
runs.env.allow lists, plus runs.env.set, and a HOME that is an empty directory of the
run's alone. So what the engine reads of its process (the docker host, registry logins in
the home directory, cloud credentials) is what the operator gave runs and nothing else; a
private registry needs DOCKER_CONFIG in the allowlist, pointing to a login the operator
chose for runs. Tasks do not get the environment of the process either, only their own
envs and the secrets they declare.
The task cache is kept below the server's storage directory, one cache for the runs of
a repository and one per caller for uploads. A run from an upload can therefore not leave
an entry that a run from a reviewed repository finds. A task is keyed by its definition
and its sources, not by where the checkout is.
A run fails with the reason when the server does not list the repository, when the ref
can not be fetched or has moved, when it comes from an upload: uploads are not
supported by the executor yet, use source: ref. Cancelling a run stops the fetch or
the running task.
Before it starts, the executor derives the accounts of the run again from the pinned files with the run's environment, and fails the run if it would use one it was not admitted for. The secrets of the tasks are read as the accounts of the server config, by the provider commands, and a secret of any other account is refused when it is fetched.
The process of a run is not a sandbox, see One server is one trust level.
Limits and restarts
One caller can not take the machine. Per caller (issuer and subject) the server counts the runs that wait
for an approval (runs.limits.waiting) and the runs in the queue (runs.limits.queued); a submit over
either is answered 429 and says which. At most runs.limits.running runs of one caller execute at the
same time, and the next run in the queue whose caller has a place left starts first, so one caller with
many runs does not keep the others waiting. A run that executes longer than runs.maxDuration is stopped
and fails with that reason. The log of a run is cut off at runs.maxLogBytes with a truncated event;
the state changes are always kept.
Runs are written down when their state changes. On start the server takes them back:
- finished runs keep their state, events and logs;
- runs that wait for an approval wait on, for the time that was left, and are rejected when it has passed;
- runs that were queued or running when the server stopped have failed with it:
the server stopped while the run was running, submit it again.
A graceful stop (SIGTERM) leaves the waiting runs waiting, and cancels the queued and running ones.
Changes to runs need a restart; only the entitlements are read again on SIGHUP, see
Changing entitlements.
Config
runs:
maxConcurrent: 4 # default
keep: 200 # default
limits: # per caller (issuer and subject), 429 over it
waiting: 10 # default, runs that wait for an approval
queued: 20 # default, runs that wait for a free place
running: 2 # default, runs that execute at once, the others wait in the queue
maxDuration: 1h # default, a longer run is stopped and fails
maxLogBytes: 1048576 # default, log output kept per run
env:
allow: [PATH, DOCKER_HOST] # the only variables of the server a run sees, none by default
set: { CI: 'true' }| Key | What it does |
|---|---|
maxConcurrent | Runs that execute at the same time, the others wait in queue. 4 by default. |
keep | Finished runs that are remembered, the oldest are dropped first. 200 by default. |
limits | What one caller may have at a time, see Limits and restarts. |
maxDuration | A run that executes longer is stopped and fails. 1h by default. |
maxLogBytes | The log of a run that says more is cut off. 1 MiB by default. |
env.allow | The variables of the server process that a run, its provider commands and the git fetch can see. Nothing else of the server's environment is passed on, and the list is empty by default. A server that runs tasks needs PATH in it, Windows needs its system variables (SystemRoot, ComSpec, PATHEXT, TEMP) as well. |
env.set | Fixed values that are set in addition. |