Digitorn
Digitorn
All posts
yaml

Eight blocks instead of fifteen: the YAML schema rewrite

Digitorn's app YAML used to scatter related fields across the file. We rewrote the schema into eight canonical top-level blocks, each with one job. Here is what changed, why, and how the migration tool keeps every old YAML working.

DDigitornMay 1, 20267 min read

A configuration file is supposed to answer the question "what does this thing do?" in one read. The first version of Digitorn's app YAML answered it in three or four. Fields were where they were because the runtime had needed them in that order, not because anyone had thought about how a reader scans the file. We rewrote the schema. Here is what came out.

What the old shape looked like

A typical app spread its declarations across roughly fifteen top-level keys. app:, agents:, modules:, capabilities:, channels:, behavior:, widgets:, theme:, features:, slash_commands:, skills:, variables:, include:, middleware:, pipeline:, plus an execution: block that itself contained another twenty fields. Some keys belonged together conceptually (modules and capabilities both describe what the agent can do) but lived four screens apart. Some keys belonged to the user-facing client (theme, features, slash_commands) but sat next to keys that controlled the daemon's lifecycle (execution.mode, execution.timeout).

Reviewers asked the same question on every PR: "where does this field belong again?". When two engineers couldn't agree, the answer was typically "wherever the existing example happens to put it". That's the smell of a schema that grew organically and never got pruned.

Eight blocks, one purpose each

The new shape is exhaustive at the top level and shallow on purpose. Eight blocks, each owning one concern:

YAML
1schema_version: 223app:        # identity: app_id, name, version, icon, color, tags4runtime:    # lifecycle: mode, max_turns, triggers, hooks, middleware, context5agents:     # list of agents (id, role, brain, system_prompt, modules)6tools:      # what the agent can call: modules, capabilities, channels7security:   # boundaries: behavior rules, sandbox, credentials_schema8ui:         # pure display, never read by the daemon: theme, widgets, greeting9dev:        # developer affordances: skills, variables, include10flow:       # optional declarative orchestration graph

Every field has exactly one home. The header above is not a partial example; those are all the top-level keys.

The split that mattered most was separating tools: from runtime:. Capability declarations and module declarations now live next to each other, where reviewers expect them. Lifecycle settings (mode, timeout, triggers, hooks) are isolated under runtime:, where someone tuning execution behaviour can find them without scrolling past UI configuration. Anything the daemon does not read at runtime, such as theme colours and slash command labels, is in ui: and can be skipped entirely if you only care about the runtime contract.

Why flow is a top-level block

The block we agonised over the most was flow:. It was nested under runtime: in early drafts because flows control execution order, and execution belongs in runtime:. We moved it out to its own top-level block at the last minute. Here is why.

A flow is a paradigm shift, not a tweak. An agent app without a flow runs the standard agent loop: the LLM decides what to do, calls tools, observes results, loops. An agent app with a flow runs a directed graph the author wrote by hand, where steps fire in declared order and the LLM is just one of several possible step types. These are two different programming models that happen to share a runtime.

Burying that distinction inside runtime: would have hidden the choice from anyone scanning the file. A reviewer should be able to glance at the top-level keys and immediately know "this is a freeform agent" versus "this is a scenography". Flow is not a runtime knob. It is the shape of the program.

The migration is mechanical, the alias pass keeps both shapes valid

Forcing a hard cutover on a schema like this is how you generate an angry mailing list. We didn't.

The compiler accepts both v1 and v2. Behind the scenes, an alias pass walks the v1 keys, lifts them to their canonical v2 home, and runs Pydantic validation against the v2 model. Both shapes parse to the same AppDefinition object. A YAML written eighteen months ago still deploys today.

For files in active development, a CLI command rewrites them in place:

Bash
1digitorn yaml migrate-v2 path/to/app.yaml

The migrator leaves nothing behind. Top-level modules: becomes tools.modules. Top-level behavior: becomes security.behavior. execution.workspace becomes runtime.workdir, and execution.greeting becomes ui.greeting. The output is canonical v2, with comments preserved where the YAML library could carry them through.

