Skip to content
Development
Skill

/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`,

From plugin
amethyst
1.6k30 skills3 commands
Install
$ npx -y skills add vitorpamplona/amethyst --skill relay-client --agent claude-code

How 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.md
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.kt

Core 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
Ships withamethyst

Nostr client for Android

Get the whole plugin

Other skills on amethyst.