Many programs already read their settings with
Viper: defaults in code, a file, environment
variables over the file, flags over both. The github.com/jumppad-labs/xcl/viper
package adds xcl as one more file format. The program keeps its Viper and its
keys, and the file gains what xcl has: blocks, variables, references and
functions, and a clear report when something in it is wrong.
The example is the settings of a runner: the server it reports to, the agents it can run, and which agents take which tasks.
The Go types
The data model is the application’s. A block type is a plain struct with
xcl tags, and nothing else: no embedded type and no import of xcl at all.
// Runner is the runner itself. It has no label field, so it is a// singleton: there is one, written runner { ... }type Runner struct { Server string `xcl:"server" mapstructure:"server"` Listen string `xcl:"listen,optional" mapstructure:"listen"`}
// Agent is one way the runner can perform a tasktype Agent struct { Name string `xcl:"name,label" mapstructure:"name"` Model string `xcl:"model" mapstructure:"model"` MaxConcurrentRuns int `xcl:"max_concurrent_runs,optional" mapstructure:"max_concurrent_runs"`}
// Task says which agents take a task, in order of preferencetype Task struct { Agents []Agent `xcl:"agents" mapstructure:"agents"`}A type with a label field is written with a label, agent "claude" {}. A
type with no label field, and no Meta field, is a
singleton: it is written without labels,
and the configuration holds one of it.
The configuration
A configuration is a directory, so it can be split into files:
variable "listen" { default = "127.0.0.1:7660"}
runner { server = "https://maalstrom.app" listen = variable.listen}agent "claude" { model = "claude/opus" max_concurrent_runs = 2}
agent "local" { model = "ollama/qwen3:32b"}
task "document" { agents = [agent.claude, agent.local]}Everything an xcl file can do works here, across the files of the directory.
variable.listen is resolved before Viper sees the value, as are functions
such as env("RUNNER_SERVER") and references to other blocks, such as
agent.claude, wherever in the directory they are declared.
Reading it
xclviper.New takes the block types and returns a Viper, and xclviper.Read
reads a file or a directory into it. From there on it is Viper as usual:
v := xclviper.New( xclviper.Type[Runner]("runner"), xclviper.Type[Agent]("agent"), xclviper.Type[Task]("task"),)
// a default in code, the file over it, and the environment over bothv.SetDefault("agent.claude.max_concurrent_runs", 1)v.SetEnvPrefix("MAALSTROM")v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))v.AutomaticEnv()
if err := xclviper.Read(v, "./config"); err != nil { return err}
fmt.Println("server:", v.GetString("runner.server"))fmt.Println("listen:", v.GetString("runner.listen"))fmt.Println("claude:", v.GetString("agent.claude.model"))server: https://maalstrom.applisten: 127.0.0.1:7660claude: claude/opusEach block is laid out under <type>.<label>, with its attributes named as
the struct’s tags name them: agent "claude" { model = … } is
agent.claude.model. A singleton is laid out under its type alone:
runner { server = … } is runner.server. A block type with a subtype adds
it, database "postgres" "main" is database.postgres.main, and nested
blocks are nested keys.
v.SetConfigFile and v.ReadInConfig still work for a single file;
xclviper.Read is what reads a directory.
The environment overrides the file the way it does for any format Viper reads:
MAALSTROM_RUNNER_SERVER=https://maalstrom.internal ./runnerserver: https://maalstrom.internalFlags bind the same way, with v.BindPFlag, so a Cobra command can set any
key the file can.
References, in order
A list of references to labelled blocks, such as agents, stays a list in
the order written, and each element keeps its label under the label field’s
name:
var settings struct { Runner Runner `mapstructure:"runner"` Task map[string]Task `mapstructure:"task"`}if err := v.Unmarshal(&settings); err != nil { return err}
for _, agent := range settings.Task["document"].Agents { fmt.Println(agent.Name, agent.Model)}claude claude/opuslocal ollama/qwen3:32bLabelled blocks nested inside a block, rather than referred to, stay keyed by their label, as top-level blocks are.
An attribute the file leaves out is left out of Viper’s keys, so
v.GetInt("agent.local.max_concurrent_runs") falls back to a default set for
it. Unmarshal does not: it fills the struct from what the file set, and
the field stays 0. Read a key that has a default with Get, or set the
default in the struct before unmarshalling into it.
When the file is wrong
A mistake in the file is reported by xclviper.Read as an xcl
diagnostic, so the program can show it the way xcl shows any
problem in configuration, with the file, the block, the field and the line at
fault:
agent "claude" { model = "claude/opus" modle = "claude/haiku"}if err := xclviper.Read(v, "./config"); err != nil { var problem *xclerrors.Diagnostic if errors.As(err, &problem) { report := xcl.DiagnosticPieces(xclerrors.Diagnostics{problem}, 80) os.Stderr.Write(highlight.Text.Render(report)) }
return err}Error: Unsupported argument
on config/agents.xcl line 3, in agent.claude, field modle: 3 | modle = "claude/haiku"
An argument named "modle" is not expected here.A runner given a label, runner "nas" {}, is reported at the label, since
the type is a singleton:
Error: Unexpected label
on config/runner.xcl line 5, in runner: 5 | runner "nas" {
runner takes no labels, there is one of it: write it as runner { … }, without "nas".highlight.ANSI in place of highlight.Text colours the report for a
terminal.
What to notice
- The structs are the application’s. No embedded type and no xcl import;
the
xcltags are all xcl needs, and whether a type takes a label follows from whether it has a label field. - A configuration can be a directory.
xclviper.Readreads every.xclfile in it as one configuration, so blocks refer across files. - Viper stays Viper. Defaults, environment variables, flags and
Unmarshalwork as they do for YAML or TOML, and the program can still read those formats too. - The file is more than a format. Variables, functions and references are resolved before Viper sees a value, and a list of references keeps its order.
- Mistakes are reported as configuration. A typo is named with its file, block, field and line, with the source around it.
The viper package is Go only. From Python, read the same files with a
Config and its lookups; see the parity list.