Rocksky — Elixir SDK

Package Version

A pipe-friendly Elixir client for the Rocksky XRPC API.

def deps do
  [
    {:rocksky_ex, "~> 0.1"}
  ]
end

Quick start

client = Rocksky.new(token: System.get_env("ROCKSKY_TOKEN"))

{:ok, profile} =
  client
  |> Rocksky.Actor.get_profile(did: "did:plc:7vdlgi2bflelz7mmuxoqjfcr")

{:ok, %{"scrobbles" => scrobbles}} =
  client
  |> Rocksky.Actor.get_actor_scrobbles(did: "did:plc:7vdlgi2bflelz7mmuxoqjfcr", limit: 25)

The client is always the first argument so calls compose naturally with |>. Every namespace module mirrors an XRPC NSID: app.rocksky.actor.getProfile becomes Rocksky.Actor.get_profile/2, app.rocksky.scrobble.createScrobble becomes Rocksky.Scrobble.create_scrobble/2, and so on.

Configuring the client

Rocksky.new(
  base_url: "https://api.rocksky.app",   # defaults to the public API
  token:    "your-bearer-token",         # required for authenticated procedures
  headers:  [{"x-app", "my-app"}],       # extra request headers
  req_options: [retry: false]            # forwarded to Req
)

You can also configure the default base URL globally:

# config/config.exs
config :rocksky_ex, base_url: "https://api.rocksky.app"

Derive an authenticated client from a shared base:

base = Rocksky.new()
authed = Rocksky.Client.with_token(base, "tok")

Examples

Create a scrobble

client
|> Rocksky.Scrobble.create_scrobble(
  title:     "In Bloom",
  artist:    "Nirvana",
  album:     "Nevermind",
  timestamp: System.system_time(:second)
)

Builder style

The SDK ships a chainable builder for every procedure with a JSON body. Each builder validates required fields locally before hitting the network and gives you autocompleteable setters per field:

Builder XRPC procedure
Rocksky.Scrobble.Builderapp.rocksky.scrobble.createScrobble
Rocksky.Song.Builderapp.rocksky.song.createSong
Rocksky.Mirror.Builderapp.rocksky.mirror.putMirrorSource
Rocksky.Apikey.Builderapp.rocksky.apikey.createApikey
Rocksky.Shout.ReplyBuilderapp.rocksky.shout.replyShout
Rocksky.Shout.ReportBuilderapp.rocksky.shout.reportShout
alias Rocksky.Scrobble.Builder, as: Scrobble

Scrobble.new(title: "In Bloom", artist: "Nirvana")
|> Scrobble.album("Nevermind")
|> Scrobble.album_art("https://...")
|> Scrobble.spotify_link("https://open.spotify.com/track/...")
|> Scrobble.timestamp(System.system_time(:second))
|> Scrobble.submit(client)
# => {:ok, %{...}}
alias Rocksky.Song.Builder, as: Song

Song.new(title: "Lithium", artist: "Nirvana")
|> Song.album("Nevermind")
|> Song.isrc("USDW19811234")
|> Song.duration(257_000)
|> Song.submit(client)

Camel-cased lexicon keys (e.g. albumArt, mbId, spotifyLink) become snake-cased setters (album_art/2, mb_id/2, spotify_link/2). new/1 and put/2 accept either form:

alias Rocksky.Mirror.Builder, as: Mirror

Mirror.new(provider: "lastfm", enabled: true, external_username: "alice")
|> Mirror.api_key("...")
|> Mirror.submit(client)

Use put/2 to set several at once, and to_body/1 to inspect the JSON body without submitting:

Scrobble.new(title: "x", artist: "y")
|> Scrobble.put(album: "Nevermind", year: 1991)
|> Scrobble.to_body()
# => %{title: "x", artist: "y", album: "Nevermind", year: 1991}

Missing required fields are caught locally:

Scrobble.new(title: "Only title") |> Scrobble.submit(client)
# => {:error, %Rocksky.Error{reason: :missing_fields, body: %{missing: [:artist]}}}

