learn / 08·4 — under workflows · boards

a rendering,NOT Adatabase

Every tracker you've used is a second database with views. Grep this runtime for the Board table and the search comes back empty — on purpose. A board is the kernel's render() over the plan file, plus a write path back into it. Two verbs, zero models. This page is why that's the whole trick — and where it bites.

boards11 min read
A small figure standing before a single colossal illuminated wall-sized board whose columns of glowing task-cards are visibly drawn from one enormous open book on a pedestal below it, beams of light running from the book's pages up into each column — bright, monumental, 1970s sci-fi style

where's the BOARD table?

You've read the parent lesson's claim — boards are renderings of the plan file — and a reasonable instinct kicks in: that's a nice sentence, but where's the machinery? Every tracker you've ever opened is a second system of record. Somewhere there is a boards table, a columns schema, a card row with a status enum, and a sync job whose entire job is dragging that database back toward reality after reality moves.

So go looking. Open the runtime and grep for it — Board structs, column models, a kanban service. You should be told this upfront, because the search is short: it comes back empty. There is no Board table in the host. There is no card schema. There is no sync job, because there is nothing to sync between. The thing you expected to find is the thing that was deliberately not built.

That absence is not a gap — it's the design. The rest of this page is the account of what's there instead, and it's smaller than the table you went looking for.

a board, DECOMPOSED

board /bɔrd/ noun

1. the kernel's render(org) → html over a plan file, plus a write path back into that same file — kanban, status page, calendar, an agent's ready list are all the same composition with different clothes. Two verbs, zero models.

The whole construct decomposes into exactly two functions that already exist for other reasons. The read half is the kernel turning org into HTML. The write half is editing the file the HTML was rendered from. Nothing renders to a board and nothing is stored as a board — the file is the single source, and a board is a view of it that knows the way home.

Here is the parent's diagram redrawn with the actual function names on the arrows. The plan file fans out into any surface through one read function; every surface's only mutation is a write back into the same file:

flowchart TD
  file[["plan.org — the single source"]]
  file -- "render(org)" --> board["kanban surface"]
  file -- "render(org)" --> status["status page"]
  file -- "render(org)" --> cal["deadline calendar"]
  file -- "tangle_plan(org)" --> ready["an agent's ready list"]
  board -. "file write (PUT /w/:id)" .-> file
  ready -. "file write (exec)" .-> file
  style file fill:#f2ddb0,stroke:#121316,stroke-width:2.5px
  style board fill:#ffffff,stroke:#121316
  style status fill:#ffffff,stroke:#121316
  style cal fill:#ffffff,stroke:#121316
  style ready fill:#ffffff,stroke:#121316
  

The dashed arrows are dashed on purpose, and we'll be honest about why in the limits section: today the write path is a file write, not a mutation API. But notice what's missing from the picture — there is no box in the middle holding state. There is no board between the file and its views, because the runtime never learned what one is.

the one-line DENIAL

You don't have to take the diagram's word for it. The runtime states the position itself, in the moduledoc of the module you'd expect to find a board inside — workflow.ex. Verbatim:

A workflow is the unit of orchestration — there is no separate
"board". A native Org :workflow: headline IS the DAG… Org owns the
orchestration spec; the runtime just executes it — no DSL, no board model.

Grep the whole host layer for the word board and you find it in only three kinds of place: that denial; the names of org files (plan.org, BOARD.org) that some agents generate; and one module, groundskeeper/board.ex, whose job is to write an org file — not to model a board. There is no Board struct, no table, no schema anywhere in the engine.

This is the host-versus-loaded boundary showing through. The host holds primitives — parse, render, run. A board is a product behavior, and product behavior lives in the declarative layer: it's org text and a little CSS, not engine code. The runtime can't drift from a board model for the same reason it can't have a bug in one — it doesn't have one.

one line of Rust, three SURFACES

Depth rung — this is the read half, all the way down, and it really is one line. The kernel declares a single export in its interface type: export render: func(org: string) -> string. Every export is string-to-string; the kernel takes no host imports at all, which is its own kind of safety — give it anything but a string and the fault traps inside the WebAssembly runtime before it can touch a thing. The implementation behind that export is the entire definition:

