This is the full developer documentation for Stratum
# Stratum
> The open-source, agent-first code forge — evaluation-gated merges, provenance, and agent identities, built on Cloudflare Workers.
The documentation for Stratum, the open-source, agent-first code forge. For what Stratum is and who it is for, see [usestratum.dev](https://usestratum.dev).
## Start here
[Section titled “Start here”](#start-here)
* [Getting started](/guides/getting-started/) — from sign-up to a merged, evaluation-gated change: policy, agent identity, and the change flow.
* [Importing from GitHub](/guides/importing/) — bring an existing repository in and keep it in sync with its source.
* [CI integration](/guides/ci-integration/) — run evaluations on infrastructure you already have and report verdicts back.
## Reference
[Section titled “Reference”](#reference)
* [Authentication](/reference/authentication/) — tokens, sessions, and the admin key.
* [Endpoints](/reference/endpoints/) — the REST surface, with the [OpenAPI specification](/reference/openapi/) as its contract.
* [Agent discovery](/reference/agent-discovery/) — the metadata an agent reads before its first request.
# CI Integration (Bring Your Own CI)
> Bring your own CI: run evaluations on your existing infrastructure and report verdicts back.
Stratum has **no native CI runner** — there are no workflow files, no hosted build agents, and no GitHub Actions replacement. What Stratum *does* run for you is its evaluation and merge pipeline, and it gives you a small, fixed set of ways to get code executed as part of that pipeline. This page inventories them honestly and shows how to wire an external CI system you host into the evaluation gate.
## The complete code-execution inventory
[Section titled “The complete code-execution inventory”](#the-complete-code-execution-inventory)
Stratum causes code to run in exactly four places, all driven by `.stratum/policy.yaml`. The first three run inside Stratum’s own pipeline; the fourth hands your code to a hosting provider that runs it:
> \[!IMPORTANT] **Two of these four need the Cloudflare Sandboxes binding, and it is not enabled — on the hosted instance or by default anywhere.** `[[sandboxes]]` is commented out in every environment of the repository’s `wrangler.toml`, because Sandboxes is a gated beta.
>
> That makes **1. the sandbox evaluator** and **3. `merge.postMergeCommand`** unavailable unless you self-host *and* have Sandboxes access *and* uncomment the binding. The consequence is not cosmetic: a policy naming the `sandbox` evaluator does not skip it, it **fails closed** — score 0, failed. How far that reaches depends on two policy settings:
>
> * Under the default `requireAll: true`, the aggregate verdict is `every(passed)` (`src/evaluation/composite-evaluator.ts:63-65`), so the failed `sandbox` result sinks it. The change lands in `needs_changes` and **every merge in that project is blocked** until you remove the evaluator. The one exception is `merge.allowForce: true`, which is off by default (force is deny-by-default, `changes.ts:477-480`) and which lets a writer force *other* changes past the failed evaluator — but **not the policy edit that removes it**: `.stratum/policy.yaml` is protected config, and force is refused on a change that touches it (`changes.ts:483-488`). So force is not a way out of this particular trap.
> * Under `requireAll: false` the aggregate is `some(passed)`, so another passing evaluator still carries it — *unless* `sandbox` is named in `merge.requiredEvaluators`, which is checked per evaluator (`src/merge/protection.ts:73-80`) and blocks the merge on its own either way.
>
> See [the sandbox evaluator](#1-the-sandbox-evaluator) for why it behaves that way.
>
> Neither **2. the `webhook` evaluator** nor **4. `deploys:`** needs the Sandbox binding, but they are not equally ready. The webhook evaluator needs only a reachable `https://` endpoint you host and its `url` in the policy. `deploys:` needs deployment setup first: a supported `target`, that provider’s credentials in the project secret store, an instance-level `DEPLOY_SECRET_KEY` to decrypt them, and a `DEPLOY_QUEUE` binding — without those a deployment fails or is never enqueued. See [Deployments](/guides/deployments/). If you want tests gating your merges today, the webhook evaluator is the supported route.
### 1. The sandbox evaluator
[Section titled “1. The sandbox evaluator”](#1-the-sandbox-evaluator)
The `sandbox` evaluator (`src/evaluation/sandbox-evaluator.ts`) materializes the **full workspace tree at the evaluated commit** — the same tree the merge would land — into a fresh Cloudflare Sandbox and runs a command there (default `npm test`, default timeout 60s, configurable via `command` and `timeoutMs`). If the tree carries a `package.json` it installs first, using `npm ci` when a lockfile is present and `npm install` otherwise, and passing `--ignore-scripts` unless the policy opts in (see below). Exit code 0 scores 1.0; otherwise the test output is parsed for `N passed / M failed` counts to derive a partial score.
**npm lifecycle scripts are disabled by default.** The evaluated tree is authored by an agent or by anyone who can push to the workspace, so its `package.json` could declare `preinstall`/`install`/`postinstall` — which would run before any human has reviewed the change. Installs therefore pass `--ignore-scripts`. If your build genuinely needs them (native modules, a `prepare` step), opt back in:
```yaml
evaluators:
- type: sandbox
allowInstallScripts: true
```
Note that a native module will usually *install* fine with scripts ignored and then fail later, when the test command tries to load the unbuilt binary — so an opaque `.node` load error in your suite is the symptom to look for. Because `.stratum/policy.yaml` is a protected config file, flipping this flag requires a human approval and cannot be force-merged.
**Every evaluation has a total time budget** (`totalBudgetMs`, default 150s) covering the tree read, dependency install, and the command. Each phase is granted whatever is left, and exhausting the budget returns a failing verdict whose reason names the phase — `sandbox budget exceeded (install)` — rather than hanging until your client times out. The per-phase defaults (90s install, 60s command) sum to exactly the budget, so you only encounter it if you have raised a timeout or your repo is genuinely slower than the budget allows.
It **requires the optional `SANDBOX` binding**. If a policy names a `sandbox` evaluator and the binding is absent, the evaluator does not silently disappear — it is replaced with an “unavailable” evaluator that returns score 0 / failed (see `buildEvaluators` in `src/services/change-flow.ts`). In other words, it **fails closed**.
The evaluator’s `diff` argument is ignored on purpose: an earlier version reconstructed a pseudo-tree from the diff’s `+` lines, which could not run a real suite — no base tree, no untouched sources, no `package.json` unless it happened to change. The tree is read from the repo instead.
It is still not a general CI environment — one command, one timeout, no matrix, no artifacts, no caching between runs — but it does run against real sources rather than a reconstruction.
### 2. The `webhook` evaluator — your CI, called synchronously
[Section titled “2. The webhook evaluator — your CI, called synchronously”](#2-the-webhook-evaluator--your-ci-called-synchronously)
The `webhook` evaluator (`src/evaluation/webhook-evaluator.ts`) is the bring-your-own-CI hook: Stratum POSTs the change to an HTTPS endpoint you host, and your endpoint returns the verdict. The full contract is documented [below](#the-webhook-evaluator-contract).
### 3. `merge.postMergeCommand` — post-merge smoke in a sandbox
[Section titled “3. merge.postMergeCommand — post-merge smoke in a sandbox”](#3-mergepostmergecommand--post-merge-smoke-in-a-sandbox)
After a merge, the policy’s `merge.postMergeCommand` (if set) runs in a Cloudflare Sandbox against the **full merged tree** (`src/merge/post-merge.ts`), with a default timeout of 60s (`postMergeTimeoutMs`). On failure the merge is automatically reverted unless `merge.autoRevert: false`. Like the sandbox evaluator, this requires the `SANDBOX` binding; without it the check is skipped with a warning.
### 4. `deploys:` — publishing the merged tree to a provider
[Section titled “4. deploys: — publishing the merged tree to a provider”](#4-deploys--publishing-the-merged-tree-to-a-provider)
After a merge survives the post-merge check, each entry in the policy’s `deploys:` list uploads the merged tree to a hosting provider — Cloudflare (static assets or a Worker script) or Vercel — using credentials stored in the project’s encrypted secret store. Stratum does not execute that code itself; the provider does, once it is published.
There is **no build step**: the tree is deployed exactly as committed. Commit your built output, or use the `vercel` target, which builds the uploaded source remotely. A deploy can be gated behind an approval (`requiresApproval: true`), and a failed one can be retried, but there is no rollback and no preview environment. See [Deployments](/guides/deployments/) for the full configuration reference, the per-target secrets, and the limits.
That’s it. Everything else — building artifacts, scheduled jobs, matrix runs, artifact retention — must live in a system you run outside Stratum.
## The webhook evaluator contract
[Section titled “The webhook evaluator contract”](#the-webhook-evaluator-contract)
Configure it in `.stratum/policy.yaml`:
```yaml
evaluators:
- type: webhook
url: https://ci.example.com/stratum-eval
secret: # optional; enables HMAC signing — see the warning
timeoutMs: 10000 # optional; default 10000 (10s)
merge:
requiredEvaluators: ["secret_scan", "webhook"]
```
You can declare more than one `webhook` entry. Each is contacted independently, with its own `url`, `secret` and `timeoutMs`, and each records its own eval run. Because every one of them records the type `webhook`, a `requiredEvaluators` entry for `webhook` means *every* receiver must pass — one receiver’s rejection blocks the merge even if the others approved. Re-evaluating replaces the whole set, so a later passing round clears an earlier failure.
> **`secret` is a literal in a committed file.** `EvaluatorConfig` types it as `secret?: string` (`src/evaluation/types.ts`) and the policy loader performs no environment or secret-store lookup, so the only way to enable HMAC signing today is to write the value into `.stratum/policy.yaml` — where every reader of the repository can see it. There is no `.dev.vars` or Wrangler-secret indirection for this field; adding one is an implementation change, not configuration. Until then, treat a signed webhook as authenticating *the repository*, not a confidential channel, and rotate the value if the repo’s readership changes.
### Request (Stratum → your endpoint)
[Section titled “Request (Stratum → your endpoint)”](#request-stratum--your-endpoint)
* `POST` with `Content-Type: application/json`.
* Body: `{"diff": "", "policy": {...}, "baseSha": ""}` — the policy object is the parsed evaluation policy, so your endpoint can read its own config from `policy.evaluators`. It is passed through `sanitizePolicy` first (`src/evaluation/sanitize-policy.ts`), which strips `secret` from every webhook evaluator entry, so no receiver sees any evaluator’s signing secret — including its own. Provision your receiver’s copy of the secret out of band; do not expect to read it from the payload.
* `baseSha` is the commit the diff was computed against, resolved from the same clone that produced the diff. **Check out that exact commit and apply the diff to it.** Do not apply the diff to your mirror’s current default branch: the branch can advance between diff generation and delivery, and `git apply` will often succeed anyway wherever the context lines still match — producing a pass or fail for a tree the change never proposed.
* `baseSha` is **omitted**, not null, when Stratum had no base to name. Treat its absence as “I cannot reproduce this” and reject the request rather than falling back to your branch tip.
* `baseSha` is inside the signed body, so it cannot be swapped in transit — **but only when `secret` is configured**, because that is what produces the `X-Stratum-Signature` header at all. Without a secret nothing about the payload is authenticated, and an unsigned receiver must be reached over `https://` so the transport protects what the HMAC otherwise would.
* The field is additive: a receiver written against the earlier `{diff, policy}` contract keeps working, and simply keeps the failure mode described above.
* If `secret` is set, the header `X-Stratum-Signature: sha256=` carries an HMAC-SHA256 of the exact request body, keyed with the secret. Verify it before trusting the payload.
* The URL must target a **public host over http/https** — localhost, private IP ranges, `.internal`/`.local` names, and bare single-label hostnames are rejected and the evaluation fails closed (score 0). Redirects are **not followed**; a 3xx counts as failure.
One property of the current contract is worth knowing before you point a receiver at it. This section describes what ships today.
* **`http://` URLs are accepted**, and even a configured HMAC authenticates the body without encrypting it. Over plain HTTP the diff, the policy, and `baseSha` travel in cleartext, and an unsigned request over plain HTTP is neither authenticated nor confidential — anyone on the path can substitute the base the suite runs against. Use an `https://` URL and terminate TLS in front of your receiver; treat that as required, not advisory, when no secret is set.
### Response (your endpoint → Stratum)
[Section titled “Response (your endpoint → Stratum)”](#response-your-endpoint--stratum)
Reply `200 OK` with JSON:
```json
{ "score": 0.95, "passed": true, "reason": "142 tests passed" }
```
* `score`: number (aggregated with the other evaluators’ scores — averaged when `requireAll` is on, the default; max otherwise).
* `passed`: boolean verdict.
* `reason`: human-readable explanation shown in the change’s evidence.
Any non-2xx status is recorded as a failed evaluation (`Webhook failed: HTTP `).
### The timeout constraint (important)
[Section titled “The timeout constraint (important)”](#the-timeout-constraint-important)
The call is **synchronous**: evaluation runs at change-creation time, inside the request, and the webhook is aborted after `timeoutMs` (default **10 seconds**). There is **no async callback API today** — your CI must produce its verdict within the request window. Practical approaches:
* Keep the webhook check fast (lint, typecheck, focused smoke tests) and raise `timeoutMs` moderately for slower suites.
* Pre-warm workers/caches on your CI host so a run doesn’t pay cold-start.
* Run the long suite out-of-band and have the webhook return the verdict for the most recent equivalent state — accepting the staleness tradeoff.
A timed-out or errored webhook surfaces as an external-service error for that evaluator, and a `merge.requiredEvaluators` entry for `webhook` will keep the change unmergeable until a re-evaluation passes.
## Wiring your CI to the contract
[Section titled “Wiring your CI to the contract”](#wiring-your-ci-to-the-contract)
The endpoint’s job is narrow: authenticate the request, decide, and answer in the verdict shape. A sketch of just that part:
```js
// Sketch — the contract, not a deployable receiver. See the note below.
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.STRATUM_WEBHOOK_SECRET;
/** Constant-time check of the `sha256=` header against the raw body. */
function signatureValid(rawBody, header) {
if (!SECRET) return true; // no secret configured: Stratum sends no signature
if (typeof header !== "string" || !header.startsWith("sha256=")) return false;
const expected = createHmac("sha256", SECRET).update(rawBody).digest();
const received = Buffer.from(header.slice("sha256=".length), "hex");
// timingSafeEqual throws on a length mismatch, so compare lengths first.
return received.length === expected.length && timingSafeEqual(received, expected);
}
// rawBody is the exact bytes Stratum sent — verify before parsing, since
// re-serializing JSON would change what you are authenticating.
if (!signatureValid(rawBody, headers["x-stratum-signature"])) {
respond(401, { error: "bad signature" });
} else {
const { diff, policy, baseSha } = JSON.parse(rawBody);
if (!baseSha) {
// Nothing to reproduce against — a verdict here would describe a tree the
// change never proposed. Fail loudly rather than guessing at a base.
respond(400, { error: "payload carries no baseSha" });
} else {
// `yourCi` must check out baseSha and apply the diff to THAT commit.
const verdict = await yourCi(diff, policy, baseSha);
respond(200, {
score: verdict.score, // 0..1, as in the contract above
passed: verdict.passed, // boolean — this is what gates the merge
reason: verdict.reason, // shown in the change UI
});
}
}
```
**What this deliberately leaves out.** `yourCi` is where the change under evaluation actually gets built and tested, and that is the part this guide does not attempt to specify. Applying a diff and running its test suite means executing attacker-controlled code: `git apply` alone can introduce hooks and `.gitattributes` filters, and a test suite can do anything. Doing that safely is an infrastructure problem — separate user and PID namespaces so the workload cannot read the receiver’s environment, a fresh filesystem per evaluation so one change cannot observe or poison another, resource and time bounds, no ambient credentials, no reachable cloud metadata — and it is not something a copyable snippet can be trusted to get right. Run it on disposable, least-privileged infrastructure you already trust for untrusted builds. Issue #281 tracks writing that guidance up properly.
Two constraints worth knowing before you build it: the response must arrive inside the timeout window described above, and the receiver should sit behind TLS you terminate yourself — the body carries the change diff.
### Using GitHub Actions as the executor
[Section titled “Using GitHub Actions as the executor”](#using-github-actions-as-the-executor)
GitHub Actions cannot answer a synchronous webhook directly (a dispatched workflow run is asynchronous), so the common pattern in **layer mode** is to not use the webhook evaluator at all: keep the repo synced to GitHub, let Actions run on the promoted PR as usual, and let humans review there. If you want Actions *inside* the Stratum gate, you need a small always-on receiver (like the sketch above) that runs the same checks the workflow does — or that proxies to a pre-warmed self-hosted runner — within the timeout window.
## What’s missing vs GitHub Actions
[Section titled “What’s missing vs GitHub Actions”](#whats-missing-vs-github-actions)
To set expectations, Stratum has none of the following today:
* Workflow definitions (`.github/workflows`-style pipelines, triggers, steps)
* Hosted or self-hosted runner management
* Matrix builds
* Build artifacts (upload/download/retention)
* Dependency/build caching
* Scheduled jobs (cron workflows)
* A secrets store *for evaluators*. Stratum does have an encrypted per-project secret store, but it is **deploy-only**: the deploy runner is the sole reader, and nothing interpolates it into a policy file. The webhook evaluator’s `secret` still lives literally in `.stratum/policy.yaml`.
* Deployment *environments* — no staging/production separation, no per-environment variables, no environment protection rules. What exists is a flat list of named deploys, each optionally gated by a single approval (`requiresApproval: true`), plus a retry. There is no rollback, no preview deploy of an unmerged change, and no build step. See [Deployments](/guides/deployments/).
* Status-check aggregation — Stratum does not collect external CI check results the way a GitHub PR’s checks tab does. An external system reports a verdict only by answering the webhook evaluator synchronously; a check that reports anywhere else is invisible to the gate.
If you need those, run a real CI system and connect it via the webhook evaluator (for gating) or via GitHub sync in layer mode (for everything else).
## See also
[Section titled “See also”](#see-also)
* [Deployments](/guides/deployments/) — the `deploys:` policy block, targets, secrets, and limits
* [FAQ: Does Stratum replace GitHub Actions?](/guides/faq/#does-stratum-replace-github-actions)
* `docs/CURRENT_CAPABILITIES.md` — the authoritative current state
# The Stratum CLI
> Install and use @stratum-eng/cli — projects, workspaces, commits, and the change flow from the terminal.
`@stratum-eng/cli` puts the change flow in the terminal: projects, workspaces, commits, evaluation-gated changes, reviews, and issues, each command a thin wrapper over the [REST API](/reference/endpoints/). It is built for humans and shell scripts; agents are usually better served by the [MCP server](/guides/mcp/), which speaks the same surface over a protocol editors and agent frameworks already understand.
## Installation
[Section titled “Installation”](#installation)
The package is **not yet published to npm** — install it from the `cli/` directory of the repository (Node 20+):
```bash
git clone https://github.com/stratum-eng/stratum.git
cd stratum/cli
npm install && npm run build
npm link # puts the `stratum` binary on your PATH
```
## Authentication
[Section titled “Authentication”](#authentication)
```bash
stratum login
```
That is the whole thing: `login` opens your browser, you approve the CLI on Stratum’s consent screen, and the tokens land in `~/.stratum/config.json`. There is no token to create by hand and none to paste.
Under the hood it is the OAuth 2.1 authorization-code flow that native apps use (RFC 8252): the CLI registers itself as a public client ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)), proves possession of the code with PKCE, and receives the redirect on a loopback listener bound to an ephemeral port on `127.0.0.1`. Nothing is minted that you have to store, and the grant shows up under **Settings → Connected applications** alongside your editors, where it can be revoked like any other.
The **granted** scope is printed at login and by `stratum status`, so a grant narrower than you asked for is visible immediately rather than at the first write that fails. By default the session is read **and** write, because most commands write. For a session that cannot change anything:
```bash
stratum login --read-only
```
Sign out, which asks the host to revoke the grant and then clears the local credential — and still clears it locally if the host is unreachable:
```bash
stratum logout
```
`logout` reports that the revocation request was *delivered*, not that the grant is gone: [RFC 7009 §2.2](https://datatracker.ietf.org/doc/html/rfc7009#section-2.2) has the server answer `200` whether or not anything matched. To confirm, check **Settings → Connected applications**.
### Headless: CI, containers, and anywhere without a browser
[Section titled “Headless: CI, containers, and anywhere without a browser”](#headless-ci-containers-and-anywhere-without-a-browser)
Without a terminal, `stratum login` refuses rather than opening a browser nobody can see. Two options:
```bash
stratum login --host https://app.usestratum.dev --key stratum_user_xxxxx
```
or set `STRATUM_HOST` and `STRATUM_API_KEY`, which override the config file. `STRATUM_API_KEY` overrides on its own; `STRATUM_HOST` on its own retargets a stored **API token** only. With a browser session it is refused, because an OAuth grant belongs to the server that issued it and is worthless — and unsafe — anywhere else. Passing `--key` is also how you say “do not open a browser” on a machine that has one. Create the token from **Settings → API tokens**; token management deliberately requires a browser session, so a token can never mint another token.
> **Changed:** `stratum login --host ` *without* `--key` now opens a browser instead of prompting for a key. A script that piped a key into that prompt must pass `--key` or use the environment variables.
Two things to know either way:
* **Credentials land in `~/.stratum/config.json`.** The file is `0600` in a `0700` directory — including on installs upgraded from a release that left it world-readable — and is written atomically, so an interrupted write cannot destroy a session. It is still plaintext: treat it like the token itself. In CI, skip the file entirely and use the environment variables.
* **`login --key` verifies the key exists, not what it can do.** It calls the host’s `/health` endpoint with your key, and an invalid or mistyped key fails there with `HTTP 401` before anything is saved. What it does *not* check is **scope**: a `read`-scoped token logs in fine and only fails on the first write. The browser flow has no such gap — you choose the scope at login and `--read-only` is the only way to end up with less than you need.
`stratum status` confirms which account you are:
```bash
stratum status
# Authenticated as you@example.com (usr_...)
```
### Sessions renew themselves
[Section titled “Sessions renew themselves”](#sessions-renew-themselves)
An OAuth access token is good for an hour and the CLI refreshes it silently when it expires, rotating the stored refresh token each time. You stay signed in for up to 30 days of inactivity, and 180 days in total, after which `stratum login` asks for approval again. An API token, by contrast, lives until it expires or is revoked.
Rotation is coordinated across processes with a lock file (`~/.stratum/config.lock`), so running several `stratum` commands at once is safe. It has to be: presenting a refresh token that has already been rotated is indistinguishable from a stolen one, and the server responds by revoking the whole grant.
A read-only session — `--read-only`, or a `read`-scoped API token — is refused on every request that is not a `GET` or a `HEAD`, with `403 TOKEN_SCOPE_INSUFFICIENT`. Beyond `logout`, the CLI has no commands for creating or revoking API tokens: that surface is deliberately [session-only](/reference/authentication/#managing-tokens-requires-a-session), so it lives in the settings UI.
## Projects
[Section titled “Projects”](#projects)
```bash
stratum init billing # create a project in your namespace
stratum init billing --org acme # ...or under an organization
stratum init billing --public # public instead of the default private
stratum projects # list your projects
stratum activity acme/billing # recent events (changes, merges, evals)
```
`init` prints the project’s git remote when one is provisioned.
Deleting a project is owner-only, irreversible, and asks you to repeat the project reference as a confirmation token:
```bash
stratum project delete acme/billing --confirm acme/billing
```
Deletion is enqueued (the command prints the job id) and removes the project and everything attached to it — see the [projects API](/reference/endpoints/) for what the cascade covers.
## Workspaces and commits
[Section titled “Workspaces and commits”](#workspaces-and-commits)
A workspace is your isolated fork of the project; changes are proposed from it.
```bash
stratum workspace create acme/billing --name fix-retries
stratum workspace list acme/billing
stratum workspace delete acme/billing fix-retries
```
`stratum commit` bridges a **local git checkout** to a workspace: it reads the files currently *staged* in your local repository (the index version, not the working tree) and commits their full contents to the workspace:
```bash
git add src/retry.ts
stratum commit --project acme/billing --workspace fix-retries -m "Honour Retry-After"
```
Limits worth knowing, since the underlying commit API takes a map of complete file contents: at most **2,000 files** and **25 MB** of content per commit, and **staged deletions and renames cannot be expressed** — the command errors on a staged deletion rather than silently dropping it. For anything the file map cannot say, push to the workspace’s git remote instead; the same change gate applies either way.
## Changes
[Section titled “Changes”](#changes)
```bash
stratum change create --project acme/billing --workspace fix-retries
# Created change chg_abc123 (open)
# Evaluation: score 0.92, passed — all gates passed
```
Creating a change runs the project’s evaluation gates **synchronously** and prints the verdict. Then:
```bash
stratum change list acme/billing --status open
stratum change show chg_abc123 # per-evaluator evidence + metered costs
stratum change review chg_abc123 --verdict approve --comment "LGTM"
stratum change merge chg_abc123 # runs the full merge gate
stratum change merge chg_abc123 --squash
stratum change merge chg_abc123 --force # denied unless policy allows force
stratum change reject chg_abc123
```
`review` accepts `approve` or `request_changes` (the comment-only verdict from the [code review guide](/guides/code-review/) is not exposed here), and the change author’s own approval never counts toward required approvals. A refused merge prints the blocking reasons — the codes are in the [error reference](/reference/errors/).
## Issues
[Section titled “Issues”](#issues)
```bash
stratum issue create acme/billing --title "Retry storms on 429" --body "..."
stratum issue create acme/billing --title "Retry storms" --change chg_abc123
stratum issue list acme/billing --status closed # default is open
stratum issue close acme/billing 42
```
An issue created with `--change` auto-closes when that change merges — see [Issues](/guides/issues/).
## Account
[Section titled “Account”](#account)
```bash
stratum account delete --confirm your-username
```
Irreversible, GDPR-grade erasure of your account and owned projects, confirmed by repeating your username. Your credentials stop working the moment the command returns; the deletion itself runs as a background job.
## Errors and scripting
[Section titled “Errors and scripting”](#errors-and-scripting)
Every failure exits non-zero, so the CLI composes with `&&` and `set -e`. API failures print an `Error: ...` line to stderr carrying the server’s message — plus the server’s `reasons` as indented bullet lines when there are several, as on a `PROTECTION_BLOCKED` merge. (Bad arguments are reported by the option parser in its own `error: ...` format before any request is made.) The machine-readable codes behind API errors are catalogued in the [error reference](/reference/errors/).
## Reference
[Section titled “Reference”](#reference)
* [`cli/README.md`](https://github.com/stratum-eng/stratum/blob/main/cli/README.md) — the package’s own quick reference
* [Getting started §6](/guides/getting-started/#6-connect-your-tools) — where the CLI fits in the end-to-end flow
* [OpenAPI specification](/reference/openapi/) — the API each command wraps
# Code Review
> Line-anchored comment threads, replies, resolve/unresolve, and the three review verdicts.
Stratum’s merge gate decides whether a change *may* land. Code review is how people say whether it *should*. The two are independent: a change can be green on every evaluator and still sit unapproved, and no amount of approval lets a change past a failing required evaluator.
Everything here works on a change, from the change page in the web UI or through the API.
## Comment threads
[Section titled “Comment threads”](#comment-threads)
A comment is either **floating** — attached to the change as a whole — or **anchored** to a line of the diff.
```bash
# A floating comment on the change
curl -X POST https://app.usestratum.dev/api/changes/chg_123/comments \
-H "Authorization: Bearer stratum_user_xxxxx" \
-H "Content-Type: application/json" \
-d '{"body": "Worth a follow-up issue, not a blocker."}'
```
To anchor a comment to a line, pass `file` and `line` together. `line` is 1-based. `side` (`old` or `new`) picks which half of the diff the line belongs to, and `commitSha` records the commit the anchor was taken against — both are accepted only alongside a `file`+`line` anchor.
```bash
curl -X POST https://app.usestratum.dev/api/changes/chg_123/comments \
-H "Authorization: Bearer stratum_user_xxxxx" \
-H "Content-Type: application/json" \
-d '{
"body": "This retries on a 4xx — the circuit breaker will trip on client errors.",
"file": "src/github/client.ts",
"line": 214,
"side": "new",
"commitSha": "a1b2c3d4..."
}'
```
Anyone who can **read** the project may comment — including agents. Comments are capped at 20,000 characters.
### Replies
[Section titled “Replies”](#replies)
Passing `parentCommentId` posts a reply into an existing thread:
* The parent must belong to the same change.
* Replies inherit the thread’s anchor, so the anchor fields must be omitted.
* Replies to replies are flattened onto the thread root — threads are one level deep, not a tree.
```bash
curl -X POST https://app.usestratum.dev/api/changes/chg_123/comments \
-H "Authorization: Bearer stratum_user_xxxxx" \
-H "Content-Type: application/json" \
-d '{"body": "Good catch — fixed in the next commit.", "parentCommentId": "cmt_456"}'
```
### Resolving a thread
[Section titled “Resolving a thread”](#resolving-a-thread)
```bash
curl -X POST https://app.usestratum.dev/api/changes/chg_123/comments/cmt_456/resolve \
-H "Authorization: Bearer stratum_user_xxxxx"
```
`POST .../unresolve` reopens it. Two rules are worth knowing:
* Only **project writers or the comment’s author** may resolve a thread, so a reader cannot quietly close feedback they received.
* Only a **thread root** can be resolved. Resolving a reply is refused — resolution is a property of the conversation, not of an individual message.
Resolving is bookkeeping, not a gate: an unresolved thread does not block a merge. Use `request_changes` for that.
## Review verdicts
[Section titled “Review verdicts”](#review-verdicts)
A review is a verdict on the whole change:
```bash
curl -X POST https://app.usestratum.dev/api/changes/chg_123/reviews \
-H "Authorization: Bearer stratum_user_xxxxx" \
-H "Content-Type: application/json" \
-d '{"verdict": "approve", "comment": "Reads well, tests cover the retry path."}'
```
| Verdict | Effect on the change |
| ----------------- | ------------------------------------------------------------------ |
| `approve` | Moves the change to `approved`; counts toward `requiredApprovals`. |
| `request_changes` | Moves the change to `needs_changes`; blocks the merge. |
| `comment` | Records the text in the discussion. Status untouched. |
Submitting a verdict requires **write** access to the project — commenting only needs read access, but a verdict moves the change’s state. Only open, `needs_changes`, `accepted`, or `approved` changes can be reviewed.
### Comment-only reviews
[Section titled “Comment-only reviews”](#comment-only-reviews)
`{"verdict": "comment"}` is for saying something substantive without taking a position. It requires a `comment` (an empty one is a `400`), and it is deliberately inert:
* It **never counts toward required approvals** and never blocks a merge.
* It **never replaces an existing verdict** by the same reviewer. If you have already approved or requested changes, a later comment-only review leaves that verdict standing — a verdict row is only recorded when you have none yet.
The body lands in the change’s append-only discussion rather than in the verdict row, which is why it survives alongside a verdict you gave earlier. It emits `change.commented`, so activity feeds and webhook subscribers see it like any other comment.
## Reviews are human-only
[Section titled “Reviews are human-only”](#reviews-are-human-only)
`POST /api/changes/{id}/reviews` rejects agent tokens on every surface. An agent can read a diff, comment on it, and anchor those comments to lines — but it cannot approve, and it cannot clear `requiredApprovals`. This is the human-approval invariant described in [Getting Started](/guides/getting-started/#the-human-approval-invariant), and it is enforced in the route, not in the UI.
Separately, the **change author’s own approval never counts** toward `requiredApprovals` — otherwise a lone writer could open a change, approve it, and self-merge. For a change an agent created, the excluded author is the agent’s owning user, so an operator cannot approve their own agent’s work either. (Changes created before authorship was recorded have no author to exclude, so on those every approval counts — including what may in fact be a self-approval. The strict fail-closed rule applies on the manual conflict-resolution path when the originating change is unknown.)
## Reviewing in GitHub instead
[Section titled “Reviewing in GitHub instead”](#reviewing-in-github-instead)
In layer mode, promoting a change to a GitHub PR moves the conversation to GitHub, and your team reviews there as usual. Stratum still posts the evaluation verdict to the PR as a comment (edited in place on re-evaluation) and as a `stratum/evaluation` commit status. See [Getting Started](/guides/getting-started/#choose-your-level-of-buy-in-layer-mode-vs-forge-mode).
## Reference
[Section titled “Reference”](#reference)
* [Reviews and comments API](/reference/endpoints/)
* [OpenAPI specification](/reference/openapi/) — exact schemas and status codes
# Deployments
> Publish the merged tree to Cloudflare or Vercel after a change lands — the deploys: policy block, encrypted project secrets, the approval gate, limits, and what v1 does not do.
Stratum can publish the merged tree to a hosting provider after a change lands. You declare deploys in the same `.stratum/policy.yaml` that gates your merges, store the provider credential once as a project secret, and every subsequent merge that survives the post-merge check publishes automatically.
This is deliberately small. It is **not a CI system**: there is no build step, no preview environment, and no rollback. Read [Limitations](#limitations-read-this-before-you-rely-on-it) before you point production at it.
## Quickstart
[Section titled “Quickstart”](#quickstart)
Zero to a live deploy. Step 1 is once per instance, steps 2–4 once per project; after that merging is the whole workflow. Each step links to its reference section for the detail this skips.
### 1. Confirm the instance can deploy — once, by whoever runs it
[Section titled “1. Confirm the instance can deploy — once, by whoever runs it”](#1-confirm-the-instance-can-deploy--once-by-whoever-runs-it)
Two things the application cannot provision for itself, and neither failure is obvious from the UI:
| Missing | What you see |
| -------------------------- | ------------------------------------------------------------------------------------------------------- |
| `DEPLOY_SECRET_KEY` | Storing a secret fails; every deploy fails with `DEPLOY_SECRET_KEY is not configured on this instance…` |
| the `DEPLOY_QUEUE` binding | **Nothing at all** — a merge never enqueues a deploy, so no row is written and no error is shown |
```bash
wrangler secret put DEPLOY_SECRET_KEY --env production
wrangler queues create stratum-deploys
wrangler queues create stratum-deploys-dlq
```
On an instance you do not operate, ask its operator — there is no way to check from the UI. Detail, including why rotating the key is destructive: [Operating a Stratum instance](#operating-a-stratum-instance-with-deploys-enabled).
### 2. Collect the provider values
[Section titled “2. Collect the provider values”](#2-collect-the-provider-values)
The secret **names** are fixed — the deploy reads these exact names from the project store.
**Cloudflare** (`cloudflare-pages` and `cloudflare-workers`):
| Secret | Where to get it |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `CLOUDFLARE_API_TOKEN` | [dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens) → **Create Token** → **Custom token**. One permission is enough: **Account ▸ Workers Scripts ▸ Edit**. Shown once — copy it immediately. |
| `CLOUDFLARE_ACCOUNT_ID` | The id in any dashboard URL: `dash.cloudflare.com//…` |
| `CLOUDFLARE_WORKERS_SUBDOMAIN` | **Workers & Pages** → your `.workers.dev`. Optional, but without it the deploy succeeds and records **no URL**. |
**Vercel**:
| Secret | Where to get it |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `VERCEL_TOKEN` | Vercel → **Account Settings → Tokens**. |
| `VERCEL_PROJECT_ID` | The Vercel project’s **Settings → General → Project ID**, or `vercel project ls`. |
| `VERCEL_TEAM_ID` | Optional, but required in practice for a team-scoped token — without it the API resolves against your personal account. |
### 3. Store them on the project
[Section titled “3. Store them on the project”](#3-store-them-on-the-project)
*Project → Settings → Deploy secrets*, or:
```bash
curl -X PUT https://app.usestratum.dev/api/projects/@acme/site/secrets/CLOUDFLARE_API_TOKEN \
-H "Authorization: Bearer $STRATUM_TOKEN" \
-H "Content-Type: application/json" \
-d '{"value":"…"}'
```
Must be a project admin, and **not an agent** — agent tokens are refused on every secret route. There is no read path: nothing returns a stored value. More: [Storing a secret](#storing-a-secret).
### 4. Declare the deploy
[Section titled “4. Declare the deploy”](#4-declare-the-deploy)
Commit one of these as `.stratum/policy.yaml`. **The `evaluators:` block is not optional** — a policy file without it is malformed, and a malformed policy blocks *every merge in the project*, with an error about evaluators that will not obviously point back here. See [Before you start](#before-you-start-your-policy-still-needs-evaluators).
```yaml
# Static site: publishes ./public as committed.
evaluators:
- type: diff
deploys:
- name: site
target: cloudflare-pages
dir: public
secrets: [CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID]
```
Do **not** add `CLOUDFLARE_WORKERS_SUBDOMAIN` to that `secrets:` list. Naming a secret there makes it *required*, which turns the optional “no URL on the row” outcome into a failed deploy. The target loads it anyway when it is stored.
```yaml
# Worker script: uploads the .js/.mjs files in the tree.
evaluators:
- type: diff
deploys:
- name: api
target: cloudflare-workers
secrets: [CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID]
```
```yaml
# Vercel: uploads the source; Vercel builds it. Human approval before it ships.
evaluators:
- type: diff
deploys:
- name: production
target: vercel
requiresApproval: true
secrets: [VERCEL_TOKEN, VERCEL_PROJECT_ID]
```
Remember there is **no build step**: Cloudflare targets publish the tree as committed, so commit your built output. `vercel` is the exception — it uploads the source and Vercel builds it.
### 5. Merge a change, then watch it
[Section titled “5. Merge a change, then watch it”](#5-merge-a-change-then-watch-it)
Deploys run after a merge survives the post-merge check — not on the merge event itself, so a change that gets auto-reverted never deploys. Watch it at *Project → Deploys*. First run reaches `succeeded`, `failed`, or `pending_approval` if you set `requiresApproval`.
## If your first deploy didn’t work
[Section titled “If your first deploy didn’t work”](#if-your-first-deploy-didnt-work)
Matched against the exact text a deployment records, so you can search for what you actually see.
| What you see | What it means | Fix |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **No deployment row at all** | The merge never enqueued: the post-merge check reverted or failed the merge, or the instance has no `DEPLOY_QUEUE` binding | Check the post-merge check passed (or is not configured); then step 1 |
| **No deployment row, but the merge looks fine** | A bound `DEPLOY_QUEUE` can still fail on `send`. The request is then written to the event outbox, which a five-minute cron sweeps and forwards, so the row appears late rather than never | Wait out one sweep. If the outbox write failed too the request is genuinely gone, and the log says so: `Post-merge deploy request could not be made durable and is lost`. There is no row to retry, so merge again or deploy out of band |
| **No deployment row, and the merge went through `merge-batch`** | Expected. `merge-batch` (`src/routes/changes.ts:898`) never calls `enqueueMergeDeploy` — it runs no post-merge check and enqueues no deploy, by design: the batch path merges many changes in one push | **No post-merge recovery exists.** Approve and Retry both act on an existing row (`notFound("Deployment", id)` otherwise), and the deployments API has no route that creates one. Merge through the single-change path when you want a deploy |
| Status `skipped` | There was nothing configured to deploy at that commit — no policy file, or a policy with no `deploys:` entries | Check `.stratum/policy.yaml` is committed on the **default branch** and declares `deploys:`. `skipped` never means an error |
| `Missing project secret: X — add it in project settings` | The policy names a secret the store doesn’t have | Add it (step 3); names are case-sensitive |
| `DEPLOY_SECRET_KEY is not configured on this instance…` | Instance prerequisite missing | Step 1 |
| `Could not decrypt project secret… may have been rotated` | `DEPLOY_SECRET_KEY` changed after the secret was stored | Re-enter every secret for the project — there is no re-encryption path |
| `Policy file … is present but invalid` | YAML didn’t parse, or `evaluators:` is missing | Fix the file; note this also blocks merges |
| `Could not read the tree at ` | The merge commit is unreachable — force-push, auto-revert, or re-import | Merge again; the commit is pinned, so Stratum will not deploy a different tree |
| `403`/`401` from the provider | Token lacks permission, or is scoped to the wrong account/team | Cloudflare: **Workers Scripts ▸ Edit**. Vercel: set `VERCEL_TEAM_ID` for a team token |
| `503 DEPLOY_QUEUE_UNAVAILABLE` on Approve or Retry | No `DEPLOY_QUEUE` binding | Step 1 |
| `succeeded` but no URL on the row | `CLOUDFLARE_WORKERS_SUBDOMAIN` isn’t stored | Store it — the deploy itself worked |
| `succeeded` but the site is unchanged (Vercel) | `succeeded` means **accepted for build**; Stratum does not poll | Check the build in Vercel — a post-handoff build failure is invisible here |
## Before you start: your policy still needs `evaluators:`
[Section titled “Before you start: your policy still needs evaluators:”](#before-you-start-your-policy-still-needs-evaluators)
**A `.stratum/policy.yaml` that has a `deploys:` block but no top-level `evaluators:` array is treated as malformed, and a malformed policy blocks every merge in the project.** The policy parser requires `evaluators` to be present and to be an array; anything else is a parse failure, and a parse failure fails the merge gate closed:
> Policy file .stratum/policy.yaml is present but invalid (missing or non-array ‘evaluators’); merges are blocked until it is fixed.
That is intended behaviour for the merge gate, but it means “I only want deploys” is not a configuration you can write. If you have no opinion about evaluation, declare the cheap default explicitly:
```yaml
# The minimum viable deploy-only policy. `evaluators:` is REQUIRED — a policy
# file without it is malformed and blocks all merges.
evaluators:
- type: diff
deploys:
- name: site
target: cloudflare-pages
dir: public
```
The secret scanner runs on every change regardless of what this file says, so a lone `diff` evaluator is not “no gate at all”.
The same rule applies to the JSON form, `stratum.config.json`. Stratum reads `.stratum/policy.yaml` first and, only if it is absent, `stratum.config.json`. A file that is present but broken is an answer — Stratum does not fall through to the other one.
## How a deploy is triggered
[Section titled “How a deploy is triggered”](#how-a-deploy-is-triggered)
1. A change merges.
2. The post-merge smoke check runs (if the policy configures one). A failing check auto-reverts the merge.
3. **Only if the post-merge check did not revert or fail**, Stratum enqueues a deploy of the merge commit. A deploy is never triggered from the `change.merged` event, because that event fires before the check runs.
4. The deploy consumer reads the tree **at the pinned merge commit** and parses the policy out of *those* bytes. Configuration and code are therefore always from the same commit: a policy change that lands after your merge cannot retroactively change how your merge deploys.
5. One `deployments` row is created per `deploys:` entry, and each entry is run in turn.
Things that do **not** trigger a deploy:
* A merge that the post-merge check reverted.
* `POST /api/projects/{name}/changes/merge-batch` — batch merges run neither the post-merge check nor a deploy.
* A push straight to the project’s default branch (deploys hang off the change merge path).
* An instance with no `DEPLOY_QUEUE` binding configured.
Before it runs, the deploy re-reads the change’s status and the project’s deletion state. A change that was reverted between the merge and the deploy is refused, and so is a project that is being deleted.
## The `deploys:` configuration reference
[Section titled “The deploys: configuration reference”](#the-deploys-configuration-reference)
`deploys:` is a top-level list in `.stratum/policy.yaml`. Each entry is a mapping:
```yaml
deploys:
- name: marketing-site # required
target: cloudflare-pages # required
dir: dist # optional
secrets: # optional
- CLOUDFLARE_API_TOKEN
- CLOUDFLARE_ACCOUNT_ID
requiresApproval: false # optional, default false
```
| Field | Required | Rules |
| ------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | yes | `^[a-z][a-z0-9-]{0,31}$` — lowercase letters, digits and dashes, max 32 characters. It is this deploy’s stable identity across merges, and it is what supersession and the deployments list group by. Must be unique within the file. |
| `target` | yes | One of `cloudflare-pages`, `cloudflare-workers`, `vercel`. |
| `dir` | no | Repo-relative directory to publish. Must be relative (no leading `/`), contain no `..` segment and no null byte, max 255 characters. The prefix is stripped, so `dir: dist` publishes `dist/index.html` as `index.html`. Omit it to publish the whole tree. |
| `secrets` | no | Up to 16 secret names, each `^[A-Z][A-Z0-9_]{0,63}$`. Naming a secret here makes it **required**: a declared name that is not stored fails the deploy. You do not have to list the target’s own secrets — they are loaded either way. |
| `requiresApproval` | no | Boolean (a quoted `"false"` is rejected, not coerced). `true` holds the deployment at `pending_approval` until someone approves it. |
**A policy file may declare at most 16 `deploys:` entries.** Anything beyond the sixteenth is not run and is reported as a single `failed` deployment row naming how many were declared — siblings run sequentially inside one Worker invocation, so an unbounded list would be an unbounded merge.
**Unknown fields are rejected, not ignored.** An entry containing `requireApproval` (missing the “s”) is refused with a reason naming the field, rather than being silently rebuilt without it — which would turn a deploy you gated behind an approval into one that ships straight to production.
### A rejected entry becomes a visible failed deployment
[Section titled “A rejected entry becomes a visible failed deployment”](#a-rejected-entry-becomes-a-visible-failed-deployment)
Bad `deploys:` configuration does not block your merge, and it is not dropped in silence either: each rejected entry is recorded as a `failed` deployment row carrying the reason (`deploys[0]: unknown target "netlify" (expected one of cloudflare-pages, cloudflare-workers, vercel)`). A deploy that was written and never runs means production quietly stopped updating, so it is reported where you will see it — on the project’s Deployments page.
### The malformed-policy row is a post-merge fallback, not the merge block
[Section titled “The malformed-policy row is a post-merge fallback, not the merge block”](#the-malformed-policy-row-is-a-post-merge-fallback-not-the-merge-block)
A policy file that fails to parse at all produces one `failed` deployment row (named `(unresolved)`) pointing at the file. That row is **not** the merge gate reporting itself — it is a separate, later check, and the distinction matters because the two look at different commits:
* **Before the merge**, the gate parses the project’s *current* policy. A file that does not parse blocks the merge outright, and no deployment row exists because no merge happened.
* **After a merge**, the deploy runner re-parses the policy out of the *pinned merge commit’s* tree, so configuration and code always come from the same commit. If **that** commit’s policy is the unparseable one, there is nothing to deploy and nothing to name, so the failure is recorded as an `(unresolved)` row reading `Policy file .stratum/policy.yaml is present but invalid (…); no deploy configuration could be read from it.` rather than being dropped in silence.
The two reads happen at different times against different trees, so they can disagree — which is exactly when this row shows up. Treat it as “the commit that was deployed had a broken policy”, not as a second copy of the merge error. Fixing the project’s current policy unblocks merges; it does not retroactively change a row already recorded against an older commit, and it does not re-run that deploy. Retry it once the fix is merged.
## Targets and their secrets
[Section titled “Targets and their secrets”](#targets-and-their-secrets)
All three targets are first-class. Secrets are read from the project’s secret store by these exact names.
### `cloudflare-pages` — a static site on Cloudflare
[Section titled “cloudflare-pages — a static site on Cloudflare”](#cloudflare-pages--a-static-site-on-cloudflare)
| Secret | | Meaning |
| ------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------- |
| `CLOUDFLARE_API_TOKEN` | **required** | API token with Workers Scripts edit permission on the account. |
| `CLOUDFLARE_ACCOUNT_ID` | **required** | The account that owns the Worker. |
| `CLOUDFLARE_WORKER_NAME` | optional | Script name to publish as. Defaults to the deploy’s `name`. |
| `CLOUDFLARE_WORKERS_SUBDOMAIN` | optional | Your account’s `workers.dev` subdomain. Without it the deployment **succeeds with no URL** (see below). |
**This target is implemented against Workers Static Assets, not Pages Direct Upload.** The `target:` name is the user-facing name for “publish my static site on Cloudflare” and is unchanged, but the implementation uploads an asset manifest and publishes a Worker with an `assets` binding and no `main_module`. Two reasons: Pages Direct Upload’s asset endpoints (the upload-token, check-missing and asset-upload calls Wrangler drives) are not in Cloudflare’s public API reference, so implementing it would mean guessing at a surface that carries a production credential; and Cloudflare itself steers new projects to Workers rather than Pages. The practical consequences are that the deployment appears in your Cloudflare account as a **Worker**, not as a Pages project, and that Pages-specific features (preview branches, the Pages dashboard) do not apply.
Content types are set at upload time from the file extension, which is what visitors are served. An unrecognized extension is uploaded as `application/octet-stream`.
### `cloudflare-workers` — a Worker script
[Section titled “cloudflare-workers — a Worker script”](#cloudflare-workers--a-worker-script)
| Secret | | Meaning |
| ------------------------------- | ------------ | ----------------------------------------------------------------------------- |
| `CLOUDFLARE_API_TOKEN` | **required** | As above. |
| `CLOUDFLARE_ACCOUNT_ID` | **required** | As above. |
| `CLOUDFLARE_WORKER_NAME` | optional | Script name. Defaults to the deploy’s `name`. |
| `CLOUDFLARE_WORKER_MAIN_MODULE` | optional | Entry-point path within the deployed tree. |
| `CLOUDFLARE_WORKERS_SUBDOMAIN` | optional | Your `workers.dev` subdomain; without it the deployment succeeds with no URL. |
Only `.js` and `.mjs` files are uploaded, as ES modules. Everything else in the selected tree (a README, a lockfile) is skipped, and the count of skipped files is reported on the deployment. If no module is found, the deploy fails.
Without `CLOUDFLARE_WORKER_MAIN_MODULE`, the entry point is the first of `worker.js`, `worker.mjs`, `index.js`, `index.mjs`, `src/index.js`, `src/index.mjs` present in the tree; if none is, the deploy fails and names the candidates. When you do set it, it is validated against the tree — a typo fails with a reason instead of deploying some other file.
The upload sends a pinned `compatibility_date` (currently `2026-08-20`) rather than the current date, so redeploying the same commit cannot silently change its runtime behaviour.
### `vercel` — build and host on Vercel
[Section titled “vercel — build and host on Vercel”](#vercel--build-and-host-on-vercel)
| Secret | | Meaning |
| ------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------- |
| `VERCEL_TOKEN` | **required** | Vercel API token. |
| `VERCEL_PROJECT_ID` | **required** | The Vercel project to deploy into. |
| `VERCEL_TEAM_ID` | optional | Required in practice for a team-scoped token — without it the API resolves the token against your personal account. |
The whole tree is inlined (base64) into a single `POST /v13/deployments` with `target: production`, rather than uploaded file-by-file: one provider request instead of one per file, which is what the Worker’s subrequest budget can afford. Base64 inflates the payload by about a third, so the 25 MB tree limit becomes roughly a 33 MB request body — that is this target’s binding constraint.
**A `vercel` deployment recorded as `succeeded` means “accepted for build”.** Vercel answers as soon as it has queued the deployment (`readyState: QUEUED`) and builds asynchronously; Stratum does **not** poll for the result, because polling would hold a queue message open for the length of somebody else’s build. The provider’s state at hand-off and its deployment id are recorded in the row’s reason, e.g. `provider state at hand-off: QUEUED (the provider finishes asynchronously; Stratum does not poll it); provider deployment dpl_…`. A build that fails **after** hand-off is visible in Vercel, not in Stratum. If the API returns `ERROR`, `CANCELED` or `BLOCKED` outright, the deployment is recorded as `failed`.
### When a deployment has no URL
[Section titled “When a deployment has no URL”](#when-a-deployment-has-no-url)
The Cloudflare targets derive the deployment URL from `CLOUDFLARE_WORKERS_SUBDOMAIN`, because the account’s `workers.dev` subdomain is not part of the API surface these targets were written against and inventing a lookup for it would be a guess. **If you do not store that secret, the deploy still succeeds — it just records no URL.** Store it if you want a clickable link on the deployments page.
Note that `CLOUDFLARE_WORKERS_SUBDOMAIN` lives in the secrets table for uniformity, but it is not confidential: it is a public hostname component, written verbatim into the deployment’s `url`. Treat it as configuration that happens to be stored beside your credentials, not as a credential.
## Storing a secret
[Section titled “Storing a secret”](#storing-a-secret)
Secrets are per project, encrypted at rest (AES-GCM, with the project id and secret name bound as additional authenticated data), and there is **no read path** — no API, UI or CLI surface returns a stored value. Only the deploy runner decrypts them.
* **In the UI:** *Project → Settings → Deploy secrets*. Add by name and value; delete by name. Values are never rendered back.
* **Over the API:**
```bash
curl -X PUT https://app.usestratum.dev/api/projects/@acme/site/secrets/CLOUDFLARE_API_TOKEN \
-H "Authorization: Bearer $STRATUM_TOKEN" \
-H "Content-Type: application/json" \
-d '{"value":"…"}'
```
Rules:
* Names must match `^[A-Z][A-Z0-9_]{0,63}$`.
* Values are capped at 4096 bytes. The storage layer imposes no minimum, but an empty value is not useful: the settings form rejects it outright, and a target reads an empty required secret as missing and fails the deploy.
* Writing an existing name replaces it.
* Every write and delete is recorded in the audit trail (`secret.written`, `secret.deleted`).
**Only a project admin who is not an agent may manage secrets.** An agent token is refused on every secret route, even when the agent’s owner is the project owner: deploy credentials are the one thing an agent identity is never trusted with. Deleting the project deletes its secrets and its deployment history.
## The approval gate
[Section titled “The approval gate”](#the-approval-gate)
With `requiresApproval: true`, the merge creates the deployment at `pending_approval` and nothing is uploaded. Someone then presses **Approve** on the deployment page, or calls `POST /api/deployments/{id}/approve`; the row moves to `queued`, is enqueued, and runs.
Approving requires project **write** access and a **user** identity. An agent token (`stratum_agent_…`) sets no user id and is refused.
**Be precise about what that buys you.** The guarantee is “not an agent identity” — exactly the same guarantee, and the same limitation, as a human review verdict. A user’s scoped API token and an MCP OAuth grant both authenticate as the user and are both accepted. It is therefore **not** evidence that a human was at a keyboard: an automation running under your token can approve a deploy. If your threat model needs a real human, this gate does not provide one.
Approval is single-use and safe to double-click: the status flip is a conditional update, so a second approver gets `409 DEPLOYMENT_NOT_PENDING` rather than a second deployment of the same commit.
## Retry
[Section titled “Retry”](#retry)
A finished deployment can be re-run from the **Retry** button or `POST /api/deployments/{id}/retry`. This creates a *new attempt row* for the same commit with `attempt` incremented, and enqueues it; the original row is kept, so the history shows both.
* Only a terminal deployment (`succeeded`, `failed`, `superseded`, `skipped`) can be retried. Retrying a `queued`, `running` or `pending_approval` row is refused with `409 DEPLOYMENT_NOT_RETRYABLE` — for `pending_approval` in particular, allowing it would route straight around the approval gate, because a retry row starts `queued`.
* Retry requires project write access, and unlike approve it **does** accept an agent identity.
* The originating change id is carried onto the retry, so the “was this change reverted?” check still applies. Retrying a change that has since been reverted is refused.
* Two people retrying at once is safe: the second gets `409 DEPLOYMENT_RETRY_EXISTS`.
* A retry counts as the *newest* intent for that deploy name, even when the commit it re-runs is old — an operator re-running an earlier commit is asserting that they want it live.
Retry is also the closest thing to a rollback — see [Limitations](#limitations-read-this-before-you-rely-on-it).
## Deployment statuses
[Section titled “Deployment statuses”](#deployment-statuses)
| Status | Meaning |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pending_approval` | `requiresApproval: true` and nobody has approved it yet. Nothing has been uploaded. |
| `queued` | Waiting for the deploy consumer to pick it up. |
| `running` | A consumer holds a lease on the row and is uploading. |
| `succeeded` | The provider accepted the deployment. For `vercel`, this means **accepted for build**, not “live” — see above. |
| `failed` | Anything that went wrong, with a reason: a rejected `deploys:` entry, an unreadable tree, a missing or undecryptable secret, `DEPLOY_SECRET_KEY` unset, a limit exceeded, a provider 4xx/5xx, an unreachable provider. |
| `superseded` | A newer merge of the same deploy `name` took over. Either this row had not started when the newer one ran, or the newer one had already succeeded by the time this one reached the front of the queue — in which case this deployment is refused rather than published, so an out-of-order delivery cannot put the older commit back into production. |
| `skipped` | **Only one thing:** there was nothing configured to deploy — no policy file at that commit, or a policy with no `deploys:` entries. Every operator error is `failed`, never `skipped`. |
`succeeded`, `failed`, `superseded` and `skipped` are terminal.
A deploy that hits a provider error also stores a **log tail**: the provider’s response, with every secret value substituted out, truncated to 16 KiB. Redaction always runs on the full text before truncation, so a secret cannot survive by straddling the cut. The log tail is served **only to project writers** — never on the public read path, and never in a list response for a reader who cannot write.
Deployments also emit events you can subscribe to with a project webhook: `deployment.requested`, `deployment.succeeded`, `deployment.failed`.
## Limits
[Section titled “Limits”](#limits)
Every limit is checked **before the first provider request goes out** — a deploy that uploaded half a tree and then failed would leave the site in a state nobody asked for. Limits are applied to the tree *after* `dir` narrowing, so they judge the bytes actually being uploaded.
| Limit | Value |
| ------------------------------------------- | ----------- |
| Files per deployment | 2,000 |
| Total bytes per deployment | 25 MiB |
| Bytes per file | 10 MiB |
| Provider HTTP requests per deployment | 2,048 |
| Stored log tail | 16 KiB |
| Deployment lease / queue visibility timeout | 15 minutes |
| Secret value | 4,096 bytes |
| Secrets named per deploy entry | 16 |
| `deploys:` entries per policy file | 16 |
Exceeding one is a `failed` deployment naming the number, e.g. `too many files: 2431 exceeds the 2000-file deploy limit`. An empty selection is also a failure (`no files found under "dist" at this commit — check the deploy's "dir"`), not a silent no-op.
Sibling deploys from one merge run **sequentially**, not concurrently: they share one Worker invocation’s CPU, memory and subrequest budget.
## Operating a Stratum instance with deploys enabled
[Section titled “Operating a Stratum instance with deploys enabled”](#operating-a-stratum-instance-with-deploys-enabled)
Self-hosters need three things beyond the default deployment:
1. **`DEPLOY_SECRET_KEY` must be set as a Wrangler secret.** It is the master key every project secret is derived from. Without it, storing a secret fails and every deploy fails with `DEPLOY_SECRET_KEY is not configured on this instance, so deploy secrets cannot be decrypted.`
```bash
wrangler secret put DEPLOY_SECRET_KEY --env production
```
**Rotating it makes every stored secret undecryptable.** There is no re-encryption path: after a rotation, deploys fail with `Could not decrypt project secret…` and every project must re-enter its values.
2. **The queues must exist**, and the `DEPLOY_QUEUE` binding must be configured. Without the binding, merges simply never enqueue a deploy, and approve/retry answer `503 DEPLOY_QUEUE_UNAVAILABLE`.
```bash
wrangler queues create stratum-deploys
wrangler queues create stratum-deploys-dlq
```
3. **The dead-letter queue has no consumer.** `stratum-deploys-dlq` is configured as the DLQ for `stratum-deploys`, but nothing reads it: a message that exhausts its retries lands there and **sits for manual inspection**. Nothing alerts, and no deployment row is written by the DLQ itself. This is a deliberate v1 choice, not an oversight — but it means “the queue is healthy” is something an operator has to check, not something Stratum reports.
In practice a message only reaches the DLQ for an *indeterminate* failure (D1 or KV unavailable, the project unreadable). A failed deployment is a result, not a delivery failure, and is acked with a `failed` row rather than retried; a malformed message is acked and logged.
The staging environment uses `stratum-deploys-staging` and `stratum-deploys-dlq-staging` so its messages can never be handed to the production consumer.
## API
[Section titled “API”](#api)
| Route | Access |
| ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/projects/{namespace}/{slug}/secrets` | Project admin, agents refused. Names and metadata only — never values. |
| `PUT /api/projects/{namespace}/{slug}/secrets/{name}` | Project admin, agents refused. |
| `POST /api/projects/{namespace}/{slug}/secrets` | Form-friendly create/replace (`name`, `value` in the body). |
| `DELETE /api/projects/{namespace}/{slug}/secrets/{name}` | Project admin, agents refused. |
| `POST /api/projects/{namespace}/{slug}/secrets/{name}/delete` | Form-friendly delete. |
| `GET /api/projects/{namespace}/{slug}/deployments` | Anyone who can read the project. `logTail` is included only for writers. Filters: `name`, `status`, `limit` (default 50, max 200), `offset`. |
| `GET /api/deployments/{id}` | Anyone who can read the deployment’s project; unknown or unreadable ids answer 404 alike. |
| `POST /api/deployments/{id}/approve` | Project writer, **user identity required**. |
| `POST /api/deployments/{id}/retry` | Project writer; agents allowed. |
In the UI, deployments live at `/{namespace}/{slug}/deployments`, and secrets under *Settings*.
Full request and response schemas are in the [OpenAPI specification](/reference/openapi/); see also [the endpoint reference](/reference/endpoints/).
## Limitations (read this before you rely on it)
[Section titled “Limitations (read this before you rely on it)”](#limitations-read-this-before-you-rely-on-it)
These are design boundaries of v1, not bugs:
* **No build step.** Stratum deploys the tree **exactly as committed**. There is no `npm install`, no bundler, no framework build. If your site needs a build, commit the built output (and point `dir:` at it) or use a provider that builds for you — `vercel` builds the uploaded source remotely, the two Cloudflare targets do not.
* **No preview deploys.** Only merged commits deploy. There is no way to publish an unmerged change for review.
* **No rollback.** There is no “revert to the previous deployment” button. The recovery path is to **retry an earlier successful deployment** of the same commit, or to merge a revert change and let that deploy. Neither is instantaneous.
* **Netlify is not supported.** The three targets above are the whole list.
* **The approval gate refuses agent identities, not automation.** It accepts a user’s scoped API token and MCP OAuth grants. See [The approval gate](#the-approval-gate).
* **A deploy-only project still needs an `evaluators:` block**, or every merge is blocked. See [the top of this page](#before-you-start-your-policy-still-needs-evaluators).
* **Secret redaction is literal-substring matching.** A provider that echoes a credential back in some encoded or transformed form (URL-encoded, hashed, split across a JSON boundary) would not be caught, which is part of why the log tail is writers-only.
* **No per-project deploy quota.** Nothing rate-limits how often a project deploys beyond the limits table above.
* **Deployment history has no retention policy.** Rows accumulate until the project is deleted.
## See also
[Section titled “See also”](#see-also)
* [CI Integration (Bring Your Own CI)](/guides/ci-integration/) — what Stratum does and does not execute
* [Getting Started](/guides/getting-started/) — the policy file and the merge gate
* [FAQ](/guides/faq/)
# FAQ
> Common questions about Stratum's merge gate, provenance, CI, limitations, and telemetry.
## What is Stratum?
[Section titled “What is Stratum?”](#what-is-stratum)
The open-source, agent-first code forge, where humans and AI agents are both first-class citizens. It hosts git repositories (on Cloudflare Artifacts), and every proposed change — whether a human or an agent wrote it — passes through policy-defined evaluation gates before it can merge. Merged changes carry provenance (which agent, which model, which prompt) and cost records.
## How is this different from GitHub branch protection and required checks?
[Section titled “How is this different from GitHub branch protection and required checks?”](#how-is-this-different-from-github-branch-protection-and-required-checks)
GitHub branch protection gates on external check *statuses*; Stratum runs the evaluation itself, treats agents as first-class identities, and enforces invariants GitHub doesn’t have:
* **A secret scan is always on and always blocking** — not a configurable check someone can remove.
* **Approvals are human-only by construction.** An agent token cannot approve any change on any surface (API, CLI, MCP). On GitHub, a bot with the right permissions can approve and merge.
* **Staleness is enforced against what was evaluated**: a merge is rejected if the workspace advanced after evaluation (`409 STALE_WORKSPACE`), so you can never merge commits the evaluators didn’t see. `requireFreshBase` additionally rejects merges when the base moved (`409 STALE_BASE`).
* **Force-merge is deny-by-default** — the override only exists if the policy explicitly sets `merge.allowForce: true`.
* **Post-merge smoke with auto-revert**: a configured command runs against the merged HEAD, and a failure automatically lands a forward revert commit. This one needs the Cloudflare Sandboxes beta, which is off by default everywhere (see the next answer); without the binding the command is skipped with a warning.
* **Provenance and cost are recorded per merged change** — model, prompt hash, eval score, LLM tokens, sandbox time.
* A malformed `.stratum/policy.yaml` **fails closed** (blocks merges) instead of silently falling back to defaults.
## Does Stratum replace GitHub Actions?
[Section titled “Does Stratum replace GitHub Actions?”](#does-stratum-replace-github-actions)
No. Stratum has no native CI runner — no workflows, hosted runners, matrix builds, artifacts, caching, scheduled jobs, deployment environments, or status-check aggregation (it does not collect external CI check results the way a GitHub PR’s checks tab does). Its code execution is limited to the evaluation pipeline: the sandbox evaluator, the `webhook` evaluator (a synchronous call-out to CI *you* host, which must answer within the request timeout — default 10s), and the post-merge smoke command run in a sandbox.
Of those, **only the webhook evaluator works out of the box.** Both sandbox paths need the Cloudflare Sandboxes beta, and `[[sandboxes]]` is commented out in `wrangler.toml` — so the hosted instance and any fresh self-host have no `SANDBOX` binding. Without it the sandbox evaluator does not skip, it **fails closed** (score 0 / failed), and naming `sandbox` in `merge.requiredEvaluators` therefore blocks every merge in that project; `merge.postMergeCommand` is skipped with a warning instead. If you want tests gating merges today, bring your own CI and wire it in via the webhook evaluator, or use layer mode and keep running GitHub Actions on the promoted PRs. See [CI Integration](/guides/ci-integration/).
Two things on that list have since arrived in a narrow form. Stratum now has an **encrypted per-project secret store** — but it is deploy-only: the deploy runner is its sole reader, and the webhook evaluator’s `secret` still lives literally in the policy file. And it can **deploy the merged tree** to Cloudflare or Vercel from a `deploys:` block, with an optional approval gate and a retry. That is not deployment environments: there is no staging/production separation, no per-environment variables, no build step, no preview deploy, and no rollback. See [Deployments](/guides/deployments/).
## Do I have to leave GitHub to use it?
[Section titled “Do I have to leave GitHub to use it?”](#do-i-have-to-leave-github-to-use-it)
No. Stratum supports two modes with the same codebase. In **layer mode**, Stratum sits between your agents and GitHub: import the repo, enable bidirectional sync (inbound webhooks, outbound PR promotion), and agent work goes through Stratum’s gates while your team keeps reviewing GitHub PRs — each evaluation of a change with a linked PR posts the verdict to the PR as a comment and a `stratum/evaluation` commit status. In **forge mode**, Stratum is the source of truth and GitHub isn’t involved at all. You choose the level of buy-in, and you can start with layer mode.
## Do I need a GitHub account at all?
[Section titled “Do I need a GitHub account at all?”](#do-i-need-a-github-account-at-all)
No. Email magic-link authentication is the recommended sign-in and has no external dependencies. GitHub OAuth (needed for GitHub sync) and Google OAuth are alternatives; all resolve to the same email-based identity, and all three ask you to choose your username when the account is first created.
## What happens when an evaluation fails?
[Section titled “What happens when an evaluation fails?”](#what-happens-when-an-evaluation-fails)
The change is created anyway, with each evaluator’s verdict, findings, and score recorded as evidence you can inspect (`stratum change show ` or the UI). What a failure blocks is the **merge**: any evaluator type listed in `merge.requiredEvaluators` must have a passing latest run. To recover, push new commits to the workspace — re-evaluation runs against the new state — or, if your policy allows it (`allowForce: true`, off by default), force-merge past it. The reference agent exits with code `2` in this case so CI wrappers can distinguish “agent produced rejected work” from operational errors.
## Can agents approve or merge their own code?
[Section titled “Can agents approve or merge their own code?”](#can-agents-approve-or-merge-their-own-code)
Agents can never **approve** — reviews are human-only, everywhere, with no configuration that relaxes it. An agent cannot trigger the *merge* either — `POST /api/changes/{id}/merge` (and merge-batch) requires a user identity, so a merge call with an agent token is refused with `401` regardless of policy. The human in the loop presses merge; what `merge.requiredApprovals` controls is how many approvals from humans *other than the change’s author* must exist before that press succeeds. The merger themselves can be one of those approvers — the only excluded identity is the author (for an agent-created change, the agent’s owning user). Set it to 1 or more on anything you care about, so no single person can both own the agent and wave its work through unreviewed.
## What does provenance actually record?
[Section titled “What does provenance actually record?”](#what-does-provenance-actually-record)
Per merged change: the author identity (human or agent), the model and a hash of the prompt snapshotted at change creation, and the evaluation score, per merged commit. Full per-evaluator evidence (scores, findings, durations) is linked by change. The schema also has room for model config, reasoning traces, and tool calls where the client supplies them. Agents authenticate with their own tokens, bounded by an owning user’s access, so work is attributed to the agent, not laundered through a human account.
## What does it cost, and what is metered?
[Section titled “What does it cost, and what is metered?”](#what-does-it-cost-and-what-is-metered)
The software is free software (see the licensing question below) and costs nothing; self-hosted, you pay only for the Cloudflare resources you use. The full binding list is in the [README](https://github.com/stratum-eng/stratum#prerequisites) — Workers, Artifacts, D1, KV, Queues, Durable Objects, R2 and Analytics Engine, plus Workers AI for the `llm` evaluator and Sandboxes for the `sandbox` evaluator **and** `merge.postMergeCommand`. Stratum meters estimated resource usage per change — LLM tokens, sandbox execution milliseconds, and git operations — and shows it alongside the evaluation evidence, so you can see what each (agent) change cost you. The hosted instance has open signup and is free while billing and multi-tenancy for a managed offering (“Stratum Cloud”) are planned but not built.
## What are the current limitations?
[Section titled “What are the current limitations?”](#what-are-the-current-limitations)
Honestly, several:
* **Diff limits**: diffs are hunk-level with unified and side-by-side views, but binary files are not diffed and very large files are rendered whole.
* **Scale**: git operations run in-memory inside a Worker, so very large repositories will hit Worker limits. Moving git ops off the Worker is on the roadmap.
* **Push to project remotes doesn’t move `main` directly** — the push is rejected in-protocol with the reason (and, where gated push is enabled, opens an eval-gated change instead). Push to workspace remotes for direct writes.
* **Authorization** is still minimal in places; project-level access control is not enforced on every route. One deliberate GitHub-parity choice worth knowing: any authenticated user can **open** an issue on a public project (rate-limited), while editing or closing issues requires project write access.
* **Evaluation runs synchronously** at change creation — there’s no async evaluation queue yet, so very slow evaluators stretch the request.
* **No Git LFS**: see [Does Stratum support Git LFS?](#does-stratum-support-git-lfs) below.
* **Git submodules are not supported.** A gitlink entry at any depth, or a root-level `.gitmodules` file, is rejected — with a clear error — whenever a change is created, on a gated push and through the REST API alike, rather than risk a server-side merge silently corrupting it. The same check runs on import, but there it is best-effort: an import whose tree can’t be read proceeds unscanned with a warning. See [Importing from GitHub](/guides/importing/#unsupported-content).
See [`docs/CURRENT_CAPABILITIES.md`](https://github.com/stratum-eng/stratum/blob/main/docs/CURRENT_CAPABILITIES.md) for the authoritative, current state.
## Does Stratum support Git LFS?
[Section titled “Does Stratum support Git LFS?”](#does-stratum-support-git-lfs)
No. There is no LFS server: the git smart-HTTP router exposes only `info/refs`, `git-upload-pack`, and `git-receive-pack` — there is no `/info/lfs` route and no `objects/batch` endpoint, so `git lfs` clients get a `404 Not found` when they call the batch API and an LFS-enabled clone fails at that point. Combined with the 50 MB cap on git push request bodies, large-binary workflows are effectively blocked. Keep binaries out of Stratum-hosted repos, or keep LFS-dependent repos on GitHub and use layer mode. See [`docs/CURRENT_CAPABILITIES.md`](https://github.com/stratum-eng/stratum/blob/main/docs/CURRENT_CAPABILITIES.md) and the implementation sketch in [`docs/REMAINING_WORK.md`](https://github.com/stratum-eng/stratum/blob/main/docs/REMAINING_WORK.md).
## Can I use plain `git` with Stratum?
[Section titled “Can I use plain git with Stratum?”](#can-i-use-plain-git-with-stratum)
Yes, over smart HTTP with your API key as the HTTP Basic password (enter it at the prompt or store it with a git credential helper — don’t embed it in the URL): `git clone https:///@ns/slug.git` for projects (read), and `.../@ns/slug/workspaces/.git` for workspaces (read **and** `git push`). A push to the project URL is rejected in-protocol so the evaluation gate can’t be bypassed. SSH transport is not supported (Workers have no raw TCP listener).
## What do I need to self-host?
[Section titled “What do I need to self-host?”](#what-do-i-need-to-self-host)
Node.js 22.13+ and your own Cloudflare account with Workers, **Artifacts (which is in beta — you need access to it)**, D1, KV, Queues, and Durable Objects. Optional: the Workers AI binding for the LLM evaluator, R2 for backups, and Cloudflare Email for magic links. Sandboxes — for the `sandbox` evaluator and `merge.postMergeCommand` — is a gated beta whose `[[sandboxes]]` binding is commented out in `wrangler.toml`; uncomment it in every `[env.*]` block you deploy once your account has access, and until then keep both features out of your policy, since the evaluator fails closed (and a `requiredEvaluators` entry for it blocks all merges). The [README Quick Start](https://github.com/stratum-eng/stratum/blob/main/README.md#quick-start) covers secrets, migrations, and deployment; keep production and staging in separate Artifacts namespaces.
## What license is Stratum under, and what does self-hosting oblige me to?
[Section titled “What license is Stratum under, and what does self-hosting oblige me to?”](#what-license-is-stratum-under-and-what-does-self-hosting-oblige-me-to)
Licensing is by directory: the server, web UI, `docs/`, and `website/` sources are **AGPL-3.0-or-later**; the `@stratum-eng/cli` and `@stratum-eng/agent` packages are **Apache-2.0**; the published agent skills under `website/public/.well-known/agent-skills/` stay **MIT**, because they exist to be pasted into third-party agents. Releases through v0.2.0 were MIT and stay MIT. [`LICENSING.md`](https://github.com/stratum-eng/stratum/blob/main/LICENSING.md) is authoritative and explains each choice.
Running an **unmodified** Stratum obliges you to nothing at all — no notice, no publication. If you **modify** Stratum and other people reach your instance over a network (your own developers count), AGPL §13 asks you to offer those users the source of the version you are running. Point `STRATUM_SOURCE_URL` in `src/version.ts` at the repository holding your changes and redeploy; the page footer and the `X-Source-Code` header on every API and `/mcp` response then carry the offer. That is the source-offer mechanism — the part Stratum handles for you — rather than the whole of the license: modifying Stratum also carries §5’s obligations to mark your changes and preserve the legal notices.
Nothing in the license reaches your own work. The repositories Stratum hosts, the changes it evaluates, your `.stratum/policy.yaml`, your custom evaluators consumed over the CI integration, and anything that talks to the REST API, the CLI, or `/mcp` are separate works under whatever license you choose. Commercial use, including running Stratum as a paid service, is permitted under the AGPL.
## How do backups work?
[Section titled “How do backups work?”](#how-do-backups-work)
Backups require the **optional R2 bucket binding** — without it the scheduled backup logs a warning and skips the run, so configure R2 if you want backups. With R2 configured, D1 (changes, issues, events, costs, audit) and KV identity data back up daily and on demand, along with the reachable history of a rotating slice of repositories — coverage rotates across runs under a per-run cap, so every repo is covered over time rather than every run. There is a tested restore path, documented in [`docs/runbooks/backup-restore.md`](https://github.com/stratum-eng/stratum/blob/main/docs/runbooks/backup-restore.md).
## Can I turn off telemetry?
[Section titled “Can I turn off telemetry?”](#can-i-turn-off-telemetry)
Yes, at two levels.
**Per account.** Open **Settings → Privacy** and clear *Send anonymous usage analytics*. That stops future events for your account and for any agent token you own — an agent inherits its owner’s choice, so routing traffic through an agent does not re-enable it. The change takes effect on your next request. It is not retroactive: events already sent are not deleted.
**Browser analytics is separate, and off unless you turn it on.** Everything described above is sent by the server. A second, independent switch — `POSTHOG_PUBLIC_KEY` — additionally loads PostHog’s JavaScript SDK in your users’ browsers. The documentation site is built separately and reads the same variable at build time, so it is set in both places if you run both. It is a distinct key on purpose: enabling server-side telemetry has never meant running third-party code in your users’ browsers or sending their IP addresses anywhere, and setting `POSTHOG_API_KEY` still does not. If it is unset, no analytics script is served, to anyone. It must be a PostHog **project** key (`phc_…`); anything else is refused rather than published, because the value appears in every page of HTML and a personal API key (`phx_…`) pasted there would be a credential leak.
**Per instance.** Analytics only runs at all if you set a `POSTHOG_API_KEY`, and self-hosted instances can set `STRATUM_TELEMETRY_DISABLED = "true"` in `wrangler.toml` to switch it off for everyone. Declare it in **each** `[env..vars]` block you deploy — named environments do not inherit top-level `[vars]`, so a top-level-only setting never reaches `wrangler deploy --env=production`. The instance switch always wins over an individual account’s preference.
### What exactly is sent
[Section titled “What exactly is sent”](#what-exactly-is-sent)
Every property on every event is either a **source-code literal** (a route pattern, an event type, a tool name) or a **bounded value** (a status code, a duration, a score, an enum). Names, paths, URLs, titles, refs, diffs, file contents, and request payloads are never sent. The one exception is noted against `mcp_request` below.
The list is exhaustive — `src/analytics/events.ts` is its single source of truth, and a test fails the build if this table and that file disagree.
| Event | Sent when | Properties |
| -------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------ |
| `api_request` | Once per request that matched a route | `method`, `route`, `status`, `latency_ms`, `surface`, `actor_type` |
| `mcp_request` | Once per JSON-RPC message handled at `/mcp` | `mcp_method`, `outcome`, `tool`, `client_name`, `client_version`, `protocol_version` |
| `auth_completed` | Once per completed sign-up or sign-in | `kind` (`signup`/`signin`), `provider` (`github`/`google`/`email`) |
| `error_occurred` | Once per unhandled exception | `route`, `method`, `error_type` |
| `background_job_completed` | Once per background job reaching a terminal state | `job`, `outcome`, `attempts` |
| `stratum.` | Once per repository activity (a change opening, a merge, a deploy) | `project_id`, `actor_type`, plus the per-type properties below |
If you have also set `POSTHOG_PUBLIC_KEY`, the SDK sends these from the browser. They are produced by PostHog’s code, not Stratum’s, so the guarantees below describe how we configure it rather than what we choose to emit:
| Event | Sent when |
| --------------- | ----------------------------------------------------------------------- |
| `$pageview` | A page is loaded |
| `$pageleave` | A page is left |
| `$autocapture` | A link, button, or form control is interacted with |
| `$rageclick` | The same spot is clicked repeatedly, if your PostHog project enables it |
| `$identify` | A signed-in user is associated with their account |
| `$set` | Person properties are recorded, as part of identifying |
| `$create_alias` | A pre-sign-in anonymous session is linked to the account |
| `ui_click` | An instrumented control is clicked. Carries one property, `element` |
Every event additionally carries `environment` — the label the operator set in `STRATUM_ENVIRONMENT`, so staging traffic can be told apart from production — and `$lib_version`, the Stratum version that sent it, so a change in a metric can be tied to the release that caused it. An event caused by an agent also carries `agent_id`.
One person property is ever set: `signup_provider`, recorded once when an account is created. It is what lets a question like “accounts that signed up with GitHub in August” be asked at all. Your email address, username, and display name are never sent.
The per-type properties on `stratum.*` events are the whole reason they are worth collecting, and they are whitelisted per event type:
| Event type | Extra properties |
| -------------------------- | ----------------------------------------------------------- |
| `stratum.change.evaluated` | `score`, `passed` |
| `stratum.change.reviewed` | `verdict` (`approve`/`request_changes`/`comment`) |
| `stratum.project.imported` | `source_provider` (`github`/`gitlab`/`bitbucket`/`other`) |
| `stratum.issue.closed` | `linked_change` (boolean) |
| `stratum.deployment.*` | `target` (`cloudflare`/`vercel`), `linked_change` (boolean) |
Everything else in an event’s payload is dropped: workspace names, commit shas, issue titles, import URLs, and deployment failure text all stay on your instance. An event type not listed above contributes no extra properties at all.
### What the browser sends, and what it does not
[Section titled “What the browser sends, and what it does not”](#what-the-browser-sends-and-what-it-does-not)
**Everything in this section describes the Stratum application.** The documentation site is a separate deployment with separate rules, described after it — its pages are published documentation with no accounts and nothing private, so it deliberately does not redact what the application must.
Browser analytics on the application is held to the same promise as the server, but it takes more work to keep, because PostHog’s SDK collects by default and this app’s URLs and page titles are made of the things the promise forbids.
* **The same route patterns.** The server hands the SDK the route pattern it matched, and every URL-bearing property is rewritten to it before the event leaves the browser. `$current_url` becomes `https://your-host/:namespace/:slug`. Query strings, refs and `#fragment` anchors go with it.
* **Page titles are dropped**, because this app’s titles contain repository, file, and issue names.
* **Referrers are dropped**, not rewritten. The referrer is the *previous* page’s URL, and the route pattern describes the current one, so rewriting it would be silently wrong rather than merely absent. The referring **domain** is kept, since it has no path and is what makes acquisition answerable.
* **Anything unrecognised is dropped.** The filter is an allowlist: a property a future SDK release introduces is removed unless it has been reviewed and named. It cannot leak by being new.
* **Clicked elements are recorded without their text or attributes.** Otherwise a click on a repository link would send the repository name as link text and its path as an `href`. You therefore see that a file link was clicked, not which file.
* **`ui_click` names the control, and only from a fixed vocabulary.** Because of the masking above, `$autocapture` can say a button was clicked but never which one, so navigation and the main actions carry a `data-ph` attribute whose value is written literally in Stratum’s own source — `nav-settings`, `project-tab-deployments`. `ui_click` reports that value in `element` and nothing else, and the browser drops any value that is not a plain `[a-z][a-z0-9-]*` token, so a name assembled from a repository or file name cannot be reported even by mistake. This is the only event on this page Stratum sends itself rather than configuring PostHog to send.
* **No feature flags are requested.** The SDK’s flags call is not a captured event, so the redaction above cannot reach it — and PostHog builds its body from stored properties that include the first URL of the session. Stratum reads no flags in the browser, so the call is switched off entirely rather than trusted.
* **No Core Web Vitals or dead-click tracking.** Both are implemented in code posthog-js downloads at runtime, and Stratum blocks runtime downloads so the only script your users receive is the pinned one served from your origin. The trade is deliberate: unpinned third-party code on pages that render private repositories is worse than a missing metric.
* **No session replay.** Stratum does not record sessions. It renders private source, and no masking configuration makes recording it a good idea.
* **Do Not Track is respected**, and so is Global Privacy Control — the SDK checks `navigator.doNotTrack`, `navigator.msDoNotTrack`, `window.doNotTrack` and `navigator.globalPrivacyControl`. This is the only control a signed-out visitor has, since the per-account setting needs an account.
* **IP addresses.** This is the one thing the browser sends that the server never did. PostHog derives approximate location from it. Requests are proxied through your own instance at `/_ph/*`, so PostHog sees your Worker rather than your users — and the client IP is forwarded in a header, because without it every user appears to be in a Cloudflare datacentre. If that trade is wrong for your instance, leave `POSTHOG_PUBLIC_KEY` unset.
* **Why the requests go through your instance.** The SDK and its events are served from your own origin rather than PostHog’s. It keeps the script under your Content-Security-Policy, lets it be version-pinned and cached, and means content blockers do not silently bias your numbers toward users who do not run one. That last reason is a deliberate choice and is stated here rather than left for you to discover in `src/routes/posthog-proxy.ts`. The proxy forwards only PostHog’s ingestion paths, strips your session cookie and `Referer` before forwarding, and refuses anything else. It is inert unless `POSTHOG_PUBLIC_KEY` is set, so an instance that never turned browser analytics on is not running a relay, and it refuses a beacon from an account that has opted out — which is what stops a browser tab opened *before* you opted out from continuing to report. That account check applies to the application only; the documentation site has no accounts.
### What the documentation site sends
[Section titled “What the documentation site sends”](#what-the-documentation-site-sends)
`docs.usestratum.dev` is built and deployed separately, and it is instrumented separately. The differences are deliberate:
* **URLs are sent as they are**, not rewritten to route patterns. Which documentation page someone read is the entire question there, and every one of those URLs is public.
* **Clicked link text and attributes are not masked**, for the same reason: they are published documentation, so masking them would cost the answer and protect nothing.
* **There is no account**, so there is no per-account opt-out to honour and no user is ever identified. `respect_dnt` is the only control, and it is on.
* **No session replay**, the same as the application.
* It is off unless `POSTHOG_PUBLIC_KEY` is present at build time, so a fork building these docs ships nothing.
### The details worth knowing
[Section titled “The details worth knowing”](#the-details-worth-knowing)
* **Route patterns, never URLs.** `api_request` reports `/:namespace/:slug/files`, not the path that was actually requested, so namespaces, repo slugs, change ids, and file paths are not sent. A request that never reached a registered route is captured with `route: "*"`; a 404 is excluded entirely rather than captured as `"*"`.
* **`surface`** says which part of Stratum served the request — `api`, `ui`, `git`, `mcp`, `auth`, `admin`, or `internal`. It is derived from the route pattern, so it inherits the same guarantee.
* **`mcp_request` carries the one free-text field.** `client_name` and `client_version` are whatever the connecting software calls itself in the MCP handshake, capped at 64 characters. They answer “which editors and agents connect to Stratum”. Everything else on the event is bounded: `tool` and `mcp_method` are reported only when they name a tool or a JSON-RPC method this build actually implements, and anything else — both are strings a client can put anything in — is reported as `unknown` rather than echoed back.
* **`error_occurred` never carries the exception message.** Messages quote their input. Only the error’s type name (`TypeError`) is sent; the message stays in your own Workers logs.
* **Project ids, never project names.** A name identifies private source as surely as a repo slug in a URL does. The opaque id groups events just as well.
Events also carry identity attribution: the `distinctId` is the acting user’s id (or `server` for unattributed requests, and `system` for events no person caused — both marked personless) so usage can be counted per account.
**An agent is attributed to the person who owns it**, not to itself. An agent token is a credential you minted, acting under your account — your opt-out already governs it, so your identity is what “who did this” means. The agent’s own id rides along as `agent_id`, so the two stay distinguishable without an agent counting as a separate person.
Domain events are dated from when the activity happened, not from when the queue got round to exporting them.
## Where are my invite codes?
[Section titled “Where are my invite codes?”](#where-are-my-invite-codes)
Open **Settings** (the *settings* link in the header) and look under *Account*. If your account was issued invite codes, they are listed there with a share link for each one and whether it has been redeemed yet — codes you can still give away are marked *Available*. (`/profile` still redirects there.)
Codes are issued only on an instance running the optional closed-beta gate, and only to accounts that joined through it. If you signed up while signups were open, you have no codes and the page says so. The hosted instance (`app.usestratum.dev`) has open signup, so most accounts there hold none.
Send someone the share link rather than the bare code: it carries `?ref=`, which fills the code in for them on the signup form. Each code admits one person, and a redeemed code cannot be reused.
The page also stays useful after the gate is switched off — codes minted while it was on remain listed and redeemable, so the listing keys off the invite service being configured rather than off the gate. If the page says your codes could not be loaded, that is a temporary problem reaching the invite service; nothing has been lost, and a reload once it recovers shows them again.
## What if my policy file has a mistake in it?
[Section titled “What if my policy file has a mistake in it?”](#what-if-my-policy-file-has-a-mistake-in-it)
A present-but-malformed `.stratum/policy.yaml` fails closed: the merge gate blocks until the file parses, rather than silently running on defaults. This is deliberate — a typo in a stricter policy can’t quietly downgrade governance. Fix the YAML and re-evaluate.
## How do I get help?
[Section titled “How do I get help?”](#how-do-i-get-help)
Open an issue on the GitHub repository, or start with the [getting started guide](/guides/getting-started/), [troubleshooting](/guides/troubleshooting/), and the [API reference](/reference/openapi/).
# Getting started
> From sign-up to a merged, evaluation-gated change — policy, agent identity, and the change flow.
This guide walks a new team from zero to a merged, evaluation-gated change. By the end you will have a project, a merge policy, a registered agent identity, and the CLI and MCP server connected to your tools.
Stratum is the open-source, agent-first code forge, where humans and AI agents are both first-class citizens. Every proposed change — human or agent — passes through the same evaluation gates before it can merge, and every merged change carries a provenance record of who (or what model) produced it.
## 1. Sign up or self-host
[Section titled “1. Sign up or self-host”](#1-sign-up-or-self-host)
### Hosted
[Section titled “Hosted”](#hosted)
The hosted instance runs at `https://app.usestratum.dev` with **open signup** — no invite code or waitlist. (If you hit an invite prompt, you’re on an instance whose operator has enabled the optional closed-beta gate.)
Sign in with any of:
* **Email magic link** (recommended — no external accounts needed): enter your email at `/auth/email` and click the link you receive.
* **GitHub OAuth** at `/auth/github` — required later if you want bidirectional GitHub sync.
* **Google OAuth** at `/auth/google`.
All three resolve to the same email-identity account, so you can mix methods. Every method asks you to choose a **username** the first time: it is your namespace (`@you/project` in every URL and clone URL). You can change it in Settings while you own no projects; while any exists it is fixed, because every project is keyed under it (after deleting your last project, allow up to 15 minutes before the form reappears). GitHub and Google sign-in suggest one from your handle or email address and let you pick another before the account exists. A separate display name, shown in the header, can be changed any time.
### Self-hosting
[Section titled “Self-hosting”](#self-hosting)
Stratum is AGPL-3.0-or-later and self-hostable on your own Cloudflare account. You need Node.js 22.13+ and a Cloudflare account with Workers, **Artifacts (beta)**, D1, KV, Queues, and Durable Objects. R2 is optional — it backs the scheduled backup run, which logs a warning and skips when `BACKUPS` is unbound (`src/backup/run-backup.ts:116`). The Workers AI binding is optional too, and only needed for the LLM evaluator. Sandboxes — needed by the `sandbox` evaluator and by `merge.postMergeCommand` — is a **gated Cloudflare beta**, and its `[[sandboxes]]` binding ships **commented out** in `wrangler.toml`, so neither the hosted instance nor a fresh self-host has it. Leave both features out of your policy until you have Sandboxes access and have uncommented the binding in every `[env.*]` block you deploy. Follow the [Quick Start in the README](https://github.com/stratum-eng/stratum/blob/main/README.md#quick-start) — everywhere this guide says `app.usestratum.dev`, substitute your own instance’s origin.
Self-hosting an unmodified Stratum obliges you to nothing. If you modify it and other people use your instance over a network, AGPL §13 asks you to offer them that version’s source — one constant, `STRATUM_SOURCE_URL` in `src/version.ts`, points both the page footer and the `X-Source-Code` response header at your repository. The code you *host in* Stratum is untouched by this.
## 2. Create or import a project
[Section titled “2. Create or import a project”](#2-create-or-import-a-project)
### Create a fresh project
[Section titled “Create a fresh project”](#create-a-fresh-project)
From the dashboard, click **New Project** and pick a name and visibility, or use the API:
```bash
curl -X POST https://app.usestratum.dev/api/projects \
-H "Authorization: Bearer stratum_user_xxxxx" \
-H "Content-Type: application/json" \
-d '{"name": "my-project", "visibility": "private"}'
```
Projects live under a namespace — `@your-username` for personal projects, or an org slug for org-owned projects. Org membership grants read access; the org owner/admin role or membership in a write/admin team grants write access.
### Import an existing repository
[Section titled “Import an existing repository”](#import-an-existing-repository)
Stratum imports from **GitHub, GitLab, and Bitbucket**. Imports run as background jobs, so large repositories don’t block the request:
```bash
curl -X POST https://app.usestratum.dev/api/projects/@you/my-project/import \
-H "Authorization: Bearer stratum_user_xxxxx" \
-H "Content-Type: application/json" \
-d '{"url": "https://github.com/your-org/your-repo", "branch": "main"}'
```
### Choose your level of buy-in: layer mode vs. forge mode
[Section titled “Choose your level of buy-in: layer mode vs. forge mode”](#choose-your-level-of-buy-in-layer-mode-vs-forge-mode)
You do not have to leave your current forge to use Stratum. The same codebase supports two modes:
* **Layer mode (minimal buy-in).** Stratum sits between your agents and GitHub. Import a GitHub repo and enable **bidirectional GitHub sync**: inbound webhooks keep the Stratum project current with pushes and PRs, and outbound sync promotes a Stratum change to a GitHub PR. Whenever a change with a linked PR is evaluated, the verdict is posted to the PR as a comment (edited in place on re-evaluation) and a `stratum/evaluation` commit status. Agents work through Stratum’s gates; your team keeps reviewing in GitHub PRs.
* **Forge mode (full buy-in).** Stratum is the source of truth for repos, workspaces, and changes. No GitHub required — email magic links mean no external accounts at all.
Start in layer mode if you’re evaluating; nothing about the change flow below differs between the modes.
## 3. Write your evaluation policy
[Section titled “3. Write your evaluation policy”](#3-write-your-evaluation-policy)
Merge gates are configured in a `.stratum/policy.yaml` file at the root of your repository. Commit it like any other file. Here is a realistic policy for a TypeScript service:
```yaml
# Evaluators live at the TOP level — not nested under any other key.
evaluators:
# Bound the blast radius of any single change. Patterns match FILE PATHS
# in the diff, not file contents.
- type: diff
maxFiles: 30
maxLines: 1000
forbiddenPatterns:
- "*.lock"
- "node_modules/"
- ".env"
# Call your existing CI system. The secret is used literally — there is
# no environment-variable interpolation in this file — and timeoutMs is
# capped at 120000 (2 minutes).
- type: webhook
url: "https://ci.example.com/evaluate"
secret: "a-long-random-string-you-generated"
timeoutMs: 120000
# Run the test suite in a Cloudflare Sandbox. LEFT COMMENTED ON PURPOSE:
# Sandboxes is a gated beta and its binding is off by default, and this
# evaluator does not skip when the binding is missing — it returns
# score 0 / failed. Uncomment only once [[sandboxes]] is enabled.
# - type: sandbox
# command: "npm test"
# timeoutMs: 120000
# totalBudgetMs: 150000
# allowInstallScripts: false
# AI review of the diff via the Workers AI binding, scored 0.0-1.0.
# Omitting `model` uses the default (@cf/meta/llama-3.1-8b-instruct);
# a model id Workers AI doesn't serve makes this evaluator fail closed.
- type: llm
threshold: 0.7
merge:
requiredApprovals: 1
# Only evaluators that actually run belong here. "sandbox" would block every
# merge in this project while the Sandboxes binding is absent.
requiredEvaluators: ["secret_scan", "diff"]
allowForce: false
requireFreshBase: true
# Sandboxes-only, so also commented out. Unlike the evaluator, this one
# degrades quietly: with no binding the smoke test is skipped with a warning.
# postMergeCommand: "npm test"
# postMergeTimeoutMs: 120000
# autoRevert: true
```
A malformed policy file **fails closed**: the merge gate blocks rather than silently falling back to defaults, so a typo in a stricter policy can’t quietly downgrade your governance.
The same fail-closed instinct is why the Sandboxes-dependent entries above are commented out. `merge.requiredEvaluators` is enforced against the *latest run* of each named evaluator, and an evaluator whose binding is missing runs and fails rather than being skipped — so naming `sandbox` on an instance without Sandboxes makes every change in that project permanently unmergeable. Recovering means editing `.stratum/policy.yaml`, which is itself a protected config file requiring a human approval and refusing force-merge.
### The evaluators
[Section titled “The evaluators”](#the-evaluators)
* **Secret scan — always on, always blocking.** You don’t configure it and you can’t turn it off. Every change is scanned for API keys, tokens, and other credentials; a hit blocks the merge.
* **`diff`** — pure analysis of the change, no code execution. Caps the number of files (`maxFiles`, default 20) and lines (`maxLines`, default 500) changed, and can reject diffs touching `forbiddenPatterns` or missing `requiredPatterns`. **Patterns match file paths, not file contents** — `*` is a wildcard and anything else is a substring match, so `node_modules/` works and `console.log(` matches nothing. The verdict is **scored, not binary**: each violation costs 0.25 off a 1.0 score, and the evaluator passes at `minScore` — so under the default 0.7, a *single* violation (0.75) still passes and it takes two to fail. Set `minScore` above 0.75 if any one violation should block. Cheap, fast, and the first line of defense against runaway agent edits.
* **`webhook`** — POSTs the change to an external URL (your existing CI) and waits up to `timeoutMs` (default 10s, capped at 120s) for a verdict. `secret` signs the delivery so your CI can verify it came from Stratum; the value is used exactly as written, so generate a real secret rather than a `${...}` placeholder — nothing interpolates it.
* **`sandbox`** — **needs the Sandboxes beta, which is off by default everywhere, including the hosted instance.** `[[sandboxes]]` is commented out in `wrangler.toml`, so unless you self-host, have Sandboxes access, and uncomment the binding, this evaluator is unavailable — and *unavailable does not mean skipped*: it is substituted with an evaluator that returns score 0 / failed, so it drags the aggregate verdict down, and listing it in `merge.requiredEvaluators` blocks every merge in the project. Where the binding *is* enabled it materializes the workspace tree at the evaluated commit into a fresh Sandbox and runs `command` (default `npm test`), passing or failing on exit code. `timeoutMs` and `installTimeoutMs` bound the scored command and the dependency install separately (defaults 60s and 90s, each clamped to 1s–120s); `totalBudgetMs` (default 150s) bounds their *sum* — each phase gets `min(configured, budget remaining)`, and running out fails the evaluation instead of hanging. Dependency installs pass `--ignore-scripts` by default, since the evaluated tree is untrusted code an agent wrote and a `preinstall`/`postinstall` would otherwise run before any human review; set `allowInstallScripts: true` if your build genuinely needs them (native modules, a `prepare` step) — the usual symptom of leaving it off when you need it is *not* a failing install, but a native module that installs unbuilt and then fails when the test command loads it.
* **`llm`** — sends the diff to an LLM for review against your criteria. `model` picks the reviewer and must be a model the **Workers AI binding** serves (default: `@cf/meta/llama-3.1-8b-instruct`); `threshold` is the minimum passing score (0.0–1.0); `maxDiffChars` bounds how much diff is sent (default 24,000, max 100,000). An unavailable model or unparseable verdict fails closed. Token usage is recorded on the change as a cost record.
Two top-level knobs sit alongside `evaluators`: `requireAll` (default `true`) makes the aggregate verdict demand every evaluator pass — set it to `false` to pass when any one does — and `minScore` (default `0.7`, clamped to 0–1) is the per-evaluator pass threshold the `diff` and `sandbox` evaluators score against.
### The merge protections
[Section titled “The merge protections”](#the-merge-protections)
Everything under `merge:` is branch protection, enforced at the merge step:
* **`requiredApprovals`** — how many **human** approvals a change needs before it can merge. Agent approvals never count (see the invariant below).
* **`requiredEvaluators`** — evaluator types whose *latest run* must have passed. A change with a failing required evaluator cannot merge.
* **`allowForce`** — force-merge is **deny-by-default**. The `?force=true` override is rejected unless the policy explicitly sets `allowForce: true`. Leave it off (or set it to `false`, as above) unless you have a specific break-glass need.
* **`requireFreshBase`** — when true, a change whose recorded base is behind the project HEAD is rejected with `409 STALE_BASE`; re-evaluate on the new base first. Independently of this flag, a merge is always rejected if the workspace advanced after it was evaluated (`409 STALE_WORKSPACE`) — you can never merge commits the evaluators didn’t see.
* **`postMergeCommand`** + **`postMergeTimeoutMs`** — a smoke command run in a Cloudflare Sandbox against the merged HEAD (e.g. `npm test`), with a default timeout of 60 seconds. This needs the same Sandboxes beta binding as the `sandbox` evaluator, but unlike the evaluator it fails *open*: with no binding the check is skipped with a log warning and the merge stands, so a policy that sets it on an instance without Sandboxes is silently getting no smoke test.
* **`autoRevert`** — if the post-merge command fails, Stratum lands a forward revert commit, marks the change `reverted`, and emits a `change.reverted` event. On by default when a `postMergeCommand` is set.
## 4. Register an agent identity
[Section titled “4. Register an agent identity”](#4-register-an-agent-identity)
Agents are not shared service accounts — each one is a first-class identity:
```bash
curl -X POST https://app.usestratum.dev/api/agents \
-H "Authorization: Bearer stratum_user_xxxxx" \
-d '{"name": "refactor-bot"}'
```
This returns an **agent token** (`stratum_agent_...`). Two things to know about its scope:
* The token is **bounded by the owning user**: the agent inherits your project access (including org access) and nothing more. Agent tokens do **not** expire and carry no read/`read_write` scope of their own. Within that inherited access an agent can read, fork workspaces, commit, open changes, comment, and open issues — but the **deciding** endpoints require a user identity and refuse an agent token outright: review verdicts (approve *and* request changes), merge, reject, re-evaluate, GitHub PR promotion, and issue triage (edit/close). Session-only endpoints (creating and revoking tokens) are out of reach for agent tokens as for scoped ones — the lone exception, `rotate-token` accepting the legacy credential, is covered in the [authentication reference](/reference/authentication/). Revoke an agent token by deleting the agent from the settings UI (or `DELETE /api/agents/{id}`); that is the only way to retire it.
* All writes made with an agent token are attributed to the agent in **provenance** — merged changes record which agent and which model produced them, not just which human owned the token.
### The human-approval invariant
[Section titled “The human-approval invariant”](#the-human-approval-invariant)
Reviews (approve / request changes) are **human-only**. An agent token cannot approve any change — not its own, not another agent’s. This holds across every surface (REST API, CLI, MCP): if your policy sets `requiredApprovals: 1`, a human must look at the change before it merges. There is no configuration that relaxes this.
The reference agent in [`agent/`](https://github.com/stratum-eng/stratum/blob/main/agent/README.md) shows the intended shape: it creates its own identity, forks a workspace, asks Claude for edits, commits, and opens a change — then stops. Review and merge stay on the platform.
## 5. The change flow
[Section titled “5. The change flow”](#5-the-change-flow)
Every contribution — human or agent — follows the same path:
```text
workspace → commit → change (evaluation runs) → review → merge
```
1. **Fork a workspace.** A workspace is an isolated fork of the project — the equivalent of a branch, with its own git remote.
```bash
stratum workspace create @you/my-project --name fix-n-plus-one
```
2. **Commit.** Commit files to the workspace via the CLI (`stratum commit` sends your staged files), the API, or `git push` to the workspace remote (see section 6).
```bash
stratum commit --project @you/my-project --workspace fix-n-plus-one -m "Fix N+1 query"
```
3. **Create a change.** A change is Stratum’s merge proposal (a PR, roughly). Creating it runs your full evaluation policy **synchronously** — you get each gate’s verdict in the response.
```bash
stratum change create --project @you/my-project --workspace fix-n-plus-one
stratum change show chg_xxxxx # eval evidence + costs
```
4. **Review.** A human approves or requests changes; this moves the change’s state machine. Agents cannot perform this step.
```bash
stratum change review chg_xxxxx --verdict approve --comment "LGTM"
```
5. **Merge.** The default is a **true three-way merge commit** (`--squash` / `strategy: "squash"` opts into a squash), serialized per-project through a Durable Object merge queue so there are no races. The merge is rejected if a required evaluator is failing, approvals are short, the base is stale (`requireFreshBase`), or the workspace moved since evaluation. A conflicting three-way merge is refused with `409 MERGE_CONFLICT` and a conflict id rather than silently falling back to a squash. If a `postMergeCommand` is configured *and* the Sandboxes binding is present, it runs against the merged HEAD and a failure auto-reverts.
```bash
stratum change merge chg_xxxxx
```
### What a merged change carries
[Section titled “What a merged change carries”](#what-a-merged-change-carries)
Every merged change keeps:
* **Provenance** — the author (human or agent), the model and prompt hash snapshotted at change creation, and the evaluation score, per merged commit.
* **Evaluator evidence** — the full per-evaluator results (score, findings, duration), linked by change and browsable in the UI.
* **Cost records** — estimated resource usage per change: LLM tokens, sandbox execution time, and git operations.
If a change was linked to an issue, the issue auto-closes on merge.
## 6. Connect your tools
[Section titled “6. Connect your tools”](#6-connect-your-tools)
### CLI — `@stratum-eng/cli`
[Section titled “CLI — @stratum-eng/cli”](#cli--stratum-engcli)
The CLI wraps the full REST API: projects, workspaces, commits, changes (including review and merge), issues, and activity. It is not yet published to npm — install it from the `cli/` directory of the repo:
```bash
git clone https://github.com/stratum-eng/stratum.git
cd stratum/cli && npm install && npm run build
npm link # puts the `stratum` binary on your PATH
stratum login # opens your browser; no token to create or paste
# Headless (CI, containers), where there is no browser:
# stratum login --host https://app.usestratum.dev --key stratum_user_xxxxx
# or: export STRATUM_HOST=... STRATUM_API_KEY=... (env overrides the config file)
stratum status # who am I
stratum projects # list your projects
```
See [the CLI guide](/guides/cli/) for the full command reference, configuration, and the commit path’s limits.
### MCP server — `/mcp`
[Section titled “MCP server — /mcp”](#mcp-server--mcp)
Stratum serves MCP from the Worker itself, so **any** MCP-capable agent or editor (Claude Code, Cursor, Zed, Copilot, custom agents) reaches the eval-gated change flow with nothing to install.
How much of that flow depends on the credential. An OAuth grant with `mcp:write` — or a `stratum_user_` token with write access — gets all of it: read files, fork workspaces, commit, create changes (with each gate’s verdict returned), review, merge, and track issues. An `mcp:read` grant reads only. An **agent token** can read, fork, commit and open a gated change, but never review, merge or reject: those are human decisions, and that is the point.
Claude Code:
```bash
claude mcp add --transport http stratum https://app.usestratum.dev/mcp
```
Any MCP client that supports remote servers:
```json
{
"mcpServers": {
"stratum": {
"type": "http",
"url": "https://app.usestratum.dev/mcp"
}
}
}
```
Replace the host with your own instance if you self-host. The first tool call opens your browser to sign in and approve a consent screen; the client registers itself and manages tokens from there, so no Stratum credential is ever pasted into an editor’s config. Revoke access from **Settings → Connected applications**. A headless client with no browser can send a `stratum_user_` or `stratum_agent_` token as a bearer token instead.
All governance invariants hold over MCP exactly as over REST: agent tokens can’t submit review verdicts, merge, or reject, failing evaluators block merges, and provenance is recorded. See [the MCP server guide](/guides/mcp/) for the full tool reference, the OAuth details, and exactly what each credential kind may do.
### Plain git over smart HTTP
[Section titled “Plain git over smart HTTP”](#plain-git-over-smart-http)
Stratum projects and workspaces are real git remotes. Authenticate with your API key as the HTTP Basic password (username is ignored) — when prompted, or via a [git credential helper](https://git-scm.com/docs/gitcredentials). Don’t embed the key in the URL: it ends up in shell history and `.git/config`.
```bash
# Clone a project (read) — enter your API key at the password prompt
git clone https://app.usestratum.dev/@you/my-project.git
# Clone AND push to a workspace (read + write)
git clone https://app.usestratum.dev/@you/my-project/workspaces/fix-n-plus-one.git
cd fix-n-plus-one
# ...edit, commit...
git push
```
Note the asymmetry: a push to the **project** URL does not update `main` directly — a direct push to a protected ref would bypass the evaluation gate. The push is answered in-protocol: each ref reports `remote rejected` with the reason, and on instances with gated push enabled, a single-ref push to `main` lands your commits on a server-managed workspace and opens an eval-gated change whose id is streamed back in the push output. Otherwise, push to a **workspace** remote and open a change as usual.
## Where to go next
[Section titled “Where to go next”](#where-to-go-next)
* [Code Review](/guides/code-review/) — comment threads, line anchors, and verdicts
* [Issues](/guides/issues/) — the built-in tracker, and linking issues to changes
* [CI Integration](/guides/ci-integration/) — bring your own CI via the webhook evaluator
* [FAQ](/guides/faq/) — common questions, including honest current limitations
* [Importing from GitHub](/guides/importing/) — import and sync details
* [Troubleshooting](/guides/troubleshooting/) — common issues
* [API reference](/reference/openapi/) — the complete REST surface
# Importing from GitHub
> Import a repository from GitHub, GitLab, or Bitbucket, track progress, and keep the project in sync with its source.
Stratum imports repositories from **GitHub, GitLab, and Bitbucket**. Imports run as background jobs, so large repositories don’t block the request. The complete request/response contract for every endpoint on this page is in the [OpenAPI specification](/reference/openapi/).
Examples use the hosted instance and a user API token — substitute your own host and token:
```bash
export STRATUM_HOST=https://app.usestratum.dev
export STRATUM_API_KEY=stratum_user_xxxxx
```
## Start an import
[Section titled “Start an import”](#start-an-import)
```bash
curl -X POST "$STRATUM_HOST/api/projects/@username/repo/import" \
-H "Authorization: Bearer $STRATUM_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://github.com/owner/repo", "branch": "main"}'
```
A successful request returns `201` with an `importId` and `status: "queued"`. Imports are rate limited (3 per minute per user, 1 concurrent import per project) and only allowed into your own namespace. If the project already exists with an incomplete import, the import is re-triggered and `200` is returned.
### Options
[Section titled “Options”](#options)
| Field | Default | Description |
| ------------ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `url` | (required) | Repository URL on `github.com`, `gitlab.com`, or `bitbucket.org`. |
| `branch` | provider’s default branch | Branch to import. When omitted, Stratum asks the provider API for the repository’s real default branch; if that lookup fails the import falls back to `main` (fail-open) and logs a warning. |
| `depth` | `10` | Shallow clone depth: an integer from 1 to 1000, or `0` / `"full"` to import the branch’s full history. |
| `visibility` | `private` | `private` or `public`. |
## Track progress
[Section titled “Track progress”](#track-progress)
Poll the status endpoint:
```bash
curl -H "Authorization: Bearer $STRATUM_API_KEY" \
"$STRATUM_HOST/api/projects/@username/repo/import/status"
```
This returns an `ImportProgress` object: a `status` (`queued`, `cloning`, `processing`, `completed`, `failed`, `cancelled`, …), a `progress` object (`totalFiles`, `processedFiles`, `currentFile`, `bytesTransferred`, `totalBytes`), and any `errors` and `logs`. Polling also recovers an import that has been in an actively-progressing state (`cloning`, `processing`, `syncing`, `cancelling`) for more than **20 minutes** without its timestamp advancing. `queued` is deliberately excluded — a job waiting to be picked up is not stalled, and the scheduled sweep covers it under a one-hour grace period.
`processedFiles` and `totalFiles` are not an incrementing counter: they are both written once, from the file walk that builds the repository snapshot, in the `processing` phase just before the status becomes `completed`. Expect them to stay at `0` for the whole clone and then jump to the final count.
One exception: the snapshot write handles its own failures and returns no count, and the import is still marked `completed` in that case. So a completed import whose `processedFiles` and `totalFiles` are both `0` means the snapshot could not be built — not that the repository was empty. The repository itself imported fine; only the file listing is missing.
Or subscribe to Server-Sent Events instead of polling — the stream emits the same `ImportProgress` JSON as an SSE `data:` line every 2 seconds until the import completes, fails, or is cancelled:
```bash
curl -N -H "Authorization: Bearer $STRATUM_API_KEY" \
"$STRATUM_HOST/api/projects/@username/repo/import/stream"
```
A failed import can be retried with `POST …/import/retry` (rate limited like the initial import), and an ongoing one cancelled with `POST …/import/cancel`.
## Sync
[Section titled “Sync”](#sync)
Keep an imported project in sync with its source repository. Trigger a sync check — when the source has new commits, a background sync is queued:
```bash
curl -X POST "$STRATUM_HOST/api/projects/@username/repo/sync" \
-H "Authorization: Bearer $STRATUM_API_KEY"
```
The response reports `hasUpdates`, `commitsBehind`, `latestCommit`, and `lastSyncedCommit`, plus an `importId` and `status: "queued"` when a sync was actually started.
Check state with `GET …/sync/status`, which returns a `SyncStatus` object (`lastSyncStatus`: `success` / `failed` / `in_progress` / `idle`, `lastSyncedAt`, `commitsBehind`, `autoSyncEnabled`, and an `importProgress` object while a sync is active), or follow it live over SSE with `GET …/sync/stream` — the sync-status object is emitted every 2 seconds until the sync succeeds or fails, and the stream self-closes after 5 minutes.
Enable automatic syncing with:
```bash
curl -X POST "$STRATUM_HOST/api/projects/@username/repo/sync/settings" \
-H "Authorization: Bearer $STRATUM_API_KEY" \
-H "Content-Type: application/json" \
-d '{"autoSyncEnabled": true}'
```
Auto-sync runs on a **fixed daily schedule** (06:00 UTC on the hosted instance) for every project with `autoSyncEnabled`. The settings endpoint also accepts a `syncFrequency` field, but it is stored and **not yet used** — the scheduler does not read it, so it does not change the cadence. Trigger `POST …/sync` yourself for anything more frequent. Past runs are listed by `GET …/sync/history` (paginated with `limit`/`offset`).
## Failure notifications
[Section titled “Failure notifications”](#failure-notifications)
If a **queued** import fails, Stratum emails the user who started it, plus one operator copy. (A deployment with no import queue configured falls back to processing the import directly in the request, and that path sends no failure notification at all — it is a development fallback, not the normal path.)
Sending requires two things:
* **An `EMAIL` binding on the Worker.** Without it no failure notification is sent, whatever the addresses below are set to.
* **At least one resolvable recipient.** The operator copy goes to `ADMIN_EMAIL`, or — when `ADMIN_EMAIL` is unset — to `EMAIL_FROM_ADDRESS`. Leaving `ADMIN_EMAIL` unconfigured therefore does not switch the operator copy off; it redirects it to the sender address.
When the initiator’s address cannot be resolved, the operator copy is still sent, and identical addresses are de-duplicated so nobody is mailed twice. A failure is reported only in the logs when the `EMAIL` binding is absent, or when neither an initiator address nor an operator address can be resolved.
## Current limitations
[Section titled “Current limitations”](#current-limitations)
What import does **not** do yet:
* **Public repositories only.** Imports clone anonymously — provider tokens are not used for the clone, and GitHub sign-in only requests the `user:email` OAuth scope. Token-authenticated clones of private repositories are future work.
* **Git data only.** Issues, pull requests, releases, and tags are not imported — only the selected branch’s commit history and files.
* **Single branch.** Only one branch is imported per project. The clone is a `singleBranch` clone; it is bounded by `depth` only for shallow imports — `depth: 0` (or `"full"`) omits the cutoff and fetches the whole history of that one branch.
* **No self-hosted instances.** Only `github.com`, `gitlab.com`, and `bitbucket.org` URLs are recognized; GitHub Enterprise Server and self-hosted GitLab are not supported.
Bidirectional GitHub sync — inbound webhooks and outbound PR promotion, i.e. **layer mode** — is covered in [Getting started](/guides/getting-started/#choose-your-level-of-buy-in-layer-mode-vs-forge-mode).
## Unsupported content
[Section titled “Unsupported content”](#unsupported-content)
**Git submodules are not supported.** A repository containing a gitlink tree entry (the `160000` mode git uses for a submodule reference) at any depth, or a root-level `.gitmodules` file, is rejected. A gated push is refused over the git protocol, `POST /api/projects/{name}/changes` answers 400, and an import ends as `failed` — each carrying an explanatory message rather than a machine-readable submodule code.
On import the check is best-effort. When the just-imported tree can be read, submodule content fails the import with `status: "failed"` — before the project is ever marked imported. When the tree cannot be read at all (the read token cannot be minted, the clone fails, or the scan itself errors) the import proceeds with a warning and is left unscanned, rather than failing a healthy repository because of an infrastructure hiccup. So a completed import is not on its own proof that a repository is submodule-free.
The same scan runs whenever a change is created — on a gated push to a project’s default branch and on `POST /api/projects/{name}/changes` alike — and there it is unconditional: a change carrying submodule content is refused, and so is one whose scan could not run. That is the gate that keeps submodule content out of a merge.
This is deliberate: git’s checkout silently drops a gitlink entry when Stratum’s server-side git layer materializes a working tree, so partially importing a repo with submodules would let a later merge quietly corrupt it rather than fail loudly. Remove submodules (or flatten them into the repo) before importing, or push to a workspace remote for content that never touches the default branch. Full submodule support (recursive clone and browsing) is tracked for later; see [`CURRENT_CAPABILITIES.md`](https://github.com/stratum-eng/stratum/blob/main/docs/CURRENT_CAPABILITIES.md).
# Issues
> Open, triage, search, and link issues — and how a merged change closes them.
Stratum has a built-in issue tracker. It is deliberately small — issues exist to give agents and humans a shared, linkable statement of intent that a change can be tied back to, not to be a full project-management tool.
## Opening an issue
[Section titled “Opening an issue”](#opening-an-issue)
```bash
curl -X POST https://app.usestratum.dev/api/projects/@acme/billing/issues \
-H "Authorization: Bearer stratum_user_xxxxx" \
-H "Content-Type: application/json" \
-d '{"title": "Retry storms on 429 from the GitHub API",
"body": "The client retries 429 without honouring Retry-After."}'
```
**Read access to the project is enough to open an issue.** On a public project that means any authenticated user — the platform-wide per-identity rate limit (1,000 requests/minute authenticated) is what bounds abuse, not a per-issue quota. Editing, closing, and labelling all require **write** access on the project *and a user identity* — a reader can raise a problem but cannot triage it, and an agent token can open and comment but never triage, whatever access its owner has.
Project permission and credential scope are separate gates, and both apply. Opening an issue is a `POST`, so a `read`-scoped API token is refused with `403 TOKEN_SCOPE_INSUFFICIENT` however much project access its owner has. Use a `read_write` token or a browser session; the examples here assume one.
Titles are capped at 200 characters and bodies at 20,000.
Issues are numbered per project, starting at 1, and referenced by that number rather than by an opaque id.
## Linking an issue to a change
[Section titled “Linking an issue to a change”](#linking-an-issue-to-a-change)
`linkedChangeId` ties an issue to the change meant to resolve it. The change must belong to the same project — a link into another project is rejected.
```bash
curl -X POST https://app.usestratum.dev/api/projects/@acme/billing/issues \
-H "Authorization: Bearer stratum_user_xxxxx" \
-H "Content-Type: application/json" \
-d '{"title": "Retry storms on 429", "linkedChangeId": "chg_123"}'
```
**When that change merges, every open issue linked to it closes automatically**, attributed to `system`, and emits an `issue.closed` event. Per-issue failures are logged and skipped rather than failing the merge.
## Triage: labels, assignee, status
[Section titled “Triage: labels, assignee, status”](#triage-labels-assignee-status)
All of it is one `PATCH`:
```bash
curl -X PATCH https://app.usestratum.dev/api/projects/@acme/billing/issues/42 \
-H "Authorization: Bearer stratum_user_xxxxx" \
-H "Content-Type: application/json" \
-d '{"labels": ["bug", "needs-repro"], "assignee": "usr_abc", "status": "open"}'
```
| Field | Notes |
| ---------------- | ------------------------------------------------------------- |
| `title`, `body` | Same caps as on create. |
| `status` | `open` or `closed`. |
| `labels` | Replaces the whole set. Max 20 per issue, 50 characters each. |
| `assignee` | A user id. Pass `null` to unassign. |
| `linkedChangeId` | Pass `null` to unlink. |
`labels` is a **replace**, not a merge: send the full list you want the issue to end up with. `POST .../issues/{number}/close` exists for the UI button and is a **toggle** — on an already-closed issue it reopens it, and it answers with a `302` redirect. Scripts should prefer the idempotent `PATCH {"status": "closed"}`.
## Finding issues
[Section titled “Finding issues”](#finding-issues)
`GET /api/projects/{namespace}/{slug}/issues` filters on four axes, which combine:
```bash
# Open bugs assigned to a given user, mentioning "retry"
curl "https://app.usestratum.dev/api/projects/@acme/billing/issues?status=open&label=bug&assignee=usr_abc&q=retry" \
-H "Authorization: Bearer stratum_user_xxxxx"
```
* `status` — `open` or `closed`
* `label` — issues carrying that exact label
* `assignee` — issues assigned to that user id
* `q` — case-insensitive substring search over **title and body**, max 200 characters. `%` and `_` match literally, so a search string containing them finds them rather than acting as wildcards.
Results are ordered newest-first by issue number. Paginate with `limit` (default 100, max 500) and `offset`.
## Comments
[Section titled “Comments”](#comments)
```bash
curl -X POST https://app.usestratum.dev/api/projects/@acme/billing/issues/42/comments \
-H "Authorization: Bearer stratum_user_xxxxx" \
-H "Content-Type: application/json" \
-d '{"body": "Reproduced against the 2026-08-30 build."}'
```
Comments are capped at 20,000 characters and listed with the same `limit`/`offset` pagination as issues. Read access is enough to comment.
These are issue comments, and separate from the review threads on a change — for line-anchored comments on a diff, see [Code Review](/guides/code-review/).
## Imported repositories have no issues
[Section titled “Imported repositories have no issues”](#imported-repositories-have-no-issues)
A GitHub import brings **git data only**. Issues, pull requests, releases, and tags do not come across, so an imported project starts with an empty tracker and issue numbering starts at 1. See [Importing from GitHub](/guides/importing/#current-limitations).
## Reference
[Section titled “Reference”](#reference)
* [Issues API](/reference/endpoints/)
* [OpenAPI specification](/reference/openapi/) — exact schemas and status codes
# The Stratum MCP server
> Connect any MCP-capable agent or editor to the evaluation-gated change flow over the hosted /mcp endpoint — OAuth 2.1, nothing to install.
Stratum serves [MCP](https://modelcontextprotocol.io/) directly from the Worker at **`/mcp`**, so any MCP-capable agent or editor — Claude Code, Cursor, Zed, Copilot, custom agents — can read a project, fork a workspace, commit, and propose governed changes. There is nothing to install: you point your client at a URL and approve the connection in your browser.
Stratum’s governance invariants hold over MCP exactly as they do over the REST API, because the tools call the same route handlers: a change cannot merge past failing evaluators, review verdicts are a human gate, and every merged change keeps its provenance and cost records. The protocol is a doorway, not a bypass.
(This server is distinct from the docs site’s own [agent discovery surface](https://docs.usestratum.dev/reference/agent-discovery/), which serves documentation to agents. This one operates on your projects.)
## Connecting a client
[Section titled “Connecting a client”](#connecting-a-client)
The endpoint is `/mcp` on your own Stratum instance. The examples below use `https://app.usestratum.dev`, the maintainer’s hosted instance — **if you self-host, substitute your own Worker’s origin.** Pointing a client at someone else’s instance sends your tool calls there, along with any credential you configure by hand.
Claude (web, desktop and mobile), as a connector: **Settings → Connectors → Add custom connector**. Name it, paste `https://app.usestratum.dev/mcp` as the URL, and leave the advanced OAuth client id and secret blank — Stratum registers the client itself. Click **Connect** on the new connector and approve the consent screen that opens; the tools are then available in any conversation where the connector is enabled.
Claude Code:
```bash
claude mcp add --transport http stratum https://app.usestratum.dev/mcp
```
Any MCP client that supports remote servers:
```json
{
"mcpServers": {
"stratum": {
"type": "http",
"url": "https://app.usestratum.dev/mcp"
}
}
}
```
Connecting opens your browser, asks you to sign in if you are not already, and shows a consent screen: which application is asking, what it will be able to do, and the host it returns you to. The full redirect address and where to disconnect later are one click away under **Where this request came from**. Approve it once; the client stores the tokens and refreshes them on its own.
You never paste a Stratum credential into your editor’s configuration, and you can withdraw access at any time from **Settings → Connected applications** without touching the client.
### Headless clients and CI
[Section titled “Headless clients and CI”](#headless-clients-and-ci)
An automation with no browser can present a Stratum credential directly instead of running the OAuth flow:
```bash
curl -X POST https://app.usestratum.dev/mcp \
-H "Authorization: Bearer $STRATUM_API_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
Both credential kinds work: a user token (`stratum_user_...`) or an agent token (`stratum_agent_...`). See [what an agent token can do](#what-an-agent-token-can--and-cannot--do) below — the difference matters.
## How authorization works
[Section titled “How authorization works”](#how-authorization-works)
The endpoint is an OAuth 2.1 protected resource, and Stratum is the authorization server. A client bootstraps the whole flow from the URL alone:
1. It calls `/mcp` with no credential. The `401` carries a `WWW-Authenticate` header naming `/.well-known/oauth-protected-resource` and the scopes full use of the endpoint needs (`scope="mcp:read mcp:write"`).
2. That document points at `/.well-known/oauth-authorization-server`.
3. The client registers itself at `/oauth/register` ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) dynamic client registration) — no pre-shared client id, nothing configured in advance.
4. It opens `/oauth/authorize` in your browser. You sign in and consent.
5. It exchanges the resulting code at `/oauth/token`.
Details worth knowing:
| Property | Value |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| PKCE | **Required**, `S256` only. `plain` is not offered and cannot be stored. |
| Authorization code lifetime | 60 seconds, single use. A replayed code revokes **every grant that client holds for you** (RFC 6749 §10.5), not just the one the code produced — but a redemption that merely *fails validation* (wrong client, wrong verifier, wrong redirect URI) leaves the code untouched, so observing one is not enough to disrupt the real client. |
| Access token lifetime | 1 hour |
| Refresh token lifetime | 30 days, **rotated** on every use |
| Redirect URIs | `https`, or plaintext `http` on loopback (`localhost`, `127.0.0.1`, `[::1]`) for native apps. Matched exactly against the registered list, except that a loopback redirect may use any port, which RFC 8252 requires because a native app binds whatever port is free when it starts. |
| Scopes | `mcp:read`, `mcp:write` |
Every metadata document is built from the request’s own origin, so a self-hosted instance advertises itself with nothing to configure.
### Scopes
[Section titled “Scopes”](#scopes)
| Scope | Grants |
| ----------- | ------------------------------------------------------------------------------------------------------------------- |
| `mcp:read` | Read projects, files, changes, evaluations and issues. Exactly a read-only API token. |
| `mcp:write` | The above, plus workspaces, commits, changes, merges, reviews and issue management. Exactly a read-write API token. |
A client that asks for no scope gets `mcp:read`. Most clients ask for more: the `401` challenge names `mcp:read mcp:write` as what full use of the endpoint needs, and the protected-resource metadata lists both scopes as supported, so a client that follows either discovery path can request both. Neither obliges it to: a client may still ask for `mcp:read` alone. The consent screen spells out what the grant covers, so a read-only connection is a choice you can see being made. A read-only grant is refused in the middleware, before routing, on any write request — so no route has to remember the rule.
The one place the check is deferred is `/mcp` itself, and only because the HTTP method there says nothing about the operation: every MCP call is a POST, `tools/list` as much as `stratum_commit`, so refusing on the method would lock a read-only grant out of the endpoint rather than restrict it. Each tool call is re-dispatched internally as a real API request carrying the same credential, and *that* request meets the identical check against the method the operation actually uses. So `stratum_get_file` works on an `mcp:read` grant and `stratum_commit` does not.
An OAuth grant is a *delegated* credential: it lives inside software you do not control. Two things it therefore cannot do, regardless of scope:
* **Reach the admin API.** `/api/admin/*` is refused for OAuth grants even when the account behind them is the instance administrator.
* **Mint the legacy API key.** The key never expires and cannot be revoked individually, so a credential that could mint one would outlive its own revocation.
### Disconnecting an application
[Section titled “Disconnecting an application”](#disconnecting-an-application)
**Settings → Connected applications** lists every client you have authorized, with its self-declared name, its client id, the access you granted and when it was last used. Disconnecting is immediate: the access token and the ability to refresh both stop working at once.
Application names are chosen by the application at registration and are not vetted by anyone. A name you do not recognise should be disconnected.
## The tools
[Section titled “The tools”](#the-tools)
Eighteen tools cover the full contribution loop. Project arguments take a `namespace/slug` reference (`@acme/api` and `acme/api` both work).
**Identity and reading**
| Tool | What it does |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `stratum_whoami` | Identify the authenticated **user** (calls `GET /api/users/me`, which refuses agent tokens). |
| `stratum_list_projects` | List projects in the caller’s **own namespace** — org-namespace projects are not included, and an agent token gets an empty list. |
| `stratum_get_project` | Project metadata — id, namespace, visibility, git remote. |
| `stratum_list_files` | File paths at the HEAD of the default branch. |
| `stratum_get_file` | One file’s content at HEAD. |
| `stratum_get_activity` | Recent activity feed. |
**Forking and committing**
| Tool | What it does |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `stratum_create_workspace` | Fork an isolated workspace; returns its name and git remote. |
| `stratum_list_workspaces` | List a project’s workspaces — all of them; there is no status filter, so workspaces whose changes already merged still appear. |
| `stratum_commit` | Commit a map of repo-relative paths to **complete file contents**. |
`stratum_commit` takes the `project_id` from `stratum_get_project`, not the `namespace/slug` reference. The file map is capped at 2,000 files, and it cannot express a deletion or rename — only full contents of added or modified files. Larger or destructive edits go over the workspace’s git remote instead.
**The whole MCP request is capped at 8 MB**, below the REST API’s 25 MB commit limit. A tool call is buffered, parsed, re-serialized into an internal request and parsed again, so a body near the REST limit would cost more than a Workers isolate’s entire 128 MB budget — which is shared with every other request that isolate is serving. A commit that does not fit belongs on the git remote anyway.
**The change flow**
| Tool | What it does |
| ----------------------- | ---------------------------------------------------------------------------------------------------- |
| `stratum_create_change` | Open a change; **synchronously** runs every gate in `.stratum/policy.yaml` and returns the verdicts. |
| `stratum_list_changes` | List changes, optionally by status. |
| `stratum_get_change` | One change with per-gate evidence and metered costs. |
| `stratum_merge_change` | Merge a change that passed its gates and approvals. |
| `stratum_reject_change` | Close a change without merging. |
| `stratum_review_change` | Submit an `approve` or `request_changes` verdict. |
**Issues**
| Tool | What it does |
| ---------------------- | ----------------------------------------------------------- |
| `stratum_create_issue` | Open an issue; linking a change id auto-closes it on merge. |
| `stratum_list_issues` | List issues, optionally by status. |
| `stratum_update_issue` | Update status, title, or body by issue number. |
## What an agent token can — and cannot — do
[Section titled “What an agent token can — and cannot — do”](#what-an-agent-token-can--and-cannot--do)
The server accepts either token kind, but the API behind it does not treat them alike. With an **agent token**:
* **Works:** the project reading tools (`get_project`, `list_files`, `get_file`, `get_activity`), `stratum_create_workspace`, `stratum_commit`, `stratum_create_change`, `stratum_create_issue`, and `stratum_list_issues` — an agent can do everything up to and including proposing a gated change and opening issues about its work. Address projects by `namespace/slug` directly: `stratum_list_projects` returns an **empty list** on an agent token rather than the owner’s projects, and `stratum_whoami` returns a 401.
* **Always refused:** `stratum_merge_change`, `stratum_reject_change`, `stratum_review_change`, and `stratum_update_issue`. Merging, deciding, and issue triage are user actions, and review verdicts are refused from agent tokens **entirely** — `request_changes` as much as `approve`. An agent’s feedback channel is [change comments](/guides/code-review/), which the REST API accepts from agents but this server does not yet expose as a tool.
So an agent-token deployment is a *proposer*: it forks, commits, opens the change, and a human (over the UI, [CLI](/guides/cli/), or their own MCP session) reviews and merges.
An **OAuth grant is a user credential**, not an agent identity: a human consented to it in a browser, so it carries the same authority a user token does, including review verdicts when `mcp:write` was granted. If you want an MCP client that can propose but never decide, connect it with an agent token rather than through OAuth.
## Operational notes
[Section titled “Operational notes”](#operational-notes)
* The endpoint is **stateless**: no session to establish or resume, no `Mcp-Session-Id`. Every request is self-contained and authenticated on its own, so any edge location can answer any call and a reconnect loses nothing.
* `GET /mcp` returns **405**. This server never initiates messages, so there is no server-to-client stream to open; clients treat the 405 as “none offered”.
* Protocol versions accepted: `2025-06-18`, `2025-03-26`, `2024-11-05`. An explicitly unsupported `Mcp-Protocol-Version` header is a 400; an absent one is fine.
* Tool failures come back as results marked as errors, prefixed `Stratum API error:` — or `Invalid arguments for :` for a schema violation, so a calling agent knows whether to fix its input or its expectations. Argument errors list **every** problem at once rather than the first.
* Evaluation runs inside `stratum_create_change`, so on a project with a slow sandbox evaluator that call legitimately runs long. There is no client-side deadline cutting it short any more — the bound is the request’s own budget. If a call does fail mid-evaluation, the evaluation continues server-side: follow up with `stratum_list_changes` / `stratum_get_change` rather than re-submitting.
* The credential is re-resolved on **every** tool call, not once per connection. A token you revoke stops working on the next call.
## Reference
[Section titled “Reference”](#reference)
* [Getting started §6](/guides/getting-started/#6-connect-your-tools) — where MCP fits in the end-to-end flow
* [Authentication](/reference/authentication/) — the credential kinds
* [CI integration](/guides/ci-integration/) — evaluating changes on your own infra
* [OpenAPI specification](/reference/openapi/) — the API the tools wrap
# Troubleshooting
> Symptoms and fixes for auth, imports, evaluation, merges, and access.
Symptoms grouped by where they surface. Machine-readable codes are listed in the [error code reference](/reference/errors/).
## Authentication and tokens
[Section titled “Authentication and tokens”](#authentication-and-tokens)
### `401 Unauthorized`
[Section titled “401 Unauthorized”](#401-unauthorized)
The token is unknown, revoked, or **expired**. Named API tokens can carry a 1–365 day expiry, checked on every request, and an expired token is a `401` everywhere — API and git alike. Check the token’s expiry in Settings; revoked and expired rows are kept, so it will still be listed.
An agent token also fails closed if its **owning user is being deleted**.
### `403 TOKEN_SCOPE_INSUFFICIENT`
[Section titled “403 TOKEN\_SCOPE\_INSUFFICIENT”](#403-token_scope_insufficient)
You are using a `read` token for a write. On API routes a `read` token is refused with this `403` on everything that is not a `GET` or `HEAD`. Over **git** the same rule applies to `git push` — including the `info/refs?service=git-receive-pack` advertisement that precedes it — but surfaces as a plain `404` (“Not found”), deliberately identical to a missing repository so permissions can’t be probed. A push that mysteriously 404s on a repo you can clone is this. Mint a `read_write` token, or use the one you meant to.
Read-only bounds *damage*, not *exposure*: a `read` token still reads every private project its owner can read.
### `403 SESSION_REQUIRED`
[Section titled “403 SESSION\_REQUIRED”](#403-session_required)
Token-management endpoints (`GET|POST /api/users/me/tokens`, `DELETE /api/users/me/tokens/{id}`, `POST /api/users/me/legacy-token/disable`, and `rotate-token` for scoped tokens) accept the browser session cookie **only**, whatever the token’s scope. A token that could mint or revoke tokens would make revocation meaningless. Do it from the settings UI.
### `409 TOKEN_LIMIT_REACHED`
[Section titled “409 TOKEN\_LIMIT\_REACHED”](#409-token_limit_reached)
You hold 20 active tokens. Revoke one — revoked tokens do not count toward the limit, so rotating never locks you out.
### A token shows as never used
[Section titled “A token shows as never used”](#a-token-shows-as-never-used)
`lastUsedAt` is written at most once an hour, and **not at all** on the git smart-HTTP path, which has no execution context to defer the write to. A token used only for `git clone` legitimately shows as never used.
## Imports
[Section titled “Imports”](#imports)
### The import is stuck in “Import in Progress” or `CANCELLING`
[Section titled “The import is stuck in “Import in Progress” or CANCELLING”](#the-import-is-stuck-in-import-in-progress-or-cancelling)
It is not stuck any more. A scheduled sweep moves wedged jobs to a terminal state on their own: a cancel that never finished lands in `cancelled`, anything else that stopped progressing lands in `failed`. A job that was never picked up by a worker gets a much longer grace period than one that started and stalled, and its message will say the queue message was likely lost — retry to re-queue it.
Previously this recovery only ran when somebody happened to open the project’s progress page, so an abandoned job could claim to be running indefinitely.
### The import failed on submodules
[Section titled “The import failed on submodules”](#the-import-failed-on-submodules)
Git submodules are **not supported**. A gitlink entry at any depth, or a root-level `.gitmodules`, fails the import rather than silently importing a broken tree.
On import the scan is **best-effort**: if the just-imported tree cannot be read at all (the read token cannot be minted, the clone fails, or the scan itself errors) the import proceeds with a warning and is left unscanned, rather than failing a healthy repository over an infrastructure hiccup. So a completed import is not on its own proof that a repository is submodule-free.
The same scan runs whenever a change is created — on a gated push and through `POST /api/projects/{name}/changes` alike — and **there it is unconditional**: a change carrying submodule content is refused, and so is one whose scan could not run. That is the gate that keeps submodule content out of a merge. See [Unsupported content](/guides/importing/#unsupported-content).
### The import failed on Git LFS
[Section titled “The import failed on Git LFS”](#the-import-failed-on-git-lfs)
Git LFS is entirely absent — there is no `/info/lfs` route and no `objects/batch` endpoint, so an LFS-enabled clone or push fails when the `git lfs` client tries to reach them. Pointer files import as ordinary text.
### `SYNC_DIVERGED`
[Section titled “SYNC\_DIVERGED”](#sync_diverged)
The upstream and the Stratum copy have genuinely diverged in content — this is raised only for real conflicts, not for a fetch window that was merely too shallow. The fetch window is deepened incrementally before this is reported, so a `SYNC_DIVERGED` means the histories actually conflict, not that the clone was too shallow.
### Only some tags came across
[Section titled “Only some tags came across”](#only-some-tags-came-across)
Tag fetching pulls only tag-reachable objects and caps the tag count, reporting `truncated` explicitly rather than silently dropping the rest.
## Evaluation
[Section titled “Evaluation”](#evaluation)
### Every change fails on `SANDBOX binding is not configured`
[Section titled “Every change fails on SANDBOX binding is not configured”](#every-change-fails-on-sandbox-binding-is-not-configured)
Your policy names a `sandbox` evaluator on an instance without the Cloudflare Sandboxes binding. `[[sandboxes]]` is commented out in `wrangler.toml` (it is a gated beta), so this is the default state on the hosted instance and on a fresh self-host.
The evaluator is **not skipped**. It is replaced with one that returns score 0 / failed and the reason *“sandbox unavailable: SANDBOX binding is not configured — enable `[[sandboxes]]` in `wrangler.toml` or remove the sandbox evaluator from the policy”*. That drags the aggregate verdict down, and if `sandbox` is listed in `merge.requiredEvaluators` it blocks **every** merge in the project.
Remove the `sandbox` evaluator (and any `"sandbox"` entry in `requiredEvaluators`) from `.stratum/policy.yaml`, or enable the binding. Note the recovery commit edits a protected config file, so it needs a human approval and cannot be force-merged. `merge.postMergeCommand` behaves differently: with no binding it is skipped with a warning rather than failing the merge.
The `llm` evaluator fails closed the same way when the `AI` binding is absent (`llm unavailable: AI binding is not configured`), though `wrangler.toml` does bind Workers AI in every environment.
The remaining entries in this section apply only where Sandboxes **is** enabled.
### `sandbox budget exceeded (install)` or `(command)`
[Section titled “sandbox budget exceeded (install) or (command)”](#sandbox-budget-exceeded-install-or-command)
The evaluation ran out of its **total** time budget — `totalBudgetMs`, default 150s — which bounds the *sum* of the install and command phases. Each phase is granted `min(configured, budget remaining)`, and exhausting the budget returns a failing verdict naming the phase rather than hanging.
Raise `totalBudgetMs` on the `sandbox` evaluator in `.stratum/policy.yaml`, or make the phase cheaper. Note the per-phase defaults (`installTimeoutMs` 90s, `timeoutMs` 60s) sum to exactly the default budget, so an unconfigured project is never truncated.
### A native module fails when the test command loads it
[Section titled “A native module fails when the test command loads it”](#a-native-module-fails-when-the-test-command-loads-it)
Dependency installs pass `--ignore-scripts`, because the evaluated tree is code an agent wrote and a `preinstall`/`postinstall` would run before any human review. The symptom is **not** a failing install — the module installs unbuilt and then fails at load. If your build genuinely needs lifecycle scripts (native modules, a `prepare` step), set `allowInstallScripts: true` on the `sandbox` evaluator.
### A change that passed before now fails on re-evaluation
[Section titled “A change that passed before now fails on re-evaluation”](#a-change-that-passed-before-now-fails-on-re-evaluation)
Re-evaluating runs under the current defaults, and the default install timeout dropped from 120s to 90s. Re-check against `totalBudgetMs` above.
## Merging
[Section titled “Merging”](#merging)
### `STALE_BASE` / `STALE_WORKSPACE` / `WORKSPACE_UNVERIFIABLE`
[Section titled “STALE\_BASE / STALE\_WORKSPACE / WORKSPACE\_UNVERIFIABLE”](#stale_base--stale_workspace--workspace_unverifiable)
The change was evaluated against code that is no longer what would land. Re-evaluate on the current base. `STALE_WORKSPACE` specifically means the workspace advanced *after* evaluation, so unevaluated commits can never slip in.
### Approvals disappeared
[Section titled “Approvals disappeared”](#approvals-disappeared)
Approvals are dismissed when the evaluated **base** moves, not only when the tip does — a change re-evaluated against a newer base was previously keeping approvals that had been granted against different code.
### `PROTECTION_BLOCKED`
[Section titled “PROTECTION\_BLOCKED”](#protection_blocked)
The response lists the specific `reasons`. Common ones:
* Not enough approvals. Remember the **change author’s own approval does not count**; for an agent-created change the excluded author is the agent’s owning user.
* The change edits the merge-protection config, which requires an approval even when `requiredApprovals` is 0.
### `MERGE_CONFLICT`
[Section titled “MERGE\_CONFLICT”](#merge_conflict)
The response carries a conflict id for `POST /api/projects/conflicts/{id}/resolve`. Manual resolutions are **not** a back door: they are secret-scanned and run through the full merge gate, reusing the originating change’s review trail.
## Projects and access
[Section titled “Projects and access”](#projects-and-access)
### `404` on a project you believe exists
[Section titled “404 on a project you believe exists”](#404-on-a-project-you-believe-exists)
Read access is checked before existence is revealed, so an unauthorised private project is a `404`, not a `403`. Check the namespace and slug, and that your token’s owner has access.
### `409 TARGET_DELETING`
[Section titled “409 TARGET\_DELETING”](#409-target_deleting)
The project, or its owner’s account, is being deleted. Writes are refused so the cascade is not fighting new rows.
## Getting help
[Section titled “Getting help”](#getting-help)
Include the machine-readable `code` where there is one, the project path, and the change or issue number. Open an issue on [GitHub](https://github.com/stratum-eng/stratum/issues).
# Agent discovery
> Every machine-readable entry point Stratum publishes, what each one contains, and how an agent should walk them.
Stratum treats agents as first-class users, so everything a human can find in these docs an agent can find in a machine-readable form. This page is the map.
## Start here
[Section titled “Start here”](#start-here)
An agent that knows nothing but the domain should walk this order:
1. **DNS** — `_index._agents.usestratum.dev` (SVCB) points at the origin that serves the discovery documents.
2. **`/.well-known/ai-catalog.json`** — the ARD manifest. One document listing every capability, each with a media type and a URL.
3. Follow the entry that matches the task.
An agent that already has an HTML page can skip step 1: every page carries the catalogue in an RFC 8288 `Link` header and an in-page ``.
## The entry points
[Section titled “The entry points”](#the-entry-points)
| Path | Format | What it is |
| ------------------------------------------------------------------------------ | ----------------------------- | ------------------------------------------------------------------------------------- |
| [`/.well-known/ai-catalog.json`](/.well-known/ai-catalog.json) | ARD manifest | Every capability Stratum publishes, with representative queries for semantic indexing |
| [`/.well-known/api-catalog`](/.well-known/api-catalog) | RFC 9727 linkset | Service description and documentation links for the REST API |
| [`/.well-known/mcp/server-card.json`](/.well-known/mcp/server-card.json) | MCP server card | The remote MCP endpoint’s URL, how to authenticate to it, and its tool list |
| [`/.well-known/agent-skills/index.json`](/.well-known/agent-skills/index.json) | Agent Skills Discovery v0.2.0 | SKILL.md artifacts with `sha256` digests |
| [`/auth.md`](/auth.md) | Markdown | The agent registration contract, in prose |
| [`/openapi.yml`](/openapi.yml) | OpenAPI 3.1 | The complete REST API contract |
| [`/llms.txt`](/llms.txt), [`/llms-full.txt`](/llms-full.txt) | Plain text | Documentation index and complete corpus |
| [`/sitemap-index.xml`](/sitemap-index.xml) | XML | Every page |
All of them are served with `Access-Control-Allow-Origin: *`, so an agent running in a browser on another origin can read them directly.
## Markdown instead of HTML
[Section titled “Markdown instead of HTML”](#markdown-instead-of-html)
Any documentation page returns its Markdown source when you ask for it:
```bash
curl -H "Accept: text/markdown" https://docs.usestratum.dev/guides/getting-started/
```
Appending `.md` to the path works too, and survives intermediary caches that ignore `Vary: Accept`.
## Skills
[Section titled “Skills”](#skills)
Three SKILL.md artifacts cover the things agents actually need to do. Verify the `sha256` digest from the index against what you download.
* [`stratum-merge-gate`](/.well-known/agent-skills/stratum-merge-gate/SKILL.md) — get a change through the evaluation gates, and read the verdict when one blocks
* [`stratum-mcp`](/.well-known/agent-skills/stratum-mcp/SKILL.md) — connect an MCP client and use the tool set
* [`stratum-agent-identity`](/.well-known/agent-skills/stratum-agent-identity/SKILL.md) — obtain a credential and understand its limits
## WebMCP
[Section titled “WebMCP”](#webmcp)
Documentation pages register [WebMCP](https://webmachinelearning.github.io/webmcp/) tools on load, so a browser agent can call the site instead of scraping it: `search_stratum_docs`, `read_stratum_doc`, `list_stratum_docs`, `get_stratum_api_spec`, and `get_stratum_agent_auth`. The registration is a no-op in browsers without `navigator.modelContext`.
## DNS-AID
[Section titled “DNS-AID”](#dns-aid)
[DNS-AID](https://datatracker.ietf.org/doc/draft-mozleywilliams-dnsop-dnsaid/) records under the `_agents` namespace let an agent find the discovery origin without a prior HTTP request:
```text
_index._agents.usestratum.dev. 3600 IN SVCB 1 docs.usestratum.dev. (
alpn="h2,http/1.1" port=443
mandatory=alpn,port
key65280="/.well-known/ai-catalog.json" )
```
`key65280` carries the draft’s `well-known` parameter, which has no IANA allocation yet and so travels in RFC 9460’s private-use range. The zone must be signed with DNSSEC before these records are relied on — discovery records tell an agent where to send credentials, and unsigned they are trivially spoofable.
The records are kept in [`website/dns/agents.zone`](https://github.com/stratum-eng/stratum/blob/main/website/dns/agents.zone), with apply and verification steps in the README beside it.
## OAuth metadata, and why it is not here
[Section titled “OAuth metadata, and why it is not here”](#oauth-metadata-and-why-it-is-not-here)
The MCP endpoint at `app.usestratum.dev/mcp` is an OAuth 2.1 protected resource, and `https://app.usestratum.dev` is its authorization server. The two documents a client needs are published on **that** origin, not this one:
* `https://app.usestratum.dev/.well-known/oauth-protected-resource` — RFC 9728
* `https://app.usestratum.dev/.well-known/oauth-authorization-server` — RFC 8414
That split is not an oversight. Both specs have a client *derive* the URL from an origin it already holds — the protected resource’s in RFC 9728 §3, the issuer’s in RFC 8414 §3 — and both derivations land on `app.usestratum.dev`, which these docs do not serve. A copy published here would be found only by agents that already knew to look for it, while every spec-compliant client kept getting a 404 from the origin that actually matters. So the docs origin names them absolutely, in the [server card](/.well-known/mcp/server-card.json), and serves neither itself.
An agent needs none of this in advance. `POST /mcp` with no credential; the `401` carries a `WWW-Authenticate` header naming the protected-resource document, which points at the authorization server, which offers dynamic client registration. The whole flow bootstraps from the endpoint URL alone.
## What Stratum does not publish
[Section titled “What Stratum does not publish”](#what-stratum-does-not-publish)
There is **no `/.well-known/openid-configuration`**, on either origin. Stratum’s access tokens are opaque and it is not an OpenID Connect provider: no `jwks_uri`, no ID token, nothing to validate offline. Every token is checked against the database on the call that presents it, which is what makes revocation take effect on the next request rather than at the next expiry.
There is **no client-credentials grant**, and none is planned. Every MCP grant starts with a human at a consent screen, because an agent’s authority in Stratum is derived from a person’s and that link is the point.
The **REST API is not itself OAuth-protected**. Its credentials are opaque bearer tokens minted out-of-band by a human account holder, described in [`/auth.md`](/auth.md). An `mcp:*` access token is accepted there too — a grant carries its user’s authority on every surface — but there is no second OAuth flow to discover for REST.
Publishing endpoints that do not exist would send agents into a handshake that cannot complete, which is worse than the metadata being absent — and it is precisely the class of confidently-wrong machine output Stratum exists to gate.
# Authentication
> Named scoped API tokens, agent tokens, session cookies, anonymous access, and the admin API key.
Most API endpoints accept a Stratum API token as an `Authorization: Bearer ` header. Browser requests may instead be authenticated by the `stratum_session` cookie. The [OpenAPI specification](/reference/openapi/) marks the exact security requirements per endpoint.
## API tokens
[Section titled “API tokens”](#api-tokens)
For programmatic access:
* **User tokens**: `stratum_user_xxxxx` — named, scoped, independently revocable, and optionally expiring. Created from the settings UI or `POST /api/users/me/tokens`.
* **Agent tokens**: `stratum_agent_xxxxx` — issued to an agent, tied to the owning user’s access, and attributed to the agent in provenance. They do **not** expire and are not scoped. Within the access they inherit they can read, fork workspaces, commit, open changes, comment, and open issues — but every **deciding** endpoint requires a user identity and refuses an agent token: review verdicts (approve and request-changes alike), merge, reject, re-evaluate, GitHub PR promotion, issue editing/closing, approving a gated deployment, and managing a project’s deploy secrets — the secret routes refuse an agent outright, including one whose *owner* is a project admin, because deploy credentials are the one thing an agent is never trusted with. Retrying an already-finished deployment is not a deciding endpoint and *is* open to an agent with write access. Session-only endpoints (token management) refuse agent and scoped tokens alike — the one exception is `rotate-token`, [below](#the-legacy-token). Revoke one by deleting the agent (settings UI, or `DELETE /api/agents/{id}`).
```bash
curl -H "Authorization: Bearer stratum_user_xxxxx" \
https://app.usestratum.dev/api/projects
```
### Scopes
[Section titled “Scopes”](#scopes)
Every user token carries exactly one scope, chosen when it is created:
| Scope | What it can do |
| ---------------- | ---------------------------------------------------------------------------------- |
| `read` (default) | `GET` and `HEAD` requests, and `git clone` / `git fetch` over HTTPS. Nothing else. |
| `read_write` | Everything its owner can do through the API and git. |
A `read` token is refused on **every** request that is not a `GET` or a `HEAD`, with `403` and the code `TOKEN_SCOPE_INSUFFICIENT`. The check runs before routing, on an allow-list of methods, so no endpoint can forget it and a reason expressed as a `POST` gets no exception. Over git, the same rule is applied to the operation the server resolves rather than to the URL: a `read` token clones and fetches, and is refused on `git push` to both the project and the workspace remote — including the `info/refs?service=git-receive-pack` advertisement that precedes it.
Read-only bounds *damage*, not *exposure*: a `read` token still reads every private project its owner can read. Sessions and agent tokens are unaffected by scopes.
### Expiry and limits
[Section titled “Expiry and limits”](#expiry-and-limits)
* `expiresInDays` is an integer from **1 to 365**; omit it for a token that never expires. Expiry is checked on every authentication, against the time of the request.
* An expired or revoked token is a `401` everywhere — API and git alike. Revoked rows are kept, so the audit trail survives revocation.
* Each user may hold **20 active tokens**. The 21st is a `409` (`TOKEN_LIMIT_REACHED`). Revoked tokens do not count towards the limit, so rotating never locks you out.
* `lastUsedAt` records when a token last authenticated, written at most once an hour. It is **not** written on the git smart-HTTP path, which has no execution context to defer the write to — so a token used only for `git clone` can show as never used.
### Managing tokens requires a session
[Section titled “Managing tokens requires a session”](#managing-tokens-requires-a-session)
`GET|POST /api/users/me/tokens`, `DELETE /api/users/me/tokens/{id}` and `POST /api/users/me/legacy-token/disable` accept the `stratum_session` cookie **only** — the cookie every web sign-in sets (magic link, GitHub, or Google OAuth). An API token calling them gets `403 SESSION_REQUIRED`, whatever its scope. A token that could mint tokens, revoke its siblings, or turn off the legacy credential would make revocation meaningless — a lost machine would simply issue itself a replacement.
The plaintext is returned exactly once, by the call that creates it, with `Cache-Control: no-store`. It is never stored and never listed again; a listing shows only the non-secret prefix (e.g. `stratum_user_1a2b3c4d`), which is enough to recognise a credential sitting in a CI config.
```bash
# Create a read-only token that expires in 90 days (session cookie required)
curl -X POST https://app.usestratum.dev/api/users/me/tokens \
-b "stratum_session=..." -H "Content-Type: application/json" \
-d '{"name":"buildkite","scope":"read","expiresInDays":90}'
```
### The legacy token
[Section titled “The legacy token”](#the-legacy-token)
**Every** account carries a single unnamed credential on the user row — it is minted at signup, though the plaintext is discarded unseen, so on a new account it sits inert until `POST /api/users/me/rotate-token` mints and returns a fresh one. Only accounts from before scoped tokens may have seen theirs. The credential works with `read_write` access and never expires. It is legacy for a reason: it has no name, no scope, no expiry, and no last-used record, rotating it invalidates whatever else is using it — and any account can activate it, so “we predate scoped tokens” is not the only way to end up depending on one. Prefer named scoped tokens for everything.
`POST /api/users/me/legacy-token/disable` (or the button in the settings UI) makes it permanently unusable, by rotating it to a value that is never returned to anyone. Named API tokens are unaffected. Move anything still using the legacy credential onto a named token first — disabling it cannot be undone, though `rotate-token` will mint a fresh legacy credential if you truly need one.
`rotate-token` accepts a browser session or the legacy credential itself, but refuses a **scoped** token or an **OAuth grant** with `SESSION_REQUIRED`. The key it mints never expires and cannot be revoked one at a time, so letting either rotate it would mean revoking them contained nothing — the credential could have issued itself a permanent replacement on the way out.
## OAuth grants
[Section titled “OAuth grants”](#oauth-grants)
Stratum is an OAuth 2.1 authorization server, and the remote MCP endpoint at `/mcp` is a protected resource. A client registers itself ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)), sends the user to a consent screen, and receives an access token — so a user connects an editor without ever pasting a credential into it.
Two first-party clients use this flow, and the grants they receive are the same kind of credential:
* **MCP clients** (editors, agent frameworks) — see [the MCP guide](/guides/mcp/).
* **The CLI**, since `stratum login` — see [the CLI guide](/guides/cli/). It registers as a public client with a loopback redirect ([RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252)) rather than an https one, because it runs on the user’s own machine.
An OAuth access token authenticates **everywhere a user token does**, which is what keeps the MCP tools from drifting away from the API they wrap — with the carve-outs listed below.
| | |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Access token prefix | `stratum_mcp_` |
| Lifetime | 1 hour, refreshed with a rotating 30-day refresh token. The refresh window slides on every rotation but is capped **180 days** from first issue, so an actively used grant still returns the user to the consent screen twice a year |
| Authorization code | 60 seconds, single use |
| Scopes | `mcp:read` (= a `read` token), `mcp:write` (= a `read_write` token) |
| PKCE | Required, `S256` only |
| Discovery | `/.well-known/oauth-protected-resource`, `/.well-known/oauth-authorization-server` |
An access token authenticates like any other bearer token, on every endpoint a user token reaches, and the `read`/`read_write` scope rules above apply unchanged. Two limits are specific to it, because a grant lives inside software the user does not control:
* **No admin API.** `/api/admin/*` returns `403 ADMIN_REQUIRES_DIRECT_CREDENTIAL` for an OAuth grant, even when the account behind it is the instance administrator.
* **No legacy-key minting.** Like a scoped token, it is refused by `rotate-token` with `SESSION_REQUIRED`.
Grants are listed and revoked by their owner under **Settings → Connected applications**, and by the client itself at `POST /oauth/revoke` ([RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009)). Revocation is immediate and covers both halves of the pair — revoking either the access token or the refresh token ends the grant.
## Anonymous access
[Section titled “Anonymous access”](#anonymous-access)
Read endpoints on projects with `visibility: public` accept anonymous requests. A small set of endpoints requires no authentication at all:
* `GET /health`, `GET /api/health`, `GET /api/health/simple` — liveness and dependency health checks
* `GET /api/users/check-username` — username availability
* `POST /api/webhooks/github` — the inbound GitHub webhook receiver, which is authenticated by HMAC signature (`X-Hub-Signature-256`) against the configured webhook secret rather than by a bearer token
## Admin API key
[Section titled “Admin API key”](#admin-api-key)
Administrator endpoints (metrics, audit, backup, restore, deletion jobs) additionally accept an `X-Admin-API-Key` header carrying the instance’s admin API key, configured by the instance operator. Alternatively an authenticated user whose email matches the instance’s `ADMIN_EMAIL` is treated as an administrator — but **not** when they authenticated with an MCP OAuth grant, which is refused on these routes before routing.
## Dev login
[Section titled “Dev login”](#dev-login)
For local development:
```bash
curl http://localhost:8787/dev-login
```
The route is gated on `DEV_LOGIN_ENABLED = "true"` **and** a `localhost` / `127.0.0.1` request host, checked as one condition so neither gate can be bypassed alone. The flag is `"true"` only in the top-level (local) `wrangler.toml` config and `"false"` in staging and production, where the route answers `403`.
# API Endpoints
> The Stratum REST API surface — projects, branches, workspaces, changes, reviews, issues, deployments, agents, users, and organizations.
* [Projects](#projects-api) — projects, branches, browsing, import, sync
* [Workspaces](#workspaces-api)
* [Changes](#changes-api) — the change lifecycle, merging, GitHub promotion
* [Reviews and Comments](#reviews-and-comments-api) — comment threads and review verdicts
* [Issues](#issues-api) — the built-in issue tracker
* [Deployments and Deploy Secrets](#deployments-and-deploy-secrets-api) — post-merge deployments, the approval gate, and the per-project secret store
* [Agents](#agents-api)
* [Users](#users-api) — profile, account deletion, API tokens
* [Organizations](#organizations-api)
The complete, authoritative surface — request and response schemas included — is the [OpenAPI specification](/reference/openapi/).
## Projects API
[Section titled “Projects API”](#projects-api)
### List Projects
[Section titled “List Projects”](#list-projects)
`GET /api/projects`
### Create Project
[Section titled “Create Project”](#create-project)
`POST /api/projects`
### Get Project
[Section titled “Get Project”](#get-project)
`GET /api/projects/{namespace}/{slug}`
### Import from GitHub
[Section titled “Import from GitHub”](#import-from-github)
`POST /api/projects/{namespace}/{slug}/import`
See the [Importing from GitHub guide](/guides/importing/) for the available options, how to track an import’s progress, and keeping the project in sync with its source.
### Delete Project
[Section titled “Delete Project”](#delete-project)
`DELETE /api/projects/{namespace}/{slug}`
Permanently deletes a project and **all** associated data (repo + workspace forks, changes, issues, events, metrics, webhooks). **Owner-only.** The request body must confirm the exact path:
```json
{ "confirm": "@namespace/slug" }
```
Returns `202 Accepted` with `{ "status": "deleting", "jobId": "del_…" }` — the cascade runs asynchronously and is idempotent/resumable. A mismatched `confirm` returns `400`; a non-owner returns `404`.
### List Branches
[Section titled “List Branches”](#list-branches)
`GET /api/projects/{namespace}/{slug}/branches`
Returns `{ defaultBranch, branches: [{ name, oid }], truncated, totalBranchCount }`. Read from the remote’s ref advertisement, so the cost does not grow with the branch count. Capped at 200 — `truncated` says so explicitly, and the default branch is never the entry dropped.
### Create Branch
[Section titled “Create Branch”](#create-branch)
`POST /api/projects/{namespace}/{slug}/branches`
```json
{ "name": "release/2.x", "startPoint": "trunk" }
```
`startPoint` may be a branch name or a full 40-character commit sha, and defaults to the default branch’s tip. Short shas and **tag names** are refused: a tag can point at a non-commit, its peeled commit is not advertised on the push path, and a tag may sit outside both the default branch’s history and the tag set a backup captures — so a branch created from one could fail to push or vanish on restore. The new ref can only point at an object the repository already holds, so branch creation cannot introduce content that has not passed the change gate.
**Writers only** (a non-writer gets `404`). `400` for an invalid name or an unresolvable start point. Three distinct `409`s: `BRANCH_EXISTS` if the name is taken, `BRANCH_NAME_CONFLICT` if it collides with an existing ref path (`release` when `release/2.x` exists — git stores refs as paths, so one cannot be both a file and a directory), and `NO_DEFAULT_BRANCH` if the configured default branch is not advertised by the remote and there is therefore nothing to default to.
### Delete Branch
[Section titled “Delete Branch”](#delete-branch)
`DELETE /api/projects/{namespace}/{slug}/branches/{name}`
Hierarchical names are passed as real path segments (`.../branches/release/2.x`); a percent-encoded slash (`release%2F2.x`) names the same branch, since each path segment is decoded individually. **Writers only.** The default branch cannot be deleted — `409 DEFAULT_BRANCH_PROTECTED`.
### Browsing a branch
[Section titled “Browsing a branch”](#browsing-a-branch)
`GET .../files`, `.../content`, and `.../log` accept `?ref=`, defaulting to the project’s default branch. Branch names only: an unknown ref is a `404` (never a silent fall back to the default), and a name that is both a branch and a tag is a `409 AMBIGUOUS_REF` rather than a guess at which was meant. The response echoes the `ref` actually read.
## Workspaces API
[Section titled “Workspaces API”](#workspaces-api)
### List Workspaces
[Section titled “List Workspaces”](#list-workspaces)
`GET /api/workspaces/{namespace}/{slug}/workspaces`
### Create Workspace
[Section titled “Create Workspace”](#create-workspace)
`POST /api/workspaces/{namespace}/{slug}/workspaces`
### Commit Changes
[Section titled “Commit Changes”](#commit-changes)
`POST /api/workspaces/{name}/commit`
## Changes API
[Section titled “Changes API”](#changes-api)
A change is a reviewable, evaluation-gated proposal to move a project’s default branch. See the [OpenAPI specification](/reference/openapi/) for exact schemas, and [Reviews and Comments](#reviews-and-comments-api) for the discussion layer on top of a change.
### List Changes
[Section titled “List Changes”](#list-changes)
`GET /api/projects/{name}/changes`
### Create Change
[Section titled “Create Change”](#create-change)
`POST /api/projects/{name}/changes`
Evaluation runs **synchronously** at creation, so a slow evaluator stretches the request. A workspace containing git submodules is refused here with a `400` carrying the explanatory message.
### Get Change
[Section titled “Get Change”](#get-change)
`GET /api/changes/{id}`
### Re-evaluate
[Section titled “Re-evaluate”](#re-evaluate)
`POST /api/changes/{id}/evaluate`
Users only. Merged, rejected, and promoted changes cannot be re-evaluated.
Re-evaluation runs under the **current** evaluator defaults, so a change that passed under older limits may fail. Approvals are dismissed when the evaluated base moves, not only when the tip does.
### Reject
[Section titled “Reject”](#reject)
`POST /api/changes/{id}/reject`
Users only. Merged changes cannot be rejected.
### Merge
[Section titled “Merge”](#merge)
`POST /api/changes/{id}/merge`
Runs the full merge gate. Common refusals (see [Error codes](/reference/errors/) for the full list and the response shapes):
| Code | Meaning |
| ------------------------ | ------------------------------------------------------------------- |
| `STALE_BASE` | The recorded base is behind project HEAD (`merge.requireFreshBase`) |
| `STALE_WORKSPACE` | The workspace advanced after evaluation |
| `WORKSPACE_UNVERIFIABLE` | Workspace state could not be verified against what was evaluated |
| `PROTECTION_BLOCKED` | Blocked by branch protection; the response lists `reasons` |
| `MERGE_CONFLICT` | Conflicts; the response carries a conflict id |
### Merge a Batch
[Section titled “Merge a Batch”](#merge-a-batch)
`POST /api/projects/{name}/changes/merge-batch`
Resolves and policy-gates every change, then merges the eligible ones onto the project head with a **single push** (see [ADR 004](https://github.com/stratum-eng/stratum/blob/main/docs/adr/004-high-frequency-agent-commits.md)). At most 80 changes per request, and requires the RepoDO backend. `force` is deny-by-default and must be allowed by project policy (`merge.allowForce: true`).
### Promote to a GitHub PR
[Section titled “Promote to a GitHub PR”](#promote-to-a-github-pr)
`POST /api/changes/{id}/github-pr`
Users only. The change must be `accepted` (or already promoted), and the project must be connected to GitHub with a configured token. Creates a PR — **draft by default** — from branch `stratum/{changeId}` and marks the change `promoted`.
```json
{ "title": "…", "body": "…", "draft": true }
```
The PR base is **always the project’s own recorded default branch** and is not accepted from the request body: this endpoint acts with the instance-wide GitHub token, so honouring a caller-supplied base would let any caller aim that shared credential at a branch of its choosing.
Once a change has a linked PR, each evaluation posts the verdict to the PR as a comment (edited in place on re-evaluation) and as a `stratum/evaluation` commit status.
## Reviews and Comments API
[Section titled “Reviews and Comments API”](#reviews-and-comments-api)
Comment threads and human review verdicts on a change. See the [Code Review user guide](/guides/code-review/) for the concepts, and the [OpenAPI specification](/reference/openapi/) for exact schemas.
### Add a Comment
[Section titled “Add a Comment”](#add-a-comment)
`POST /api/changes/{id}/comments`
Read access to the project is enough — users **and agents** may comment. `body` max 20,000 chars.
```json
{ "body": "…", "file": "src/x.ts", "line": 214, "side": "new", "commitSha": "a1b2…" }
```
* `file` and `line` must be passed **together** to anchor the comment to a diff line. `line` is 1-based.
* `side` (`old` | `new`) and `commitSha` are accepted **only** alongside a `file`+`line` anchor.
* `parentCommentId` posts a reply into an existing thread. The parent must belong to the same change, replies inherit the thread’s anchor (so anchor fields must be omitted), and a reply to a reply is flattened onto the thread root.
### List Comments
[Section titled “List Comments”](#list-comments)
`GET /api/changes/{id}/comments`
### Resolve a Thread
[Section titled “Resolve a Thread”](#resolve-a-thread)
`POST /api/changes/{id}/comments/{commentId}/resolve`
### Unresolve a Thread
[Section titled “Unresolve a Thread”](#unresolve-a-thread)
`POST /api/changes/{id}/comments/{commentId}/unresolve`
Allowed for **project writers or the comment’s author**. Only a thread *root* can be resolved — resolving a reply is refused. Resolution is bookkeeping and does not gate a merge.
### Submit a Review
[Section titled “Submit a Review”](#submit-a-review)
`POST /api/changes/{id}/reviews`
**Users only** — agent tokens are refused, on every surface. Requires **write** access to the project. Valid on changes in `open`, `needs_changes`, `accepted`, or `approved`.
```json
{ "verdict": "approve", "comment": "…" }
```
| `verdict` | Effect |
| ----------------- | ------------------------------------------------------ |
| `approve` | Change → `approved`; counts toward `requiredApprovals` |
| `request_changes` | Change → `needs_changes` |
| `comment` | Comment-only; status untouched |
A `comment` verdict **requires** a non-empty `comment` (else `400`). It never counts toward required approvals, never blocks a merge, and never replaces an existing `approve`/`request_changes` verdict by the same reviewer — a verdict row is recorded only if that reviewer has none yet. Its text is written to the change’s discussion and emits `change.commented`.
The response carries `changeStatus`: the new status after an `approve`/`request_changes`, or the unchanged status after a `comment`.
The **change author’s own approval never counts** toward `requiredApprovals`; for an agent-created change the excluded author is the agent’s owning user.
### List Reviews
[Section titled “List Reviews”](#list-reviews)
`GET /api/changes/{id}/reviews`
## Issues API
[Section titled “Issues API”](#issues-api)
Issues are numbered per project and addressed by that number. See the [Issues user guide](/guides/issues/) for the concepts, and the [OpenAPI specification](/reference/openapi/) for exact schemas.
### Open an Issue
[Section titled “Open an Issue”](#open-an-issue)
`POST /api/projects/{namespace}/{slug}/issues`
Read access is enough to open an issue. Also accepts form-encoded bodies, which redirect on success instead of returning JSON.
```json
{ "title": "…", "body": "…", "linkedChangeId": "chg_123" }
```
`title` max 200 chars, `body` max 20,000. `linkedChangeId` must reference a change in the same project. `409 TARGET_DELETING` if the project is being deleted.
### List Issues
[Section titled “List Issues”](#list-issues)
`GET /api/projects/{namespace}/{slug}/issues`
Anonymous on public projects. Query parameters, all combinable:
| Parameter | Meaning |
| ---------- | ------------------------------------------------------------------------------------- |
| `status` | `open` or `closed` |
| `label` | Issues carrying this exact label |
| `assignee` | Issues assigned to this user id |
| `q` | Case-insensitive substring over title + body, max 200 chars (`%` and `_` are literal) |
| `limit` | Default 100, max 500 |
| `offset` | Rows to skip |
Ordered newest-first by issue number.
### Get an Issue
[Section titled “Get an Issue”](#get-an-issue)
`GET /api/projects/{namespace}/{slug}/issues/{number}`
### Update an Issue
[Section titled “Update an Issue”](#update-an-issue)
`PATCH /api/projects/{namespace}/{slug}/issues/{number}`
Requires **write** access **and a user identity** — an agent token is refused with `401`. This is the editing and labelling path, so an agent can open an issue and comment on it but cannot retitle, reassign, relabel, or close one. Only the fields present in the body are written.
```json
{ "title": "…", "body": "…", "status": "closed",
"assignee": "usr_abc", "labels": ["bug"], "linkedChangeId": null }
```
`labels` replaces the entire set (max 20 per issue, 50 chars each). `assignee` and `linkedChangeId` accept `null` to clear.
### Close an Issue
[Section titled “Close an Issue”](#close-an-issue)
`POST /api/projects/{namespace}/{slug}/issues/{number}/close`
Requires **write** access and a user identity. No body. Despite the name it is a **toggle**: it flips an open issue closed and a closed issue back open, and answers with a `302` redirect (it backs the UI button). For an idempotent close from a script, use `PATCH {"status": "closed"}` instead.
Issues linked to a change also close **automatically** when that change merges, attributed to `system`.
### Add a Comment
[Section titled “Add a Comment”](#add-a-comment-1)
`POST /api/projects/{namespace}/{slug}/issues/{number}/comments`
Read access is enough. `body` max 20,000 chars.
### List Comments
[Section titled “List Comments”](#list-comments-1)
`GET /api/projects/{namespace}/{slug}/issues/{number}/comments`
Paginated with `limit` (default 100, max 500) and `offset`.
## Deployments and Deploy Secrets API
[Section titled “Deployments and Deploy Secrets API”](#deployments-and-deploy-secrets-api)
Post-merge deployments and the encrypted per-project secret store that feeds them. See the [Deployments user guide](/guides/deployments/) for the concepts — the `deploys:` policy block, the three targets, limits, and what each status means — and the [OpenAPI specification](/reference/openapi/) for exact schemas.
Two authorization rules here are stricter than the usual project read/write split:
* **Every secret route refuses agent identities**, even when the agent’s owner is a project admin. Deploy credentials are the one thing an agent token is never trusted with.
* **`logTail` is served to project writers only.** It carries a redacted provider payload, and redaction is literal-substring matching.
No route on either router returns a secret value. There is no read path for a stored secret anywhere in the API.
### An agent token gets `403` from a secret route and `401` from approve
[Section titled “An agent token gets 403 from a secret route and 401 from approve”](#an-agent-token-gets-403-from-a-secret-route-and-401-from-approve)
Both routes refuse an agent identity, but with different status codes, and the difference is deliberate rather than an inconsistency to be normalized away:
| Caller | Secret routes | `POST /api/deployments/{id}/approve` |
| -------------------------------- | ---------------------------------------------------- | ------------------------------------------------------- |
| Anonymous / no credential | `401` | `401` |
| Agent token (`stratum_agent_…`) | `403 Agent credentials cannot manage deploy secrets` | `401 Only authenticated users can approve a deployment` |
| User without the required access | `403` | `403 Project access denied` |
A secret route checks `agentId` **first**, so an agent is a recognized principal being told what it may not do — “we know exactly who you are, and this is not yours”: `403`. Approve checks only for a `userId`, which an agent token never sets, so an agent falls into the same branch as an unauthenticated caller — “we do not know who you are *as a user*”: `401`. Neither code is retryable with the same credential; both mean “use a user identity”.
### Deploy secrets
[Section titled “Deploy secrets”](#deploy-secrets)
#### List secret names
[Section titled “List secret names”](#list-secret-names)
`GET /api/projects/{namespace}/{slug}/secrets`
Requires **project admin** and a non-agent identity: `403` for an agent token (and for a user who is not an admin), `401` for a caller with no user identity at all. Returns names and metadata only:
```json
{ "secrets": [
{ "name": "CLOUDFLARE_API_TOKEN", "createdBy": "usr_abc", "updatedBy": "usr_abc",
"createdAt": "2026-09-01T10:00:00.000Z", "updatedAt": "2026-09-01T10:00:00.000Z" }
] }
```
#### Create or replace a secret
[Section titled “Create or replace a secret”](#create-or-replace-a-secret)
`PUT /api/projects/{namespace}/{slug}/secrets/{name}`
```json
{ "value": "…" }
```
`name` must match `^[A-Z][A-Z0-9_]{0,63}$`; `value` is capped at 4096 bytes. Writing an existing name replaces it. Audited as `secret.written`.
Answers `500 DEPLOY_SECRET_KEY_MISSING` when the instance has no `DEPLOY_SECRET_KEY` configured — secrets cannot be encrypted without it.
#### Create or replace a secret (form-friendly)
[Section titled “Create or replace a secret (form-friendly)”](#create-or-replace-a-secret-form-friendly)
`POST /api/projects/{namespace}/{slug}/secrets`
The browser’s way in to the PUT above, which an HTML form cannot issue. Takes `name` and `value` in the body and runs the same authorization gate. A form-encoded caller is redirected back to the project settings page with a fixed error code in the query string; a JSON caller gets JSON.
#### Delete a secret
[Section titled “Delete a secret”](#delete-a-secret)
`DELETE /api/projects/{namespace}/{slug}/secrets/{name}`
`404` if the project has no such name. Audited as `secret.deleted`.
#### Delete a secret (form-friendly)
[Section titled “Delete a secret (form-friendly)”](#delete-a-secret-form-friendly)
`POST /api/projects/{namespace}/{slug}/secrets/{name}/delete`
Browsers cannot issue DELETE. Same gate; a form caller lands back on the settings page, including when the name was already gone.
### Deployments
[Section titled “Deployments”](#deployments)
#### List deployments
[Section titled “List deployments”](#list-deployments)
`GET /api/projects/{namespace}/{slug}/deployments`
Readable by anyone who can read the project (anonymously, for a public project). Newest first.
| Parameter | Meaning |
| --------- | ---------------------------------------------------------------------------------------------- |
| `name` | Only deployments for this `deploys:` entry name |
| `status` | One of `pending_approval`, `queued`, `running`, `succeeded`, `failed`, `superseded`, `skipped` |
| `limit` | Default 50, max 200 |
| `offset` | Rows to skip |
`logTail` is present only when the caller can write to the project.
#### Get a deployment
[Section titled “Get a deployment”](#get-a-deployment)
`GET /api/deployments/{id}`
The URL carries no project; the row’s own project is what gets authorized. A deployment in a project the caller cannot read answers `404`, not `403`, so an id cannot be confirmed by probing.
#### Approve a deployment
[Section titled “Approve a deployment”](#approve-a-deployment)
`POST /api/deployments/{id}/approve`
Releases a `pending_approval` deployment: the row moves to `queued` and is enqueued. Requires project **write** access and a **user** identity — an agent token is refused. This is the same guarantee (and the same limitation) as a human review verdict: a user’s scoped API token and MCP OAuth grants are accepted, so it means “not an agent”, not “a human at a keyboard”.
* `401` — no `userId` on the request. This covers both an anonymous caller and an agent token, because an agent token sets `agentId` and never `userId`; unlike the secret routes, this check does not look at `agentId` at all. See [the note above](#an-agent-token-gets-403-from-a-secret-route-and-401-from-approve).
* `403` — a user identity that cannot write to the deployment’s project.
* `409 DEPLOYMENT_NOT_PENDING` — the row already left `pending_approval` (a second approver lands here rather than deploying the commit twice).
* `503 DEPLOY_QUEUE_UNAVAILABLE` — the instance has no `DEPLOY_QUEUE` binding. Checked *before* the status flip, since approval is not repeatable.
**A failed enqueue is not an error.** If the queue send is rejected after the row has moved to `queued`, the request is written to the event outbox and the call still succeeds — the five-minute sweep starts it. This is deliberate: the row is already `queued`, so `approve` would refuse a second attempt and `retry` only accepts terminal rows, which made the old “try again” advice impossible to act on. A `500` now means the outbox write failed too, i.e. nothing durable was recorded.
Audited as `deployment.approved`. A form-encoded caller gets a `302` back to the deployment page.
#### Retry a deployment
[Section titled “Retry a deployment”](#retry-a-deployment)
`POST /api/deployments/{id}/retry`
Creates a **new attempt row** for the same commit with `attempt` incremented, and enqueues it. Requires project write access; unlike approve, an agent identity is accepted. Answers `201` with the new row.
* `409 DEPLOYMENT_NOT_RETRYABLE` — the deployment is not in a terminal status. A `pending_approval` row in particular cannot be retried, because the retry row would start `queued` and route around the approval gate.
* `409 DEPLOYMENT_RETRY_EXISTS` — someone else already created this attempt.
* `503 DEPLOY_QUEUE_UNAVAILABLE` — as above.
A failed enqueue is recorded to the outbox and still answers `201`, for the same reason as approve: the new attempt row already exists and is `queued`, so a repeat call would only collide with the unique index on `(project, name, commit, attempt)`.
The originating change id is carried onto the retry, so the “was this change reverted?” check still applies to it. Audited as `deployment.retried`.
### Events
[Section titled “Events”](#events)
Deployments emit `deployment.requested`, `deployment.succeeded` and `deployment.failed` onto the project’s event stream; all three are subscribable from a project webhook.
## Agents API
[Section titled “Agents API”](#agents-api)
An agent is a non-human contributor registered by a user. It authenticates with an agent token and inherits its owner’s project access. See [Authentication](/reference/authentication/) for how agent tokens differ from a user’s scoped API tokens.
### List Agents
[Section titled “List Agents”](#list-agents)
`GET /api/agents`
### Create Agent
[Section titled “Create Agent”](#create-agent)
`POST /api/agents`
Returns the new agent alongside its token as `token` — the **only** time the plaintext is ever returned. Neither `GET /api/agents` nor `GET /api/agents/{id}` includes it, and there is no way to re-read it; a lost token means deleting the agent and registering a new one.
Agent tokens **do not expire** and are not scoped. The only way to revoke one is to delete the agent.
### Get Agent
[Section titled “Get Agent”](#get-agent)
`GET /api/agents/{id}`
### Delete Agent
[Section titled “Delete Agent”](#delete-agent)
`DELETE /api/agents/{id}`
Deletes the agent row, which is what revokes its token — **the only revocation path for a credential that never expires.** Requires the owning user’s identity: `401` without a user identity (an agent token sets `agentId` and never `userId`, so an agent cannot delete itself or any sibling), `403` for a user who is not the owner, `404` for an unknown id. Audited as `agent.revoked`.
Deleting the owning user’s account revokes their agents too, as part of the account cascade ([Delete Account](#delete-account)).
## Users API
[Section titled “Users API”](#users-api)
Token management is covered in full — scopes, expiry, limits, and the legacy credential — in [Authentication](/reference/authentication/).
### Get Current User
[Section titled “Get Current User”](#get-current-user)
`GET /api/users/me`
Returns the authenticated user’s profile.
### API tokens
[Section titled “API tokens”](#api-tokens)
Listing, creating, revoking, and disabling the legacy token accept the **browser session cookie only**. An API token calling those gets `403 SESSION_REQUIRED`, whatever its scope — a token that could mint or revoke tokens would make revocation meaningless. `rotate-token` is the exception and has its own rule, described with it below.
#### List Tokens
[Section titled “List Tokens”](#list-tokens)
`GET /api/users/me/tokens`
Shows each token’s name, scope, expiry, `lastUsedAt`, and non-secret prefix. The plaintext is never listed again.
#### Create Token
[Section titled “Create Token”](#create-token)
`POST /api/users/me/tokens`
```json
{ "name": "buildkite", "scope": "read", "expiresInDays": 90 }
```
`scope` is `read` (default) or `read_write`. `expiresInDays` is 1–365; omit for a token that never expires. The plaintext is returned **exactly once**, by this call, with `Cache-Control: no-store`. `409 TOKEN_LIMIT_REACHED` past 20 active tokens.
#### Revoke Token
[Section titled “Revoke Token”](#revoke-token)
`DELETE /api/users/me/tokens/{id}`
Revoked rows are kept, so the audit trail survives revocation, and they do not count toward the 20-token limit.
#### Disable the Legacy Token
[Section titled “Disable the Legacy Token”](#disable-the-legacy-token)
`POST /api/users/me/legacy-token/disable`
Permanently disables the single unnamed credential every account carries on its user row (minted at signup; revealed only by `rotate-token`). Named tokens are unaffected. **Cannot be undone** — move anything still using it onto a named token first.
#### Rotate the Legacy Token
[Section titled “Rotate the Legacy Token”](#rotate-the-legacy-token)
`POST /api/users/me/rotate-token`
Accepts a browser session **or the legacy credential itself**, but refuses a **scoped** token with `SESSION_REQUIRED`. The key it mints never expires and cannot be revoked individually, so letting a scoped token rotate it would mean revoking that token contained nothing — it could have issued itself a permanent replacement on the way out.
### Delete Account
[Section titled “Delete Account”](#delete-account)
`DELETE /api/users/me`
GDPR-grade account erasure. Deletes the caller’s account and **all** owned projects, revokes all tokens/sessions (and the user’s agents), and **anonymizes** the user’s contributions to *other* people’s projects (author set to a `deleted-user` tombstone — the contribution stays, the identity is removed). Requires confirmation with the caller’s own username:
```json
{ "confirm": "" }
```
Setting deletion **immediately invalidates the caller’s credentials** (subsequent requests return `401`). Returns `202 Accepted` with `{ "status": "deleting", "jobId": "del_…" }`; the cascade runs asynchronously and always completes (org sole-ownership is auto-resolved, never blocking erasure). A mismatched `confirm` returns `400`.
## Organizations API
[Section titled “Organizations API”](#organizations-api)
### List Organizations
[Section titled “List Organizations”](#list-organizations)
`GET /api/orgs`
### Create Organization
[Section titled “Create Organization”](#create-organization)
`POST /api/orgs`
### Members and teams
[Section titled “Members and teams”](#members-and-teams)
Members and teams are managed under `/api/orgs/{slug}/members` and `/api/orgs/{slug}/teams` — add and remove a member, create, list and delete a team, and add and remove a team’s members. See the [OpenAPI specification](/reference/openapi/) for the exact routes and schemas.
# Error Codes
> HTTP status codes and the machine-readable error codes Stratum returns.
Errors are returned as JSON: `{ "error": "" }`, with a machine-readable `code` field present on some errors. The per-endpoint status codes are in the [OpenAPI specification](/reference/openapi/).
## HTTP Status Codes
[Section titled “HTTP Status Codes”](#http-status-codes)
| Code | Meaning |
| --------- | ----------------------------------------------------------------------------------------------------------------------- |
| 200 | OK |
| 201 | Created |
| 202 | Accepted — the work runs asynchronously (e.g. project/account deletion) |
| 302 / 303 | Redirect — form-encoded requests to form-friendly endpoints redirect instead of returning JSON |
| 400 | Bad Request |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | Not Found |
| 409 | Conflict — merge staleness and merge conflicts (see codes below) |
| 410 | Gone — the resource was deleted |
| 413 | Payload Too Large — a request body exceeded its cap (`PAYLOAD_TOO_LARGE`; the git and `/mcp` paths have their own caps) |
| 422 | Unprocessable — the request was understood but cannot be applied |
| 429 | Rate Limited |
| 500 | Server Error |
| 501 | Not Implemented — an optional integration is not configured on this instance (e.g. GitHub/Google OAuth) |
| 502 | Bad Gateway — an upstream provider call failed |
| 503 | Service Unavailable — a dependency is unhealthy, or an optional binding is absent (see `DEPLOY_QUEUE_UNAVAILABLE`) |
| 504 | Gateway Timeout — an operation exceeded its bound (see `PUSH_TIMEOUT`) |
## Machine-readable error codes
[Section titled “Machine-readable error codes”](#machine-readable-error-codes)
* Caller authentication failures carry **no machine-readable code**: a `401` is `{"error": "Invalid token"}` (or similar) with no `code` field. Branch on the status, not a code. (`AUTH_ERROR` exists internally but surfaces only when the *instance’s* outbound GitHub credential fails during a sync push — it never describes your own authentication.)
* `404`s likewise usually carry no code — `{"error": "..."}` only — and an unauthorized private project is deliberately indistinguishable from a missing one. There is no `PROJECT_NOT_FOUND` code; where a code does appear on a 404 it is the generic `NOT_FOUND`.
* Rate limiting carries **no machine-readable code**: a `429` has `{"error": "Too many requests"}` plus `Retry-After`, `X-RateLimit-Limit`, and `X-RateLimit-Remaining` headers
* `TOKEN_SCOPE_INSUFFICIENT` — a `read` token was used for a write. Returned `403`, checked before routing on the HTTP method, on API routes. The git smart-HTTP router enforces the same rule on the resolved operation (so `git push` and its `git-receive-pack` advertisement are refused too) but answers with its deliberate plain-text `404` — never this code — so an unauthorized caller cannot distinguish “no permission” from “no such repo”
* `TOKEN_LIMIT_REACHED` — `409`; the account already holds 20 active tokens. Revoked tokens do not count toward the limit
* `SESSION_REQUIRED` — `403`; the endpoint accepts the browser session cookie only. Applies to token management (`/api/users/me/tokens`, `legacy-token/disable`) and to `rotate-token` when called with a scoped token
* `SESSION_EXPIRED` — `401`; the browser session is no longer valid
* `ADMIN_REQUIRES_DIRECT_CREDENTIAL` — `403`; an MCP/CLI OAuth grant was used on `/api/admin/*`. Refused before routing, even when the account behind the grant is the instance administrator
* `CSRF` — `403`; a `POST`/`PUT`/`PATCH`/`DELETE` arrived with an `Origin` or `Referer` that is missing, malformed, or names a different host. Enforced for session-cookie callers and for the few unauthenticated endpoints that must not be forgeable (magic-link verify); bearer-token callers are unaffected
* `SYNC_DIVERGED` — the upstream and the Stratum copy have genuinely diverged in content. The fetch window is deepened incrementally first, so this is a real conflict rather than a too-shallow clone
* `PINNED_SHA_UNREACHABLE` — the pinned commit is not reachable in the clone
* `PUSH_TIMEOUT` — `504` during restore. The push **may still land** — do not assume it failed
* `STALE_BASE` — the change’s recorded base is behind the project HEAD (`merge.requireFreshBase`); re-evaluate on the new base
* `STALE_WORKSPACE` — the workspace advanced after evaluation; the merge is rejected so unevaluated commits can never land
* `STALE_PROJECT` — `409`; the project’s default branch advanced between the point a manual conflict resolution was evaluated and the point it would have been committed. The same principle as `STALE_WORKSPACE` from the other side: the resolution would otherwise have landed on a base no evaluator saw. Nothing is committed — re-resolve against the current revision
* `WORKSPACE_UNVERIFIABLE` — the workspace state could not be verified against what was evaluated
* `MERGE_CONFLICT` — the merge produced conflicts; the response includes a conflict id for `POST /api/projects/conflicts/{id}/resolve`
* `PROTECTION_BLOCKED` — `403` (not a 409, despite sitting beside the staleness codes); the merge is blocked by branch protection and the response lists the `reasons`. A policy that names an evaluator whose binding is absent — most commonly `sandbox`, whose `[[sandboxes]]` binding is commented out by default — fails closed rather than being skipped, so it shows up here permanently until the binding is enabled or the evaluator is dropped from the policy
* `TARGET_DELETING` — `409`; the project (or its owner) is being deleted
* `NOT_REDRIVABLE` — `409`; the deletion job is not in an incomplete state
* `IMPORT_IN_PROGRESS` — `409`; the project’s import has not finished, so the requested operation would race it
* `GONE` — `410`; the resource was deleted
* `INVALID_PATH` — `422`; the requested path is invalid (e.g. a conflict resolution naming a path outside the repo)
* `AMBIGUOUS_REF` — `409`; a `?ref=` names something that is both a branch and a tag, so the read is refused rather than guessing
* `BRANCH_EXISTS` — `409`; a branch of that name already exists
* `BRANCH_NAME_CONFLICT` — `409`; the name collides with an existing ref *path* (`release` when `release/2.x` exists — git stores refs as paths)
* `NO_DEFAULT_BRANCH` — `409`; the configured default branch is not advertised by the remote, so there is nothing to default a start point to
* `DEFAULT_BRANCH_PROTECTED` — `409`; the default branch cannot be deleted
* `SUBMODULES_UNSUPPORTED` — a gitlink entry (any depth) and/or a root-level `.gitmodules` file was found on import, or in the workspace a change is being created from — a gated push and `POST /api/projects/{name}/changes` alike, since both run the same scan; git submodules are not supported (see [Importing from GitHub](/guides/importing/#unsupported-content)). This code is internal and is **not** returned to API clients as a code: `POST /api/projects/{name}/changes` reports it as a plain 400 carrying the explanatory message, a gated push reports it over the git protocol as a per-ref `ng` reason, and an import records a `failed` queue job. Match on the message, not on this identifier.
### Post-merge deployments
[Section titled “Post-merge deployments”](#post-merge-deployments)
* `DEPLOY_QUEUE_UNAVAILABLE` — `503`; the instance has no `DEPLOY_QUEUE` binding, so deployments are not enabled here. Checked before the row’s status is changed, so approving or retrying is still possible once it is configured
* `DEPLOYMENT_NOT_PENDING` — `409`; the deployment is not awaiting approval (it already ran, or someone else approved it first)
* `DEPLOYMENT_NOT_RETRYABLE` — `409`; only a finished deployment can be retried. A `queued`, `running` or `pending_approval` row still has a future, and retrying one would publish the same commit twice
* `DEPLOYMENT_RETRY_EXISTS` — `409`; that attempt number already exists — someone retried first, and their row is the live one
### Manual conflict resolution
[Section titled “Manual conflict resolution”](#manual-conflict-resolution)
`POST /api/projects/conflicts/{id}/resolve` runs the same gates a change does, and reports them with its own codes:
* `SECRET_DETECTED` — `422`; the secret scan rejected the resolution; the response carries `issues`
* `EVALUATION_FAILED` — `422`; the evaluator suite rejected the resolution; the response carries `issues`
* `EVAL_PREP_FAILED` — `502`; the diff the evaluators would judge could not be built
* `INVALID_INPUT` — `400`/`422`; the resolution body is malformed, names an unknown strategy, or carries a file over the 10 MB per-file cap
### Generic codes
[Section titled “Generic codes”](#generic-codes)
Anything thrown as an `AppError` and not matched above surfaces with its own code and status: `VALIDATION_ERROR` (`400`), `NOT_FOUND` (`404`), `FORBIDDEN` (`403`), `CONFLICT` (`409`), `PAYLOAD_TOO_LARGE` (`413`), `EXTERNAL_SERVICE_ERROR` / `GITHUB_ERROR` / `GIT_PROTOCOL` (`502`), and `INTERNAL_ERROR` (`500`). Treat these as families, not as a stable contract for a specific endpoint — the coded errors above are the ones worth branching on.
Separately, an **import job** records a failure *category* on the job row — `NETWORK_ERROR`, `TIMEOUT`, `AUTH_ERROR`, `NOT_FOUND`, `RATE_LIMITED`, `UNSUPPORTED_CONTENT`, `STORAGE_ERROR`, `GIT_ERROR`, `CANCELLED`, `UNKNOWN_ERROR` — classified from the failure message. As *categories* these are not API error responses: they appear on the admin metrics API and in the failure notification, not as a `code` on a request you made.
Two of those names do also exist as response codes in their own right, so match on the endpoint rather than on the name alone:
* `GIT_ERROR` — `500`; a git operation the request depended on could not be completed. Returned by `POST /api/projects/conflicts/{id}/resolve` when the project’s current revision cannot be read, so the evaluated base cannot be verified. The request was well formed; retrying can succeed
* `AUTH_ERROR` — `401`; the repository credential was rejected or has expired
# OpenAPI specification
> The complete, machine-readable Stratum REST API specification.
The complete REST API surface is described by an OpenAPI 3 specification. The source of truth lives in the repository at [`docs/api/openapi.yml`](https://github.com/stratum-eng/stratum/blob/main/docs/api/openapi.yml) and is published with this site:
* **[Download `openapi.yml`](/openapi.yml)**
Point any OpenAPI tooling at it — Swagger UI, Redoc, Postman/Insomnia imports, or client generators such as `openapi-generator` and `openapi-typescript`.
```bash
# Generate TypeScript types for the API
npx openapi-typescript https://docs.usestratum.dev/openapi.yml -o stratum-api.d.ts
```