Notebooks
A Markdown document in your workspace is a notebook: ordinary prose with embedded blocks that render their live output inline. It’s the narrative counterpart to a frame’s page — use it for runbooks, incident write-ups, and annotated dashboards where explanation and data belong together.
Embedding a block
Add a fenced code block with the block language and a single @block/*
definition (the same shape you’d author on a frame’s page):
# Checkout health
Total requests:
```block{ "@block/stat": { "title": "Requests", "value": { "@expr/query": { "from": "requests", "select": { "value": "count()" } } } }}```The block’s visual output is injected directly after the fence. A document can hold any number of blocks interleaved with prose.
Rendering against the nearest frame
A block renders in the context of the nearest frame — the deepest frame in
the document’s route chain. So a notebook at
services/{ServiceName}/notes.md resolves its blocks against the
services/{ServiceName} frame: their @expr/query, view columns, and filters
all resolve there, exactly as on that frame’s page. A notebook with no
surrounding frame still renders; blocks that need a frame’s data surface their
own error.
Pinning a frame and a time window
A block fence can carry metadata after the language word, as key="value"
attributes — handy for binding a block to a specific frame or a fixed window
regardless of where the notebook lives:
```block title="Checkout requests" frame="services/checkout" from="now-1h" to="now"{ "@block/stat": { "value": { "@expr/query": { "from": "requests", "select": { "value": "count()" } } } }}```framebinds the block to that frame instead of the nearest one, so it renders against the frame’s view and scope. It is the frame’s concrete instance id — exactly what the address bar shows after@frames/, including any percent-escapes (services/checkout/operations/oteldemo.CheckoutService%2FPlaceOrder), not a{Param}pattern. Copy it from the URL, or pick the frame from the cell header.from/topin a time window — ISO timestamps (2026-06-01T00:00:00Z) or date math (now-1h,now) — independent of any surrounding range.fromalone pins a window;todefaults tonow.titlesets the label shown in the cell header in place of the block type.
All four are optional. The fence body stays just the inner @block/* definition;
the frame and window are applied around it.
Embedding a SQL query
For an ad-hoc query, a sql fence is shorthand for a @block/table over the
query — the fence body is the SQL, and the result renders as a table directly
below it:
Slowest endpoints:
```sqlSELECT SpanName, quantile(0.99)(Duration) AS p99FROM spansGROUP BY SpanNameORDER BY p99 DESC```A sql fence takes the same frame, from, to, and title attributes as a
block fence, with the same meaning — so the query resolves against the bound
frame’s tables and pinned window:
```sql frame="services/checkout" from="now-1h" to="now" title="Slowest endpoints"SELECT SpanName, quantile(0.99)(Duration) AS p99 FROM spans GROUP BY SpanName```This is equivalent to a block fence wrapping
{ "@block/table": { "from": { "query": "…" }, "height": "auto" } } — the tagged
{ query } from lets it page through a large result in the database rather
than fetching every row. Reach for the full block form when you need table
columns, sorting, row clicks, or any block other than a table.
Every table in a cell — sql or block fence — sizes to the rows it returns:
a cell’s result sits in the document’s flow, so a table never reserves a full
page’s height the way it does in a dashboard slot (content_height is ignored
here).
How a notebook renders
When you open a notebook by its route, it renders read-only: prose as Markdown, and each embedded block as a collapsible source above its live output. Blocks render through the same engine as a frame’s page, so their output updates as the underlying data changes.
Cells load as you reach them: a cell runs nothing — not its query, not its
frame= binding — until it scrolls into view, so opening a long notebook costs
only the cells on screen. Once a cell has run it stays loaded, so scrolling back
past it doesn’t re-query. Prose is unaffected and always renders. A render with
no viewport to scroll (noemata run, noemata validate --online) runs every
cell up front instead.
Inspect mode (the toolbar toggle, or ⌘I) works here as on a frame’s page:
hover a block to highlight it, click for its actions. A cell currently offers
Copy screenshot — the other actions need a block’s authored source location,
which a cell does not yet resolve.
Where notebooks live
A notebook under @frames/ is a versioned workspace file — author, validate, and
commit it like any frame. You can also keep scratch notebooks in the app’s side
panel; those are stored under .local/notebooks/ and are never
version-controlled (the .local convention). Each scratch notebook is its own
folder named with a human-readable slug, holding an index.md — the folder
leaves room for sibling files (attachments, exports) to live alongside the
document. The side panel gives each an editable title, sorted
most-recently-edited first.
A scratch notebook has no surrounding frame, so its query cells run against a
standalone runtime: raw SQL over real tables — and inline @expr/query over
them — works, without a frame’s view columns, scalars, filters, or automatic
time-scoping. Pin a frame attribute on a cell to render it against that frame
instead.
A notebook is just Markdown — a versioned .md file under @frames/, or a
.local/notebooks/<slug>/index.md for a scratch one — so you (or a coding agent)
can create and edit one directly in the workspace.