Nostr Agent Onboarding · start here · index · source:
nostr-dev/docs/patterns/nsite-publishing.md· snapshot 2026-10-10Paths such as
~/Documents/…,~/Production Environment/…,repos/…and services onlocalhostrefer to the author's workstation and are not available to you — read them as worked examples of a setup you can recreate.
Pattern: publishing a client as an nsite (NIP-5A)
Read this before writing a line of deploy code. Five nsites have been published from this machine and every one of them cost a fresh round of rediscovery. The failures are always silent: the site returns HTTP 200 and looks broken, or returns 404 and looks un-deployed. Nothing tells you why.
Verified against the live network 2026-09-21. Spec read from
nostr-protocol/nips5A.mdat that date. Measurements in §4 were taken with throwaway keys against the real servers on that date.
0. The short version
# The workspace has a deploy script. Use it; don't write a fifth one.
~/Documents/nostr-dev/bin/nsite-deploy.py \
--dir dist \
--keys ~/Documents/nostr-dev/secrets/<your-site>/keys.json \
--title "Your Site" \
--description "One sentence."
Six rules that cover ~every failure ever seen here:
- Kind 15128, no
dtag. 34128 is dead; adtag on a root site gets the event rejected by the one relay that matters. - The events must land on
wss://relay.nsite.lol. No gateway serves a site whose manifest it cannot find. - Put
https://nostr.downloadfirst in theserverlist.blossom.primal.netserves everytext/*asset astext/plain(§4), which silently kills stylesheets and service workers. - Publish under a dedicated key, never the owner's. 15128 is replaceable per pubkey — one deploy under the wrong key destroys whatever site that identity already served.
- Ship a
/404.html. The spec makes it the gateway's mandatory fallback; without it every deep link is a bare 404. - Verify by fetching the gateway, not by counting relay OKs. A deploy reports success and still 404s.
1. The event model, as the spec stands today
NIP-5A "Static Websites (nsites)", draft optional. Reference gateway:
hzrd149/nsite-gateway, which is what
runs nsite.lol.
1.1 Kinds
| Kind | What | Class | Rule |
|---|---|---|---|
| 15128 | Root site | replaceable, one per pubkey | MUST NOT carry a d tag |
| 35128 | Named site ("subdomain") | addressable | MUST carry d, matching ^[a-z0-9-]{1,13}$, not ending in - |
| 5128 | Manifest snapshot | regular, immutable | MUST carry exactly one aggregate x and one a |
| 34128 | Legacy per-path event | addressable | dead. relay.nsite.lol refuses it |
| 10002 | NIP-65 relay list | replaceable | how a gateway finds the manifest |
| 10063 | BUD-03 Blossom server list | replaceable | how a gateway finds the blobs |
The 13-character cap on a named site's d is not arbitrary: the canonical
DNS label is <pubkeyB36><dTag>, pubkeyB36 eats 50 of the 63 characters a
label allows.
1.2 Manifest tags
| Tag | Form | Required |
|---|---|---|
path |
["path", "/abs/path.ext", "<sha256-hex>"] |
yes, one or more |
x |
["x", "<sha256-hex>", "aggregate"] |
optional, recommended (mandatory on 5128) |
d |
site identifier | 35128 only |
server |
["server", "https://blossom…"] |
no — but see §4, it is load-bearing |
title |
human-readable title | no |
description |
description, not summary |
no |
source |
https:// or nostr:// git URL |
no |
app |
["app", "<kind>:<pubkey>:<d>", "<relay>"] |
no (new — see §9) |
a / A |
copy lineage | copied sites only |
There is no relay tag in the spec. nsyte writes them and two of the
scripts on this machine copied that; it is harmless but it is not a spec tag,
and it is not how a gateway finds you — kind 10002 is.
content is empty in practice. The manifest is replaceable: every deploy
republishes the complete file list. There is no incremental update.
1.3 The aggregate hash
Deterministic identity for a site version, independent of tag order:
- Take every
pathtag. - Emit one line per tag, exactly
<sha256hash> <absolute-path>\n. - Sort the lines ascending lexicographically.
- Concatenate as UTF-8, SHA-256 it, lowercase hex.
nak req -k 15128 -a <pubkey> --limit 1 wss://relay.nsite.lol \
| jq -r '.tags[] | select(.[0] == "path") | "\(.[2]) \(.[1])"' | sort | sha256sum
Two manifests are "the same site" iff their aggregate hashes match — which is
what makes a copied or mirrored nsite verifiable. Emit it. bin/nsite-deploy.py
does; nsyte 0.27 and nak nsite do not.
1.4 How a gateway resolves <npub>.nsite.lol
- Parse the left-most DNS label. Valid
npub→ root site.^v[0-9a-z]{50}$→ snapshot.^[0-9a-z]{50}[a-z0-9-]{1,13}$→ named site. Else: not found. - Find the manifest on its own relays (
relay.nsite.lol) and on the relays in the author's kind 10002, which it looks up viaLOOKUP_RELAYS(defaultwss://user.kindpag.es,wss://purplepag.es). - Match the path. Exact
path-tag match; a request not ending in a filename getsindex.htmlappended (/→/index.html,/blog/→/blog/index.html). No match, or no manifest at all → MUST fall back to/404.html. - Fetch the blob by sha256: manifest
servertags first, then the author's 10063, then the gateway's own fallbacks. Nothing anywhere → 404. The gateway SHOULD verify the blob's sha256 against thepathtag. - Serve it, forwarding the Blossom server's
Content-TypeandContent-Length. This forwarding is the whole ballgame — see §4.
Two consequences worth internalising:
- The manifest does not have to be on
relay.nsite.lolif your 10002 is discoverable on the lookup relays and points somewhere the gateway can read. Publishing torelay.nsite.lolis simply the path that has no moving parts. - The gateway never executes anything. An nsite is static files talking to relays and Blossom from the browser. No server code, ever.
1.5 relay.nsite.lol policy (probed 2026-09-21)
"This is a relay for nsite.lol, it only accepts kind 10002, 10063, 15128, 35128, and 5128 events."
strfry 1.1.0, max_message_length: 131072 (128 KB ≈ 1,000+ path tags),
max_limit: 5000, no restricted_writes. It is public-write for those kinds
and those only — it will not hold your app's content events.
2. Four ways to deploy, and when to pick each
All four were run against the local rehearsal stack (§6) on the same four-file directory, 2026-09-21. What each one actually puts in the kind 15128:
bin/nsite-deploy.py |
nak nsite |
nsyte |
ngit nsite |
|
|---|---|---|---|---|
path tags |
✅ | ✅ | ✅ | ✅ |
server tags |
✅ | ✅ | ✅ | ✅ |
title |
✅ | ❌ no flag | ✅ | ✅ |
description |
✅ | ✅ | ✅ | ✅ |
source |
✅ | ✅ | — | ✅ |
aggregate x |
✅ | ❌ | ❌ | ? |
/404.html fallback |
✅ | ❌ | ✅ --fallback |
✅ --fallback |
| SPA route aliases | ✅ --route |
❌ | ❌ | ❌ |
| kind 10002 + 10063 | ✅ | ❌ | flags exist but silently no-op in --no-config -i |
via 10063 discovery |
| forbidden-key guard | ✅ | ❌ | ❌ | ❌ |
| verifies the gateway | ✅ | ❌ | ❌ | ❌ |
| per-server upload tolerance | ✅ | ❌ aborts on first failure | ✅ | ? |
| standalone directory | ✅ | ✅ | ✅ | ❌ needs an ngit repo |
| bunker signing | ❌ local key only | ✅ | ✅ nbunksec |
✅ nbunksec/--signer |
2.1 bin/nsite-deploy.py — the workspace default
~/Documents/nostr-dev/bin/nsite-deploy.py. Distilled from the four scripts
that Idle StarFighter, the Nostr Workshop Board, the Signer Login Tester and
Cerebrate each grew independently, plus everything in this document. It walks
a directory, uploads to Blossom with per-server tolerance, emits 15128 (with
the aggregate x) + 10063 + 10002, refuses forbidden pubkeys, aliases SPA
routes, and verifies against the live gateway before it claims success.
bin/nsite-deploy.py --dir dist --keys <keys.json> \
--title "T" --description "D" \
[--source nostr://npub…/repo] \
[--route /about --route /help] \
[--dry-run]
Use it. If it is missing something your site needs, fix it there — not in a fifth copy.
2.2 nak nsite — built in, and nobody noticed
nak has had an nsite subcommand this whole time.
nak nsite upload --root -s https://nostr.download --sec <key> dist/ \
wss://relay.nsite.lol wss://nos.lol wss://relay.damus.io
nak nsite download <npub> ./out
Why every agent missed it, and why that is fixed. There used to be two
nakbinaries here, and the one that won onPATH(~/.local/bin/nak, v0.18.3, Feb 2026) had nonsitecommand — sonak nsitelooked like it did not exist. Resolved 2026-09-21:nakis now v0.20.7 at~/go/bin/nak, it is the only one onPATH, andnak nsiteworks. The old binary is parked at~/.local/bin/nak.v0.18.3.bak. Seeknown-issues.md§7.1.
It is genuinely good: it signs Blossom auth properly, and it sets each blob's
Content-Type from the file extension via Go's mime.TypeByExtension (verified
— .css→text/css, .js→text/javascript, .wasm→application/wasm).
Its limits, read from the source:
- It aborts the whole deploy on the first failed upload to any server. With flaky public Blossom that is a mid-deploy abort. This is the main reason the hand-rolled scripts exist.
- No
--titleflag, so notitletag. - No aggregate
xtag. - No 10002 / 10063 — publish those yourself with
nak event. - No forbidden-key guard. Nothing stops you replacing the owner's site.
Good for a throwaway or a local rehearsal, and the only one-liner that takes a bunker. For a release, use §2.1.
2.3 nsyte — the third-party CLI
~/.deno/bin/nsyte (0.27.0 installed; 0.28.1 upstream as of 2026-09-08).
Deno; deno install -A -f -g -n nsyte jsr:@nsyte/cli.
nsyte deploy dist -r wss://relay.nsite.lol,wss://nos.lol -s https://nostr.download \
--sec <hex|nsec|bunker://…|nbunksec…> --fallback dist/404.html -i
nsyte debug <npub> --relays … # the diagnostic when a site 404s
nsyte list -p <npub> -r wss://relay.nsite.lol
Worth knowing:
--dry-run/--dry-run-show-kindspreviews the exact events with no network.--secauto-detects nsec / hex /bunker:///nbunksec;nsyte bunkerandnsyte cimanage NIP-46 signers for CI.--publish-relay-list/--publish-server-listpublish nothing in--no-config -imode (observed). Publish 10002/10063 yourself.- Never use
--publish-relay-list/--publish-server-list/--publish-profilewith the owner's key — they overwrite the owner's real 10002, 10063 and kind 0, which their phone depends on. nsyte runcannot do a fully-local gateway rehearsal: its profile-relay lookup is hardcoded touser.kindpag.es/purplepag.es, so a local-only key resolves to nothing. It also injects a CSP withconnect-src 'self' wss: https:, which blocks plainws://localhostrelays — don't debug that as an app bug.
2.4 ngit nsite — new in ngit 3
ngit 3.0.2 grew a full NIP-5A publisher:
ngit nsite publish dist --id blog --title "…" --description "…" \
--source nostr://npub…/repo --fallback dist/404.html \
--blossom-server https://nostr.download --relay wss://relay.nsite.lol \
--nbunksec <…> # or -n <nsec>, or --signer <npub|alias>
It is the most complete of the three third-party options: --fallback does the
/404.html mapping the spec requires, --id switches to a named 35128, it
reads nsyte-compatible .nsite/config.json, it discovers Blossom servers from
the author's kind 10063 when you don't override, and it signs via nsec, bunker
(nbunksec) or a configured signer.
Its one constraint: it must run inside an ngit repository. Outside one it
fails with no nostr git remotes or git config "nostr.repo" value. That is a
sensible design — the site belongs to the repo, and --source writes itself —
but it means it is not a drop-in for a standalone dist/. Reach for it when the
site is the front end of a GRASP repo; use §2.1 otherwise.
While you are here: ngit 3 also added ngit release (software applications,
releases and assets — the Zapstore side, see
zapstore-publishing.md), ngit container (OCI
images over Nostr + Blossom), ngit ci, and ngit pr replacing the old
ngit list / ngit fetch.
3. Identity: the rule that has no exceptions
Kind 15128 is replaceable per pubkey. Publishing a root site under an identity that already has one irreversibly replaces it. There is no merge, no warning, no undo.
The owner's key 5ea4648045bb1ff222655ddd36e6dceddc43590c26090c486bef38ef450da5bd
(npub1t6jxfqz9…sqvksrw, "Constant") already serves a live root nsite
(Archon-Web, kind 15128). It also has a real kind 10002, a real
Amethyst-maintained 10063, and a real kind 0. All four are destroyable by a
careless deploy.
So, per the workspace identity convention (CLAUDE.md § "Persistent identities"):
mkdir -p ~/Documents/nostr-dev/secrets/<site> && chmod 700 ~/Documents/nostr-dev/secrets/<site>
nak key generate > ~/Documents/nostr-dev/secrets/<site>/keys.json
chmod 600 ~/Documents/nostr-dev/secrets/<site>/keys.json
# then a README.md beside it and configs/identities/<site>.json with the public half
and put a hard abort on forbidden pubkeys in the deploy script — the owner's
key always, plus any key that is public by design (the Workshop Board's embedded
workshop key is in its FORBIDDEN set for exactly this reason: if it could
publish the site, anyone holding the app could replace the site).
If the owner's own identity must sign, use a named site (35128, d ≤ 13 chars)
via their bunker, and never the --publish-* flags. A root site under the owner
key is forbidden.
4. Blossom Content-Type: the measurement that explains everything
The gateway forwards whatever Content-Type the Blossom server reports. The
server records that type at upload, keyed by sha256 — so a wrong type can
only be fixed by changing the file's bytes. Re-uploading identical content
re-uses the existing blob and its existing wrong type.
Measured 2026-09-21, uploading the same three files from a fresh key via
nak blossom upload, then reading back what each server serves:
| Server | .css |
.js |
.wasm |
GET |
|---|---|---|---|---|
https://nostr.download |
text/css; charset=utf-8 ✅ |
text/javascript; charset=utf-8 ✅ |
application/wasm ✅ |
200 direct |
https://blossom.primal.net |
text/plain ❌ |
text/plain ❌ |
application/wasm ✅ |
302 → 200 |
https://blossom.band |
upload 400 | upload 400 | upload 415 | 404 |
https://azzamo.media |
upload 415 | upload 415 | upload 415 | — |
http://localhost:8081 (ngit-relay) |
text/css; charset=utf-8 ✅ |
text/javascript; charset=utf-8 ✅ |
application/wasm ✅ |
200 |
Read that carefully, because it corrects the folklore:
nak blossom uploadwas never the problem. It sends the correctContent-Typefrom the file extension, and the local server andnostr.downloadboth honour it.blossom.primal.netrewrites everytext/*type totext/plain. It is not "unreliable" and it is not content-sniffing — it is deterministic:text/*is flattened,application/*passes through. A browser refuses a stylesheet served astext/plain, and refuses to register a service worker served astext/plain. The page renders, completely unstyled, looking like it loaded fine.blossom.bandnow rejects fresh-key uploads outright (400 for text, 415 for wasm), not just.wasmas previously recorded. Do not list it.azzamo.media(=blossom.azzamo.media) cannot host an nsite. It is the owner's media server and first in their kind 10063, it accepts uploads from any key, and it preserves image types faithfully — but it refuses by MIME with HTTP 415:text/htmlandtext/javascript"not allowed for security reasons",text/css,text/plainandapplication/wasm"not supported". Since an nsite is almost entirely those types, it must never appear in aservertag. Use it for media; see../sources/blossom.md.cdn.satellite.earthreturns 500 on both upload and GET (still down) — and it is the second entry in the owner's live 10063, so their Amethyst media has a dead fallback in it.inner.sebastix.socialis reachable again (401 on/upload, 404 on unknown sha — i.e. a healthy BUD-01/02 server). Note its root path is the API;…/blossom/is an HTML catch-all that returns 200 for everything and would false-positive every dedup check. Its relay side isrestricted_writes: truewithmax_event_tags: 14, so it cannot hold a manifest of more than ~11 files.
What follows operationally:
SERVERS = ["https://nostr.download", "https://blossom.primal.net"], in that order, and abort if the first one fails for any file. Primal is a mirror for availability, never the type-of-record. The canonical list lives inconfigs/blossom.json→web_assets.- Inlining CSS and JS into
index.htmlremains a good belt-and-braces move — it removes the failure mode entirely — but now you know why, and that it is only necessary because of server #2. .wasmis safe everywhere that accepts it, including at 9 MB. Still ship thefetch → arrayBuffer → WebAssembly.instantiatefallback so a host with the wrong type can't break boot.- There are no COOP/COEP headers from nsite.lol ⇒ no
SharedArrayBuffer, no wasm threads. Ever. Single-threaded Go/TinyGo/wasm-bindgen is fine. - No CSP from the gateway; CORS is
*on gateway and blob GETs alike, so the app may fetch relays and blobs cross-origin without a proxy.
5. The failure table
| Symptom | Cause | Fix |
|---|---|---|
Site not found / 404 at the root |
Manifest never reached relay.nsite.lol, or reached it and was rejected |
Publish 15128 there; check the OK message, not the relay count |
| Deploy reports "3/4 relays accepted", site still 404s | The one that refused was relay.nsite.lol — usually because the root event carried a d tag ("Root site events must not include a 'd' tag") |
Drop the d. It belongs to 35128 |
| Page loads completely unstyled, no console error | Stylesheet served text/plain by blossom.primal.net (§4) |
Inline the CSS, or make sure nostr.download holds it and is listed first |
| Service worker silently never registers | Same, for the SW script | Same |
| Every deep link 404s | No /404.html path in the manifest |
Add a path tag for /404.html pointing at index.html's sha; alias the known routes too |
| Deep link renders but returns HTTP 404 | You copied index.html to 404.html — that renders but the status is still 404 | Add extra path tags for the real routes, same sha, no extra upload → 200 |
| Redeploy doesn't show up | Gateway cache-control: max-age=3600 |
Hash-suffix asset filenames so only /index.html is a mutable path; hard-refresh; wait |
Deploy hangs forever on a nak call |
nak reads stdin |
stdin=subprocess.DEVNULL on every call — but you cannot pass both stdin= and input=, so set it only when nothing is piped |
| Site was live, now 404 | Someone published a 15128 under the same pubkey | This is why §3 exists |
nsite.lol returns 502 |
Gateway outage; it is a real SPOF | Try another gateway (<npub>.nsite-host.com); the manifest is on relays, any implementation can serve it. A service worker precaching the bundle makes this survivable |
| Legacy site 404s | Published as 34128, or as 35128 with an empty d |
Republish as a 15128 root |
6. Rehearsing locally
| Service | Endpoint | nsite behaviour |
|---|---|---|
nostr-rs-relay |
ws://localhost:8080 |
accepts 15128 / 10063 / 10002 — use this |
| khatru GRASP relay | ws://localhost:8081 |
rejects 15128 ("event does not relate to a stored repository") — git-scoped, unusable here |
| Blossom (ngit-relay) | http://localhost:8081 |
BUD-01/02 work; preserves the uploaded Content-Type, wasm included |
Level 1 — the app. python3 -m http.server 8088 -d dist or
nsyte serve -d dist. Playwright + headless Chromium are installed.
Level 2 — the pipeline. Rehearse the whole publish against local infra with a throwaway key:
nostr-rs-relay --config ~/Documents/nostr-dev/relays/nostr-rs-relay.config.toml &
KEY=$(nak key generate)
bin/nsite-deploy.py --dir dist --sec "$KEY" \
--relay ws://localhost:8080 --server http://localhost:8081 --no-verify
nak req -k 15128 -a $(nak key public "$KEY") ws://localhost:8080 | jq '.tags|length'
This proves the walk, the hashing, the upload with MIME, the manifest shape and the relay accept. Everything except the gateway.
Level 3 — the gateway. nsyte run cannot do this locally (§2.3). Either run
hzrd149/nsite-gateway yourself with LOOKUP_RELAYS/NOSTR_RELAYS pointed at
ws://localhost:8080, or accept Levels 1+2 and do the real check read-only on
nsite.lol at release time.
7. Definition of done
A deploy is not done when the relays said OK. It is done when:
- [ ]
curl -sI https://<npub>.nsite.lol/→ 200, and the rightcontent-type - [ ]
curl -sI …/<your stylesheet or main js>→ the correct type, nottext/plain - [ ]
curl -so /dev/null -w '%{http_code}' …/<a deep route>→ 200, not 404 - [ ]
curl -sI …/definitely-not-a-real-path→ serves your/404.html - [ ] The page renders and does its job in a real browser (Playwright is installed)
- [ ]
nak req -k 15128 -a <pubkey> wss://relay.nsite.lolreturns the event - [ ] The aggregate
xin the event matches the one computed from its ownpathtags - [ ] A
configs/identities/<site>.jsonexists with the public half and thesecrets_locationpointer, andgit statusdoes not show the secret - [ ]
nsite-manifest.jsonis committed next to the source, so the next deploy is a diff rather than an archaeology exercise
8. Sites published from this machine
All five verified HTTP 200 on 2026-09-21.
| Site | npub | Source |
|---|---|---|
| Signer Login Tester | npub138kdqjl645xz6rvuw92m2v4us0shhpxezgqqz9zrrj2jdstakvus86qzyh |
~/Production Environment/Signer-Login-Tester/ |
| Nostr Workshop Board | npub1jnrsdqxyyegg7xz8tl6u5zv5lccex393dz28uekzmdjjs9fjw4ysfch4a4 |
~/Production Environment/Nostr-Workshop-Leaderboard/ |
| Cerebrate | npub1qldvgjekvjzur0vjjffe2m7k4y575k879ypr6ke08tfnxshrn33q0ghyac |
~/Production Environment/Cerebrate/ |
| Idle StarFighter | npub1kemgxdffeejmk0xf3ja7lhmxucp4cqzzx4jcvrv4l3ucua3j40gsfgteus |
~/Production Environment/Idle StarFighter/ |
| Earthtree Media (demo) | npub143ac528xxw9gtftammcf9dn7yhpcdy6k36824u85009wkwsx45cq57ev6h |
~/Production Environment/EarthTreeMedia-Nostr/ |
| Nostr Agent Onboarding | npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe |
~/Production Environment/Nostr-Agent-Onboarding/ |
Plus the owner's own npub1t6jxfqz9… → Archon-Web. Do not touch that key.
The Earthtree site was recorded as 404 after the 34128→15128 transition; it was
republished with nsyte and is live. The note is stale.
9. What changed in the spec since the last local write-up
The previous in-depth pass on this machine was Facebook-Museum/research/nsite-procedure.md
(2026-07-07) — still the best long-form account of how it was figured out, and
the source of much of this document. NIP-5A has moved since:
apptags.["app", "<kind>:<pubkey>:<d>", "<relay>"]links a manifest to an upstream app descriptor — deliberately generic so it can point at NIP-89 (31990) or NIP-82/Zapstore (32267). This is the seam between an nsite andpatterns/zapstore-publishing.md: the same app can be a web nsite and a Zapstore listing, cross-referenced.- Copied nsites.
a(immediate parent) +A(lineage origin) let anyone pin someone else's site under their own namespace at a known state. Kind anddmay change across a copy;a/Aare the stable references. This is credible exit for a website. - Snapshots formalised. 5128 now has MUSTs: copy the parent's
pathtags, exactly one matching aggregatex, exactly onea, and copyAif present. - The aggregate hash is specified, with the exact line format and sort order, and is what makes two manifests provably the same site.
- Canonical addressing moved to a single DNS label (
<pubkeyB36><dTag>,v<snapshotIdB36>) to dodge wildcard-certificate limits, and the spec's example host is nownsite-host.com. - Blob verification: the host SHOULD check the served blob's sha256 against
the
pathtag and treat a mismatch as not-found. 34128is now listed in the NIPs registry explicitly as deprecated.
Related
bin/nsite-deploy.py— the shared implementationknown-issues.md§7 — the nak-binary split, the primal MIME rewrite, blossom.band's rejectionspatterns/going-public.md— the wider "mirror local artifacts to public infrastructure" recipe; nsite is its step 4patterns/go-client-baseline.md— if the client you are publishing is Go/wasmsources/blossom.md— BUD mechanics~/Production Environment/Facebook-Museum/research/nsite-procedure.md— the 2026-07-07 primary research this distils