LifeLab · model · AI as blocks, not as chat
Blocks are addressable, taggable, transcludable, editable. A conversation stored in an AI block's metadata is none of those, so it needs its own scroller, its own renderer, and its own escape hatch when a code block wants to read it. Making the output a block deletes the second model. But the fix isn't an “AI lens” — a lens is a live pull, and the thing you like most about your proposal is that the input is frozen at invocation. The right decomposition is three primitives you almost already have: context, prompt, invocation.
nb.context() → blocks[]
Render-tree preorder up to this block, bounded by the nearest enclosing lens or page root. Not a chat history, not a retrieval step — a pure function of where the cursor is. This is the primitive that must be first class; everything else composes out of it.
a block · often ![[prompts/…]]
A prompt is a block, so a reusable prompt is a lens. Transclude prompts/tag-and-summarize above the invocation and the workflow is versioned, diffable, and remixable with no prompt library UI.
block.content = /ai …
Writes 1..N blocks as its own span — one level in, under the invocation. Model can't search and can't edit — it reads what's above it and writes beneath it. Tool-free by construction.
So: AI output is an ordinary span that happens to have been authored by an agent whose input was everything above it. No new table, no new renderer, no second scroller.
lens — live, recomputed, no snapshot
A lens is a question you keep asking. Its result set has no identity — you can't tag it, you can't come back to the version you read on Tuesday.
invocation — one moment, frozen, stored
An invocation is an answer you keep. It has an id, a time, tags, and a place in the outline — which is exactly what makes it remixable later.
Keeping them separate buys you the composition you want: the lens assembles the context, the invocation freezes it. Collapse them and you lose the temporality that made the idea good in the first place.
invocation inside a lens · scope stops at the lens boundary
Three rules make this unambiguous, and all three are cheap: (1) scope root = nearest enclosing lens, else page root. (2) order = render-tree preorder, so nested lenses contribute their resolved blocks, in the order you see them. (3) a transcluded block that itself contains the invocation contributes its own subtree, not its home page.
why the lens bound is load-bearing
It's not purity — it's the cost control. Page-scoped context on a long day page sends everything you wrote since breakfast, every time. The lens bound is how a user says “this much, no more,” using a mechanism they already understand.
It's also the legibility win. You said you like knowing exactly what went in. That's only true if the boundary is visible — and a lens boundary is drawn on screen. Highlight the scope while the invocation block is focused and the context becomes something you can see, not something you trust.
And it makes the workspace flow from your proposal the default rather than a discipline: make a lens, collect context in it, invoke inside it.
Siblings were the lazy default and they're wrong. Indent the output one level under the invocation and the answer becomes a span — the collapsed unit your model already has — which means the invocation block is the handle for everything it produced.
And it stays reversible in the direction that matters: when an answer turns out to be a real note rather than a byproduct, promote-in-place lifts it to your level. Nesting is the cheap default, not a cage.
source stops being a property. Delete the invocation and its output goes with it — lifecycle for free, no orphan sweep.The one real cost is depth. Invoke inside a lens inside a page and the answer sits three levels in. Worth it: the alternative is answers interleaved with your own thinking at the same rank, which is precisely the flat mush that made the chat panel feel separate in the first place.
Flat Blocks already settled this: tags are stored per block, never copied down; inheritance is a read-time view option — the tag view's “include sub-blocks” expands each hit to span(hit). Nest the output and that rule does the work by itself.
you tag the invocation · the answer is inside the span
Query #lifelab/ai with include-sub-blocks and the answer comes back with its prompt attached — which is the unit you actually want to re-read. Query without it and you get the invocations alone: a clean index of every question you've asked.
what the model may still emit
Suggestions, namespaced, and only when asked. #ai/proposed-tag/… renders differently and never joins the real vocabulary until you accept it — same review path as the edit proposals. The default invocation writes no tags at all.
This is a better answer than the one I gave in the ledger. Namespacing model tags contains the mess; inheriting yours means there's no mess to contain — the vocabulary stays exactly as large as the set of tags a human typed, which was the property that made tags trustworthy.
It also gives the model less to be wrong about. Content is the hard part; taxonomy is where it confidently invents #writing next to your #prose.
what the invocation block carries
inputs: [a91f, b204, c7e1, c7e2] ← resolved context
model: claude-… at: 2026-08-16T09:14
run: 2
Three fields on one block, not on every generated block — nesting absorbed source. Reproducibility and “what did it actually read” survive; the payload doesn't.
and then the chat comes back for free
![[#lifelab/ai]] + include sub-blocks
The conversation becomes a lens, not a record — and with spans it's literally prompt-then-answer, in order, which is what a transcript was for. Same machinery as every other view, rendered in the one scroller you already have.
re-running · versions instead of overwrite
Because context is dynamic, a re-run is a different question, and pretending otherwise is how you lose work. Append with a run number and fold the older ones by default: the block list is the history, which is the same trick as the provenance lens.
failure is content, not an exception
Malformed output keeps its raw text in a block tagged #ai/failed rather than rolling back. You can read it, fix the prompt above it, and re-run — and validation stays cheap: output must lex as blocks, tags must lex as tags. Nothing more.
Inline-AI vs title-AI is two invocation models to learn, two scope rules to explain, and two ways for the model to touch your notes. The invariant is worth more: AI only ever appends. An edit is then just an appended block whose content is a patch against a target, rendered as a suggestion — and accepting it is a normal block operation you already have.
One rule covers content edits, metadata writes, tag additions and merges: the proposal is a block, so it's reviewable, taggable and ignorable like anything else. Never-accepted proposals are a lens (#ai/proposed) — a to-review tray, gone when empty.
and keep search — just move it
Removing tools was the right instinct applied one step too far. compose_lens is the stronger half of your proposal: an agent that emits lens syntax instead of results is auditable, editable, and cheap to re-run. A bad query is a text fix, not a re-prompt. Generation stays tool-free; retrieval becomes a thing you can read.
Two commands, and a third that chains them. /lens <intent> writes a lens block above you; /ai <intent> reads the context and writes blocks below you; /ask does both when you don't want to think. Only the third is an agent, and it's the one you can decline to use.
| thing | where it lives | reasoning |
|---|---|---|
| nb.context() | first class | The one genuine primitive. Only the renderer knows the render tree, the enclosing lens, and preorder. Userland can't reconstruct it, and every other piece is trivial once it exists. |
| nb.ai() | first class | Streaming, keys in Keychain, cancellation, cost accounting. Thin: takes blocks and a prompt, yields text. No tools, no conversation state. |
| block create + provenance | first class | Writes go through the same path as typing, one level under the invocation. Provenance is three properties on the invocation row — no schema. |
| /ai, /lens, /ask | userland | Shipped as default-but-editable scripts. This is the demo: the AI feature is 40 lines of Lua the user can read, fork, and point at their own prompts. |
| prompt library | userland | A page of blocks. Reuse is transclusion. Zero UI. |
| chat view | userland | A saved lens over source. Build it if you miss it; you probably won't. |
| tag namespace | solved by nesting | Was the risk; isn't anymore. The invocation carries your tags, the answer sits in its span, and read-time inheritance does the rest — so the vocabulary stays exactly as large as the set of tags a human typed. Model-suggested tags, if you want them at all, land under #ai/proposed-tag/… and go through review. |
| context budget | decide now | Show the resolved block count and token estimate on the invocation block before it runs. Cheap, and it's what makes the lens bound feel like a control rather than a limit. |
Net: one new read API, one new write path, three properties, no tables. Same argument as the one-outline reading — it's reversible. If block-first AI feels wrong in your hands, you delete a slash command, not a migration. And the thing you'd be deleting is small precisely because the model does less: it reads what's above it and writes one level beneath it.