Files
amethyst/docs/plans/2026-04-22-nip-audio-rooms-draft.md
T
Claude ac1e751bec docs: draft NIP-XX — Interactive Audio Rooms Join Protocol
Captures the gap between NIP-53's room discovery and what audio-capable
clients/servers must actually agree on to interop. NIP-53 defines the
30312 event but leaves the HTTP control plane + MoQ namespacing +
audio codec params entirely to individual implementations. With Nests
going generic-server, this is the moment to standardize.

What the draft covers:

1. HTTP control plane:
   - Path convention: GET <service>/<d-tag>
   - NIP-98 `Authorization: Nostr <base64>` header required
   - JSON response shape (endpoint, token, transport, codec,
     sample_rate, frame_duration_ms, moq_version)
   - Canonical error-status map (401/403/404/410/503)
2. WebTransport + MoQ handshake requirements (Extended CONNECT,
   required HTTP/3 settings, Bearer token passing).
3. MoQ track naming — vendor-neutral one-element namespace
   `[<d-tag>]` with track-name = speaker-pubkey-hex. Explicit
   rejection of the `["nests", <d-tag>]` prefix for new deployments.
4. Audio object format: raw Opus packets (no Ogg, no TOC), 48 kHz
   mono, 20 ms default, mono PCM 16-bit decode target. Both
   OBJECT_DATAGRAM and STREAM_HEADER_SUBGROUP accepted; listeners
   MUST handle both.
5. Per-track access control: server MUST verify publishing pubkey is
   a host/speaker in the current 30312 event (which is replaceable —
   revocation cascade is spec'd).
6. Leave procedure (UNSUBSCRIBE, UNANNOUNCE, final 10312 presence,
   WT_CLOSE_SESSION capsule).
7. Presence extension: the `["muted", "1"|"0"]` tag we already ship
   in nestsClient's MeetingRoomPresenceEvent overload, promoted from
   Amethyst-specific to NIP-defined.
8. Server + client requirements summaries.
9. Known divergences from current nostrnests/nests servers (two-
   element `["nests", <d-tag>]` namespace + `/api/v1/nests/<d-tag>`
   path) with a transition strategy via a `"nip_xx": true` flag in
   room-info responses.
10. Security considerations (bearer-token handling, NIP-98 `u` tag
    binding, audio-replay attack surface, server-impersonation VU
    meter recommendation).

Deliberately out of scope: E2E-signed audio objects (future NIP),
federation between audio servers, room recording/transcription.

Intended workflow: share with the Nests team while they're designing
the generic server, land changes against their feedback, then open a
PR on nostr-protocol/nips.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 13:27:18 +00:00

13 KiB

NIP-XX — Interactive Audio Rooms Join Protocol

draft optional

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.

A standard, vendor-neutral profile lets multiple server implementations interoperate with multiple clients. A client that follows this NIP will work against any server that advertises compliance, and vice versa.

Dependencies

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.

HTTP control plane

Base URL

The service tag from the kind 30312 event is the base URL. All control- plane requests are constructed as:

GET  <service>/<room-d-tag>            — room-info / join

where <room-d-tag> is the d tag value of the kind 30312 event, URL-path-encoded.

The server MAY expose additional paths under the same base (e.g. <service>/ for server metadata, <service>/.well-known/nostr-audio-rooms for discovery). This NIP only specifies <service>/<room-d-tag>.

Authentication

Every 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", "GET"] (or POST, DELETE, etc. matching the request verb)
  • created_at within 60 s of the server's clock
  • A valid signature

The server SHOULD reject requests older than 60 s with 401 Unauthorized. Servers MAY also reject requests whose signer isn't allowed in the room (e.g. the room is private and the signer isn't on the allow-list).

Join / room-info response

GET <service>/<room-d-tag> with a valid NIP-98 header returns a JSON body with Content-Type: application/json:

{
  "endpoint": "https://relay.example.com:4443/moq",
  "token": "eyJhbGciOi…",
  "transport": "webtransport",
  "codec": "opus",
  "sample_rate": 48000,
  "frame_duration_ms": 20,
  "moq_version": "draft-17"
}

Fields:

Field Type Required Meaning
endpoint string (https URL) yes WebTransport URL the client connects to.
token string yes Opaque bearer token the client passes to the WebTransport layer (see below).
transport string no, defaults to "webtransport" Reserved for future transports. Clients MUST fail closed on unknown values.
codec string no, defaults to "opus" Audio codec name. Reserved for future codecs. Clients MUST fail closed on unknown values.
sample_rate integer no, defaults to 48000 Samples per second.
frame_duration_ms integer no, defaults to 20 Audio frame duration.
moq_version string no, defaults to server's preferred Identifier of the MoQ-transport draft the server speaks (e.g. "draft-17"). Clients MUST include this version (and nothing else) in CLIENT_SETUP.

