Skip to content

Alphasfile

The Alphasfile is a single HCL2 document. Each service is a two-label block: service "<toolchain>" "<name>" { ... }. Toolchain is go, rust, ruby, nodejs, or pkg — they differ in how the binary is sourced, built, and run (pkg runs a prebuilt native package via mise rather than building anything; see Package services).

service "go" "nats-server" {
  git {
    url = "github.com/nats-io/nats-server"   # zordon-owned bare clone
    tag = "v2.14.0"
    # exe defaults to "." (main package at repo root)
  }

  arguments {
    values = {
      main = {
        p = 9010
        m = 9011
      }
    }
  }
}

service "rust" "tansu" {
  crate {
    name = "tansu"
  }
  features = ["server"]
}

service "go" "prometheus" {
  git {
    url = "github.com/prometheus/prometheus"
    tag = "v3.11.3"
  }
  src { exe = "./cmd/prometheus" }   # main package, relative to the repo root

  arguments {
    values = {
      main = {
        "config.file"        = "prometheus.yml"
        "log.format"         = "json"
        "web.listen-address" = ":9020"
      }
    }
    options {
      prefix = "--"
    }
  }

  readiness {
    http {
      path = "/-/ready"
      port = 9020
    }
  }
}

service "go" "my-app" {
  src {
    path = "~/code/my-app"   # your own checkout; zordon never writes to the primary
    exe  = "."
  }

  runtime {
    cmd = ["${fs::bin()}/my-app", "-addr", ":8080"]
  }
}

Components

A component "<name>" { } block is a namespace for services with an optional toolchain pin of its own. Two components may each declare a service "go" "db"; the component keeps them apart.

toolchain {
  go { version = "1.27.0" }            # the default component's pin, and the fallback for every component
}

service "go" "gateway" {               # top-level = the default component; identity unchanged
  vars = { upstream = component.payments.service.go.api.vars.port }
  runtime { after = [component.payments.service.go.api.runtime.ready] }
}

component "payments" {
  toolchain {
    go { version = "1.22.0" }          # only payments' services run under this pin
  }

  service "go" "db" { … }
  service "go" "api" {
    vars = { db = service.go.db.vars.port }        # bare `service.*` = this component
    runtime { after = [service.go.db.runtime.ready, toolchain.go.ready] }
  }
}
aspect default component (top level) inside component "m"
canonical id service.<tc>.<name> component.m.service.<tc>.<name>
display name (picks, zordon status, checkout dir, worktree branch) <name> m/<name>
bare service.<tc>.<name> in expressions default component component m only, no fallback
cross-component reference component.<other>.service.<tc>.<name>… component.<other>.service.<tc>.<name>…
toolchain.<lang>.ready toolchain.<lang>@ready component.m.toolchain.<lang>@ready for the component's own pin, else toolchain.<lang>@ready
fs::bin() and build output <state>/bin, artifact <name> <state>/bin/m, artifact <name> (a block moved into a component keeps ${fs::bin()}/${self.name} unchanged)
state dirs (fs::etc(), fs::var()) <state>/etc/<name> <state>/etc/m/<name>
zordon get path service.<tc>.<name>.… component.m.service.<tc>.<name>.…
MCP provision tool provision__<tc>_<name>__<step> provision__<tc>_m_<name>__<step>

Rules:

  • A component name is a plain identifier ([A-Za-z][A-Za-z0-9_-]*) and is declared once across all loaded files.
  • A component may not share its name with a top-level service, because both would own <state>/{bin,src,etc,var}/<name> and the branch zordon/<ws>/<name>.
  • Services are unique per (component, toolchain, name), so the same name in two components is not a duplicate.
  • Inside a component the default component is not addressable, neither this level's nor a federation parent's; a parent's components stay reachable as component.<c>.….
  • self.component holds the declaring component's name (empty at top level), handy for -name "${self.component}/${self.name}" style flags.
  • fs::service::bin, fs::service::etc and fs::service::var accept component.<c>.service.<tc>.<name> references.
  • A component's toolchain { } pins are keyed <c>/<lang> in the resolved manifest and the plan renders them inside the component block.
  • env, dotenv and sysenv stay top-level only; component-scoped env is not supported.

See Pin different toolchain versions per component for the recipe and examples/components for a runnable stack.

Parts

An Alphasfile is the index of its directory: every Alphasfile.<name> next to it is a part of the same unit, read without an import. The unit is the union of the Alphasfile and its parts.

