where does config GO?
The parent lesson made the case that org holds prose and structure in one parseable file. But config still feels like it lives somewhere else. Every system that grows up grows a second format for fields: a YAML manifest describes the package, a JSON blob holds the agent settings, a database table carries the task metadata. The document is one thing; the configuration that drives it is always somewhere adjacent.
That split is the same disease the parent diagnosed, wearing a different coat. The moment a heading needs to be more than a title — to carry a model name, a CLI binary, a deploy target — the convention is to leave the document and go fill out a form in another file, in another grammar, parsed by another library. So which heading does that form describe? You hope the names line up.
Org refuses the split. A heading that needs fields gets them inline, in the same tree, parsed by the same kernel. This lesson is the mechanism that makes that true — and the honest catalog of where it bends.
the DEFINITION
1. a :PROPERTIES: … :END: block directly under a headline that turns it into a typed record — key-value pairs parsed by the kernel into a deterministic map. The same grammar carries toolkit manifests, agent identities, deployment configs, and the spec inside this page.
The word is borrowed straight from org-mode, where a drawer is any
fold-away block. The one that matters here is the :PROPERTIES:
drawer, because that's the one the kernel reads into structured fields. A
headline without one is a title. A headline with one is a row.
the FOUR lines
The whole grammar is four lines, and you've already written it if you've ever filled out a form. A keyword opens it, key-value pairs sit between colons, a keyword closes it:
* Data Analyst :agent: :PROPERTIES: :ID: analyst :Type: ai :MODEL: xiaomi/mimo-v2.5 :END:
Two rules govern whether the kernel sees it, and both are strict. First,
the drawer must be the first thing in the section — a planning line
(SCHEDULED / DEADLINE) is allowed to sit above it,
but a single line of prose before :PROPERTIES: and the kernel
silently returns nothing. Second, every key gets a value. Keys are
uppercased on the way in and values are trimmed, so the authored
:Type: ai comes back as TYPE with the value
ai, and :KEY: spaced value
comes back as spaced value with the padding gone.
That normalization is not cosmetic. It means the consumer reading a drawer
never has to guess at casing or whitespace — analyst.org and a
machine-rendered file agree on the spelling of every key, because the kernel
decides it, not the author.
one PARSER, three doors
There is exactly one piece of code that turns a drawer into a record: a
function called headline_of() in the kernel, which reads each
property pair and inserts it into a sorted map with the key uppercased and the
value trimmed. Sorted matters — the map is a BTreeMap, so keys
come out alphabetically, deterministically, the same bytes every time. Same
input, same output, forever.
flowchart LR org["a .org file
:PROPERTIES: … :END:"] org --> hl["headline_of()
uppercase key · trim value"] hl --> map[["sorted props map
(BTreeMap → JSON)"]] map --> a["AgentDef.parse
model · toolkits"] map --> t["Toolkits.view
CLI_BIN · STATUS"] map --> w["Workspace
DID · SCOPE"] map --> s["schedule_of()
:SCHEDULE: → cron"] style org fill:#f2ddb0,stroke:#121316,stroke-width:2.5px style hl fill:#ffffff,stroke:#121316 style map fill:#fbfaf6,stroke:#121316,stroke-width:2.5px style s fill:#aee5c2,stroke:#121316
And that one function is reachable through three doors that are the same
code compiled three ways. It's a pure string-to-string Rust crate, so it
ships as a native library inside the wbx CLI (wbx
query reads a drawer directly), as the oql.wasm Component
embedded in the runtime at compile time (the engine calls the identical
function), and as a WebAssembly build running in your browser tab right now.
A toolkit manifest, an agent file, and this page get parsed by the same logic
whether you're at a terminal, on a server, or reading the docs. The
kernel lesson walks the whole crate; here it's enough to
know the map you get back is the same map everywhere.
keys the kernel PROMOTES
Most keys are inert to the kernel — it copies them into the map and lets each consumer decide what they mean. But two keys the kernel treats specially, promoting them out of the property bag into structured fields of their own:
| key in the drawer | what the kernel does with it | who reads the promotion |
|---|---|---|
:ID: | lifted to a top-level id field on the row, beside title and tags | every consumer that needs identity — agents, toolkits, workspace members |
:SCHEDULE: | becomes {"cron": …} in the build plan — and beats a SCHEDULED: planning timestamp if both exist | the build plan; the keeper that fires the cadence |
The verdict of that table: :ID: is how a drawer says "this is
the thing named X," lifted where any consumer can find it without digging
through properties; and :SCHEDULE: is how a drawer carries a cron
string, which the kernel surfaces into every compiled world. One honest
caveat on the second one — the kernel carries the schedule, but it
does not fire it. There is no cron daemon hiding in the kernel; execution
cadence is the keeper's job, and the full time grammar
lives in its own timestamps lesson. The kernel's role
is to make the schedule a parseable fact, not to run a clock.
the BESTIARY
Here is the payoff, and the centerpiece of the page. Every record below is a real headline with a real drawer, parsed by the one kernel, consumed by a different part of the system. Each row names the record, the keys its drawer actually carries, and who reads them:
| record | keys on its drawer | what reads it |
|---|---|---|
| toolkit manifest | :ID: · :CLI_BIN: · :STATUS: · :SKILL_DIR: |
the toolkit view — CLI_BIN names the command on PATH |
| agent definition | :ID: · :MODEL: · :TOOLKITS: · :TAGLINE: |
the agent parser — :TOOLKITS: splits on whitespace into the skill index |
| fleet member | :DEF: (required) · :LIFECYCLE: · :INTERVAL: |
the keeper — :DEF: points at an agent file, :INTERVAL: sets the beat |
| lifecycle state | :KIND: · :NEXT: · :REPEAT: · :MIN-INTERVAL: |
the lifecycle spec — headings are states, the drawer is the transition |
| workspace member | :DID: · :PATH: · :SCOPE: |
ref resolution — :DID: first, then :PATH:; scope defaults to read |
| workflow task | :DONE-WHEN: · :ORDERED: |
the acceptance gate — :DONE-WHEN: runs in the sandbox, fail-closed |
| deployment config | ENGINE_PLACE · TENANCY_MODE · STORAGE · DATABASE · AUTH |
a hand-rolled scan — never executed, inert declarative config |
The thing worth sitting with: these are not seven config formats with a family resemblance. They are seven uses of the same four lines of grammar. A toolkit and an agent and a deployment do not share a schema — they share a parser, and each one validates its own keys downstream. That's why a person who can read one drawer can read all of them. The last row is the one exception to flag now and again at the end: deployment config is parsed by a deliberately hand-rolled scan, not the kernel, precisely because a deployment file should be inert — read as data, never run.
from drawer to RUNNING agent
Depth rung — skippable, but it's where a drawer stops being a data
structure and becomes a live worker. Take analyst.org from the
top of this page and trace it all the way to a running
agent:
sequenceDiagram participant F as analyst.org participant K as kernel (headline_of) participant A as AgentDef.parse participant T as Toolkits.injection participant R as Agent.run F->>K: :PROPERTIES: drawer K->>A: props map · MODEL · TOOLKITS · TAGLINE Note over A: id + model read straight off the map A->>T: :TOOLKITS: shell wb sandbox T->>A: skill index appended to the system prompt Note over A: system prompt comes from the
** System prompt heading — NOT the drawer A->>R: %{id, model, toolkits, system} Note over R: the agent runs
Read the sequence as a story. The file hands its drawer to the kernel,
which returns the sorted map. The agent parser reads id and
model straight off that map — no extra step. It splits
:TOOLKITS: on whitespace into a list, and each declared toolkit's
skill manual gets auto-injected into the system prompt, so an agent
that declares sandbox in its drawer wakes up already knowing how
to use it. Then the one thing that is not in the drawer: the system
prompt itself comes from a ** System prompt heading in the body,
not from a property. The drawer says who the agent is and what it
has; the prose says what it should do. That division — fields in
the drawer, briefing in the heading — is the whole authoring shape, and the
authoring lesson walks it end to end.
machines WRITE drawers too
So far the drawer has been something a person authors. But the same grammar flows the other way: the system writes drawers as readily as it reads them, because a record a machine can emit is a record a human and another machine can both read back.
The clearest case is an agent's own event log. Every tool call an agent makes writes one headline with the arguments captured as JSON in a drawer:
* tool call — vfs-query :event:
:PROPERTIES:
:ARGS: {"sql":"select count(*) from vfs"}
:END:
Nobody types that. It's emitted by the runtime, one headline per call,
which means an agent's entire run is a parseable org file you can later query
with the same wbx query that reads a hand-written manifest. The
run is fully observable because it was recorded in the one grammar
everything else speaks. The same is true of the task ledger a keeper renders —
a TASKS.org with :ID:, :FILE:,
:STARTED:, and :FINISHED: per task, generated from a
ledger and never hand-edited — and of the manifest written when a toolkit is
promoted, carrying the :ID: and :CLI_BIN: a person
would otherwise have authored.
There's a quieter machine-readable convention worth naming: a board claim.
An agent that takes a task can write an :AGENT: property and commit
it to git, telling the other agents "this one is mine." That's a
def-level protocol, not a runtime lock — the system honors it by
convention, not by enforcement, and the keepers lesson
is honest about exactly that line.
this page is a RECORD
The recursive kicker, and the easiest one to verify: this page you're
reading is itself a drawer. Near the bottom of the source sits a
script block tagged workbook-spec, and its drawer
drives the chrome you're looking at — the kicker, the three-line title, the
chip, the colors, the hero image:
flowchart TD spec[["script#workbook-spec
:SLUG: :KICKER: :TITLE1-3: :PC: :HERO:"]] spec --> chrome["learn.js
renders hero · kicker · chip live"] spec --> card["og/build.py
generates the social card"] style spec fill:#f2ddb0,stroke:#121316,stroke-width:2.5px style chrome fill:#ffffff,stroke:#121316 style card fill:#ffffff,stroke:#121316
Two readers consume that one drawer, and neither touches the HTML around it.
The page's own script reads the spec and paints the live chrome — change
:TITLE2: in the drawer and the bub word in the headline changes
with it. The social-card generator reads the same spec — and only the spec,
never the surrounding markup — to draw the image that shows up when this URL
is shared. The keys are uppercase-only by design, the same normalization the
kernel applies everywhere. One honest footnote: the workbook artifact
spec uses a JSON dialect under that same workbook-spec marker,
while these learn pages use the org dialect. Two dialects, one marker —
whether they converge is unsettled, so the specs lesson
treats them as distinct, and so should you.
the SEAMS
Honesty section. Drawers are unforgiving in ways worth knowing before they bite you. Four seams, named plainly.
Placement fails silently. The drawer must be the first thing under the headline. Put a line of prose above it and the kernel returns an empty map — no warning, no error, just nothing. This is the single most common way a drawer disappears:
* node
some prose first
:PROPERTIES:
:KEY: value
:END:
→ props: {} — the kernel never saw the drawer
A valueless line can void the whole drawer. A property line with
nothing after it — :EMPTY: alone — can make the parser drop every
sibling key, not just the empty one. The exact rule is uncertain, but the
behavior is demonstrable: one bad line and the record is gone. The fix for
both is mechanical — drawer first (a planning line may precede it), every key
gets a value.
Values are stringly typed. A drawer has no schema and no types. Every
value is a string, so there is no kernel-level guarantee that
:MODEL: names a real model or that ENGINE_PLACE is a
valid enum. Each consumer validates its own keys — the deployment scanner
checks its enums, the agent parser checks its model. The kernel gives you a
faithful map; meaning is everyone's own responsibility.
There are two parser families. The strict kernel is one. But for
inert deployment config there's a deliberately hand-rolled scan that never
executes, and for agent-written task files there's a lenient merge that
also honors bare :KEY: val lines anywhere in a task
body — because agents write sloppy org and the system would rather read it
than reject it. On a conflict between a bare line and the drawer, the drawer
wins. That leniency is a choice, not an accident, and it's a real seam: the
same file can be parsed strictly in one place and loosely in another.
questions people actually ASK
Is a drawer a database row?
No — and the parent lesson already drew this line. A drawer is a handful of fields on a headline, parsed into a small map. Heavy data — anything you want to index, join, or query at scale — goes in the SQLite disk, not in properties. A drawer is for the fields that describe a thing, not the data inside it.
Why are the keys uppercase?
The kernel uppercases every key on the way in, so the consumer never has
to guess at casing. You can author :Type: or :type:
and read back TYPE either way. It's normalization that buys
determinism — the map is the same regardless of how the author typed it.
Can I invent my own keys?
Yes. The kernel copies every key into the map; consumers simply ignore
the ones they don't recognize. Add :OWNER: or :NOTE:
to a task and nothing breaks — your tooling can read it, and the runtime
won't trip over it. Unknown keys are inert, not errors.
Drawer or #+KEYWORD:?
Different altitudes. A #+KEYWORD: is file-level metadata; a
drawer is headline-level, scoped to one record. Some consumers accept both —
a toolkit reads CLI_BIN from a #+CLI_BIN: keyword
or a drawer property — but the rule of thumb is: file-wide facts go in
keywords, per-record fields go in drawers.
How do I see the record a drawer produces?
Run wbx query over the file. It calls the same kernel the
runtime uses and prints the JSON map — uppercased keys, trimmed values,
:ID: promoted, keys alphabetically sorted. It's the fastest way
to confirm a drawer parses the way you think it does, especially when
placement might be silently wrong.
Does :SCHEDULE: mean the kernel runs my cron?
No. The kernel carries the schedule into the build plan as a fact; it does not fire it. Execution cadence is the keeper's job, driven by intervals and external triggers. The drawer makes "every morning at six" parseable; running it on time is a separate engine's responsibility.
keep GOING
A drawer is one construct of the grammar — these neighbors are the rest of the same sentence.