Skip to content
quartz-integration logo

Quartz Integration Guide

quartz-integration

Integration guide for using the Quartz Nostr KMP library in external projects. Use when: (1) adding Quartz as a Gradle dependency, (2) setting up NostrClient with WebSocket, (3) creating/signing/sending events, (4) building relay subscriptions with Filter, (5) handling keys with KeyPair/NostrSign...

vitorpamplona/amethyst0installs1.6Kstars

SKILL.md

Full skill instructions

Quartz Integration Guide

Reference for integrating com.vitorpamplona.quartz:quartz into external Nostr KMP projects.

Published artifact: com.vitorpamplona.quartz:quartz:1.11.0 (Maven Central) Targets: JVM 21+, Android (minSdk 21+), iOS (XCFramework quartz-kmpKit) License: MIT


1. Gradle Setup

Version Catalog (libs.versions.toml)

[versions]
quartz = "1.11.0"

[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }

build.gradle.kts (KMP project)

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(libs.quartz)
        }
    }
}

Android-only project

dependencies {
    implementation("com.vitorpamplona.quartz:quartz:1.11.0")
}

Transitive dependencies pulled in automatically

Quartz exposes these as api (you get them transitively):

DependencyUsed for
fr.acinq.secp256k1:secp256k1-kmp-*Schnorr signing
com.github.anthonynsimon:rfc3986-normalizerRelay URL normalization
com.fasterxml.jackson.module:jackson-module-kotlinEvent JSON parsing

For Android, add to build.gradle.kts:

android {
    packaging {
        resources.excludes += "/​META-INF/​{AL2.0,LGPL2.1}"
    }
}

2. Key Concepts

Core Types

typealias HexKey = String        // 64-char hex string (pubkey, event id, sig)
typealias Kind = Int             // Event kind number
typealias TagArray = Array<Array<String>>

Event Anatomy

@Immutable
open class Event(
    val id: HexKey,        // SHA-256 of canonical JSON (64 hex chars)
    val pubKey: HexKey,    // Author public key (64 hex chars)
    val createdAt: Long,   // Unix timestamp (seconds)
    val kind: Kind,        // Event type
    val tags: TagArray,    // [["e","eventid"], ["p","pubkey"], ...]
    val content: String,
    val sig: HexKey,       // Schnorr signature (128 hex chars)
)

Kind Classification

// Regular events — stored by relays forever
val isRegular = kind in 1..9999

// Replaceable events — relay keeps only latest per (pubkey, kind)
val isReplaceable = kind == 0 || kind == 3 || kind in 10000..19999

// Addressable events — relay keeps latest per (pubkey, kind, d-tag)
val isAddressable = kind in 30000..39999

// Ephemeral events — relays don't persist
val isEphemeral = kind in 20000..29999

3. Key Management

Generate a new keypair

import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair

// Generate fresh random keys
val keyPair = KeyPair()

// From existing private key bytes
val keyPair = KeyPair(privKey = myPrivKeyBytes)

// Read-only (public key only, cannot sign)
val keyPair = KeyPair(pubKey = myPubKeyBytes)

// Access
val pubKeyHex: String = keyPair.pubKey.toHexKey()
val privKeyHex: String? = keyPair.privKey?.toHexKey()

Convert between formats

import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser

// ByteArray → hex
val hex = byteArray.toHexKey()

// hex → ByteArray
val bytes = HexKey.decodeHex(hex)

// Bech32 import (npub, nsec)
val parsed = Nip19Parser.uriToRoute("npub1abc...")
// or
val parsed = Nip19Parser.uriToRoute("nsec1abc...")

4. Signing Events

NostrSignerInternal (local key, JVM + Android)

import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal

val keyPair = KeyPair()
val signer = NostrSignerInternal(keyPair)

// Sign any EventTemplate
val template = TextNoteEvent.build("Hello Nostr!")
val signedEvent: TextNoteEvent = signer.sign(template)

NostrSignerSync (synchronous, for testing)

import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync

val signerSync = NostrSignerSync(keyPair)
val event = signerSync.sign<TextNoteEvent>(
    createdAt = TimeUtils.now(),
    kind = 1,
    tags = emptyArray(),
    content = "Hello!"
)

NostrSigner interface (for custom signers)

