hammerkit

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

CallWhat it does
POST /v1/runsSubmit a run. Answers 202 with the run in state queued, or awaiting-approval when the caller only has the account on request.
GET /v1/runsThe 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>/cancelCancel 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>/eventsThe 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" }
}
  • definition names the published build file and the version to run: a hash, or latest when 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 is 400.
  • tasks are task names as on the command line, up to 100. A task the build file does not have is 400. Instead of names, labels in options.filter can 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 gets 403 naming the account, one who may only request it gets a run in awaiting-approval, and a caller without builds level may not run.
  • source says 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, is 400. The kinds the accounts of the run list in source (and, for refs, the branches) are the only ones allowed, anything else is 403.
  • options are the settings of hammerkit run that make sense on a server, all optional: env, filter and exclude (labels), cache (checksum, modify-date or none), cacheReadOnly, timeout (for example 30m) and skipDeps. How many tasks run in parallel and how much of the machine they use stay with the server, and watch and 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:

StatusWhen
202The run is accepted, queued or awaiting-approval.
400The 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.
401The token is missing or invalid.
403The 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.
409The version was yanked, was published before accounts were recorded, or nobody may approve an account the caller can only request.
429The caller is over their limits.
501The run is from an upload, which the executor does not support yet.
502The server could not reach the repository to resolve the ref.
503The 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:

CallWhat it does
POST /v1/sourcesAnnounce 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/sourcesThe 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 400 that names it, even when a src pattern reaches it: .env (not .env.example, .env.sample, .env.template), .npmrc, .netrc, .pypirc, .git-credentials, private keys (id_rsa and 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 builds level; others get 403. An account can further restrict its source.

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

  1. 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.
  2. 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.
  3. 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.
  4. 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

server.yaml
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' }
KeyWhat it does
maxConcurrentRuns that execute at the same time, the others wait in queue. 4 by default.
keepFinished runs that are remembered, the oldest are dropped first. 200 by default.
limitsWhat one caller may have at a time, see Limits and restarts.
maxDurationA run that executes longer is stopped and fails. 1h by default.
maxLogBytesThe log of a run that says more is cut off. 1 MiB by default.
env.allowThe 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.setFixed values that are set in addition.

On this page