Skip to content
Twig docs Open Twig MenuClose

API reference

23 members

Everything the object passed to your plugin can do, by area. The member list and the signatures are checked against the TwigApi type by a test, so this page cannot fall behind the code.

Lifecycle

export default

since 0.1.0
export default function (twig: TwigApi): void | (() => void)

The plugin. Twig imports the module and calls its default export with an API object of its own: at launch while it is on, each time it is switched on, and again when the marketplace listing changes what it runs (a re-approve, a new URL). Whatever it returns is called when the plugin is switched off or removed, and before each of those restarts.

twig
TwigApi This plugin’s own API object; disabling the plugin undoes everything registered through it.
Returns
void | (() => void)

It can run more than once in a session, so keep no state outside it that a second run would double. Every add returns a function that undoes it, and on an unsubscribe, but you do not have to keep them: Twig undoes everything a plugin registered before it stops or starts again. Return a cleanup for what you made yourself, such as a timer or a window listener.

jsexport default
export default function (twig) {
  const hour = 60 * 60 * 1000
  const timer = setInterval(() => twig.notice('Stand up and stretch'), hour)
  return () => clearInterval(timer)
}

export const name

since 0.1.0
export const name: string

What the Plugins screen calls a plugin added by URL. Optional: without it the entry is named after the file.

jsexport const name
export const name = 'Word count'

Pages

pages.list

since 0.1.0
pages.list(): Page[]

Every page in the graph, journal days and whiteboards included.

jspages.list
const days = twig.pages.list().filter((p) => p.kind === 'journal')

pages.get

since 0.1.0
pages.get(name: string): Page | undefined

A page by name or alias, case-insensitive.

jspages.get
const page = twig.pages.get('reading list')
if (page) twig.panels.open('word-count')

pages.create

since 0.1.0
pages.create(name: string): Page

Creates the page, or returns the one already there.

jspages.create
const inbox = twig.pages.create('Inbox')
twig.blocks.insertChild(inbox.id, 'TODO Sort the inbox')

pages.current

since 0.16.0
pages.current(): Page | undefined

The page the screen is showing: a page, a day, or the page a zoomed block belongs to.

Undefined on screens that are not a page — All pages, Tasks, Settings, and a plugin view of something else. On a view that says page: true it is the page that view is showing. Pair it with on(‘route’) to follow the user around.

jspages.current
const page = twig.pages.current()
if (!page) return

pages.backlinks

since 0.16.0
pages.backlinks(name: string): Block[]

Every block that references the page, by [[name]], #tag or alias, and the non-empty blocks of any page that names it in its own properties. Each block once, in no particular order.

name
string
Returns
Block[] Empty when no page has that name.
jspages.backlinks
const n = twig.pages.backlinks('Ana Ruiz').length
twig.notice(`${n} blocks mention Ana`)

pages.markdown

since 0.16.0
pages.markdown(name: string): string

The page as an export writes it: the page’s own properties first, as key:: value lines, then one bullet per block, nested with tabs.

name
string
Returns
string An empty string when no page has that name.
jspages.markdown
await navigator.clipboard.writeText(twig.pages.markdown(page.name))

Blocks

blocks.get

since 0.1.0
blocks.get(id: string): Block | undefined

One block by id, or undefined once it has been deleted.

jsblocks.get
const block = twig.blocks.get(blockId)
if (block?.marker === 'TODO') twig.blocks.setMarker(blockId, 'DOING')

blocks.all

since 0.16.0
blocks.all(): Block[]

Every block in the graph, in no particular order.

A new array of the live records, built by a walk over the whole graph: fine on a click, not on every keystroke. Read the records; change them only through the API.

jsblocks.all
const waiting = twig.blocks.all().filter((b) => b.marker === 'WAITING')

blocks.children

since 0.1.0
blocks.children(key: string): readonly string[]

Ordered child ids. The key is a block id or a page id, so one call walks from either.