abstract class NostrSigner(val pubKey: HexKey) {
    abstract fun isWriteable(): Boolean
    abstract suspend fun <T : Event> sign(createdAt: Long, kind: Int, tags: Array<Array<String>>, content: String): T
    abstract suspend fun nip04Encrypt(plaintext: String, toPublicKey: HexKey): String
    abstract suspend fun nip04Decrypt(ciphertext: String, fromPublicKey: HexKey): String
    abstract suspend fun nip44Encrypt(plaintext: String, toPublicKey: HexKey): String
    abstract suspend fun nip44Decrypt(ciphertext: String, fromPublicKey: HexKey): String
    abstract suspend fun deriveKey(nonce: HexKey): HexKey
    abstract fun hasForegroundSupport(): Boolean
    // Convenience: auto-detects NIP-04 vs NIP-44 by ciphertext format
    suspend fun decrypt(encryptedContent: String, fromPublicKey: HexKey): String
}

5. Creating Events

Using typed event builders (recommended)

import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent

// Kind 1 — Text note
val template = TextNoteEvent.build("Hello Nostr!")
val event: TextNoteEvent = signer.sign(template)

// Kind 1 — Reply
val replyTemplate = TextNoteEvent.build(
    note = "Interesting thread!",
    replyingTo = originalEventHintBundle
)

// Kind 7 — Reaction
val reactionTemplate = ReactionEvent.build(
    content = "+",          // "+" = like, "-" = dislike, emoji = custom
    originalNote = targetEvent
)

Using low-level Event.build DSL

import com.vitorpamplona.quartz.nip01Core.core.Event

val template = Event.build(
    kind = 1,
    content = "Hello world",
    createdAt = TimeUtils.now()
) {
    // TagArrayBuilder DSL
    add(arrayOf("p", mentionedPubKey))
    add(arrayOf("t", "nostr"))
    add(arrayOf("subject", "Greeting"))
}

val event: Event = signer.sign(template)

TagArrayBuilder DSL methods

// In the DSL lambda:
add(arrayOf("tagname", "value"))          // append
addFirst(arrayOf("tagname", "value"))     // prepend
addUnique(arrayOf("d", "my-slug"))        // replace all tags with same name
addAll(listOf(arrayOf("t", "tag1"), ...)) // bulk add
remove("tagname")                          // remove all with this name

6. Relay Client Setup (JVM / Android)

The relay client requires an OkHttp WebSocket builder (available on JVM + Android).

Minimal setup

import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import okhttp3.OkHttpClient

// Build the WebSocket factory
val okHttpClient = OkHttpClient.Builder().build()
val wsBuilder = BasicOkHttpWebSocket.Builder { _ -> okHttpClient }

// Create client (manages its own CoroutineScope internally)
val nostrClient = NostrClient(websocketBuilder = wsBuilder)
nostrClient.connect()

With custom OkHttpClient per relay

val wsBuilder = BasicOkHttpWebSocket.Builder { normalizedUrl ->
    if (normalizedUrl.url.contains(".onion")) {
        torEnabledOkHttpClient   // Tor proxy for .onion relays
    } else {
        regularOkHttpClient
    }
}

With custom CoroutineScope

val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val nostrClient = NostrClient(wsBuilder, scope = appScope)

7. Subscribing to Events

Normalize relay URLs first

import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer

// Returns NormalizedRelayUrl (wrapper with validated wss:// URL)
val relayUrl = RelayUrlNormalizer.normalize("wss://relay.damus.io")
val relayUrlOrNull = RelayUrlNormalizer.normalizeOrNull("wss://relay.damus.io")

// Handles common fixes: https:// → wss://, strips whitespace, etc.

Build a Filter

import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter

// Fetch a user's notes
val filter = Filter(
    authors = listOf(pubKeyHex),
    kinds = listOf(1),
    limit = 50
)

// Since a timestamp
val filter = Filter(
    kinds = listOf(1, 6),
    since = System.currentTimeMillis() / 1000 - 3600  // last hour
)

// By event tags
val filter = Filter(
    kinds = listOf(7),
    tags = mapOf("e" to listOf(eventId))   // reactions to an event
)

// AND tag filter (NIP-91)
val filter = Filter(
    kinds = listOf(1),
    tagsAll = mapOf(
        "t" to listOf("nostr"),
        "p" to listOf(specificPubKey)
    )
)

