format spec

the format,
all of it.

Standard CommonMark, plus a small set of conventions for properties, refs, tags, tasks and runnable code. That's it. The .md you read is the .md outl wrote.

anatomy of a page

one file. two layers.

Every page is one .md + one .outl sidecar. The .md is what humans (and other tools) read. The sidecar holds CRDT IDs and metadata that doesn't belong inline.

pages/launch.md
title:: Launch icon:: 🚀 tags:: launch, q2-2026 - ship 0.1.0 #launch - draft [[release notes]] - post on [[avelino.run]] - code block runs: ```python print(2 + 2) ``` > result: 4
.launch.outl json sidecar
{ "version": 1, "blocks": { "01HF...A1": { "text": "ship 0.1.0", "parent": "ROOT", "props": { "status": "done" } }, "01HF...A2": { "text": "draft [[release notes]]", "parent": "01HF...A1" } }, "tags": ["launch", "q2-2026"] }

Lose the sidecar? outl doctor regenerates it from the op log. Lose the .md? The op log still has every block.

01 · properties

key:: value.

Every property is a plain markdown line: key:: value. Two colons. Space. Value. That's the entire grammar.

Page-level properties sit at the top of the file before any bullet. Block-level properties sit on the line below their block. outl recognizes a handful of reserved keys; everything else is yours.

key scope what it does example
title:: page Display name. Slug is the filename. title:: My Launch Plan
icon:: page Single emoji. Surfaces in header, switcher, backlinks, autocomplete, inline [[refs]]. icon:: 🚀
tags:: page Comma-separated tag list applied to the whole page. tags:: launch, q2-2026
alias:: page Alternate names for the page. [[Alternate Name]] resolves here. alias:: launch, q2 launch
auto-run:: block Code blocks under this property re-run on page open (cache-aware). auto-run:: on
source-hash:: result Written by outl onto > result: subblocks. SHA-256 of source. Don't edit by hand. source-hash:: a91f2c3
priority:: block Custom property. Any key:: value pair works. Used by queries. priority:: p1

Custom keys are first-class. Queries (planned) treat them as columns. Anything key:: value indexes.

02 · references

[[pages]], ((blocks)), #tags.

Three reference primitives. All of them stay in the markdown — nothing is rewritten with internal IDs visible to you.

syntax what it does in context
[[Page Name]] Wiki-link to another page. Resolved via slug. If page doesn't exist, the link is clickable and creates it. [[Launch Plan]] is on track
[[Alternate Name]] Resolved via alias:: on the target page. [[q2 launch]] (alias)
((block-id)) Block reference. Renders the referenced block inline. ID maps to sidecar entry. ((6624a82c))
#tag Inline tag. Indexed and queryable. shipped #launch
#a/b/c Namespaced tag. A page per level, and the parent lists and collects its children. ran on #os/linux/debian
[label](url) Standard markdown link. Untouched. [paper](https://...)

a slash makes a hierarchy

#os/linux/debian parses as one tag, resolves to one page, and keeps its name verbatim — that much was always true. What the slash gets you now is everything a hierarchy implies. The os page lists every descendant below its backlinks, at every level, indented by depth. And a block tagged #os/linux is a backlink of os too, so the parent collects what its children collect.

pages/os.md — nested pages
› nested pages
• os/linux
• os/linux/debian
• os/linux/arch
• os/macos
› mentions of a descendant · showing 50 of 3,221
it comes from the title
A slug is one path component of pages/<slug>.md, so a / in it is folded and os/linux lives at pages/os-linux.md — flat, like every other page. The slash you typed survives in title::, and that is where the tree is read from. No new operation, no new on-disk field, no migration. A graph imported from Roam years ago already answers correctly.
per segment, not per prefix
oscar/wilde starts with os as a string. It is not in the os namespace. Matching compares slugified segments, which is also what makes OS/Linux and os/linux one namespace rather than two — they resolve to the same page, so they had better resolve to the same parent.
exact tags did not move
#os and #os/linux are still different tags, and #projector is still not #project. The parent collects its children's mentions; it does not absorb their identity.
big namespaces are capped
A namespace's mentions have no natural size — one page on the author's own graph collects 3,221 blocks. Folding those into the backlinks list makes an unreadable panel and puts a quarter-megabyte of block text on the wire per page open. Descendant mentions ship as their own collapsed section, capped at 50, with the real total beside it.
03 · tasks

three states,
two spellings.

A task is a word at the front of a block. There used to be two of them, and no way to say a task was underway — which meant everybody invented their own convention with a tag or a property, and none of it reached a status: query or the progress counter. DOING is that third state.

word form checkbox form state meaning
TODO - [ ] TODO on the list, not started
DOING - [/] DOING underway
DONE - [x] DONE finished

The CommonMark checkbox is the second spelling, and a block written that way is a task everywhere a TODO block is one: it draws a checkbox, answers status: queries, counts in the progress chip, and toggles. It used to be inert prose. The toggle chord walks one stop per press — none → TODO → DOING → DONE → none — and the first toggle of a checkbox rewrites it into the word form, where it stays.

The trailing space is what separates a task from prose, which is why DOINGs are piling up is a sentence and not a started task. And the progress counter puts DOING on the total side, never the done side — a started task is unfinished work.

04 · code blocks

fences run.

Standard markdown fenced code blocks. Five languages are recognized as runtime — the rest stay as syntax-highlighted prose. Full walkthrough at /code.

```python
RustPython
```js
Boa (ES2015+)
```lua
mlua 5.4
```rust
rustc → wasm32-wasip1 → wasmtime
```lisp
Steel (Scheme R5RS-ish)
lang-lisp build flag

the result subblock

A blockquote starting with › result: immediately under a code fence is the output. outl owns this subblock — re-running rewrites it in place. Don't edit by hand; your edits get overwritten.

on disk
- the answer ```python print(6 * 7) ``` > result: 42 source-hash:: a91f2c3
05 · what outl deliberately doesn't do

markdown stays
markdown.

  • ✕ No id:: lines. Block IDs never appear in your markdown. They live in the sidecar.
  • ✕ No YAML frontmatter delimiters. No --- separators at the top. Properties are plain key:: value lines you can read in any markdown tool.
  • ✕ No HTML comments smuggling metadata. No <!-- collapsed:: true -->. State lives in the sidecar.
  • ✕ No custom non-CommonMark syntax. If you copy a block out of outl into pandoc, hugo, or your editor's preview, it renders as you'd expect.
The test: delete outl tomorrow. Open every .md in cat. Nothing reads like an artifact.
06 · external edits

edit in vim.
outl catches up.

When you save a .md externally, outl runs a 3-level matching algorithm to figure out which block in your file maps to which ID in the sidecar:

  1. 1.
    Exact text match.

    If a block's text is unchanged, its ID stays. Most edits move blocks around, not text.

  2. 2.
    Structural match.

    Parent + sibling position. If text changed but the block is in the same place, it's the same block — text is just an edit.

  3. 3.
    Fuzzy text similarity.

    Levenshtein distance over candidates. If structure changed too, the most similar surviving block keeps the ID. Ambiguous matches surface in outl reconcile.

Duplicating a block in VS Code gives the duplicate a fresh ID (no collision). Renaming a page updates the slug; title:: stays human. Full algorithm in docs/markdown-format.

one format,
no surprises.

The full reference is open source on github.