New to Claude Skills? Learn how to install them →

vitorpamplona on GitHub

Relay Client

Free

Manage subscriptions and filters for Amethyst's relay client.

Get this skill

Free · Opens the source repo

What Relay Client does

Relay Client is a skill designed for developers working with the Amethyst relay client layer. It provides a structured approach to handle subscriptions and filter assemblies within the commons/.../relayClient/ directory. This skill is particularly useful when managing compose-scoped subscriptions, such as those implemented by ComposeSubscriptionManager and Subscribable. It also supports filter assemblers like MetadataFilterAssembler, ReactionsFilterAssembler, and preloaders like MetadataPreloader, enabling efficient data handling and lifecycle management for composables.

The skill streamlines the process of subscribing to data while ensuring that only relevant information is fetched and displayed. It deduplicates filters across different screens, which is essential for applications that require multiple views of similar data. For instance, when a new screen needs to display events, developers can easily create a new FilterAssembler to manage this without redundant data fetching. The skill also includes mechanisms for preloading metadata and profile pictures for a set of pubkeys, which can significantly enhance user experience by reducing loading times.

In addition to managing subscriptions, Relay Client handles End Of Stream Events (EOSE), which allows the application to transition smoothly from loading states to displaying historical data. This is crucial for applications that require real-time data updates while maintaining a responsive user interface. The skill is built to work seamlessly with Kotlin coroutines and integrates well with existing composable structures, making it an essential tool for any developer looking to enhance their Amethyst-based applications.

When to use it

Use this skill when developing screens that require dynamic data subscriptions or when implementing filter assemblers for efficient data handling.

When not to use it

This skill may not be suitable for applications that do not utilize the Amethyst framework or for static data scenarios where subscriptions are unnecessary.

What you can build with it

Dynamic Event Display

When adding a new screen that requires displaying events, use Relay Client to create a `FilterAssembler` for efficient data retrieval.

Profile Metadata Preloading

Utilize Relay Client to preload metadata and profile pictures for a set of pubkeys, enhancing the user experience by reducing loading times.

Lifecycle-Aware Subscriptions

Implement `ComposeSubscriptionManager` to wire a composable that subscribes on enter and unsubscribes on leave, ensuring optimal resource usage.

How to install Relay Client

View source

1. Install with the skills CLI

npx skills add vitorpamplona/amethyst/relay-client --agent claude-code

2. Or install it manually

Download the skill folder and drop it into ~/.claude/skills/ for all projects, or .claude/skills/ to scope it to one repo. Restart Claude Code so it picks up the new skill.

Anthropic's agentic coding CLI, and the reference implementation of Agent Skills. Drop a skill folder into ~/.claude/skills and Claude Code loads it automatically whenever a task matches the skill's description. Claude Code docs

Inside SKILL.md

Written by vitorpamplona

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.

Per-visible loading — the canonical entry points (observeUser* / observeNote*)

Prefer these over hand-rolled "load metadata for this list" calls. They are the shared, KMP way to load data only for what's on screen — a composable subscribes while it is in composition and unsubscribes ~30s after it leaves (or the app backgrounds). Both live in commons/relayClient/:

  • Per user (relayClient/user/): observeUserInfo/Picture/Banner/AboutMe/Name(user) each open a composition-scoped UserFinderFilterAssemblerSubscription(user) and return reactive State. Metadata (kind 0 + relay lists) loads for on-screen users only, coalesced into one batched REQ per relay for the whole visible set.
  • Per note (relayClient/event/): EventFinderFilterAssemblerSubscription(note) loads a note's interactions (reactions / zaps / reposts / replies) while it is composed. Android's observeNote* display observers layer on top of the same subscription.

Both read front-end-provided CompositionLocals — LocalUserFinder / LocalUserFinderAccount (reused by the event finder) / LocalEventFinder — provided once near the composition root (Android AppModules, Desktop Main.kt via its subscriptions coordinator). The account seam is the narrow UserFinderAccount (snapshot relay-hint getters), NOT the fat IAccount. error() defaults mean these must never be reached from a composition without a relay client (e.g. the Android :napplet sandbox).

The load-once, viewport-batch path (FeedMetadataCoordinator.loadMetadataForNotes / loadMetadataBatched) is superseded for foreground loading; MetadataPreloader remains only as an optional off-screen background warmer.

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, geode, tests, non-compose code): don't go through Subscribable — use the INostrClient extension functions in quartz/…/nip01Core/relay/client/accessories/ (fetchAll, fetchFirst, fetchAllPages, publishAndConfirm, count, negentropyReconcile/negentropySync, …). They're extensions, so they don't show up under "usages of NostrClient" — see that package's README.md for the catalog before writing a raw subscribe/collect loop.
  • nostr-expert/references/tag-patterns.md — how tags inform what a filter needs to look for.
  • kotlin-coroutines/references/relay-patterns.md — relay pool internals (sibling layer beneath assemblers).
  • feed-patterns skill — feeds compose several Subscribables (content + metadata + reactions).
  • account-state skill — Account's per-kind flows are themselves consumers of the relay-client layer.

Frequently asked questions about Relay Client

Similar skills