The reason for offering both is not nostalgia. It is that we run a Hub of community-published agents. Forcing every author to migrate on a deadline would have broken installs for end users who never wrote any YAML themselves. The alias pass means installs keep working while every PR slowly nudges the ecosystem forward.

What broke when we did it

Three things, each instructive.

First, the canvas builder. We have a visual canvas (digitorn-builder) that reads YAML, renders it as nodes and edges, and writes it back when the user drags things around. The read/write paths were full of hardcoded JSON paths like doc.modules.X.config and doc.execution.entry_agent. Every one of them had to move to the canonical paths (doc.tools.modules.X.config, doc.runtime.entry_agent). We caught two real bugs along the way: the validator was reading doc.runtime.credentials_schema, but the canonical home is doc.security.credentials_schema. The check had been silently passing because the path didn't exist.

Second, the documentation. Every YAML example in the doc set had to compile against the canonical schema. We wrote a small audit that walked every .md file, extracted every yaml code fence, and ran it through the compiler. The first run flagged twenty-six broken examples. Some were trivial (mode: conversation paired with watchers: true, which is a hard error in v2 because watchers require background mode). Some revealed real schema confusion (a connections: field on the database module that the module's actual config schema doesn't accept). We fixed every one. The audit now runs in CI.

Third, the AI agent that writes YAML. We ship a builder agent that can author and deploy app YAMLs from natural-language prompts. Its system prompt was full of v1 examples. We retrained it on canonical v2 by rewriting every example in the prompt and adding a list of "patterns the LLM tends to hallucinate" so it stops trying to put modules: at the top level.

The unflattering observation about ourselves

The thing that took the longest was not the schema rewrite. It was admitting that the original layout had been wrong from the start.

Each of the fifteen v1 top-level keys had a defensible reason for existing where it did, when it was added. The behavior: block had been added when the behaviour engine was a new idea and giving it a top-level home felt like signalling its importance. The widgets: block came in when widgets shipped, and putting it at the top mirrored what the team was thinking about that quarter. None of these decisions was wrong on its own. The cumulative effect was a schema that read like a release-notes archive.

Refactoring that took us six weeks. Most of it was not code. It was building the migration story so existing apps kept working, writing the audit that proved nothing broke, and going through every blog post and README and prompt to update the syntax. The schema change itself was a few hundred lines.

If you are considering a similar move on your own runtime, the pattern that worked for us was: alias pass first, hard validation second, migrator third, doc audit fourth. Skipping any of those four steps means you ship a schema that is correct on paper and broken in practice.

Concretely, what to write today

If you are starting a new app, write canonical v2. The doc set, the builder, the canvas, every example in the Hub, and the migration tool all assume v2. There is no scenario where v1 is a better choice for a new file.

If you have an old app, leave it as is and let the alias pass handle it, or run the migrator when you next touch the file. Either is fine. The runtime cannot tell.

If you maintain an internal tool that generates YAMLs (a code generator, a templating system, a custom CLI), point it at canonical v2 and stop emitting v1. The alias pass exists to keep old files working, not to perpetuate a shape we wish we had never shipped.

Bash
1# Validate a YAML against the canonical schema2digitorn yaml lint app.yaml34# Rewrite an old YAML to canonical5digitorn yaml migrate-v2 app.yaml67# Print a human-readable summary of the file's structure8digitorn yaml explain app.yaml

The new shape is documented end to end at docs/app-language/00-index.md. Every block has its own page. Every example in those pages compiles. We made that a rule because the docs are how new authors learn the schema, and a doc with a broken example teaches the wrong shape.

#yaml#schema#framework-design#migration#language-design
ShareLinkedIn
Newsletter

One post a fortnight, in your inbox.

Engineering notes from the Digitorn team. No marketing, no launch announcements, no "10 prompts that will change your life". Just the things we write that we'd want to read.

One-click unsubscribe. We never share your address. Powered by our own infrastructure, not a tracker.
D
The Digitorn team

We build the open-source AI agent runtime that runs on your own machine. YAML over Python, multi-agent by default, marketplace for sharing.

Keep reading

Try it now

Ship your first AI agent in 5 minutes.

Open-source. Self-hosted. YAML-first. Bring your own LLM keys, agents run on your machine.