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
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 board | run — the worker | |
|---|---|---|
| kernel call | tangle_plan(org) | tangle_plan(org) — same call |
| what comes back | {workflow, schedule, tasks, sub_workflows} | same shape + task outputs + exports |
| what runs | nothing — the schedule only | every step, in topological waves |
| tasks field holds | component names | component 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 construct | board meaning |
|---|---|
| a TODO / NEXT / DOING keyword | the card's column |
| TODO + all deps DONE | ready — pick it up |
| nesting | a sub-workflow |
:ORDERED: t | siblings run in sequence |
absence of :ORDERED: | siblings run in parallel (max 8 at once) |
:BLOCKER: | an explicit cross-edge |
:done-when: / :check block | the gate that lets a card reach DONE |
| an already-DONE headline | the 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:
| surface | reads from | writes back? | direction |
|---|---|---|---|
plan.org (the keeper) | its own backlog | yes — NEXT / TODO / CANCELLED | two-way |
| dream verdicts | last run's telemetry | yes — mechanical edits to plan.org | two-way |
BOARD.org (groundskeeper) | the bd task store | never hand-edited | one-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
callandwsverbs areparse,tangle,validate,render— all reads. There is noset-stateorflip-to-DONEverb. Mutation today means writing the file: an agent with exec, a human with an editor, or aPUT /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.jsonand_steps.jsonlare 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.orgavoids 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.