flotilla/.agents/skills/flotilla-model/SKILL.md

20 KiB

name description
flotilla-model Use this skill when working on how flotilla speaks nostr: publishing or reading content in a space or room, adding or changing an event kind, NIP-29 room state, moderation and room invites, NIP-43 space membership and invite links, NIP-86 relay management and admin gating, NIP-42 auth or NIP-70 protected events, deciding which relays an event goes to, or replacing code that hand-parses tags or builds events with makeEvent.

Flotilla's nostr model

Flotilla treats a relay as a community (a space) and a NIP-29 group on that relay as a channel (a room). Most of the protocol logic lives below the app: @welshman/domain has a Reader/Writer pair per kind, and @welshman/app plugins (Rooms, RelayMemberLists, RelayManagement, …) assemble relay state out of those readers. Flotilla's own protocol code is a thin layer on top, mostly in src/app/access.ts, src/app/rooms.ts, src/app/management.ts and the components that publish.

The per-feature kind inventory is in kinds.md.

Spaces and rooms

A space is a relay URL. No event defines one; the normalized URL is the identity, and routes carry it as encodeRelay(url) (src/app/relays.ts) under /spaces/[relay]. Any relay can be opened as a space. NIP-29 support only decides whether it has rooms.

A room is a NIP-29 group h on that relay. The same h can exist independently on several relays, so anything room-scoped is keyed by both: makeRoomKey(url, h) from @welshman/app gives ${url}'${h}, and isRoomId in src/app/rooms.ts tests for the '. Other relay-scoped keys use | (${url}|${d} for relay roles and NIP-CD commands) so the two can't collide. Room content carries ["h", h]; content with no h belongs to the whole space. /spaces/[relay]/chat (makeSpaceChatPath) is the space-wide chat, which is RoomChat with no h.

The user's spaces are their kind 10009 ROOMS list (RoomList factory, RoomLists plugin): r tags for spaces, group tags for rooms with the space URL as the hint. userSpaceUrls and deriveUserRooms(url) in src/app/rooms.ts read it. "Joined" in the UI means "in this list", which is separate from NIP-43 membership on the relay: src/routes/spaces/[relay]/+layout.svelte prompts SpaceJoin for any URL not in userSpaceUrls. A deployment with VITE_PLATFORM_RELAYS uses PLATFORM_RELAYS in place of the list for sync and navigation.

NIP-11 relay info

The Relays plugin (relays in src/app/core.ts) fetches each relay's NIP-11 document into a domain Relay. These fields drive protocol decisions:

Read Decides
hasNip(29) whether the space has rooms. Without it everything lives in the space chat: makeSpaceEntryPath (src/app/routes.ts), shareEvent (src/app/share.ts), room search, notification grouping, SpaceMenuRooms
hasNip(70) whether space content is marked protected (below)
self the relay's own pubkey, the trust anchor for relay-signed state
pubkey the space's owner, who writes space-wide content that has no other author (deriveUserIsSpaceOwner)
redirect_to the relay has moved. The space layout offers SpaceRedirect, which runs roomLists.migrateRelay and goToMovedSpace
hasNip(50), hasNip("BUD-02"), hasNip("9a") search, blossom uploads, push

Relay-signed state

The relay publishes NIP-29 and NIP-43 state under its NIP-11 self key: room metadata, admins, members and pins, the space member list, and roles. Anyone can publish events of those kinds, so readers must check the author. The welshman collections do: Rooms and every RelaySignedDerivedPlugin (RelayMemberLists, RelayRoles, RoomPinLists) drop events whose author isn't the relay's self, and re-check when NIP-11 loads. For a relay-authored kind with no plugin, filter deriveEventsForUrl(url, filters) on that self key yourself rather than reading a bare deriveEventsForUrl.

The app signs everything it publishes with the user's own key. Space-wide content with no author of its own belongs to the space's owner, the pubkey NIP-11 names: featured content (setFeaturedContent in src/app/featured.ts) is published by that person and read back scoped to them, so deriveUserIsSpaceOwner(url) is what shows the editor.

