Server requirements
Burrow talks to a blyg’s owner API. Since studio 0.9, the upstream reference Worker
(https://github.com/blygger/blygger-studio) documents that API as an OpenAPI contract
(openapi.json; route guide in its docs/api.md). Burrow 0.13 and later need
blygger-studio 0.47 or later; it is tested end to end against studio 0.47.0 (and 0.46.1, below the floor).
Every route the app shares with upstream is checked against openapi.json in its tests
(crates/blyg-core/tests/fixtures/).
Why 0.47. Burrow’s [[ picker writes labelled links, [[id | label]], and a studio
older than 0.47 doesn’t understand the label: published there, the link would come out
as literal text or broken. On an older blyg (studio 0.9 or later) everything syncs as
usual except posts with labelled links, which wait until the blyg is updated. It says
so once, keeps a status-bar notice with an Update your blyg link, and shows the same
in the About window. See Labelled links on an older blyg
and Updating an older server.
A stock blygger-studio is fully supported. Nothing in this app needs a patched server: writing, publishing, versions, pins, subscriptions, mentions, settings, the reading list, read state across Macs, lineage and AI disclosure all run on upstream’s own routes.
How Burrow checks the version
When it connects, and once a session after that (then every minute, or on a manual sync, while the blyg is too old):
GET {blyg-url}/blyg.json, the public manifest. A studio’s saysgenerator: "blygger-studio/<version>"; Burrow compares that version with 0.47.0. It is the only manifest key that tells 0.46 from 0.47: both sendblyg: "0.4". Upstream says the same: “Your running version is thegeneratorinblyg.json”.- Another implementation’s generator, a version Burrow can’t read, or no manifest:
one
GET /api/search?limit=1. Since 0.47 each hit carries alabel(the text the picker writes); a hit without one is an older studio. - No hit to look at (an empty blyg), or no search route: Burrow can’t tell, and doesn’t lock the blyg out on a guess. It is treated as new enough.
A failed check (offline, a 5xx) decides nothing: changes wait for a verdict.
Labelled links on an older blyg
Below 0.47 a post waits when its text holds a labelled link or quote, [[id | label]]
or ![[id | label]], with a label that isn’t empty and outside code (inline, fenced or
indented code is text to the studio too). Burrow reads that with the studio’s own
grammar (blyg_core::floor::has_labels).
- What waits: that post’s edits (its creation and saves stay in the outbox) and its
publish. The list shows it with a tooltip on its pill, “Waiting: labelled links need
studio 0.47”. Publishing it opens a sheet: “This post has labelled links, which your
blyg can’t show until it’s updated to studio 0.47.” ⏎ writes them as bare
[[id]]/![[id]](which every studio understands; code is left alone) and publishes. esc leaves the post as it is. - What goes: every other post, as usual. Withdraw and pin of any post go too: they don’t send the text. So do restore, image uploads (into a post that hasn’t reached the blyg yet, unattached), forks (the blyg copies the source itself; the fork then waits like any post if its text has labels), subscriptions, read marks and settings.
- Generated text: a
[TK]scope’s output is part of the post and the studio renders it like the rest, so a labelled link there holds the post too. So does a labelled source in a[TK]instruction. - Checked on every flush, from the text as it is now: removing the labels releases the post, and the next sync sends it. When the blyg is updated, Burrow notices within a minute (or at once on a manual sync) and sends what waited.
- New links: on such a blyg the
[[picker and ⌘K write the bare form, so nothing new waits.
Signing in
- Sign in with the browser (studio 0.28+, the default): Burrow registers itself as an OAuth client of your blyg, opens the studio’s consent page, and keeps the grant (a one-hour access token, renewed with a rotating refresh token until the grant ends, at most 30 days after your studio sign-in) in your Keychain. Signing out revokes it.
- Or make an API token in Studio → More → Client access (REST API, every permission: read, draft,
publish, manage, and on studio 0.39+
reading:state; it lasts 30 days) and paste it. A token missing a permission gets a 403 that names it. - On an older studio, or as a fallback, the studio password: Burrow signs in at
{blyg-url}/studio/loginlike the browser does, keeps the session cookie in memory only (the password is in your Keychain), and signs in again when the session ends.
A stock studio has no owner token in its environment: that was the old Worker fork’s
extension 1 (BLYG_OWNER_TOKEN). When a blyg stops taking a saved credential (a revoked
or expired token, an ended sign-in, or such an owner token after the blyg moved to a
stock studio), every call answers 401. Burrow then stops syncing (no retries), keeps
every queued change, and opens the sign-in sheet once, browser first. Signing in again
sends what waited.
What each studio version brings
| Feature | Stock studio |
|---|---|
| Reading list | 0.40+: GET /api/reading/imported (listImportedReading): every imported post with its Markdown, author, thumb, hoppers, read state, transclusions and lineage, keyset-paged by an opaque cursor, 100 a page. 0.9–0.39: built from GET /api/reading (which posts there are) plus GET /api/imports/{sub}/{id} for each new or changed one, with thumbs and hoppers from /api/signals and each hopper; slower. |
| Read state across Macs | 0.39+ (reading:state scope), unread too; see Read-state sync. Older: stays on each Mac. |
| Who a post replies to, or forks | 0.18+ sends it with each post (on 0.40+ with its cited). Older: Burrow fetches it from the author’s blyg, a few posts per sync. |
| AI disclosure for text generated in Burrow | 0.28+: each TK scope’s provenance goes with the text (PATCH /api/items/:id {content_md, provenance}, and on POST /api/items); on 0.41+ a remote source carries its origin. Older: before publishing such text Burrow says it will go out without the disclosure, and lets you cancel. Generation on the blyg’s own server records its disclosure itself. |
| Removing an image you pasted, then deleted | 0.26+. Older: it stays on the server. |
| Syncing every 15 s on change counters | 0.32+ (GET /api/changes). Older: read whole every 60 s. |
Below 0.47 the “Older” columns still apply to what syncs. Burrow 0.12 and earlier also showed a sheet listing a pre-0.40 studio’s limits; 0.13’s floor replaced it with the update notice.
Shapes worth knowing (studio 0.40+)
GET /api/reading/imported?limit=1..100&cursor=…→{items, next, limit}. Rows are camelCase (subscriptionId,remoteId,withdrawn,l0,version,readVersion,observedAt,contentMd,contentHtml(sanitized),author{name, url},page,sourceUrl,thumb,hoppers,transclusions,stubOf,forkedFrom,generated), newestobservedAtfirst, with no drops or repeats across pages. Alimitout of range or a cursor the server didn’t issue is400;beforeis not accepted. There are no page-level flags: read state is advertised onGET /api/reading(read_state,read_state_clear), and each row’s lineage is the origin’s own (null when none).GET /api/settingscarries many keys Burrow doesn’t use (custom_theme*,font,feed_order,front_page,posts_path,links_*,setup,defaults,feed_orders, …). Burrow ignores them, and itsPATCHsends only the keys it owns.- Mentions may have
relation: "source"(0.41: generated text drew on your post). - Hopper routes take
~ownas the subscription with your own item’s id (0.45).
Legacy: the old Worker fork’s extensions
Before upstream had all of the above, the maintainer’s Worker fork carried extensions. Burrow still speaks them where it is cheap, so a fork keeps working, but a stock studio needs none of them.
| # | Extension | Now |
|---|---|---|
| 1 | Bearer-token owner auth with the Worker secret BLYG_OWNER_TOKEN. | Superseded by studio 0.28’s OAuth and manual tokens. Such a token still goes in the “API token” field on a fork; a stock studio refuses it (401, see above). |
| 2 | Owner JSON reads. | Upstream since 0.9; retired. |
| 3 | GET /api/reading/imported?limit&before=<cursor> → {items, next, read_state, read_state_clear, lineage} with snake_case rows, at most 500 a page. | Upstream since 0.40 under the same path with its own shape (above). Burrow tells them apart on the walk’s first page (which sends only limit=100, taken by both): a page with the fork’s read_state flag is the fork’s, and later pages use before; otherwise cursor. |
| 4 | PUT /api/items/:id/tk-provenance. | Retired by studio 0.28’s provenance on PATCH /api/items/:id; used only when a server refuses that field. |
| 5 | Read-state sync. | Upstream since 0.39 (below), same write routes and bodies. |
Upstream’s scoped tokens may not reach a fork’s own extension routes.
Read-state sync
Upstream since studio 0.39, read and unread (clearing read state leaves a tombstone against stale reads); before that it was the fork’s extension 5, with the same write routes and bodies. The server keeps, per reading row, the highest version the owner has read. It never lowers that value on a read, so every read write is idempotent and replay-safe; only a clear resets it.
Capability.
- Upstream:
GET /api/readinganswers with top-levelread_state: true(andread_state_clear: true), and each imported entry carriesreadVersion(an integer, ornullwhen unread) andversion, the version held. On 0.40+ the samereadVersionis on everyGET /api/reading/importedrow; Burrow reads the flags fromGET /api/reading?limit=1once a session. - The fork:
GET /api/reading/importedanswers{items, next, read_state: true}, and each item carriesread_version.
The app reads the read version only when read_state is true.
Writes (owner auth, like every /api call). Scope: these four writes
(markRead, markUnread, markReadBatch, markUnreadBatch) need the bearer scope
reading:state; owner:manage alone is refused with 403 insufficient_scope. The
app spells the name in one place, READ_STATE_SCOPE in
crates/blyg-core/src/api/oauth.rs. The browser sign-in asks for it when the studio
lists it in scopes_supported (an older studio would refuse the whole request for a
scope it doesn’t know). A client registered before (Burrow 0.10) is reused; the studio
grants it the new scope.
| Call | Body | Success | Errors |
|---|---|---|---|
PUT /api/reading/:sub/:remoteId/read | {version: integer ≥ 1, read_at?} | 200 {ok: true, stored: bool, read_version: n | null}. Stores max(existing, version). For an unknown subscription or item, or a read older than the row’s last clear: stored: false, nothing written. | 400 bad body, 401, 403 |
POST /api/reading/read | {items: [{sub, remote_id, version, read_at?}]}, at most 500 | 200 {ok: true, received: n}. The same max-merge per row. Unknown or stale rows are skipped. | 400 for any malformed entry or more than 500 entries (nothing is written), 401, 403 |
DELETE /api/reading/:sub/:remoteId/read | none | 200 {ok: true, stored: false, read_version: null}. Clears the row and records unread_at (the server’s time). Idempotent. | 401, 403 |
POST /api/reading/unread | {items: [{sub, remote_id}]}, at most 500 | 200 {ok: true, received: n}, all or nothing. | 400, 401, 403 |
read_at (ISO-8601 with an offset, e.g. 2026-10-06T12:00:00Z) is sent only to a
server that advertises read_state_clear: a read earlier than the row’s unread_at is
ignored, so a queued read can’t undo a later “mark unread” from another device. Upstream
bodies are strict, so the app never sends read_at to a server that doesn’t
advertise the flag. Upstream’s 400 body is {error, issues: [{path, message}]}.
Unknown rows are acknowledged, never 404, because the app reads a 404 from these
endpoints as “this server doesn’t keep read state”.
What the app does:
- Marking posts read or unread is instant and local: one post, a selection (⌘-click, ⇧-click, ⌘A in the Reader’s list; r / u, or the row’s menu), or the open post. On a server with the capability it also queues one op per reading row (duplicates of the same post, too) in the outbox, so marks survive going offline and restarting. A row has at most one waiting op, and the last action wins: an unread replaces a waiting read and the other way round; repeated reads coalesce to the highest version.
- Several waiting ops go as one
POST /api/reading/reador/unread(500 per call), so marking a whole list is one write against the owner budget; a single mark is onePUTorDELETE. - A read carries
read_at: when it was marked on this Mac, kept with the row until the server confirms it (reads from before this was tracked count as the epoch, so any clear wins over them). APUTthe server answersstored: falsebecause of a later clear takes the server’s state: the row is unread here too. - A pull sets the local value to
max(local, server), except that a row marked unread here ignores server read versions at or below the one it was read at (its floor). On a server withread_state_clear, a row this Mac had confirmed but the server now reports lower or unread was cleared on another device, and it becomes unread here; a row read here that the server hasn’t got is queued again (with itsread_at). - Unread without
read_state_clear(the fork’s extension 5) stays on this Mac: no unread op is sent, and the floor keeps the server’s old read version from re-marking the row read on every pull. Reading the post again ends it. If the server later gainsread_state_clear, those local unreads are sent then. - The first time a database sees the capability, the app batch-uploads every read version
it holds (500 per call) and records that in its
metatable. - A
403on a write (a sign-in or token withoutreading:state, such as one made before studio 0.39) holds read state for the rest of the session: the ops stay queued (nothing is dropped), new marks keep queueing, and the rest of the outbox syncs as usual. The app says so once, with the server’s reason: sign in with the browser again (or make a new token withreading:state). It doesn’t retry until the next sign-in or launch, which sends what waited. - Without the capability, read state stays on this Mac. The app makes no requests to
these endpoints and shows no errors. A
404from them turns sync off and drops anything queued.
Privacy. Read state is owner-only. No public page, feed, blyg.json, item document
or export reads it. It holds only numbers (and the time of a clear) keyed by subscription
and item, never text. A change, including a clear, advances the reading revision of
GET /api/changes, so a mark on one device shows on another’s next pull.
Also used when present
POST /api/mediaanswering200 {…, duplicate: true}for identical bytes on the same item. Not upstream (upstream issue #7).DELETE /api/media/:id(removes an upload) is upstream since studio 0.16, answering{ok, outcome: "deleted" | "detached"}. On an older server it 404s, and an abandoned paste leaves its file there.
What the app uses from upstream
These follow openapi.json exactly. The differences from the pre-0.9 API that
mattered here:
- Collections answer
{items, total, offset, limit}; the app pages withoffset/limit(100 a page). - Items are created with
POST /api/items {mode: "blank" | "fork", …}, which answers201with the item.POST /api/forkis gone. - Edits are
PATCH /api/items/:id {content_md?, kind?, responses?}. A draft’s kind changes in place until its first publish, so changing it no longer makes a new draft. Unknown fields are a 400. - Pins are
PUT /api/items/:id/versions/:version/pin, with no body. - An item’s
responsesis"default" | "show" | "hide". The app works out whether a page shows responses from that and settings’show_responses_default. - Items carry no permalink. The app uses
{blyg-url}/f/{id}(t/for threads);blyg-urlincludes any mount path. - Pause and resume are
PATCH /api/subscriptions/:id {paused}. Settings, mention hiding and subscription edits arePATCHtoo. - Errors are
{error, errors?, issues?}. The app showsissues(validation failures) as details.
Degrading gracefully
- A saved credential the blyg refuses (
401): sync stops (no retries), queued changes stay queued, and the sign-in sheet opens once, browser first. - Older than studio 0.47: Burrow says “Your blyg needs updating” once (on connecting, or when Burrow starts), with a status-bar notice and an Update your blyg link while it lasts, and the same in the About window. Posts with labelled links wait in the outbox; everything else syncs. See Labelled links on an older blyg.
- A server older than studio 0.9: the connect sheet refuses it, and a blyg already
connected isn’t read from either: on such a server a new route’s 404 would read as
“deleted” and duplicate your posts. Burrow recognises it by
GET /api/itemsanswering withouttotal(or 404). - Studio older than 0.40: the reading list is built from upstream’s older routes (see above).
- Older than studio 0.28: before publishing text that was generated in the app, the app warns that it will go out without AI disclosure, and lets you cancel.
- Studio 0.32+ (
GET /api/changes): each sync first reads the change counters and fetches only the collections whose domains moved (everything, every 10 minutes), so the app syncs every 15 s instead of every 60 s. An older server is read whole every 60 s. - Work budgets (studio 0.28+): a
429keeps the change queued and retries afterRetry-After; the app says “The blyg asked Burrow to slow down”. Autosave pushes for one post are at least 3 s apart, about 20 writes a minute at most. - Older than studio 0.39: read state stays on each Mac.
Updating an older server
Follow upstream’s Releases and upgrading: read the changelog entries between your version and 0.47 (or later), each with its Migrations: line. Coming from 0.8 or earlier, start with Upgrading to Blygger Studio 0.11, which covers source and Worker-archive installs, the database migration, and path-mounted blygs. A fork that carries the legacy extensions doesn’t need them: a stock studio is fully supported. Once the blyg is updated, Burrow notices within a minute (or at once on a manual sync) and sends what waited; there’s nothing else to do in the app.
Optional: server-side generation with Gemma 4
A Worker with a Workers AI binding ("ai": {"binding": "AI"}) can run TK generation on
@cf/google/gemma-4-26b-a4b-it with no API key. The app’s “Generate on my blyg server” provider
uses whatever the Worker is configured to use.
Status
Upstream’s plan (its docs/migration.md §3) was for this app to run on the documented
API alone, with OAuth in place of extension 1. As of studio 0.40 it does: reading rows
(#11, #61), lineage in them (#12) and read state are upstream. Only the media
duplicate reply (#7) is still a fork’s.