Configuration
Every cairntrace surface reads from a single config source: cairntrace.config.yml. The file is optional — a spec with absolute URLs runs without one — but anything project-specific (environments, services, secrets, retention, integrations) lives there. The schema is strict: unknown top-level keys are rejected, so a typo fails cairn config validate instead of silently being ignored.
cairn config validate --json # validate structure + cross-field rules
cairn config validate --config ./cairntrace.config.ymlFile location resolution
The loader walks the file system upward from the spec file looking for the first cairntrace.config.yml. If your specs live in specs/flows/login.yml and the project root has the config, that's used. --config <path> overrides the lookup on every command that reads config.
Schema
# cairntrace.config.yml
version: 1 # required, always 1
project: my-app # used in artifacts, breadcrumbs, MCP resource names
defaultEnvironment: local # the env used when no --env is passed (default: local)
artifactRoot: ~/.cairntrace/runs # override the default artifact root
workflowRoots: [./flows, ./checks] # dirs cairn scans for specs (cairn explain / codemap)
environments: # required — at least one environment
local:
baseUrl: http://localhost:3000
vars: { allowedCountry: US, retryCount: 3, useSandbox: true }
viewport: { width: 1280, height: 800 }
staging:
baseUrl: https://staging.app.com
services: false # disable all services for this env (app is remote)
secrets: { provider: tvault, tvault: { group: payments, env: prod } }
secrets: # default secrets block (an env-level secrets replaces it)
provider: tvault # env | tvault
keys: [API_KEY, DB_URL] # selected TinyVault values for this invocation
required: [API_KEY, DB_URL] # fail the run if these are unset/empty
tvault: # required when provider: tvault
project: my-app # direct mode — OR —
# group: payments # inheritance mode (requires env)
# env: prod
retention:
keepRuns: 10 # prune to newest N runs/spec after every run
keepFailedRuns: 10 # newest N failed/errored runs survive pruning anyway (default: 10)
publish: { enabled: true, retentionDays: 7 } # explicit remote archive before pruning
report:
theme: cairn # cairn | slate | midnight | contrast
colors: { accent: "rgb(94,129,172)", danger: "#c0392b" } # optional CSS token overrides
browser: # browser-backend tuning (agent-browser)
verifyAfterClick: true # confirm same-tab link delivery (default: true)
postClickSettleMs: 20000 # opt in to network-idle after every click
# click settleMs > spec settleMs > this value
webServer: # optional single-server lifecycle for `cairn run`
command: "node .output/server/index.mjs"
build: "bun run build" # run once before command (skipped when reusing)
url: http://localhost:3000 # readiness probe (defaults to the env baseUrl)
waitForText: "listening on" # …or treat ready when this hits stdout/stderr
reuseExisting: true # reuse a server already answering (false in CI)
readyTimeoutMs: 60000
setup: ["bun run db:migrate"] # run after ready, before specs
teardown: ["rm -rf ./.cairntrace/tmp"] # best-effort, after specs
services: # multi-service lifecycle (docker/seed/tmux) — see Services page
docker: { command: "docker compose up -d", reuseExisting: true, readinessCheck: "curl -sf http://localhost:27017" }
seed: { command: "yarn demo-import", ttlSeconds: 21600, freshnessCheck: "mongosh --quiet --eval 'db.count()' mydb" }
tmux:
session: myapp
windows:
- { name: web, cwd: web-app, command: "yarn serve", readyOn: { url: http://localhost:8080 } }
teardown: ["tmux kill-session -t myapp", "docker compose down"]
stash: { enabled: true, autoStash: always, capture: [tmux, docker, seed] }
stash: # fcheap run-artifact stash — see Stash page
enabled: true
autoStash: on-failure # on-failure | never
tags: [regression, audit]
clips: # default tags; clip points live in the spec
tags: [regression]
investigate: # fcheap connect + vecgrep — see Investigate page
codebaseDir: ./src # default codebase for `cairn investigate --connect`
mode: hybrid # semantic | keyword | hybrid
limit: 10
index: false # build/refresh vecgrep before connecting
autoInvestigate: on-failure # on-failure | never
annotate: # codemap annotation — see Annotate page
enabled: true
autoAnnotate: on-run # on-run (pass+fail) | on-investigate | never
source: cairntraceThe schema is .strict() at every level, so a misspelled key (e.g. runner: or run: — neither exists) is a validation error, not a silent no-op.
Agent-browser confirms same-tab link delivery from URL, document, or DOM evidence by default without waiting for network-idle. A positive click-step or spec-root settleMs, or browser.postClickSettleMs, opts into network-idle settling; click/spec values take precedence over config. Playwright honors explicit click/spec values and otherwise keeps its native waits. A resolved value of 0 skips both the extra settle and the link-delivery probe at that scope. browser.verifyAfterClick: false disables the agent-browser guard globally.
Environments
environments is a required record of name → EnvironmentConfig. Each environment can carry:
| Key | Effect |
|---|---|
baseUrl | prepended to open: steps that begin with /; also ${baseUrl} |
vars | substituted as ${vars.X} in specs (config env vars) |
viewport | browser viewport applied at run start (spec-level viewport: wins) |
services | false disables all services for this env; a partial services: block deep-merges over the top-level one |
secrets | replaces the top-level secrets: block for this env entirely |
The active environment is --env <name>, else defaultEnvironment, else local.
Placeholder resolution
For every ${baseUrl}, ${env.X}, ${vars.X}, or ${secrets.X} in a spec:
${baseUrl}→ the active environment'sbaseUrl.${env.X}→ the invocation environment. Withsecrets.provider: tvault, onlysecrets.keys,secrets.required, and names referenced in the root spec or its imported actions are resolved; they never mutate globalprocess.env.${env.X:-default}resolves a default expression when the var is missing or empty; nested placeholders inside the default work.${vars.X}→ merged vars, in priority order: CLI--var key=value(highest) > specvars:>environments.<env>.vars.${secrets.X}→ the secrets bag (env or tvault-resolved).
Each placeholder is resolved exactly once and emitted verbatim — a value that itself contains ${...} stays inert, so there is no cross-secret injection. An unresolved ${vars.X} fails at parse time with a typed error pointing at the spec line.
Where run-time settings actually live
Several settings that look like config actually live on the spec, not cairntrace.config.yml:
backend,mode,viewport,vars,environment,settleMs,coldStart,preconditions,session,redaction,metadata,artifacts(capture policies, video, clip points) — all spec-root keys.redaction:on a spec is{ headers?, queryParams?, storageKeys?, values? }(arrays of strings to scrub), not a regex list.
Backend choice and capture policies are per-spec because they describe what this flow observes, not project plumbing. Project plumbing (environments, services, secrets, retention, integrations) is what goes in config.
Validation
cairn config validate runs the zod schema plus cross-field .refine() rules:
secrets.provider: tvaultrequires atvault:block with eitherproject(direct) orgroup+env(inheritance) — not both.tmuxwindow names must be unique within a session.- A
tmuxwindow withreadyOnmust specify at least one ofurlortext. - An
xlsxverifier (spec-side) requiressheetsorvalidations.
Run it in CI before cairn run so a malformed config fails fast instead of mid-run.
See also
- Steps / Verifiers — the spec vocabularies (spec-root keys, not config)
- Services — the
services:lifecycle in depth - Secrets — the
secrets:/tvault:blocks - Stash / Clip / Investigate / Annotate — the integration blocks
- Doctor & clean —
retention.keepRunsand thecairn cleanit feeds - Troubleshooting — "Redaction layer rejected your config pattern" and other config errors