The library is written by its members. A shelf or a pin is signed with the member's own key and published to the space like any other space content, so the library reads every PINBOARD seen on the relay rather than only the relay's own. Whoever signed a shelf is the only one who can edit or delete it, and anyone can pin to it.

NIP-29 rooms

Constant Kind Factory Author Flotilla use
ROOM_META 39000 RoomMeta relay name, about, picture, flags
ROOM_ADMINS 39001 RoomAdmins relay room admins
ROOM_MEMBERS 39002 RoomMembers relay member snapshot
ROOM_PINS 39005 RoomPins relay pinned messages
ROOM_ADD_MEMBER / ROOM_REMOVE_MEMBER 9000 / 9001 RoomAddMember / RoomRemoveMember admin addRoomMembers, RoomMemberMenu
ROOM_EDIT_META 9002 RoomEdit admin rooms.editRoom
ROOM_CREATE / ROOM_DELETE 9007 / 9008 RoomCreate / RoomDelete admin RoomForm, RoomDetailMenu
none (ROOM_CREATE_INVITE in src/app/access.ts) 9009 none admin publishRoomInvite
ROOM_UPDATE_PINS 9010 RoomUpdatePins admin roomPinLists.setPins
ROOM_JOIN / ROOM_LEAVE 9021 / 9022 RoomJoin / RoomLeave user joinRoom / leaveRoom in src/app/access.ts
ROOM_CREATE_PERMISSION 19004 RoomCreatePermission not checked deriveUserCanCreateRoom

@welshman/util also defines ROOM_ADD_PERM (9003), ROOM_REMOVE_PERM (9004), ROOM_DELETE_EVENT (9005) and ROOM_EDIT_STATUS (9006); flotilla uses none of them. An admin removes a message with NIP-86 banEvent instead (RoomItemMenu, EventMenu).

How Rooms builds a room

rooms.get().forRoom(url, h) yields {id, url, h, meta, members, admins} from the three relay-signed state kinds. A ROOM_DELETE tombstones the room when it is at least as new as all of that state, so a room re-created after deletion comes back. Membership (members(url, h), membershipStatus(url, h)) replays the 39002 snapshot, then newer 9000/9001 ops authored by an admin or the relay, then pending 9021/9022 requests, into MembershipStatus.Initial | Pending | Granted. pendingJoins(url, h?) lists unanswered join requests, and deriveSpaceActionItems (src/app/actionItems.ts) merges them with reports into the admin queue, keeping the half the user holds a method for — reports under banevent, join requests under allowpubkey.

src/app/rooms.ts puts space authority on top:

  • deriveUserIsRoomAdmin: a space's staff administer every room.
  • deriveUserRoomMembershipStatus: an admin is always Granted.
  • addRoomMembers: allows each non-member at the relay (NIP-86 allowPubkey) before publishing 9000, because a room member the relay won't serve can't read the room.
  • deriveUserRooms, deriveOtherRooms, deriveOtherVoiceRooms: rooms from the user's 10009 list and the rest of the space, limited to rooms the relay still advertises. meta.hasLivekit() marks a voice room.

Room flows

  • Create (RoomForm.svelte): rooms.createRoom (9007, h from randomId()), tolerating an "already" error, then rooms.editRoom (9002) with metadata and flags, then joinRoom.
  • Delete (RoomDetailMenu.svelte): rooms.deleteRoom (9008), then roomLists.removeRoom.
  • Join and leave (joinRoom, leaveRoom in src/app/access.ts): two publishes, the 9021/9022 the relay may refuse and the user's 10009 list, which is what puts the room in the sidebar. isMembershipRefusal counts duplicate: and "already a member" replies as success.
  • Invite (publishRoomInvite): a 9009 with a random code tag. The link carries h and code, and joinRoom(url, h, code) sends the code as the join's claim.
  • Pins: roomPinLists.setPins(url, h, pins) sends 9010 and the relay republishes 39005. deriveRoomPinnedEvents (src/app/roomPins.ts) loads the pinned events from the room's relay.

