Typed OCaml toolkit for the AT Protocol.
Resolve identities, read and write repositories, follow the firehose, and call AppView, Ozone, and hosted Bluesky products (chat, video, Jetstream) from one library. Protocol pieces β XRPC, CID/CAR/MST, lexicons, OAuth/DPoP β are implemented here, not left as raw HTTP.
1.0.2 is the packaged surface. Pin this repository until atproto.1.0.2 lands on opam-repository; then opam install atproto. See CHANGELOG.md.
This package is a client. It does not host a PDS, chat service, video transcoder, Tap, SMS gateway, or push backend. See What this package does not host.
These two calls hit the public AppView over the network. They do not need ATP_AUTH. ATP_PUBLIC only gates live tests; it is not required to run this snippet.
opam pin add atproto git+https://github.com/david-engelmann/atproto.git
# after atproto.1.0.2 is on opam-repository:
# opam install atproto(* public AppView β needs network, no ATP_AUTH *)
let did = (Identity.resolve_handle "jay.bsky.team").did
let posts = Feed.search_posts ~q:"atproto" ~limit:5 ()examples/quickstart.ml is that flow as a copy-paste executable (dune exec -- examples/quickstart.exe).
Requires OCaml >= 4.14.1 and < 5.4 (CI: 4.14.1 and 5.3.0). Jane Street core / async / ppx_jane / zstandard are >= v0.16.0 and < v0.18~ (v0.16 on 4.14, v0.17 on 5.1β5.3). Public Jane Street v0.17 does not support OCaml 5.4+; 5.0 is untested. Jetstream dict-zstd needs system libzstd (Debian/Ubuntu libzstd-dev, macOS Homebrew zstd) before opam pin / opam install . --deps-only. The Jane Street zstandard package is Linux-only (x86_64 / arm64).
opam pin add atproto git+https://github.com/david-engelmann/atproto.git
# after atproto.1.0.2 is on opam-repository:
# opam install atprotoFrom a local clone:
opam pin add atproto .
# or install build/test deps without pinning a release
opam install . --deps-only --with-test
dune build -p atprotoIn a dependent dune stanza:
(libraries atproto)opam pin / opam install . run dune build -p atproto and install the public atproto library. Release notes: CHANGELOG.md. Official lexicons stay pinned at bluesky-social/atproto f0d4877a.
Resource | Where |
|---|---|
API reference | https://david-engelmann.github.io/atproto/ ( |
Release notes | |
License | |
Issues | https://github.com/david-engelmann/atproto/issues |
Security | |
Contributing | |
For agents |
Pushes to main deploy odoc with GitHub Actions Pages. Pull requests also upload the odoc-html artifact.
Each row is a starting point. Function-level detail lives in odoc; release-by-release depth is in the CHANGELOG.
Area | Modules | Purpose |
|---|---|---|
Session |
| App-password sessions, app passwords, invites, email, |
Identity |
| Handle / DID resolve, PLC directory, |
AppView |
| Profiles, timelines, search, graphs, bookmarks, public labelers |
Records |
| create/put/delete/applyWrites and typed post/like/follow/β¦ builders |
Sync |
| Repo CAR, MST, CID; indexer / backfill toolkit (not a hosted Tap) |
Firehose |
|
|
OAuth |
| PKCE, DPoP, public HTTPS client-metadata, browser login glue |
Chat |
| Hosted |
Video |
| Hosted |
Ozone |
|
|
Notifications |
| List, prefs, activity subscriptions, |
Contacts |
| Hosted phone-verified contacts. No SMS gateway |
Labels |
|
|
Lexicon |
| Parse lexicon-1 JSON, validate, |
Syntax |
|
|
Other clients |
| Admin, temp, user reports, drafts, age assurance |
Records (other) |
|
|
HTTP |
| Shared XRPC GET/POST; HTTP/2 TLS for public HTTPS |
Experimental |
| Proposal 0016 only β not a spaces product API |
Create a .env (see sample.env) when you need a session or a non-default host.
Variable | Purpose |
|---|---|
|
|
| PDS / entryway host without a scheme ( |
|
|
| Set |
Optional hosts (all without a scheme): ATP_APPVIEW_HOST, ATP_OZONE_HOST / ATP_OZONE_DID, ATP_CHAT_HOST / ATP_CHAT_DID, ATP_VIDEO_HOST. Local-network extras: ATP_AUTH_BOB, ATP_AUTH_OZONE, BASE_ENDPOINT (default xrpc).
Session creation, repo writes, graph mutes, bookmarks, chat, ozone, and most feed helpers need ATP_AUTH. Chat also needs a DM-capable session (transition:chat.bsky, include:chat.bsky.authFullChatClient, or ATP_CHAT=1). Public identity, DID PLC, firehose subscribe, AppView reads (public.api.bsky.app), and most com.atproto.sync.* reads do not need auth, but those live hops skip unless ATP_PUBLIC is truthy.
Live opt-ins (unset in CI): ATP_PUBLIC (unauthenticated public-internet hops), ATP_CHAT, ATP_PHONE / ATP_PHONE_NUMBER, ATP_PUSH / ATP_PUSH_DID / ATP_PUSH_TOKEN, ATP_SPACE / ATP_SPACE_HOST (no default space host), JETSTREAM_API_KEY.
These paths are library-ready. None of them start a hosted service in this repo. Offline sketches live under examples/.
AT Protocol identifies a public client by an HTTPS client_id that is the URL of a JSON metadata document. This library builds, validates, and serializes that document and drives the browser login path. It does not host the file or a login UI.
Oauth.public_https_metadata). client_id must be https://host/path with no port; web redirect_uris must be HTTPS on the same origin. Native clients may use http://127.0.0.1 / http://[::1] or a reverse-domain custom scheme.application/json (Oauth.metadata_document). The body's client_id must match the fetch URL. See examples/client-metadata.json and examples/oauth_https_metadata.ml.Oauth.start_browser_login discovers the PDS authorization server, runs PAR, and returns the authorize URL (PKCE S256 + DPoP + state).?code=&state=&iss=), Oauth.complete_browser_login exchanges the code for a DPoP token. Authed AppView / Ozone / chat still use Oauth.get_service_auth, not the DPoP access token. DPoP cannot be proxied.Loopback http://localhost?redirect_uri=β¦ and local TestNetwork stay the development path. Auth.createSession stays a Cohttp password session.
chat.bsky.*)Client for the hosted Bluesky chat service (did:web:api.bsky.chat#bsky_chat, host api.bsky.chat / ATP_CHAT_HOST). Official TestNetwork does not start a DM service. This repo does not fake one.
Oauth.default_scope (atproto transition:generic) is not enough. Declare Oauth.default_chat_scope (adds transition:chat.bsky) or Oauth_scope.full_chat_client_scope (include:chat.bsky.authFullChatClient). Privileged app-passwords carry a chat grant; regular ones do not. Chat.scope_has_chat detects a DM grant.createSession + Chat.list_convos / get_messages / send_message through the PDS with atproto-proxy (Chat.effective_proxy).getServiceAuth (aud = Chat.service_aud, lxm = the chat.bsky.* NSID) and call Chat.list_convos_service / get_messages_service / send_message_service on api.bsky.chat. Those helpers do not send atproto-proxy.examples/chat_production.ml is offline wiring.
app.bsky.video.*)Client for the hosted Bluesky video service (video.bsky.app / ATP_VIDEO_HOST). Official TestNetwork does not start a transcoder. This repo does not fake one.
did:web:<pds-host> from the session #atproto_pds (Video.pds_audience), not did:web:video.bsky.app. lxm is com.atproto.repo.uploadBlob. Password: Video.mint_upload_token. OAuth: Oauth.get_service_auth with the same aud / lxm.Video.upload_video. Larger files: multipart start_upload / upload_part / finish_upload. Poll with Video.poll_job_status / ensure_blob.Video.video_embed_json / embed_of_job + Records.post). Do not write the HLS playlist into the create embed.examples/video_production.ml is offline wiring.
Repo_sync)A backfill / firehose-apply toolkit for building an indexer. It is not a hosted Tap. Official TestNetwork is a local PDS + AppView + Ozone stack, not a Tap host.
Repo_sync.fetch_repo / backfill pull com.atproto.sync.getRepo. Offline: open_car / resync_from_car.walk_json decodes records as IPLD JSON. export_record_proof / verify_record_proof are getRecord inclusion proofs.process_commit applies #commit ops while Synchronized. A #sync with a different rev marks Desynchronized until resync_from_car.export_car / export_subset. Offline fixture: write_signed_repo (production signers use Mst.sign_p256 / sign_k256).examples/repo_sync_indexer.ml is an offline sketch.
Client for Jetstream live tail and Network Replay HTTP. Live subscribe / subscribe_one stay unauthenticated (v2 offers Sec-WebSocket-Protocol: xrpc.v1.json; dict-zstd is ~compress:true).
Bluesky-hosted archive HTTP (planSnapshot / planBackfill / listSegments / β¦) needs an operator API key from bsky.network/account β not a PDS JWT. Pass JETSTREAM_API_KEY (or JETSTREAM_ARCHIVE_TOKEN / ~token) as the raw key. This library does not invent one. require_archive_token fails closed before HTTP. Self-hosted Jetstream needs no key.
examples/jetstream_archive.ml is offline wiring.
Client for hosted Bluesky phone verification, contact import, and push registration. It does not send SMS and does not start an APNs/FCM gateway.
Contact.get_matches / import_contacts through the PDS. OAuth mints AppView service-auth and calls *_service on public.api.bsky.app.Contact.start_phone_verification β verify_phone β import_contacts is Bluesky-hosted SMS. Live hops skip unless ATP_PHONE=1 and ATP_PHONE_NUMBER is an E.164 number you own. Temp.request_phone_verification is a different privileged signup-SMS client and is also not faked.Notification.register_push takes a caller serviceDid, device token, platform (ios / android / web), and appId. Official Bluesky push is closed to the official app.examples/contacts_production.ml is offline wiring.
These are hosted products this client talks to, not missing protocol cores. The client paths above are implemented; the servers are not.
client-metadata.json and the browser redirect β your application hosts them. The library builds the document and drives authorize β code β token.Repo_sync is the indexer library. This repo does not fake a Tap host.video.bsky.app. No local transcoder.@atproto/dev-env 0.6.4 does not start one (ozone.chatUrl = localhost:2590, βmust run separate chat serviceβ). Talk to api.bsky.chat.JETSTREAM_API_KEY. Live subscribe stays unauthenticated.requestPhoneVerification is not faked.Lt_hash, At_uri.Space, Space_commit, Space_credential, Space_xrpc, and Space_sync implement draft proposal 0016. They are experimental, not a stable product API. Deferred: com.atproto.simplespace.*, space: OAuth scopes, proposal registerNotify repo. Live hops skip unless ATP_SPACE=1 and ATP_SPACE_HOST names a real host. This repo does not fake a space host.Official lexicons are pinned at f0d4877a. CI @lexicon-coverage fails if that pin grows and a public client NSID lacks a helper (or an explicit skip). That gate is not dune runtest / opam with-test. Hosted-only servers are not skip reasons.
opam install . --deps-only --with-test
dune build
dune runtestdune build typechecks examples/offline.ml against the public API (no network). dune runtest also runs the offline production sketches. A release-style build is dune build -p atproto and dune runtest -p atproto. Live Bluesky tests that need credentials skip unless ATP_AUTH is a real email:app-password pair (placeholders in sample.env do not count). Unauthenticated public-network tests (handle resolve, PLC, getLatestCommit, subscribeRepos, AppView reads) skip unless ATP_PUBLIC is truthy. Default GitHub TestSuite leaves ATP_PUBLIC unset so opam with-test stays offline-safe. Re-run public hops with make test-public or the optional PublicLive workflow (workflow_dispatch only).
make test-pds starts Blueskyβs official @atproto/dev-env@0.6.4 β PLC (:2582), PDS (:2583), AppView (:2584), Ozone (:2587). Chat, video, Tap, SMS, and push are not in that stack.
make test-pds
# or step by step
./scripts/local-atproto.sh up
./scripts/local-atproto.sh account
eval "$(./scripts/local-atproto.sh env)"
export ATP_REQUIRE_LOCAL_PDS=1
dune exec -- test/test_local_pds.exe
./scripts/local-atproto.sh downPoint the client at the stack with ATP_SCHEME=http, ATP_HOST=localhost:2583, ATP_APPVIEW_HOST=localhost:2584, ATP_OZONE_HOST=localhost:2587, ATP_AUTH=alice.test:hunter2. Mock accounts come from official generateMockSetup (alice.test / bob.test / ozone admin admin-mod.test). If the network is up, a failed protocol call fails the test; the suite skips only when it is not aimed at a local host.
OAuth against this TestNetwork (loopback metadata, PAR, DPoP, service-auth) is covered by test/test_local_oauth.ml. See .github/CONTRIBUTING.md.
Copy-paste sketches under examples/. dune build typechecks them; none invent a hosted service.
File | Demo |
|---|---|
Public AppView: resolve a handle and search posts (no | |
Typechecks the public API with no network | |
HTTPS | |
Sample public HTTPS OAuth client-metadata document (you still host it) | |
Hosted | |
Hosted | |
Indexer / backfill via | |
Jetstream archive HTTP with an operator-supplied key | |
Hosted phone / contacts / push clients (no SMS or APNs/FCM) |
(* public AppView, no auth *)
let did = (Identity.resolve_handle "jay.bsky.team").did
let commit = Sync.get_latest_commit did
let posts = Feed.search_posts ~q:"atproto" ~limit:5 ()
let author =
Feed.get_author_feed_page ~actor:"jay.bsky.team" ~limit:5
~filter:Feed.filter_posts_no_replies ~include_pins:true ()
(* typed record + facet *)
let post =
Records.post ~text:"hello #atproto" ~created_at:"2024-01-01T00:00:00.000Z"
~facets:[ Facet.tag ~byte_start:6 ~byte_end:14 "atproto" ]
()
(* password session *)
let username, password = Auth.username_and_password_from_env
let session = Session.create_session username password
let profile = Actor.get_profile session "jay.bsky.team"
(* OAuth: you still host client-metadata.json at client_id and receive /cb *)
let client_id = Oauth.https_client_id ~host:"client.example" ()
let meta =
Oauth.public_https_metadata ~client_id
~redirect_uris:[ "https://client.example/cb" ] ()
let _ = Oauth.metadata_http_response meta
(* Oauth.start_browser_login / complete_browser_login drive authorize β token *)
(* firehose + Jetstream URL (subscribe_one talks to the public WS) *)
let _header, msg = Firehose.subscribe_one ()
let _js =
Jetstream.subscribe_url
~filter:
{
Jetstream.empty_filter with
collections = [ "app.bsky.feed.post" ];
kinds = [ Jetstream.Commit ];
}
()
(* experimental proposal 0016 β not a spaces product *)
let space =
At_uri.Space.of_string "at://did:example:space/space/app.bsky.group/test"
let () = assert (not (At_uri.Space.is_record space))