Potion/Docs

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.

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, for pasting into an AI.

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), 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.

CallReturns
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

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.

{
  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:

NodeShapeOps
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:

TypeOps
text, selecteq, neq, contains, not_contains, starts_with, ends_with, in, not_in, empty, not_empty
numbereq, neq, gt, gte, lt, lte, between, empty, not_empty
dateon, before, after, on_or_before, on_or_after, between, within, empty, not_empty
booleq, empty, not_empty
jsonhas_key, contains, empty, not_empty
field titleeq, neq, contains, not_contains, starts_with, ends_with, empty, not_empty
field created_at, updated_aton, 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

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.
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.

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.

FieldWhat you get
any property keyits value, typed: a number property arrives as a number
tagsthe tag names, as a list
contentsthe raw markdown body
coverthe cover image as a data: URI, ready for <img src>
aliasthe short name a [[wiki link]] can use
createdcreated_at
pinnedwhether it sits in Quick Access
filesattachments, as { id, name }
linksthe notes this note links to, as { id, title, href } — [[links]] and ?note= links, resolved, once each
backlinksthe notes that link to this one, same shape
headingsthe 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.

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

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:

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

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.

<!-- 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:
<!-- 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.

// 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.
AreaActions (arguments)
Whome() → { id, name } or null (see Who is reading)
WorkspacegetWorkspace()
NotescreateNote({ 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 detailsgetNoteProperties({ note }) · setNoteProperties({ note, values }) (null clears) · addNoteTags({ note, tags }) · removeNoteTag({ note, tag }) · getNoteLinks({ note }) · getRevisions({ note }) · restoreRevision({ note, revision }) · getTrash() · getAliases()
RecordsgetStructures() · 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, … })
TagsupdateTag({ tag, name, color, is_pinned }) · deleteTag({ tag }) · setTagLinks({ tag, linked_tag_ids }) — tag is a tag id
PropertiescreateProperty({ key, type, options, label }) · updateProperty({ property, … }) · deleteProperty({ property })
PresetsgetPresets() · createPreset({ name, tags, properties, ask }) · updatePreset({ preset, … }) · deletePreset({ preset }) · applyPreset({ preset, note, answers, overwrite })
ViewscreateView({ name, filter, sort, display_properties }) · updateView({ view, … }) · deleteView({ view })
Graph lensesgetLenses() · createLens({ name, scope, settings }) · updateLens({ lens, … }) · deleteLens({ lens })
Quick AccessgetQuickAccess() · pinNote({ note, icon, color }) · updatePin({ note, … }) · unpinNote({ note })
FilesgetFiles({ 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 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:

<!-- 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.

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.

<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:

<!-- 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.

<!-- 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:

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

(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

LimitValue
Rows per answer25 by default, 200 at most
Rows when contents or headings is requested50
offset100,000
Fields per request24
Filter50 conditions, groups 4 deep, lists of 100, strings of 500
Search200 characters; at most 100 matches
Questions per block40 every 10 seconds
Timeout per question15 s
One image, source / inlined12 MB / 800 KB
All images in one answer8 MB
All contents in one answer1.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:

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.

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

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:

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:

FieldOps
number, date, timeeq, neq, gt, gte, lt, lte, in, not_in, is_empty, not_empty
texteq, neq, contains, in, not_in, is_empty, not_empty
select, note, file, recordeq, neq, in, not_in, is_empty, not_empty
booleq, neq (value true or false), is_empty, not_empty
created_at, updated_ateq, 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.

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:

{ "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:

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.