The relay enforces the RoomMetaReader flags (isClosed, isHidden, isPrivate, isRestricted); the UI only reflects them. Until membership is Granted, RoomChat hides a private room's messages and blocks posting to a restricted room, and RoomDetail describes the flags.

NIP-43 space membership

Constant Kind Factory Flotilla use
RELAY_MEMBERS 13534 RelayMembers relay-signed member list, relayMemberLists.forUrl(url)
RELAY_ADD_MEMBER / RELAY_REMOVE_MEMBER 8000 / 8001 RelayAddMember / RelayRemoveMember membership ops; the space chat shows 8000 the way a room shows 9000
RELAY_JOIN 28934 RelayJoin join request carrying a claim (publishJoinRequest)
RELAY_INVITE 28935 RelayInvite unused since claims moved to NIP-86
RELAY_LEAVE 28936 RelayLeave publishLeaveRequest
RELAY_ROLE 33534 RelayRole relay-signed role definitions (relayRoles)

Roles are a flotilla extension. A member tag is ["member", pubkey, ...roleIds], and deriveSpaceMemberRoles in src/app/roles.ts parses the role ids because RelayMembersReader has no getter for them yet. Roles are only ever changed over NIP-86 (createRole, editRole, deleteRole, assignRole, unassignRole), never by publishing 33534.

Joining a space (attemptRelayAccess and Access in src/app/access.ts):

  1. Open the socket and drive NIP-42 auth, retrying up to three times.
  2. Publish RelayJoin with the claim. The writer protects the event and requires a forced relay.
  3. Translate refusals: "invite code" means rejected, "claim" means the space needs an invite.
  4. completeJoin: roomLists.addRelay(url), restart sync, and Sync.push the user's RELAYS, MESSAGING_RELAYS, FOLLOWS and PROFILE to the space so other members can see them.

Invite links are ${PLATFORM_URL}/join?r=<relay>&c=<claim>, plus h and code for a room (makeInviteLink; parseInviteLink also accepts a bare relay URL). src/routes/join renders SpaceInviteAccept, which calls Access.acceptInvite to join the space and then the room. Access.prepareInvite gets a claim over NIP-86 (supportedmethods, then listclaims, then createclaim); this replaced reading RELAY_INVITE events. Leaving (SpaceExit, SpaceAuthError) is roomLists.removeRelay(url) plus publishLeaveRequest(url).

NIP-86 relay management

relayManagement.get().forUrl(url) returns welshman's ManagementApi: JSON-RPC over HTTP at the relay's URL, each call signed with a fresh NIP-98 event. Every method resolves to {result, error}.

Methods Called from
supportedMethods deriveSpaceSupportedMethods (src/app/management.ts), Access.prepareInvite
banPubkey, unbanPubkey, allowPubkey, unallowPubkey, listBannedPubkeys ProfileDetail, SpaceMemberMenu, SpaceMemberBannedMenu, SpaceInvite, ReportMenuList, addRoomMembers
banEvent RoomItemMenu, EventMenu, ReportMenuList, RoomJoinItem (dismissing a join request)
createRole, editRole, deleteRole, assignRole, unassignRole RoleCreate, RoleEdit, SpaceRoleMenu, SpaceMemberRoles, RoleAddMembers
listClaims, createClaim Access.prepareInvite
changeRelayName, changeRelayDescription, changeRelayIcon SpaceEdit

A relay answers supportedmethods with what the authenticated pubkey may call, so every control is gated on the method behind it: deriveSpaceSupportedMethods(url) (re-checked at most every five minutes per pubkey and URL) and $supportedMethods.includes("banpubkey"). Still handle an error from the call, since a listed method can be refused for a particular event or target. deriveUserIsSpaceStaff(url) is only "the list came back non-empty", which is all there is to go on for the room permissions NIP-86 has no method for — deriveUserIsRoomAdmin and deriveUserCanCreateRoom, which also takes ROOM_CREATE_PERMISSION grants.

