/searchable-events
The NIP-50 indexing surface of Quartz — the `SearchableEvent` interface, which event kinds are searchable, exactly what text each kind's `indexableContent()` contributes, how the SQLite/filesystem stores consume it, and the NIP-50 `SearchQuery` extension grammar plus
$ npx -y skills add vitorpamplona/amethyst --skill searchable-events --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
/searchable-events
Context preview
The summary Claude sees to decide when to auto-load this skill.
The NIP-50 indexing surface of Quartz — the `SearchableEvent` interface, which event kinds are searchable, exactly what text each kind's `indexableContent()` contributes, how the SQLite/filesystem stores consume it, and the NIP-50 `SearchQuery` extension grammar plus
SKILL.md
searchable-events.SKILL.mdname: searchable-events
description: The NIP-50 indexing surface of Quartz — the `SearchableEvent` interface, which event kinds are searchable, exactly what text each kind's `indexableContent()` contributes, how the SQLite/filesystem stores consume it, and the NIP-50 `SearchQuery` extension grammar plus `SearchRelayListEvent` (kind 10007). Use when making a kind searchable, changing what a kind indexes, diffing the searchable set at a Quartz version bump (external search engines mirror this table), debugging why an event is or isn't found by search, or working with search extensions (`include:spam`, `domain:`, …).
Searchable Events — the NIP-50 indexing surface
The contract
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableEvent.kt`:
interface SearchableEvent {
fun indexableContent(): String
}One method; marker and extractor in one. An event kind is searchable **iff** its event class implements this interface **and** the class is wired into `EventFactory` (the stores probe searchability by kind through `EventFactory.create` — an unwired implementor is invisible).
Rules every implementation follows (keep them when adding one):
- **Plain text out.** Return the human-meaningful fields joined with `"\n"` (a handful of
metadata-ish kinds use `" "`); no markup stripping is performed — markdown/asciidoc content goes in raw, JSON-content kinds (kind 0 metadata, marketplace stalls, channel info) **parse first and join the extracted fields**, never the raw JSON.
- **Never throw, never null.** There is no defensive wrapper at any call site; a throw aborts
the insert transaction. Parsed-JSON implementations use `?.let { … } ?: ""`.
- **Only public data.** Encrypted content stays out (e.g. kind 30382 contact cards index only
the public petname/summary/topics, never the NIP-44 payload).
- Typical shapes: `content` alone (~33 kinds); `listOfNotNull(title(), content)`;
`listOfNotNull(title(), summary(), content)`; lists index `title() + description()`.
The full kind table
**`references/searchable-kinds.md`** in this skill holds the authoritative table — every implementor with its kind number, class, and the exact `indexableContent()` expression (126 concrete classes / 129 kind values as of 2026-08). Diff that file at a version bump to answer "did the searchable set or any kind's indexed text change?".
Notables that surprise people:
- **Kind 9735 (zap receipt) indexes the embedded zap request's content**
(`zapRequest?.content.orEmpty()`) — receipts are searchable by the zapper's comment.
- **Kind 0 / 31990** index many profile fields space-joined (name, about, nip05, lud16,
website, picture URL, …).
- **Kind 30063 is claimed twice** (`ReleaseArtifactSetEvent` in nip51Lists and the experimental
`SoftwareReleaseEvent`); `EventFactory` resolves 30063 to `ReleaseArtifactSetEvent`, so `title()\ndescription()` is what actually gets indexed — `SoftwareReleaseEvent.indexableContent()` is dead on the store path.
- Poll kinds (1068, 6969) append each option label on its own line.
MANDATORY maintenance when you touch this surface
Adding `SearchableEvent` to a kind, removing it, or changing any `indexableContent()` body:
1. **Update `references/searchable-kinds.md`** in the same PR (external search engines — e.g. the Vespa-backed store's `SearchExtractors` — mirror this table at pin bumps; a silent change ships them stale search results). 2. **Remember existing databases don't reindex themselves.** Old rows keep their old (or missing) FTS text until `IEventStore.reindexFullTextSearch()` runs — the KDoc on that method is the contract. App-side, schedule the resumable overload after shipping such a change. 3. New implementors must be **registered in `EventFactory`** or the reindex scan and kind pre-filter (`FullTextSearchModule.isSearchableKind`) will never see them.
Eligibility policy: a kind becomes searchable when it carries human-authored, human-meaningful text (titles, bodies, names, descriptions). Pure-machine kinds (reactions, follow lists, zaps minus their comment, relay lists) stay out to keep the index small.
How the stores consume it
**SQLite** (`nip01Core/store/sqlite/FullTextSearchModule.kt`): `CREATE VIRTUAL TABLE event_fts USING fts5(content, content='', contentless_delete=1)` — contentless, `rowid` = `event_headers.row_id`, an `AFTER DELETE` trigger keeps it in sync. On insert (when FTS is on and not deferred): `if (event is SearchableEvent)` → bind `event.indexableContent()` — the only method ever called. Tokenization is entirely SQLite's default FTS5 `unicode61`; queries are always a bound `event_fts MATCH ?` (never concatenated), ordered by bm25 `rank` then `created_at DESC`. Query-side semantics (relevance ordering, extension stripping, FTS-off behavior, deferred catch-up) are rules STORE-S01…S06 in the `event-store-semantics` skill.
**Filesystem store** (`jvmMain/.../store/fs/FsIndexer.kt` + `FsSearchTokenizer.kt`): tokenizes `indexableContent()` itself, approximating `unicode61` (split on non-letter/digit, lowercase); the same tokenizer runs on queries so drift cancels.
NIP-50 client side
**`SearchQuery`** (`nip50Search/SearchQuery.kt`) — typed parse of the `search` filter string into `terms` + `extensions`. A whitespace token is an extension iff it looks like `lowercasekey:value` (the value not starting with `//`, so URLs stay free text); duplicate keys keep the last; unknown extensions are preserved (`extension(key)`). Typed accessors: `includeSpam`, `domain`, `language`, `sentiment`, `nsfw`. `stripExtensions()` / `Filter.strippingSearchExtensions()` is the bridge the built-in stores use — unsupported extensions are **ignored** (NIP-50), so an extensions-only search collapses to an unconstrained query, never match-nothing. A server-side store that implements its own extensions (`observer:`, `sort:rank`, …) receives the raw string (see the `IEventStore` KDoc) and should
Read more
name: searchable-events description: The NIP-50 indexing surface of Quartz — the `SearchableEvent` interface, which event kinds are searchable, exactly what text each kind's `indexableContent()` contributes, how the SQLite/filesystem stores consume it, and the NIP-50 `SearchQuery` extension grammar plus `SearchRelayListEvent` (kind 10007). Use when making a kind searchable, changing what a kind indexes, diffing the searchable set at a Quartz version bump (external search engines mirror this table), debugging why an event is or isn't found by search, or working with search extensions (`include:spam`, `domain:`, …).
Searchable Events — the NIP-50 indexing surface
The contract
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableEvent.kt`:
interface SearchableEvent {
fun indexableContent(): String
}One method; marker and extractor in one. An event kind is searchable **iff** its event class implements this interface **and** the class is wired into `EventFactory` (the stores probe searchability by kind through `EventFactory.create` — an unwired implementor is invisible).
Rules every implementation follows (keep them when adding one):
- **Plain text out.** Return the human-meaningful fields joined with `"\n"` (a handful of
metadata-ish kinds use `" "`); no markup stripping is performed — markdown/asciidoc content goes in raw, JSON-content kinds (kind 0 metadata, marketplace stalls, channel info) **parse first and join the extracted fields**, never the raw JSON.
- **Never throw, never null.** There is no defensive wrapper at any call site; a throw aborts
the insert transaction. Parsed-JSON implementations use `?.let { … } ?: ""`.
- **Only public data.** Encrypted content stays out (e.g. kind 30382 contact cards index only
the public petname/summary/topics, never the NIP-44 payload).
- Typical shapes: `content` alone (~33 kinds); `listOfNotNull(title(), content)`;
`listOfNotNull(title(), summary(), content)`; lists index `title() + description()`.
The full kind table
**`references/searchable-kinds.md`** in this skill holds the authoritative table — every implementor with its kind number, class, and the exact `indexableContent()` expression (126 concrete classes / 129 kind values as of 2026-08). Diff that file at a version bump to answer "did the searchable set or any kind's indexed text change?".
Notables that surprise people:
- **Kind 9735 (zap receipt) indexes the embedded zap request's content**
(`zapRequest?.content.orEmpty()`) — receipts are searchable by the zapper's comment.
- **Kind 0 / 31990** index many profile fields space-joined (name, about, nip05, lud16,
website, picture URL, …).
- **Kind 30063 is claimed twice** (`ReleaseArtifactSetEvent` in nip51Lists and the experimental
`SoftwareReleaseEvent`); `EventFactory` resolves 30063 to `ReleaseArtifactSetEvent`, so `title()\ndescription()` is what actually gets indexed — `SoftwareReleaseEvent.indexableContent()` is dead on the store path.
- Poll kinds (1068, 6969) append each option label on its own line.
MANDATORY maintenance when you touch this surface
Adding `SearchableEvent` to a kind, removing it, or changing any `indexableContent()` body:
1. **Update `references/searchable-kinds.md`** in the same PR (external search engines — e.g. the Vespa-backed store's `SearchExtractors` — mirror this table at pin bumps; a silent change ships them stale search results). 2. **Remember existing databases don't reindex themselves.** Old rows keep their old (or missing) FTS text until `IEventStore.reindexFullTextSearch()` runs — the KDoc on that method is the contract. App-side, schedule the resumable overload after shipping such a change. 3. New implementors must be **registered in `EventFactory`** or the reindex scan and kind pre-filter (`FullTextSearchModule.isSearchableKind`) will never see them.
Eligibility policy: a kind becomes searchable when it carries human-authored, human-meaningful text (titles, bodies, names, descriptions). Pure-machine kinds (reactions, follow lists, zaps minus their comment, relay lists) stay out to keep the index small.
How the stores consume it
**SQLite** (`nip01Core/store/sqlite/FullTextSearchModule.kt`): `CREATE VIRTUAL TABLE event_fts USING fts5(content, content='', contentless_delete=1)` — contentless, `rowid` = `event_headers.row_id`, an `AFTER DELETE` trigger keeps it in sync. On insert (when FTS is on and not deferred): `if (event is SearchableEvent)` → bind `event.indexableContent()` — the only method ever called. Tokenization is entirely SQLite's default FTS5 `unicode61`; queries are always a bound `event_fts MATCH ?` (never concatenated), ordered by bm25 `rank` then `created_at DESC`. Query-side semantics (relevance ordering, extension stripping, FTS-off behavior, deferred catch-up) are rules STORE-S01…S06 in the `event-store-semantics` skill.
**Filesystem store** (`jvmMain/.../store/fs/FsIndexer.kt` + `FsSearchTokenizer.kt`): tokenizes `indexableContent()` itself, approximating `unicode61` (split on non-letter/digit, lowercase); the same tokenizer runs on queries so drift cancels.
NIP-50 client side
**`SearchQuery`** (`nip50Search/SearchQuery.kt`) — typed parse of the `search` filter string into `terms` + `extensions`. A whitespace token is an extension iff it looks like `lowercasekey:value` (the value not starting with `//`, so URLs stay free text); duplicate keys keep the last; unknown extensions are preserved (`extension(key)`). Typed accessors: `includeSpam`, `domain`, `language`, `sentiment`, `nsfw`. `stripExtensions()` / `Filter.strippingSearchExtensions()` is the bridge the built-in stores use — unsupported extensions are **ignored** (NIP-50), so an extensions-only search collapses to an unconstrained query, never match-nothing. A server-side store that implements its own extensions (`observer:`, `sort:rank`, …) receives the raw string (see the `IEventStore` KDoc) and should
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