# platform/Alphasfile
package "platform" {
  import "./ingest" {}
  component "api" { service "go" "api" { … } }
}

# platform/Alphasfile.jobs (a part of package "platform")
package "platform" {
  component "jobs" {
    service "go" "worker" {
      vars = { api = component.api.service.go.api.vars.port }
    }
  }
}
rule behavior
part a file named Alphasfile.<name> in the same directory as an Alphasfile; subdirectories are not parts
without an Alphasfile an Alphasfile.<name> is never read
rules a part obeys the rules of the Alphasfile it joins
part of a package opens the same package with package "<name>" {} and holds nothing outside it; another name or no package block is an error
part of an entrypoint holds no package block; it may hold anything an entrypoint may
union services, components, imports and requires add up; a package's features, inputs and outputs add up
collisions a component, feature, input or output declared in two parts is an error naming both places
env, dotenv, sysenv add up across parts: dotenv files in file order, sysenv as a union, an env key set in two parts is an error
toolchain the languages of every part add up; a language pinned by two parts is an error, because a unit has one default pin per language; a component's own toolchain {} still pins another version for its services
workspace declared once per unit, in any part
visibility every component of a unit sees every other, whichever part declares it
importing a file an error: an import names a package's directory, and a part is read only through its Alphasfile
cfg::hash() and drift covers every part, so editing a part restarts a level like editing its Alphasfile
remote checkouts a part that links out of the checkout is an error

See Split an Alphasfile across files for the recipe and examples/fragments for a runnable stack.

Imports

An import block pulls a package into the stack by its directory.

rule behavior
syntax import "<path>" "<alias>"? { inputs = {…}, features = [...] }, repeatable
placement the entrypoint's top level, a component block, and a package block
path a directory; starts with ./, ../, / or ~/ and resolves against the declaring file's directory; any other spelling is a remote identity
loading every package loads once; imports of packages outside the stack are loaded and checked too
stack the entrypoint's components and, repeated until nothing changes, the packages imported by anything already in the stack
visibility a package is visible where it is imported: package.<p>.outputs.<n>
visibility error names the expression and the missing import and where it goes
duplicates a component name is declared once across all loaded files
relative src { path } resolves against the directory of the file that declares the service
zordon plan prints # import <dir> as <name> per package under the level header

Packages

A package is the API between a stack and the components that implement it. Its file holds one package block: what goes in (features, inputs), what comes out (outputs), its imports, and its components.

# caddy/Alphasfile
package "caddy" {
  features = {
    dns = { description = "Resolves hosts through CoreDNS." }
  }
  inputs = {
    domain = { description = "The zone hosts live in.", type = string, default = "test" }
    sites  = { description = "Hosts to route, by site.", type = map(object({ host = string, upstream = string })), default = {} }
  }
  outputs = {
    url = { description = "Where Caddy serves.", type = string, value = "http://127.0.0.1:${component.caddy.service.go.caddy.vars.http}" }
  }

  import "../coredns" {
    enabled = features.dns
    inputs  = { zone = inputs.domain }
  }

  component "caddy" {
    service "go" "caddy" { … }
  }
}

# Alphasfile
import "./caddy" "edge" {
  features = ["dns"]
  inputs   = { domain = "dev" }
}
rule behavior
file a package's Alphasfile holds exactly one package "<name>" {} block and nothing outside it
block content features, inputs, outputs, import, require, toolchain and component blocks; env, dotenv, sysenv, workspace and services outside a component are errors
declarations features, inputs and outputs each map a name to { description = "…", … }; the description is required
target an import whose path resolves to a directory whose Alphasfile holds a package block; a directory without one is an error
name the alias label when given, else the package block's label; unique among packages in the stack
identity a component of a package is package.<p>.component.<c>; its services are package.<p>.component.<c>.service.<tc>.<svc>, shown as <p>/<c>/<svc>
short name zordon status shows <p>/<svc> for a service of a component named like its package
picks zordon start, zordon plan and zordon workspace create pick a package as <p>, one of its components as <p>/<c>, and a service by its own name when that name is unique in the stack; see picks
inside a package component.<c> is one of the package's own components; another package is package.<q>.component.<c>
import at the entrypoint's top level the final word on the package's features; every other import's features must be among them, while its inputs join as in Inputs
any other import turns features on when the entrypoint does not import the package
features from several imports unite; every importer gets what it needs
an input from several imports see Inputs: a map takes entries from all of them, any other type is an error
unmet need a feature the entrypoint leaves off is an error on the entrypoint's import that quotes the feature's description and names the importer
entrypoint imports several imports of the same package at the entrypoint's top level must pass the same features and the same input expressions, compared as written
cycle packages that import each other cannot be configured first; importing one of them at the entrypoint's top level breaks the cycle
parts every Alphasfile.<name> in the package's directory is a part of it; see Parts
toolchain the package's toolchain {} pins every component that has none of its own
visibility a package's components see each other and whatever the package or the component imports
federation importing a package that is a federation level of the invocation is an error, because it would run twice
on its own zordon start in the package's directory runs it with default inputs and no features; a required input is an error
host variables a service's process gets none unless the entrypoint passes them with sysenv; without HOME, zordon points Go's caches at $ZORDON_HOME/go, so a package builds without anything from the host
zordon plan prints # import <dir> as <name> [features: …] (imported by …) and renders package "<p>" { component "<m>" { … } }

