Recipe format — your CI/CD pipeline as code
Your repo opts in by shipping .slipstream.json at the root. The control plane validates
it with the exact schema rendered below — also published as JSON Schema at
/schema/recipe-v1.json, so you can point your
editor's $schema at it for inline validation and autocomplete.
Rules the schema cannot express, enforced at validation time: at most 16 leaf stages,
stage names must be unique, and stages may be empty only when extends names a preset.
| Field | Type | Required | Constraints |
|---|---|---|---|
$schema | string | no | — |
version | constant 1 | yes | — |
extends | string | no | pattern ^[a-z][a-z0-9-]*@\d+$ |
kind | "ci" | "deploy" | "artifact" | no | — |
env | object | no | — |
secrets | array | no | min items 1, max items 8 |
disable | array | no | — |
envKey | string | no | min length 1, max length 128 |
toolRepos | array | no | max items 8 |
stages | array | yes | min items 0 |
Raw JSON Schema
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://slipstream.dev/schema/recipe-v1.json",
"title": "RunsGreen recipe v1",
"type": "object",
"properties": {
"$schema": {
"type": "string",
"format": "uri"
},
"version": {
"type": "number",
"const": 1
},
"extends": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*@\\d+$"
},
"kind": {
"type": "string",
"enum": [
"ci",
"deploy",
"artifact"
]
},
"env": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"maxLength": 4096
}
},
"secrets": {
"minItems": 1,
"maxItems": 8,
"type": "array",
"items": {
"type": "object",
"properties": {
"from": {
"type": "string",
"const": "infisical"
},
"projectId": {
"type": "string",
"minLength": 1
},
"env": {
"type": "string",
"minLength": 1
},
"path": {
"type": "string",
"minLength": 1
},
"tokenEnv": {
"type": "string",
"pattern": "^[A-Z0-9_]{1,64}$"
}
},
"required": [
"from",
"projectId",
"env",
"path"
],
"additionalProperties": false
}
},
"disable": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 40
}
},
"envKey": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"toolRepos": {
"maxItems": 8,
"type": "array",
"items": {
"type": "string",
"pattern": "^[\\w.-]+\\/[\\w.-]+$"
}
},
"stages": {
"minItems": 0,
"type": "array",
"items": {
"anyOf": [
{
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 40
},
"cmd": {
"type": "string",
"minLength": 1,
"maxLength": 500
},
"env": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"maxLength": 4096
}
},
"timeout_s": {
"type": "integer",
"minimum": 30,
"maximum": 1800
},
"secrets": {
"type": "boolean"
},
"affected": {
"type": "boolean"
},
"if_changed": {
"maxItems": 64,
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 4096
}
},
"coverage_summary": {
"type": "string",
"minLength": 1,
"maxLength": 256
},
"after": {
"type": "string",
"minLength": 1,
"maxLength": 40
}
},
"required": [
"name",
"cmd"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"parallel": {
"minItems": 1,
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 40
},
"cmd": {
"type": "string",
"minLength": 1,
"maxLength": 500
},
"env": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"maxLength": 4096
}
},
"timeout_s": {
"type": "integer",
"minimum": 30,
"maximum": 1800
},
"secrets": {
"type": "boolean"
},
"affected": {
"type": "boolean"
},
"if_changed": {
"maxItems": 64,
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 4096
}
},
"coverage_summary": {
"type": "string",
"minLength": 1,
"maxLength": 256
},
"after": {
"type": "string",
"minLength": 1,
"maxLength": 40
}
},
"required": [
"name",
"cmd"
],
"additionalProperties": false
}
}
},
"required": [
"parallel"
],
"additionalProperties": false
}
]
}
}
},
"required": [
"version",
"stages"
],
"additionalProperties": false
}Worked example
A typical recipe: install once, then test in parallel, with one secrets-gated integration stage at the end.
{
"version": 1,
"env": { "CI": "true" },
"stages": [
{ "name": "install", "cmd": "bun install --frozen-lockfile", "timeout_s": 600 },
{
"parallel": [
{ "name": "unit", "cmd": "bun test --timeout 20000" },
{ "name": "typecheck", "cmd": "bunx tsc --noEmit" }
]
},
{ "name": "integration", "cmd": "bun test integration/", "secrets": true, "timeout_s": 300 }
]
}
Each stage is a named shell command. Stages run in order; a parallel group runs its
stages concurrently and the run proceeds when all of them pass. A stage with
"secrets": true receives the secret bundle configured for your repo. See
push to green for how a run executes these stages.
timeout_s is checked against your own run history
timeout_s (30–1800) is the wall the executor kills the stage at on every run. Agentic Pipeline refuses a
value your repo's own successful runs prove is too tight: it compares your declared
timeout against the p95 duration it has measured for a stage of that name, and if the
timeout is below that p95, validation fails and names the value to set instead
(ceil(p95 × 1.5)). You get the same answer from slipstream_recipe_validate before you
push and from the check if you push anyway — a slightly-too-tight timeout otherwise fails
intermittently, which costs several red runs to diagnose a number the platform already knew.
Two things worth knowing:
- Lowering a timeout on an unchanged recipe never newly fails. A successful run can never have taken longer than the timeout it ran under, so a stage that has always had this timeout has no measurement above it and is never refused.
- The history is keyed by stage NAME. If you repurpose a stage name for a different
command, it is judged on the old command's durations. Rename the stage, or ask an
operator to set
slipstream.recipe_timeout_gate.<owner>/<repo>to0with a reason.
Per-run environment
Every stage — even "secrets": false ones — receives these non-secret
coordinates for the run:
| Variable | Value |
|---|---|
SLIPSTREAM_SHA | The commit SHA being built |
SLIPSTREAM_REPO | owner/repo |
SLIPSTREAM_RUN_ID | The run's id (log key, dashboard) |
SLIPSTREAM_TOOLS_DIR | Path to the warm per-run tools cache |
Each is also exported under the legacy NATIVE_* name (NATIVE_SHA, …) as a
permanent alias, so existing recipes keep working; prefer the SLIPSTREAM_*
spelling in new recipes.
Declared runtimes
A recipe may declare the interpreter runtimes it needs; the executor provisions them into a warm cache before the first stage runs and fails the run loudly if provisioning fails — no stage ever starts against a half-installed toolchain:
"runtimes": { "python": "3.12" }
Supported runtimes: python (via uv — the interpreter is shared per version across repos, so only the first run on a machine pays the install, while each repo keeps its own virtualenv) and node (installed per repo). At most 4 entries. Stages get the runtime on PATH plus the repo's .venv/bin, so uv sync → ruff → pytest work with zero bootstrap.
Presets
Instead of writing stages, a recipe can extend a maintained preset:
{ "version": 1, "extends": "std/python-standard@1", "stages": [] }
std/python-standard@1 runs uv sync, ruff check ., and pytest -q; the contract is a pyproject.toml with ruff and pytest as dev dependencies. stages may be empty only when extends names a preset. Presets are versioned by major — the @1 pins the contract.
Coverage telemetry
A stage may declare where it writes a coverage summary; after that stage succeeds, the executor reads the file and stamps a lines-covered percentage on the run:
{ "name": "tests", "cmd": "bun run test:coverage", "coverage_summary": "artifacts/coverage/coverage-summary.json" }
Accepted shapes: istanbul/vitest json-summary ({"total":{"lines":{"pct":N}}}) or minimal {"lines_pct":N}. Coverage is telemetry, never a gate: a missing or unparsable file, a failed stage, or a skipped stage yields no coverage and never affects the run verdict. Declare it on one merged-summary stage per recipe; the value appears on the runs API and the dashboard's coverage column.
Skipping a stage intentionally
A stage that exits with code 250 is recorded as intentionally skipped: the run stays green and stages that depend on it still proceed. Use it for change-scoped work — for example, a summary stage that finds nothing to summarize, or a build that detects its inputs did not change.
Change-scoped stages (if_changed)
A stage may declare if_changed: an array of git pathspecs. When the run has a valid base/head pair, Agentic Pipeline diffs those paths — if nothing matched since the base, the stage is recorded skipped and does not run. Any doubt (no base, an unresolvable SHA, a git error) always means run, never a silent skip:
{ "name": "portal-e2e", "cmd": "bun run test:e2e:portal", "if_changed": ["portal/", "packages/"] }
A skipped stage still satisfies dependents in a needs graph (below) — it never blocks the rest of the run.
Parallel stages as a dependency graph (v2, needs)
Recipe v2 stages can declare needs: the names of stages that must complete first. Agentic Pipeline then schedules the whole recipe as a dependency graph instead of strict top-to-bottom order — a stage starts the moment its dependencies are done, even while an unrelated, slower stage is still running:
{
"version": 2,
"tenant": "k2k",
"stages": [
{ "name": "install", "cmd": "bun install --frozen-lockfile" },
{ "name": "backend-test", "cmd": "bun test backend/", "needs": ["install"] },
{ "name": "frontend-test", "cmd": "bun test frontend/", "needs": ["install"] },
{ "name": "e2e", "cmd": "bun run test:e2e", "needs": ["backend-test", "frontend-test"] }
]
}
A stage with no needs runs as soon as capacity allows. If a stage fails, only its transitive dependents are skipped (recorded skip_reason: "dependency-failed") — independent stages keep running and already-finished results are kept.
DAG stages may also declare resource hints that shape how ready stages share one machine: cpu (cores) and memory_mb are counted against machine capacity when known, stages sharing a resource_group never run concurrently, and exclusive: true runs a stage with nothing else in flight. Stages that build images with the machine's local Docker/Buildx are placed in an automatic docker-buildx group (one local builder — concurrent builds would just contend); setting an explicit resource_group overrides that placement, which is the escape hatch for builds targeting separate remote builders.
Deploy recipes
CI and CD are separate files with the same format family: .slipstream.json (this page) defines the CI pipeline, and .slipstream-deploy.json — recipe v2 with "kind": "deploy" — defines what ship on green executes after a green check on main. Both are repo-owned and fetched at the triggering commit, so a merged edit to either file is active on the very next run. The feature is opt-in per repo — and it opts itself in: landing a valid .slipstream-deploy.json on the default branch turns the ship key on for that repo (slipstream.ship_auto). To keep a deploy recipe without shipping on green, set slipstream.ship.<owner>/<repo>=0.
Deploy change scoping (skip_when_only, opt-in)
By default a deploy always runs. Two per-repo modes change that, set via
PUT /api/slipstream/config on slipstream.deploy_scope.<owner>/<repo> (safe or prove) —
both require this file to be repo-owned, and both are opt-in while the feature is under test.
safe mode reads a new top-level deploy-only key, the inverse of if_changed: paths that are
safe to ignore, not paths that matter. The deploy predict-skips only when the whole diff
since the last deploy that actually rolled fits entirely inside this list:
{ "version": 2, "tenant": "k2k", "kind": "deploy", "skip_when_only": ["docs/", "README.md"] }
Entries must be literal paths or directory prefixes — no glob characters (* ? [ ] { }), no
leading !, no absolute path, no ./.. segment, and never one of the reserved names a
deploy always depends on regardless of what changed: fly.toml, .dockerignore,
package.json, bun.lock, bun.lockb, package-lock.json, yarn.lock, pnpm-lock.yaml,
.slipstream.json, .slipstream-deploy.json, .slipstream-artifact.json, Dockerfile.
slipstream_recipe_validate reports every violation by name. An omitted or empty list means
the deploy never predict-skips on scope alone — an omission only ever costs an extra deploy,
never a missed one. safe/prove also strip any per-stage if_changed from the deploy
dispatch, so declaring both on the same recipe earns a validation warning: the per-stage list
would silently stop doing anything.
prove mode never predicts from a declared list at all — every deploy dispatches, and the
platform hashes each declared app's actual build context, skipping only the build+roll it can
prove redundant (identical content, and the app already running the image that content would
produce). A skip in either mode is recorded with a skip_reason your run's stage table shows
verbatim: skip-when-only: <matched entries> for safe, unchanged-proven (optionally
followed by a context-hash prefix) for prove. Whichever agent surface reports on your deploy
(slipstream_follow, slipstream_deploy_history) states this same evidence as
unchanged_because rather than a bare "production is unchanged" claim. Full design, honesty
limits, and the adoption ceiling: docs/CONTROL-PLANE.md § Deploy change scoping.
Reading an unchanged_because verdict
A deploy that released nothing always names the evidence behind that outcome. Four values, and they are not equally strong:
scoped— every stage was skipped because nothing in the recipe's declaredif_changedinputs changed. This is an allowlist, so an input nobody declared looks exactly like one that did not change; the verdict says so and offers both remedies. If the merge should have changed production, force it (slipstream_rerun {kind: deploy, confirm: true}). If the changed tree never deploys, declare it inskip_when_onlyand future merges reportdenylistinstead. Where the control plane itself made the call before any executor booted, it also states how many files it compared.denylist— the whole diff since the last deploy that actually rolled fell inside your declared-ignorable paths, and the matched entries are named. A missing entry costs an extra deploy, never a missed one.content— the build ran and was proved identical to what is verified live; only the rollout was skipped. The proof covers image inputs, not base-image drift.empty— the deploy concluded with no recorded stages. Nothing rolled, and nothing is claimed about scope lists.
A deploy whose stages recorded different bases reports the LEAST-PROVEN of them: the verdict covers the whole deploy, so one stage skipped on a trusted list makes the whole claim that good.
Artifact-first CD (opt-in)
An artifact-enabled repo adds a third file, .slipstream-artifact.json — recipe v2, "kind": "artifact" — that builds and pushes the repo's images to an immutable, SHA-addressed tag BEFORE the deploy runs. .slipstream-deploy.json then declares which of those images it promotes:
{ "version": 2, "tenant": "k2k", "kind": "deploy", "artifacts": [{ "app": "my-app-backend", "stage": "build-backend" }] }
Once a repo is artifact-enabled, deploy admission is strict: every declared app must already have a produced image for the deploy's exact commit, or the deploy is refused with a clear message naming the app and SHA — Agentic Pipeline never builds an image inside a deploy as a fallback. Scope which apps rebuild with the same stage-level if_changed used everywhere else, on the STAGES inside .slipstream-artifact.json; each declared app names its build stage via stage (one app per stage). An app whose build stage is skipped as unchanged still gets a digest for that commit: the artifact run carries it forward, preferring the run's own base commit — the exact commit the skip was computed against, so no history lookup is needed — and falling back to the nearest ancestor commit that genuinely built it, recorded as inherited with full provenance either way. Whenever that inheritance cannot be proven — a first build, a new app, unresolvable history — the stage is force-run instead, so ambiguity always means build, never skip.