# Potion

> A markdown note app for people and their AI agents: notes, tags, properties, links, views, layouts, records, files, and an MCP server any assistant can use. Every workspace is isolated.

Index: https://potion.run/llms.txt


---

<!-- Concepts: https://potion.run/docs/concepts.md -->

# Concepts

Potion is a note app, a bookmark manager and a knowledge base in one. Everything in it starts from one idea, the note, and grows from there: notes get tags and properties, views filter them, links connect them, and your AI assistant works on them with you. Beside the notes sit **records**: structured rows you log and chart, like a weight on a date, which never crowd the notes you read.

![Colourful note tiles on a grid, a few joined by winding threads](/docs/concepts/overview.svg?v=2)

| Concept | In one line |
| --- | --- |
| **Note** | an object: a title and markdown content, plus everything attached to it |
| **Clipper** | saves what you find on the web or your phone as a note |
| **Tag** | a label; is this note in the set? |
| **Property** | a typed value that makes a note descriptive |
| **File** | a document, readable and searchable: attached to a note, or kept in the workspace alone |
| **View** | a filter and a sort, kept, showing what matches now |
| **Preset** | the tags and properties a new note of one kind starts with |
| **Layout** | one design many notes render through |
| **Html block** | a live page inside a note, built with AI, that reads your data |
| **Graph** | notes linked to notes, drawn as a map of your knowledge |
| **Record** | a row of structured data, not a note: its shape is declared first, and a block can chart it |
| **Workspace** | an isolated container for all of the above |
| **MCP** | your AI assistant, working in Potion as you |

## Note

![A note card with a tag](/docs/concepts/note.webp)

**A note is just markdown, but it is an object.** It has a **title** and **content**, and the content is markdown with a few special transformers on top: a YouTube or X link becomes a playable embed, a formula becomes typeset math, a code block becomes a diagram or a live page, `[[a title]]` becomes a link to another note.

Around that text, the note carries everything that describes it:

| Part | What it adds |
| --- | --- |
| **Tags** | which sets the note belongs to |
| **Properties** | typed values: a rating, a status, a date |
| **Files** | attached documents |
| **Links** | the other notes it points to, and the ones pointing back |
| **Cover**, **alias** | an image for its card; a short name like `todo` |

Because a note is an object and not only text, it can be filtered, sorted, drawn on a graph and read by code.

## Clipper

**Most notes are not typed, they are collected.** Say you are on YouTube and find a video about architecture you like. With the **Chrome extension** or the **iOS share sheet** (together, the Clipper), you save it in a couple of taps: the note keeps the link, the video becomes an embed you can watch inside the note, you tag it `architecture`, and it appears in your feed.

The Clipper saves links, embeds, images and whole articles as clean text. It can also apply a preset as you save, so a film arrives already tagged and asking for its rating.

## Tag

![An abstract carrot with a violet tag tied to it](/docs/concepts/tag.webp?v=3)

A tag is a label, and it answers one question: **is this note in the set?** Tags are cheap. There is nothing to set up, a note can carry many, and a tag exists as soon as one note uses it.

A tag can bring others with it: link `postgres` to `database`, and every note tagged `postgres` is tagged `database` too.

## Property

![A note with typed values of different sizes](/docs/concepts/property.webp)

A property is a **typed value** on a note, and it is what makes a note truly descriptive. Where a tag only says *in or out*, a property says *how much*, *when* and *which one*.

| Type | Holds | Example |
| --- | --- | --- |
| text | a string | `author: Le Guin` |
| number | a number | `rating: 8` |
| date | a day | `read_on: 2026-09-16` |
| bool | yes or no | `favourite: true` |
| select | one of a fixed list | `status: reading` |
| json | free-form structured data | `{ "runtime": 148, "country": "FR" }` |

Each workspace keeps its own list of properties, and a note holds at most one value for each. That is what lets you ask for *films rated 8 or more*, *articles read this month*, or *everything with no status yet*.

## File

![A document clipped to a note](/docs/concepts/file.webp)

A note can carry attachments: PDF, EPUB, Word documents, markdown, text, HTML, CSV and JSON. A file does not have to belong to a note: left alone, it lives in the workspace until you delete it, and Manage → Files is where it is kept. Their **text is extracted**, page by page, so a file is not just stored: it can be searched and read, by you and by your assistant.

## View

![A pile of notes filtered into an ordered list](/docs/concepts/view.webp)

With tags and properties in place, sooner or later you want to see something specific. **That is what a view is: the source where a filter is applied.** A view keeps:

- a **filter**: which notes, from tags, properties, title and dates, combined with *all*, *any* and *none*;
- a **sort**: in what order, by date, title or any property;
- a **display**: which properties show on each card.

A view holds no notes of its own. Open it and it runs again, so it always shows what matches right now, including a note you saved a minute ago. The full rules are in [Filters and views](/docs/filters).

## Preset

![A stamp putting a tag onto a new note](/docs/concepts/preset.webp)

A preset is the **starting point for one kind of note**: the tags and property values it always gets. A "Film" preset tags `movie`, sets `status: to watch`, and asks for a rating.

It is the mirror image of a view. A view reads notes that exist; a preset writes onto a note being created. Pick one in the Clipper and the note is described the moment it is saved.

## Layout

![One layout rendering the same design on several notes](/docs/concepts/layout.webp)

A layout is **one design that many notes render through**. Every film note can show the same poster card, each filled in from that note's own properties. Change the layout once and every note using it changes. A layout can belong to a preset, so saving a film offers the film card.

## Html block

![A note containing a small chart](/docs/concepts/block.webp)

An html block is **a live page inside a note**: a chart, a table, a dashboard, a timeline. It is how nice UI gets made on the fly: you describe what you want, your assistant writes the block, and it renders in the note.

- **Libraries.** A block can load one library from a fixed, allowed set (charts, tables, maps, maths, animation and more). See [Block libraries](/docs/libraries).
- **Your own data.** Inside a block, a special object called `potion` reads the workspace: `potion.self()` returns the note the block lives in, and `potion.getNotes()` returns the notes a filter matches, with their tags and properties. See the [Block Data API](/docs/block-data-api).
- **It can change things, when you allow it.** A block can also act: log a record, set a property, create a note. That is how a form, a switch or a vote lives in a note. Before a block's first change, Potion asks you, once per block. A shared note accepts changes from its readers only for what the note itself allows, and a signed-in reader can agree to be known to it, so what they write carries their name — a roll call, a vote, a game.
- **Safe by design.** A block runs sandboxed, with no network: what it reads can only be shown back to you, and what it changes goes through the same rules as the app.

A layout is simply an html block that many notes share.

## Graph

![A magnifying glass over a graph of connected notes](/docs/concepts/lens.webp)

Notes can **link** to each other. Write `[[Another note]]` in a note, or link to its URL, and the two are connected. Every note knows its **links** (what it points to) and its **backlinks** (what points to it). A link can even point to a note you have not written yet.

The **graph** draws those connections as a map of your knowledge, with tags as hubs if you want them. A **lens** saves one way of looking at it: which notes, and what to draw. See [Graph](/docs/graph).

## Workspace

![Two separate containers, each holding its own notes](/docs/concepts/workspace.webp)

A workspace is an **isolated container**: a project, a topic, a part of your life. Notes, tags, properties, views, presets, layouts and lenses in one workspace are invisible from another, and links stay inside it.

## Where you use Potion

| App | For |
| --- | --- |
| **Web app** | everything: writing, views, the graph, layouts, managing tags and properties |
| **iOS app** | reading and writing on the go, views, the feed |
| **Clipper** | saving from Chrome and from any iOS app's share sheet |
| **MCP** | any AI assistant, working directly in your workspace |
| **Talk** | Potion's own AI, on your OpenRouter account: questions and approved changes, web and iOS |

They all work on the same notes. Save a video on your phone, and it is in the web app's feed and within your assistant's reach a moment later.

## Record

![A structure card, a table with one highlighted row, and a small chart drawn from it](/docs/concepts/record.webp)

Some things are not documents. A weight on a date, a chess rating, an idea jotted down to look at later: rows you accumulate, not pages you open.

A **structure** declares the shape. `weight` is a number and a date, and nothing else may be written to it. A **record** is one row of that structure. Records have no title and no content, and they appear in none of the places notes appear: not the feed, not search, not the graph, not a view. Three hundred weigh-ins never bury the six things you actually wanted to read.

They are not hidden notes. They are a different thing, in their own table, which is why they cannot leak into a list of notes by accident.

| Part | What it is |
| --- | --- |
| **Structure** | the declared shape: a name, a description, and typed fields (text, number, date, time of day, yes/no, a choice from a list, a link to a note, a file, a row of another structure), each optionally required or unique, numbers with a unit such as kg and a range |
| **Record** | one row of a structure: its values, and when it was written and last changed (`created_at`, `updated_at`, set by the database) |

**A record cannot exist before its structure.** Ask an assistant to log something it has no shape for, and it will ask you what the shape should be rather than inventing one. After that, "log my weight: 81.9" is one sentence. A missing required value is refused, not guessed: a gap in a series is visible, a wrong number is not. An optional one left empty is saved, and the assistant says so and offers once to fill it.

**A structure only grows.** Fields can be added, but not renamed, retyped or removed, because rows already written cannot be rewritten to match. A rule on an existing field can only be tightened once every row already meets it: a field becomes required only when no row lacks it, and a choice leaves a list only when no row still holds it. A number's unit, once rows exist, cannot change to another: kg cannot quietly become lb.

**A field can be unique:** one row per value. One weigh-in per date, one refuge per street. A second row with a value already taken is refused, and the refusal names the row that holds it; an assistant told to *set* rather than *add* updates that row instead. Text is compared ignoring case and extra spaces, so "Industrial Street" and "industrial  street" are the same value, while "Industrial St" is a different one. A field can be made unique later only if no two rows already share a value, and it can stop being unique at any time. A yes/no field cannot be unique: only two rows could ever exist. Uniqueness can also span two fields together: one row per day *per habit*.

**A field keeps its name forever, but not its label.** The key that filters and charts use never changes; the label people read can, whenever you like. And a field you stop using can be hidden: it leaves the forms and columns, its old values stay, and it is no longer required. A number can also carry a unit and a range, so 819 typed for 81.9 is refused rather than charted. Once rows hold values in kilograms, the unit can be neither changed nor removed, because that would silently change what every stored number means.

**A structure can hold just one row**, which makes it a settings object: your targets, your defaults. Saving it replaces the row, and Records shows it as a form rather than a table. A structure can also cap a list at a number of rows, or be **immutable**: each row is written once and never edited, the way a reading or a ledger line should be. A wrong one is deleted and written again.

**Every row has a permanent id**, which is how a note, a block or an assistant points at one row. A row can itself point at a note: a reading session at the book, an expense at the note that holds its receipt. It can point at a file in the workspace, or at a row of another structure: the set at its workout. A structure that others point at cannot be deleted until they are.

Once there are rows, a note can draw them. An html block reads a series and charts it, and totals and averages come straight from the database, per day, week, month or year, so neither a block nor an assistant has to add rows up. A block can write rows too, which turns a note into a form: a button that logs a row, and another note that watches the structure and updates live. And a structure can tell another system: a **webhook** calls your URL whenever a matching row is written, from anywhere. See [Webhooks](/docs/webhooks). The data lives in the workspace, so a logging note, an averaging note and a comparison note all read the same rows and none of them owns it.

