The owner API lives at /api, including installations with a mounted Studio.
The public Blygger protocol remains separate.
Sign into Studio before calling the API from a browser on the same origin.
Server clients must send the owner session cookie.
OAuth and cross-origin clients belong to the next compatibility phase.
Shared Zod definitions validate JSON bodies, path parameters, and query parameters. They also define resource responses and generate the OpenAPI 3.1 contract. Resource projectors validate their output with these schemas. Contract tests validate successful responses against the same definitions. Hey API generates the SDK directly from the contract, without changes to generated code.
All paths below start with /api.
Consult openapi.json for every field and response.
| Resource | Read | Write |
|---|---|---|
| Items | GET /items, GET /items/{id} |
POST /items, PATCH /items/{id}, DELETE /items/{id} |
| Versions | GET /items/{id}/versions/{v} |
PUT /items/{id}/versions/{version}/pin |
| Subscriptions | GET /subscriptions, GET /subscriptions/{id} |
POST /subscriptions, PATCH /subscriptions/{id}, DELETE /subscriptions/{id} |
| Hoppers | GET /hoppers, GET /hoppers/{id} |
POST /hoppers, PATCH /hoppers/{id}, DELETE /hoppers/{id} |
| Hopper membership | Included in hopper detail | PUT or DELETE /hoppers/{id}/items/{sub}/{remoteId} |
| Signals | GET /signals |
PUT or DELETE /signals/{sub}/{remoteId} |
| Mentions | GET /mentions?direction=inbound or outbound |
PATCH /mentions/{id} with hidden |
| Settings | GET /settings |
PATCH /settings |
| Media | Included in item detail | POST /media with a multipart file (inline=true when the client places it in the text), DELETE /media/{id} (detaches; deletes the file only when no published version shows it) |
| Imported items | GET /imports/{sub}/{id}, GET /imports/{sub}/{id}/history, GET /imports/{sub}/{id}/versions/{v} (history and public versions, read from the origin) |
The subscription importer manages these items |
| Reading | GET /reading, GET /reading/imported (imported items only, cursor-paged; see below) |
Read only |
| Read state | readVersion on each imported Reading entry |
PUT/DELETE /reading/{sub}/{remoteId}/read, POST /reading/read, POST /reading/unread |
| Change revisions | GET /changes |
Maintained by database triggers |
| Quote freshness | GET /freshness, GET /items/{id}/freshness |
POST /items/{id}/refresh |
Publication, withdrawal, restoration, and generation remain explicit operations:
POST /items/{id}/publishandPOST /items/{id}/withdrawaccept an optional publication note.POST /items/{id}/restorecopies a stored version into the working copy. It does not publish.POST /items/{id}/generategenerates one TK scope.POST /subscriptions/{id}/resyncrefreshes a Blyg subscription.POST /items/{id}/note-draftdrafts a changelog note from the change between the published version and the working copy. It never publishes. When the published version is unpinned, the note must not reproduce wording that only that version had; a draft that does returns 422. Sendnote_generated: truewithPOST /items/{id}/publishonly when the note is that draft, unedited: it becomeschangelog[].generatedon the wire.POST /items/{id}/refreshrepublishes a thread to re-bake quotes whose source has a newer version. See below.
Preview and search use /preview and /search.
GET /changes returns { epoch, domains }, with revision counters for items,
Reading, subscriptions, hoppers, signals, settings and the public feed. It uses
the same current owner/delegated read authorization as other private reads.
Clients can skip unchanged data refreshes, but must capture their target before
loading and acknowledge it only after successful installation. These are change
signals, not row-delta cursors or permission to cache authorization decisions.
The old fork, stub, pause, resume, response-policy, and pin routes no longer exist.
Studio calls the canonical routes through the generated SDK.
One creation route accepts three recipes.
These examples use the generated SDK with an authenticated client:
const blank = await unwrap(BlyggerApi.createItem({
client,
body: { mode: "blank", kind: "fragment", content_md: "A new draft" },
}));
const fork = await unwrap(BlyggerApi.createItem({
client,
body: {
mode: "fork",
source: { origin: "https://example.org/blyg/", id: "source-id", version: 1 },
},
}));
const response = await unwrap(BlyggerApi.createItem({
client,
body: {
mode: "response",
source: { subscription_id: "subscription-id", remote_id: "source-id" },
selection: "An optional passage from the source",
},
}));Blank creation may omit mode or the entire body.
A fork starts from a pinned version and permanently records its source.
A response creates a thread with a citation to the imported source.
Creation returns the item, status 201, and a Location header.
Hopper and media creation also return 201.
Subscription creation returns 200 when the owner must accept the proposed subscription, then 201 when it creates the subscription.
PATCH changes only supplied fields.
Omitted fields retain their values.
Null clears only fields that explicitly allow null, such as an item's stub_of.
Unknown JSON fields fail validation rather than disappearing silently.
Preferences use booleans, not database integers or strings such as "on".
const item = await unwrap(BlyggerApi.updateItem({
client,
path: { id: blank.id },
body: { content_md: "Revised text", responses: "hide" },
}));Item edits accept content_md, kind, stub_of, and responses.
The response policy is default, show, or hide.
Kind becomes fixed after the first publication.
Fork lineage and generation provenance cannot change through PATCH.
All requested changes must pass schema and domain validation before a write. Item and hopper writes guard the state that their domain rules used. If that state changes during the request, the API returns 409 without applying the patch. Reload the resource before deciding whether to repeat the edit. These guards do not provide general revision control for concurrent text edits.
Pinning uses PUT because repeating the same pin has the same effect. A pin is permanent, including after withdrawal. Deletion discards an unpublished draft. Withdraw a published item instead.
Collection reads return { items, total, offset, limit }.
Use offset and limit to read another slice.
Reading also returns source counts and the selected source.
Mentions also return the requested direction.
const reading = await unwrap(BlyggerApi.listReading({
client,
query: { offset: 0, limit: 25 },
}));
for (const item of reading.items) console.log(item.key);Reading defaults to 25 entries and accepts at most 50 per request. Other paginated reads accept at most 100. Search defaults to 20 results. Collection queries limit the rows they return before resource conversion. Totals, sorting, and large offsets can still scan rows in D1. An offset past the end returns an empty array. Invalid pagination returns 400.
Poll /reading to read the current D1 state.
Polling does not fetch remote feeds. The existing subscription cron still does that.
The planned SPA polls visible reading data every 15 seconds and pauses in hidden tabs.
The API sanitizes imported HTML before it returns reading bodies.
Item detail includes the item fields, authored_kind, media, versions, and the published version.
Version resources include their kind, including threads with no transclusions.
Item and version references use objects, and flags use booleans.
Imported protocol metadata retains its existing serialized JSON fields.
Subscription resources omit internal HTTP cache fields.
Hopper detail includes total and source_count.
Use ?preview=true for an index preview of three memberships and bodies.
The default hopper detail still includes all memberships and bodies.
GET /reading is the Studio's timeline: own and imported entries together,
sanitized HTML only, paged by offset. A client that syncs what the owner reads
and writes from it (Blygger Desktop) needs the other shape, and
GET /reading/imported (listImportedReading, owner:read) is it:
- Every imported item, own items excluded, newest observed first. Each row is
an imported entry as
GET /readingreturns it (subscriptionId,remoteId,kind,withdrawn,l0,version,readVersion,updated,observedAt,contentHtml(sanitized),sourceUrl,pinnedVersionRetained, …) plusorigin,created,contentMd,author({ name, url }or null),page(as the origin declared it),thumb(the owner's signal: 1, −1 or null),hoppers(ids) and the origin'stransclusions,stubOfandforkedFrom. - The last three are the origin's own JSON, verbatim and unvalidated: remote data, unknown members included. Null means none held, or stored data that is malformed or of the wrong kind.
readVersionis the read-state join below: null when unread or cleared.- Paged by an opaque cursor over
(observed_at, subscription_id, remote_id). Passnextback ascursor;nextis null on the last page. One poll run stamps oneobserved_aton everything it imports, so ties are common and the cursor resumes inside one. Rows observed after a walk began sort ahead of its cursor, so a poll between two requests neither shifts nor repeats the walk; start a new walk from the top to collect them. A cursor this server did not issue is 400. limitdefaults to 50 and accepts at most 100; outside 1–100 is 400.
let cursor: string | undefined;
do {
const page = await unwrap(BlyggerApi.listImportedReading({ client, query: { cursor, limit: 100 } }));
for (const entry of page.items) console.log(entry.subscriptionId, entry.remoteId, entry.readVersion);
cursor = page.next ?? undefined;
} while (cursor);A client that keeps "read" for imported items can store it here, so a post read
on one device reads as read on the owner's others. Each reading row holds one
number: the highest version the owner has read. It is Studio-private. No public
page, feed, item document, blyg.json or export reads it.
PUT /reading/{sub}/{remoteId}/readwith{ version }stores the larger of the held and requested values and returns{ ok, stored, read_version }. A replay or a stale client never lowers it.POST /reading/readwith{ items: [{ sub, remote_id, version }] }does the same for up to 500 rows in one transaction and returns{ ok, received }. Any malformed entry fails the whole request with 400, and nothing is written. The batch is one request against the write budget, however many rows it marks.- A row this server does not hold, by subscription or by item, is acknowledged
with 200 and
stored: false, never 404. Clients may read a 404 from these routes as "this server keeps no read state". GET /readingreturnsread_state: true, and each imported entry carriesreadVersion: an integer, or null when unread. A client can rely on the flag rather than probing the write routes. Each imported entry also carriesversion, the version held here:readVersionbelow it means a newer version arrived after the owner read it, and it is the value to send when marking the entry read.
DELETE /reading/{sub}/{remoteId}/readclears the row and always returns{ ok: true, stored: false, read_version: null }. It is idempotent, and an unknown subscription or item is 200 as well, never 404.POST /reading/unreadwith{ items: [{ sub, remote_id }] }clears up to 500 rows in one transaction and returns{ ok, received }, validated whole likePOST /reading/read.GET /readingreturnsread_state_clear: truewhen the server supports both.
A clear leaves a tombstone: the server records unread_at, its own time of
the clear, and readVersion reads null. Both read writes accept an optional
read_at (ISO-8601 with Z or an offset; on PUT beside version, in the
batch on each item), the time the client read the post:
- A read whose
read_atis earlier than the row's latest clear is ignored.PUTanswersstored: falsewith whatever is held; the batch skips that row. Nothing changes, so no revision advances. - A read with a later
read_at, or the same instant, applies. - A read with no
read_atapplies, as before clearing existed. Sendread_atfrom any client that queues reads offline, or a queued read can undo a later "mark unread" from another device. The Studio acts online and sends none, so its reads always apply.
A clear resets the row, so the next accepted read stores its own version rather than the larger of it and the value held before the clear. From there reads are monotonic again. The tombstone stays after a read applies, so a second stale read is still refused.
read_at is compared as an instant (the server normalizes it to UTC) against
the server's clock. A client whose clock runs fast can make a read look later
than a clear that followed it; one that runs slow can have a fresh read
ignored. Clients that cannot trust their clock may omit read_at and accept
last-write-wins.
Version is an integer from 1 (items start there) to 2^32 − 1. All four writes need reading:state,
a scope of their own so a sync-only reader app need not hold owner:manage. Read state goes with its imported item or subscription when either
is deleted, and a change, including a clear, advances the reading revision in
GET /changes.
A thread bakes each quote at the version it held when it published. A quote is stale when a newer version of the item it quoted exists, either here (our own item was republished, or the importer already pulled the source's new version) or only at the source's origin. Only the direct relation counts: a quote of a thread is not stale because that thread's own quotes moved.
GET /freshnesslists every published thread with a stale quote or one that would block a republish. It reads only D1.GET /items/{id}/freshnessreports each quote in the published version:baked(the version in the thread),held(what a republish would bake now),live(what the origin serves, from{origin}items/{id}.json), and astatusofcurrent,refreshable,behind,passage-missing,unresolvableorretained. It probes origins unless?probe=false. A failed probe reads aslive: null, never as stale.POST /items/{id}/refreshwith an optionalnotere-imports the sources that are behind, then republishes. A refresh is a publish: it creates a new version, a feed entry, and mentions for the references whose target version changed. The words do not change, socontent_hashstays the same. It returns 409 when the working copy holds anything other than the published words, when a quote would make the republish fail, or when nothing is stale.
Errors return JSON with an error string.
Validation errors also include issue paths and messages.
Domain errors may include details such as unresolved references.
Unknown routes return 404. A known route with the wrong method returns 405 and an Allow header.
Generated methods expose data, errors, and the HTTP response.
unwrap() throws for a failed response.
The SDK does not automatically retry writes.
Run npm run sdk:generate after changing the shared contract.
Commit openapi.json and sdk/generated/ with the server changes.
CI validates generation drift, Worker behavior, browser flows, and packaged release artifacts.
See SDK usage for browser and server transports.