// Full-text search (NIP-50)
val filter = Filter(
    kinds = listOf(1),
    search = "bitcoin lightning"
)

Open a subscription

import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage

val relay = RelayUrlNormalizer.normalize("wss://relay.damus.io")

val subId = "my-sub-${System.currentTimeMillis()}"
val filtersMap = mapOf(relay to listOf(filter))

nostrClient.openReqSubscription(
    subId = subId,
    filters = filtersMap,
    listener = object : IRequestListener {
        override fun onEvent(subId: String, event: Event, relay: IRelayClient) {
            println("Got event: ${event.id}")
        }
        override fun onEOSE(subId: String, relay: IRelayClient) {
            println("End of stored events from ${relay.url}")
        }
    }
)

// Close when done
nostrClient.close(subId)

Global relay listener

nostrClient.subscribe(object : IRelayClientListener {
    override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) {
        when (msg) {
            is EventMessage -> handleEvent(msg.subscriptionId, msg.event)
            is EoseMessage  -> handleEose(msg.subscriptionId)
            else -> {}
        }
    }
    override fun onConnected(relay: IRelayClient, pingMillis: Int, compressed: Boolean) {
        println("Connected to ${relay.url} in ${pingMillis}ms")
    }
    override fun onDisconnected(relay: IRelayClient) {
        println("Disconnected from ${relay.url}")
    }
    // other callbacks: onConnecting, onSent, onCannotConnect
})

8. Publishing Events

import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer

val relaySet = setOf(
    RelayUrlNormalizer.normalize("wss://relay.damus.io"),
    RelayUrlNormalizer.normalize("wss://nos.lol"),
)

// Sign the event
val template = TextNoteEvent.build("Hello Nostr!")
val event: TextNoteEvent = signer.sign(template)

// Send to relays (handles retry + reconnect automatically)
nostrClient.send(event, relaySet)

9. Event Serialization

import com.vitorpamplona.quartz.nip01Core.core.Event

// Serialize to JSON string
val json: String = event.toJson()

// Parse from JSON string
val event: Event = Event.fromJson(json)

// Null-safe parse
val event: Event? = Event.fromJsonOrNull(json)

// Specific typed parse (returns base Event, cast if needed)
val textNote = Event.fromJson(json) as? TextNoteEvent

10. Bech32 Encoding / Decoding (NIP-19)

import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub

// Decode any bech32 entity (plain or nostr:-prefixed).
// uriToRoute() returns Nip19Parser.ParseReturn? — the parsed Entity is in .entity
when (val entity = Nip19Parser.uriToRoute(input)?.entity) {
    is NPub     -> println("pubkey: ${entity.hex}")
    is NNote    -> println("event id: ${entity.hex}")
    is NEvent   -> println("event: ${entity.hex}, relays: ${entity.relay}")
    is NProfile -> println("profile: ${entity.hex}")
    is NAddress -> println("address: ${entity.aTag()}")
    null        -> println("not a valid bech32 entity")
    else        -> {}
}

// Encode: ByteArray extensions from nip19Bech32/​ByteArrayExt.kt
val npub = pubkeyBytes.toNpub()   // also toNsec(), toNote(), ...
// TLV entities with relay hints (relays: List<NormalizedRelayUrl>)
val nevent = NEvent.create(eventIdHex, authorHex, kind, relays)

11. Encryption

NIP-44 (modern, recommended)

// Via signer (preferred)
val encrypted = signer.nip44Encrypt(
    plaintext = "Secret message",
    toPublicKey = recipientPubKeyHex
)
val decrypted = signer.nip44Decrypt(
    ciphertext = encrypted,
    fromPublicKey = senderPubKeyHex
)

// Auto-detect format (NIP-04 or NIP-44)
val plaintext = signer.decrypt(encryptedContent, fromPublicKeyHex)

NIP-04 (legacy, avoid for new code)

val encrypted = signer.nip04Encrypt(plaintext, recipientPubKeyHex)
val decrypted = signer.nip04Decrypt(ciphertext, senderPubKeyHex)

12. Common NIP Event Builders

NIP-02 — Follow list (kind 3)

import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent

val template = ContactListEvent.build(
    follows = listOf(
        ContactListEvent.Contact(pubKey = alicePubKey, relayUrl = "wss://relay.damus.io", petname = "alice"),
        ContactListEvent.Contact(pubKey = bobPubKey)
    )
)

