the kind PROBLEM
You've met headlines and TODO states already — the parent lesson built them. But there's a problem hiding in that uniformity: everything in your tree is the same kind of thing. A heading is a heading. So how does a machine know which heading is a toolkit it should offer to an agent, which subtree should compile and run, which heading is an agent's identity, and which is just a note to yourself?
The usual answers all bolt a second system onto the document. Filenames —
put toolkits in a toolkits/ folder, agents in agents/,
and now meaning lives in the path, not the text. Directories with magic
names. YAML front-matter — a little typed header riding above the prose, in
a different grammar with different rules. Or a registry: an index file, a
database, a manifest that lists what's what and drifts out of sync the first
time someone edits the real thing without updating the list.
Every one of those is a second place where truth lives. The document says one thing; the registry says another; eventually they disagree. The question worth asking is whether the kind annotation could live in the grammar itself — so there's nothing to keep in sync, because there's only one thing.
the DEFINITION
1. a word in the trailing :colon:run:
of an org headline — the kind annotation machines filter on;
stripped from the title, never displayed as text, and carried in every
query row. One tag turns a heading into a role the runtime reacts to.
Org's own thesis, declared at the top of the one parser everything shares: "Org is a language, not just syntax: TODO state, tags, drawers, source blocks — all carry meaning." Workbooks takes that literally. The trailing tag isn't decoration the way a hashtag is — it's the type annotation of the written layer, and the kernel treats it as load-bearing.
how a tag is READ
The mechanics are small and exact, which is the point. A tag is the run of colon-delimited words at the very end of a headline. Here's a real one, from this project's example workflow:
* Nightly digest :workflow: ** Fetch events :component: ** Summarize :component: ** Send :component:
Four facts govern how the kernel reads those:
- Trailing run only. The tag is the final whitespace-separated
token that starts and ends with a colon. A colon mid-title is just a
colon —
* Fetch: eventshas no tags. :a:b:is two tags. The kernel extracts them as a flat list.:workflow:yields["workflow"];:a:b:yields["a","b"]. The easter egg in this page's own source —:toolkit:agent:workflow:— parses to three.- Stripped from the title. The kernel's
strip_tagsremoves that trailing run, so the title comes back clean:"Nightly digest", not"Nightly digest :workflow:". A tag is metadata, not title text. - Exact string match. The whole tag API is one membership check —
has_tagasks whether the headline's tag list contains that exact string. No globbing, no hierarchy, no case folding.:Workflow:is not:workflow:.
That's the entire reading model. Three lines of Rust do the matching; everything downstream — compile, discover, validate — is built on that one check.
the five tags that RUN the ecosystem
Most tags you'll ever write are inert labels the runtime ignores — and that's fine. But five tags are canon: each one is read by a specific module, and carrying it turns a heading into something the system acts on. This is the heart of the page. One tag, one role:
| tag | module that reacts | what the heading becomes | proof in the tree |
|---|---|---|---|
| :workflow: | Workbooks.Workflow | a DAG — its :component: children are tasks, header args are edges | * Nightly digest :workflow: |
| :component: | build_plan (kernel) | a task / component leaf — a unit of code inside a world | ** Fetch events :component: |
| :toolkit: | Workbooks.Toolkits | a discoverable capability — found by a tag query | * ffmpeg — multimedia processing :toolkit: |
| :agent: | Workbooks.AgentDef | a runnable identity — reads :ID: :MODEL: :TOOLKITS: off it | * keeper :agent: |
| :member: | Workbooks.Workspace | a workspace entry — a participant with a :DID: / :PATH: | * alice :member: |
Read the table as a single sentence repeated five ways: a word at the end
of a heading hands that heading to exactly one part of the runtime, which
treats it as exactly one kind of thing. Nothing else about the heading
changed — same indentation, same prose body, same drawers. The
:workflow: moduledoc says it outright: "A native Org
:workflow: headline is the DAG… no DSL, no board
model." The tag is the whole declaration.
And notice the discipline in the example: a workflow heading and its component children are tagged differently because they are different kinds — but the agent that runs them and the toolkit it reaches for are tagged differently again, because kind is the only thing a tag ever encodes.
tags on the QUERY surface
Because every headline row carries its tags, the document is queryable by
kind without any extra index. The kernel's parse_headlines
emits one row per heading, and "tags" is a field on every row.
Run the real verb against the real file:
$ wbx query nightly-digest.org
{ "level": 1, "title": "Nightly digest", "tags": ["workflow"],
"scheduled": { "at": "2026-06-06T06:00", "repeat": "+1d", "active": true },
"state": null, "id": null, "props": {} }
→ title comes back WITHOUT the tag — strip_tags did its job
→ tags is a list — :a:b: would be two entries
So filtering by kind is filtering rows. From the shell, with
jq, "give me every task in this workflow" is one line:
$ wbx query digest.org | jq '.[] | select(.tags | index("component")) | .title'
→ "Fetch events" "Summarize" "Send"
The runtime does the same thing in Elixir, and this is the part worth sitting with: the entire toolkit-discovery "subsystem" is one filter over the same rows. No registry, no index file, no scan of a manifest directory that drifts. Discovery is a tag query:
def discover(org), do: org |> OQL.parse_headlines() |> Enum.filter(&toolkit?/1) |> Enum.map(&view/1) defp toolkit?(h), do: "toolkit" in h["tags"]
Run that over every manifest.org under the toolkits root and
the tags are the registry. The real heading it finds —
* ffmpeg — multimedia processing :toolkit:, with an
:ID: and a skill directory in its properties — is registered by
nothing but the word at the end of its title. Here's the whole pipeline,
one file in, three kinds of thing out:
flowchart LR f["an org file
headlines with trailing :tags:"] p["parse_headlines
one row per heading"] r["rows
each carries tags: [ … ]"] f --> p --> r r -- ""toolkit" in tags" --> tk["discoverable toolkits
Workbooks.Toolkits.discover"] r -- ""agent" in tags" --> ag["runnable identities
Workbooks.AgentDef.parse"] r -- ""workflow" in tags" --> wf["compilable worlds
build_plan"] style f fill:#ffffff,stroke:#121316 style p fill:#ffffff,stroke:#121316 style r fill:#f2ddb0,stroke:#121316,stroke-width:2.5px style tk fill:#f3c5a3,stroke:#121316 style ag fill:#9fc4e8,stroke:#121316 style wf fill:#aee5c2,stroke:#121316
One honest note about syntax. The docs sketch a fuller query language —
composable s-expressions like (and (tags :toolkit:) (property :ID:
"wb")) — as the design direction. That parser is not shipped as a
kernel export today. What ships is exactly what you just saw:
wbx query returns all rows, and the tag filter lives one step
later — a jq selector at the shell, or one line of Elixir in
the host. When this page writes (tags :toolkit:), read it as
"the filter over query output," not as a string the kernel parses.
the tag that COMPILES
depth rung · skippable — how a tag becomes a runnable plan
Discovery is filtering; compilation is the strongest form of "a tag does
something." The kernel's build_plan walks the tree and a tag
decides what each heading is allowed to become. A top-level heading is only
treated as a world if it has_tag("workflow"). Inside a
world, the level-walk asks each descendant its tag:
has_tag("workflow") recurses into a sub-world;
has_tag("component") makes a task leaf with code. And
validate skips — literally continues past — any
heading not tagged workflow. Untagged headings are invisible to
the compiler.
flowchart TD root["* Nightly digest :workflow:
→ build_plan opens a WORLD"] c1["** Fetch events :component:
→ a task leaf (rust)"] c2["** Summarize :component:
→ a task leaf (js)"] c3["** Send :component:
→ a task leaf (go)"] note["** Notes (no tag)
→ ignored — not in the plan"] root --> c1 root --> c2 root --> c3 root --> note c1 -- ":out events → :in events" --> c2 c2 -- ":out summary → :in summary" --> c3 out["world JSON
{ "worlds": [ { tasks: [fetch, summarize, send] } ] }"] c3 --> out style root fill:#aee5c2,stroke:#121316,stroke-width:2.5px style c1 fill:#f2ddb0,stroke:#121316 style c2 fill:#f2ddb0,stroke:#121316 style c3 fill:#f2ddb0,stroke:#121316 style note fill:#d9dbd3,stroke:#121316 style out fill:#ffffff,stroke:#121316
Walk the graph as a story. The top heading carries :workflow:,
so build_plan opens a world. Its three children each carry
:component:, so each becomes a task with real code — Rust, then
JS, then Go — wired by their :out→:in header args
into a chain. A fourth child with no tag sits right there in the same tree
and the compiler never looks at it. Out the bottom comes the world JSON: one
world, three tasks, the plan the engine runs. The tree didn't gain a build
file or a config block. One tag on the parent and one on each child was the
entire build specification.
tags, states, and PROPERTIES
A tag carries kind — and only kind. It's tempting to reach for a tag whenever you want to annotate a heading, but the written layer divides that labor across four carriers, and keeping them separate is what keeps the grammar legible. Each question has its own mechanism:
| the question | answered by | example |
|---|---|---|
| what KIND is this? | a tag | :workflow: :component: :toolkit: |
| what STATE is it in? | a TODO keyword | TODO DOING DONE |
| what DATA does it hold? | a property drawer | :ID: ffmpeg |
| what does it MEAN? | the body / header args | the prose, the :out→:in edges |
The verdict of that table is a rule of thumb you can carry everywhere: reach for a tag only when you're naming a kind. State is a TODO keyword — a heading moves TODO → DOING → DONE without its tag ever changing. Data is a property in a drawer. Meaning is the body and the header args.
The proof that the system holds this line is a deliberate counter-example.
Workbooks.Workflow.Todo runs a native TODO outline with — in its
own words — "no custom tags, nothing an agent has to learn." A plain outline
with states, nesting, and a couple of :ORDERED:/:BLOCKER:
properties does the whole job. The ecosystem reaches for tags to mark
kind, never for state and never for data — because
it already has the right tool for each.
a tag is not a PERMISSION
One sharp boundary, because it's easy to assume a tag confers power. It
doesn't. A :toolkit: tag makes a heading discoverable —
it does not make it trusted, and it does not make it runnable. The toolkits
module is blunt about this:
A toolkit dropped there is UNTRUSTED supply-chain input. Discovery is one query. Trust is a separate gate.
The discovery root is unauthenticated — anyone can drop a
manifest.org with :toolkit: on it and it will show
up in a query. That's by design: discovery and trust are different concerns.
Execution is gated separately, downstream, by an explicit capability grant —
the same grants-not-inheritance posture the rest of the architecture runs
on. So a tag is a label that makes something findable, never a key
that makes something allowed. Mixing those up is the classic supply-chain
mistake; the written layer keeps them apart on purpose.
where it BITES
Honesty section. The tag model is deliberately small, and the smallness has edges:
- Flat strings, exact match only. No globbing, no case folding.
:Toolkit:and:toolkit:are different tags, and nothing warns you. - No inheritance. Emacs org-mode propagates a parent's tags to its
children; this kernel does not.
has_tagchecks only the heading's own list. Subtrees are found by level-walking the structure, not by a tag flowing downward. Tag the parent:workflow:and the children are not automatically anything — they need their own:component:. - No special org conventions. Org-mode's
:noexport:and:ARCHIVE:semantics are not implemented — grep the runtime and CLI and you get zero hits. A tag named:noexport:is just another inert label here. - Misspelling is silent invisibility. This is the one that bites
hardest. Write
* Nightly digest :workflw:and the compiler doesn't error —build_planignores the heading because it isn't taggedworkflow, andvalidatereports no diagnostics because there's nothing taggedworkflowto validate. The plan doesn't fail; it vanishes. - The composable query language is direction, not shipped. The
(and (tags …))s-expression syntax lives in the docs as the design intent. Today you filter rows.
$ wbx tangle digest.org # heading tagged :workflow:
→ { "worlds": [ { … three tasks … } ] }
$ wbx tangle typo.org # same file, :workflw: typo
→ { "worlds": [] } no error. no diagnostics. just gone.
The lesson in that pair: with tags, the failure mode is never a stack trace. It's an empty result. The tree looks fine, the file parses, and the thing you meant to declare simply isn't there — because the machine read the string you actually wrote, not the one you meant.
questions people actually ASK
Can I invent my own tags?
Yes — freely. Unknown tags are inert labels; the runtime parses and
carries them in every query row, but only the canon five
(:workflow: :component: :toolkit: :agent: :member:) switch
machinery on. Tag a heading :urgent: or :draft:
and it's a label you can filter on yourself with jq — it just
doesn't compile or get discovered. Useful, harmless, yours.
Why a tag and not a property?
Because they answer different questions. A tag is the heading's
kind — cheap to scan, one membership check, and it's what the
runtime branches on. A property is data the heading carries
(:ID:, :MODEL:). You tag a heading
:agent: to say it is an agent; you give it an
:ID: property to say which agent. Kind, then detail.
Do child headings inherit my tag?
No — unlike Emacs org-mode. This kernel's has_tag checks
only the heading's own tags; nothing propagates downward. A
:workflow: parent finds its tasks by walking structure and
reading each child's own :component: tag, not by
inheriting anything. If you want a child to be a component, tag the child.
What's the difference between a tag and a TODO state?
Kind versus state. The tag says what a heading is and rarely
changes; the TODO keyword says where it is in its lifecycle and changes
constantly. A :component: heading stays a component whether
it's TODO, DOING, or DONE. Don't encode state in a tag — that's what the
keyword is for.
My workflow silently does nothing. Why?
First suspect: a misspelled tag. The compiler only opens a world for a
heading tagged exactly :workflow:, and only treats a child as
a task if it's tagged exactly :component:. A typo
(:workflw:, :Component:) produces no error and no
diagnostics — just an empty plan. Run wbx query and read the
tags field on each row; what the kernel sees is what's
actually there.
Is the :easter:egg: in this page's source real?
Yes. View source on this page (and on the org lesson) and you'll find an HTML comment that is valid org — a headline with a multi-tag run. Under this same kernel it parses to a real tag list. The format eats itself: a comment in a web page is structure to the parser that compiles every workbook.
keep GOING
Tags are one carrier of meaning in the written layer — the kind carrier. Its neighbors carry the rest.