Files
amethyst/docs/plans/2026-04-22-nip-audio-rooms-draft.md
T
Claude 8b5af5d496 docs(audio-rooms): refresh against shipped state + nostrnests gap audit
Existing plan docs were written before the moq-lite swap, the
create-space + kind-10112 work, and the harness / submodule findings.
This refresh aligns them with what's actually live on the branch and
captures the work still ahead.

  - 2026-04-26-audio-rooms-completion.md — flipped to a STATUS-FIRST
    layout: implementation table for every protocol/transport/UI
    surface, "pending" table for the remaining items (reconnect,
    level meters, Desktop / iOS, Nests parity), pointers section
    refreshed.
  - 2026-04-26-moq-lite-gap.md — marked DONE with the commit range
    that landed it (fb47a4c71cf99d015b0d7); "When picking up"
    section now points at the shipped surface first, raw protocol
    references second.
  - 2026-04-22-nip-audio-rooms-draft.md — major surgery to match
    today's nostrnests reality:
      * status banner up top calling out the revision
      * dependencies dropped IETF MoQ-transport, added moq-lite
        Lite-03 + ALPN "moq-lite-03"
      * HTTP control plane: GET <service>/<room-d-tag> → POST /auth
        with {namespace, publish}, returning {token}; documented the
        JWT claim shape (root, get, put), 600 s lifetime, regex
        on `namespace`, JWKS endpoint, error matrix
      * Audio transport: replaced IETF SETUP / TrackNamespace tuples
        / OBJECT_DATAGRAM with moq-lite Lite-03 (ControlType varint,
        per-bidi message types, group uni streams, audio/data track,
        no in-band SETUP, FIN-as-unsubscribe semantics)
      * New event-kind sections: kind 4312 (admin command / kick),
        kind 10112 (audio-room server list)
      * Reconciliation section explaining what changed from the
        original draft and why
  - NEW: 2026-04-26-nostrnests-integration-audit.md — punchlist of
    every nostrnests/NestsUI feature we don't yet ship, sourced from
    a code-walk of the React app + moq-auth + API.md (which is
    LiveKit-era and dead). Tier 1 (low-effort, visible): chat,
    reactions, role parsing + promotion, hand-raise queue, kick
    (kind 4312), edit/close room, scheduled rooms, listener counter.
    Tier 2: participant grid, augmented presence tags
    (publishing/onstage), per-avatar context menu + zap, share via
    naddr. Tier 3: room theming. Tier 4: token-refresh +
    Connection.Reload sanity checks.

Verified `:nestsClient:jvmTest` + `:amethyst:compilePlayDebugKotlin`
both still green after the doc changes (no code touched).
2026-04-26 19:23:41 +00:00

16 KiB

NIP-XX — Interactive Audio Rooms Join Protocol

draft optional

Revision 2026-04-26. Replaces the IETF MoQ-transport / GET <service>/<room-d-tag> sketch from the original draft. The wire described here matches the nostrnests/nests + kixelated/moq reference deployment running in production today, and is what amethyst/nestsClient implements. See "Reconciliation with previous spec drafts" near the bottom.

Abstract

This NIP specifies the client/server control plane and real-time audio transport for joining a NIP-53 Meeting Space (kind 30312) as a listener or speaker. It closes the gap between NIP-53's room discovery (which defines only what a room is) and what an audio-capable client must do to actually hear and speak in it.

The transport is moq-lite Lite-03 over WebTransport with a thin NIP-98-authenticated HTTP /auth endpoint that mints an ES256 JWT. A client that follows this NIP will work against any server speaking the same wire shape.

Dependencies

  • NIP-53: Live Activities defines kind 30312 (Meeting Space), kind 10312 (Room Presence), and kind 1311 (Live Activity Chat).
  • NIP-98: HTTP Auth defines kind 27235 and the Authorization: Nostr <base64-event> header.
  • IETF webtransport-http3 carries the audio transport.
  • moq-lite Lite-03 (kixelated/moq) — the application-level framing on top of WebTransport. Selected by ALPN "moq-lite-03". Wire spec: kixelated/moq-rs/rs/moq-lite/src/. NOT to be confused with IETF draft-ietf-moq-transport, which is wire-incompatible.

Terminology

  • Host — the pubkey that signed the kind 30312 event.
  • Speaker — any pubkey listed in the 30312 event's p tags with role host or speaker.
  • Listener — any other participant (including un-tagged members of the audience).
  • MoQ endpoint — the WebTransport URL a server returns from its room-info HTTP endpoint; where audio flows once the session is established.

