Nostr event model¶
Ownership
This document is owned by PlebChat as of 2026-07; it originated in
VibeReader, which PlebChat's Read mode supersedes (vibereader is now
frozen reference). The kinds, schemas, and the vibereader settings
d-tag are retained verbatim for continuity with events already on
relays — an existing VibeReader identity's library appears in PlebChat
on first sync. Future protocol changes happen here.
This is the sync contract: how Read-mode state maps onto nostr events and Blossom blobs. The local data model mirrors it 1:1. Changes here are protocol changes — think before editing.
Design rules¶
- Book identity is the EPUB file's sha256 (lowercase hex). It is
simultaneously the IndexedDB key, the nostr
dtag, the Blossom blob address, and the cross-user "same book" matching key. Deterministic identity means events from two devices merge without ID reconciliation. A different file (another edition, a fixed typo) is a different book; the NIP-73 ISBN tag is the cross-edition escape hatch. - Private by default: event
contentis NIP-44-encrypted to the author's own key (the NIP-60/NIP-51 self-encryption pattern). Private events carry no cleartext tags beyondd— titles, quotes, and even which book an event belongs to stay inside the ciphertext. - Addressable replacement is the edit primitive: all custom kinds live
in the 30000–39999 addressable range, so republishing with the same
dsupersedes the old event. No NIP-09 delete choreography for updates. - Sharing = republishing the same event in plaintext (see kind 30104 below) — shared state stays editable and addressable. NIP-84 export is a compatibility bonus, not the mechanism.
- Explicit only: nothing below is published without a user action.
Kind registry¶
The original PoC drafted kinds 30001–30005; those collide hard with NIP-51 (30002 relay sets, 30003 bookmark sets, 30004/30005 curation sets), so the design moved to the unassigned 3010x block (verified against the NIPs registry: 30079–30165 has no assignments).
| Kind | Entity | d tag |
Encryption |
|---|---|---|---|
| 30101 | Book metadata | {epub-sha256} |
NIP-44 to self, or plaintext when shared |
| 30102 | Reading progress | {epub-sha256} |
NIP-44 to self |
| 30103 | App settings | vibereader |
NIP-44 to self |
| 30104 | Annotation (unified) | anno-{id} |
NIP-44 to self, or plaintext when shared |
| 30105–30106 | reserved (chat sync — undecided; transcripts are device-local for now) | ||
| 30107 | News feed subscription | sha-256 of normalized feed URL | NIP-44 to self |
| 30108 | Saved news article (read-later, metadata only) | sha-256 of article key | NIP-44 to self |
| 30109 | Feedback triage marker — admin-only; marks a kind-1060 report done/open (published by scripts/fetch-feedback.mjs AND the /admin console, identical body; see docs/proposals/feedback-reports.md) |
rumor id of the report | NIP-44 to self (admin key) |
| 30110 | Relay health snapshot — published by a daily cron on the relay host signing with a dedicated bot key (2026-project/MAC_MINI/strfry/health-snapshot.sh), rendered on /admin; see docs/proposals/relay-health-bot.md |
UTC snapshot date (YYYY-MM-DD) |
NIP-44 bot → feedback admin |
| 30111 | App settings (AI endpoint/key/model, Search credentials, News prefs — one event, whole payload; see docs/proposals/settings-sync.md) |
plebchat |
NIP-44 to self |
| 30112 | PDF in Read's Files collection (defined, not yet synced — see docs/proposals/pdf-files.md) |
{pdf-sha256} |
NIP-44 to self |
| 30113 | Agent preset — one event per user-defined preset, pushed on the editor's Save, pulled at session start, LWW by payload updatedAt; deletes tombstone + NIP-09 (see docs/proposals/agent-platform.md) |
preset-{nanoid} |
NIP-44 to self |
| 30114 | Agent skill — same contract as 30113; content carries the skill's markdown body | skill-{nanoid} |
NIP-44 to self |
Non-addressable custom kinds:
| Kind | Entity | Encryption |
|---|---|---|
| 1060 | Feedback / bug report — rumor only, never published bare; travels inside a kind-1059 gift wrap to the feedback admin pubkey (see docs/proposals/feedback-reports.md) |
NIP-59 gift wrap |
Standard kinds used as-is:
| Kind | Use |
|---|---|
| 9802 | NIP-84 highlight — optional export at share time for Highlighter/Amethyst compatibility |
| 1059 | NIP-59 gift wrap — transport for kind-1060 feedback reports (always accepted by the app relay's write policy, so non-whitelisted keys can submit). Published to FEEDBACK_RELAYS ONLY (exclusive, never the pool): wraps are signed by ephemeral keys, so a copy leaked to a public relay can never be deleted |
| 10063 | Blossom user server list (BUD-03) |
| 24242 | Blossom authorization events (BUD-11) |
Event schemas¶
Timestamps are Unix ms inside content (matching the local model);
created_at is standard nostr seconds. All content shapes are exactly the
IndexedDB record minus redundant keys.
30101 — Book metadata¶
{
"kind": 30101,
"tags": [["d", "<epub-sha256>"]],
// content, NIP-44 encrypted to self:
"content": {
"title": "…", "creator": "…",
"publisher": "…", "language": "en", "isbn": "…", "description": "…",
"fileSize": 1234567,
"blossom": { "servers": ["https://…"], "coverSha256": "…" }, // where the bytes live, if backed up
"addedAt": 1752264000000, "updatedAt": 1752264000000
}
}
Two local Book fields never appear in content: lastOpenedAt (noise) and
localOnly (the per-book sync opt-out — a local-only book and its
progress/annotations are excluded from sync push and pull entirely, so the
flag has no remote representation by construction; events pushed before the
flag was set remain on relays until the book is deleted).
Shared (public shelf): same mechanism as shared annotations (design
rule 4) — the same event, same d, republished with plaintext content
plus NIP-73 ISBN tags when known:
{
"kind": 30101,
"tags": [
["d", "<epub-sha256>"],
["i", "isbn:9780765382030"], // NIP-73, when known
["k", "isbn"]
],
"content": "{ …same JSON, unencrypted… }"
}
Semantics: this is the user's opt-in public shelf. Title, creator,
publisher, language, ISBN, description, and fileSize become public — and so
does the blossom pointer if present, which makes the book file fetchable
by anyone (public-by-hash; the deliberate "borrow from a friend's shelf"
path — the share confirm must say so). lastOpenedAt is stripped as always;
reading progress (30102) never gets a plaintext variant. Unshare =
republish encrypted, drop the tags. Browse queries this enables:
- Someone's public shelf:
{"kinds":[30101,30104],"authors":["<pk>"]}, keeping only plaintext-parseable events. - Readers of a book:
{"kinds":[30104],"#x":["<sha256>"]}(already below).
30102 — Reading progress¶
{
"kind": 30102,
"tags": [["d", "<epub-sha256>"]],
"content": { // encrypted
"cfi": "epubcfi(/6/12!/4/2/8:12)",
"percentage": 0.42,
"sectionHref": "chapter4.xhtml",
"updatedAt": 1752264000000
}
}
Merge rule: last-write-wins by content updatedAt (not relay created_at).
30103 — App settings¶
{
"kind": 30103,
"tags": [["d", "vibereader"]],
"content": { // encrypted
"reading": { "fontSize": 18, "fontFamily": "serif", "lineHeight": 1.6, "theme": "sepia" },
"updatedAt": 1752264000000
}
}
AI endpoint config (keys!) is deliberately excluded — it never leaves the device.
30104 — Annotation (the unified primitive)¶
One event per selection. Highlight-only (color, no note), note-only
(note, no color), or both. The d value is a stable random ID — never
the CFI, which can change with rendering.
Private (default):
{
"kind": 30104,
"tags": [["d", "anno-V1StGXR8_Z5jdHi6B-myT"]],
"content": { // encrypted
"book": "<epub-sha256>",
"cfiRange": "epubcfi(/6/12!/4/2,/8:5,/10:12)",
"quote": "the highlighted text",
"color": "yellow", // optional
"note": "user's thoughts", // optional
"createdAt": 1752264000000, "updatedAt": 1752264000000
}
}
Shared (user flips an annotation public): the same event — same kind,
same d — republished with plaintext content plus cleartext query tags:
{
"kind": 30104,
"tags": [
["d", "anno-V1StGXR8_Z5jdHi6B-myT"],
["x", "<epub-sha256>"], // cross-user book matching key
["i", "isbn:9780765382030"], // NIP-73, when known
["k", "isbn"]
],
"content": "{ …same JSON, unencrypted… }"
}
Because it stays addressable, a shared annotation remains editable and can be
made private again (republish encrypted, drop the tags). "All public
annotations on this book, from anyone" is one query:
{"kinds":[30104],"#x":["<sha256>"]}.
Optional NIP-84 export: at share time the user may also emit a standard
kind 9802 (immutable) so the highlight renders in Highlighter, Amethyst,
etc.: content = quote, comment tag = note, x/i tags as above, plus a
["a", "30104:<pubkey>:anno-…"] back-reference to the living annotation.
The 9802 is a derived snapshot — the 30104 stays canonical.
30107 — News feed subscription¶
One event per subscribed RSS/Atom feed (the news surface in Search mode;
see docs/proposals/news-mode.md). The d value is the sha-256 hex of the
normalized feed URL — deterministic across devices, and the URL stays inside
the ciphertext (subscriptions are private reading habits).
{
"kind": 30107,
"tags": [["d", "<sha256-of-normalized-feed-url>"]],
"content": { // encrypted
"url": "https://example.com/feed.xml",
"title": "Example Feed",
"siteUrl": "https://example.com", // optional
"description": "…", // optional
"addedAt": 1752264000000, "updatedAt": 1752264000000
}
}
30108 — Saved news article (read-later)¶
Metadata only — never article markdown (relay event-size limits); content
re-fetches on demand and caches device-locally. The d value is the sha-256
of the canonical article key: 30023:<pubkey>:<d> for nostr articles, the
normalized URL for rss/web.
{
"kind": 30108,
"tags": [["d", "<sha256-of-article-key>"]],
"content": { // encrypted
"key": "30023:<pubkey>:<d-tag>", // or the URL for rss/web
"source": "nostr", // "nostr" | "rss" | "web"
"title": "…",
"url": "https://…", // rss/web only
"nostr": { "pubkey": "…", "dTag": "…" }, // nostr only
"summary": "…", "image": "https://…", // optional
"publishedAt": 1752264000000, // optional
"savedAt": 1752264000000, "updatedAt": 1752264000000
}
}
Merge/delete semantics are identical to the Read-mode kinds: LWW by content
updatedAt, tombstone republish on delete, NIP-09 ride-along.
30112 — PDF (Read's Files collection)¶
Defined, not yet published. The shape is fixed here so it can't drift
while read/nostr/sync.ts catches up (four-modes.md Phase 7); today PDFs are
device-local and this kind never leaves the app. Deletes already write a
30112:<sha256> tombstone, so a deletion made now can't resurrect the day
sync is switched on.
Its own kind precisely so PDFs never touch 30101/30102/30104. A PDF has no
CFI, so it has no annotations (30104's wire format is the CFI) and no
highlight anchoring — see docs/proposals/pdf-files.md.
{
"kind": 30112,
"tags": [["d", "<pdf-sha256>"], ["x", "<pdf-sha256>"]], // x = Blossom hash, as 30101
"content": { // encrypted
"sha256": "…",
"title": "…",
"author": "…", // optional
"pageCount": 12, // optional
"size": 148231,
"mimeType": "application/pdf",
"blossomUrls": ["https://…"], // optional; backup is Phase 7
"addedAt": 1752264000000, "updatedAt": 1752264000000
}
}
There is deliberately no PDF progress kind. Books have 30102; PDFs keep their page marker in a device-local store instead. That holds two rules at once: the record↔event 1:1 rule (no unsynced field smuggled into the body), and 30102's reason for existing — reading position stays a separate event that is never shared, rather than riding along inside a document's metadata.
30109 — Feedback triage marker (admin-only)¶
Signed by the feedback admin key, never by app users; the admin pubkey is
on the relay's write whitelist for this. d = the rumor id of the kind-1060
report (stable across outbox re-wraps). Marking done or reopening just
publishes a replacement — the report's gift wrap is never deleted or
reposted. The relay retains markers and reports indefinitely (the 60-day
purge was removed 2026-07-14) but is not backed up; the /admin console's
IndexedDB archive (feedbackInbox) keeps a second copy, snapshotting the
last marker seen per report. Written by both scripts/fetch-feedback.mjs
and the console — the body shape below is shared law.
{
"kind": 30109,
"tags": [["d", "<rumor-id-of-1060-report>"]],
"content": { // NIP-44 self-encrypted to the admin key
"v": 1,
"status": "done", // "done" | "open"
"at": "2026-07-14T02:50:00.000Z",
"note": "fixed in 3c0f94b", // optional
"description": "…" // first 120 chars of the report, for context
}
}
30111 — App settings¶
Whole-payload sync of the settings a user should only ever have to enter
once: the AI endpoint config (including the API key), Search credentials
(Firecrawl key, NIP-50 relay, effort), and News preferences. One addressable
event, d = plebchat, always NIP-44 to self — the payload carries
credentials, so there is no plaintext variant and no cleartext tags beyond
d. Pushed only by the explicit Save on /settings; pulled once at session
start. LWW by content updatedAt (never relay created_at), no tombstones
(settings are never deleted, only replaced). The relays/blossomServers
lists and reading (synced separately as 30103) stay out of the payload.
See docs/proposals/settings-sync.md.
{
"kind": 30111,
"tags": [["d", "plebchat"]],
"content": { // NIP-44 self-encrypted
"ai": { "baseUrl": "…", "apiKey": "…", "model": "…", "streaming": true,
"api": "openai-completions", "models": [] },
"search": { "firecrawlApiKey": "…", "searchRelay": "wss://…", "effort": "balanced" },
"news": { "feedRelay": "wss://…" },
"updatedAt": 1752264000000
}
}
Blossom (books & covers)¶
- EPUB bytes and cover images upload as-is; their sha256 is their address (BUD-01/02), which by design equals the book ID.
- Auth via kind 24242 events signed by the user's key (BUD-11), scoped with
t/x/servertags and an expiration. - The user's server list is a kind 10063 event (BUD-03); uploads go to the
first server, mirror to the rest (
PUT /mirror). - Servers cap sizes by policy (~100 MB class) — check with
HEAD /uploadbefore pushing. - Restore path: a 30101 whose bytes are missing locally → fetch
https://<server>/<sha256>.epub, re-verify the hash, store.
Resolved design decisions (2026-07, Phase D alignment)¶
- Pull = naive REQ: one
{"kinds":[30101,30102,30103,30104],"authors":[me]}fetch per sync. Addressable events mean the relay already holds only the latest perd, so this is current state in one round trip. Negentropy (NIP-77) stays a future optimization (cyphertap carries a client implementation, currently unexported). - Merge = last-write-wins by
updatedAtinside content (not relaycreated_at), per record. Multiple relays may return stale versions of the same addressable event — dedup by(kind, d)keeping newestcreated_atbefore merging. - Deletes propagate as tombstones: deleting republishes the same
addressable event with content
{deleted: true, updatedAt}(encrypted as usual). LWW makes every device converge; the local DB keeps a tombstone row so later pushes can't resurrect. A NIP-09 kind-5 (["a","<kind>:<pk>:<d>"],["k","<kind>"]) is also emitted for relays that honor it — bonus cleanup, not load-bearing. - Blobs upload raw (no client-side encryption): the EPUB's sha256 stays simultaneously identity and Blossom address, and identical files dedup across users. Consequence the UI must own: anything backed up to a public Blossom server is fetchable by anyone who can compute the book's hash — fine for freely-licensed content, effectively public redistribution for purchased ebooks. The first backup shows an explicit warning; file backup is per-book and opt-in, never automatic.
- Sync UX: one global "Sync" action (library header) pushes+pulls all state events; per-book "Back up file" handles the Blossom blob separately. An unsynced-changes dot on the Sync button, a summary toast after. Relay and Blossom-server lists are user-editable in Settings (Blossom list also published as kind 10063).
- Chat sync: transcripts stay device-local; 30105/30106 remain reserved.