Recipe and plan schema

Both are TOML, and both are validated against a schema on load โ€” invalid input is rejected, not best-effort parsed. This page is the field list; if you want the syntax itself โ€” which tables repeat, what the dots mean, and which mistakes the parser stays quiet about โ€” read the TOML cheat sheet first.

Plan

[[components]]
name = "api"                       # string, required, unique in the plan
recipe = "recipes/api.toml"        # path to a TOML file, or "name:version" from the store

That is the entire plan schema. Everything else is in the recipe.

Recipe

[metadata]

FieldTypeRequiredNotes
namestringyesReverse-DNS by convention. Part of the recipe identity
versionstringyesSemver. Part of the recipe identity
descriptionstringno
publisherstringno
typestringnoReserved

[[artifacts]]

FieldTypeDefaultNotes
uristringโ€”Where to download from
sha256stringโ€”Mandatory unless --insecure-skip-verify
sig_uristring<uri>.sigDetached signature
cert_uristringโ€”Leaf certificate, if not provisioned on the device
unpackboolfalseExtract into the working directory. Without it, the file is placed there as a hard link to the cached copy
github_tokenstringโ€”Sets Authorization for private GitHub assets
headerstableโ€”Extra HTTP headers for this download
deltatableโ€”Opt into patching instead of downloading โ€” see below

[artifacts.delta]

Optional. Absent means the artifact is always downloaded whole, which is what every recipe written before this field does.

FieldTypeDefaultNotes
serverstringโ€”Base URL of a delta server. Required. The patch URL is derived: {server}/delta/{base sha256}/{sha256}
sha256stringโ€”Digest of the uncompressed archive after patching. Required โ€” it is what the result is verified against
formatstringbsdiff+zstdPatch encoding. An unrecognised value is not an error: the agent logs it and downloads the whole artifact

Requires unpack = true. Every failure falls back to the full download rather than failing the apply. See Artifacts for the trust model, the measured savings and the current limits.

[lifecycle.install]

FieldTypeDefaultNotes
scriptstringโ€”Shell, run once in the working directory
require_privilegeboolfalseDeclares that it needs privilege

[lifecycle.run]

FieldTypeDefaultNotes
typestringprocessprocess or container
restart_policystringalwaysalways, on-failure, never
max_retriesint5 for always/on-failureTerminal once exhausted

[lifecycle.run.exec] โ€” process components

FieldTypeDefaultNotes
commandstringโ€”Required. ./x is relative to the working directory
argsstring listโ€”
working_dirstringcomponent workdir
envtableโ€”Extra environment variables

[lifecycle.run.security] โ€” process components

FieldTypeDefaultNotes
userstringagent’s user"user", "uid", "user:group", "uid:gid"
no_new_privilegesboolfalsePR_SET_NO_NEW_PRIVS
capabilitiesstring listabsentAllow-list. [] means none; omitted means unchanged

[lifecycle.run.container] โ€” container components

FieldTypeDefaultNotes
imagestringโ€”Required
runtimestringautoauto, containerd, cli, nerdctl, docker, podman
pull_policystringif-not-presentalways, never, if-not-present
network_modestringbridgehost, bridge, none, or the name of a user-defined network (CLI runtimes only)
network_aliasesstring listthe component nameDNS names the container answers to on a user-defined network. Refused on runtime = "containerd"
userstringโ€”uid:gid inside the container
privilegedboolfalse
hostnamestringโ€”
env, labelstableโ€”

[[lifecycle.run.container.mounts]]: source, target, type (bind/volume/tmpfs), read_only.

[[lifecycle.run.container.ports]]: host_ip, host_port, container_port, protocol.

[lifecycle.run.container.resources]: memory_mb, memory_swap, cpu_shares, cpu_quota, cpu_period, pids_limit. All applied under containerd and the CLI runtimes. memory_swap is MB of memory plus swap (-1 unlimited) and needs memory_mb; cpu_period defaults to 100000 ยตs and needs cpu_quota. A recipe breaking either rule is refused.

[lifecycle.run.state]

FieldTypeDefaultNotes
versionintabsentThe state version this build requires. Absent and 0 are different: 0 is state before the first migration. A rollback to a plan declaring a lower number for the same component is refused โ€” see rollback

[lifecycle.run.health]

FieldTypeDefaultNotes
checkstringโ€”http://โ€ฆ or https://โ€ฆ (2xx only), tcp://โ€ฆ, cmd:โ€ฆ (runs through /bin/sh -c)
execstring listโ€”argv run directly, no shell. Mutually exclusive with check
intervalduration10s
timeoutduration3s
failure_thresholdint3

[[datasets]]

Data the agent keeps fresh without restarting the component. See datasets.

FieldTypeDefaultNotes
namestringโ€”Required. Becomes a directory and KEYSTONE_DATASET_<NAME>; same allow-list as a recipe name
manifeststringโ€”Required. URL (or local path) of the signed manifest naming the current version
sig_uristring<manifest>.sigDetached signature of the manifest
cert_uristringKEYSTONE_LEAF_CERTSigning certificate
refreshduration24hHow often to look. Minimum 1m โ€” a dataset is not a poll loop
max_ageduration3 ร— refreshPast this the dataset reports stale
keepinteger2Versions retained. Minimum 2: below that there is no rollback target
requiredbooleantrueWhether a component may start without it
headerstableโ€”Extra HTTP headers, for a hub behind auth

[lifecycle.reload]

How a component is told its data changed, instead of being restarted.

FieldTypeNotes
signalstringSIGHUP, SIGUSR1 or SIGUSR2. Process components only โ€” a container has no PID to signal, and declaring it on one is rejected
scriptstringRun instead of a signal, from the working directory. This is how a container reloads
gracedurationHow long the component has to prove it survived. Default 30s

Signals are an allow-list on purpose: a reload is meant to make a component reread a file, and SIGKILL would turn “your data changed” into an outage.

[lifecycle.shutdown]

FieldTypeNotes
scriptstringBest-effort hook on stop

[resources]

FieldTypeEnforcedNotes
open_filesuintyesRLIMIT_NOFILE, set on the component’s process
memory_limitstringโ€”Refused. Use [lifecycle.run.container.resources] memory_mb
cpu_quotaintโ€”Refused. Use [lifecycle.run.container.resources] cpu_quota

[[dependencies]]

FieldTypeDefaultNotes
namestringโ€”Another recipe’s metadata.name
versionstringanySemver constraint
typestringhardhard, soft, ordering

Validating before you deploy

curl -X POST --data-binary @plan.toml "localhost:8080/v1/plan/apply?dry=true"

A dry run loads and validates every recipe, verifies signatures and computes the reconcile plan โ€” without installing or starting anything. It also rejects any key the agent does not recognise, which a real apply tolerates on purpose so that a recipe can outrun the agents it is published to. The TOML cheat sheet has that trade in full.