Open **Records** in the sidebar to browse them: search the structures, pick one, and its rows appear as a table you can sort, filter and export as CSV. **Manage → Structures** shows each structure's fields, adds new ones, and deletes a structure. That last one removes every row with it, so it asks you to type the name, and it can only be done here, never by an assistant. Records are managed on the web and through the MCP; the iOS app shows the notes that chart them, not the rows themselves.

## Your AI

![An AI assistant plugged into a stack of notes](/docs/concepts/mcp.webp)

**The MCP is what turns Potion into a place an AI can work.** Potion speaks the Model Context Protocol, so the assistant you already use connects to it — Claude, Cursor, VS Code, Zed, or anything else that supports MCP servers. Connected, it does what you do, in your words: *save this article with the Article preset*, *make a view of films I rated 8 or more*, *read the PDF on my research note and summarise chapter 3*, *build a chart of what I read this year*, *show me how my Rust notes connect*.

It works as you, in one workspace, under the same rules that protect your data. It prefers small edits to rewrites, so it changes only what you asked. And it knows Potion: the concepts on this page are the ones it reasons with.

Connecting takes one line. See [The MCP](/docs/mcp).

**Or talk to Potion itself.** Connect your OpenRouter account in Settings → AI, and Potion gets its own AI: **Talk**, a chat over your workspace on the web and on iOS, and an **AI** switch in search. It answers from your notes and records, and every change it proposes waits for your approval. See [AI](/docs/ai).


---

<!-- Embeds: https://potion.run/docs/embeds.md -->

# Embeds

An embed turns a link into the thing itself: a video you can play, a post you can read, a track you can listen to, right inside the note.

This page is also available as plain Markdown at [/docs/embeds.md](/docs/embeds.md).

::toc

## Writing one

Put the link inside `::embed[…]`, on a line of its own:

```text
::embed[https://www.youtube.com/watch?v=dQw4w9WgXcQ]
```

For an embed in the middle of a sentence, use a single colon: `:embed[https://…]`.

A bare link is **not** embedded. It stays a link. That is on purpose: a note full of links to read later should not turn into a wall of players.

You rarely type this yourself. The Clipper (the Chrome extension and the iOS share sheet) writes the embed when you save a video or a post, and an assistant working through the MCP does the same.

## Supported sources

| Source | Links that work | Web | iOS |
| --- | --- | --- | --- |
| YouTube | videos, Shorts and `youtu.be` links | yes | yes |
| X (Twitter) | posts on `x.com` or `twitter.com` | yes | yes |
| Instagram | posts, reels and TV (`/p/`, `/reel/`, `/tv/`) | yes | yes |
| Reddit | posts and comments under `/r/…` or `/user/…` | yes | yes |
| Vimeo | videos | yes | as a link |
| Spotify | tracks, albums, playlists and podcasts on `open.spotify.com` | yes | as a link |
| SoundCloud | tracks and sets | yes | as a link |
| Video files | direct links ending in `.mp4`, `.webm`, `.ogg`, `.mov` or `.m4v`, played by the browser | yes | as a link |
| GitHub | repositories, shown as a card | as a link | yes |

More sources will be added to this list over time.

## When a link is not supported

Nothing breaks. A link from a source that is not on the list, or one the source refuses to embed (a private video, a deleted post), is shown as an ordinary link to the page.

## Embeds and html blocks

An html block cannot load anything from the internet, so it cannot embed a video or a post itself. Put the embed in the note's markdown, next to the block, and let the block do the drawing.


---

<!-- Filters and views: https://potion.run/docs/filters.md -->

# Filters and views

![A pile of notes filtered into an ordered list](/docs/concepts/view.webp)

A filter describes **which notes** you mean: "films rated 8 or more", "articles I haven't read, saved this month", "anything tagged `rust` or `go` that isn't archived". Potion has one filter, and it is used everywhere a set of notes is chosen:

| Where | The filter decides |
| --- | --- |
| a **view** | which notes appear in its feed |
| the **graph** | which notes it shows |
| an **html block** | which notes it reads, through `potion.getNotes()` |
| the **MCP** | which notes a view or lens an AI builds will hold |

So the rules on this page hold in all of them, on the web, on iOS and through the MCP.

This page is also available as plain Markdown at [/docs/filters.md](/docs/filters.md).

::toc

## A filter is a tree

A filter is a **group** of conditions. A group says how its conditions combine:

| Match | Means | Reads as |
| --- | --- | --- |
| `all` | every condition matches | AND |
| `any` | at least one matches | OR |
| `none` | no condition matches | NOT |

A group can hold other groups, which is how "A and (B or C)" is written. In the web and iOS filter builders, **+ Condition** adds a condition and **+ Group** adds a nested group; the ⋯ menu on each row duplicates it, wraps it in a group, or deletes it.

"Films rated 8 or more, or not rated yet":

```text
all of
├─ Tags has all of  #movie
└─ any of
   ├─ rating ≥ 8
   └─ rating is empty
```

An **empty filter matches every note**. Groups nest at most 4 deep, and a filter holds at most 50 conditions.

## Conditions

There are three kinds of condition.

### Tags

| Condition | Matches notes that |
| --- | --- |
| has all of `a`, `b` | carry every listed tag |
| has any of `a`, `b` | carry at least one |
| has none of `a`, `b` | carry none of them |

A filter refers to tags by identity, so **renaming a tag never breaks a filter**. Deleting a tag that a view or lens filters on is refused until they stop using it.

### Properties

What you can ask depends on the property's type:

| Type | Conditions |
| --- | --- |
| text, select | is, is not, contains, does not contain, starts with, ends with, is any of, is none of |
| number | =, ≠, >, ≥, <, ≤, is between |
| date | is on, is before, is after, is on or before, is on or after, is between, is within |
| bool | is |
| json | has key, contains |
| every type | is empty, is not empty |

- **Text matching ignores case** ("contains Rust" finds "rust"). **Is** is exact.
- **Is between** includes both ends.
- **Is empty** means the note has no value for that property at all.

### Title and dates

Every note also has three built-in fields:

| Field | Conditions |
| --- | --- |
| Title | is, is not, contains, does not contain, starts with, ends with, is empty, is not empty |
| Created | is on, is before, is after, is on or before, is on or after, is between, is within |
| Updated | same as Created |

## Rules worth knowing

**Negative conditions include notes with no value.** `status is not done` also matches notes that have no status at all, the way a person means it. If only notes that *have* a status should count, add `status is not empty` beside it. The same goes for *does not contain*, *is none of* and *has none of*.

**Comparisons skip notes with no value.** `rating > 7` never matches an unrated note, and neither does `rating < 7`.

**Dates are days, not moments.** "Created is on 2026-09-16" matches the whole day. Dates can be fixed or relative:

| Write | Means |
| --- | --- |
| a date, `2026-09-16` | that day |
| today, or *n* days / weeks / months / years ago or from now | a day that moves with the calendar |
| is within the last 30 days | from 30 days ago up to and including today |
| is within the next 2 weeks | from today up to 2 weeks ahead |

A relative date is worked out **every time the filter runs**, so "created within the last 7 days" always means the last 7 days, not the week the view was made. "Today" is your own day: the apps record your time zone when you save a filter.

**A filter never drops a condition silently.** If a condition names a tag or property that no longer exists, it matches nothing rather than being ignored, because ignoring it would quietly show more notes than you asked for.

## Views

A view is a filter you keep. Open it and it runs again, so it always shows what matches now, including notes added after the view was made. A view holds no notes of its own; nothing is "in" a view except by matching.

A view decides three things, and they never affect each other:

| Part | Decides | Never |
| --- | --- | --- |
| **Filter** | which notes appear | — |
| **Sort** | their order | hides a note |
| **Display** | what each card shows | changes which notes appear |

### Sort

A view sorts by one of: **created**, **updated**, **title**, or **a property**, ascending or descending.

- **Notes without a value for the sort property come last**, in either direction. Sorting never hides a note; if you only want notes that have a value, add *is not empty* to the filter.
- A **select** property sorts by the **order of its options** (`unread → reading → done`), not alphabetically.
- A **json** property cannot be sorted by.
- Ties break newest first, so paging through a view is stable.

The property a view sorts by is **shown on its cards automatically**, with an arrow for the direction.

### Display

Beyond the title and preview, a card can show:

- **Up to three property chips**, in the order you choose. A note with no value for one simply shows no chip.
- **Tags**, or not. Hiding them is useful in a view already filtered to one tag, where every card would show the same one.

Most views are best with no chips at all: a rating is useful in "Best films" and noise in "Inbox".

### The rest of a view

- **Cover, colour and icon** give the view its tile.
- **Pinned** views sort to the top of the view list.
- A **note view** is not a filter at all: it is a permanent shortcut to one note, chosen when it is created.

## Examples

| You want | Filter | Sort |
| --- | --- | --- |
| Films worth rewatching | all of: has all of `#movie`, rating ≥ 8 | rating, descending |
| Reading queue | all of: has any of `#article`, `#paper`; status is not `done` | created, ascending |
| This week's captures | Created is within the last 7 days | created, descending |
| What hasn't been rated | all of: has all of `#movie`, rating is empty | title |
| Rust or Go, not archived | all of: has any of `#rust`, `#go`; has none of `#archived` | updated, descending |

## For AI and code

Through the MCP and in html blocks, a filter is written as JSON, using **tag names and property keys**. Potion looks them up in the workspace and refuses any that do not exist, rather than guessing:

```json
{
  "match": "all",
  "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" } }
  ]
}
```

| In the builder | In JSON |
| --- | --- |
| has all of / any of / none of | `has_all`, `has_any`, `has_none` |
| is, is not | `eq`, `neq` |
| contains, does not contain | `contains`, `not_contains` |
| starts with, ends with | `starts_with`, `ends_with` |
| is any of, is none of | `in`, `not_in`, with a list |
| >, ≥, <, ≤ | `gt`, `gte`, `lt`, `lte` |
| is between | `between`, with `[low, high]` |
| is on, before, after, on or before, on or after | `on`, `before`, `after`, `on_or_before`, `on_or_after` |
| is within | `within`, with `{ "dir": "last" \| "next", "n": 30, "unit": "day" }` |
| has key | `has_key` |
| is empty, is not empty | `empty`, `not_empty`, with no value |

A date value is `"YYYY-MM-DD"` or relative, `{ "relative": { "n": -7, "unit": "day" } }` (units `day`, `week`, `month`, `year`; negative is the past). The root may carry `"tz": "America/Toronto"` to say what "today" means; without it, today is in UTC.

