Skip to content

Build-Time Parameters

llms.txt lists every page for an agent

A real application often needs to vary a build: which environment, which tier, which region. The naive way is process.env.LOOM_TIER read at module scope. It works, but TypeScript as Data refuses to fold it, because the value depends on the process running the build instead of the source. Build-time parameters move that input from module scope into the build command, where chant validates and records it.

Declare each parameter in chant.config.ts’s buildParams:

chant.config.ts
import type { ChantConfig } from "@intentius/chant";
export default {
buildParams: {
tier: {
type: "string",
enum: ["light", "production", "production-ha"],
default: "light",
},
env: {
type: "string",
default: "dev",
// Opt-in, EXPLICIT env-var fallback — the only place an env var may
// feed a build-time parameter. Reading process.env directly from
// project source is never supported.
env: "LOOM_ENV",
},
},
} satisfies ChantConfig;

Each parameter declares a type ("string" | "number" | "boolean"), an optional default, an optional enum of allowed values, and an optional env mapping.

Terminal window
# Highest precedence — repeatable
chant build --param tier=production --param env=staging
# Second precedence — a JSON file of { "name": value }
chant build --params-file ./params.prod.json
# Third precedence — only when the parameter declares an `env` mapping
LOOM_ENV=staging chant build
# Lowest precedence — chant.config.ts's declared default
chant build

Precedence, most to least specific: --param > --params-file > the parameter’s own declared env mapping > a workspace flag’s value for the environment > default. A parameter with no default and no supplied value is a build error naming the parameter — never a silently-undefined value, and never a thrown error from inside project source:

build parameter "tier" has no value — pass --param tier=<value>, use --params-file, or add a default in chant.config.ts's buildParams
build parameter "tier" must be one of "light", "production", "production-ha", got "bogus"

This replaces a hand-written validator like:

function tierFromEnv(): Tier {
const raw = process.env.LOOM_TIER ?? "light";
if (!VALID_TIERS.includes(raw)) throw new Error(`LOOM_TIER must be one of ${VALID_TIERS.join(", ")}, got "${raw}"`);
return raw as Tier;
}

A declared enum reports the same violation as a build error naming the parameter, instead of a thrown Error from inside a function chant has no way to attribute.

In a workspace member, a parameter may be backed by a feature flag: a workspace record that names the member, the parameter and a value per environment (#4024). chant build --env <env> takes the flag’s value for that environment and records it with source: "flag:<name>". A build without --env, and a project outside a workspace, reads no flags.

Where the environment has a gate, meaning a component enabled in it declares a gate step, --param, --params-file and the env mapping can’t set a flag-backed parameter. The build refuses, so turning a flag on there goes through a reviewed change to the record and the environment’s gate:

build parameter "newCheckout" is backed by the flag "new-checkout", and "prod" has a gate, so --param can't set it; change the flag record and let the change go through the gate (gates: prod-release)

Source imports the resolved values from @intentius/chant/params — never reads process.env:

import { params } from "@intentius/chant/params";
export const tier = params.tier as Tier;
export const env = params.env ?? "dev";

params is a plain object, so ordinary property access, optional chaining, and ?? all work exactly as they do on any other value.

A parameter declared with required: false and no default may go unset without a build error. An unset optional parameter is absent from params, so params.<name> reads as undefined, never null. A default written as ?? x applies, a truthiness check is false, and a property that receives the bare value (spec: { baseImageArn: params.baseImageArn }) is dropped from the output in both JSON and YAML rather than shipped as baseImageArn: null.

chant.config.ts
baseImageArn: { type: "string", required: false, env: "KMV_BASE_IMAGE_ARN" },
// params.ts
export const baseImageArn =
(params.baseImageArn as string | undefined) ?? `arn:aws:lambda:${region}:aws:microvm-image:al2023-1`;

An explicitly supplied empty string (--param name= or an exported-but-empty env var) is a value, not “unset”. It reaches params.<name> as "" and does not fall through ??. A null in a --params-file is rejected as a build error.

Because the value is known at build invocation — before any project file is imported or folded — a params.<name> reference resolves to a literal, not a symbolic node, and not a function call. env: process.env.LOOM_ENV ?? "dev" cannot fold: the value comes from ambient state at module-evaluation time. env: params.env folds to "dev" (or whatever the build was invoked with) the same way a const does, so chant build --fold reduces it with zero module execution.

Determinism is preserved, not weakened: same parameters in, same output out. The parameters are supplied explicitly and recorded (BuildResult.buildParams, each with its source: cli, params-file, env, default or flag:<name>), rather than read invisibly from whatever environment happened to be running the build.

The ownership marker follows the parameter

Section titled “The ownership marker follows the parameter”

chant.config.ts is evaluated before build parameters exist, so a config field cannot read params.env. For the one config field that naturally wants to vary with the build, the ownership marker’s env, the config references the parameter instead:

export default {
ownership: { stack: "fountain", env: { param: "env" } },
buildParams: {
env: { type: "string", default: "dev", env: "FOUNTAIN_ENV" },
},
} satisfies ChantConfig;

--param env=prod then sets the chant.intentius.io/env label and the params.env value in source from one resolution. Before this, a config that wanted both had to read process.env itself for the marker and declare a parameter for the label, and nothing kept the two in step. A build with a literal ownership.env next to an env parameter that resolved to a different value warns, naming both values.

A reference that cannot be satisfied, a parameter the config does not declare or one that resolved to no value, is a build error. The marker is what --owned filtering, drift and prune key on, so a marker stamped without an env is the quiet failure this exists to rule out.

Interpolating the same parameter into physical names is what lets several instances of one stack coexist in one account — chant build --param env=staging and --param env=prod then produce disjoint names and disjoint markers from the same source. The parameter can also declare env: "CHANT_ENV" so chant build --env <name> supplies it, validated against the project’s declared environments. Both patterns are worked through in Resource Naming.

chant build --fold rejects a bare process reference with a message naming this mechanism, not the generic “unresolved identifier”:

ambient "process" read is not foldable — declare a build-time parameter instead
(chant.config.ts's buildParams + `chant build --param name=value`/`--params-file`)
and reference it via `import { params } from "@intentius/chant/params"`,
rather than reading process.env directly

chant