key
string A page id for its top-level blocks, a block id for the blocks under it.
Returns
readonly string[]
jsblocks.children
const walk = (key) =>
  twig.blocks.children(key).flatMap((id) => [id, ...walk(id)])
const everyBlockOnThePage = walk(page.id)

blocks.words

since 0.68.0
blocks.words(id: string, drop?: (ref: string) => boolean): HTMLElement

The block’s words as the app draws them on one line, the way a task row shows them: the priority as its small letter, a reference or a tag as its name, code as code. No brackets, backticks, [#A], date lines or properties. A reference to a journal day reads short, as a row says a day: Sep 23, with the year only when it is another (Sep 23, 2025).

id
string
drop?
(ref: string) => boolean Called with each reference’s page name as written (Sep 23rd, 2026, not the short form it reads as). One it returns true for is left out, with the space after it — say, the page your view is already showing. Kept when it is all the words the block has.
Returns
HTMLElement

A new <span> on each call, styled by the app’s own classes. A reference in it is words, not a link, so it can sit inside a link of your own; a macro is left out. Empty once the block is deleted.

jsblocks.words
const a = document.createElement('a')
a.href = `#/block/${block.id}`
a.append(twig.blocks.words(block.id))

blocks.setContent

since 0.1.0
blocks.setContent(id: string, content: string): void

Replaces the text. References and tags are re-derived from what you wrote.

jsblocks.setContent
twig.blocks.setContent(blockId, block.content + ' #done')

blocks.insertAfter

since 0.1.0
blocks.insertAfter(id: string, content: string): Block

A new sibling after that block — or its first child, when the block has visible children.

Which of the two you get depends on the block being expanded, not on an argument.

jsblocks.insertAfter
const next = twig.blocks.insertAfter(blockId, 'Follow up on Friday')

blocks.insertChild

since 0.16.0
blocks.insertChild(parentKey: string, content: string, at?: number): Block

A new child under a block, or under a page when the key is a page id. Last by default; at is the index, so 0 puts it first.

parentKey
string A block id, or a page id to add a top-level block.
content
string
at?
number Where among the children: 0 is first. Left out, past the end or below 0, it is clamped to last or first.
Returns
Block
jsblocks.insertChild
twig.blocks.insertChild(page.id, 'Summary: …', 0)

blocks.setMarker

since 0.16.0
blocks.setMarker(id: string, marker: string | null): void

Sets the marker on the first body line — TODO, DOING, DONE, LATER, NOW, WAITING, CANCELED — or clears it with null.

Whatever you pass is written; nothing checks it is one of those.

jsblocks.setMarker
twig.blocks.setMarker(blockId, 'DONE')

blocks.setDates

since 0.16.0
blocks.setDates(id: string, dates: { scheduled?: number | null; deadline?: number | null }): void

Rewrites the SCHEDULED: and DEADLINE: lines. A day key such as 20260921 sets one, null removes it, leaving a key out keeps it.

id
string
dates
{ scheduled?: number | null; deadline?: number | null } Day keys, yyyymmdd as a number. Only the keys you pass change.
Returns
void

Only the lines you set move: a date you pass is written as the block’s last line, and one you leave out stays where it was.

jsblocks.setDates
twig.blocks.setDates(blockId, { deadline: 20260930, scheduled: null })

Events

on

since 0.1.0
on(event: 'block' | 'page' | 'route', cb: (id: string) => void): () => void

Fires after a change with the record id, or for route with the new hash. Returns an unsubscribe.

event
'block' | 'page' | 'route'
cb
(id: string) => void
Returns
() => void Call it to stop listening. Twig also stops every listener when the plugin is switched off.

Deletion fires too, and the id no longer resolves — check blocks.get before using it.

json
const off = twig.on('block', (id) => {
  const block = twig.blocks.get(id)
  if (!block) return // deleted
})

Commands and macros

commands.add

since 0.1.0
commands.add(name: string, run: (ctx: { blockId: string }) => void): () => void

A slash command, listed under / by name. run receives the block the cursor is in. From a plugin the marketplace files under Actions, the same command is also in the Actions menu on a page’s own screen (not on Today, a day’s screen, a whiteboard or a zoomed block), and run then receives the page’s first block, or '' when the page is empty.

name
string What the / list shows, and what the Actions menu shows.
run
(ctx: { blockId: string }) => void
Returns
() => void

Keyed by exact name: a second plugin registering the same name replaces the first.

jscommands.add
twig.commands.add('Mark done', ({ blockId }) => {
  twig.blocks.setMarker(blockId, 'DONE')
})

macros.add

since 0.1.0
macros.add(name: string, render: (args: string, blockId: string) => HTMLElement): () => void

Renders {{name args}} wherever it appears in a block. The way a plugin puts something inside the outline.

name
string
render
(args: string, blockId: string) => HTMLElement Gets everything after the name, as written, and the id of the block it is in.
Returns
() => void

The name is lower-cased when stored, so {{WordCount}} and {{wordcount}} are one macro. The element is rebuilt when the macro’s own text changes or the block leaves edit mode — not when other lines of the block change. Give the element a dispose function and Twig calls it when the element is discarded.

jsmacros.add
twig.macros.add('shout', (args) => {
  const el = document.createElement('strong')
  el.textContent = args.toUpperCase()
  return el
})

Panels

panels.add

since 0.16.0
panels.add(id: string, panel: { title: string; render(host: HTMLElement): void | (() => void) }): () => void

A sidebar panel. render fills the host element; return a function to be called when the panel closes or the plugin is switched off.

Registering is not showing. Call panels.open, usually from a command or a nav link.

jspanels.add
twig.panels.add('clock', {
  title: 'Clock',
  render(host) {
    const tick = () => { host.textContent = new Date().toLocaleTimeString() }
    tick()
    const timer = setInterval(tick, 1000)
    return () => clearInterval(timer)
  },
})

panels.open

since 0.16.0
panels.open(id: string): void

Shows a registered panel in the sidebar. Opening one that is already open does nothing.

jspanels.open
twig.commands.add('Clock', () => twig.panels.open('clock'))

Views and navigation

views.add

since 0.16.0
views.add(slug: string, view: { label: string; page?: true; render(host: HTMLElement, params: string[]): void | (() => void) }): () => void

A screen of its own at #/x/<slug>. Anything after the slug in the hash arrives as params, already decoded. A view that reads params[0] as a page name says so with page: true, and a page’s own screen then offers it as a tab beside Outline, opening #/x/<slug>/<page name>. There is no tab on Today, a day’s screen, a whiteboard or a zoomed block.

slug
string One case, like a macro name: #/x/Board and #/x/board are one view.
view
{ label: string; page?: true; render(host: HTMLElement, params: string[]): void | (() => void) } page: true arrived in 0.17.0; before it, no view had a tab on a page.
Returns
() => void

The screen is rebuilt when the params change, and the cleanup you return runs first. Keep state in the hash if it should survive that.

jsviews.add
twig.views.add('hello', {
  label: 'Hello',
  render(host, [who = 'world']) { host.textContent = `Hello, ${who}` },
})

nav.add

since 0.1.0
nav.add(label: string, href: string): () => void

A link under Plugins in the More sheet. Point it at a view of your own, #/x/<slug>, or at any page.

label
string
href
string A hash address: #/x/<slug> for a view, #/page/<name> for a page.
Returns
() => void
jsnav.add
twig.nav.add('Hello', '#/x/hello/there')

UI helpers

notice

since 0.16.0
notice(text: string): void

A short line at the bottom of the screen: the way to say “done”, or that something happened out of view.

Gone after three seconds. Two can be up at once; a third replaces the oldest rather than queueing behind them. Trimmed and cut at 160 characters; an empty one shows nothing.

jsnotice
twig.notice('Copied as Markdown')

Records

Page

since 0.1.0
type Page = { id, name, kind, props, … }

What pages.list, pages.get, pages.create and pages.current return. A journal day is a page too, and so is a whiteboard.

id
string Stable. blocks.children(page.id) is its top-level blocks.
name
string As written, case kept.
nameLower
string The name in lowercase, for comparing.
kind
'page' | 'journal' | 'whiteboard' A journal is a day.
day?
number A journal’s day key, such as 20260918.
favorite?
true Starred.
aliases?
string[] Kept for builds before 0.43.0; read props.alias instead.
type?
PageType What the page is, set from its menu (0.35.0): page, tag, person, location, book, website, organization, event, artist, album, track, movie or tv-show (organization and event since 0.56.0, the last five since 0.90.0). Absent, it is inferred.
props?
Record<string, string> The page’s own properties (0.43.0): a type’s fields, alias, tags, anything imported. Keys lowercase, values as written. Absent on a page from before 0.43.0 not yet moved over.
propsAt?
Record<string, number> When each property last changed. For sync; a plugin has no use for it.
createdAt
number Milliseconds since the epoch.
updatedAt
number Milliseconds since the epoch, when last written.

These are the live records: read them, and change a page only through the API.

jsPage
const people = twig.pages.list().filter((p) => p.type === 'person')
const birthdays = people.map((p) => [p.name, p.props?.birthday])

Block

since 0.1.0
type Block = { id, pageId, content, marker, … }

What blocks.get, blocks.all, pages.backlinks and the inserts return. Everything after updatedAt is read out of content, so it changes when the text does.

id
string Stable, and what ((id)) references.
pageId
string The page it is on.
parentId
string | null The block it is under, or null at the top of the page.
order
number Sorts it among its siblings. Compare it; do not compute with it — blocks.children is the order.
content
string The text as written, markers, dates and properties included.
collapsed?
true Folded.
createdAt
number Milliseconds since the epoch.
updatedAt
number Milliseconds since the epoch, when last written.
refs
string[] The page names it references — [[name]], #tag, and tags:: and alias:: values — first-seen case, each once.
tags
string[] The refs written as tags.
blockRefs
string[] The block ids it references with ((id)).
marker?
Marker TODO, DOING, DONE, LATER, NOW, WAITING or CANCELED, from the first line.
priority?
Priority A, B or C, written [#A].
heading?
number The level of a # heading.
scheduled?
number The SCHEDULED: day key.
deadline?
number The DEADLINE: day key.
properties?
Record<string, string> The block’s own key:: value lines.

These are the live records: read them, and change a block only through the API.

jsBlock
const late = twig.blocks.all().filter((b) =>
  b.deadline !== undefined && b.deadline < 20260923 && b.marker !== 'DONE')

Settings, storage and network

AreaThere is no API. Use
SettingsA field in your own panel or view: a plugin cannot add a section to Settings. The Reading time example is one.
StoragelocalStorage, under a key only your plugin uses, prefixed with its name. It stays on the device: not synced, not in a backup.
Networkfetch, as on any page, within CORS. Say what leaves the device in the marketplace’s Network line.

Reads and writes

The graph is held in memory and written behind you, so reads are synchronous and cheap. Writes go through the same path the editor uses: references are re-derived, undo records a step, and other devices get the change on the next sync.

Rendering

A plugin never sees React. A macro returns an element; a panel or a view is handed a host element to fill. The app’s design tokens are in scope on anything you make — write color: var(--ink) or background: var(--panel) and both color schemes are handled for you. A block’s words, drawn the way the app draws them, are blocks.words. Styles you need are yours to inject: a single <style> element added once, from a string, is the pattern the shipped plugins use.

The app’s class names are its own and can change in any release, except these eight, which a plugin may put on what it makes. The examples use nothing else.

ClassWhat it draws
chipPlugin or macro output: a small, quiet label that is not a link.
metaA line of small print in the app’s voice, in --ink-3.
mutedA quiet sentence: a hint, or an empty state.
eyebrowA label in mono capitals over a section or a column.
numA number in the mono.
checkThe app’s checkbox, on an <input type="checkbox">.
check-labelA checkbox and its words, side by side.
fieldThe app’s input box, around an <input>.