This walkthrough takes you from an empty folder to a working model that
generates a file, with live rebuilds. It assumes only that you can run node
and npm. Follow it top to bottom — each step builds on the last.
By the end you will have:
- written a model in
.aontu, - unified it into a single JSON document,
- generated an artifact from it with an action,
- and watched it all rebuild on save.
New to the ideas here? You can do the tutorial first and read the explanation afterwards — the concepts will land better once you have seen them work.
mkdir hello-model && cd hello-model
npm init -y
npm install @voxgig/model pinopino is a peer dependency used for logging. Installing it now avoids a warning
later.
Create the conventional layout:
mkdir -p model buildCreate model/model.aontu:
# model/model.aontu
service: name: 'orders'
service: port: 8080
Build it once:
npx voxgig-model model/model.aontuYou will see a few log lines, and a new file model/model.json:
{
"service": {
"name": "orders",
"port": 8080
}
}That is the whole job of the core tool: take your .aontu source, unify it,
and write the result as a single canonical JSON model. Everything else builds on
that model.
The point of a modeling language is to capture rules, not just values. Replace
model/model.aontu with:
# model/model.aontu
# A reusable "shape" for a service: defaults plus type constraints.
shape: service: {
name?: string # optional in the shape; each service supplies it
port: *8080 | integer # default 8080, but must be an integer
public: *false | boolean # default false
}
# Two services, each built from the shape.
service: orders: $.shape.service & { name: 'orders' }
service: web: $.shape.service & { name: 'web', public: true, port: 443 }
Rebuild:
npx voxgig-model model/model.aontumodel/model.json now contains:
{
"shape": { "service": { "port": 8080, "public": false } },
"service": {
"orders": { "name": "orders", "port": 8080, "public": false },
"web": { "name": "web", "port": 443, "public": true }
}
}Notice what happened:
ordersinherited the defaultport: 8080andpublic: false.weboverrode both. The override unifies with the shape — if you triedport: 'https'it would fail, becauseportmust be aninteger.
$.shape.service is an absolute reference (from the model root) and &
unifies it with the per-service overrides. This is how you keep one source
of truth for structure and reuse it everywhere.
Why is
nameoptional (name?)? The shape itself appears in the output, and every field in the generated model must resolve to a concrete value. A barename: stringwith nothing to satisfy it would fail to generate — so the shape leavesnameoptional (it may be absent) and each concrete service supplies its own.
Real models grow. Move the shape into its own file.
model/shapes.aontu:
# model/shapes.aontu
shape: service: {
name?: string
port: *8080 | integer
public: *false | boolean
}
model/model.aontu:
# model/model.aontu
@"./shapes.aontu"
service: orders: $.shape.service & { name: 'orders' }
service: web: $.shape.service & { name: 'web', public: true, port: 443 }
Rebuild — the output is identical. @"./shapes.aontu" imports the file and
unifies it into the model. Imports are tracked as dependencies, which matters in
step 6.
A model is only useful if it drives output. An action is a small JS module that receives the unified model and produces an artifact.
First, declare the action in the config file
model/.model-config/model-config.aontu:
# model/.model-config/model-config.aontu
sys: model: action: {
envFile: load: 'build/envFile'
}
The load path is relative to the project root (the folder above model/)
and has no extension, so this points at build/envFile.js. (Action names are
plain identifiers — use envFile, not env-file; a hyphen would need quoting.)
Now write the action, build/envFile.js:
// build/envFile.js
const Path = require('node:path')
module.exports = async function envFile(model, build) {
// project root is two levels up from the model root file
const root = Path.resolve(build.path, '..', '..')
const lines = Object.entries(model.service).map(
([name, svc]) => `# ${name}\nPORT_${name.toUpperCase()}=${svc.port}`
)
build.fs.writeFileSync(
Path.resolve(root, 'services.env'),
lines.join('\n') + '\n'
)
return { ok: true }
}Build again:
npx voxgig-model model/model.aontuYou now have a generated services.env:
# orders
PORT_ORDERS=8080
# web
PORT_WEB=443
Using build.fs (rather than require('fs') directly) means your action
automatically respects --dryrun.
Generating on demand is fine; generating as you type is better. Start watch mode:
npx voxgig-model --watch model/model.aontuLeave it running. In another terminal (or your editor), change a value — set
orders to port: 9090 in model/model.aontu, or edit model/shapes.aontu.
Within a moment the tool rebuilds and services.env updates. Editing the
imported shapes.aontu works too, because imports are tracked dependencies.
Press Ctrl-C to stop.
Before wiring a model into anything destructive, preview it without writing files:
npx voxgig-model --dryrun model/model.aontuThe build runs exactly as normal — actions execute, the model resolves — but every write is redirected to an in-memory filesystem. Nothing on disk changes.
- A model is
.aontusource unified into one JSON document. - Types (
integer,string, …) and defaults (*value | type) let the model enforce its own rules. - References (
$.path) and unification (&) reuse structure. - Imports (
@"./file") split a model across files. - Actions turn the model into artifacts, and respect
--dryrunviabuild.fs. - Watch mode rebuilds on every change, including changes to imports.
- How-to guides — focused recipes (build args, custom producers, embedding the API, in-memory filesystems, and more).
- Reference — every CLI flag, config key, API type, and language construct.
- Explanation — why unification, how the build lifecycle and watcher actually work, and the current limitations.