# Block Data API

An `html` block in a note can read the workspace it lives in, and change it. That is what makes a dashboard a dashboard rather than a screenshot of one: a table of every film rated 8 or more, a chart of what is still unread, a card that styles itself from its own note — and a form that logs a record or flips a status.

```js
const films = await potion.getNotes({
  filter: { match: 'all', children: [
    { type: 'tags', op: 'has_all', tags: ['movie'] },
    { type: 'property', key: 'rating', op: 'gte', value: 8 },
  ] },
  sort: { by: 'rating', direction: 'desc' },
  fields: ['rating', 'cover'],
  limit: 50,
});
films.total;   // how many match in all
films.hasMore; // whether another page exists
```

This page is also available as plain Markdown at [/docs/block-data-api.md](/docs/block-data-api.md), for pasting into an AI.

::toc

## Why it is shaped like this

A block is **untrusted code**. It is usually written by an AI, but it arrives inside a note body, and note bodies include clipped web pages, extracted file text and anything pasted. Every rule below follows from that.

- **No egress.** The block's frame allows no network at all (`connect-src 'none'`, images only as `data:` URIs). A block that reads the workspace has nowhere to send what it read; it can only show it back to the person who already owns it.
- **A closed set of calls.** A block does not compose a query. It picks one of the calls below and the app runs it. Nothing passes through, so what a block can reach only grows when the app adds a call on purpose.
- **Your workspace only.** No call takes a workspace, a user or a note id. The app adds the workspace the note is in, proves it is yours, and looks up every tag, property and view a block names *inside* that workspace. Another workspace is unreachable, even one you own.
- **Writes ask first.** A block can change data (see [Changing data](#changing-data-actions)), but the first time one tries, the app asks you, once per exact block, in a bar the block cannot reach. A clipped page can never change anything silently.

## The calls

Every call returns a promise. Every call takes an options object, and `{ refresh: true }` in it skips the cache.

| Call | Returns |
|---|---|
| `potion.getNotes({ filter, sort, search, fields, images, limit, offset })` | **array** of rows, with `.total` and `.hasMore` attached (and `.truncated` when bodies were cut) |
| `potion.count({ filter, search })` | **number** |
| `potion.getViewNotes(viewId, { fields, images, limit, offset })` | **array** of rows in the view's own order, `.total` and `.hasMore` attached |
| `potion.getViews()` | **array** of `{ id, name }` |
| `potion.getRecords(structure, { filter, sort, direction, limit, offset })` | `{ structure, fields, records: [{ id, created_at, updated_at, data, by }], total, hasMore }`; `fields` are the structure's definitions (see Records below) |
| `potion.summarizeRecords(structure, { field, period, by, date, filter })` | `{ structure, field, unit, buckets: [{ bucket, count, sum, avg, min, max }] }` — counted and averaged in the database |
| `potion.self({ fields, images })` | **one row, or `null`** |
| `potion.getTags()` | **array** of `{ name, count }` |
| `potion.getProperties()` | **array** of `{ key, label, type }` |
| `potion.getPropertyValues(key, { filter, search })` | **array** of `{ value, count }`, commonest first |
| `potion.openNote(id, section)` | nothing; opens that note in the app, scrolled to the heading `section` (a slug) when given |
| `potion.mapStyle(name)` | a map style for the `maplibre-gl` library whose tiles come through Potion — see [Maps](#maps) |

`window.potion` does not exist where the API is not offered, so always check for it first.

## Filtering

A filter is the same tree a saved view stores, spelled with tag **names** and property **keys**. A view, the filter builder on the web and on iOS, the MCP and a block all ask the same question the same way.

```js
{
  match: 'all',                  // all | any | none
  children: [
    { type: 'tags', op: 'has_all', tags: ['movie'] },
    { type: 'group', match: 'any', children: [
      { type: 'property', key: 'rating', op: 'gte', value: 8 },
      { type: 'property', key: 'rating', op: 'empty' },
    ] },
    { type: 'field', field: 'created_at', op: 'within', value: { dir: 'last', n: 30, unit: 'day' } },
  ],
}
```

A **group** matches when `all`, `any` or `none` of its children match, and groups nest. The conditions are:

| Node | Shape | Ops |
|---|---|---|
| Tags | `{ type: 'tags', op, tags: ['name', …] }` | `has_all`, `has_any`, `has_none` |
| Property | `{ type: 'property', key, op, value }` | by the property's type, below |
| Field | `{ type: 'field', field, op, value }` | `title`, `created_at`, `updated_at`, below |

Ops by property type:

| Type | Ops |
|---|---|
| text, select | `eq`, `neq`, `contains`, `not_contains`, `starts_with`, `ends_with`, `in`, `not_in`, `empty`, `not_empty` |
| number | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `between`, `empty`, `not_empty` |
| date | `on`, `before`, `after`, `on_or_before`, `on_or_after`, `between`, `within`, `empty`, `not_empty` |
| bool | `eq`, `empty`, `not_empty` |
| json | `has_key`, `contains`, `empty`, `not_empty` |
| field `title` | `eq`, `neq`, `contains`, `not_contains`, `starts_with`, `ends_with`, `empty`, `not_empty` |
| field `created_at`, `updated_at` | `on`, `before`, `after`, `on_or_before`, `on_or_after`, `between`, `within` |

Values:

- `in` and `not_in` take a list: `["reading", "queued"]`.
- `between` takes `[low, high]`, numbers or dates.
- A date is `"YYYY-MM-DD"`, or `{ relative: { n: -7, unit: 'day' } }` counted from today. Units are `day`, `week`, `month` and `year`.
- `within` takes `{ dir: 'last' | 'next', n: 30, unit: 'day' }`, today included.
- `has_key` takes a key; json `contains` takes any JSON value.
- `empty` and `not_empty` take no value.

Rules worth knowing:

- **Dates are days.** A date also takes `eq`, `gt`, `gte`, `lt`, `lte` for convenience (on, after, on or after, before, on or before), and "today" is the reader's own day: the app stamps the device's time zone on every filter.
- **Negative ops also match notes with no value.** `status neq "done"` includes notes that have no status at all. A comparison like `gt` never matches a note with no value.
- **`contains`, `starts_with` and `ends_with` ignore case; `eq` does not.**
- **A name that does not exist is not an error.** A dashboard written against last week's tags still renders. The condition simply has the truth value it implies: `has_all` a missing tag matches nothing, `has_none` of it matches everything, and a missing property is empty for every note.
- **An op the property's type does not take is an error**, and the message says which ops it does take.
- A filter holds at most 50 conditions, groups nest at most 4 deep, lists hold at most 100 items and strings at most 500 characters.

## Sorting

```js
sort: { by: 'rating', direction: 'desc' }
```

`by` is `created_at`, `updated_at`, `title` or any property key. `direction` is `asc` or `desc`, and defaults to `desc`.

- One key. Ties always break on creation date, newest first, then id, so paging is stable.
- Notes with no value for the property come **last**, in both directions.
- A select sorts in its **declared option order**, not alphabetically.
- With no sort, notes come most recently updated first. With a search and no sort, they come in order of relevance.
- A json property has no order, and a key the workspace does not define has no values; either falls back to the default order.

## Search

```js
const hits = await potion.getNotes({ search: 'dune', limit: 10 });
```

The same ranked search the app uses: titles first, then tags, then bodies. `"quoted phrases"` match exactly and `-word` excludes. A search combines with a filter and a sort, and returns at most **100** matches in all, so its `total` never exceeds 100.

## Paging

A call answers **one page**, never the whole workspace: 25 rows unless you ask for more, 200 at most (50 when you ask for `contents` or `headings`). Ask for the next page with `offset`.

```js
let offset = 0;
const page = await potion.getNotes({ filter, sort, limit: 100, offset });
page.total;    // 10,000
page.hasMore;  // true: ask again with offset + page.length
```

- `total` is exact however large the workspace is. `hasMore` is true while `offset + page.length < total`.
- `offset` goes up to 100,000.
- A block may ask **40 questions every 10 seconds**. That stops a runaway loop, and still lets someone press "next page" as long as they like. Past it, the call throws.
- To draw a number, count, don't page. `count` and `getPropertyValues` answer in one query at any size; pulling ten thousand rows into a frame to count them is slow to fetch, slow to inject and slow to draw.

## The row

`id`, `title`, `updated_at` and `href` are **always** present. Everything else appears only because you named it in `fields`.

`href` opens that note in the app: put it on an `<a>` and a table of notes is a table of doorways.

| Field | What you get |
|---|---|
| any property key | its value, typed: a number property arrives as a number |
| `tags` | the tag names, as a list |
| `contents` | the raw markdown body |
| `cover` | the cover image as a `data:` URI, ready for `<img src>` |
| `alias` | the short name a `[[wiki link]]` can use |
| `created` | `created_at` |
| `pinned` | whether it sits in Quick Access |
| `files` | attachments, as `{ id, name }` |
| `links` | the notes this note links to, as `{ id, title, href }` — `[[links]]` and `?note=` links, resolved, once each |
| `backlinks` | the notes that link to this one, same shape |
| `headings` | the note's table of contents, as `{ depth, text, slug }` — the list `::toc` renders |
| `*` | every property the workspace defines, plus tags |

`images: ['poster']` is not a field. It names property keys that hold an image URL, and the app inlines those as `data:` URIs the way it inlines a cover, because the frame cannot load a remote image.

These names shadow a property of the same name: a property called `cover` is unreachable through `fields`. Ask for what you draw and nothing more; every field is bytes across the bridge.

## The note the block lives in

`potion.self()` is the one identity call, and the note comes from the app, never from the block. It lets one layout render differently on every note, from that note's own properties.

```js
const me = await potion.self({ fields: ['*'] });
if (!me) return;                                  // deleted while on screen
const accent = (me.css_accent || '').trim() || '#6272a4';
```

Any property can be absent, empty or deleted from the workspace, so default everything. `count` does not exclude the block's own note; when a dashboard carries the tag it summarises, subtract itself.

## Counting and tallies

```js
const unread = await potion.count({ filter: { match: 'all', children: [
  { type: 'property', key: 'status', op: 'eq', value: 'unread' },
] } });

const byStatus = await potion.getPropertyValues('status', { filter: movies });
// → [{ value: 'done', count: 40 }, { value: 'unread', count: 12 }]
```

`getPropertyValues` answers with a **list**, not a keyed map, commonest first and at most 200 values. Indexing it by value gives `undefined`, which renders as an empty chart with no error:

```js
const by = Object.fromEntries(byStatus.map(r => [r.value, r.count]));
by.unread; // 12
```

Values arrive as text: a number as `"8"`, a bool as `"true"`, a date as its ISO instant. Give it the same filter as the list beside it and the two always agree.

## Views

```js
const views = await potion.getViews();                      // [{ id, name }]
const w = await potion.getRecords('weight', {            // records: data, not notes
  sort: 'created_at', direction: 'asc', limit: 100 });
const avg = w.records.slice(-10)
  .reduce((n, r) => n + r.data.kg, 0) / 10;                 // average of the last ten
const best = views.find(v => v.name === 'Best reads');
const rows = best ? await potion.getViewNotes(best.id, { fields: ['rating'] }) : [];
```

`getViewNotes` answers in the view's own filter and order, a page at a time, with `total`. A view from another workspace answers nothing.

## The cache

Answers are cached per question, for a few minutes, and cleared when you save a note on this device. A value set from the MCP or another device reaches an open block when its cache expires, or at once with `{ refresh: true }`. Use `refresh` for a deliberate reload button, never on every call, and rule the cache out before diagnosing a number that looks wrong.

## Sharing

On a shared note the API exists, but a reader may ask **only what the author pinned** in the note's own source. Anything else is refused.

```text
<!-- potion:share notes tag=movie property=rating>=8 fields=rating,style sort=rating:desc -->
<!-- potion:share count tag=movie -->
<!-- potion:share values key=status -->
<!-- potion:share views -->
<!-- potion:share records structure=weight sort=created_at:desc -->
<!-- potion:share summary structure=weight field=kg period=week -->
```

- `tag=` repeats or takes a comma list. `property=` is `key` (has a value), `key:value`, or a comparison: `key>=8`, `key<5`, `key!=done`. `fields=`, `sort=key` or `sort=key:asc`, `view=` and `key=` are as in the calls.
- Kinds: `notes`, `count`, `view_notes` (or `view`), `views`, `tags`, `property_defs` (or `properties`), `property_values` (or `values`), `records` and `records_summary` (or `summary`).
- **Records**: `structure=` names it (by id survives a rename), `where=` is one condition of the flat record filter — `where=kg>=80`, `where=done:true`, bare `where=note` for "has a value" — repeatable and ANDed; `sort=field:desc`; for a summary `field=`, `period=`, `by=`, `date=`. Anything else, pin as JSON.
- **A records pin shares every row that matches it, with every field of the structure.** Paging is not part of a pin, so `limit=1` does not keep older rows private, and a record has no `fields` projection. Narrow what is shared with `where=`; pin a `summary` instead of the rows when the reader only needs the totals.
- **A nested filter or a search is pinned as JSON**, exactly as the block sends it:

```text
<!-- potion:share notes {"filter":{"match":"any","children":[{"type":"property","key":"rating","op":"gte","value":8},{"type":"tags","op":"has_all","tags":["favourite"]}]},"fields":["rating"]} -->
```

- The pin must match the question **exactly**: not one more field, not a broader tag, not a different sort. Two things are left out of the match: paging (`limit`, `offset`), so a reader can scroll, and the time zone, so a reader in another country is not refused.
- `self` cannot be pinned. A block that depends on it shows its fallback to a public reader.
- Pins are read from the note on every request, so editing the directive changes what readers may ask, and deleting it closes the door at once.

## Changing data — actions

A block can also **do** what you can do in the app: log a record, change a status, create a note, pin it, file it under a view. These are **actions**: `potion.<action>({ … })`, one object of arguments, a promise of the result.

```js
// A form that logs a reading, then changes this note's status.
await potion.writeRecords({ structure: 'lamp', data: { color: '#3b82f6', brightness: 65, on: true } });
await potion.setNoteProperties({ values: { status: 'done' } });   // on this note
```

**The first write asks you.** A block may have come from a clipped page or an agent, so the first time one tries to change anything the app shows a bar over it: *This block wants to add 1 record to "lamp". Allow it to change your data?* Allow once and that block writes freely from then on. The approval is for its **exact code**: change the code and you are asked again. A layout used by many notes is one block, allowed once. Reads never ask.

- `note` is a note id, or `'self'` for the note the block is in. Where an action takes a note and you leave it out, it means `'self'`.
- **A feed card never writes.** Reads answer there; a write is refused with "Open the note to make changes." Open the note and it works.
- **Plan limits apply** as anywhere else: a Free plan's note and record caps hold for a block too.
- **Everything is reversible.** Deleting a note moves it to the trash, a record delete is soft, and a body edit keeps the old version as a revision.
- A refusal comes back as a rejected promise whose message is the reason: a value that does not fit its property, a required field left out, a name already taken.

| Area | Actions (arguments) |
|---|---|
| Who | `me()` → `{ id, name }` or `null` (see [Who is reading](#who-is-reading)) |
| Workspace | `getWorkspace()` |
| Notes | `createNote({ title, content, tags, alias, cover_url, properties })` · `updateNote({ note, title, content, alias, pinned, cover_url, tags, if_unchanged })` · `appendToNote({ note, text, heading })` · `editNote({ note, old_str, new_str })` · `trashNote({ note })` · `restoreNote({ note })` |
| Note details | `getNoteProperties({ note })` · `setNoteProperties({ note, values })` (null clears) · `addNoteTags({ note, tags })` · `removeNoteTag({ note, tag })` · `getNoteLinks({ note })` · `getRevisions({ note })` · `restoreRevision({ note, revision })` · `getTrash()` · `getAliases()` |
| Records | `getStructures()` · `writeRecords({ structure, data })` or `({ structure, rows: [{ data }] })` — a row that collides on a unique field is refused ("must be unique"); pass `on_conflict: 'update'` to update the row it hits instead (never on a shared page) · `updateRecord({ record, data })` · `deleteRecord({ record })` · `createStructure({ name, fields, … })` · `updateStructure({ structure, fields, … })` |
| Tags | `updateTag({ tag, name, color, is_pinned })` · `deleteTag({ tag })` · `setTagLinks({ tag, linked_tag_ids })` — `tag` is a tag id |
| Properties | `createProperty({ key, type, options, label })` · `updateProperty({ property, … })` · `deleteProperty({ property })` |
| Presets | `getPresets()` · `createPreset({ name, tags, properties, ask })` · `updatePreset({ preset, … })` · `deletePreset({ preset })` · `applyPreset({ preset, note, answers, overwrite })` |
| Views | `createView({ name, filter, sort, display_properties })` · `updateView({ view, … })` · `deleteView({ view })` |
| Graph lenses | `getLenses()` · `createLens({ name, scope, settings })` · `updateLens({ lens, … })` · `deleteLens({ lens })` |
| Quick Access | `getQuickAccess()` · `pinNote({ note, icon, color })` · `updatePin({ note, … })` · `unpinNote({ note })` |
| Files | `getFiles({ note })` · `getFile({ file })` · `readFile({ file, pages })` · `searchFiles({ query, file })` · `renameFile({ file, file_name })` |

The rules behind each action are the app's own: the same ones the [public API](/docs/api) enforces, with the same answers. Deleting or renaming a workspace, API keys, deleting a structure, deleting a note for good, uploading or deleting a file, and layouts are not actions.

### Writes on a shared note

A shared page changes nothing unless the note **pins the write**, in its own source, next to its read pins:

```text
<!-- potion:share write records structure=lamp -->         readers may add rows to "lamp"
<!-- potion:share write properties keys=status,rating -->   readers may set those keys, on this note
<!-- potion:share write append -->                           readers may add to the end of this note
```

A pinned write runs **as you**, the author, with no question to the reader: the pin is your permission. Anything that does not fit a pin exactly is refused (another structure, another key, another note). Pins are read again on every request, so deleting the comment closes it at once. Writes from a shared page are rate-limited per reader and per link.

### Who is reading

`potion.me()` answers who is looking at the block:

- **On your own note:** you, `{ id, name }`.
- **On a shared note:** the reader, **only if they are signed in to Potion and agree**. The first time a block asks, the page asks them once for that note: "Let this note see that you're Sam?". Allow gives `{ id, name }`; not now, or not signed in, gives `null`. The iOS app's shared-note sheet proves who its reader is to the page with a short-lived pass; the page never takes a name from the block.

Once a reader agreed, **every row they write through a pin carries their name**: `by` on each record (`"Sam"`), `null` for the author or an anonymous reader. A name is never something a reader can type into `by`; a block that wants anonymous names too keeps a text field for them, and should show it as unverified.

**Ask when it matters, not on open.** Call `me()` when the reader does something that needs a name (joining a game, signing a list), never as the page loads. Anyone can read a shared note without being asked anything.

Keep the host's controls in a **second, private note**: it is yours, so its blocks run with your full permissions (start a round, delete rows), and nothing a reader can reach does the same.

## Live records — watchRecords

A dashboard on a screen should not need a reload. `potion.watchRecords` asks the same question
`getRecords` asks, answers it now, and answers again every time the structure changes — a row
written, edited or deleted, from any surface: the web, the phone, an agent through the MCP.

```js
const stop = potion.watchRecords('door', {
  sort: 'created_at', direction: 'desc', limit: 1,
  onError: e => { status.textContent = e.message; },
}, answer => {
  const open = answer.records[0]?.data.open;
  lamp.className = open ? 'green' : 'red';
});
// stop() ends it; leaving the note ends it too.
```

- **Records only.** A record is a discrete event, so its structure gives a precise signal. A note is
  rewritten by autosave; a live note would be a firehose.
- The callback receives exactly what `getRecords` returns. An answer that did not change is not sent
  again, and bursts of writes arrive as one answer.
- **The host holds the connection**, not the block: the block still has no network. The server keeps
  one live subscription per structure and pushes the re-run answer, so a hundred screens watching one
  structure cost one subscription.
- `onError` hears a problem or the end of the watch. A refused question (not records, not pinned)
  ends it at once.
- At most **4** live questions per block.
- **In a feed card it answers once** — fresh from the server, not from the feed's cache — and does not
  stay live. A feed of live cards would hold a connection per card nobody is watching; the note itself is
  where the block is live. The same block works in both places without checking which it is in.
- **On a shared note** it answers only a pinned question, and the pin is checked again before every
  push — un-sharing the note or deleting the pin stops a live screen at its next change.
- On iOS it works in your own note view; the phone has no public page.

## In the feed — potion:feed

In its own note a block always runs. A block can also carry a **feed section**: what a feed card shows
in its place. It is optional, it is never rendered in the note itself, and when it is there it wins: the
card shows it and does not run the block.

```js
<template data-potion-feed>
  <div class="card"><h3>Solar system</h3><p>A 3D scene. Open the note to turn it.</p></div>
</template>
```

Write one whenever the block is not something to show small and running: a 3D scene, a film, a game, a
form. Draw it (markup, inline SVG, a `data:` image), since a line of text is the weakest poster.

The same choice can be made explicitly, with one line in the fence:

```text
<!-- potion:feed live -->     runs in the card, as in the note (the default)
<!-- potion:feed poster -->   the card shows the block's poster: static, no script, no query
<!-- potion:feed off -->      the card shows no block
```

The poster is markup the block carries for that purpose, inside a `<template data-potion-feed>`, which a
browser never renders, so the note does not show it. The block's own `<style>` elements come along, so the
poster can use the block's classes. Every script and inline handler is removed from it, and it gets no
`window.potion`. A block with a feed section and no directive is a `poster`; `live` beside a feed section
runs the block anyway, and `off` shows nothing. A `poster` block with no template shows nothing, like `off`.

```js
<!-- potion:feed poster -->
<template data-potion-feed>
  <div class="card"><h3>Quick Draw</h3><p>Open the note to join and play.</p></div>
</template>
```

Give every block that polls, watches, waits for people or is played to `poster` (or `off`). A game, a
live board, a form: none of them should run while someone scrolls past. A layout can declare it, and every
note using that layout follows. Where a block is being worked on (a layout's page, Live), it always runs.

## Maps

A block can draw a street map with the `maplibre-gl` library. It still has no network: `potion.mapStyle(name)`
returns a style whose tiles, fonts and sprites the host fetches for it, from OpenFreeMap through Potion. Pass the
result straight to the map:

```js
const style = await potion.mapStyle('liberty'); // also 'positron', 'bright', 'dark', 'fiord'
const map = new maplibregl.Map({ container: 'map', style, center: [-73.58, 45.51], zoom: 11 });
```

A place is an ordinary note. Give the notes you want on a map two number properties (for example `lat` and
`lng`), read them with `potion.getNotes({ tag: 'restaurant', fields: ['lat', 'lng'] })`, and draw them as a
GeoJSON source. `potion.openNote(id)` opens one from a click. A style URL from the internet never loads, and
tiles have their own allowance (600 every 10 seconds), separate from the query limits below.

## Failing well

```js
(async function () {
  const box = document.getElementById('out');
  if (!window.potion) { box.textContent = 'Open this in Potion to see live data.'; return; }
  try {
    const rows = await potion.getNotes({ filter, fields: ['rating'] });
    if (!rows.length) { box.textContent = 'Nothing matches yet.'; return; }
    /* build the DOM */
  } catch (err) {
    box.textContent = err.message;   // a refused filter, the rate limit, a timeout, an unpinned question
  }
})();
```

- **Ask everything first, then build the page once.** Do all the asking at the top of an async function, in parallel with `Promise.all`.
- **Empty is not an error**, and needs its own sentence. A chart given all-zero values draws nothing and reads as broken.
- **Let each part fail alone.** Do not blank a chart whose numbers are fine because a separate count failed.

## Limits

| Limit | Value |
|---|---|
| Rows per answer | 25 by default, 200 at most |
| Rows when `contents` or `headings` is requested | 50 |
| `offset` | 100,000 |
| Fields per request | 24 |
| Filter | 50 conditions, groups 4 deep, lists of 100, strings of 500 |
| Search | 200 characters; at most 100 matches |
| Questions per block | 40 every 10 seconds |
| Timeout per question | 15 s |
| One image, source / inlined | 12 MB / 800 KB |
| All images in one answer | 8 MB |
| All `contents` in one answer | 1.5 MB; rows past it are dropped whole and `truncated` is set |

## Older blocks

The flat arguments from before filters still work, and mean what they always meant:

```js
potion.getNotes({ tag: 'movie', properties: [{ key: 'rating', op: 'gte', value: 8 }, { key: 'style', value: 'sci-fi' }] });
```

`tag` / `tags` must all be present; a property test with no `op` is equals, with no `value` it means "has a value", and `ne` means "has the property, with another value". They combine with a `filter` when both are given. New blocks should use `filter`.

## Links and sections

A block can read a note's place in the web of links and its outline, and link into sections.

```js
const me = await potion.self({ fields: ['backlinks', 'headings'] });
// me.backlinks → [{ id, title, href }]   me.headings → [{ depth, text, slug }]
```

- **`links` / `backlinks`** follow the same rules as the "Linked from" section: trashed notes are left out, a dangling link is not listed, and a note linked several ways (`[[Foo]]`, `[[Foo#A]]`, a `?note=` href) appears once. `*` does not include them; ask by name.
- **`headings`** are exactly the entries `::toc` shows, with the same slugs, so a block can draw its own table of contents in its own look.
- **Link to a section.** `<a href="#slug">` scrolls the note the block sits in to that heading. `<a href="?note=<uuid>#slug">` opens another note at that heading. `potion.openNote(id, slug)` does the same from script. A slug is lower-case letters, digits and dashes; anything else is ignored.
- On a shared note these fields follow the pin rule like any other field: pin exactly the call you make.

## What it does not reach

- **A write without your say.** Every block asks before its first change, and a shared page writes only what its note pins.
- **Another note by id, for reading.** Reads reach notes by filter, view, search or "me". Actions take a note id (or `'self'`), always inside your workspace.

## Records

Records are structured rows that are **not** notes — a weight on a date, a rating, a logged
idea. They live in their own table, appear in no note surface, and are read with their own
call:

```js
const r = await potion.getRecords('weight', {
  filter: [{ field: 'kg', op: 'gte', value: 80 }],   // FLAT list, ANDed
  sort: 'created_at',                                // or any declared field
  direction: 'asc',
  limit: 100,
  offset: 0,                                          // page with offset; stop when !hasMore
});
// r = { structure, structureId, fields, records: [{ id, created_at, updated_at, data, by }], total, hasMore }
```

The structure is named **by its name or by its id** — the host resolves either inside this
workspace, and a block never reaches another one. A name is easier to write, but it stops
matching when the structure is renamed; an id never changes. Every answer carries `structureId`,
so a block meant to last should use it: `potion.getRecords('c06a45a8-…', …)`. An unknown structure
answers empty (`structureId: null`) rather than throwing, so a renamed structure blanks the block
without taking the page down with it.

Each row carries its `id`. `limit` is at most 200 and `offset` at most 10,000; `hasMore` says
whether the next page has anything.

Its filter is a **flat list of conditions, ANDed** — not the note filter tree, because a record
has no tags and no properties. Each kind of field answers its own ops:

| Field | Ops |
| --- | --- |
| number, date, time | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `not_in`, `is_empty`, `not_empty` |
| text | `eq`, `neq`, `contains`, `in`, `not_in`, `is_empty`, `not_empty` |
| select, note, file, record | `eq`, `neq`, `in`, `not_in`, `is_empty`, `not_empty` |
| bool | `eq`, `neq` (value `true` or `false`), `is_empty`, `not_empty` |
| `created_at`, `updated_at` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte` (and sortable) |
| `id` (the row's own id) | `eq`, `neq`, `in`, `not_in` |
| `*` (every field: a search) | `contains` only: a row matches when any of its values contains the text, case ignored (1–200 characters) |

Negative ops (`neq`, `not_in`) also match rows without the value. `in` / `not_in` take an array
of at most 100 values. A date must be a real day, `YYYY-MM-DD`. On `created_at` and `updated_at` — when the
row was written and last changed, set only by the database — a bare date is the whole UTC day — `eq: '2026-09-15'` is every row that day, `lte` includes its evening — and an
ISO timestamp compares exactly. A condition the field cannot answer, or a value of the wrong
kind, refuses the whole query with the reason; nothing is ever dropped. A field the structure
does not declare matches no rows.

```js
const one = await potion.getRecords('weight', { filter: [{ field: 'id', op: 'eq', value: r.records[0].id }] });
const two = await potion.getRecords('habit', { filter: [{ field: 'habit', op: 'in', value: ['run', 'read'] }] });
```

`fields` describes the structure, one object per field:

```json
{ "key": "kg", "type": "number", "label": "Weight", "unit": "kg", "min": 0, "max": 400,
  "required": true, "hidden": false, "unique": false, "description": "Morning weight" }
```

Types are `text`, `number`, `date`, `select` (with `options`), `boolean`, `time` (an `HH:MM`
string) and `note` (a note id — pass it to `potion.openNote`). Only `key` and `type` are always
present. Show `label` when it is set and fall back to `key`; leave a `hidden` field out of a
table unless asked; print a number with its `unit`; `min` / `max` are the bounds a value was
checked against, handy for a chart's axis.

### Summaries

`summarizeRecords` asks the database for totals instead of shipping every row into the block:

```js
const s = await potion.summarizeRecords('weight', { field: 'kg', period: 'month' });
// s = { structure: 'weight', field: 'kg', unit: 'kg',
//       buckets: [{ bucket: '2026-08-01', count: 21, sum: 1730.4, avg: 82.4, min: 81.2, max: 83.9 }, …] }
const line = s.buckets.map(b => ({ x: b.bucket, y: b.avg }));   // a monthly average line
```

- `field` — a number field; `sum`, `avg`, `min`, `max` are computed from it. Without one (or
  with another type) only `count` is meaningful.
- `period` — `day`, `week`, `month` or `year`; each `bucket` is the period's first day
  (`YYYY-MM-DD`). Rows are dated by `date` (a date field's key) when given, otherwise by
  `created_at` in the device's time zone.
- `by` — a field key: one bucket per value, e.g. `{ by: 'habit' }` counts each habit.
- Use `period` or `by`, not both; with neither, one bucket (`bucket: null`) covers every row.
- `filter` — the same flat list as `getRecords`.
- No matching rows gives `buckets: []`: treat it as zero. `unit` is the field's unit, or `null`.

Several notes can read the same series, and none of them owns it: one logs, one averages, one
compares.

**On a shared note, these calls answer what the author pinned** — a `records` or `summary`
pin, exactly like a notes pin (see *Sharing*). Anything not pinned is refused, so wrap it in
try/catch like any other call and render the error.