NIP-25 — Reaction (kind 7)

import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent

val like = ReactionEvent.build("+", targetEvent)
val dislike = ReactionEvent.build("-", targetEvent)
val custom = ReactionEvent.build("🤙", targetEvent)

NIP-57 — Zap request (kind 9734)

import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent

val template = LnZapRequestEvent.build(
    message = "Great post!",
    relays = listOf("wss://relay.damus.io"),
    target = targetEvent,
    zapType = LnZapRequestEvent.ZapType.PUBLIC
)
val zapRequest: LnZapRequestEvent = signer.sign(template)

NIP-59 — Gift wrap / sealed DM (kind 1059 + 14)

import com.vitorpamplona.quartz.nip17Dm.NIP17Factory

// Creates sealed rumor + gift wrap pair
val (dmEvent, giftWrap) = NIP17Factory.create(
    msg = "Private message",
    fromSigner = senderSigner,
    toUsers = listOf(recipientPubKey),
    relayList = listOf("wss://relay.damus.io")
)

NIP-23 — Long-form article (kind 30023)

import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent

val template = LongTextNoteEvent.build(
    body = markdownContent,
    title = "My Article",
    image = "https://example.com/​cover.jpg",
    summary = "A brief summary",
    slug = "my-article"   // d-tag
)

13. Platform-Specific Notes

JVM / Desktop

// jvmMain dependencies needed in consuming project:
// secp256k1-kmp-jni-jvm and lazysodium-java are transitive from quartz
// But you need JNA on the classpath for libsodium:
implementation("net.java.dev.jna:jna:5.18.1")

Android

// androidMain dependencies (transitive from quartz):
// secp256k1-kmp-jni-android, lazysodium-android, jna (aar)
// No extra setup needed beyond the maven dependency.

// For NIP-55 (Android external signer apps):
import com.vitorpamplona.quartz.nip55AndroidSigner.ExternalSignerLauncher

iOS

The library produces an XCFramework named quartz-kmpKit.

# Build XCFramework
./​gradlew :quartz:assembleQuartz-kmpKitReleaseXCFramework
# Output: quartz/​build/​XCFrameworks/​release/​quartz-kmpKit.xcframework

In Xcode: drag & drop the .xcframework into your project, then use from Swift via Kotlin/​Native interop.


14. Event Store (SQLite, all platforms)

SQLite-backed storage in commonMain (JVM, Android, iOS — uses the bundled androidx.sqlite driver) with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50, NIP-62). All operations are suspend:

import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore

val store = EventStore()  // default DB file "events.db"

// Insert
store.insert(event)

// Query
val events = store.query<Event>(
    Filter(authors = listOf(pubKey), kinds = listOf(1), limit = 50)
)

// Count (NIP-45)
val count = store.count(Filter(kinds = listOf(1)))

// Full-text search (NIP-50)
val results = store.query<Event>(Filter(search = "bitcoin"))

15. Quick Reference

TaskAPIPackage
Generate keysKeyPair()nip01Core.crypto
Create signerNostrSignerInternal(keyPair)nip01Core.signers
Build eventTextNoteEvent.build(...) or Event.build(kind, content) { tags }nip10Notes, nip01Core.core
Sign eventsigner.sign(template)nip01Core.signers
Serializeevent.toJson()nip01Core.core
ParseEvent.fromJson(json)nip01Core.core
Normalize relay URLRelayUrlNormalizer.normalize("wss://...")nip01Core.relay.normalizer
Setup relay clientNostrClient(BasicOkHttpWebSocket.Builder { okhttp })nip01Core.relay.client
Subscribeclient.openReqSubscription(subId, mapOf(relay to filters), listener)nip01Core.relay.client
Publishclient.send(event, setOf(relayUrl))nip01Core.relay.client
NIP-44 encryptsigner.nip44Encrypt(text, recipientPubKey)nip01Core.signers
Bech32 decodeNip19Parser.uriToRoute("npub1...")nip19Bech32
Bech32 encodeNip19Bech32.createNPub(pubKeyHex)nip19Bech32

Common Event Kinds

