Exosphere
Exosphere is a collection of AT Protocol clients and utilities.
Documentation
- HexDocs: https://hexdocs.pm/exosphere
What’s inside
Exosphere.ATProto.*: lower-level, spec-aligned implementation building blocks (see atproto.com)Exosphere.*: public-facing API modules built on top ofExosphere.ATProto.*(XRPC client, OAuth session, firehose consumer, etc.)
Getting started
Installation
Add exosphere to your dependencies:
def deps do
[
{:exosphere, "~> 0.5"}
]
end
Quickstart: XRPC client
Exosphere.XRPC.Client is a small wrapper around Exosphere.ATProto.XRPC.Client.
# Create an unauthenticated client for a PDS
client = Exosphere.XRPC.Client.new("https://bsky.social")
{:ok, %{"did" => did}} =
Exosphere.XRPC.Client.query(client, "com.atproto.identity.resolveHandle",
handle: "atproto.com"
)
OAuth (DPoP-bound sessions)
Exosphere.ATProto.OAuth.* implements the full ATProto OAuth
profile: identity-to-server discovery,
client metadata documents, PAR, PKCE, private_key_jwt, DPoP-bound tokens
with nonce handling, token exchange, and rotating refresh tokens.
Exosphere.OAuth.Session wraps the result in a GenServer that keeps the
session fresh and signs XRPC calls.
alias Exosphere.ATProto.OAuth.{Client, ClientMetadata, Discovery, Flow, JWK}
client = Client.new!(
metadata: ClientMetadata.new!(
client_id: "https://app.example.com/oauth-client-metadata.json",
client_name: "My App",
redirect_uris: ["https://app.example.com/oauth/callback"],
scope: ["atproto", "transition:generic"],
jwk: JWK.to_public(client_key)
),
key: client_key,
redirect_uri: "https://app.example.com/oauth/callback"
)
{:ok, resolved} = Discovery.resolve("alice.example.com")
{:ok, {authorize_url, ctx}} = Flow.authorize_url(client, resolved)
# ... browser round-trip; store ctx server-side ...
{:ok, session} = Flow.callback(ctx, callback_params)
{:ok, pid} = Exosphere.OAuth.Session.start_link(session: session)
{:ok, profile} = Exosphere.OAuth.Session.query(pid, "app.bsky.actor.getProfile", actor: session.sub)
See the OAuth guide for the complete walk-through, including local-development loopback clients and the in-process mock PDS for e2e testing.
Firehose (subscribeRepos)
Use Exosphere.Firehose.Consumer to connect to a relay’s
com.atproto.sync.subscribeRepos WebSocket endpoint, decode frames into
structured messages, and dispatch them to your callback.
Running under a supervisor
The consumer requires an :on_event callback with arity 2: (message, state) -> state.
children = [
{Exosphere.Firehose.Consumer,
relay_url: "wss://bsky.network",
cursor: nil,
on_event: &MyApp.Firehose.on_event/2,
name: MyApp.FirehoseConsumer}
]
Supervisor.start_link(children, strategy: :one_for_one)
Handling events
Messages are decoded into maps with a :type key (for example :commit, :identity, :handle).
For commit messages, you can extract record data from the embedded CAR blocks via
Exosphere.ATProto.Firehose.Message.extract_records/1.
defmodule MyApp.Firehose do
require Logger
alias Exosphere.ATProto.Firehose.Message
def on_event(%{type: :commit} = msg, state) do
# Persist msg.seq somewhere if you want resumable consumption (cursor).
case Message.extract_records(msg) do
{:ok, records} ->
Logger.info("commit seq=#{msg.seq} records=#{length(records)}")
state
{:error, reason} ->
Logger.warning("commit seq=#{msg.seq} extract_records failed: #{inspect(reason)}")
state
end
end
def on_event(msg, state) do
Logger.debug("firehose event: #{inspect(msg.type)}")
state
end
end
Verifying repositories
You don't have to trust a PDS's word for what's in a repository. Exosphere can fetch a full repository archive and prove it against the key the account advertises in its DID document:
{:ok, %{rev: rev, records: records}} =
Exosphere.ATProto.Repo.verify_checkout("https://bsky.network", "did:plc:abc123")
That one call downloads com.atproto.sync.getRepo, reads every record out of
the Merkle Search Tree, confirms the record set matches the commit's signed
root, resolves the DID document, and verifies the commit signature. If it
returns {:ok, _}, the records provably come from the account controlling
that DID.
For firehose events, Exosphere.ATProto.Firehose.Message.verify_commit/1
checks a #commit message's embedded blocks against its signed MST root —
see the Firehose guide for when that succeeds (incremental
CARs only carry new blocks) and how to build on it.
Lexicons: register, type-check, and publish
Exosphere ships compile-time typed modules for the vendored bsky/community lexicons, and a runtime workflow for lexicons of your own — or anyone else's. See the Lexicons guide for the full walkthrough: authoring, linting, validation semantics, publishing and safe modification, resolution, and host-app code generation.
Define (or fetch) a lexicon, type-check records against it, and publish it to
a PDS as a com.atproto.lexicon.schema record:
{:ok, schema} = Exosphere.Lexicon.Schema.new(%{
"lexicon" => 1,
"id" => "com.example.post",
"defs" => %{"main" => %{
"type" => "record", "key" => "tid",
"record" => %{"type" => "object",
"required" => ["text"],
"properties" => %{"text" => %{"type" => "string", "maxGraphemes" => 100}}}
}}
})
# Type-check records at runtime (spec semantics: unknown fields ignored,
# open unions, byte-vs-grapheme string limits; pass strict: true to reject)
:ok = Exosphere.Lexicon.register(schema)
:ok = Exosphere.Lexicon.validate("com.example.post", %{
"$type" => "com.example.post", "text" => "hello"
})
# Publish: record key is the NSID, so it lives at
# at://<did>/com.atproto.lexicon.schema/com.example.post
{:ok, %{uri: uri, cid: cid}} =
Exosphere.Lexicon.publish(session, pds_url, did, schema)
Lexicons published by any repository can be fetched back and registered:
# From a known repo
{:ok, schema} =
Exosphere.Lexicon.Resolver.fetch(pds_url, did, "com.example.post", register: true)
# Or every lexicon a repo publishes
{:ok, %{schemas: schemas}} = Exosphere.Lexicon.Resolver.list(pds_url, did)
# Or via NSID authority (DNS TXT _lexicon.<domain> → DID → PDS)
{:ok, schema} = Exosphere.Lexicon.Resolver.resolve("com.example.post")
To go back to compile-time safety, vendor a repo's lexicons and generate typed modules for them:
$ mix exosphere.gen.lexicons --from did:plc:abc123
Before publishing, lint your lexicon documents against the spec rules (spec errors plus style-guide warnings for missing descriptions):
$ mix exosphere.lint.lexicons my_lexicon.json
Notes
- The consumer reconnects automatically on disconnects and errors, re-subscribing at the last cursor it tracked (with capped, jittered backoff between attempts).
- For more control (or lower-level access), use the
Exosphere.ATProto.*modules directly.
CI / Releases
This project uses GitHub Actions:
- CI: runs
mix format --check-formatted,mix credo --strict,mix test, andmix dialyzeron pushes + PRs. - Auto-versioning on merge: when a PR is merged into
main, a workflow requires exactly one label:major,minor, orpatch. It bumpsmix.exs, commits, tagsvX.Y.Z, and pushes (which triggers the Hex release workflow). - Release: pushing a tag like
v0.1.0publishes the package + docs to Hex.
To enable publishing, add a repository secret named HEX_API_KEY (generate one via mix hex.user key generate).