/relay-client
Subscription and filter-assembly patterns for the Amethyst relay client layer in `commons/.../relayClient/`. Use when working with compose-scoped subscriptions (`ComposeSubscriptionManager`, `Subscribable`), filter assemblers (`MetadataFilterAssembler`,
$ npx -y skills add vitorpamplona/amethyst --skill relay-client --agent claude-codeHow it fires
How this skill gets triggered: by you, by Claude, or both.
- Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
- You can call itInvoke it directly when you want it.
- Slash command
/relay-client
Context preview
The summary Claude sees to decide when to auto-load this skill.
Subscription and filter-assembly patterns for the Amethyst relay client layer in `commons/.../relayClient/`. Use when working with compose-scoped subscriptions (`ComposeSubscriptionManager`, `Subscribable`), filter assemblers (`MetadataFilterAssembler`,
SKILL.md
relay-client.SKILL.mdname: relay-client
description: Subscription and filter-assembly patterns for the Amethyst relay client layer in `commons/.../relayClient/`. Use when working with compose-scoped subscriptions (`ComposeSubscriptionManager`, `Subscribable`), filter assemblers (`MetadataFilterAssembler`, `ReactionsFilterAssembler`, `FeedMetadataCoordinator`), preloaders (`MetadataPreloader`, `MetadataRateLimiter`), EOSE managers, or any feature that needs to talk to relays lifecycle-aware from a composable. Complements `nostr-expert` (protocol filter syntax) and `kotlin-coroutines` (callbackFlow patterns).
Relay Client & Subscriptions
The layer between `LocalCache`/`Account` and the raw relay connection. Ensures composables only subscribe to what is visible, deduplicates filters across screens, and rate-limits bulk queries like "fetch metadata for these 200 pubkeys".
When to Use This Skill
- Adding a new screen that needs events it doesn't already have (write a `FilterAssembler`).
- Wiring a composable to subscribe on enter / unsubscribe on leave (`ComposeSubscriptionManager`).
- Preloading metadata / profile pictures for a set of pubkeys (`MetadataPreloader`).
- Deduplicating identical filters across concurrent screens.
- Handling EOSE → "we have historical data, stop showing loading" transitions.
Layout
All under `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/`:
relayClient/
├── assemblers/ # "Given these inputs, build this relay Filter"
│ ├── MetadataFilterAssembler.kt # kind 0 for N pubkeys
│ ├── ReactionsFilterAssembler.kt # kind 7 for N note ids
│ ├── FeedMetadataCoordinator.kt # coordinates metadata loads for a feed
│ └── CashuMintDirectoryFilterAssembler.kt / CashuWalletFilterAssembler.kt
├── composeSubscriptionManagers/
│ ├── ComposeSubscriptionManager.kt # interface Subscribable<T>
│ ├── MutableComposeSubscriptionManager.kt # reference impl
│ └── ComposeSubscriptionManagerControls.kt # DisposableEffect-style controls
├── eoseManagers/ # EOSE tracking per subscription
│ └── IEoseManager / BaseEoseManager / PerKeyEoseManager / SingleSubEoseManager
├── nip17Dm/ # gift-wrap DM plumbing
│ └── FilterGiftWrapsToPubkey.kt / GiftWrapDecryptor.kt
├── preload/
│ ├── MetadataPreloader.kt # bulk-fetch metadata with rate limiting
│ └── MetadataRateLimiter.kt # token-bucket-ish limiter
└── subscriptions/
├── KeyDataSourceSubscription.kt # "this set of keys drives this filter"
├── LifecycleAwareKeyDataSourceSubscription.kt
└── PrioritizedSubscriptionQueue.kt / SubscriptionPriority.ktCore Concept: `Subscribable<T>`
// composeSubscriptionManagers/ComposeSubscriptionManager.kt
interface Subscribable<T> {
val state: StateFlow<T>
fun subscribe()
fun unsubscribe()
}Every feature-level manager implements or embeds a `Subscribable`. The `MutableComposeSubscriptionManager` reference implementation uses reference-counting so that two screens asking for the same feed share one subscription, and only the last leaver actually closes it.
`ComposeSubscriptionManagerControls.kt` provides `DisposableEffect`-style helpers so composables don't leak subscriptions when the user navigates away or the process backgrounds.
Typical Flow
@Composable
fun ProfileHeader(pubKey: HexKey) {
val subscription = rememberSubscribable(pubKey) {
MetadataFilterAssembler(setOf(pubKey)).toSubscribable()
}
LaunchedEffect(pubKey) { subscription.subscribe() }
DisposableEffect(pubKey) { onDispose { subscription.unsubscribe() } }
val metadata by subscription.state.collectAsStateWithLifecycle()
// render metadata…
}The assembler produces a `Filter` (see `quartz/.../nip01Core/relay/RelayFilters.kt` in the quartz module). The `RelayPool` below dedups, opens subs, emits events to `LocalCache.consume`, and emits EOSE through the eose manager.
Assemblers
An assembler is a plain class:
class MetadataFilterAssembler(
private val pubKeys: Set<HexKey>,
) {
fun toFilter(): Filter = filter {
kinds(MetadataEvent.KIND)
authors(pubKeys)
limit(pubKeys.size)
}
}Assemblers stay pure — no state, no I/O. They're the composition seam: `FeedMetadataCoordinator` takes a list of visible notes and assembles a single metadata filter covering every referenced pubkey.
Preloaders
`MetadataPreloader` is the "I need metadata for 200 pubkeys, but don't melt my CPU or the relay" path. It uses `MetadataRateLimiter` (token bucket) to throttle bulk fetches and group them into relay-friendly chunks.
Related: `amethyst/.../service/images/ImageLoaderSetup.kt` also uses preloaders for blurhash hydration — they're a general pattern, not metadata-specific.
EOSE Handling
Each subscription tracks "End of Stored Events" per relay. The eose manager in `eoseManagers/` aggregates per-relay EOSE into a single "loading done" boolean that the UI uses to hide spinners. Without aggregation, composables would flicker as individual relays ack.
Patterns
DO
- Build one `Subscribable` per feature scope (screen / dialog / card).
- Dedupe via reference counting — multiple identical subscriptions should share.
- Use `DisposableEffect` / `LaunchedEffect` to tie sub/unsub to lifecycle.
- Put the relay `Filter` building in an assembler so the test is trivial.
- Route bulk metadata through `MetadataPreloader`; don't fire N subscriptions.
DON'T
- Don't call `RelayPool` / `NostrClient` directly from composables — always through a `Subscribable`.
- Don't hold a subscription past the composable's lifetime — memory & socket leaks.
- Don't build ad-hoc filters inline in composables — assemblers only.
- Don't preload metadata for everything — it's a rate-limited resource and competes with user-visible loads.
Related
- **Headless / one-shot client ops** (CLI, g
Read more
name: relay-client description: Subscription and filter-assembly patterns for the Amethyst relay client layer in `commons/.../relayClient/`. Use when working with compose-scoped subscriptions (`ComposeSubscriptionManager`, `Subscribable`), filter assemblers (`MetadataFilterAssembler`, `ReactionsFilterAssembler`, `FeedMetadataCoordinator`), preloaders (`MetadataPreloader`, `MetadataRateLimiter`), EOSE managers, or any feature that needs to talk to relays lifecycle-aware from a composable. Complements `nostr-expert` (protocol filter syntax) and `kotlin-coroutines` (callbackFlow patterns).
Relay Client & Subscriptions
The layer between `LocalCache`/`Account` and the raw relay connection. Ensures composables only subscribe to what is visible, deduplicates filters across screens, and rate-limits bulk queries like "fetch metadata for these 200 pubkeys".
When to Use This Skill
- Adding a new screen that needs events it doesn't already have (write a `FilterAssembler`).
- Wiring a composable to subscribe on enter / unsubscribe on leave (`ComposeSubscriptionManager`).
- Preloading metadata / profile pictures for a set of pubkeys (`MetadataPreloader`).
- Deduplicating identical filters across concurrent screens.
- Handling EOSE → "we have historical data, stop showing loading" transitions.
Layout
All under `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/`:
relayClient/
├── assemblers/ # "Given these inputs, build this relay Filter"
│ ├── MetadataFilterAssembler.kt # kind 0 for N pubkeys
│ ├── ReactionsFilterAssembler.kt # kind 7 for N note ids
│ ├── FeedMetadataCoordinator.kt # coordinates metadata loads for a feed
│ └── CashuMintDirectoryFilterAssembler.kt / CashuWalletFilterAssembler.kt
├── composeSubscriptionManagers/
│ ├── ComposeSubscriptionManager.kt # interface Subscribable<T>
│ ├── MutableComposeSubscriptionManager.kt # reference impl
│ └── ComposeSubscriptionManagerControls.kt # DisposableEffect-style controls
├── eoseManagers/ # EOSE tracking per subscription
│ └── IEoseManager / BaseEoseManager / PerKeyEoseManager / SingleSubEoseManager
├── nip17Dm/ # gift-wrap DM plumbing
│ └── FilterGiftWrapsToPubkey.kt / GiftWrapDecryptor.kt
├── preload/
│ ├── MetadataPreloader.kt # bulk-fetch metadata with rate limiting
│ └── MetadataRateLimiter.kt # token-bucket-ish limiter
└── subscriptions/
├── KeyDataSourceSubscription.kt # "this set of keys drives this filter"
├── LifecycleAwareKeyDataSourceSubscription.kt
└── PrioritizedSubscriptionQueue.kt / SubscriptionPriority.ktCore Concept: `Subscribable<T>`
// composeSubscriptionManagers/ComposeSubscriptionManager.kt
interface Subscribable<T> {
val state: StateFlow<T>
fun subscribe()
fun unsubscribe()
}Every feature-level manager implements or embeds a `Subscribable`. The `MutableComposeSubscriptionManager` reference implementation uses reference-counting so that two screens asking for the same feed share one subscription, and only the last leaver actually closes it.
`ComposeSubscriptionManagerControls.kt` provides `DisposableEffect`-style helpers so composables don't leak subscriptions when the user navigates away or the process backgrounds.
Typical Flow
@Composable
fun ProfileHeader(pubKey: HexKey) {
val subscription = rememberSubscribable(pubKey) {
MetadataFilterAssembler(setOf(pubKey)).toSubscribable()
}
LaunchedEffect(pubKey) { subscription.subscribe() }
DisposableEffect(pubKey) { onDispose { subscription.unsubscribe() } }
val metadata by subscription.state.collectAsStateWithLifecycle()
// render metadata…
}The assembler produces a `Filter` (see `quartz/.../nip01Core/relay/RelayFilters.kt` in the quartz module). The `RelayPool` below dedups, opens subs, emits events to `LocalCache.consume`, and emits EOSE through the eose manager.
Assemblers
An assembler is a plain class:
class MetadataFilterAssembler(
private val pubKeys: Set<HexKey>,
) {
fun toFilter(): Filter = filter {
kinds(MetadataEvent.KIND)
authors(pubKeys)
limit(pubKeys.size)
}
}Assemblers stay pure — no state, no I/O. They're the composition seam: `FeedMetadataCoordinator` takes a list of visible notes and assembles a single metadata filter covering every referenced pubkey.
Preloaders
`MetadataPreloader` is the "I need metadata for 200 pubkeys, but don't melt my CPU or the relay" path. It uses `MetadataRateLimiter` (token bucket) to throttle bulk fetches and group them into relay-friendly chunks.
Related: `amethyst/.../service/images/ImageLoaderSetup.kt` also uses preloaders for blurhash hydration — they're a general pattern, not metadata-specific.
EOSE Handling
Each subscription tracks "End of Stored Events" per relay. The eose manager in `eoseManagers/` aggregates per-relay EOSE into a single "loading done" boolean that the UI uses to hide spinners. Without aggregation, composables would flicker as individual relays ack.
Patterns
DO
- Build one `Subscribable` per feature scope (screen / dialog / card).
- Dedupe via reference counting — multiple identical subscriptions should share.
- Use `DisposableEffect` / `LaunchedEffect` to tie sub/unsub to lifecycle.
- Put the relay `Filter` building in an assembler so the test is trivial.
- Route bulk metadata through `MetadataPreloader`; don't fire N subscriptions.
DON'T
- Don't call `RelayPool` / `NostrClient` directly from composables — always through a `Subscribable`.
- Don't hold a subscription past the composable's lifetime — memory & socket leaks.
- Don't build ad-hoc filters inline in composables — assemblers only.
- Don't preload metadata for everything — it's a rate-limited resource and competes with user-visible loads.
Related
- **Headless / one-shot client ops** (CLI, g
Other skills on amethyst.
- /account-state
Account state and in-memory event store patterns in Amethyst. Use when working with `Account.kt` (per-user state objects — `kind3FollowList`, `nip65RelayList`, `muteList`, `bookmarkState`, each exposing a `.flow` StateFlow), `LocalCache` (the object-level event store backed by
Open skill - /amy-expert
Patterns for extending `amy`, the Amethyst CLI in `cli/`. Use when adding an `amy <verb>` command, touching files under `cli/src/main/kotlin/…/cli/`, wiring a new subcommand into `Main.kt`, writing an interop test script that drives Amy, or extracting logic out of `amethyst/`
Open skill - /android-expert
Android platform patterns for the `amethyst/` module. Use when working with (1) Android navigation (Navigation Compose, type-safe routes, bottom nav), (2) runtime permissions (camera, notifications, biometrics), (3) platform APIs (Intent, Context, Activity, ContentResolver), (4)
Open skill - /auth-signers
Signer abstraction patterns in Amethyst. Use when working with event signing, choosing between a local keypair (`NostrSignerInternal`), a remote NIP-46 bunker signer (`NostrSignerRemote`), or a NIP-55 Android external-app signer (`NostrSignerExternal`). Covers the abstract
Open skill - /compose-expert
Advanced Compose Multiplatform UI patterns for shared composables. Use when working with visual UI components, state management patterns (remember, derivedStateOf, produceState), recomposition optimization (@Stable/@Immutable visual usage), Material3 theming, custom ImageVector
Open skill - /compose-modifier-and-layout-style
Use when writing or reviewing Jetpack Compose layout APIs, modifier parameters, modifier chain construction, hardcoded root layout decisions, or layout wrappers around a single conditional. Technique-layer skill — complements the codebase-specific compose-expert.
Open skill