pub fn render(org: &str) -> String {
    Org::parse(org).to_html()
}

Parse the org, emit the HTML. The heavy lifting is borrowed — orgize, an MIT-licensed org parser — so the kernel's own contribution to rendering a board is a function body you can read in one breath. And because it's that small and that pure, the same function reaches three surfaces without a second implementation:

flowchart LR
  src["crate::render — one function"]
  src --> comp["wasm Component (WIT)"]
  src --> babi["browser ABI (ptr/len)"]
  src --> rlib["native rlib"]
  comp --> rt["the runtime page
/w/:id"] babi --> learn["this learn notebook
in your tab"] rlib --> cli["the wb / wbx CLI"] style src fill:#f2ddb0,stroke:#121316,stroke-width:2.5px style rt fill:#ffffff,stroke:#121316 style learn fill:#ffffff,stroke:#121316 style cli fill:#ffffff,stroke:#121316

Three exits from one function. The runtime embeds the compiled oql.wasm at build time and calls render/1 as a plain GenServer call, handing back the raw HTML string — that's the page you get at GET /w/:id. A second ABI compiles the same crate for the browser, exporting oql_render over a pointer-and-length calling convention, so the identical kernel runs directly in a web page — which is how these learn notebooks render org with no server at all. And the native build links the crate as an ordinary rlib for the CLI. As the kernel source itself puts it: there is no "wasm version" of the logic — one body, three wrappers.

the page that TALKS BACK

Rendering gives you a board you can look at. What makes it a board you can use is that the served page ships with a way home. The runtime hands every workbook page a tiny client — window.wb — pointed back at the same engine that rendered it. Verbatim, the page carries:

window.wb = {
  call: (fn, org) => fetch("/w/<id>/call", …),
  live: (fn, org) => …WebSocket("/w/<id>/ws")
};
// the Workbook UI talks to its backend — this same runtime.

So the full loop is four arrows between a browser and one runtime, against one file. Walk it: you PUT the org source up; you GET the rendered page back; the page calls home over HTTP or streams over a socket; and every verb resolves against the same stored org:

sequenceDiagram
  participant B as browser
  participant R as the runtime (one file)
  B->>R: PUT /w/:id  (the plan.org source)
  R-->>B: 201 stored
  B->>R: GET /w/:id  (render the board)
  R-->>B: the rendered page + window.wb
  B->>R: wb.call("validate", org)  → POST /w/:id/call
  B->>R: wb.live("render", org)  → WS /w/:id/ws  {"fn","org"}
  

The HTTP call endpoint dispatches a small verb set — parse, tangle, validate — straight into the kernel. The ws endpoint upgrades to a socket whose frames are just {fn, org}, adds render to the same dispatch, and streams the result; its moduledoc calls it "the interactive upgrade over the HTTP backend — same dispatch, streamed." Either way, the page and its backend cannot disagree, because they're the same runtime reading the same file you PUT.

the board is the runner, PAUSED

Here is the cleanest proof that the board and the execution are one thing. The runtime exposes a single workflow endpoint with a flag. With ?plan=1 it returns the schedule — the board view, no execution. Without the flag it runs the same plan. Both branches start from the identical kernel call, tangle_plan(org): the board is literally the dry-run of the runner.

POST /api/workflow?plan=1   -d '{"org": "..."}'
# → [{"workflow":"Hello, Workbook",
#     "schedule":{"at":"2026-06-06T09:00","repeat":"+1d","active":true},
#     "tasks":["Greet"], "sub_workflows":[]}]

POST /api/workflow          -d '{"org":"...","input":""}'
# → same shape, but tasks now carry outputs; exports surfaced;
#   executed in topological waves

What comes back from each, side by side:

?plan=1 — the boardrun — the worker
kernel calltangle_plan(org)tangle_plan(org) — same call
what comes back{workflow, schedule, tasks, sub_workflows}same shape + task outputs + exports
what runsnothing — the schedule onlyevery step, in topological waves
tasks field holdscomponent namescomponent names + their outputs

An agent-typed component runs as a real agent; anything else runs as a WebAssembly filter step. And execution order isn't configured anywhere — it's topological waves: each wave is the set of steps whose predecessors are all done, and steps within a wave are independent, so they run in parallel. The plan view is that exact computation with the running switched off. You are never looking at a different artifact than the one that executes — you're looking at it at rest.

a status page that is two FILE READS

Depth rung. A live board — the thing that shows you a run clearing in real time — sounds like it must need a stateful server pushing card updates. It doesn't. Watch the lifecycle of the TODO runner, where the native org outline is the state machine — no custom tags, the keywords are the columns:

sequenceDiagram
  participant C as client
  participant R as runtime
  participant F as /tmp/bb/wf-4217/_status.json
  C->>R: POST /api/workflow/todo  (the org outline)
  R->>F: write {slug, stage:"running"}
  R-->>C: 202 {slug, status:"running"}
  Note over R,F: spawned runner works the tasks
  C->>R: GET /api/brand-book/:slug  (poll)
  R->>F: File.read
  F-->>C: {stage:"running"}  …then {stage:"done", tasks:[…]}
  

Read it as a story. You POST a TODO outline; the runtime writes a one-line status file, spawns the runner asynchronously, and answers 202 {slug, status:"running"} immediately. While the runner works, you poll a status endpoint — and that endpoint is nothing but File.read of _status.json. On finish the runner overwrites the file with {stage:"done", tasks:[…]}; on a crash, with {stage:"error", error}. The whole loop in three curls:

curl -X POST /api/workflow/todo -d '{"org":"* TODO ship\n** TODO write copy\n** TODO build"}'
# → 202 {"slug":"wf-4217","status":"running"}

curl /api/brand-book/wf-4217
# mid-run → {"slug":"wf-4217","stage":"running"}
# after   → {"slug":"wf-4217","stage":"done","tasks":[
#   {"id":"write-copy","idx":1,"title":"write copy","state":"DONE","output":"…","ts":1765500000}, …]}

curl /api/telemetry/wf-4217
# → {"stage":"done","tasks":[…],"tool_calls":23,"total_ms":48210,"errors":[],"recent":[…]}

The richer telemetry endpoint reads two files instead of one: _status.json for the stage and tasks, and _steps.jsonl — a line per tool call, written at the chokepoint every exec agent passes through — and rolls them into {stage, tasks, tool_calls, total_ms, errors, recent}. It's observable mid-flight precisely because there's no board object to query; the truth is on disk, and a status page is just someone reading it. A cross-run index over all of /tmp/bb is a pure scan that writes nothing, so it can never drift from the per-run files it summarizes.

And the minimum-viable kanban? It's three CSS lines in the runtime's own viewer — columns are classes computed from the file's own keywords:

.kw-TODO { background:#fce8e6; color:#c5221f }   /* red    */
.kw-DONE { background:#e6f4ea; color:#137333 }   /* green  */
.kw-NEXT,.kw-WAIT { background:#feefc3; color:#b06000 } /* amber */

A small JavaScript pass lifts the leading keyword of each headline into a badge. That's the entire board styling — semantics from the file, layout from your stylesheet.

the agent is a board CONSUMER

There's a third board, and it has no pixels: the ready set an agent reads to decide what to do next. The TODO runner treats the org outline as the whole specification — keywords are states, nesting is sub-workflows, ordering and edges are properties — and the agent consumes it the way a worker reads a board:

org constructboard meaning
a TODO / NEXT / DOING keywordthe card's column
TODO + all deps DONEready — pick it up
nestinga sub-workflow
:ORDERED: tsiblings run in sequence
absence of :ORDERED:siblings run in parallel (max 8 at once)
:BLOCKER:an explicit cross-edge
:done-when: / :check blockthe gate that lets a card reach DONE
an already-DONE headlinethe resume cursor — skipped on rerun

Two of those rows carry more than they look. DONE is not a free claim: a task with a :done-when: shell command or a :check source block only reaches DONE if that check passes — and it runs in the WASM shell, fail-closed, never on the native host, keyed off a sentinel string. And because already-DONE tasks are skipped, the file's own states are the resume cursor: kill a run, restart it, and it picks up exactly where the file says the work stopped. The board isn't a view of the agent's progress — it's the surface the agent reads, mutates, and resumes from, all in one file.

boards running in PRODUCTION

This isn't a thought experiment — real standing agents work real boards through this exact seam every day. Three of them, and the contrast between them is the lesson:

surfacereads fromwrites back?direction
plan.org (the keeper)its own backlogyes — NEXT / TODO / CANCELLEDtwo-way
dream verdictslast run's telemetryyes — mechanical edits to plan.orgtwo-way
BOARD.org (groundskeeper)the bd task storenever hand-editedone-way, by choice

The keeper that tends this project's landing site runs in plan mode by default: the agent only critiques and updates its backlog — it never edits the page directly. Its plan.org is a standing board that, in the lifecycle's own words, "is the ongoing task DAG — it never 'completes'." Each night the dream phase writes back into it through a deliberately mechanical protocol: a verdict must name the exact heading text of a task on the board, and the agent applies it by rule — pick up becomes NEXT, put down becomes TODO, cancel becomes CANCELLED. That's a real agent exercising the write half nightly.

The counter-example is the instructive one. BOARD.org is regenerated from the bd task store in one direction, grouped by priority, committed when a flag is set — and its own header says it: "never hand-edited, no two-way sync." That's not a limitation of the architecture. It's a surface that chose to be read-only, because for that board the source of truth lives elsewhere. Direction is per-surface policy, not a property of boards — which is exactly the freedom this design buys you.

where it BITES

Honesty section. The clean story above has real edges, and you should know them before you build on it.

  • The RPC seam is read-only. The call and ws verbs are parse, tangle, validate, render — all reads. There is no set-state or flip-to-DONE verb. Mutation today means writing the file: an agent with exec, a human with an editor, or a PUT /w/:id. The parent's "drag a card → file" picture is the architecture's affordance, not a shipped widget.
  • There is no drag-drop kanban in the repo. None ships. The kw- badges in the viewer are the pattern at minimum viable scale, and a full board is yours to build on the read/write seam.
  • render() gives you semantics, not layout. It emits generic HTML with keyword classes. Columns, swimlanes, and drag handles are your CSS and JS over those states — the kernel will never opinion-ate your board's shape.
  • The live board lives in /tmp/bb. _status.json and _steps.jsonl are ephemeral by design; durable history is the sealed ledger and the SQLite telemetry db, not the tmp files a poll reads.
  • One-way is a choice you must make on purpose. Nothing stops two writers from fighting over one file. BOARD.org avoids that by declaring itself generated-only. If your board has an upstream source of truth, say so — the architecture won't decide direction for you.

None of these is a missing feature pretending to be a principle. The design gives you the two verbs and the one file; what you render and which direction you let it flow are decisions it deliberately leaves to you.

questions people actually ASK

So how do I flip a card to DONE?

You edit the file. Change the headline's keyword to DONE and PUT /w/:id the new source — or let an agent with exec do it as part of working the task. There is no flip-a-card endpoint because the file is the card; the only mutation is a write to it.

Can I build a real kanban on this?

Yes — and you have both halves already. For columns, call render (or tangle_plan if you want the structured tasks) and group by keyword. For writes, PUT the edited org back. The runtime's own kw- CSS is the smallest version of exactly this; a richer board is more stylesheet, not more engine.

Why isn't there a mutation API?

Because the file is the API. A set-state RPC would be a second writer to a thing that already has one perfectly good way to be written — and a second writer is exactly how a board drifts from its source. Keeping mutation as "edit the org" keeps the single-source guarantee the whole design rests on.

Isn't BOARD.org a contradiction — a one-way board?

No. Direction is policy, not architecture. BOARD.org is generated from the bd store and declares itself never-hand-edited because for that surface the truth lives upstream. Two-way is available; one-way was chosen. Both are boards.

Does this scale past /tmp?

The live view is meant to be ephemeral — a run's status and step log in /tmp/bb, cheap to read and fine to lose. Durable truth is the plan file in version control plus the sealed ledger and the telemetry SQLite db. Don't treat the tmp files as the record; treat them as the window.

Is the Autopoet's backlog one of these?

Exactly one of these — a standing agent working a plan file against the system itself, picking up TODOs and writing DONE back. It's the worked example of every idea on this page.

keep GOING

Boards are the read-and-write face of the plan — each neighbor goes one layer deeper.