Features

Features are the only part of a package known before planning, because they decide which blocks exist.

package "coredns" {
  features = {
    resolver = { description = "Writes /etc/resolver/<zone> with sudo, so a browser resolves the zone." }
  }

  component "coredns" {
    service "go" "coredns" {
      sudo "resolver" {
        enabled = features.resolver
        apply   = "…"
      }
    }
  }
}
rule behavior
features = { <n> = { description = "…" } } declares the package's features; the description says what the feature turns on and is quoted in errors
value off unless an import lists it in features = [...]; read as features.<n>, a bool
unknown feature an error listing the declared ones
enabled on service, file, provision, sudo and import blocks inside a package; a false value removes the block before planning
enabled expressions only features.<n> may be referenced and no function may be called; the result must be a bool
reference to a removed block an error on the referencing block that says which enabled removed the target and asks to gate the reference the same way
enabled outside a package an error

Inputs

Inputs are what goes into a package, evaluated with the services, so a value may carry another service's port. Every import that sets an input gives it a value, and the input's type decides how they join: a map takes the entries of all of them, any other type exactly one. Entries are data joined into one map before the package is evaluated, never calls: the package runs once with every entry.

# gateway/Alphasfile
package "gateway" {
  inputs = {
    port   = { description = "Port to listen on; null picks one.", type = number, default = null }
    routes = { description = "Paths to proxy, by service.", type = map(object({ prefix = string, upstream = string })), default = {}, unique = ["prefix"] }
  }
  …
}

# orders/Alphasfile
package "orders" {
  import "../gateway" {
    inputs = {
      routes = {
        orders = { prefix = "/orders/", upstream = "127.0.0.1:${component.orders.service.go.orders.vars.port}" }
      }
    }
  }
  …
}

# Alphasfile
import "./gateway" { inputs = { port = 8080 } }
rule behavior
inputs = { <n> = { description, type, default?, unique? } } declares the package's inputs; type is one of the types
inputs = { <n> = <expr> } on an import gives the input a value; evaluated in the scope of the import, so it may read what the importer sees, such as its own inputs or another package's outputs
map(T) from several imports the entries of every import joined into one map; the entrypoint's import adds entries like any other
key set twice an error naming both imports
unique = ["<attr>", …] for a map(object({ … })): the same value of the attribute in two entries is an error naming both; a null value is not compared
any other type from several imports an error naming where both imports set it, even when they give the same value; an import that passes null sets nothing
default the value when no import sets the input, or every import sets it to null; a map's default is not joined with entries; without a default the input is required; it is evaluated in the package, so it may read the package's own services
type check a value that does not fit the type is an error naming the input, the import and where in the value it went wrong, such as ["orders"].prefix: a string is required
inputs.<n> inside the package: the input's value; a map is sorted by key, each entry with defaults applied
evaluation an input is evaluated with the other producers; a reference to inputs.<n> waits for every import that sets it
unknown input setting an input the package does not declare is an error listing the declared ones, and so is reading one in a service
switched-off import an import removed by enabled, or outside the stack, sets nothing
inputs outside a package an error

Outputs

Outputs are what a package gives back: where it listens, when it is ready, a value per entry of a map input.

# gateway/Alphasfile
package "gateway" {
  outputs = {
    url   = { description = "Where the gateway listens.", type = string, value = "http://127.0.0.1:${component.gateway.service.go.gateway.vars.port}" }
    ready = { description = "The gateway answers requests.", type = string, value = component.gateway.service.go.gateway.runtime.ready }
  }
  …
}

