Blueprint.roc is a Roc application on the roc-blueprint platform. It
provides config, a list of settings:
app [config] { pf: platform "<the platform URL of a release>" }
import pf.Recipes
config = [
Name("my-project"),
Environment("dev", [
Programs([Fetch("roc-stable", Recipes.roc_nightly("nightly-2026-10-28-bad769b"))]),
Expect(["git"]),
]),
Shell("default", [Use("dev")]),
Task("check", [Use("dev"), Run(["roc-stable", "check", "main.roc"])]),
]
An environment gets what it has from three places, and each is written differently: a program that is downloaded (Fetch a program), a tool the machine already has (Expect a tool on the machine), and a tool a package source installs (Choose where tools come from).
Get started has a complete file with the current URL.
Top-level settings§
| Setting | Meaning |
|---|---|
Name(Str) | Project name. Required, once. |
Systems(List(System)) | The systems the project is for, each <arch>-<os> of a known architecture (x86_64, aarch64, riscv64, i686, armv7l, powerpc64le) and operating system (linux, darwin, windows, freebsd). Defaults to x86_64 and aarch64 on Linux and macOS; an empty list is invalid. Every fetched program is resolved for each of them. |
Packages(InputName, From(Provider)) | A package source and the set it is: NixPackages(FlakeRef) or GuixPackages(Str). Every source is declared; none is implied, and a project that takes no tool from a package source declares none. |
Input(InputName, FlakeRef) | A flake input the Nix resolver fetches: an overlay when an environment lists it in Overlays, otherwise a plain input for Raw data. It is not a tool source. |
Resolver(Str, List(InputName)) | Assigns sources, inputs and overlays that are not package sources to a provider. Input, Overlay and Source declarations, and the Roc bundle sources RocPackages adds (each named roc-<bundle file name without .tar.zst>, see Lock the Roc packages scripts use), belong to Nix without one; write Resolver only to assign one of them to another provider. Package sources already identify their provider; core SourceFile needs no resolver. |
Executor(InputName, ExecutorProvider) | Named build executor that builds select with Execute. Nix(source) builds with Nix, taking its bootstrap package set from the declared Packages source source. Adapter(provider, source) names another provider’s executor; source is its bootstrap package source, or "" when it needs none. blueprint check asks the provider to validate each executor and refuses a Nix executor with an empty bootstrap source. |
Environment(EnvName, List(EnvironmentSetting)) | Named tools and scoped overlays, optionally inherited. |
Shell(EnvName, List(ShellSetting)) | Named alias with exactly one Use(environment). blueprint shell selects alias "default". |
Task(TaskName, List(TaskSetting)) | Named argv command with exactly one Use(environment) and one Run(argv). No shell alias is required. |
Source(InputName, FlakeRef) | Locked non-flake input, e.g. Source("assets", "path:./assets"). |
SourceFile(InputName, Url, Str) | Original file pinned by its published SHA-256 (64 lowercase hexadecimal characters). Inputs exposes the unchanged file. See original file sources. |
Build(InputName, List(BuildSetting)) | Managed artifact with required Execute(executor), Use, exact argv Run and relative Output; optional Files, Inputs and Needs. |
Workflow(WorkflowName, List(WorkflowStep)) | Ordered RunTask(name, extra_argv), BuildArtifact(name) and RunWorkflow(name) steps. |
Raw(backend, target, Val) | Provider-specific data; see Pass data to the provider. |
Custom(kind, name, Val) | Extension data. The current CLI rejects unsupported extensions. |
Environment settings§
Inside an Environment, Tools and Overlays occur at most once, ToolsFor
occurs at most once per System, Command occurs at most once per command
name, ToolWith occurs at most once per tool, and RocPackages, Programs
and Expect occur at most once each:
| Setting | Meaning |
|---|---|
Tools(List(Tool)) | Tools a package source installs, each written "<source>#<name>": "nix#git" is git from the source declared as nix. A Nix name is an attribute path; quote a segment that contains dots, as in "nix#zigpkgs.\"0.16.0\"". |
ToolsFor(System, List(Tool)) | Add tools only for a declared target System. Each System occurs at most once per Environment. |
Command(Str, Tool) | Expose one tool’s main program under another command name, without adding the tool’s own executables. |
ToolWith(Tool, List((Str, Val))) | A tool built with options its package takes; see Configure a tool. |
RocPackages(List(Str)) | Released Roc bundle URLs to have in place before anything runs, so Roc programs in the environment resolve them without downloading; see Lock the Roc packages scripts use. |
Programs(List(Program)) | The programs the environment has on PATH that are downloaded, each a Fetch(ProgramName, recipe); see Fetch a program. |
Expect(List(ProgramName)) | Programs the machine must already have on PATH; see Expect a tool on the machine. |
Overlays(List(InputName)) | Ordered inputs applied to this environment’s package set as overlays. |
Extend(EnvName) | Inherit one environment’s tools, overlays, commands, configured tools, Roc packages, programs and expected tools before appending this environment’s selections. |
Inheritance deduplicates by first occurrence, parent first. Omitted or empty
Tools/ToolsFor/Overlays lists do not clear inherited values. A standalone environment
has no overlays unless selected. Run must contain a nonempty executable;
arguments remain separate strings, including extra CLI arguments after --.
There is no In setting or implicit task environment. One name is only one
of a program, an expected tool and a Command in an environment, including
what it inherits.
Fetch a program§
A program that is published as a file or an archive is fetched: downloaded
from a URL Blueprint.roc gives, checked against a digest in
Blueprint.lock, and put on the PATH of the environments that list it. No
package source is involved and Nix is not started.
Environment("dev", [
Programs([
Fetch("roc-stable", Recipes.roc_nightly("nightly-2026-10-28-bad769b")),
Fetch("blueprint", Recipes.file("https://github.com/lukewilliamboswell/roc-blueprint/releases/download/0.5.0/blueprint-{system}")),
]),
]),
Fetch(name, recipe) is a command name and a recipe: a function from a
system to where the program is downloaded from on that system. The
environment gains the command name. A recipe downloads nothing. It is
asked once for each of the project’s Systems while Blueprint.roc is
checked, and its answers are what blueprint works from. The Recipes
module is the platform’s; import it with import pf.Recipes.
What a recipe answers§
| Answer | Meaning |
|---|---|
File(url) | The program is that one file. It is made executable. |
Archive(url, format, member) | An archive in the format TarGz, TarXz or Zip, with the program at the path member inside it. The archive is unpacked whole, and the command is a link to the program where it lies, so it finds what the archive put beside it. |
Mirrored(download, urls) | A File or an Archive with alternate URLs for the same bytes, tried in order after its own. Blueprint.lock holds one digest for all of them, so an alternate need not be trusted. |
Unavailable | The program does not exist for that system. This is an error only where a task on that system needs it; a program unavailable on every declared system is refused when Blueprint.roc is checked. |
A recipe that cannot answer at all, for a version it does not know, stops
with crash and a message; the compiler reports it with the recipe’s file
and line.
The recipes the platform provides§
Recipes.file(template)A program that is one file at a URL which names the system.
{system}in the template stands for the system asked about, such asx86_64-linux, and{arch}and{os}for its two parts. No other placeholder is accepted, and none may stand in the host name. The program is taken to exist for every declared system: when its publisher has no file for one of them,blueprint updatefails on that file and says so. State it by listing the systems the project supports inSystems, or write a recipe that answersUnavailable.Recipes.roc_nightly(tag)The Roc compiler of one release of roc-lang/nightlies, by its tag,
nightly-YYYY-MM-DD-<commit>. It answers for x86_64 and arm64 Linux, Apple Silicon and Intel macOS and x86_64 Windows, andUnavailablefor any other system. Fetched asroc-stable, it is the compiler a script that starts#!/usr/bin/env roc-stableruns on.
Write a recipe§
A recipe is an ordinary function, so a project writes its own for a program whose downloads do not follow one pattern:
app [config] { pf: platform "<the platform URL of a release>" }
import pf.Config
import pf.Recipes
import pf.System
config = [
Name("my-project"),
Systems(["x86_64-linux", "aarch64-linux", "aarch64-darwin"]),
Environment("dev", [
Programs([
Fetch("roc-stable", Recipes.roc_nightly("nightly-2026-10-28-bad769b")),
Fetch("wasm-tools", wasm_tools),
]),
]),
Task("validate", [Use("dev"), Run(["wasm-tools", "validate", "app.wasm"])]),
]
wasm_tools : System -> Config.Download
wasm_tools = |system| {
base = "https://github.com/bytecodealliance/wasm-tools/releases/download/v1.240.0"
match system {
"x86_64-linux" => Archive("${base}/wasm-tools-1.240.0-x86_64-linux.tar.gz", TarGz, "wasm-tools-1.240.0-x86_64-linux/wasm-tools")
"aarch64-linux" => Archive("${base}/wasm-tools-1.240.0-aarch64-linux.tar.gz", TarGz, "wasm-tools-1.240.0-aarch64-linux/wasm-tools")
"aarch64-darwin" =>
Mirrored(
Archive("${base}/wasm-tools-1.240.0-aarch64-macos.tar.gz", TarGz, "wasm-tools-1.240.0-aarch64-macos/wasm-tools"),
["https://mirror.example.net/wasm-tools-1.240.0-aarch64-macos.tar.gz"],
)
_ => Unavailable
}
}
Give a recipe of more than a line its type, System -> Config.Download, as
here: a mistake in it is then reported on its own line. A recipe written
inline, as Fetch("tool", |system| ...), is checked just the same, and the
compiler may report a mistake in it against the platform’s file with the
line and column of the literal in words.
A System is not text. To write it into a URL, convert it:
Environment("dev", [
Programs([Fetch("tool", |system| File("https://example.org/releases/1.0/tool-${system.to_str()}"))]),
]),
With ${system} alone the compiler reports a type mismatch of
from_interpolation on Url, at the first line of the file and not at the
recipe. Recipes.file avoids the interpolation altogether:
Environment("dev", [
Programs([Fetch("tool", |system| File("https://example.org/releases/1.0/tool-${system}"))]),
]),
Share a program between environments§
Fetch is written inside Programs, so a program cannot be listed without
being declared. An environment that extends another inherits its programs.
Two environments that do not extend one another share a program through an
ordinary binding:
app [config] { pf: platform "<the platform URL of a release>" }
import pf.Recipes
roc_stable = Fetch("roc-stable", Recipes.roc_nightly("nightly-2026-10-28-bad769b"))
config = [
Name("my-project"),
Environment("scripts", [Programs([roc_stable]), Expect(["git"])]),
Environment("release", [Programs([roc_stable]), Expect(["gh"])]),
Environment("ci", [Extend("scripts")]),
]
One name always means the same downloads. Two Fetch values with one name
whose recipes answer differently for a declared system are refused as two different programs are named roc-stable.
What is checked, and when§
roc check, in an editor or on the command line: Each text, as what it is where it is written: a system is <arch>-<os> of a known architecture and operating system, in a match pattern and beside == too; a URL is https://<host>/<path> with no credentials, query or fragment; the path of a program in an archive is relative and not a URL, so the two cannot change places; a nightly tag has its form; a template has only its three placeholders. Then the whole configuration: a program name is a plain file name, one name is only one of a program, an expected tool and a Command in an environment, no URL is listed twice for one download, and every program exists for at least one declared system.
blueprint update: That every download exists, by downloading it or by taking the digest its release publishes; and, where both are to be had, that they agree. See What update needs and records.
blueprint run, shell and workflow: That Blueprint.lock holds the program for this system under the URLs Blueprint.roc now gives; that every expected tool is on PATH; then that the download has the recorded length and SHA-256, on every use; that an archive holds nothing that leaves the directory it is unpacked in; and that the program is in it.
A program whose URLs changed since the lock was written is stale by itself:
the message names it, and blueprint update <name> resolves it alone.
Expect a tool on the machine§
Some tools are better taken from the machine than installed: git, which
every developer and CI runner has, or gh, which must be the one that is
logged in. Expect declares them:
Environment("dev", [
Expect(["git", "gh"]),
]),
Before a task or shell of that environment starts, and before anything is
downloaded for it, blueprint looks for each name on PATH. One message
names everything that is missing:
blueprint: this machine lacks what the task needs, and blueprint installs none of it:
gh: environment dev expects it on PATH (Expect)
An expected tool is not installed, not put on PATH and not pinned, and no
version is asked of it: the environment runs with whichever one the machine
has. Where a version matters, fetch the program or take it from a package
source instead, or have a task check tool --version. blueprint requirements lists what a project’s tasks expect, so a CI job can install
it first.
A sandboxed build has nothing of the machine, so an environment a build uses
may not have Expect or Programs.
Choose where tools come from§
A tool that a package source installs is written "<source>#<name>", for a
source declared with Packages:
Packages("nix", From(NixPackages("github:NixOS/nixpkgs/nixos-unstable"))),
Packages("stable", From(NixPackages("github:NixOS/nixpkgs/nixos-24.05"))),
Environment("dev", [Tools(["nix#git", "stable#jq"])]),
The source’s name is the project’s choice; nix and stable mean nothing to
blueprint. The source is always written, in Tools, ToolsFor, Command
and ToolWith alike, and there is no default. "git" alone is refused
where it is written:
"git" names no package source. Write "<source>#git" for a declared source, as
in Packages("nix", From(NixPackages("github:NixOS/nixpkgs/nixos-unstable")))
and "nix#git"; or, if git is already on the machine, list it in Expect.
A tool that names a source the project does not declare is refused with the
sources it does declare, as unknown source: nix; Blueprint.roc declares stable. A project whose environments have no such tool declares no source,
and Nix is not started for it.
Nix is started only for an environment that has a tool from a Nix source.
An environment Nix must build although it has no tool of its own, such as
the sandbox of a build whose environment is empty, is built with the
bootstrap source of the Nix executor that the project’s builds select (the
first by name when they select several). When no build selects one, it is
built with the first Nix source declared, in the order of Blueprint.roc.
The bash that nix develop runs a shell or task with comes from the first
Nix source the generated flake declares.
The core does not autodetect a provider or translate package names. Guix
source intent and pure capability validation exist, but there is no Guix
renderer or executor.
An incompatible requested environment fails; it never retries another provider.
Missing native packages and unavailable target packages fail in Nix rather than
being silently filtered out. Nix tools applicable to unsupported targets fail
before file writes or provider execution. Systems remains authoritative for
fetched programs: a mixed project can include Windows alongside Unix when its
Windows environments need no Nix tools. Scope Unix tools with ToolsFor;
unscoped tools, commands and configured tools apply to every declared system.
Inherited system scopes are preserved. An unused Nix source does not require
Nix outputs for Windows.
Full rendering checks all used environments and their applicable systems.
Selected tasks and shells check the requested system and environment closure;
unrelated systems do not invalidate a supported Unix operation. Sandboxed
build outputs cover only x86_64-linux, the existing executor target. Explicit
build requests elsewhere fail before provisioning or execution.
Create and update locks requiring Nix resolution on a supported Unix host. Windows consumes that same committed lock for provider-free tasks without Nix; Windows updates requiring Nix remain unsupported.
Add tools for one system§
For a shell shared by Linux and macOS, put common tools in Tools and declare
platform libraries explicitly:
Environment("dev", [
Tools(["nix#git", "nix#python3"]),
ToolsFor("x86_64-linux", ["nix#wayland", "nix#alsa-lib"]),
]),
ToolsFor is provider-neutral Spec intent. It applies to inherited environments,
shells, tasks and builds on that System; undeclared systems and duplicate
declarations are rejected during evaluation.
Give a tool another command name§
Command exposes one tool of a package source under another command name,
without adding the tool’s own executables. It keeps a pinned tool beside
another of the same name: a second Python, say, for scripts that must not
follow the project’s own:
Packages("nix", From(NixPackages("github:NixOS/nixpkgs/nixos-unstable"))),
Packages("stable", From(NixPackages("github:NixOS/nixpkgs/nixos-24.05"))),
Environment("dev", [
Tools(["nix#python3"]),
Command("python-stable", "stable#python3"),
]),
The environment gains python-stable beside the python3 of nix. The
command runs the tool’s main program as its package declares it; a package
that declares none fails in Nix. Command names are plain file names. An
extending environment inherits commands and replaces one by declaring the
same name.
A Command is a tool of a package source, so its environment starts Nix. A
program that is published as a download needs neither: Fetch names its
command itself. A Roc compiler for scripts is written
Fetch("roc-stable", Recipes.roc_nightly(tag)), as in Fetch a program;
Command("roc-stable", "nix#rocpkgs.<tag>") with the roc-overlay selects
the same compiler through Nix, for an environment a sandboxed build uses.
Configure a tool§
Some packages are built to order. A Rust toolchain from rust-overlay comes
with the host’s standard library only; the targets it can compile for and
its extra components are options of the package. ToolWith names the tool
and those options:
Input("rust", "github:oxalica/rust-overlay"),
Input("zig", "github:mitchellh/zig-overlay"),
Environment("host", [
Overlays(["rust", "zig"]),
Tools(["nix#zigpkgs.\"0.16.0\""]),
ToolWith("nix#rust-bin.stable.\"1.95.0\".default", [
("targets", List([Str("x86_64-unknown-linux-musl"), Str("aarch64-unknown-linux-musl")])),
("extensions", List([Str("rust-src")])),
]),
]),
The environment gains cargo and rustc from Rust 1.95.0 with both musl
targets and the standard library’s source. Zig needs no options: zig-overlay
names each release as an attribute, which Tools selects.
The options are the names and values the package’s own override takes,
written as Val data: strings, integers, booleans, lists and attribute
sets. They are passed as data and are never an expression, so they select
among what the package offers and cannot say how to build it. A package
that takes no options, or not the ones named, fails in Nix.
A tool is either plain or configured in an environment, not both. An extending environment inherits configured tools; configuring an inherited tool replaces all of its options. Three things are not supported:
options on a
ToolsFororCommandtool;rust-overlay’s
fromRustupToolchainFile, which reads a file of the project: the generated flake does not see the project’s files;configured tools from a Guix source.
Pin an exact version of a tool§
A tool’s version is whatever its source holds at the pinned revision.
Blueprint.roc names the tool, not its version, so to hold one tool at an
exact version, give it a package source of its own, pinned to a nixpkgs
revision that ships that version. For CPython 3.14.3:
Packages("py", From(NixPackages("github:NixOS/nixpkgs/<revision>"))),
Environment("dev", [Tools(["nix#git", "py#python314"])]),
<revision> is a full nixpkgs commit whose python314 is 3.14.3. To find
one, look at the history of the package’s file in nixpkgs, or evaluate a
candidate:
nix eval --raw github:NixOS/nixpkgs/<revision>#python314.version
How the source is written decides whether the version can move:
A source that names a revision, as above, never moves.
blueprint updateresolves it to the same commit every time. Change the version by editing the revision.A source that names a branch, such as
github:NixOS/nixpkgs/nixos-25.05, is moved byblueprint updateand byblueprint update py, and takes whatever version the branch then has.blueprint update nixleaves it alone.
Nothing in Blueprint.roc states the version, so nothing fails if it changes.
If a version matters, assert it where the tool is used, for example by having
the project’s check task run python3.14 --version and compare.
An overlay that publishes each version under its own attribute needs no second source: select the attribute, quoting a segment that contains dots.
Input("zig", "github:mitchellh/zig-overlay"),
Environment("dev", [Overlays(["zig"]), Tools(["nix#zigpkgs.\"0.16.0\""])]),
Lock the Roc packages scripts use§
A Roc script names its platform and packages by release URL, and Roc downloads
them on first use. RocPackages names those bundles for an environment
instead, so Blueprint has them in place before its scripts start, and they
run at all in a sandboxed build, which has no network:
Environment("scripts", [
Programs([Fetch("roc-stable", Recipes.roc_nightly("nightly-2026-10-28-bad769b"))]),
RocPackages([
"https://github.com/roc-lang/basic-cli/releases/download/0.26.0-rc2/Gj3zVDr3qrQa6aTh7Z1zgUfe9NeBE6R76T5ho3xrc8af.tar.zst",
"https://github.com/roc-lang/http/releases/download/1.0.0/6ZUwqYhCS8PU9Mo6MF7oV82ET2o7KYb57CLKDq4cq4sS.tar.zst",
]),
]),
Task("release", [Use("scripts"), Run(["scripts/release.roc"])]),
Each URL must be https:// and end in <hash>.tar.zst. The hash is that of
the bundle’s bytes, so the URL itself says what the download must be.
List every bundle, including dependencies of dependencies. basic-cli above
depends on http, so both are listed. roc deps <file> prints the URLs a
program depends on. Blueprint does not read package headers, so it cannot add a
missing one for you. A bundle that is not listed is downloaded by Roc as usual
in a shell or task, and fails to resolve in a sandboxed build.
What the lock pins§
For a shell or a task, nothing: blueprint downloads a missing bundle itself
and has the compiler unpack it with roc unbundle, which fails unless the
bytes are those the name is the hash of. That check is the pin, and it needs
no Nix and no entry in Blueprint.lock. A project whose Roc packages are
used by tasks and shells alone has none of them in its lock.
A sandboxed build cannot download. The bundles of an environment that a build
runs in, or that it extends, are therefore fetched by Nix as well:
blueprint update records those in Blueprint.lock like any other source,
each named roc-<hash> and owned by Nix without a Resolver, and Nix checks
the bytes against that pin. An environment a build uses takes
its compiler from a Nix source, since the Nix executor does not support downloaded programs in managed builds:
Packages("nix", From(NixPackages("github:NixOS/nixpkgs/nixos-unstable"))),
Executor("native", Nix("nix")),
Input("roc", "github:roc-lang/roc-overlay/a9b461cd4df73ccb69a79a2bc7f58ab60e715619"),
Environment("build-scripts", [
Overlays(["roc"]),
Command("roc-stable", "nix#rocpkgs.nightly-2026-10-27-de79a0f"),
RocPackages([
"https://github.com/roc-lang/basic-cli/releases/download/0.26.0-rc2/Gj3zVDr3qrQa6aTh7Z1zgUfe9NeBE6R76T5ho3xrc8af.tar.zst",
"https://github.com/roc-lang/http/releases/download/1.0.0/6ZUwqYhCS8PU9Mo6MF7oV82ET2o7KYb57CLKDq4cq4sS.tar.zst",
]),
]),
Build("notes", [Execute("native"), Use("build-scripts"), Run(["roc-stable", "scripts/notes.roc"]), Output("notes.txt")]),
The overlay is pinned to a revision that records this nightly. Keep the overlay revision and compiler version in step when changing the compiler.
An extending environment inherits its parent’s bundles.
What is written, and where§
blueprint shellandblueprint runpublish the environment’s bundles into Roc’s own package cache before entering it: the directoryroc/packagesunder$XDG_CACHE_HOME, or under~/.cachewhen that is unset. Each bundle becomes the directory<hash>there, the files the compiler unpacked from the download, which is where Roc looks before downloading. A<hash>directory that already has amain.rocis never touched, whether Roc downloaded it or an earlier run published it; one without amain.rocis incomplete and is replaced, as Roc itself would. The copy is made in a temporary directory beside it, namedblueprint-<hash>.<random>.tmp, and renamed into place, so another Roc process never sees half a package. Nothing else is written, and nothing is downloaded or written when every bundle is already there or the environment has no Roc packages. If the cache is deleted, the next run downloads and publishes again.blueprint builddoes not use that cache. The build gets a package cache of its own inside its build directory, holding only its environment’s bundles, andXDG_CACHE_HOMEpoints the build’s command at it.
A build sees only the tools its environment declares, so a build that runs Roc
needs a Roc tool, such as the roc-stable command above.
Compose settings from modules§
Use ordinary Roc lists and functions for composition, not a plugin registry.
ProjectTasks.roc returns
List(Config.Setting); its app
concatenates those settings into config. Imports do not install tools or add
runtime operations to the CLI.
Pass data to the provider§
The Nix provider accepts attribute data at two targets:
Raw("nix", "shell:default", Attrs([
("shellHook", Str("echo ready")),
("RUST_LOG", Str("debug")),
])),
Raw("nix", "flake", Attrs([("formatter", Str("nixpkgs-fmt"))])),
shell:<alias>adds attributes to that alias’smkShellcall, not other aliases or tasks using the same environment.flakeadds attributes to flake outputs.
Values are Str, Int, Bool, List([...]) and Attrs([(name, value), ...]).
They are data, not Nix expressions. Duplicate attributes and overrides of
managed packages/devShells are rejected. Raw for other providers is ignored.