atproto_client

Alpha, pre-release. Pre-1.0, evolving alongside at-record; expect breaking changes between 0.x releases.

A small, transport-agnostic atproto client for Gleam. No app or lexicon knowledge: XRPC, identity resolution, OAuth discovery, blobs, and generic repo CRUD. Dual-target (erlang + javascript); browser-only consumers want the sibling atproto_browser instead, which owns the atproto/browser/* namespace within the shared atproto/ prefix.

Installation

gleam add atproto_client

Usage

The client carries a send function instead of a hardcoded HTTP backend, so you choose the transport per target. On Erlang, gleam_httpc’s send_bits maps onto the client’s TransportError channel:

import atproto/xrpc
import atproto_core/xrpc as core_xrpc
import gleam/httpc
import gleam/result
import gleam/string

fn client() -> xrpc.Client {
  xrpc.Client(send: fn(req) {
    httpc.send_bits(req)
    |> result.map_error(fn(error) {
      case error {
        httpc.ResponseTimeout -> core_xrpc.Timeout
        httpc.FailedToConnect(_, _) ->
          core_xrpc.ConnectionFailed(string.inspect(error))
        _ -> core_xrpc.Other(string.inspect(error))
      }
    })
  })
}

Resolve an identity, then read its records:

import atproto/identity
import atproto/repo
import gleam/dynamic/decode

let assert Ok(pds) =
  identity.resolve_pds(client(), identity.default_resolver, "someone.bsky.social")

let assert Ok(rows) =
  repo.list_records(
    client(),
    pds,
    access_token,
    did,
    "app.bsky.feed.post",
    decode.dynamic,
  )

For the full walkthrough, including both authentication routes, see the getting started guide.

Architecture

Core

ModuleWhat it does
atproto/xrpcClient, TransportError, XrpcError, get/post_json, parse
atproto/identityresolve_pds (handle/DID -> PDS) via Slingshot resolveMiniDoc
atproto/authcreate_session/refresh_session (com.atproto.server.createSession)
atproto/repolist_records/create_record/get_record/put_record/delete_record/upload_blob
atproto/blobBlob type, its codec, and public_url for refs returned by repo.upload_blob
atproto/uriat-uri helpers (rkey)
atproto/constellationget_backlinks against the microcosm backlink index

OAuth

The full atproto OAuth client flow, public or confidential, one module per step:

ModuleWhat it does
atproto/oauth/metadataDiscover and verify the authorization server of a PDS
atproto/oauth/flowstart (resolve, discover, PAR + PKCE + DPoP) and callback (state/iss, exchange)
atproto/oauth/tokensrefresh/revoke of a VerifiedSession
atproto/oauth/resourceWraps a Client so requests to the session’s PDS carry the DPoP-bound token
atproto/oauth/runnerInterprets the sans-io effect kernel against a synchronous Client
import atproto/oauth/flow
import atproto/oauth/resource
import atproto_core/oauth/transport
import gleam/uri

// login: keep `pending` server-side under flow.state(pending)
let assert Ok(#(redirect_url, pending)) =
  flow.start(
    client(),
    resolver: "https://slingshot.microcosm.blue",
    identifier: "alice.bsky.social",
    client_id: "https://app.example/client-metadata.json",
    redirect_uri: "https://app.example/callback",
    scope: "atproto transition:generic",
    client_auth: transport.PublicClient,
  )

// callback: take (remove) the flow by `state`, hand over the whole query
let assert Ok(query) = uri.parse_query(callback_query_string)
let assert Ok(flow.Authenticated(session, dpop_key)) =
  flow.callback(client(), flow: pending, query:, client_auth: transport.PublicClient)

let authed = resource.client(client(), session:, dpop_key:)

callback only returns a session once the trust chain checks out: state and iss match, the token is DPoP-bound with the atproto scope, and its sub is the DID the flow started for, resolved natively to the same authorization server. Confidential clients pass transport.PrivateKeyJwt(sign), where sign(issuer) returns a freshly signed client assertion for every attempt.

Every remote URL is parsed by atproto_core/remote (https only, no IP literals or local names), but your Client is still part of the boundary: do not follow redirects (gleam_httpc’s default), keep a timeout, and on a server refuse connections to private, loopback, and link-local addresses.

The modules above are sync wrappers over the sans-io effect kernel in atproto_core. Its TransportError/XrpcError types are aliased through atproto/xrpc; constructing the variants needs atproto_core/xrpc.

Development

gleam test
✨ Search Document