Skip to content

The Recipe

A recipe is the only input a build accepts. It is a recipe.json checked into your repository, next to the pixi.toml that declares its dependencies and the pixi.lock that pins them:

text
recipes/
└── my-model-macos-aarch64-metal/
    ├── recipe.json     # this document
    ├── pixi.toml       # dependency declaration, solved by `scrollcase lock`
    └── pixi.lock       # the pinned result — committed, reviewed, and installed verbatim

The recipe's recipeId must equal its directory name, so a recipe cannot quietly claim an identity other than the one being built. The machine-readable definition is recipe.schema.json, shipped in the package under src/contract/schema/.

A complete example

json
{
  "schemaVersion": 1,
  "recipeId": "hello-box-macos-arm64-metal",
  "recipeVersion": "1.0.0",
  "boxId": "hello-box",
  "modelId": "example-org-hello",
  "runtimeId": "hello-box-runtime",
  "version": "1.0.0",
  "sourceRevision": "example-hello-v1",
  "target": { "platform": "macos", "arch": "aarch64", "accelerator": "metal" },
  "compatibility": { "minHostAppVersion": "1.0.0", "minMacosVersion": "13.0", "minRamGb": 1 },
  "pythonVersion": "3.11",
  "pixiVersion": "0.73.0",
  "pythonEntryPoint": "venv/bin/python",
  "modelCacheSubdir": "model-cache/hello",
  "assetBaseUrl": "https://assets.example.org/boxes",
  "assets": [],
  "selfTest": { "imports": ["json", "sqlite3"], "files": [] }
}

Identity

FieldRequiredMeaning
schemaVersionyesAlways 1. The wire format is frozen
recipeIdyesMust equal the recipe's directory name
recipeVersionyesVersion of the recipe itself — bump it when you change how the box is built
boxIdyesIdentity of the box across versions. Appears in archive names, object keys, and the channel pointer
modelIdyesIdentity of the packaged model
runtimeIdyesIdentity of the runtime environment the box provides
versionyesVersion of the box this recipe produces, as it appears in the release manifest
sourceRevisionyesUpstream revision of the packaged source, recorded verbatim into provenance

boxId, modelId and runtimeId are lowercase identifiers (^[a-z0-9]+(?:[-.][a-z0-9]+)*$) in the published manifests — keep them to that shape.

Three identifiers, three questions

boxId answers which artefact is this a version of?, modelId answers what model is inside?, and runtimeId answers what environment does it provide? Several boxes may package the same model with different runtimes, or the same runtime for different models; keeping the three separate is what lets a consumer reason about that.

Target

json
"target": { "platform": "linux", "arch": "x86_64", "accelerator": "cuda", "cudaVersion": "12.4" }

The supported combinations are closed — a target outside this matrix has no defined identifier and cannot be built, signed, or routed:

platformarchaccelerator
macosaarch64metal, cpu
linuxx86_64cpu, cuda
windowsx86_64cpu, cuda

cudaVersion (a major.minor string) is required for and only for CUDA targets: it is part of the box's identity, so a CUDA 12.4 build can never be mistaken for a 12.8 one. See The Box Format for the resulting target IDs, and Packaging CUDA Boxes for what a CUDA recipe declares.

Environment

FieldRequiredMeaning
pythonVersionyesPython version the box carries, recorded into provenance
pixiVersionyesThe exact pixi release used to solve and install. lock and build refuse any other version
pythonEntryPointyesInterpreter path relative to the box root. Fixed per target: venv/bin/python on macOS and Linux, venv/python.exe on Windows — a mismatch is rejected
modelCacheSubdiryesDirectory relative to the box root holding model assets
condaDependencyLicenseAuditnoPath (from the project root) to the reviewed licence inventory. When declared, the build fails if the lock no longer matches what was reviewed

The dependencies themselves live in pixi.toml, not here:

toml
[workspace]
name = "hello-box-macos-arm64-metal"
channels = ["conda-forge"]
platforms = ["osx-arm64"]

[dependencies]
python = "3.11.*"

platforms must equal the target's conda subdirectory — osx-arm64, linux-64, or win-64 — or the solve produces an environment that cannot run on the machine the box is for.

Compatibility

Constraints the installing host must satisfy. Scrollcase copies them into the release manifest verbatim and never interprets them, so a project may add its own fields alongside these:

FieldMeaning
minHostAppVersionLowest version of the installing application this box supports
maxHostAppVersionExclusiveUpper bound, exclusive
minMacosVersionMinimum macOS version
minRamGbInstalled memory in decimal GB (1 GB = 1,000,000,000 bytes)
minNvidiaDriverVersionMinimum NVIDIA driver version

