diff --git a/.cursor/plans/voice_rooms_production_f5249058.plan.md b/.cursor/plans/voice_rooms_production_f5249058.plan.md
deleted file mode 100644
index d6a10a19..00000000
--- a/.cursor/plans/voice_rooms_production_f5249058.plan.md
+++ /dev/null
@@ -1,320 +0,0 @@
----
-name: Voice Rooms Production
-overview: Implement production voice rooms by moving LiveKit token generation to the zooid relay (NIP-29 extension), making voice rooms conditional on group metadata tags, and updating flotilla to fetch tokens from the relay via NIP-98 authenticated HTTP requests.
-todos:
- - id: zooid-config
- content: "Zooid: Add [livekit] config section (server_url, api_key, api_secret) to config.go"
- status: pending
- - id: zooid-livekit-go
- content: "Zooid: Create livekit.go with token generation (livekit/protocol/auth), room creation (Twirp API), and response type"
- status: pending
- - id: zooid-endpoint
- content: "Zooid: Register GET /.well-known/nip29/livekit/{groupId} endpoint on instance router with NIP-98 auth, membership check, livekit tag check"
- status: pending
- - id: zooid-nip98-util
- content: "Zooid: Extract/reuse NIP-98 auth validation for the livekit endpoint (from api.go pattern)"
- status: pending
- - id: zooid-notext
- content: "Zooid: Add no-text enforcement in CheckWrite -- reject non-moderation events for groups with no-text tag"
- status: pending
- - id: zooid-test-cp1
- content: "Zooid: Test checkpoint 1+2 -- curl the token endpoint, verify livekit/no-text tag behavior"
- status: pending
- - id: flotilla-remove-poc
- content: "Flotilla: Remove jose dependency, VITE_LIVEKIT_* env vars, client-side JWT generation, live activity (kind:30311) publishing"
- status: completed
- - id: flotilla-nip98-fetch
- content: "Flotilla: Implement NIP-98 auth header construction and relay token HTTP fetch in voice.ts"
- status: completed
- - id: flotilla-update-join
- content: "Flotilla: Update joinVoiceRoom/leaveVoiceRoom to use relay-provided token and server_url, use hex pubkey identity"
- status: completed
- - id: flotilla-presence-fix
- content: "Flotilla: Simplify presence events to use h tag directly (drop a tag referencing live activity)"
- status: completed
- - id: flotilla-conditional-ui
- content: "Flotilla: Add roomHasLivekit/roomIsNoText helpers; update SpaceMenu to conditionally render voice/text rooms"
- status: completed
- - id: flotilla-test-e2e
- content: "Flotilla: End-to-end test -- join voice room via relay token, verify audio, verify conditional display"
- status: completed
-isProject: false
----
-
-# Voice Rooms Production Implementation
-
-This plan implements the proposed [NIP-29 LiveKit extension](https://github.com/nostr-protocol/nips/pull/2238) across two repositories: **zooid** (relay) and **flotilla** (client). Changes are organized into checkpoints so each can be tested independently. Reference implementation: [pyramid commit 4ac5418](https://github.com/fiatjaf/pyramid/commit/4ac5418175c7ea7b53e81858aa69be6f8f56c691).
-
----
-
-## Architecture
-
-```mermaid
-sequenceDiagram
- participant F as Flotilla Client
- participant Z as Zooid Relay
- participant LK as LiveKit Server
-
- F->>Z: Subscribe kind:39000 (room metadata)
- Z-->>F: Room with ["livekit"] tag
-
- F->>F: Show voice room in sidebar
-
- Note over F: User clicks voice room
-
- F->>F: Build NIP-98 auth event (kind 27235)
- F->>Z: GET /.well-known/nip29/livekit/{groupId}
Authorization: Nostr base64(event)
- Z->>Z: Verify signature, membership, livekit tag
- Z->>LK: Create room if needed (Twirp API)
- Z-->>F: { server_url, participant_token }
- F->>LK: Connect with JWT token
- F->>Z: Publish kind:10312 presence event
-```
-
----
-
-## Zooid Changes (relay)
-
-### Checkpoint 1: LiveKit config and token endpoint
-
-**Goal**: Zooid serves LiveKit JWTs to authenticated group members.
-
-**Config changes** ([zooid/config.go](zooid/config.go)):
-
-- Add a `[livekit]` section to the TOML config struct:
-
-```go
- Livekit struct {
- ServerURL string `toml:"server_url" json:"server_url"`
- APIKey string `toml:"api_key" json:"api_key"`
- APISecret string `toml:"api_secret" json:"api_secret"`
- } `toml:"livekit" json:"livekit"`
-
-
-```
-
-**New file** `zooid/livekit.go`:
-
-- `generateLivekitToken(apiKey, apiSecret, room string, pubkey nostr.PubKey) string` -- Uses `github.com/livekit/protocol/auth` to create a JWT with `RoomJoin` grant and `SetIdentity(pubkey.Hex())` (hex pubkey per the NIP).
-- `ensureLivekitRoom(apiKey, apiSecret, serverURL, roomName string)` -- Calls LiveKit's Twirp API to lazily create rooms. Cache known rooms in a `map[string]bool` with a mutex (same pattern as pyramid).
-- `TokenEndpointResponse` struct: `{ ServerURL string, ParticipantToken string }`.
-
-**HTTP endpoint** ([zooid/instance.go](zooid/instance.go)):
-
-- In the router setup (after the existing `/static/` handler), register:
-
-```go
- router.HandleFunc("GET /.well-known/nip29/livekit/{groupId}", instance.livekitTokenHandler)
-
-
-```
-
-- The handler (`livekitTokenHandler` on `*Instance`):
- 1. Check `instance.Config.Livekit.APIKey` is configured (return 404 if not).
- 2. Parse `{groupId}` from the path.
- 3. Validate NIP-98 auth header: `Authorization: Nostr `. Reuse the same pattern from `api.go`'s `authenticateNIP98`. Verify: signature, kind == 27235, `u` tag matches the request URL, timestamp is recent.
- 4. Check the group exists: call `instance.Groups.GetMetadata(groupId)`.
- 5. Check the group's kind:39000 event has a `livekit` tag (use `HasTag(meta.Tags, "livekit")`).
- 6. Check the user is a group member: `instance.Groups.HasAccess(groupId, pubkey)`.
- 7. Ensure the LiveKit room exists.
- 8. Generate a token and return JSON `{ "server_url": "...", "participant_token": "..." }`.
-
-**NIP-98 validation**: Extract from `api.go`'s `authenticateNIP98` into a shared utility in `zooid/util.go` (or call it directly), since both the REST API and the livekit endpoint need it. The key difference: the API validates `method` tag too; the livekit endpoint should also validate method is "GET".
-
-**Dependency**: Add `github.com/livekit/protocol` to `go.mod`.
-
-**Test**: With a zooid instance running with `[livekit]` config and a group that has a `livekit` tag on its kind:39000 event:
-
-```bash
-# Manually test with a NIP-98 auth header (use nak or a script to create the auth event)
-curl -H "Authorization: Nostr " \
- https://your-relay/.well-known/nip29/livekit/
-# Expect: { "server_url": "wss://...", "participant_token": "eyJ..." }
-```
-
-### Checkpoint 2: livekit and no-text tag support
-
-**Goal**: Admins can set `livekit` and `no-text` tags on groups; `no-text` groups reject text events.
-
-**Tag passthrough** -- Zooid's `UpdateMetadata` in [zooid/groups.go](zooid/groups.go) already copies all tags from edit events to the kind:39000 metadata event (converting `h` to `d`). The `livekit` and `no-text` tags will pass through naturally when an admin sends a `KindSimpleGroupEditMetadata` event containing them. No code change needed for this.
-
-**no-text enforcement** -- In `CheckWrite` in [zooid/groups.go](zooid/groups.go), after existing access checks, add:
-
-```go
-if HasTag(meta.Tags, "no-text") &&
- !slices.Contains(nip29.ModerationEventKinds, event.Kind) &&
- event.Kind != nostr.KindSimpleGroupJoinRequest &&
- event.Kind != nostr.KindSimpleGroupLeaveRequest {
- return "blocked: this group does not allow text events"
-}
-```
-
-This mirrors pyramid's `reject-event.go` logic.
-
-**Test**:
-
-- Send a `KindSimpleGroupEditMetadata` event with `["livekit"]` and `["no-text"]` tags. Query kind:39000 and verify the tags appear.
-- Try sending a regular message to a `no-text` group; expect rejection.
-- Try the token endpoint for a group with vs without the `livekit` tag; expect 403 for non-livekit groups.
-
----
-
-## Flotilla Changes (client)
-
-### Checkpoint 3: NIP-98 token fetching from relay
-
-**Goal**: Replace client-side JWT generation with relay HTTP request.
-
-**Remove POC artifacts** from [src/app/voice.ts](src/app/voice.ts):
-
-- Remove the `jose` import and the `generateToken` function.
-- Remove `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` constants.
-- Remove `LIVE_ACTIVITY` constant and `publishLiveActivity` function (dropping kind:30311).
-- Keep `ROOM_PRESENCE` and presence-related code (kind:10312).
-
-**Remove env vars** from [.env.template](.env.template):
-
-- Remove `VITE_LIVEKIT_URL`, `VITE_LIVEKIT_API_KEY`, `VITE_LIVEKIT_API_SECRET`.
-
-**Remove dependency**: Run `pnpm remove jose`.
-
-**Implement NIP-98 auth + token fetch** in [src/app/voice.ts](src/app/voice.ts):
-
-```typescript
-const fetchLivekitToken = async (
- url: string,
- groupId: string,
-): Promise<{server_url: string; participant_token: string}> => {
- const httpUrl = url.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://")
- const endpoint = `${httpUrl}/.well-known/nip29/livekit/${groupId}`
-
- const authEvent = await buildNip98AuthEvent(endpoint, "GET")
- const encoded = btoa(JSON.stringify(authEvent))
-
- const response = await fetch(endpoint, {
- headers: {Authorization: `Nostr ${encoded}`},
- })
-
- if (!response.ok) throw new Error(`Token request failed: ${response.status}`)
- return response.json()
-}
-```
-
-**Build NIP-98 auth event**: Create a helper that uses the welshman signer to sign a kind 27235 event:
-
-```typescript
-const buildNip98AuthEvent = async (url: string, method: string) => {
- const event = makeEvent(27235, {
- tags: [
- ["u", url],
- ["method", method],
- ],
- })
- // Sign using welshman's signer
- return signEvent(event)
-}
-```
-
-Look at how `publishThunk` signs events for the pattern. The signer is available via `@welshman/app`. The event must be a complete signed event (with id, pubkey, sig).
-
-**Update `joinVoiceRoom`**:
-
-- Replace `generateToken(roomName, identity)` with `fetchLivekitToken(url, h)`.
-- Use the returned `server_url` for `room.connect()` instead of the hardcoded `LIVEKIT_URL`.
-- Change identity from `nip19.npubEncode(pk)` to just `pk` (hex pubkey) since the relay now sets it in the JWT. The `nip19` import can be removed.
-- Remove the `publishLiveActivity` call; keep `startPresenceHeartbeat`.
-- Remove `makeLivekitRoomName` since the relay now determines the room name.
-
-**Update `leaveVoiceRoom`**:
-
-- Remove `publishLiveActivity(session.url, session.h, "ended")` call.
-
-**Update sync** in [src/app/core/sync.ts](src/app/core/sync.ts):
-
-- Remove `LIVE_ACTIVITY` from the import and the subscription filter. Change `[LIVE_ACTIVITY, ROOM_PRESENCE]` to just `[ROOM_PRESENCE]`.
-
-**Update presence derivation** in [src/app/voice.ts](src/app/voice.ts):
-
-- Remove the reference to `LIVE_ACTIVITY` in the `a` tag matching inside `deriveVoiceParticipants`. The presence event currently references an `a` tag containing the live activity address. Since we're dropping live activities, switch to using the `h` tag directly on the presence event:
-
-```typescript
-const event = makeEvent(ROOM_PRESENCE, {
- tags: [["h", h]],
-})
-```
-
-And in deriveVoiceParticipants, match on the `h` tag instead of the `a` tag.
-
-**Test**: With zooid running the checkpoint 1+2 changes, and a group with the `livekit` tag:
-
-- Click a voice room in flotilla.
-- Verify the NIP-98 HTTP request is sent to the relay.
-- Verify the token is received and the LiveKit connection succeeds.
-- Verify audio works between two clients.
-
-### Checkpoint 4: Conditional voice room display
-
-**Goal**: Only show voice rooms for groups with the `livekit` tag; show `no-text` groups as voice-only.
-
-**Read tags from room metadata** -- The `Room` type in [src/app/core/state.ts](src/app/core/state.ts) extends `PublishedRoomMeta`, which has an `event: TrustedEvent` field. The raw kind:39000 event tags are accessible via `room.event.tags`. Check for the `livekit` tag:
-
-```typescript
-export const roomHasLivekit = (room: Room) =>
- room.event?.tags?.some(t => t[0] === "livekit") ?? false
-
-export const roomIsNoText = (room: Room) => room.event?.tags?.some(t => t[0] === "no-text") ?? false
-```
-
-Add these helpers to [src/app/core/state.ts](src/app/core/state.ts).
-
-**Update SpaceMenu** ([src/app/components/SpaceMenu.svelte](src/app/components/SpaceMenu.svelte)):
-
-- Currently, every room gets a `` unconditionally. Change this to only render `` when the room has the `livekit` tag.
-- For `no-text` rooms, do NOT render `` (the text channel). Only render ``.
-- This requires deriving the room metadata for each `h` value. Use `getRoom(makeRoomId(url, h))` to access the room data and check tags:
-
-```svelte
-{#each $userRooms as h (h)}
- {@const room = getRoom(makeRoomId(url, h))}
- {#if !roomIsNoText(room)}
-
- {/if}
- {#if roomHasLivekit(room)}
-
- {/if}
-{/each}
-```
-
-Apply the same pattern to the `$roomSearch.searchValues(term)` loop.
-
-**Test**:
-
-- Create a group WITHOUT `livekit` tag -- verify no voice room appears.
-- Add `livekit` tag to a group -- verify voice room appears alongside text channel.
-- Create a group with both `livekit` and `no-text` tags -- verify only voice room appears (no text channel).
-
----
-
-## Summary of changes by repo
-
-### Zooid (relay)
-
-| File | Change |
-| ------------------------ | -------------------------------------------------------- |
-| `zooid/config.go` | Add `[livekit]` config section |
-| `zooid/livekit.go` (new) | Token generation, room creation, response type |
-| `zooid/instance.go` | Register HTTP endpoint, add handler method |
-| `zooid/util.go` | Extract NIP-98 validation from api.go (or add alongside) |
-| `zooid/groups.go` | Add `no-text` enforcement in `CheckWrite` |
-| `go.mod` | Add `github.com/livekit/protocol` dependency |
-
-### Flotilla (client)
-
-| File | Change |
-| ------------------------------------- | ----------------------------------------------------------------------------------------------------- |
-| `src/app/voice.ts` | Replace client-side JWT with relay HTTP fetch; add NIP-98 auth; drop live activity; simplify presence |
-| `src/app/core/state.ts` | Add `roomHasLivekit()` and `roomIsNoText()` helpers |
-| `src/app/core/sync.ts` | Remove `LIVE_ACTIVITY` from sync filters |
-| `src/app/components/SpaceMenu.svelte` | Conditionally show voice/text rooms based on tags |
-| `.env.template` | Remove `VITE_LIVEKIT_` vars |
-| `package.json` | Remove `jose` dependency |