
Relay Client
FreeManage subscriptions and filters for Amethyst's relay client.
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 source1. Install with the skills CLI
npx skills add vitorpamplona/amethyst/relay-client --agent claude-code2. 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 vitorpamplonaRelay 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-scopedUserFinderFilterAssemblerSubscription(user)and return reactiveState. 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'sobserveNote*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
Subscribableper feature scope (screen / dialog / card). - Dedupe via reference counting — multiple identical subscriptions should share.
- Use
DisposableEffect/LaunchedEffectto tie sub/unsub to lifecycle. - Put the relay
Filterbuilding in an assembler so the test is trivial. - Route bulk metadata through
MetadataPreloader; don't fire N subscriptions.
DON'T
- Don't call
RelayPool/NostrClientdirectly from composables — always through aSubscribable. - 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 theINostrClientextension functions inquartz/…/nip01Core/relay/client/accessories/(fetchAll,fetchFirst,fetchAllPages,publishAndConfirm,count,negentropyReconcile/negentropySync, …). They're extensions, so they don't show up under "usages ofNostrClient" — see that package'sREADME.mdfor 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-patternsskill — feeds compose several Subscribables (content + metadata + reactions).account-stateskill —Account's per-kind flows are themselves consumers of the relay-client layer.
Frequently asked questions about Relay Client
Similar skills
Playwright Component Testing
Test React and Vue components in isolation with Playwright.
Fluent UI Blazor
Integrate Fluent UI components in Blazor applications effortlessly.
Build MCP App
Create interactive UI widgets for MCP servers.
Web Design Reviewer
Identify and fix design issues in websites efficiently.
Markstream Install
Seamlessly integrate Markstream for Markdown rendering.
GSAP & Framer Scroll Animation
Create advanced scroll animations effortlessly.
