learn / 04·3 — under org · drawers

a headlineBECOMESa record

A :PROPERTIES: drawer turns any headline into a typed record — key-value pairs that live inside the prose, parsed by one kernel into a deterministic map. A toolkit, an agent, a deployment, this very page: all the same four lines of grammar. The ecosystem has no config format because headlines are the config format.

the records10 min read
A small figure standing before a monumental wall of glowing labeled card-catalog drawers, each sliding open to reveal a single typed record inside — bright amber and green, 1970s sci-fi style

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

draw·er /drɔːr/ noun

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
one function answers everyone — the same map, read many ways

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.

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:

recordkeys on its drawerwhat 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
the drawer is identity and equipment — the prose is the briefing

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
edit the record — the page follows

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.