# billing/Alphasfile
package "billing" {
  import "../gateway" {}

  component "billing" {
    service "go" "billing" {
      runtime {
        cmd   = ["billing", "-gateway", package.gateway.outputs.url]
        after = [package.gateway.outputs.ready]
      }
    }
  }
}
rule behavior
outputs = { <n> = { description, type, value } } declares the package's outputs; each value is evaluated in the package's scope, with the other producers, so it may read its services, features and inputs
type check a value that does not fit its type is an error naming the output
package.<p>.outputs.<n> reads an output wherever package p is visible: after an import of it, at the entrypoint's top level after the entrypoint imports it
per-entry values an output may be a map keyed like a map input, such as { for key, d in inputs.databases : key => "…/${d.name}" }, so an importer reads its own entry's value as package.db.outputs.dsn.<key>
barriers an output that names a barrier, such as runtime.ready, carries it, so after = [package.<p>.outputs.ready] waits for it
unknown output reading an output the package does not declare in a service is an error listing the declared ones

See examples/gateway for features, inputs and outputs in one runnable stack.

Types

Inputs and outputs name their type in the syntax of Terraform's type constraints; zordon reads it with its own parser.

type values
string text; a number or a bool is converted to its text
number a number; a string holding a number, such as "8080", is converted
bool true or false; the strings "true" and "false" are converted
list(T) an ordered list of T
map(T) string keys to T
object({ <attr> = T, … }) named attributes; optional(T) may be left out and is then null, optional(T, <default>) takes the default, which must be a constant
rule behavior
unsupported any, set(T) and tuple([…]) are errors that say what to use instead
unknown any other keyword, such as int, is an error that lists the types
undeclared attribute a value with an attribute the object type does not declare is an error, where HCL would drop it silently
mismatch an error names the path inside the value, such as [1].port: a number is required, got string

Remote imports

An import path that does not start with ./, ../, / or ~/ is an identity: a repository followed by a path inside it. The version lives in a require block, not in the path.

require "github.com/piotrkowalczuk/zordon" { ref = "v1.4.0" }

import "github.com/piotrkowalczuk/zordon/examples/package/caddy" {}
rule behavior
identity <host>/<owner>/<repo>[/<path>]; hosts github.com, gitlab.com and bitbucket.org; @<ref> in a path is an error that shows the require to write
require "<repo>" { ref = "<ref>" } pins a repository to a branch, a tag or a commit; the label is the repository alone
where requires live in the nearest zordon.mod above the file; a file with no zordon.mod above it declares them itself, at its top level or inside its package block
require under a zordon.mod an error: the versions of a module live in its zordon.mod
missing require an error that names the file to add the require to; zordon pkg get <repo>@<ref> fixes it only when that file is the entrypoint or shares its zordon.mod
own repository a file fetched from a repository that names that repository reads the same checkout and needs no require
one version per repository the entrypoint's require wins; without one, two requires of the same repository with different refs are an error that asks to pin it for the entrypoint
resolution order a search entry of the applying zordon.work, then the zordon.lock pin, then a fetch; the require is needed in every case
checkout $ZORDON_HOME/mod/<host>/<owner>/<repo>@<commit>, reused without the network once present
confinement a file of a remote checkout may not import / or ~/ paths, and its relative imports never leave its checkout; remote identities are allowed as anywhere else
cfg::hash() records <repo>@<commit>//<path> for remote files, so moving a pin restarts the level
zordon plan appends (<repo>@<first 12 characters of the commit>) or (search <dir>) to every remote import
zordon pkg get <repo>@<ref> writes the require to the zordon.mod above the Alphasfile, or into the Alphasfile (inside its package block if it has one), and locks the repository once the stack imports it
zordon pkg update [repo ...] moves the pins in zordon.lock to the newest commits of their refs

See zordon.work, zordon.mod and zordon.lock for the three files, Use a package from another repository, Develop a dependency locally, and examples/package for a runnable stack.

Source: git { }, src { }, crate { }