KindEvent TypeNIPQuartz class
0User metadata01MetadataEvent
1Text note10TextNoteEvent
3Follow list02ContactListEvent
4Legacy DM04PrivateDmEvent
5Deletion09DeletionEvent
6Repost18RepostEvent
7Reaction25ReactionEvent
14Chat message (sealed)17NIP17GroupMessage
1059Gift wrap59GiftWrapEvent
9734Zap request57LnZapRequestEvent
9735Zap receipt57LnZapEvent
10002Relay list65AdvertisedRelayListEvent
30023Long-form content23LongTextNoteEvent

Related Skills

  • nostr-expert — Internal Quartz patterns for Amethyst development
  • kotlin-multiplatform — KMP source sets, expect/​actual patterns
  • kotlin-coroutines — Flow patterns for relay event streams

More skills from vitorpamplona

feed-patterns logo
vitorpamplona/amethyst

feed-patterns

Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with `FeedFilter` / `AdditiveComplexFeedFilter` / `ChangesFlowFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or...

1.6K 0
View
amy-expert logo
vitorpamplona/amethyst

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/` into `commons/` so a...

1.6K 0
View
auth-signers logo
vitorpamplona/amethyst

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 `NostrSigner` base clas...

1.6K 0
View
gradle-expert logo
vitorpamplona/amethyst

gradle-expert

Build optimization, dependency resolution, and multi-module KMP troubleshooting for AmethystMultiplatform. Use when working with: (1) Gradle build files (build.gradle.kts, settings.gradle), (2) Version catalog (libs.versions.toml), (3) Build errors and dependency conflicts, (4) Module dependencie...

1.6K 0
View
account-state logo
vitorpamplona/amethyst

account-state

Account state and in-memory event store patterns in Amethyst. Use when working with `Account.kt` (per-user StateFlow properties — follow list, relays, settings, mutes, bookmarks), `LocalCache` (the object-level event store backed by `LargeCache`), `User`/`Note` model classes, or any ViewModel tha...

1.6K 0
View
android-expert logo
vitorpamplona/amethyst

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) Material3 theming...

1.6K 0
View
relay-client logo
vitorpamplona/amethyst

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`, `ReactionsFilterAssembler`, `FeedMetadataCo...

1.6K 0
View
Amethyst logo
vitorpamplona/amethyst

Amethyst

Build customized Amethyst Nostr clients for Android. Fork, rebrand, customize, and distribute your own version.

1.6K 0
View
kotlin-coroutines logo
vitorpamplona/amethyst

kotlin-coroutines

Advanced Kotlin coroutines patterns for AmethystMultiplatform. Use when working with: (1) Structured concurrency (supervisorScope, coroutineScope), (2) Advanced Flow operators (flatMapLatest, combine, merge, shareIn, stateIn), (3) Channels and callbackFlow, (4) Dispatcher management and context s...

1.6K 0
View
kotlin-expert logo
vitorpamplona/amethyst

kotlin-expert

Advanced Kotlin patterns for AmethystMultiplatform. Flow state management (StateFlow/SharedFlow), sealed hierarchies (classes vs interfaces), immutability (@Immutable, data classes), DSL builders (type-safe fluent APIs), inline functions (reified generics, performance). Use when working with: (1)...

1.6K 0
View
find-missing-translations logo
vitorpamplona/amethyst

find-missing-translations

Use when comparing Android strings.xml locale files to find untranslated string resources, missing translation keys, or preparing translation work for a specific language

1.6K 0
View
find-non-lambda-logs logo
vitorpamplona/amethyst

find-non-lambda-logs

Use when auditing or migrating Log calls — flags both interpolated Log.d/i/w/e that should use the lambda overload (allocation hygiene) and catch-block Log.w/e that interpolate ${e.message} but drop the throwable (lost stack traces)

1.6K 0
View

Popular AI tools

Kaiber logo
Video

Kaiber

Generate, edit, and beat-sync AI video with leading models in one workspace.

Paid
View
Vimcal logo
Productivity

Vimcal

The world's fastest calendar for remote work

Free
View

Transform Your Design with AI Designer by ImgCreator.ai

Freemium
View
Akool AI logo
Content & writing

Akool AI

Revolutionizing Video Production with AI-Powered Creativity

Paid
View

Extend an image past the frame and let AI fill the new aspect ratio.

Freemium
View
StarByFace logo
Security

StarByFace

Discover your celebrity doppelgänger with StarByFace!

Free
View
C

ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.

Freemium
View
Opus Clip logo
Coding & apps

Opus Clip

Opus.ai: Revolutionize Your Web Experience

Free
View