Unknown fields MUST be ignored by the client to allow forward-compatible server extensions.

Error responses are standard HTTP status codes with an optional JSON body {"error": "<short-code>", "reason": "<human-readable>"}:

Status When
401 NIP-98 missing, expired, or signature invalid.
403 Signer isn't allowed in this room (e.g. room closed, signer blocked).
404 Room d tag unknown to this server.
410 Room has ended.
503 Server is healthy but audio backend is unavailable.

Audio transport

WebTransport

Clients open a WebTransport session against the endpoint URL using the Extended CONNECT handshake (RFC 9220

:method: CONNECT
:protocol: webtransport
:scheme: https
:authority: <host[:port] from endpoint>
:path: <path from endpoint>
Authorization: Bearer <token from room-info response>

Servers MUST:

  • advertise SETTINGS_ENABLE_CONNECT_PROTOCOL = 1 (RFC 8441),
  • advertise SETTINGS_ENABLE_WEBTRANSPORT = 1,
  • advertise SETTINGS_H3_DATAGRAM = 1 (RFC 9297).

MoQ session

After the WebTransport session is open, the client opens its first bidirectional stream as the MoQ control stream and sends CLIENT_SETUP advertising the moq_version from the room-info response. The server replies with SERVER_SETUP selecting that version, or closes the session.

Track namespace + name

A speaker's audio track is published by the server and subscribed-to by clients under:

track_namespace = [ <room-d-tag> ]
track_name      = <speaker-pubkey-hex>     (64 lowercase hex chars)

The namespace is a one-element tuple containing the room's d tag. It is intentionally vendor-neutral: there is no "nests" or server-brand prefix.

Rationale: the namespace is uniquely keyed by the NIP-53 room id and is sufficient for a client to subscribe without knowing anything about the server's brand. Multiple servers hosting rooms with the same d tag is already not a concern — a d tag is unique under a host pubkey, and the room-info response identifies exactly which server's MoQ endpoint the client must connect to.

Audio objects

Each OBJECT on a speaker's track carries one Opus packet as its payload:

  • Opus encapsulated in raw packet form (no Ogg / no TOC-prefix), as produced by libopus / Android MediaCodec("audio/opus") encoder output.
  • sample_rate from the room-info response (default 48 000 Hz).
  • frame_duration_ms from the room-info response (default 20 ms).
  • Mono (single channel), signed 16-bit PCM domain, VBR encoding.

Object delivery MAY use either:

  • OBJECT_DATAGRAM (lowest latency, lossy) — recommended for live audio.
  • STREAM_HEADER_SUBGROUP uni-streams (reliable) — MAY be used by servers that need delivery guarantees; clients MUST support receiving both.

group_id on each object is the speaker's monotonic group counter (one group per speaker session). object_id is the zero-based Opus packet index within the group. publisher_priority is 0x80 unless the server has reason to vary it.

Server-to-client vs. client-to-server audio

A listener SUBSCRIBEs to each speaker's track namespace + track name. The server MUST accept SUBSCRIBEs from any authenticated client (subject to its access control).

A speaker ANNOUNCEs [ <room-d-tag> ] and publishes objects on track name <speaker-pubkey-hex>. The server MUST verify that the announcing pubkey is listed in the room's p tag set with role host or speaker at the current moment (the room event is replaceable — the set can change). On role revocation, the server MUST close the publisher's track.

Leaving

To leave, the client SHOULD:

  1. UNSUBSCRIBE every track it had open.
  2. If it was a speaker, send UNANNOUNCE + SubscribeDone for its own track.
  3. Publish a final kind 10312 presence event (optional, improves UX for other peers) with ["muted","1"] and without ["hand","1"].
  4. Close the WebTransport session with capsule type 0x2843 (WT_CLOSE_SESSION, code 0).

The server SHOULD treat 30 s without a kind 10312 refresh (per NIP-53) as "left" for UI purposes.

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 is in development at amethyst/nestsClient — see docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md for the transport work-in-progress.

Known divergences from current nostrnests/nests servers

At the time of writing, existing nostrnests/nests deployments use:

  • MoQ track namespace [ "nests", <d-tag> ] (two elements, "nests" prefix). This NIP specifies [ <d-tag> ] (one element). Existing nests servers SHOULD accept both for a transition period; new deployments SHOULD use the one-element form.
  • A /api/v1/nests/<d-tag> path convention. This NIP leaves the path entirely to the service tag — servers are free to pick any path.

A "compliance" phase is proposed where a server can advertise "nip_xx": true in its room-info JSON response to signal it implements this NIP verbatim. Clients MAY use that flag to choose the vendor-neutral namespace when present, and fall back to the legacy nests convention when absent.

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.