A service picks exactly one primary. The rest comes from toolchain defaults — keep the manifest small.

  • git { url = "host/owner/repo" } — remote source. zordon bare-clones once into ~/.zordon/src/...; each invocation gets a fresh git worktree. branch / tag / rev pin the revision.
  • src { path = "/path" } — local checkout used in place (no clone, edit→start loop). Relative paths resolve against the Alphasfile's directory. path is interpolated, so it can be derived from the host environment, e.g. src { path = "${os::env("MONOREPO")}/services/api" }. Outside a live invocation (zordon workspace) only host-level helpers like os::env are available; fs::/cfg::/self.* need a running instance.
  • src { exe = "..." } — the build target subdir (Go: the main package), relative to the source root. Default .. Can ride alongside git { } (subdir within the cloned workspace) or inside src { path, exe } for a local checkout.
  • crate { name, version, index, registry, git, branch, tag, rev } — rust-only. cargo install <name> from a registry or a git URL. Mutually exclusive with src / git blocks. See Rust services for the full field list.

The build is the toolchain default run in the checkout — Go: go build of exe into fs::bin() (out-of-tree, so it never dirties a src workspace); Rust: cargo build --release; Ruby: bundle install. Override it with build { cmd = [...] } (see Phases). It runs from the checkout; with no runtime { cmd } zordon runs the built binary, and runtime { cmd = [...] } is an explicit argv override (needed only when the toolchain has more than one way to run it, e.g. bundle exec ... or caddy run ...).

This is what makes parallel workspaces possible — see Workspaces.

Flags / arguments

arguments is a block with two parts:

  • values = { … } — named groups, each a flag name → value map (interpolated; joins the dependency DAG). A value is reachable as self.arguments.values.<group>.<key>. Most services need a single group (call it whatever, e.g. main); subcommand-driven tools split flags into several (e.g. global, serve).
  • options { … } — how groups render into argv. Two knobs, each an enum that matches a real CLI convention:
  • prefix — "-" (default, Go-style) · "--" (GNU long) · "/" (Windows) · "+" · "" (none).
  • separator — "=" (default) · " " (space → two argv elements, --flag value) · ":" (Windows, /flag:value) · "" (glued, -O2). Ruby always separates by space regardless of this setting.
arguments {
  values = {
    main = {
      "config.file"        = "prometheus.yml"
      "web.listen-address" = ":9020"
    }
  }
  options {
    prefix = "--"          # → --config.file=prometheus.yml
  }
}

Placement. With no runtime.cmd, every group is rendered and appended after the binary (groups in name order, keys sorted within each — deterministic). With an explicit runtime.cmd nothing is auto-appended; you place each group yourself with tpl::render::flags("<group>"), whose rendered tokens are spliced (flattened) into the argv in place. That covers the program <globals> subcommand <sub-flags> shape:

arguments {
  values = {
    global = { debug = true }
    serve  = { config = self.file.c.path }
  }
  options { prefix = "--" }
}
runtime {
  cmd = ["caddy", tpl::render::flags("global"), "run", tpl::render::flags("serve")]
}
# → caddy --debug=true run --config=/…

Quote keys that contain dots: "config.file" = "..." — bare dotted keys parse as nested objects in HCL2.

Phases: build / runtime / agent

build, runtime and agent are full lifecycle phases (not generic containers). build and runtime each take a cmd (argv list — no implicit shell) and an env map (interpolated, DAG-ordered like any other field). agent takes only env.