Event kinds used

Kind 30312 (already defined by NIP-53)

Clients and servers compliant with this NIP MUST honour these tags:

Tag Meaning Required
d Room identifier. The "room id" everywhere else in this spec. yes
room Human-readable display name. recommended
summary Short description. optional
image Room cover URL. optional
status open | private | closed. yes
service HTTPS base URL of a server implementing the HTTP control plane defined below. yes
streaming Present for non-audio streams (e.g. wss+livekit://…, HLS). MAY be omitted for pure audio rooms — in that case audio flows via the MoQ endpoint returned by the service URL. optional for audio rooms
p ["p", <pubkey>, <relay-hint>?, <role>?, <proof>?] — role is host or speaker. one host required

A client MUST reject a room whose service URL is not HTTPS.

Kind 10312 (Room Presence) — extended

NIP-53 defines this event with an optional ["hand", "1"|"0"] tag. This NIP adds a second optional tag:

Tag Meaning
["muted", "1"] or ["muted", "0"] The participant's microphone is muted (1) or hot (0) at the time of publish. Absent means unspecified.

Servers and peers MUST NOT rely on muted for authorization — it's a UI signal, not an access control. The server still enforces who is allowed to publish audio via the MoQ session.

Kind 1311 (Live Activity Chat)

Used per NIP-53 with no changes. Each room's chat is scoped by the a tag pointing at the 30312 event.

Kind 4312 (Admin Command) — moderation

Ephemeral event used by hosts and admins to issue moderation actions:

{ "kind": 4312, "content": "",
  "tags": [
    ["a", "30312:<host-pubkey>:<room-d-tag>"],
    ["p", "<target-pubkey>"],
    ["action", "kick" | ...] ] }

Recipients matching the p tag MUST verify the signer is a host or admin per the room's current 30312 event before acting. The target self-disconnects on action=kick. The host SHOULD also re-publish the 30312 with the target's p-tag removed so any future joiner sees the updated roster.

Kind 10112 (Audio-room server list)

Replaceable event listing a user's preferred MoQ host servers. Wire shape mirrors NIP-B7's BlossomServersEvent (kind 10063):

{ "kind": 10112,
  "tags": [
    ["alt", "Audio-room (nests) MoQ servers used by the author"],
    ["server", "https://moq.nostrnests.com"],
    ["server", "https://moq.example.org"],
    ... ],
  "content": "" }

Each ["server", <baseUrl>] URL is a moq-auth + moq-relay base URL. Clients SHOULD consume this event when "starting a new space" to default the service / endpoint tag fields on the kind-30312 event they're about to publish.

HTTP control plane

Base URL

The service tag from the kind 30312 event is the base URL of the auth sidecar (a.k.a. moq-auth). It exposes exactly two routes:

POST <service>/auth                      — mint a JWT for one room+role
GET  <service>/.well-known/jwks.json     — public keys for JWT verification

The server SHOULD also expose GET <service>/health returning {"status":"ok"} for liveness probes. Anything else SHOULD return 404.

Authentication

Every POST /auth request MUST carry a NIP-98 Authorization header:

Authorization: Nostr <base64(kind-27235-event)>

The kind 27235 event MUST have:

  • ["u", "<fully-qualified-URL-being-requested>"]
  • ["method", "POST"]
  • ["payload", "<sha256-hex of the request body>"] (NIP-98 §2.2)
  • created_at within 60 s of the server's clock
  • A valid signature

The server MUST reject requests older than 60 s with 401 Unauthorized.

Join / room-info response

POST <service>/auth body:

{ "namespace": "nests/30312:<host-pubkey>:<room-d-tag>", "publish": false }

The namespace MUST match the regex ^nests/\d+:[0-9a-f]{64}:[a-zA-Z0-9._-]+$ (<event-kind>:<host-pubkey>:<d-tag>, where <event-kind> is 30312 for now). publish is true for a host/speaker minting a publish token, false (or omitted) for a listener.

Response on success:

{ "token": "eyJhbGciOi…" }

The token is an ES256 JWT signed by the service server's keypair; its public key is available at <service>/.well-known/jwks.json. The relay (moq-relay) refreshes the JWKS every 30 s.

JWT claims:

Claim Meaning
root Echoed namespace value. The relay matches this against the WT URL path.
get [""] — listener may subscribe to anything under root.
put [<requester's pubkey>] — publisher may only ANNOUNCE under root/<pubkey>. Present only when publish: true.
iat / exp Standard. Token lifetime is 600 s; clients re-mint on expiry.

Error responses:

Status When
400 Body missing / malformed JSON / namespace fails the regex.
401 Authorization missing, signature invalid, u/method/payload mismatch, or created_at outside ±60 s.
429 Rate-limited (≥ 20 mint requests per IP per 60 s).

There is no per-room HTTP info endpoint, no /permissions, no /recording* — the only mutable per-room state lives in Nostr events (kind 30312 for the room, 10312 for presence, 1311 for chat, etc.).

Audio transport

WebTransport

Clients open a WebTransport session against the endpoint URL using the Extended CONNECT handshake (RFC 9220 + webtransport-http3):

:method: CONNECT
:protocol: webtransport
:scheme: https
:authority: <host[:port] from endpoint>
:path: /<namespace>?jwt=<token>

The path component is the namespace the JWT was minted for — i.e. exactly the value sent in the POST /auth body. The relay matches it against claims.root and rejects any mismatch with HTTP 401 (IncorrectRoot). The token is delivered as the ?jwt= query parameter; the relay does not inspect the Authorization header.

Servers MUST advertise SETTINGS_ENABLE_CONNECT_PROTOCOL = 1, SETTINGS_ENABLE_WEBTRANSPORT = 1, and SETTINGS_H3_DATAGRAM = 1.

moq-lite session

The application-level framing is moq-lite Lite-03 (kixelated/moq). The variant is selected by the WebTransport ALPN — clients SHOULD advertise "moq-lite-03" (and MAY include the legacy "moql").

There is no in-band SETUP message in Lite-03 — the WebTransport handshake itself is the handshake. After the WT session opens, both sides go straight to opening per-purpose streams.

Per-bidi ControlType discriminator

Every client-initiated bidi opens with a single varint ControlType byte that selects the message family:

Code Name
1 Announce (subscriber-of-announces ↔ publisher)
2 Subscribe (subscriber → publisher)
3 Fetch (decl. only; not used for live audio)
4 Probe (bitrate hint)

Body framing on every bidi/uni stream is varint(size) + payload bytes. Strings are varint(length) + UTF-8. Integers are RFC 9000 §16 varints.

Speaker — publisher path

The relay opens both Announce and Subscribe bidis to the publisher (publisher accepts inbound bidis). For each:

  • Announce body: AnnouncePlease { prefix: string }. The publisher replies Announce { status: u8 (0=Ended,1=Active), suffix: string, hops: u62 } with status=1, suffix=<own pubkey>. Publishers MUST emit status=0 on graceful shutdown.
  • Subscribe body: Subscribe { id: u62, broadcast: string, track: string, priority: u8, ordered: u8, maxLatencyMillis: varint, startGroup: varint, endGroup: varint }. The publisher replies SubscribeOk { priority, ordered, maxLatencyMillis, startGroup, endGroup }.

Per-group audio bytes flow on client-initiated uni streams the publisher opens. Each uni stream has the layout:

DataType varint = 0 (Group)
GroupHeader (size-prefixed): { subscribe: u62, sequence: u62 }
frames until QUIC FIN: { size: varint, payload: <Opus packet> }

Listener — subscriber path

A listener opens its own client-initiated bidis to the relay:

  • Announce (ControlType=1) with AnnouncePlease { prefix: <empty or namespace prefix> } to receive a flow of Announce updates from the relay (one per active publisher).
  • Subscribe (ControlType=2) per (broadcast, track) pair the listener wants. The relay replies SubscribeOk and forwards group uni streams as the publisher emits them.

For a nests audio room the listener's wire usage per speaker is:

Field Value
broadcast <speaker-pubkey-hex> (single string, no nests/ prefix)
track "audio/data"
Optional metadata track "catalog.json" (JSON description of the broadcast — clients MAY skip and just subscribe to audio/data).

Path normalisation (strip leading/trailing/duplicate /) is mandatory on every wire boundary; "/foo//bar/" and "foo/bar" MUST round-trip identically.

Audio frame format

Each frame payload on the audio/data track is one Opus packet in raw form (no Ogg, no TOC prefix), as produced by libopus / Android MediaCodec("audio/opus") output:

  • 48 000 Hz, mono, signed-16-bit PCM domain, 20 ms frame duration, VBR.

There is no per-frame envelope beyond the size varint — no timestamp, no codec config, no flags. Receivers reconstruct timing from frame arrival + the group sequence.

Unsubscribe / close

There is no UNSUBSCRIBE message: a subscriber FINs the send side of the subscribe bidi and the relay tears down. A publisher closes by emitting Announce { status: 0=Ended } on every active announce bidi and FINing the current group's uni stream.

Errors on any stream are conveyed by RESET_STREAM with a u32 error code; there is no SUBSCRIBE_ERROR / ANNOUNCE_ERROR message.

Leaving the room (Nostr-side)

In addition to the wire-level cleanup above, a leaving client SHOULD:

  1. Publish a final kind 10312 presence event with ["muted","1"], ["onstage","0"], no ["hand","1"] to flush stale UI on other peers.
  2. Close the WebTransport session with capsule type 0x2843 (WT_CLOSE_SESSION, code 0).

Chat

Per NIP-53 kind 1311. No additions.

Server requirements summary

A server claiming this NIP MUST:

  1. Accept NIP-98-signed requests at <service>/<d-tag> and return the JSON shape above on success.
  2. Enforce the status on the kind 30312 event (reject join when closed).
  3. Enforce p tag role for publishers (speakers only).
  4. Run a WebTransport / MoQ endpoint that speaks the moq_version advertised in its room-info response.
  5. Publish each speaker's Opus audio on track [<d-tag>] / <speaker-pubkey-hex> and accept SUBSCRIBEs from any authenticated listener.
  6. Not require any fields beyond those listed in this NIP for basic interop (but MAY include additional fields in its JSON response for server-specific features; clients ignore unknown fields).

Client requirements summary

A client claiming this NIP MUST:

  1. Resolve the service URL via NIP-98 GET before opening a transport.
  2. Establish WebTransport against the returned endpoint with the returned token as a Bearer header.
  3. Run the MoQ handshake at the returned moq_version.
  4. SUBSCRIBE to one track per host+speaker pubkey listed in the room event.
  5. Decode Opus per the returned sample_rate / frame_duration_ms.
  6. Publish kind 10312 presence no less frequently than every 30 s while joined, with ["muted", …] reflecting the local user's microphone state and ["hand", "1"] when requesting to speak.
  7. Fail closed on unknown transport, unknown codec, or missing endpoint fields.

Reference implementation

A compliant Kotlin/Android reference client ships in amethyst/nestsClient. The reference server is the nostrnests stack: nostrnests/nests (auth sidecar) + kixelated/moq (relay).

This NIP describes the wire as the reference server speaks it — there is no "vendor-neutral" alternative being trialled. Any server claiming compliance MUST speak the moq-lite Lite-03 framing detailed above and the POST /auth HTTP shape verbatim.

Reconciliation with previous spec drafts

Earlier drafts of this NIP described the audio transport in terms of IETF draft-ietf-moq-transport (CLIENT_SETUP / SERVER_SETUP, namespace tuples, OBJECT_DATAGRAM) and a GET <service>/<room-d-tag> HTTP control plane returning {endpoint, token, codec, sample_rate, …}.

Both have been superseded. nostrnests's reference deployment uses POST /auth with a {namespace, publish} body returning {token}, and runs moq-lite Lite-03 (single-string broadcast/track names, group- per-uni-stream framing, no in-band SETUP). IETF MoQ-transport remains a possible future ALPN, but no audio-rooms server implements it today and this NIP no longer specifies it.

Security considerations

  • The token returned by the server is a bearer token. Clients MUST NOT log it. Servers MUST scope it to a single room + pubkey and SHOULD expire it within the room's lifetime.
  • The server MUST validate the NIP-98 event's u tag matches the exact request URL, preventing token reuse across rooms.
  • Audio published by a pubkey P on this server can be replayed by a malicious peer claiming to be P on a different server — the listener has no signature on the Opus packets themselves. If end-to-end authenticity matters, a future NIP can extend this by signing each object with the speaker's Nostr key; this NIP leaves that out of scope.
  • A malicious server can serve silent audio while presence indicators show a speaker talking. Clients SHOULD expose signal-level metering (VU meter) so users can spot this.

Discussion

Send feedback to the author via Nostr or GitHub issues on the reference client repository.