The hosting backend in src/app/hosting.ts is a separate HTTP API at HOSTING_BACKEND_URL for relays the platform hosts. It authenticates with one NIP-98 header per pubkey, cached for a TTL, instead of signing every call. spaces/[relay]/admin is its page, not a NIP-86 console. Hosting is off on iOS (HOSTING_ENABLED).

Auth, trust and protected events

  • NIP-42. authPolicy in src/app/policies.ts never authenticates to a relay on the user's blocked-relay list and always does under relay_auth: aggressive. Under the default conservative, it authenticates only to relays in the user's room, relay or messaging-relay lists, or ones they have published to this session. attemptRelayAccess authenticates explicitly before a join.
  • Refusals. mostlyRestrictedPolicy counts restricted: and blocked: replies per socket. Once most requests fail, relaysMostlyRestricted turns the space status to "Access Denied" and the layout shows SpaceAuthError.
  • Unsigned events. Some relays strip signatures (hosted relays have a policy_strip_signatures flag). ingestPolicy and trustPolicy hold back unsigned events unless the relay is in the trusted_relays setting, while SpaceTrustRelay asks the user.
  • NIP-70. Space content is protected exactly when the relay advertises NIP-70, so every space publish passes setProtected(await relays.hasNip(url, 70)). Per NIP-70 (a protocol claim not verified in this repo) a relay then accepts the event only from its author, which keeps space content from being copied elsewhere. The RelayJoin, RelayLeave and RelayMembers writers protect themselves, and publishReaction and retractReaction in src/app/reactions.ts check the relay for their callers.

Which relays an event goes to

Space content goes to the space relay and nowhere else. The writer's routes decide where command.publish() sends an event. See flotilla-state for the publishing pipeline itself.

Tool Effect Use for
writer.setRoom(url, h) adds ["h", h] and forces relay(url) anything in a room
writer.forceRoutes(relay(url)) forces the relay, no h space-wide content, NIP-43 requests
default routes the user's outbox plus inboxes of p-tagged pubkeys profile, lists, settings, anything outside a space
command.publishToRelays(urls) ignores the writer's relays reactions, deletes, reports, comments, replies
wraps.get().publish({event, recipients}) a NIP-59 wrap per recipient, to their MESSAGING_RELAYS DMs and DM reactions and deletes

validate() throws when an h tag has no forced route, and every room and relay-membership writer sets requiresRelays, so a missing relay fails loudly. A content form forces the space relay and adds setRoom only when it is posting into a room, as ThreadCreate, ClassifiedForm, CalendarEventForm, GoalCreate, PollCreate and the article create route all do.

Some routing is built into welshman. Reactions.react and Deletes.deleteEvent find the target's relay in the tracker and copy its h. The 10009 writer publishes to the user's outbox and to every space it lists or used to list, so each relay hears about joins and leaves. Kind 9 has no factory, so RoomChat and publishRoomQuote publish raw templates through Thunks.

Reads are scoped the same way. Request from the space relay (relays: [url], plus "#h": [h] for a room) and read results with deriveEventsForUrl(url, filters), which uses the tracker to keep only events seen on that relay. A repository-wide query would mix rooms that share an h across relays. src/app/sync.ts pulls each space's room state, membership and recent content in the background; see flotilla-state.

Content kinds

The space sections are the kinds in CONTENT_KINDS (src/app/content.ts): ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED, POLL, PINBOARD and LONG_FORM. Each has list and detail routes under spaces/[relay]/, reached through makeContentPath in src/app/routes.ts. Chat is MESSAGE (kind 9). Comments (COMMENT, NIP-22) thread under any content kind, and makeCommentFilter(kinds) selects them by #K. A content form with shareToChat set also posts a kind 9 quoting the new event (publishRoomQuote), so clients that only render chat still see it. Zaps and goals are off on iOS (ENABLE_ZAPS in src/app/env.ts).