Both styles coexist — use one-shot keyword lists when it fits, builders when you're constructing the payload over several steps.

Find a song

{:ok, song} = Rocksky.Song.get_song(client, isrc: "USDW19811234")
{:ok, song} = Rocksky.Song.get_song(client, mbid: "f1234567-...")
{:ok, song} = Rocksky.Song.get_song(client, uri:  "at://did:plc:abc/app.rocksky.song/123")

Charts

client
|> Rocksky.Charts.get_top_tracks(limit: 10, startDate: "2026-01-01")

Free-text search

{:ok, results} = Rocksky.Feed.search(client, query: "nevermind")

Follow / unfollow

client |> Rocksky.Graph.follow_account(account: "alice.bsky.social")
client |> Rocksky.Graph.unfollow_account(account: "alice.bsky.social")

Player remote-control

client |> Rocksky.Player.play(playerId: id)
client |> Rocksky.Player.pause(playerId: id)
client |> Rocksky.Player.next(playerId: id)
client |> Rocksky.Player.seek(playerId: id, position: 60_000)

Paginate with Stream

Stream.unfold(0, fn offset ->
  case Rocksky.Actor.get_actor_scrobbles(client,
         did: "alice.bsky.social",
         limit: 50,
         offset: offset
       ) do
    {:ok, %{"scrobbles" => []}} -> nil
    {:ok, %{"scrobbles" => batch}} -> {batch, offset + length(batch)}
    {:error, _} -> nil
  end
end)
|> Stream.flat_map(& &1)
|> Enum.take(500)

See examples/ for runnable scripts (mix run examples/...).

Result shape and errors

Every function returns {:ok, body} on a 2xx response and {:error, %Rocksky.Error{}} otherwise:

case Rocksky.Song.get_song(client, uri: "at://missing") do
  {:ok, song} ->
    song

  {:error, %Rocksky.Error{status: 404}} ->
    :not_found

  {:error, %Rocksky.Error{reason: :unauthorized}} ->
    :reauth

  {:error, err} ->
    Logger.error("rocksky: #{Exception.message(err)}")
end

%Rocksky.Error{} is also an Exception, so it works with raise/1, Exception.message/1, and with/pattern matching.

Modules

Module NSID prefix
Rocksky.Actorapp.rocksky.actor.*
Rocksky.Albumapp.rocksky.album.*
Rocksky.Apikeyapp.rocksky.apikey.*
Rocksky.Artistapp.rocksky.artist.*
Rocksky.Chartsapp.rocksky.charts.*
Rocksky.Dropboxapp.rocksky.dropbox.*
Rocksky.Feedapp.rocksky.feed.*
Rocksky.GoogleDriveapp.rocksky.googledrive.*
Rocksky.Graphapp.rocksky.graph.*
Rocksky.Likeapp.rocksky.like.*
Rocksky.Mirrorapp.rocksky.mirror.*
Rocksky.Playerapp.rocksky.player.*
Rocksky.Playlistapp.rocksky.playlist.*
Rocksky.Scrobbleapp.rocksky.scrobble.*
Rocksky.Shoutapp.rocksky.shout.*
Rocksky.Songapp.rocksky.song.*
Rocksky.Spotifyapp.rocksky.spotify.*
Rocksky.Statsapp.rocksky.stats.*

If you need an NSID we haven't wrapped yet you can always drop down to Rocksky.HTTP.query/3 and Rocksky.HTTP.procedure/4:

Rocksky.HTTP.query(client, "app.rocksky.actor.getProfile", did: "alice")

Testing your own code

The SDK routes every request through Req, which means you can stub it with Req.Test — no extra mock dependency required:

client =
  Rocksky.new(
    base_url: "https://api.test.rocksky.app",
    req_options: [plug: {Req.Test, MyApp.RockskyStub}]
  )

Req.Test.stub(MyApp.RockskyStub, fn conn ->
  Req.Test.json(conn, %{"handle" => "alice"})
end)

{:ok, %{"handle" => "alice"}} = Rocksky.Actor.get_profile(client, did: "alice")

License

MIT © Tsiry Sandratraina.