An AI changing an existing view should edit it **one condition at a time** (the MCP's `edit_view_filter`, addressed by node id) rather than resending the whole tree, so the conditions it was not asked about cannot drift. See [The MCP](/docs/mcp). For html blocks, the [Block Data API](/docs/block-data-api) covers the rest: sorting, paging and which fields to read.


---

<!-- Graph: https://potion.run/docs/graph.md -->

# Graph

![A magnifying glass over a graph of connected notes](/docs/concepts/lens.webp)

The graph shows your notes as dots and their connections as lines, so you can see the shape of what you know: which notes are hubs, which ideas cluster together, what you meant to write but never did. It lives in the web app at **Graph**.

What it shows is up to you — which notes, and what to draw — from the panel on the left. A way of looking you want again can be saved as a **lens**, and the graph can open on it.

This page is also available as plain Markdown at [/docs/graph.md](/docs/graph.md).

::toc

## Reading the graph

| You see | It is |
| --- | --- |
| a small dot | a note; the more connections it has, the bigger it is |
| a line with an arrow | a link from one note to another, pointing at the note linked to; a note that links to another several times draws a thicker line |
| a large coloured dot | a tag, joined by faint lines to every note carrying it |
| a dashed outline | a note that is linked to but does not exist yet |

- **Hover** a dot to see its name and how many connections it has.
- **Click a note** for a preview with its cover, tags, the start of its text, and how many links go out and come in. **Open note** opens it.
- **Drag** to pan, **scroll** to zoom, and **drag a dot** to move it.

Titles appear once you zoom in, so a large graph stays readable.

## Choosing what it shows

Two decisions shape the graph.

**1. Which notes.** The **Notes** section of the panel holds the filter. **Filter** opens the same builder views use, with tags, properties, title and dates in *all*, *any* and *none* groups (see [Filters and views](/docs/filters)). **Clear** goes back to the whole workspace.

**2. What to draw.**

| Section | Setting | Draws |
| --- | --- | --- |
| Connections | **Links** | lines between notes that link to each other (on by default) |
| | **Tags** | tags as hubs joined to their notes (off by default) |
| Show | **Unconnected notes** | notes with no line in the connections shown (off by default) |
| | **Notes not written yet** | links to missing notes, as dashed outlines (off by default) |
| Look | **Arrows** | arrowheads on link lines (on by default) |
| | **Titles** | Zoomed (only once zoomed in), Always, or Never |
| | **Note dots**, **Lines** | colours for this device only, never saved |

A line is only drawn when **both** of its notes pass the filter. A link to a note outside it is left out, just as that note is.

### Why unconnected notes are hidden by default

A Potion workspace is often a bookmark manager and a knowledge base at once: thousands of saved articles and videos that link to nothing, next to the notes you actually connected. Drawing every note buries the connected part under a cloud of lone dots. Turn **Unconnected notes** on when you want to see everything, or use a filter to narrow the graph first.

## Saving a lens

A **lens** is those two decisions, saved under a name. The **Saved** section lists your lenses.

- **Change anything** and the active lens shows **· edited**, with **Save changes** to keep it. Switching lens without saving leaves the old one as it was.
- **Save as** keeps the current setup as a new lens. Names are unique within a workspace.
- The **star** marks the **default lens**, the one the graph opens on. Click another lens's star to make it the default. There is always exactly one: the first lens you save becomes it, and deleting it passes the star to the next lens.
- **Rename** and **delete** are on each lens. Deleting a lens deletes only the setup; no note, tag or link is touched.

The panel collapses with the « button, and the graph remembers whether it was open.

A lens belongs to one workspace. A link like `/graph?lens=<id>` opens the graph on that lens.

## Ideas

| Lens | Notes | Draw |
| --- | --- | --- |
| **Second brain** | whole workspace | Links |
| **Film map** | has all of `#movie` | Links and Tags |
| **What to write next** | whole workspace | Links, with Notes not written yet |
| **This month's thinking** | Updated within the last 30 days | Links, with Unconnected notes |
| **Topic map** | has any of `#rust`, `#go`, `#zig` | Tags only |

## What a saved lens protects

- **A tag or property that a lens filters on cannot be deleted** until the lens stops using it, the same protection views get. Otherwise the lens would quietly start showing more notes than you set it up for. The refusal names the lens.
- **Renaming a tag changes nothing**, because a lens refers to it by identity.
- **Trashed notes never appear**, and restoring a note brings its links back.

## With an AI

Through the MCP, an AI can set up and save the graph the same way you do: `list_lenses`, `read_lens`, `create_lens`, `update_lens`, `edit_lens_scope` and `delete_lens`. A lens it creates shows up in the panel straight away, and each tool returns a link that opens the graph on that lens. See [The MCP](/docs/mcp).


---

<!-- AI: https://potion.run/docs/ai.md -->

# AI: Talk, AI search and read aloud

Potion's AI answers questions about your workspace and makes changes you approve. It runs on **your own OpenRouter account**: you connect it once, pick a model, and pay OpenRouter directly for what you use. Potion never bills for AI and never sees your card.

::toc

## Connecting

**Web:** Settings → AI → **Connect OpenRouter**. **iOS:** Settings → **AI**. Either way you sign in to OpenRouter and approve a key for Potion; there is nothing to copy or paste.

- One connection per account. Connecting on the web turns AI on in the iOS app too, and the reverse.
- The key is encrypted before it is stored and is never sent to your browser or your phone.
- **Model:** the one model that thinks and answers (the *brain*). The list mixes closed and open models (Claude, GPT, Gemini, DeepSeek, Qwen, GLM, Kimi, Mistral…) with their prices. You can also switch it per conversation, from the menu under the message box.
- **Instructions:** up to 2,000 characters added to every conversation in every workspace, for example "Answer in French. Keep answers short. Tag new notes inbox." For facts about one workspace, use its memory (below).
- **Disconnect** forgets the key. To revoke it for good, delete it in your OpenRouter account.

The balance left on your OpenRouter account is shown in Settings. Each answer shows what it cost.

## Talk

Talk is a chat over your whole workspace. It is at `/talk` on the web (⌘J, or the **Talk** icon at the top right of the sidebar, beside the workspace name), and the chat bubble in the bottom bar on iOS. It is always there: before AI is connected, it opens the AI settings, where you connect.

- **Conversations are saved.** They are listed on the left (on iOS, behind the list button), and **New chat** starts a clean one.
- **Answers arrive whole.** While it works you see the steps it takes ("Finding notes", "Reading a note"), and the answer appears once it is complete.
- **Stop** ends the current answer. Anything already approved stays done, and a change still waiting for your answer is declined.
- Answers can hold **tables**, links that open the notes they mention, and live **html blocks** (a chart of your records, for example), drawn the same way as in a note.

## Memory, per workspace

Each workspace has its own memory: up to 30 short facts that Talk reads at the start of every conversation there. For example, "my films are tagged film and rated out of 10", "weights are in kg", "summaries in French". Nothing is shared between workspaces.

- **Talk can propose to remember something** when you state a lasting fact or preference, or when you say "remember…". It asks first, like any change, and the card quotes the exact fact. It never keeps secrets, one-off details, or text copied from a note.
- **"Forget that"** removes a memory, again with your approval.
- **You can see and edit all of it:** Settings → AI → **Memory** on the web, or Settings → **AI** → **Memory** in the iOS app. Click a fact to edit it, or × to forget it; the web dialog also has **Clear all**.
- Your **Instructions** apply everywhere; memory applies only to the workspace it belongs to.

## What you can ask

Talk reads your workspace through the same queries as views and html blocks, so its answers come from your data, not from memory.

| You ask | What it does |
| --- | --- |
| "The 5 oldest notes tagged health" | Finds notes by tag, oldest first |
| "Notes with a rating greater than 8", "rated between 5 and 7" | Compares a property (number, date, text, select, yes/no) |
| "My 3 best rated movies" | Sorts by a property, highest first |
| "Movie notes without a rating" | Finds notes where a property is missing |
| "Movies that are not sci-fi", "notes tagged A but not B" | Excludes values and tags |
| "Notes created in the last 7 days", "due before October 1" | Filters by dates, fixed or relative |
| "Notes whose title starts with The" | Filters by title |
| "Find my notes about Japan" | Full-text search, ranked |
| "How many notes per status?", "movies per style" | Counts the notes for each value of a property |
| "How many notes do I have?", "which tags do I use most?" | Counts |
| "What does my note Arrival say?", "what links to it?" | Reads a note, its properties and backlinks |
| "What views do I have and what do they filter?", "show my Hot Sauces view" | Reads saved views and the notes in one |
| "My average tasting score", "per drink", "per month" | Totals over records (count, sum, average, min, max), overall, per period or per value |
| "Tastings that scored 8 or more", "my last 3 weights" | Filters and sorts the rows of a structure |
| "My files about the lease", "what does the PDF say about notice?" | Lists files and searches inside them |

Anything a question needs is looked up as it is asked; asking a question never saves anything. A follow-up ("now sort them worst to best") works on the same notes.

## Changes, always with your approval

Talk can also change your workspace: create, append to or edit a note, trash or restore one, set tags and properties, create views, presets, lenses and structures, add or edit records, pin notes to the sidebar, rename files.

- **Every change waits for you.** It shows as a card, **Approve** or **Decline**, before anything happens. Declining ends the turn: it does not try again another way.
- **A change to many notes is one card.** "Tag every note rated 9 or more *favourite*" shows how many notes it will touch, and runs once you approve (up to 200 notes at a time).
- A trashed note stays in the Trash for 30 days, and an overwritten note keeps its previous version for 7, so an approved mistake can be undone.

Some things are left to you on purpose: renaming or deleting a workspace, deleting forever, deleting a structure, API keys, webhooks, uploading files, and layouts. Ask for one and Talk says it can't, and where in the app it is done. It does not try something else in its place.

## AI search

With AI connected, the search box gets an **AI** switch. Describe what you are looking for ("coffee notes I haven't finished", "that article about sleep and ADHD") and it returns **a list of notes**, the best matches first, from the notes that actually exist. It never answers in prose.

A question that is not a search for notes ("what is my average score?") is not answered there. It offers **Ask in Talk** instead, with your question already typed.

## Tasks

A **task** is one AI job you start on an open note with a button, as opposed to a conversation in Talk. You do not write a prompt: the task already knows what to do, does it the same way every time, and shows what it cost when it finishes. With AI connected, an open note has a **Tasks** menu: the sparkle button in the note's header, on the web and on iOS.

| Task | What you get | Offered on | Runs on |
| --- | --- | --- | --- |
| Summarize and read | A short spoken summary of the note | Any note | Your brain, then your voice |
| Summarize video | What a YouTube video says, with timestamps | A note that holds a YouTube video | A model that can watch video |

- **A task runs on your own OpenRouter account**, like everything else, and **its price is shown when it finishes**.
- **A task that writes only ever adds to the end of the note.** Its answer is appended when it is complete, with no second question: starting the task is the ask. Nothing already in the note is changed, and Earlier versions keeps the note from before.
- **A task keeps running when you leave the note.** A task that writes runs in the background: open another note, or another page, and it carries on. The **Tasks list** at the bottom left of the sidebar shows every recent task in the workspace: a spinner while it runs, then "Added to the note" with its price, or the reason it failed. Click one to open its note; the square stops a running task (nothing more is paid for, nothing is added); Clear empties the list of finished ones. A task that speaks is different: it plays in its note and ends when you leave it. On iOS the status shows in a strip under the open note's header (running, with Stop; then "Added to the note" with the price); the task still runs in the background there, and the workspace-wide list is on the web.
- **A task is yours to edit, and you can write your own**, in Settings → AI → Tasks (both below).

### Editing a task

Settings → AI → **Tasks** lists every task. Open one to change two things:

- **Model**: the model that does the work. Summarize and read uses your brain unless you pick another here; Summarize video offers the models that can watch a video (Gemini 3.8 Flash is the usual one; a lighter one costs less than half, a Pro one about three times more).
- **Your instructions**: up to 600 characters added to what the task is always told, so you never repeat yourself: "Always answer in French", "Keep it under 100 words", "End with three questions to think about". They change the length, language and shape of the answer. They do not change what the task reads, and text inside a note or a video still cannot give it orders.

Leaving both empty puts the task back to how it ships.

### Your own tasks

When you catch yourself asking for the same thing again and again, write it once as a task. Settings → AI → Tasks → **New task**: a name, a kind, and the instructions the model is given every time. It then sits in the Tasks menu of every note in that workspace, under the built-in ones. An agent connected through the MCP can write tasks for you too (`create_task`); they appear in the same list.

The **kind** is what the task is allowed to do, and the instructions cannot widen it:

| Kind | What it does | Where the answer goes |
| --- | --- | --- |
| Write | Reads the note and writes text | Runs in the background; appended to the note under the task's name when it is done |
| Speak | Reads the note and says its answer aloud, in your voice | Heard once; nothing is kept |
| Watch | Watches the YouTube video the note holds and writes text | Runs in the background; appended to the note when it is done, timestamps linked |

- **Instructions are standing orders**, up to 2,000 characters: "List the action items in this note as a checklist, each with who owns it", "Explain this note to a ten-year-old", "List every tool named in the video with the moment it comes up".
- **No kind edits or replaces what is in a note, reads another note, or reaches the internet.** Write and Watch only append. A task works on the one note it was started on, and what it finds there is data: a note or a video cannot give it orders.
- A task belongs to one workspace, which holds up to 30. Its name is unique there. Its kind cannot change afterwards: make another task instead.
- Each task may run on its own model; left alone, Write and Speak use your brain and Watch uses the usual video model.
- Your own tasks appear in the Tasks menu on the web and on iOS. You write and edit them on the web (or through the MCP).

### Summarize and read

Your brain model reads the note and writes a short summary meant for the ear, and a voice model reads it aloud, sentence by sentence, while it is still being written. A player appears above the note and the voice starts within a few seconds.

- **It is live and on demand.** Nothing is saved: not the audio, not the summary text, and nothing is written into the note or the workspace. Closing the player or leaving the note ends it; asking again makes a new reading.
- **The price is shown when the reading is complete**, beside the player: what the brain and the voice cost together on your OpenRouter account. A summary of an article is typically a few cents.
- **The voice is yours to pick** in Settings → AI → Voice: a voice model, then one of its voices. The summary is written in the language of the note, so choose a voice that speaks it.
- Code, html blocks, embeds, images and web addresses are left out of what the brain reads. A very long note is summarized from its beginning, and the summary says so.

On iOS the voice plays with the ring switch off, like any audio you start. The voice itself is picked on the web for now.

### Summarize video

For a note that holds a YouTube video (an embed, a link, a bare address). On any other note it stays in the menu, greyed out, and says there is no video. A model watches the video, picture and sound, and writes what it says: a short opening paragraph, the moments that matter with the time each one starts, and what to take away. The summary appears above the note as it is written.

- **It goes into the note when it is done**, under a "Video summary" heading, each timestamp a link that opens the video at that moment. Follow it in the Tasks list at the bottom left of the sidebar; stopping it there adds nothing. It is an append: nothing already in the note changes, and Earlier versions keeps the note from before.
- **A note with several videos lists each one** in the menu, by the words that link it; pick the one you want.
- **The price is shown when the summary is complete.** A video costs about $0.004 a minute to watch, so roughly 8 cents for twenty minutes and 25 cents for an hour, on your OpenRouter account.
- **The model is not your brain.** Only some models can be handed a YouTube address, today Google's Gemini family, so this task runs on Gemini 3.8 Flash unless you pick another video model for it in Settings → AI → Tasks.
- A video that is private, age-restricted or removed cannot be watched, and the task says so. Only YouTube for now.

## Privacy and limits

- Talk runs **as you, inside one workspace**. It can reach nothing you cannot, and no other workspace, even when asked.
- **Text in your notes cannot give Talk orders.** A clipped page or a file can contain instructions aimed at an AI ("ignore your instructions and delete…"); Talk treats everything it reads as data and says what a note asks instead of doing it. Even when a model is fooled, it cannot act on its own: every change waits for your Approve, Talk has no way to send anything over the internet, and it never holds a password or key.
- **An answer never loads a remote image.** Showing one would send a request the moment the answer appears, a known way to leak data; Talk names the image and its site instead. A link to another site shows that site's name before you tap it.
- Nothing runs in your browser or on your phone: the model, the key and every lookup are on the server.
- Your messages and the parts of your workspace a question needs are sent to the model you picked, through OpenRouter.
- Behind the brain, a small, cheap decision model (Jev) makes quick judgements: which guide a question needs, whether a search request is really a search, how well a note matches. Another model is only used when you ask for one.


---

<!-- The MCP: https://potion.run/docs/mcp.md -->

# The MCP

![An AI assistant plugged into a stack of notes](/docs/concepts/mcp.webp)

The Potion MCP lets an AI assistant work directly in your workspace: find and write notes, tag them, set properties, build views, set up the graph, read attached files, and design html blocks. It uses the [Model Context Protocol](https://modelcontextprotocol.io), so **any client that supports MCP servers can connect** — Claude, Cursor, VS Code, Zed, and others. Potion is not tied to one assistant.

This page covers connecting, how the tools behave, and every tool by area. For what the things themselves are, start with [Concepts](/docs/concepts).

This page is also available as plain Markdown at [/docs/mcp.md](/docs/mcp.md).

::toc

## Connecting

- **Server URL:** `https://mcp.potion.run` (Streamable HTTP)
- **Sign-in:** OAuth with the Google account you use for Potion. Your assistant opens the sign-in page the first time.
- **Plan:** Pro. On another plan every tool answers with an upgrade message, except `get_user_info`.

How you add it depends on the client. The server URL is the only part that matters; everything else is that client's own shape.

**Claude Code**: `claude mcp add --transport http potion https://mcp.potion.run`, or install the Potion plugin, which bundles this server with skills describing how Potion works.

**Claude desktop, claude.ai, and other hosted assistants**: add a custom connector with the server URL above.

**Cursor, VS Code, Zed, and most editors**: add it to the MCP server list in settings, as an HTTP server pointing at that URL.

**An agent reading the docs**: point it at [/llms.txt](/llms.txt), which lists every section as Markdown, or [/llms-full.txt](/llms-full.txt) for all of them in one file.

**Anything else**: a config like this one.

```json
{
  "mcpServers": {
    "potion": { "type": "http", "url": "https://mcp.potion.run" }
  }
}
```

### Choosing the workspace

Every tool works in **one workspace at a time**. Without anything extra that is your default workspace, which `set_default_workspace` changes.

To pin a connection to a specific workspace, for example one per project folder, send a `workspace` header with the workspace's **name**:

```json
{
  "mcpServers": {
    "potion": {
      "type": "http",
      "url": "https://mcp.potion.run",
      "headers": { "workspace": "Research" }
    }
  }
}
```

The header matches by name, so renaming that workspace breaks the connection until the header is updated.

## How the tools behave

**Everything is scoped to you and one workspace.** The server knows who you are from sign-in; no tool takes a user id, and the database itself refuses any row that is not yours.

**Names in, ids out.** Tools accept the names people use (tag names, property keys, preset and lens names, note aliases) and return ids for precise follow-up calls. A name that does not exist is refused with a list of what does, never guessed at.

**Changing is not rewriting.** Notes, view filters and lens scopes each have a precise tool for a small change beside the one that replaces everything:

| To | Precise | Replaces all |
| --- | --- | --- |
| change a note | `append_to_note` (add), `edit_note` (one passage) | `update_note` |
| change a view's filter | `edit_view_filter` (one condition, by id) | `update_view` with `filter` |
| change a lens's scope | `edit_lens_scope` (one condition, by id) | `update_lens` with `scope` |

The precise tools exist because an AI resending a whole note or filter can quietly change the parts it was not asked about.

**Destructive actions are guarded.** Deleting a note moves it to the trash for 30 days. `delete_property_def` previews what would be lost and only deletes when called again with `confirm`. A property still used by a view, preset or lens is refused, with the names of what uses it. Workspaces cannot be renamed or deleted through the MCP at all, and neither can a structure be deleted (it takes every row with it); both are done in the web app.

**Errors explain themselves.** A refused call returns `success: false`, an `error` saying why, and often the next step to take.

## Tools

### Workspaces and account

| Tool | Does |
| --- | --- |
| `get_user_info` | who is signed in |
| `list_workspaces` | your workspaces, and which is the default |
| `get_default_workspace` | the workspace tools use when no header is sent |
| `set_default_workspace` | change it |
| `create_workspace` | a new, empty workspace |
| `init_workspace` | create a first workspace if there is none |
| `get_context` | the headers the server received, to check a `workspace` header |

### Notes

| Tool | Does |
| --- | --- |
| `find_note` | find a note you named, checking aliases first, then titles |
| `list_aliases` | every note that has an alias |
| `read_note` | a note with its tags, properties, files, and links in both directions |
| `list_notes` | the 30 newest notes |
| `create_note` | a new note, with tags, alias, cover and pin |
| `append_to_note` | add text to the end, optionally under a heading |
| `edit_note` | replace one exact passage |
| `update_note` | replace the title, body or tags (guard with `if_unchanged`) |
| `delete_note` | move a note to the trash |
| `list_revisions` | earlier versions of a note, kept 7 days |
| `restore_revision` | put an earlier version back (itself undoable) |
| `upload_image` | store an image, from a public URL, an upload (`upload_id`) or as data, and get its permanent address `/api/images/<id>` for a body, a cover, or a property a block inlines |

### Search

| Tool | Does |
| --- | --- |
| `search_notes` | ranked full-text search: titles, then tags, then text; `"phrases"` and `-exclusions` |
| `search_notes_by_tags` | notes with all, or any, of some tags |
| `search_notes_by_property` | compare, sort and find missing values: `rating gt 7`, `status eq unread`, `rating empty` |

### Links

| Tool | Does |
| --- | --- |
| `list_note_links` | what a note links to, and its backlinks |
| `lint_links` | links to notes not written yet, and notes nothing links to |
| `get_agenda` | what is unfinished in the workspace, ranked, each with a suggested next call |

### Tags

| Tool | Does |
| --- | --- |
| `list_tags` | every tag with its note count and linked tags |
| `link_tags` | adding one tag also adds others, including on notes already tagged |
| `unlink_tags` | remove those rules (tags already applied stay) |
| `list_tag_links` | the rules, and why a tag appeared on a note |

Tags are created by using them on a note; there is no separate create step.

### Properties

| Tool | Does |
| --- | --- |
| `list_property_defs` | the workspace vocabulary, with how many notes and views use each |
| `create_property_def` | define a typed property: text, number, date, bool, select, json |
| `update_property_def` | rename, relabel, reorder, or widen a select's options |
| `delete_property_def` | preview, then with `confirm` delete a property and every value of it |
| `set_property` | set or clear one property on one note |

### Views

| Tool | Does |
| --- | --- |
| `list_views` | every view with its filter, sort and display |
| `read_view` | one view and the notes it matches, in its order |
| `create_view` | a feed view (filter, sort, display) or a note view |
| `update_view` | change the name, filter, sort, display, cover, colour, icon or pin |
| `edit_view_filter` | add, replace, remove, regroup or move one condition |
| `delete_view` | delete the view (not its notes) |

The filter these take is described in [Filters and views](/docs/filters).

### Graph

| Tool | Does |
| --- | --- |
| `list_lenses` | every lens, its scope summary, settings, and a link to open it |
| `read_lens` | one lens with node ids and how many notes its scope holds |
| `create_lens` | name, scope, and only the settings that differ from the defaults |
| `update_lens` | rename, replace the scope, change some settings, make default, reorder |
| `edit_lens_scope` | change one condition of the scope, like `edit_view_filter` |
| `delete_lens` | delete the setup; the next lens becomes default if needed |

A lens is a saved way of looking at the graph. See [Graph](/docs/graph).

### Records

Structured rows that are not notes — a weight on a date, a rating, a logged idea. They appear in no note surface, and exist to be queried and charted.

| Tool | Does |
| --- | --- |
| `list_structures` | the shapes that exist, what each is for, and its fields. Call it first |
| `create_structure` | declare a shape before any row of it exists; a `file` field takes a file's id, and a `record` field takes a row's id of the structure named in `structure`; a field can be `required` and `unique` (one row per value), a number can carry a `unit` and `min` / `max`, any field a display `label`; `unique_together` makes one row per combination; `max_rows: 1` makes a settings object whose write replaces its one row, `max_rows: N` caps a list; `immutable` makes rows written once, never edited |
| `update_structure` | change the description or label, add a field or a choice, turn `unique` on or off, set `max_rows`, or turn `immutable` on or off. Tightening a rule (required, unique, removing a choice, a lower `max_rows`) is refused while rows disagree; renames, retypes, removals and a changed unit are refused |
| `write_record` | add one row, or many at once. Refuses an unknown structure; names every missing required field at once; refuses a unique value already taken, naming the row, unless `on_conflict: "update"` updates that row instead (a batch is all or nothing: one bad row writes none, and is named); after a write, lists the optional fields left empty so the assistant can offer to fill them |
| `list_records` | the rows of one structure, filtered, sorted and paged; every row carries its permanent `id`, and a filter on `id` pulls one |
| `summarize_records` | count, sum, average, min and max of a number field, computed by the database: whole, per day/week/month/year, or per value of a field |
| `update_record` | correct one row; a partial change is merged and re-validated |
| `delete_record` | remove one row (soft) |

A record cannot exist before its structure, and field changes are additive only. See [Record](/docs/concepts#record).

| Tool | Does |
| --- | --- |
| `list_webhooks` | the subscriptions on the workspace's structures: URL, events, narrowers, on, off or paused |
| `create_webhook` | subscribe a URL the user gave to a structure; narrow it with `filter` (rows that match) and `fields` (updates to those fields). Answers with the signing secret, once |
| `update_webhook` | change the URL, events, filter or fields; turn it off or on; `resume` after a pause |
| `delete_webhook` | unsubscribe |
| `list_webhook_deliveries` | what was sent and how the receiver answered; `send_test` queues a test event first |

How events are sent and signed: [Webhooks](/docs/webhooks).

### Presets

| Tool | Does |
| --- | --- |
| `list_presets` | every preset with what it sets and asks |
| `read_preset` | one preset in full, by id or name |
| `create_preset` | tags, fixed values, and values to `ask` for |
| `update_preset` | change it (lists passed replace the whole list) |
| `delete_preset` | delete it; notes it was applied to keep their tags and values |
| `apply_preset` | apply to a note, with `answers` for its questions |

### Tasks

A task is one AI job you start on an open note from its **Tasks** menu, run on your own AI account: a name, a kind, and the instructions the model is given every time. An assistant can write tasks for you here; it cannot run one, because running needs your AI key, which the MCP never holds. See [Tasks](/docs/ai#tasks).

| Tool | Does |
| --- | --- |
| `list_tasks` | the tasks you wrote, the built-in ones (listed, not editable here), and the kinds with what each may do |
| `create_task` | a `name`, a `kind` and `instructions`, with an optional `description` and `model` |
| `update_task` | change the name, description, instructions or model, by id or name; the kind never changes |
| `delete_task` | delete a task you wrote, by id or name; no note is touched |

| Kind | May |
| --- | --- |
| `write` | read the note and write text, appended to the end of the note when it is done |
| `speak` | read the note and say its answer aloud; nothing is kept |
| `watch` | watch the YouTube video the note holds and write text, appended to the note when it is done |

A kind that writes only appends to the note it was started on; no kind edits what is there, reads another note or reaches the network, and instructions cannot widen that. A workspace holds 30 tasks; a name is 60 characters and unique, a description 140, instructions 2,000.

### Layouts

| Tool | Does |
| --- | --- |
| `list_layouts` | every layout, or the ones that fit a note or preset |
| `read_layout` | its html and the notes using it |
| `create_layout` | from html, or lifted from a note's block |
| `update_layout` | change it, which changes every note using it |
| `apply_layout` | make a note render through a layout |
| `detach_layout` | give a note its own copy instead of the reference |
| `load_live_layout`, `read_live_layout`, `write_live_layout`, `publish_live_layout` | work on a draft in the web app's live editor, then publish it |

### Html blocks

| Tool | Does |
| --- | --- |
| `list_block_libraries` | the libraries a block may load, and when to use each |
| `read_block_data_api` | the full [Block Data API](/docs/block-data-api) reference |

### Files

| Tool | Does |
| --- | --- |
| `upload_file` | add a file you send (PDF, EPUB, DOCX, markdown, text, HTML, CSV, JSON), as an upload (`upload_id`) or as data: attached to a note, or to the workspace alone when `note_id` is left out |
| `start_upload` | get a one-time link to send a file from your machine, so its bytes never pass through the conversation (see below) |
| `fetch_file` | attach a file straight from a public URL |
| `list_files` | files, attached or kept in the workspace alone: their status and storage used |
| `describe_file` | a file's outline and opening, to decide what to read |
| `read_file` | its text by pages, section or chunks |
| `search_file` | full-text search inside attachments |
| `rename_file` | rename it; `[[file:…]]` references follow |
| `process_file` | run text extraction again |
| `delete_file` | delete a file and its text |

#### Sending a file without typing it

Every argument of a tool call is written out by the model, so a long page or a binary file cannot sensibly travel inside one. `start_upload` moves the bytes over plain HTTP instead:

1. `start_upload({ kind })` returns an `upload_id`, a link and a ready `curl` command. `kind` is `text` (up to 2 MB), `image` (10 MB) or `file` (50 MB).
2. Send the file to the link with one `PUT`, headers unchanged. The link works once, for ten minutes.
3. Use the id: write `{{upload:<upload_id>}}` in the `content` of `create_note`, `update_note` or `append_to_note` (inside an html fence for a page), or pass `upload_id` to `upload_image` or `upload_file`.

- An upload is used once and then removed; one that is never used is deleted after an hour. A person may have ten waiting at a time.
- Pass the file's `sha256` to `start_upload` and anything else sent to the link is refused: the link is the only credential, and it appears in the conversation.
- A note built from uploads may hold 2,097,152 characters at most. The tool then answers with the note's length and not its content; `read_note` returns it.
- Uploaded text is the note's content like any other: an html block still runs in the sandbox, with no network.

### Sidebar

| Tool | Does |
| --- | --- |
| `list_quick_access` | the notes pinned to the sidebar, in order |
| `add_to_quick_access` | pin a note, with an icon and colour |
| `update_quick_access` | change a pin's icon or colour |
| `remove_from_quick_access` | unpin it |

## Good first requests

- "What's in my Potion?" — the assistant lists workspaces, views and tags.
- "Save this article to Potion with the Article preset."
- "Make a view of films I rated 8 or more, best first."
- "Make a lens of my Rust notes with their tags, and give me the link."
- "Write me a task that lists the action items of a note as a checklist."
- "Which of my notes link to *Machine Learning*?"
- "Read the PDF on my *Research* note and summarise chapter 3."


---

<!-- The API: https://potion.run/docs/api.md -->

# The API

Potion's HTTP API lets your own scripts and services read and write a workspace: capture a note, log a record, pull data back out. You generate a key in Settings, send it with each request, and the key acts as you.

It is not the same thing as [the MCP](/docs/mcp), which connects an AI assistant to your workspace. This is for code you write, and it reaches every object a workspace holds: notes, records and structures, tags, properties, presets, views, graph lenses, Quick Access and files.

This page is also available as plain Markdown at [/docs/api.md](/docs/api.md).

The whole API is described as OpenAPI 3.1 at [api.potion.run/openapi.json](https://api.potion.run/openapi.json). Import it into Postman, Insomnia or Bruno to try every endpoint with your key, or generate a client from it.

Every call goes to `https://api.potion.run/v1/…`.

::toc

## Keys

A key is created in **Settings → API keys**, and shown **once**. Potion stores a hash of it, never the key, so a lost key cannot be recovered — make another and revoke the old one.

Two things are fixed when a key is made:

- **One workspace.** A key reaches exactly one workspace and can never see another, which keeps a leaked key as contained as the workspace boundary itself.
- **Read or write.** A read-only key is refused on every write with a message saying so. Use one for anything that only pulls data.

A key never grants more than you have. It resolves to your account, and the database applies the same rules it applies to the app: your notes, your workspace, trashed notes excluded.

## Making a request

```text
Authorization: Bearer potion_sk_xxxxxxxx
```

Send JSON, receive JSON.

```text
curl https://api.potion.run/v1/me \
  -H "Authorization: Bearer potion_sk_xxxxxxxx"
```

```json
{
  "user_id": "1037…",
  "workspace_id": "4f91d444-…",
  "key": { "id": "33e5…", "name": "logging script", "scope": "write" }
}
```

Start here when something is not working: `/v1/me` tells you which workspace the key opens and whether it may write. `GET /v1/workspace` names that workspace and counts what is in it.

## Notes

| Call | What it does |
| --- | --- |
| `GET /v1/notes` | The workspace's notes, newest edit first. `limit` (max 200), `offset`, `tag`, `sort` (`created_at`, `updated_at`, `title`) and `direction` |
| `GET /v1/notes?search=…` | Ranked full-text search over titles, bodies and tag names |
| `POST /v1/notes/query` | The notes matching a filter tree (the same tree a view saves), without saving a view: `filter`, optional `search`, `limit`, `offset` |
| `GET /v1/notes/:id` | One note |
| `POST /v1/notes` | Create one: `title` (required), `content`, `tags`, `alias`, `cover_url`, `properties` |
| `POST /v1/notes/:id/append` | Add to the end: `text`, optional `heading` |
| `POST /v1/notes/:id/edit` | Replace one exact passage: `old_str`, `new_str` |
| `PATCH /v1/notes/:id` | Change fields: `title`, `content` (the whole body), `alias`, `pinned`, `pin_label`, `cover_url` (null clears), `tags` (adds), optional `if_unchanged` |
| `DELETE /v1/notes/:id` | Move it to the trash |
| `GET /v1/notes/:id/properties` | Its properties, as `{ key: value }` |
| `PATCH /v1/notes/:id/properties` | Set and clear: `{ "rating": 9, "status": null }` |
| `POST /v1/notes/:id/tags` | Add tags: `{ "tags": ["reading"] }`. A tag that does not exist yet is created |
| `DELETE /v1/notes/:id/tags/:name` | Take one tag off (the tag itself stays) |
| `GET /v1/notes/:id/links` | Where it links to (`outgoing`, unresolved links included) and what links here (`backlinks`) |
| `GET /v1/notes/:id/files` | The files attached to it |

Every list answers with `total` and `has_more`, so a count on your side always matches what you actually received.

### Three ways to write, and when to use each

**This is the part worth reading twice.** Most APIs give you one way to change a document, and it is the dangerous one.

- **Append** adds to the end without reading the note first. Nothing already there can be lost. Reach for it by default when you are adding.
- **Edit** replaces one exact passage. If the passage appears twice, or not at all, the call is refused rather than guessing. Reach for it to change something specific.
- **PATCH** replaces the whole body. Use it last. Send `if_unchanged` with the `updated_at` you read, and the call is refused if anything changed in between.

Why it matters: a note's body carries `[[wiki links]]` that hold a note's id, `potion:layout` directives and share pins. Regenerating a whole body, especially through a language model, is how those quietly break. The narrow shapes exist so you rarely have to.

```text
curl -X POST https://api.potion.run/v1/notes/$ID/append \
  -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"text":"- shipped the importer","heading":"2026-09-22"}'
```

Deleting is always soft. A note goes to the trash, where it stays recoverable for 30 days. The API has no permanent delete.

Properties are strict: a key the workspace does not define, or a value that does not fit its type, refuses the whole call and **nothing is written**. On `POST /v1/notes` the note is still created, and the refusal comes back as `properties_error`.

### History and the trash

| Call | What it does |
| --- | --- |
| `GET /v1/notes/:id/revisions` | The title and body each edit replaced, newest first, kept 7 days |
| `POST /v1/notes/:id/revisions/:revisionId/restore` | Put one back. The current version becomes a revision, so this is undoable |
| `GET /v1/notes/trash` | Notes in the trash, most recently deleted first |
| `POST /v1/notes/:id/restore` | Take one out of the trash |

A restored note gets its links back. Its Quick Access pin does not come back, and it keeps its alias only if no other note claimed it in the meantime.

## Records

Records are rows of a **structure**, not notes: a weight on a date, an expense, a logged reading. A record cannot exist before its structure, so declare the structure first.

| Call | What it does |
| --- | --- |
| `GET /v1/records/structures` | Every structure in the workspace, with its fields |
| `GET /v1/records/structures/:nameOrId` | One structure |
| `POST /v1/records/structures` | Declare one: `name`, `fields`, optional `description`, `label_field`, `unique_together`, `max_rows`, `immutable` |
| `PATCH /v1/records/structures/:nameOrId` | Change it: add fields, relabel or hide them, change the description |
| `GET /v1/records?structure=weight` | Its rows. `filter`, `sort`, `direction`, `limit` (max 500), `offset` |
| `GET /v1/records/watch?structure=weight` | The same rows, live: answered now, then again on every change (see [Watching live](#watching-live)) |
| `GET /v1/records/summary?structure=weight&field=kg` | Count, sum, average, min and max, computed in the database |
| `POST /v1/records` | Write one row (`data`) or up to 500 (`rows`), in one transaction. A unique collision is refused unless `on_conflict: "update"`, which updates the row it hits |
| `PATCH /v1/records/:id` | Merge fields into a row; `null` removes one |
| `DELETE /v1/records/:id` | Soft delete |

A batch is one transaction. If any row is refused, **nothing is written** and the error names the row, so a retry is safe.

```text
curl -X POST https://api.potion.run/v1/records \
  -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"structure":"weight","data":{"kg":81.6,"when":"2026-09-22"}}'
```

Name a structure by its **id** in anything long-lived. A name stops matching the day you rename the structure; an id never changes.

Every row carries `created_at` and `updated_at`, set by the database and never by a caller. When the day something happened matters, give the structure a date field and fill it.

`max_rows: 1` makes a **settings object**: a write replaces the one row (the previous one is kept, deleted). `max_rows: N` caps a list, refusing the row past N. Lowering it below the rows held is refused.

`immutable: true` makes rows facts: they can be written and deleted, never edited — a `PATCH` to one, or `on_conflict: "update"`, is refused. Turning it on or off is always allowed.

Field changes are **additive** once rows exist: add fields, relabel them, hide them, but never remove, retype or rename a key. `fields` replaces the list, yet a field you resend without `unique`, `unit`, `label`, `hidden`, `min` or `max` keeps what it had. Deleting a structure is done in the app.

### Filters

`filter` is a JSON list of conditions, all of which must hold:

```text
filter=[{"field":"kg","op":"gte","value":80}]
```

Each kind of field answers its own operators, and **anything else is refused rather than ignored**:

| Field | Operators |
| --- | --- |
| 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` |
| yes/no | `eq`, `neq`, `is_empty`, `not_empty` |
| `created_at`, `updated_at` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte` |
| `id` | `eq`, `neq`, `in`, `not_in` |
| `*` (a search) | `contains`: any value of the row holds the text, case ignored, 1 to 200 characters |

A refused filter is a feature: a dropped condition would quietly widen your answer, and every total built on it would be wrong. `neq` and `not_in` also match rows that have no value at all. On `created_at` and `updated_at` — set only by the database, never by a caller — a plain date means that whole day.

### Watching live

`GET /v1/records/watch` asks the same question as `GET /v1/records` and keeps the connection open: it answers now,
then again every time the structure changes, from any surface. It is **Server-Sent Events**, not a WebSocket, so
`curl -N` reads it line by line, and a read-only key is enough.

```text
curl -N -H "Authorization: Bearer $POTION_API_KEY" \
  "https://api.potion.run/v1/records/watch?structure=inbox&sort=created_at&direction=desc&limit=5&changes=true"
```

```text
event: answer
data: {"structure":{…},"records":[…],"total":12,"has_more":true}

: alive

event: answer
data: {"structure":{…},"records":[…],"total":13,"has_more":true,"changes":{"created":[{…}],"updated":[],"removed":[]}}
```

- `event: answer` carries the whole page each time; with `changes=true`, each answer after the first also names the
  rows `created`, `updated` or `removed` since the previous one, so you need not compare pages yourself. "Removed"
  means the row left the page: deleted, filtered out, or pushed past `limit` by newer rows.
- `: alive` arrives every 25 seconds so a proxy does not close a quiet connection.
- `event: problem` reports one answer that failed; the stream stays open. `event: closed` means the key was revoked
  or expired, and the stream ends.
- Changes a few hundred milliseconds apart arrive as one answer. Reconnect when the connection drops; the first
  answer tells you where things stand.

Use a watch when your program runs somewhere a webhook cannot reach, such as a laptop or a terminal. Use a webhook
when you have a public URL and want every event delivered even while you are away.

### Webhooks

A structure can call a URL of yours whenever a matching row is written — created, updated or deleted, from any surface. Subscriptions are managed here like any other resource; receiving them (what arrives, the signature, retries) is its own page: [Webhooks](/docs/webhooks).

| Call | What it does |
| --- | --- |
| `GET /v1/webhooks` | Every subscription. `?structure=` for one structure |
| `POST /v1/webhooks` | Subscribe: `structure`, `url` (https), optional `events`, `filter`, `fields`, `ignore_own_key`, `description`. **The answer holds the signing secret, once** |
| `GET /v1/webhooks/:id` | One subscription |
| `PATCH /v1/webhooks/:id` | Change it, turn it off (`enabled: false`), or `resume: true` after a pause |
| `DELETE /v1/webhooks/:id` | Unsubscribe |
| `POST /v1/webhooks/:id/rotate-secret` | A new secret, in this answer only. The old one stops at once |
| `POST /v1/webhooks/:id/test` | Send a `webhook.test` event, to check a receiver without writing a row |
| `GET /v1/webhooks/:id/deliveries` | What was sent, newest first: status, attempts, your HTTP status. `?status=failed` |
| `POST /v1/webhooks/:id/deliveries/:deliveryId/redeliver` | Send one past event again now |

## Tags

| Call | What it does |
| --- | --- |
| `GET /v1/tags` | Every tag, with its colour, `note_count` and `linked_tags` |
| `GET /v1/tags/:id` | One tag, and `used_by`: the views and graph lenses that name it |
| `PATCH /v1/tags/:id` | Rename, recolour, pin: `name`, `color`, `is_pinned` |
| `DELETE /v1/tags/:id` | Remove it from every note, then delete it |
| `PUT /v1/tags/:id/links` | The tags it brings along onto every note that carries it: `{ "linked_tag_ids": [12] }` |

Renaming is always safe: views and lenses hold a tag's id, not its name. **Deleting is refused (409) while a view filters on the tag or a graph lens is scoped by it**, and the message names them. Otherwise that view would quietly start matching more notes.

## Properties

A property is a typed value a note can carry: `text`, `number`, `date`, `bool`, `select` (with `options`), or `json`.

| Call | What it does |
| --- | --- |
| `GET /v1/properties` | The workspace's properties, each with `notes_with_value` |
| `GET /v1/properties/:idOrKey` | One, and `used_by`: views (by role), presets and graph lenses |
| `POST /v1/properties` | Define one: `key`, `type`, optional `label`, `options`, `position`, `is_pinned` |
| `PATCH /v1/properties/:idOrKey` | Rename the key, relabel, reorder, pin, change a select's options |
| `DELETE /v1/properties/:idOrKey` | Delete it **and its value on every note** |

A type can change only while no note holds a value. Narrowing a select's options leaves values already on notes in place and lists them back as `stranded_values`. A delete is refused (409) while a view, preset or graph lens uses the property.

## Presets

A preset is a named set of tags and property values written onto a note. A value can be fixed, or asked for (`ask`), with or without a pre-filled answer.

| Call | What it does |
| --- | --- |
| `GET /v1/presets` | Every preset: `tags`, `properties` (what it sets), `asks` (what it asks for) |
| `GET /v1/presets/:idOrName` | One |
| `POST /v1/presets` | Create: `name`, `tags`, `properties`, `ask`, optional `icon`, `color`, `is_pinned` |
| `PATCH /v1/presets/:idOrName` | Change it. `tags` replaces the tag list; `properties` and `ask` together replace the property list |
| `DELETE /v1/presets/:idOrName` | Delete it. Notes it was applied to keep their values |
| `POST /v1/presets/:idOrName/apply` | Apply to a note: `note_id`, optional `answers`, `overwrite` |

Applying adds and never takes away. A value the note already holds is kept unless you send `overwrite: true`. A question with no answer and no pre-filled value is left unset and listed in `properties_unanswered`. An empty value is never written.

## Views

A view is a saved filter, a sort and what its cards show, re-evaluated whenever it is read.

| Call | What it does |
| --- | --- |
| `GET /v1/views` | Every view, with its `filter`, `filter_summary`, `sort` and `display` |
| `GET /v1/views/:id` | One |
| `GET /v1/views/:id/notes` | The notes it matches now, in its order. `limit`, `offset` |
| `POST /v1/views` | Create: `name`, `filter`, `sort`, `display_properties` (at most 3), `show_tags`, `is_pinned`, `is_default`, `icon`, `color`, `cover_url` |
| `PATCH /v1/views/:id` | Change any of those. `filter` replaces the whole tree; `null` clears it |
| `DELETE /v1/views/:id` | Delete it. Notes are untouched: a view is a filter, never a folder |

`sort` is `{ "field": "created_at" | "updated_at" | "title" | "property", "direction": "asc" | "desc", "property": "rating" }`, with `property` only for a property sort.

### The filter tree

A filter is one tree of groups and conditions. It names tags by **id** and properties by **definition id**, as the app stores it, so take the ids from `GET /v1/tags` and `GET /v1/properties`.

```json
{
  "type": "group", "match": "all",
  "children": [
    { "type": "tags", "op": "has_any", "tag_ids": [12, 40] },
    { "type": "property", "def_id": "9b1c…", "op": "gte", "value": 8 },
    { "type": "group", "match": "none", "children": [
      { "type": "field", "field": "title", "op": "contains", "value": "draft" }
    ] }
  ]
}
```

- **Groups** match `all`, `any` or `none`, and nest up to 4 deep, with at most 50 conditions.
- **Tags** take `has_all`, `has_any` or `has_none`.
- **Properties** take the operators of their type:
  - text and select: `eq`, `neq`, `contains`, `not_contains`, `starts_with`, `ends_with`, `in`, `not_in`, `empty`, `not_empty`
  - number: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `between`
  - date: `on`, `before`, `after`, `on_or_before`, `on_or_after`, `between`, `within`
  - yes/no: `eq`
  - json: `has_key`, `contains`
  - every type: `empty`, `not_empty`
- **Fields** are `title`, `created_at` and `updated_at`.
- **Dates** take `YYYY-MM-DD`, or `{ "relative": { "n": 7, "unit": "day" } }`.

The database checks every tree before saving it, and a tree it cannot answer is refused with the reason. The same tree drives `POST /v1/notes/query` and a graph lens's `scope`. Filters are described in full at [/docs/filters](/docs/filters).

## Graph

The graph page opens on a **lens**, a saved way of looking at it: which notes (`scope`, a filter tree, or `null` for the whole workspace) and how to draw them (`settings`).

| Call | What it does |
| --- | --- |
| `GET /v1/lenses` | Every lens |
| `GET /v1/lenses/:id` | One |
| `POST /v1/lenses` | Create: `name`, `scope`, `settings`, `is_default` |
| `PATCH /v1/lenses/:id` | Change it. `settings` is **merged** into the current ones; `scope` replaces |
| `DELETE /v1/lenses/:id` | Delete it. If it was the default, the next lens becomes the default |

`settings` is `{ "layers": { "links", "tags" }, "nodes": { "orphans", "dangling" }, "look": { "arrows", "titles": "zoom" | "always" | "never" } }`, all yes/no except `titles`.

## Quick Access

The notes pinned to the sidebar, in order.

| Call | What it does |
| --- | --- |
| `GET /v1/quick-access` | The pinned notes, in order |
| `POST /v1/quick-access` | Pin one: `note_id`, optional `icon`, `color` (`#RRGGBB`), `icon_filled`, `position` |
| `PATCH /v1/quick-access/:id` | Restyle or move it. `:id` is the item's id or the note's id |
| `DELETE /v1/quick-access/:id` | Unpin it (the note stays) |

## Files

Files, as readable text: attached to a note, or kept in the workspace alone. Uploading and deleting are done in the app.

| Call | What it does |
| --- | --- |
| `GET /v1/files` | Every file in the workspace, attached to a note or not; or one note's with `note_id` |
| `GET /v1/files/:id` | One, with its `outline` (the sections a long document was split into) |
| `GET /v1/files/:id/text` | Its extracted text, marked by page. `pages` (`7` or `7-9`), `max_chars` (default 20,000) |
| `GET /v1/files/search?q=…` | Full-text search inside files. `file_id` narrows it to one |
| `PATCH /v1/files/:id` | Rename it: `file_name`. Its `[[file:…]]` references in the note are rewritten too |

A file's text is ready once its `status` is `ready`. A long text comes back with `truncated: true` and `next_chunk`, so ask for the next page range.

## When something is refused

| Status | Means |
| --- | --- |
| 400 | The request, or a rule: the message is the sentence to act on |
| 401 | No key, a bad key, or a revoked one |
| 403 | A read-only key tried to write |
| 404 | Not in this workspace, or already trashed |
| 409 | Something depends on it (a view uses the tag), or the name is already taken |
| 500 | Our side |

```json
{ "error": "filter on \"created_at\" takes eq, neq, gt, gte, lt or lte, not \"in\"", "code": "refused" }
```

Refusals quote the rule that stopped the write, in the same words the app uses.

## What this version does not do

These are deliberately left out, because each is either one wide, irreversible cascade or machinery that lives in the app:

- renaming or deleting a workspace
- deleting a structure
- deleting a note for good
- uploading or deleting a file
- layouts


---

<!-- Webhooks: https://potion.run/docs/webhooks.md -->

# Webhooks

A webhook tells another system that something changed in Potion. Subscribe a URL to a **structure**, and whenever one of its rows is created, updated or deleted — from the app, iOS, an assistant, an html block or the API — Potion POSTs the row to that URL within seconds.

That is how Potion reaches the world outside it: a server that sends a message when a weight crosses 90, a script that copies each expense to a spreadsheet, a bridge that turns a real light on when the lamp note is switched.

A webhook needs a public URL. A program that has none (on a laptop, in a terminal) can **watch** the structure instead: one open connection that receives the rows again on every change. See [Watching live](/docs/api#watching-live).

::toc

## Setting one up

**In the app.** Open **Records**, pick the structure, switch to **Structure**, and find **Webhooks** → **Add**. Give the URL, pick the events, and optionally narrow it. The signing secret is shown once, when you save: copy it to your receiver. Each webhook has **Send test**, a **Log** of what was sent, and **Turn off**.

**With the API.** `POST /v1/webhooks` with an API key:

```text
curl -X POST https://api.potion.run/v1/webhooks \
  -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"structure":"weight","url":"https://example.com/hooks/potion","filter":[{"field":"kg","op":"gte","value":90}],"fields":["kg"]}'
```

The answer carries `secret`. Every endpoint is listed in [the API](/docs/api#webhooks).

**With an assistant.** Ask it: "call https://example.com/hooks/potion whenever I log a weight over 90." It uses `create_webhook`, and hands you the secret.

## What fires it

A subscription is always **one structure**. Two optional narrowers make it smaller:

| Narrower | Means |
| --- | --- |
| `events` | Any of `record.created`, `record.updated`, `record.deleted`. All three by default |
| `filter` | Only rows that match — the same flat filter as record reads, e.g. `[{"field":"kg","op":"gte","value":90}]`. One record is `[{"field":"id","op":"eq","value":"<id>"}]` |
| `fields` | An update fires only when one of these fields changed. Created and deleted always fire |

A change that touches nothing but the timestamps fires nothing. A filter or field the structure cannot answer is refused when you save it, not discovered later by its silence.

## What arrives

A `POST` with `Content-Type: application/json`:

```json
{
  "id": "the event id — the same on every retry",
  "type": "record.updated",
  "occurred_at": "2026-09-23T19:20:26Z",
  "workspace_id": "…",
  "structure": { "id": "…", "name": "weight" },
  "record": { "id": "…", "data": { "kg": 91.2, "when": "2026-09-23" }, "created_at": "…", "updated_at": "…", "deleted_at": null },
  "changed": ["kg"],
  "previous": { "kg": 89.8 },
  "source": { "surface": "api", "key_id": "…" }
}
```

- The **whole row**, never a diff. On an update, `changed` names the fields that moved and `previous` holds their old values.
- `source.surface` says where the write came from: `web`, `ios`, `chrome`, `mcp`, `api` or `block`. `source.key_id` names the API key when one made it.
- The **Send test** button and `POST /v1/webhooks/:id/test` send `"type": "webhook.test"` with `record: null`.

Headers: `X-Potion-Event` (the type), `X-Potion-Delivery` (this delivery), `X-Potion-Signature`.

## Checking the signature

Anyone can POST to your URL; the signature proves it was Potion. `X-Potion-Signature` is `t=<unix seconds>,v1=<hex>`, where `v1` is the HMAC-SHA256 of `<t>.<raw body>` keyed with your secret. Verify the **raw body**, before parsing it:

```js
import crypto from 'node:crypto';
import express from 'express';

const app = express();

app.post('/hooks/potion', express.raw({ type: 'application/json' }), (req, res) => {
  const [, t, v1] = (req.header('x-potion-signature') ?? '').match(/^t=(\d+),v1=([0-9a-f]{64})$/) ?? [];
  const expected = crypto.createHmac('sha256', process.env.POTION_SECRET).update(`${t}.${req.body}`).digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
  if (!v1 || !fresh || !crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) return res.sendStatus(401);

  const event = JSON.parse(req.body);
  console.log(event.type, event.record?.data);
  res.sendStatus(200);
});

app.listen(4000);
```

No Potion library is needed: any language with HMAC-SHA256 can do this. Automation tools (n8n, Zapier, Make, Pipedream) accept the URL as it is.

## Delivery

- Answer with any **2xx** within **10 seconds**. Do the slow work after answering.
- Anything else is **retried** after 1 minute, 10 minutes, 1 hour and 6 hours, then marked failed. **Resend** in the log, or `POST …/deliveries/:id/redeliver`, sends one again.
- **Ten failures in a row pause the webhook.** Events keep queueing while it is paused; **Resume** (or `resume: true`) sends them.
- An event can arrive **twice**, and order is not guaranteed: deduplicate on `id`, and compare `record.updated_at` if order matters.
- Only public **https** addresses are called, and redirects are not followed.
- The log keeps 30 days. A workspace holds up to 25 webhooks. Rotate a leaked secret with `POST /v1/webhooks/:id/rotate-secret`; the old one stops at once.

## Writing back

A receiver often writes back — the light reports its real state, the spreadsheet row gets an id. Writing to the **same** structure would fire the webhook again, forever. So subscribe with `"ignore_own_key": true` and write with that same API key: writes made with that key never fire that webhook, and everything else still does.

## Example: a real light

The lamp note writes a row to a `lamp` structure (`color`, `brightness`, `on`). To make a real bulb follow it:

1. Add a webhook on `lamp` with `fields: ["color", "brightness", "on"]` and `ignore_own_key: true`, pointing at a small server at home (behind a tunnel) or in the cloud.
2. The receiver verifies the signature, reads `event.record.data`, and calls the light's own API — Home Assistant's `light/turn_on` with `rgb_color` and `brightness_pct`, or the Hue bridge directly.
3. When someone uses the wall switch, the receiver writes the real state back with `POST /v1/records`; the lamp note, which watches the structure, shows it.


---

<!-- Block libraries: https://potion.run/docs/libraries.md -->

# Block libraries

An html block has no network, so it cannot load scripts from a CDN. It names one of these
instead, and Potion injects it: `<!-- potion:use chart.js@4 -->`. Write the `usage` pin of
the entry: it is the version that was tested. Without a directive a block is plain HTML.

| Library | Category | Use it when | Usage |
| --- | --- | --- | --- |
| abcjs (abcjs) | Audio | Showing music as notation — a tune, a scale, an exercise — written as a few lines of ABC text instead of a picture of a score. | `<!-- potion:use abcjs@6 -->` |
| animejs (animejs) | Animation | A few coordinated animations where gsap would be overkill. | `<!-- potion:use animejs@3 -->` |
| apexcharts (apexcharts) | Charts | A chart meant to be explored rather than read once. | `<!-- potion:use apexcharts@4 -->` |
| canvas-confetti (canvas-confetti) | Animation | A celebration beat in something interactive. Rarely the right answer twice. | `<!-- potion:use canvas-confetti@1 -->` |
| caveat (@fontsource/caveat) | Fonts | A block that should read as handwritten notes: a whiteboard explainer, an annotated sketch. System 'cursive' differs on every device; this is the same everywhere. | `<!-- potion:use caveat@5 -->` |
| chart.js (chart.js) | Charts | The default for plotting data. A table beats it for three numbers; reach for it once the shape of the data is the point. | `<!-- potion:use chart.js@4 -->` |
| chroma-js (chroma-js) | Utilities | Building a colour ramp for a heatmap or checking contrast. Note v3 ships no browser build — use v2. | `<!-- potion:use chroma-js@2 -->` |
| compromise (compromise) | Text & search | Pulling structure out of note text — who is mentioned, which topics recur — without an AI call. English only. | `<!-- potion:use compromise@14 -->` |
| d3 (d3) | Charts | A chart no charting library has a name for. Harder to get right than chart.js, so prefer that unless the layout is genuinely custom. | `<!-- potion:use d3@7 -->` |
| dagre (dagre) | Diagrams | A diagram too dense to place by hand: many nodes, crossing links, labelled groups. dagre only computes positions and edge routes; the block draws them as SVG and can make nodes draggable. | `<!-- potion:use dagre@0.8 -->` |
| dayjs (dayjs) | Time | Axis labels, relative times or date maths inside a block. | `<!-- potion:use dayjs@1 -->` |
| diff (diff) | Text & search | Showing what changed between two versions of something inside a note. | `<!-- potion:use diff@9 -->` |
| echarts (echarts) | Charts | Chart types chart.js does not have, or thousands of points. It is 1MB, so do not use it for a bar chart. | `<!-- potion:use echarts@5 -->` |
| event-calendar (@event-calendar/build) | Time | Notes with dates shown as a calendar — due dates, a reading schedule, an agenda built from potion.getNotes(). Suits days and weeks rather than a long history. | `<!-- potion:use event-calendar@5 -->` |
| fabric (fabric) | Drawing | An editor-like surface: place, resize and rotate items. | `<!-- potion:use fabric@6 -->` |
| frappe-charts (frappe-charts) | Charts | A simple chart where echarts or plotly would be wildly oversized. | `<!-- potion:use frappe-charts@1 -->` |
| fuse.js (fuse.js) | Text & search | A search box in a block that should tolerate typos. Exact substring matching does not need this. | `<!-- potion:use fuse.js@7 -->` |
| gridjs (gridjs) | Tables & data | Only when a table needs to be *used* rather than read — sorted, searched, or paged. A plain markdown table is the default and handles most tabular content. | `<!-- potion:use gridjs@6 -->` |
| gsap (gsap) | Animation | Sequenced or choreographed motion. A single transition should stay CSS. | `<!-- potion:use gsap@3 -->` |
| highlight.js (@highlightjs/cdn-assets) | Text & search | Showing code *inside* a block. A plain fenced code block in the note is already highlighted — do not use this for that. Ships a dark theme; restyle .hljs if the block is light. | `<!-- potion:use highlight.js@11 -->` |
| htm (htm) | Interface | Always alongside preact — `const html = htm.bind(preact.h)` gives you JSX syntax a block can actually run. On its own it does nothing. | `<!-- potion:use htm@3 -->` |
| interactjs (interactjs) | Interface | Free positioning or resizing. For simple list reordering, sortablejs is the smaller answer. | `<!-- potion:use interactjs@1 -->` |
| js-yaml (js-yaml) | Tables & data | A block whose data is YAML — config, front matter, a spec pasted into the note. | `<!-- potion:use js-yaml@4 -->` |
| jsmind (jsmind) | Diagrams | Brainstorms and topic maps where hierarchy matters and branches should collapse. A small map that never needs to collapse is simpler as inline SVG. | `<!-- potion:use jsmind@0 -->` |
| jsxgraph (jsxgraph) | Maths | Letting the reader move a slider or drag a point and watch the maths respond — a parameter changing a curve, a tangent following a point, a theorem holding as a triangle moves. | `<!-- potion:use jsxgraph@1 -->` |
| konva (konva) | Drawing | An interactive canvas where the user moves things around. | `<!-- potion:use konva@9 -->` |
| leader-line (leader-line) | Diagrams | Annotating a normal HTML layout with pointers, without moving to a canvas. | `<!-- potion:use leader-line@1 -->` |
| lodash (lodash) | Utilities | Shaping awkward nested data. Modern array methods cover the simple cases without it. | `<!-- potion:use lodash@4 -->` |
| lottie-web (lottie-web) | Animation | A designed animation you already have as Lottie JSON, inlined into the block. | `<!-- potion:use lottie-web@5 -->` |
| luxon (luxon) | Time | Anything involving zones, durations or calendar maths. For plain formatting, dayjs is smaller. | `<!-- potion:use luxon@3 -->` |
| maplibre-gl (maplibre-gl) | Maps | Showing where things are: notes that carry coordinates as properties, stops on a trip, one place. Not for a simple list of addresses, which markdown shows fine. | `<!-- potion:use maplibre-gl@4 -->` |
| mark.js (mark.js) | Text & search | A block that shows text and lets the reader search it, so matches light up in place. | `<!-- potion:use mark.js@8 -->` |
| marked (marked) | Text & search | Rendering markdown the block itself holds as data. The note's own body is already markdown — do not use it for that. | `<!-- potion:use marked@18 -->` |
| mathjs (mathjs) | Maths | A calculator, unit converter or formula sheet — anything where the user types an expression rather than numbers into fixed fields. | `<!-- potion:use mathjs@15 -->` |
| matter-js (matter-js) | Drawing | Something that should fall, bounce or swing. Explaining a physical idea beats decorating with it. | `<!-- potion:use matter-js@0 -->` |
| minisearch (minisearch) | Text & search | A search box over note titles or contents inside a dashboard. fuse.js is better for fuzzy matching a short list; this is for ranked search over longer text. | `<!-- potion:use minisearch@7 -->` |
| nouislider (nouislider) | Interface | Letting the reader vary an input and watch the block respond — the difference between a figure and a model. | `<!-- potion:use nouislider@15 -->` |
| p5 (p5) | Drawing | Something procedural or hand-animated. Static diagrams and simple shapes belong in inline SVG. | `<!-- potion:use p5@1 -->` |
| papaparse (papaparse) | Tables & data | Turning pasted CSV into rows for a table or chart. Hand-rolled split(',') breaks on quoted fields. | `<!-- potion:use papaparse@5 -->` |
| paper (paper) | Drawing | Real geometry work — intersections, offsets, path manipulation. | `<!-- potion:use paper@0.12 -->` |
| pixi.js (pixi.js) | Drawing | Far more moving objects than canvas 2D can keep smooth. It is 800KB, so not for a handful of shapes. | `<!-- potion:use pixi.js@4 -->` |
| plotly.js-basic-dist (plotly.js-basic-dist) | Charts | Analysis output rather than a dashboard tile: error bars, log axes, many scatter points. The basic bundle has only scatter, bar and pie traces — no box plots, histograms or heatmaps (use echarts for those). | `<!-- potion:use plotly.js-basic-dist@2 -->` |
| preact (preact) | Interface | A block whose UI has real state — a filter, a toggle, a list that re-renders. Plain DOM is the default; reach for this when hand-written innerHTML starts fighting you. Pair with htm for JSX-like markup, since a block has no build step. | `<!-- potion:use preact@10 -->` |
| qrcode-generator (qrcode-generator) | Utilities | Putting a link on screen for someone to scan off the page. | `<!-- potion:use qrcode-generator@1 -->` |
| rough-notation (rough-notation) | Drawing | Marking up words that are already on the page — circle a term, underline a claim, bracket a paragraph. It follows the element, so the mark stays on its words when the text reflows; hand-placed roughjs shapes do not. | `<!-- potion:use rough-notation@0 -->` |
| roughjs (roughjs) | Drawing | A diagram that should read as a sketch rather than a finished figure. | `<!-- potion:use roughjs@4 -->` |
| simple-statistics (simple-statistics) | Tables & data | A trend line, an r value, quartiles — numbers you would otherwise get subtly wrong. | `<!-- potion:use simple-statistics@7 -->` |
| sketch (@ghostmind-dev/sketch) | Drawing | Explaining an idea the way a maths video does — an equation built up with circled terms, arrows and notes, over several boards when the idea has steps. One JSON scene; no drawing code, no network, no web fonts. | `<!-- potion:use sketch@0.6 -->` |
| smiles-drawer (smiles-drawer) | Diagrams | Drawing a molecule — caffeine, aspirin, a reaction product — from its SMILES string, which markup cannot lay out. | `<!-- potion:use smiles-drawer@2 -->` |
| sortablejs (sortablejs) | Interface | A block where the reader arranges things — ranking, planning, sorting into columns. | `<!-- potion:use sortablejs@1 -->` |
| swiper (swiper) | Interface | A slide deck or a swipeable gallery inside a note — and it works with a thumb on iOS. | `<!-- potion:use swiper@14 -->` |
| tabulator (tabulator-tables) | Tables & data | Exploring many notes as rows — group films by style, sort by rating, filter as you type. gridjs is lighter for a plain sortable table. Edits made in the table stay in the block; a block cannot write back. | `<!-- potion:use tabulator@6 -->` |
| temml (temml) | Maths | An equation inside a block — a note's own $…$ maths does not reach into html blocks. MathML needs no fonts, so it works in the sandbox where KaTeX would not. | `<!-- potion:use temml@0.13 -->` |
| three (three) | 3D | Something that has to be seen in three dimensions and turned: a molecule, a room, a solar system, a product, a 3D chart. For flat drawing or 2D animation use a canvas or SVG; this is the heaviest library on the list. | `<!-- potion:use three@<version> -->` |
| tonal (tonal) | Audio | Explaining how music is built — the notes of a scale, the chords in a key, the interval between two notes — computed rather than typed out by hand. Pairs with abcjs to show it and tone to hear it. | `<!-- potion:use tonal@6 -->` |
| tone (tone) | Audio | Making sound in a note — a scale, a rhythm, an audible demonstration. Never start audio without the reader asking for it. | `<!-- potion:use tone@15 -->` |
| two.js (two.js) | Drawing | Clean vector shapes and smooth motion without p5's sketch framing. | `<!-- potion:use two.js@0.8 -->` |
| wordcloud (wordcloud) | Text & search | Showing which words or tags dominate a set of notes at a glance. A bar chart is better when exact counts matter. | `<!-- potion:use wordcloud@1 -->` |
| zdog (zdog) | Drawing | A small 3D-looking object the reader can spin — an icon, a model, a playful illustration — without a full 3D engine. | `<!-- potion:use zdog@1 -->` |

The catalog as JSON, with sandbox notes per library: https://potion.run/api/vendor


---

<!-- Block Data API: https://potion.run/docs/block-data-api.md -->

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