A consumer that cannot evaluate a constraint must refuse the box rather than assume it passes.

Assets and payload contents

assets

Files fetched over the network during the build. Every entry is size- and hash-checked before it enters the payload, so a moved or replaced upstream file fails the build instead of quietly producing a different box under the same version.

json
"assets": [
  {
    "url": "https://huggingface.co/example-org/model/resolve/main/model.safetensors",
    "relativePath": "model-cache/hello/model.safetensors",
    "sizeBytes": 438012416,
    "sha256": "9f2b…c1"
  }
]

Downloads are resumable and re-runnable: a complete file with the right size and hash is skipped, and a partial transfer is renamed into place only after its hash matches. See Managing Model Weights.

assetArchives

Downloaded archives to expand into the payload. Extraction preserves files already present and refuses to overwrite them.

json
"assetArchives": [
  {
    "relativePath": "model-cache/hello/weights.tar.gz",
    "format": "tar.gz",
    "destination": "model-cache/hello",
    "stripComponents": 1,
    "removeAfterExtract": true
  }
]

format is zip or tar.gz. Archives are expanded at build time, so they cannot be combined with on-demand weights — the build fails rather than declaring a layout that never materialises.

localFiles

Files copied from your own repository into the payload, each verified against a declared hash so a licence notice or runtime shim cannot drift from what was reviewed.

json
"localFiles": [
  { "sourcePath": "runtime/entrypoint.py", "relativePath": "entrypoint.py", "sha256": "4c7e…9a" }
]

prunePaths

Payload paths deleted before packing, to keep the box to what it actually needs at run time — a box is a multi-gigabyte download for an end user, so pruning is a user-facing concern rather than tidiness.

json
"prunePaths": ["venv/share/doc", "venv/lib/python3.11/site-packages/numpy/tests"]

Each entry is a literal path removed recursively — there is no glob support, and a path that matches nothing is skipped silently. Over-pruning is caught by selfTest.files, below.

Payload paths

Every path inside the payload (relativePath, destination, prunePaths, selfTest.files) is relative and may never escape the payload root. Absolute paths, .. segments, and drive letters are rejected.

assetBaseUrl

Base URL the built archive and its objects are published under. It is what the signed release and channel documents point at. Required unless passed per build with --asset-base-url.

Self-test

Run with the payload's own interpreter before the box is archived, and repeated by a consumer after installing.

json
"selfTest": {
  "imports": ["torch", "transformers"],
  "files": ["model-cache/hello/model.safetensors"],
  "pythonCode": "import torch; assert torch.backends.mps.is_available()"
}
FieldRequiredMeaning
importsyesModules imported with the box's interpreter. The single most valuable check there is: an environment that unpacks but cannot import its dependencies fails on the builder's machine
filesyesFiles that must still exist after pruning — this is what stops an over-aggressive prune from shipping a broken box. May be empty
pythonCodenoExtra Python executed after the imports succeed, for checks a bare import cannot make

The target's own platform assertion is prepended automatically, and the run happens under the accelerator's validation environment. A file listed in files that is a deliberately deferred on-demand asset is not required to be present.

Weights mode

json
"weights": "embed"

embed (the default) packs assets into the archive: the box installs with no network and works air-gapped, at the cost of a large artefact. on-demand leaves them out and carries their URL, path, size and SHA-256 in the signed release, so a consumer fetches and verifies them at install time. A build may override this with --weights. See Managing Model Weights.

Parity (optional)

An optional numerical gate: run a check inside the box on more than one accelerator and require the results to agree.

json
"parity": {
  "script": "checks/parity.py",
  "accelerators": ["cpu", "metal"],
  "tolerances": { "absolute": 1e-4, "relative": 1e-3, "minimumCosine": 0.9999 }
}
FieldMeaning
scriptPath inside the box, run with the box's own interpreter. Must print a JSON array of numbers, or an object with a values array
acceleratorsAt least two, each run under its target's validation environment. The first is the reference the others are compared against — conventionally cpu
tolerancesAt least one of absolute, relative, minimumCosine

Scrollcase runs the check and enforces the thresholds; what the check computes, and what closeness is acceptable, belong to your project. Full treatment in Accelerator Parity.

Validation summary

A recipe is rejected before anything is installed when:

  • schemaVersion is not 1, or recipeId does not match its directory name;
  • pixiVersion is absent, or the pixi on hand is a different version;
  • the target is outside the supported matrix, or a CUDA target lacks a valid cudaVersion;
  • pythonEntryPoint does not match the target's layout;
  • a path escapes the payload root;
  • pixi.lock is missing, or the reviewed licence audit no longer matches it;
  • assetArchives are combined with on-demand weights.