Markdown source for agents: https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/patterns/nsite-publishing.md

Nostr Agent Onboarding · start here · index · source: nostr-dev/docs/patterns/nsite-publishing.md · snapshot 2026-10-10

Paths such as ~/Documents/…, ~/Production Environment/…, repos/… and services on localhost refer 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/nips 5A.md at 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:

  1. Kind 15128, no d tag. 34128 is dead; a d tag on a root site gets the event rejected by the one relay that matters.
  2. The events must land on wss://relay.nsite.lol. No gateway serves a site whose manifest it cannot find.
  3. Put https://nostr.download first in the server list. blossom.primal.net serves every text/* asset as text/plain (§4), which silently kills stylesheets and service workers.
  4. 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.
  5. Ship a /404.html. The spec makes it the gateway's mandatory fallback; without it every deep link is a bare 404.
  6. 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:

  1. Take every path tag.
  2. Emit one line per tag, exactly <sha256hash> <absolute-path>\n.
  3. Sort the lines ascending lexicographically.
  4. 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

  1. 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.
  2. Find the manifest on its own relays (relay.nsite.lol) and on the relays in the author's kind 10002, which it looks up via LOOKUP_RELAYS (default wss://user.kindpag.es, wss://purplepag.es).
  3. Match the path. Exact path-tag match; a request not ending in a filename gets index.html appended (/ → /index.html, /blog/ → /blog/index.html). No match, or no manifest at all → MUST fall back to /404.html.
  4. Fetch the blob by sha256: manifest server tags first, then the author's 10063, then the gateway's own fallbacks. Nothing anywhere → 404. The gateway SHOULD verify the blob's sha256 against the path tag.
  5. Serve it, forwarding the Blossom server's Content-Type and Content-Length. This forwarding is the whole ballgame — see §4.

Two consequences worth internalising:

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 nak binaries here, and the one that won on PATH (~/.local/bin/nak, v0.18.3, Feb 2026) had no nsite command — so nak nsite looked like it did not exist. Resolved 2026-09-21: nak is now v0.20.7 at ~/go/bin/nak, it is the only one on PATH, and nak nsite works. The old binary is parked at ~/.local/bin/nak.v0.18.3.bak. See known-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:

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:

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:

What follows operationally:

  1. 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 in configs/blossom.json → web_assets.
  2. Inlining CSS and JS into index.html remains 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.
  3. .wasm is safe everywhere that accepts it, including at 9 MB. Still ship the fetch → arrayBuffer → WebAssembly.instantiate fallback so a host with the wrong type can't break boot.
  4. There are no COOP/COEP headers from nsite.lol ⇒ no SharedArrayBuffer, no wasm threads. Ever. Single-threaded Go/TinyGo/wasm-bindgen is fine.
  5. 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:


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: