package atproto
Install
dune-project
Dependency
Authors
Maintainers
Sources
sha256=50ebbbd412c55878005c39eac9493a4a3475882d43d64e685aed2164048320f2
Description
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.
This package is a client. It does not host a PDS, chat service, video transcoder, Tap, SMS gateway, or push backend. An HTTP/2 TLS client is included for XRPC calls that need status and response headers.
Public modules include Auth, Session, Actor, Feed, Graph, Bookmark, Video, Unspecced, Labeler, Chat, Ozone, Admin, Repo, Repo_sync, Server, Identity, Did_plc, Did_web, Did_key, Sync, Cid, Car, Dag_cbor, Mst, Tid, At_uri, Lexicon, Temp, Firehose, Websocket, Jetstream, Oauth, Oauth_scope, Label, Xrpc, Error, Syntax, Embed, Facet, Records, Notification, Draft, Contact, Ageassurance, Moderation, Site, Germnetwork, Http_client, Request, Response, and experimental Lt_hash / Space_commit / Space_credential / Space_xrpc / Space_sync / At_uri.Space (proposal 0016 commit digest, deniable signed commits, space URIs, space-credential JWT / DPoP, draft com.atproto.space.* XRPC helpers, and local oplog / two-root CAR apply; not a spaces product API). Password chat clients send atproto-proxy (default did:web:api.bsky.chat#bsky_chat, the session DID-document #bsky_chat service, or ATP_CHAT_DID). OAuth DPoP cannot be proxied; list_convos_service / get_messages_service / send_message_service call api.bsky.chat with a getServiceAuth JWT.
Video support is a client for the hosted video service (byte upload + job-status poll). This package does not host an OAuth client-metadata document or browser login.
README
atproto
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.
Quick start
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).
Install
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.
Documentation
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.
Library map
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 |
Environment
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.
Hosted Bluesky products
These paths are library-ready. None of them start a hosted service in this repo. Offline sketches live under examples/.
OAuth (HTTPS client-metadata)
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.
- Build the document (
Oauth.public_https_metadata).client_idmust behttps://host/pathwith no port; webredirect_urismust be HTTPS on the same origin. Native clients may usehttp://127.0.0.1/http://[::1]or a reverse-domain custom scheme. - Publish it at that URL as HTTP 200
application/json(Oauth.metadata_document). The body'sclient_idmust match the fetch URL. Seeexamples/client-metadata.jsonandexamples/oauth_https_metadata.ml. Oauth.start_browser_logindiscovers the PDS authorization server, runs PAR, and returns the authorize URL (PKCE S256 + DPoP +state).- On the redirect (
?code=&state=&iss=),Oauth.complete_browser_loginexchanges the code for a DPoP token. Authed AppView / Ozone / chat still useOauth.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 (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.
- Scopes.
Oauth.default_scope(atproto transition:generic) is not enough. DeclareOauth.default_chat_scope(addstransition:chat.bsky) orOauth_scope.full_chat_client_scope(include:chat.bsky.authFullChatClient). Privileged app-passwords carry a chat grant; regular ones do not.Chat.scope_has_chatdetects a DM grant. - Password session. Privileged
createSession+Chat.list_convos/get_messages/send_messagethrough the PDS withatproto-proxy(Chat.effective_proxy). - OAuth. Mint
getServiceAuth(aud=Chat.service_aud,lxm= thechat.bsky.*NSID) and callChat.list_convos_service/get_messages_service/send_message_serviceonapi.bsky.chat. Those helpers do not sendatproto-proxy.
examples/chat_production.ml is offline wiring.
Video (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.
- Service-auth. Audience is
did:web:<pds-host>from the session#atproto_pds(Video.pds_audience), notdid:web:video.bsky.app.lxmiscom.atproto.repo.uploadBlob. Password:Video.mint_upload_token. OAuth:Oauth.get_service_authwith the same aud / lxm. - Upload. Small clips:
Video.upload_video. Larger files: multipartstart_upload/upload_part/finish_upload. Poll withVideo.poll_job_status/ensure_blob. - Embed. Put the job blob ref on the post (
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.
Indexer (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.
- Backfill.
Repo_sync.fetch_repo/backfillpullcom.atproto.sync.getRepo. Offline:open_car/resync_from_car. - Walk / proof.
walk_jsondecodes records as IPLD JSON.export_record_proof/verify_record_proofare getRecord inclusion proofs. - Firehose.
process_commitapplies#commitops whileSynchronized. A#syncwith a different rev marksDesynchronizeduntilresync_from_car. - Export. Sync 1.1
export_car/export_subset. Offline fixture:write_signed_repo(production signers useMst.sign_p256/sign_k256).
examples/repo_sync_indexer.ml is an offline sketch.
Jetstream
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.
Phone, contacts, and push
Client for hosted Bluesky phone verification, contact import, and push registration. It does not send SMS and does not start an APNs/FCM gateway.
- Contacts. Password sessions use
Contact.get_matches/import_contactsthrough the PDS. OAuth mints AppView service-auth and calls*_serviceonpublic.api.bsky.app. - SMS.
Contact.start_phone_verification→verify_phone→import_contactsis Bluesky-hosted SMS. Live hops skip unlessATP_PHONE=1andATP_PHONE_NUMBERis an E.164 number you own.Temp.request_phone_verificationis a different privileged signup-SMS client and is also not faked. - Push.
Notification.register_pushtakes a callerserviceDid, device token, platform (ios/android/web), andappId. Official Bluesky push is closed to the official app.
examples/contacts_production.ml is offline wiring.
What this package does not host
These are hosted products this client talks to, not missing protocol cores. The client paths above are implemented; the servers are not.
- HTTPS
client-metadata.jsonand the browser redirect — your application hosts them. The library builds the document and drives authorize → code → token. - Tap —
Repo_syncis the indexer library. This repo does not fake a Tap host. - Video transcoder — talk to
video.bsky.app. No local transcoder. - OSS chat —
@atproto/dev-env0.6.4 does not start one (ozone.chatUrl=localhost:2590, “must run separate chat service”). Talk toapi.bsky.chat. - Jetstream archive key — the operator supplies
JETSTREAM_API_KEY. Live subscribe stays unauthenticated. - SMS / phone-verification gateway — hosted Bluesky SMS only.
requestPhoneVerificationis not faked. - APNs/FCM — official Bluesky push is closed to the official app; third-party clients host their own gateway.
- Spaces —
Lt_hash,At_uri.Space,Space_commit,Space_credential,Space_xrpc, andSpace_syncimplement draft proposal 0016. They are experimental, not a stable product API. Deferred:com.atproto.simplespace.*,space:OAuth scopes, proposalregisterNotifyrepo. Live hops skip unlessATP_SPACE=1andATP_SPACE_HOSTnames 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.
Development
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).
Local AT Protocol network
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.
Examples
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))
Dependencies (24)
-
ppx_jane
>= "v0.16.0" & < "v0.18~" -
zstandard
>= "v0.16.0" & < "v0.18~" - zarith
-
mirage-crypto-rng
>= "1.2.0" -
mirage-crypto-ec
>= "1.2.0" -
digestif
>= "1.1.2" - jose
- lwt_ssl
- ssl
- cohttp-async
- cohttp-lwt-unix
-
cohttp
>= "5.1.0" - httpaf-lwt-unix
-
httpaf
>= "0.7.1" -
h2-lwt-unix
>= "0.10.0" -
h2
>= "0.10.0" -
lwt
>= "5.6.1" - ptime
- yojson
-
uri
>= "4.2.0" -
async
>= "v0.16.0" & < "v0.18~" -
core
>= "v0.16.0" & < "v0.18~" -
ocaml
>= "4.14.1" & < "5.4" -
dune
>= "3.11"
Used by
None
Conflicts
None