service "go" "app" {
  src { path = "../.." }

  build {
    env = { BUILD_TAG = "release" }
    # argv; wrap in sh -lc when you need a shell (here, $BUILD_TAG)
    cmd = ["sh", "-lc", "go build -ldflags \"-X main.BuiltBy=$BUILD_TAG\" -o ${fs::bin()}/app ./cmd/app"]
  }

  runtime {
    env = { LOG_LEVEL = "info" }
    cmd = ["${fs::bin()}/app", "-addr", "127.0.0.1:${self.vars.port}"]
  }

  agent {
    env = { LOG_LEVEL = "error" }   # only when `zordon --agent`
  }
}
  • build — cmd is the build command (omit the block for the toolchain default; an explicit cmd is exec'd as argv, no shell). build.env is injected only while building (go build / cargo install / bundle) and does not reach the running process — bake what you need in at build time (e.g. ldflags).
  • runtime — cmd is the service argv (there is no top-level cmd); runtime.env is the running process env.
  • agent — env only (it starts nothing). Overlaid on top of both build and runtime env, but only when alpha was started with zordon --agent. Use it so an automated/AI caller can e.g. quiet a service without editing the Alphasfile.

Layering (later wins): env {} (service-wide base) → the phase's build/runtime env → agent env (in --agent mode). This is independent of the process/dotenv chain documented in Lifecycle, which still feeds the running process.

Readiness probes

A readiness block makes alpha mark a service ready only once a probe passes. The block carries exactly one action — http, exec, or tcp — plus the shared timing knobs (initial_delay, period, timeout, failure_threshold, success_threshold).

The http action polls an endpoint and treats a 2xx/3xx reply as ready.

readiness {
  http {
    path   = "/-/ready"
    port   = 9020
    host   = "127.0.0.1"   # optional, defaults to 127.0.0.1
    scheme = "http"        # optional, "http" (default) or "https"
  }
  initial_delay     = "0s"
  period            = "200ms"
  timeout           = "1s"
  failure_threshold = 30
  success_threshold = 1
}

The exec action runs a CLI command and treats exit code 0 as ready — the readiness equivalent of a Kubernetes exec probe. Use it when the service ships its own readiness check, e.g. Postgres' pg_isready. command is the argv (no implicit shell; use ["sh", "-c", "..."] for one) and is interpolated, so it can reference self.vars and peers. env overlays the probe process environment.

readiness {
  exec {
    command = ["pg_isready", "-h", "127.0.0.1", "-p", "${self.vars.port}"]
    env     = { PGUSER = "zordon" }   # optional, overlays the inherited env
  }
  period            = "200ms"
  failure_threshold = 30
}

The tcp action dials a port and treats a successful connection as ready — for databases and brokers that don't speak HTTP. It takes a port (and optional host, default 127.0.0.1); see Package services for a worked example.

readiness {
  tcp { port = self.vars.port }
  period            = "200ms"
  failure_threshold = 30
}

If no readiness block is set, alpha treats a service as ready once it has stayed alive for --stabilization (default 1s).

Log control

service "ruby" "ruby-service" {
  ...
  log {
    format = "plain"  # or "json"; structured logs get parsed
    # A boolean predicate over each line; true drops the line, at the
    # source, before it reaches the output or alpha's log.
    filter = <<-EOT
      hasPrefix(line, "\tfrom ") or (contains(line, "level") and severity(line) <= DEBUG)
    EOT
  }
}

filter is a small DSL — contains/hasPrefix/matches on the raw line, json/logfmt/severity for structured fields, combined with and/or/not. See the log filter reference.

Debugger

debugger { enabled = true } runs a Go service under a headless Delve DAP server so IDEs and agents can attach without restarting the stack. It is a macro over existing fields, not a parallel mechanism — at Alphasfile evaluation time it expands into:

  • toolchain.go.tools += github.com/go-delve/delve/cmd/dlv@latest and github.com/go-delve/mcp-dap-server@latest (unless either is already pinned explicitly — explicit wins),
  • the toolchain-default go build gains -gcflags='all=-N -l' so breakpoints land on the right source lines (no optimizer reordering, no inlined frames),
  • the runtime argv is wrapped in dlv exec --headless --listen=127.0.0.1:<port> --api-version=2 --accept-multiclient [--continue] [--log] -- <built-binary> <flags>,
  • a synthetic MCP feature debug (bridge dap, address 127.0.0.1:<port>) is attached to the service — visible via zordon get service.<tc>.<svc>.agent.mcp.debug.address and consumed by the (forthcoming) zordon-as-MCP-server bridge.
service "go" "app" {
  src {
    path = "../.."
    exe = "./cmd/app"
  }

  vars = { port = net::pickport() }

  # No `runtime { cmd = … }` — the macro synthesizes it, and an
  # explicit cmd is rejected at validation time (use `arguments` for
  # flags, or set `wrap_runtime = false` to keep your own cmd).
  arguments {
    values = {
      main = {
        addr = "127.0.0.1:${self.vars.port}"
      }
    }
  }

  debugger {
    enabled = true                              # the only required field
    # port            = 2345                    # pin a specific port (default: kernel-picked)
    # wait_for_client = false                   # --continue (default)
    # log             = false                   # --log
    # mcp             = true                    # emit agent.mcp.debug
    # wrap_runtime    = true                    # synth runtime.cmd
  }
}

Discover the resolved port after zordon start:

zordon get service.go.app.debugger.port
zordon get service.go.app.agent.mcp.debug.address    # 127.0.0.1:<port>

Field defaults and effects:

field default effect
enabled false required toggle; everything below applies only when true
port kernel-picked free port dlv's --listen TCP port; pin a literal (e.g. port = 2345) when an IDE config needs a stable, known port
wait_for_client false when true, dlv halts at entry until a client attaches (omits --continue)
log false pass --log to dlv for self-diagnosis
mcp true emit the synthetic agent.mcp.debug feature
wrap_runtime true synthesize runtime.cmd; set false when you launch dlv from your own script

Validation errors raised at Alphasfile parse time:

  • enabled = true + explicit runtime { cmd = … } (and wrap_runtime = true) — the macro can't apply if the user already wrote a cmd. Use arguments { values = {…} } or set wrap_runtime = false.
  • enabled = true on a non-Go service — only service "go" is supported today; other toolchains will follow.

Cross-links: flags pass through arguments unchanged; the toolchain tool installs join toolchain.tools under the same pinned Go.

Top-level workspace {} — preparing a workspace directory

Alongside the service blocks, one top-level workspace {} block says how to prepare a workspace directory: which files to put in it, and what to call the branch each picked service is checked out on.

It is a different block from the service-level workspace { sparse }, which says how to cut one service's checkout. Same word, different scope.

workspace {
  branch = "zordon/${workspace.name}/${service.name}"   # the default

  file "claude" {
    path = "CLAUDE.md"
    create { source = "templates/CLAUDE.md" }
  }

  file "devcontainer" {
    path = ".devcontainer/devcontainer.json"
    create { body = enc::json({ name = "zordon-${workspace.name}" }) }
  }

  file "settings" {
    path = ".claude/settings.json"
    merge { data = { env = { ZORDON_WORKSPACE = workspace.name } } }
  }

  file "ignore" {
    path = ".gitignore"
    region { body = ".devcontainer/" }
  }
}

Each file block takes a path and exactly one operation, which is what decides who owns the file:

Operation Ownership Payload
create zordon owns the whole file body or source (exactly one)
merge zordon owns a fragment of a structured file you own data, plus optional format
region zordon owns a marked span of a text file you own body or source, plus optional comment

merge applies RFC 7386 JSON Merge Patch to JSON, YAML or TOML: objects merge key by key, any other value replaces what was there, and a null deletes its key. format is inferred from the path's extension. Arrays are replaced wholesale rather than appended to — that is what makes a repeated apply a no-op, and the trade is that a patch owns a list or does not touch it. YAML and TOML round-trip through Go libraries that drop comments and original formatting; JSON has neither to lose.

region maintains a # >>> zordon: <name> … # <<< zordon: <name> span, appending it when absent and replacing it when present. comment defaults to #. It is refused on .json, which has no comment syntax — use merge there.

source names a file inside the project root. It is rendered as an HCL template, not copied, so it can interpolate the same values a body can; a literal ${ or %{ that is not meant as interpolation must be escaped $${ or %%{.

When these are written, and what that rules out

The files are materialized by zordon workspace create and zordon workspace apply — before anything is built or started, because an agent or a dev container has to find them already in place.

That timing decides the evaluation context. Available:

  • workspace.name, .dir, .root, .hash, .port
  • fs::hash(), fs::state(), fs::tmp(), fs::bin()
  • os::env()
  • enc::json(), enc::yaml(), enc::toml()

Deliberately unavailable, each with an error naming the alternative: net::pickport(), self.*, service.<tc>.<svc>.*, src::hash(), cfg::hash(), fs::src(), fs::exe(), fs::etc(), fs::var().

None of them can answer truthfully before the stack exists — a port drawn here would simply not be the one alpha draws later. Values that depend on a running service belong in that service's own file {} block, which alpha materializes at start with the whole resolved graph in scope.

workspace.port is the exception that makes static files useful: it is derived from workspace.hash, so it is stable across runs of a given workspace and can name a server that is started afterwards.

See Workspaces for where a generated file may not go, and for the branch template.

enc::json / enc::yaml / enc::toml

The enc:: namespace serializes an HCL value into a document:

body = enc::json({
  mcpServers = {
    zordon = { type = "stdio", command = "zordon", args = ["mcp", "--dir", "."] }
  }
})

Building a document from structure rather than from a hand-quoted heredoc is what keeps an interpolated path or name carrying a quote or a backslash from corrupting it silently. Every encoder sorts object keys and keeps integers integral, so the output is a pure function of the input — which is what makes workspace apply idempotent. They are available in service-level file {} bodies too.