Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Burrow extensions (BXP, protocol 1)

An extension is a separate program that Burrow starts and talks to over its stdin/stdout. It can add commands to the Extensions palette (⇧⌘P), offer a library of documents to the notes panel (the bundled markdown-notes is one), add a marker or a ⋯ row to each reading entry (the bundled reading-time and inspect), and, with the user’s permission, read and edit the user’s own posts. Code: crates/blyg-ext (protocol, manifest, grants, host) and crates/blyg-ext-notes (the bundled extension).

Trust: what Burrow enforces, and what it can’t

An extension runs as you. Burrow enforces everything that goes through Burrow: without a grant, a burrow/* call is refused with -32001. There is no method that publishes, withdraws, pins, deletes, forks or changes the blyg’s settings, and none that returns a token. The extension never sees the Keychain, the data directory or the database, and its environment is scrubbed (no BLYGGER_*).

fs:<path> and net are declarations, not enforcement: Burrow can’t stop a native program from reading other files or using the network. The consent sheet says so in plain words (blyg_ext::describe, blyg_ext::CONSENT_CAVEAT). Only install extensions you trust. A WASM runner that enforces both is possible later without changing the protocol.

Installing and enabling

Copy the extension’s folder into ~/.config/blygger/extensions/<name>/ (the folder holds extension.toml). Nothing runs until the config file says so:

extension = hello
extension-allow = hello ui
extension-allow = hello fs:~/Notes
extension-setting = hello greeting=hi

The first start of an extension that asks for capabilities and has none granted shows the consent sheet; Allow writes the extension-allow lines.

Manifest (extension.toml)

name = "hello"                    # kebab-case, at most 40 characters
version = "0.1.0"
protocol = 1
description = "Says hello."
command = ["python3", "main.py"]  # run from this folder; a bare name is looked up on PATH
capabilities = ["ui"]             # see below

[[commands]]                      # Extensions palette rows
id = "hello"
title = "Say hello"
detail = "A friendly message"
when = "always"                   # always | editor | reading | notes | published

[[libraries]]                     # documents for the notes panel
id = "notes"
title = "Notes"
writable = true

[[settings]]                      # extension-setting = hello <key>=<value>
key = "greeting"
kind = "text"                     # text | path | bool | number
docs = "What to say."

Unknown keys are ignored. On Windows a bare command name also tries the PATHEXT extensions (.exe, .cmd, …); paths may use \.

Capabilities

CapabilityLets the extensionEnforced
items.readburrow/listItems, getItem, searchItems over your own posts, drafts and scratch notesyes
items.writeburrow/createDraft (draft or scratch), burrow/saveItem (working copy; never publishes)yes
reading.readburrow/listReading: posts held locally from subscriptions; and the reading slots (each entry’s data, see Reading slots)yes
blyg.identitythe blyg’s origin in initialize (never a token)yes
uiburrow/toast, burrow/openItemyes
hooks:itemPublished, hooks:itemSaved, hooks:itemCreatedthe matching notifications, with the post’s textyes
browser.captureburrow/browser.page: the page open in the browser pane (address, title, selection, readable text) while one of its commands runs; never cookies or sign-insyes
browser.automate:<origin>run its [[macros]] on that one origin, and burrow/browser.open URLs on ityes
fs:<path>declares the folder it reads and writesno
netdeclares that it uses the networkno

Browser: capture and macros

Two capabilities reach the browser pane, both enforced by Burrow:

  • browser.capture lets the extension call burrow/browser.page while one of its commands runs. The answer is {url, canonicalUrl?, title, selection, markdown, author?} (PageCapture), or -32004 when the pane has no page. There is no method that returns cookies, storage or headers, or that runs script.
  • browser.automate:<origin> names one website exactly: http or https, a host, an optional port, nothing after it. Burrow normalises it (lower case, xn-- for international names, default port dropped: HTTPS://Social.Example.com:443/ is https://social.example.com) and compares origins exactly, so no wildcards, parent domains or other ports. The consent sheet reads “fill in and, when you confirm, click Post on social.example.com, signed in as you”.

A macro is a declarative recipe that Burrow runs in the pane, the one the user signs in to by hand. The extension never sees that page:

capabilities = ["items.read", "browser.automate:https://social.example.com"]

[[sites]]
id = "social"
title = "Social Notes"
origin = "https://social.example.com"        # must be declared above
home = "https://social.example.com/notes"     # where to sign in; on origin
signed-out = "a[href*='sign-in']"            # optional: matches = signed out
content-blocking = false                     # optional: shield off during a run only
min-interval = "60s"                         # optional: the shortest gap between runs

[[macros]]
id = "cross-post-note"
title = "Cross-post to Social Notes…"
site = "social"
when = "published"                 # the default: a published post is open
template = "{{excerpt}}\n\n{{permalink}}"   # the default; {{title}} too
tested = "unverified"              # free text: when the selectors were last checked
steps = [
  { do = "open", url = "https://social.example.com/notes" },
  { do = "waitFor", selector = "div[contenteditable='true']", timeout = "20s" },
  { do = "focus",  selector = "div[contenteditable='true']" },
  { do = "insert", selector = "div[contenteditable='true']" },
  { do = "submit", selector = "button", text = "Post" },
  { do = "waitFor", selector = "div[contenteditable='true']", empty = true, timeout = "15s" },
  { do = "done", text = "Posted to Social Notes" },
]

Steps: open {url}, waitFor {selector, text?, empty?, absent?, timeout?}, assert {selector, text?}, focus {selector}, click {selector, text?}, insert {selector}, submit {selector, text?}, done {text}. A selector is a querySelectorAll selector; a list (a, b) is tried part by part, the first part with a match winning (so [role='dialog'] [contenteditable], [contenteditable] prefers the one in a dialog). Of the matches, the first visible one. With text, only matches whose innerText equals it (whitespace collapsed, curly quotes folded), the innermost of nested ones (a card’s label, not the card); failing that, one whose placeholder or aria-label equals it. click and submit wait for the element to be enabled, and send pointer and mouse down/up before the click; waitFor absent also counts a hidden element as gone. Durations are a whole number and ms, s or m. A step waits 10 s unless it says otherwise; open waits up to 30 s for a navigation to finish (whatever URL it ends on).

When a step’s element isn’t found, macro.log gets, under the error line, an outline of what the page does have: its path (no query), the title’s length, and up to 25 text boxes, dialogs and buttons (tag, id, classes, role, aria-label, placeholder, data-testid, name, visible, and a button’s label cut to 30 characters), at most 3 KB, never other page text, a value or the post’s text. With BLYGGER_MACRO_TRACE=1 it gets one after every step.

The manifest is refused unless: every site’s origin is declared as browser.automate:, and its home is on it; min-interval parses; each macro names one of the manifest’s sites and its template uses only {{title}}, {{excerpt}} and {{permalink}}; the first step is open and every open URL is on one of the sites; there is exactly one insert, no submit before it and at least one after it; nothing clicks after insert, and nothing opens between it and the first submit; done is the last step and only there; timeouts are more than 0 and at most 30 s; at most 64 steps. Errors name the step: macro "cross-post-note" step 2 (waitFor): timeout "45s" is over the 30s limit. Types and rules: crates/blyg-ext/src/recipe.rs.

Before a run, Burrow expands the template (one pass; CRLF becomes LF) and, if the extension implements it, calls extension/macro.prepare {macro, item, permalink, text} (10 s) → {text, note?}; method-not-found means the template’s text stands. The user sees and can edit the text, Burrow runs the steps up to the first submit, shows what the composer now holds, and runs submit only when the user clicks Post. Only granted macros of running extensions are offered (Host::macros), as ⇧⌘P rows titled “ ↗ <site title="">”, when a published post is open. The template’s <code>{{excerpt}}</code> is the post’s opening paragraph (not a heading, quote or embed), cut at 280 characters on a word boundary. An error from <code>macro.prepare</code> is a toast, and nothing runs.</site>

While a macro runs, burrow/browser.page answers -32004 and burrow/browser.open is refused (-32003): an extension never reads or moves the page a macro fills in. browser.open shows the pane, full width, at the URL.

The bundled cross-post extension (crates/blyg-ext-crosspost, run as blygger +ext cross-post) is the worked example: one site, one macro for Substack Notes (tested = "2026-10-09"), and a macro.prepare that fits the opening paragraph and the link into max-chars (280) characters, counted as grapheme clusters.

Reading slots

Two contributions put an extension on each reading entry, in the stream and in the reader. They mirror the web Studio’s extension slots (blygger-studio src/ui/extension-api.ts) and take their names:

capabilities = ["reading.read"]    # both slots need it

entry-byline = true                # entryByline: a marker on each byline

[[entry-actions]]                  # entryActions: rows in an entry's ⋯ sheet
id = "inspect"
title = "inspect"
detail = "ids, versions, references, JSON"   # optional
icon = "{ }"                                 # optional, a short glyph

The manifest is refused if it declares either without reading.read, and Burrow only asks extensions that were granted it (Host::entry_slots). The consent sheet’s words for reading.read say so: “read the posts held from your subscriptions, and add to how each one shows while you read”.

  • extension/entry.byline {entry} (2 s) → {text, tip?} or null. Burrow asks the first time an entry is drawn, off the main thread, one request after another, and caches the answer by (extension, entry, version) until the extension restarts. Drawing never waits: until the answer arrives, and when it is null, empty, an error or a timeout, the byline shows nothing. The text is drawn after a “·”, on one line, cut at 40 characters; tip shows on hover.
  • extension/entry.action {action, entry, record} (5 s) → {title, description?, text?, fields?: [{label, value}], code?, language?} (EntrySheet). An entry’s actions gain a “⋯” chip when any extension adds a row; it opens a sheet listing the rows (↑/↓, 1–9, ⏎). Choosing one sends the request, and Burrow draws the answer natively, in that order: nothing in it is markup. code is monospaced, with a Copy button (⌘C; “Copy JSON” when language is json). An error is shown in the sheet.

entry (ReadingEntry) is the entry as Burrow holds it: {subscriptionId, remoteId, origin, kind, state, version, title, contentHtml, contentMd, contentHash?}. contentHtml is the published HTML ("" for an entry held only as Markdown), and contentHash is content_hash(contentMd). record is Burrow’s stored reading row as JSON (snake_case, bodies included: blyg_ext::reading::record_of). Both methods are optional parts of protocol 1: an extension that doesn’t declare the slots is never asked, and method-not-found on a byline is “nothing”. Types: crates/blyg-ext/src/reading.rs.

Two bundled extensions use them, both off until the config names them (extension = reading-time, extension = inspect) and the user allows reading.read. They are ports of blygger-studio 0.39.0’s extensions of the same names, so the two clients agree:

  • reading-time (crates/blyg-ext-reading-time): “· 4 min” on each byline, “920 words” on hover. Words are runs of letters and digits ([\p{L}\p{N}][\p{L}\p{N}'’-]*) in the HTML’s text (scripts, styles, tags and entities become spaces); Han, kana and Hangul count one by one. Minutes are words ÷ 230 + those characters ÷ 500, shown as “< 1 min” under one and rounded half up otherwise; nothing to read shows nothing. An entry held only as Markdown is counted from the HTML blyg-render makes of it.
  • inspect (crates/blyg-ext-inspect): an “inspect” row. Its sheet summarises id, kind, version, state, content hash, stub of, forked from and the number of transclusions, then shows the record’s JSON (two-space indent) with content_md and content_html elided to “‹N characters, not shown›” (N in UTF-16 units, as the Studio counts) and *_json strings parsed. Nothing is fetched.

Wire format

JSON-RPC 2.0, one JSON object per line (LF or CRLF), both directions on the one channel. Requests carry numeric ids; each side numbers its own. Fields are camelCase, and unknown fields are ignored on both sides. Types: crates/blyg-ext/src/protocol.rs.

Host → extension, requests (timeouts): initialize (5 s) → {protocolVersion: 1, name, version}; extension/command {id, context} (60 s) → {toast?, open?}; extension/library.list {library?, path?}, library.read {id} → {id, title, markdown, body, hash}, library.write {id?, title?, folder?, markdown, baseHash?} → {id, hash} (5 s each); library.search {query, limit} and source.search (2 s); shutdown (2 s, then the process is killed).

Host → extension, notifications: burrow/itemPublished, burrow/itemSaved, burrow/itemCreated (each behind its hook), burrow/settingsChanged.

Extension → host: burrow/listItems, getItem, searchItems, createDraft, saveItem {id, contentMd, baseHash?}, listReading, openItem, toast, requestCapability {capability} (asks the user once per capability per session; only capabilities the manifest declares), browser.page (browser.capture), browser.open {url} (browser.automate: for the URL’s origin) → {url}.

Host → extension, also: extension/macro.prepare (10 s, optional; see Browser above), extension/entry.byline (2 s) and extension/entry.action (5 s) (optional; see Reading slots above).

Errors: -32000 timeout, -32001 permission denied (data.capability), -32002 stale (data.currentHash: the item or document changed since the hash you sent), -32003 refused, -32004 no page in the browser pane.

Hashes are "sha256:" + hex(SHA-256(text as UTF-8)), the blyg’s content_hash. A write that names a baseHash lands only if the current text still has it; nothing is ever merged or overwritten blindly.

Lifecycle

Burrow starts enabled extensions shortly after its window opens. A crash restarts the extension after 1 s, 5 s, then 30 s; the third failure in ten minutes stops it until Reload Config. Three timed-out requests in a row restart it. Its stderr goes to <data dir>/extensions/<name>/stderr.log (rotated at 1 MB), and initialize hands it that folder as its own storageDir.

markdown-notes

Bundled, opt-in (extension = markdown-notes), and independent of any blyg: it reads and writes folders of Markdown notes (“vaults”) and needs only fs:<folder> for each one and ui. It never adds frontmatter, never links notes to posts, and never deletes. An edit is refused if the file changed on disk since it was read; a new note never replaces an existing file. Settings: vault (the first folder), vault-<label> (one more folder each, e.g. vault-work=~/Work/Vault), folder (where new notes go inside each), poll-ms. Each vault is its own library (notes, notes.<label>). There’s no default folder: with no vault it asks only for ui and has no library.