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 branchzordon/<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.componentholds the declaring component's name (empty at top level), handy for-name "${self.component}/${self.name}"style flags.fs::service::bin,fs::service::etcandfs::service::varacceptcomponent.<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,dotenvandsysenvstay 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 freshgit worktree.branch/tag/revpin the revision.src { path = "/path" }— local checkout used in place (no clone, edit→start loop). Relative paths resolve against the Alphasfile's directory.pathis 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 likeos::envare 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 alongsidegit { }(subdir within the cloned workspace) or insidesrc { 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 withsrc/gitblocks. 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 asself.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—cmdis the build command (omit the block for the toolchain default; an explicitcmdis exec'd as argv, no shell).build.envis 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—cmdis the service argv (there is no top-levelcmd);runtime.envis the running process env.agent—envonly (it starts nothing). Overlaid on top of both build and runtime env, but only when alpha was started withzordon --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.
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@latestandgithub.com/go-delve/mcp-dap-server@latest(unless either is already pinned explicitly — explicit wins),- the toolchain-default
go buildgains-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(bridgedap, address127.0.0.1:<port>) is attached to the service — visible viazordon get service.<tc>.<svc>.agent.mcp.debug.addressand 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+ explicitruntime { cmd = … }(andwrap_runtime = true) — the macro can't apply if the user already wrote a cmd. Usearguments { values = {…} }or setwrap_runtime = false.enabled = trueon a non-Go service — onlyservice "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,.portfs::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.