kinds.md lists every kind by feature, with its factory, module and route.

Adding a kind

  1. Add the constant to @welshman/util and a factory to @welshman/domain upstream (see welshman-domain, "Adding a new kind").
  2. Publish through writer(Factory) and command(...), with setRoom or forceRoutes and setProtected(await relays.hasNip(url, 70)) for space content.
  3. For a new space section, add the kind to CONTENT_KINDS (which feeds sync, notifications, push, search and SpaceMenuNavItems), CONTENT_NOUNS and makeContentPath, and add routes under spaces/[relay]/.
  4. If users sign it through a remote signer, add it to NIP46_PERMS in src/app/nip46.ts.
  5. If it is relay-scoped state that has to survive a reload, add it to the kinds map in src/app/storage.ts.
  6. If the relay signs it, read it through a RelaySignedDerivedPlugin.

Parsing and building events

AGENTS.md gives the rule. In protocol code a hand-built event also loses behavior the writer carries: RelayJoinWriter adds -, DeleteWriter.addEvent copies the target's h and routes to its relay, RoomListWriter publishes to every listed space, and validate() refuses a room event with no relay.

In order of preference:

  1. The kind's reader: reader(Thread)(event).title(), reader(TimeEvent)(event).start().
  2. Base reader getters when the kind is known (room(), protect(), expiration(), imeta()), or the standalone helpers when it isn't (getImeta, getExpiration, getReplyTags, getCommentTagValues, getEmojis).
  3. tagValue(tagSpec("h"), event.tags) in code that handles many kinds at once (notifications, makeEventPath, feed grouping), where no single reader applies.

When welshman lacks a kind or a getter, add it there rather than working around it in the app. A tag spec is the right tool only for a tag nothing else will read, like the content tags on featured-content app data.

Known exceptions

These predate the rule or are waiting on welshman. Don't copy them; fix one when you touch it.

  • No factory yet: MESSAGE (9) in RoomChat and publishRoomQuote; 9009 room invites in src/app/access.ts; LIVEKIT_PARTICIPANTS (39004, a local constant in src/app/call.ts); STATUS (30315) in src/app/statuses.ts, where ProfileStatus still uses tags.find; push subscriptions (a literal 30390) in src/app/push/adapters/capacitor.ts; DIRECT_MESSAGE_FILE (15) in Chat.svelte.
  • Factory exists but bypassed: DMs built with makeEvent in Chat.svelte (DirectMessage); relay lists in SignUp.svelte (RelayList, MessagingRelayList); deletes in ProfileDelete.svelte and the push adapter (Delete); the vanish request as a literal 62 (VANISH).
  • Getter exists but bypassed: titles in src/app/title.ts, NoteContentThread and the threads pages; calendar start/end in src/app/feeds.ts and the calendar page; poll response tags in PollVotes; p tags of RELAY_ADD_MEMBER, ROOM_ADD_MEMBER and ROOM_CREATE_PERMISSION (SpaceMembersSummary, RoomItemAddMember, src/app/management.ts); comment E/A tags in the list pages and src/app/classifieds.ts; imeta unpacking in src/app/content.ts and Chat.svelte.
  • Getter missing upstream: role ids on member tags (src/app/roles.ts); the legacy name fallback for calendar titles (CalendarEventHeader).

Mind the name clash: ROOM from @app/rooms is the tag name "h", while ROOM from @welshman/util is kind 35834.

  • flotilla-architecture: where protocol code sits among the layers and modules
  • flotilla-state: the App, plugins, background sync, persistence, and the publishing pipeline
  • flotilla-views: routes, components, and loading data from components
  • welshman-domain: readers, writers, and adding a kind
  • welshman-util: kind constants, tag specs, RelaySelection routing, NIP-42/86/98 helpers
  • welshman-app: Rooms, RelayManagement, RelaySignedDerivedPlugin, Command, Wraps
  • welshman-net: sockets, auth state and socket policies