Gd.
Guides
Six tasks, each done twice: once from the terminal to find out whether it works, once in code to put it in a service.
§ 01
Resolve an identity
An AT Protocol identity has two names: a handle, which is a domain and can change, and a DID, which is permanent. Resolution goes handle → DID, and the DID document tells you where the account’s PDS lives and which key signs its commits.
resolve_subject accepts either form. Given a handle it queries DNS TXT at _atproto.<handle> and the HTTPS endpoint /.well-known/atproto-did and reconciles the two; given a DID it passes straight through.
cargo run --features clap,hickory-dns \
--bin atproto-identity-resolve -- alice.bsky.social
use atproto_identity::resolve::{create_resolver, resolve_subject};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let http_client = reqwest::Client::new();
// Pass nameservers explicitly, or an empty slice for the system set.
let dns_resolver = create_resolver(&[]);
let did = resolve_subject(
&http_client, &dns_resolver, "alice.bsky.social",
).await?;
println!("did = {did}");
Ok(())
}
Two sources, one answer
DNS and the well-known endpoint can disagree, and during a handle migration they routinely do. The resolver detects the conflict rather than silently preferring one; decide deliberately which your application trusts.
§ 02
Read a repository archive
A repository export is a CAR v1 file: a root CID followed by a bag of content-addressed blocks. Inside those blocks is a signed commit pointing at a Merkle Search Tree, and the tree’s leaves are the record CIDs.
Reading is streaming: CarReader hands you one block at a time, so a large export does not have to fit in memory.
cargo run --package atproto-repo --features clap \
--bin atproto-repo-car -- ls repo.car
cargo run --package atproto-repo --features clap \
--bin atproto-repo-mst -- ls repo.car
use atproto_repo::car::CarReader;
use tokio::fs::File;
async fn read_car() -> anyhow::Result<()> {
let file = File::open("repository.car").await?;
let mut reader = CarReader::new(file).await?;
// The roots are the commit CIDs this archive claims to carry.
println!("roots: {:?}", reader.roots());
while let Some(block) = reader.next_block().await? {
println!("{} — {} bytes", block.cid, block.data.len());
}
Ok(())
}
To get an export in the first place, ask the account’s PDS for it. com.atproto.sync.getRepo streams the CAR, and ?since=<rev> narrows it to the blocks added after a revision you already hold.
§ 03
Sign a record
Attestation is CID-first. The record is prepared with $sig metadata, serialised to deterministic DAG-CBOR, hashed into a CID, and the CID bytes are what get signed. That makes the signing payload independent of JSON key order, whitespace, and every other thing that would otherwise make two encodings of the same record produce two different signatures.
use atproto_attestation::{create_inline_attestation, AnyInput};
use atproto_identity::key::{generate_key, KeyType};
use serde_json::json;
fn main() -> anyhow::Result<()> {
let key = generate_key(KeyType::P256Private)?;
let record = json!({
"$type": "app.bsky.feed.post",
"text": "Hello AT Protocol!",
"createdAt": "2024-01-01T00:00:00.000Z"
});
let metadata = json!({
"$type": "com.example.inlineSignature",
"key": "did:key:...",
"issuer": "did:plc:issuer123",
"issuedAt": "2024-01-01T00:00:00.000Z"
});
// The repository DID is bound into $sig. This is the replay defence.
let signed = create_inline_attestation(
AnyInput::Serialize(record),
AnyInput::Serialize(metadata),
"did:plc:repo123",
&key,
)?;
println!("{}", serde_json::to_string_pretty(&signed)?);
Ok(())
}
Low-S normalisation
ECDSA signatures come in pairs: for every valid (r, s) there is an equally valid (r, n − s). Without normalisation an attacker can flip one into the other and produce a second, different, still-valid signature over the same record. This crate normalises to low-S on the way out. If you verify these signatures elsewhere, make sure that verifier does too.
cargo run --package atproto-attestation --features clap,tokio \
--bin atproto-attestation-sign -- \
inline record.json did:key:... metadata.json
cargo run --package atproto-attestation --features clap,tokio \
--bin atproto-attestation-verify -- signed_record.json
§ 04
Complete an OAuth flow
AT Protocol OAuth is OAuth 2.1 with three additions that are not optional: the authorization request is pushed server-to-server first (PAR, RFC 9126), the code exchange is PKCE-protected (RFC 7636), and every token is bound to a key you hold (DPoP, RFC 9449). All three live in atproto-oauth, module workflow.
Your client authenticates with a signed assertion rather than a shared string. OAuthClient holds a private_signing_key_data, not a client secret, and a second key — the DPoP key — is passed separately and binds the tokens you receive.
use atproto_identity::key::identify_key;
use atproto_oauth::resources::pds_resources;
use atproto_oauth::workflow::{
oauth_complete, oauth_init, OAuthClient, OAuthRequestState,
};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let http_client = reqwest::Client::new();
// Two distinct keys: one signs client assertions, one binds tokens.
let oauth_client = OAuthClient {
redirect_uri: "https://your-app.com/callback".to_string(),
client_id: "https://your-app.com/client-metadata.json"
.to_string(),
private_signing_key_data: identify_key("did:key:zQ3sh...")?,
};
let dpop_key = identify_key("did:key:zDna...")?;
// Ask the user's PDS which authorization server speaks for it.
let (_resource, auth_server) =
pds_resources(&http_client, "https://pds.example.com").await?;
let request_state = OAuthRequestState {
state: "random-state".to_string(),
nonce: "random-nonce".to_string(),
code_challenge: "code-challenge".to_string(),
scope: "atproto transition:generic".to_string(),
};
// 1 — Pushed Authorization Request. Redirect using request_uri.
let par = oauth_init(
&http_client,
&oauth_client,
&dpop_key,
Some("alice.bsky.social"),
&auth_server,
&request_state,
).await?;
println!("{} (expires in {}s)", par.request_uri, par.expires_in);
// 2 — The user returns with a code. expected_subject is the DID the
// flow began for; a token claiming anyone else is rejected.
let tokens = oauth_complete(
&http_client,
&oauth_client,
&dpop_key,
"received-auth-code",
"did:plc:theuser",
&oauth_request,
&auth_server,
).await?;
println!("{}", tokens.access_token);
Ok(())
}
The subject check is the interesting part
oauth_complete refuses a token whose sub claim does not equal the expected_subject you pass. That is not a formality — a mismatch means the authorisation server returned a token for a different account than the one the flow started for, and a client that skips the comparison will happily log somebody in as the wrong person.
Pass a DID, never a handle. OAuthRequest::subject documents the same requirement: the comparison is exact, and a handle will never equal a sub.
Refreshing takes the account’s DID document rather than an endpoint, because it re-runs discovery itself and pins the subject to document.id:
use atproto_oauth::workflow::oauth_refresh;
let tokens = oauth_refresh(
&http_client,
&oauth_client,
&dpop_key,
&stored_refresh_token,
&did_document,
).await?;
Expect a nonce challenge
An authorisation server may reject your first DPoP proof with use_dpop_nonce and supply the nonce it wants. That is the protocol working, not a failure. Hand-rolled clients that treat the first 400 as terminal will appear to work against some servers and not others.
If you are serving the client side in Axum, atproto-oauth-axum has the callback, JWKS and client-metadata handlers. If your service issues its own inter-service tokens instead, that is atproto-xrpcs.
§ 05
Write a record
Publishing is one XRPC call. Four things have to be right before you make it: which repository (a DID), which collection (an NSID), which record key, and which host to send it to.
That last one is the part people hard-code and regret. A PDS endpoint is not a constant — it is a property of the account, published in its DID document, and it moves when the account migrates. Resolve it every time.
export ATPROTO_PASSWORD=<app-password>
cargo run --features clap --bin atproto-client-put-record -- \
alice.bsky.social 3kabcdefghij '{
"$type": "app.bsky.feed.post",
"text": "hello",
"createdAt": "2026-01-01T00:00:00.000Z"
}'
The tool takes a handle or a DID, a record key, and the record itself. It reads the collection out of the record’s $type, resolves the subject to a DID, pulls the PDS endpoint from the DID document, opens a session, and writes. That sequence is the same one you write in code.
use atproto_client::client::{Auth, DPoPAuth};
use atproto_client::com::atproto::repo::{
create_record, CreateRecordRequest, CreateRecordResponse,
};
use atproto_identity::key::identify_key;
use atproto_record::tid::Tid;
use serde_json::json;
async fn publish(
http_client: &reqwest::Client,
document: &atproto_identity::model::Document,
access_token: &str,
) -> anyhow::Result<()> {
// The endpoint comes from the account's own DID document.
let endpoints = document.pds_endpoints();
let pds = endpoints.first().ok_or_else(|| anyhow::anyhow!("no PDS"))?;
// Tokens from the OAuth flow are DPoP-bound: the proof key travels
// with every request, not just the token exchange.
let auth = Auth::DPoP(DPoPAuth {
dpop_private_key_data: identify_key("did:key:zDna...")?,
oauth_access_token: access_token.to_string(),
});
let request = CreateRecordRequest {
repo: document.id.clone(),
collection: "app.bsky.feed.post".to_string(),
record_key: Some(Tid::new().to_string()),
validate: true,
record: json!({
"$type": "app.bsky.feed.post",
"text": "hello",
"createdAt": "2026-01-01T00:00:00.000Z"
}),
swap_commit: None,
};
match create_record(http_client, &auth, pds, request).await? {
CreateRecordResponse::StrongRef { uri, cid, .. } => {
println!("{uri} @ {cid}");
}
CreateRecordResponse::Error(err) => {
// Reached the server; the server said no.
anyhow::bail!("createRecord refused: {err:?}");
}
}
Ok(())
}
A refusal is not an Err
CreateRecordResponse is an untagged enum with two variants, and a server that rejects your write returns Ok(CreateRecordResponse::Error(..)) — not Err. The ? operator will not catch it.
Code that matches only StrongRef and waves the rest through with _ => unreachable!() compiles, passes a happy-path test, and silently treats every validation failure and every permission error as success. Match both variants. PutRecordResponse and DeleteRecordResponse have the same shape.
createRecord and putRecord differ in who owns the key. Omit record_key from a create and the server generates one, which is what you want for append-only collections like posts. putRecord requires a key and writes at it, so re-running it with the same key updates rather than duplicates — the right call for records with a natural identity, like a profile.
| Field | createRecord | putRecord |
|---|---|---|
| record_key | Option — server generates one when absent. | Required. |
| Re-running it | Writes another record. | Overwrites the one at that key. |
| swap_commit | Available. | Available. |
| swap_record | — | Available. |
validate: true asks the PDS to check the record against its lexicon before committing. Leave it on. A record that satisfies the schema is not automatically one the rest of the ecosystem will accept, but a record that fails it will not be accepted by anything.
§ 06
Consume the event stream
Jetstream is a JSON view of the firehose, filtered server-side and optionally Zstandard-compressed. You register handlers, hand the consumer a cancellation token, and it reconnects on its own.
use async_trait::async_trait;
use atproto_jetstream::{
CancellationToken, Consumer, ConsumerTaskConfig,
EventHandler, JetstreamEvent,
};
struct MyEventHandler;
#[async_trait]
impl EventHandler for MyEventHandler {
async fn handle_event(
&self,
event: JetstreamEvent,
) -> anyhow::Result<()> {
println!("{event:?}");
Ok(())
}
fn handler_id(&self) -> String {
"my-handler".to_string()
}
}
let config = ConsumerTaskConfig {
user_agent: "my-app/1.0".to_string(),
compression: false,
zstd_dictionary_location: String::new(),
jetstream_hostname: "jetstream1.us-east.bsky.network".to_string(),
collections: vec!["app.bsky.feed.post".to_string()],
};
let consumer = Consumer::new(config);
consumer.register_handler(std::sync::Arc::new(MyEventHandler)).await?;
// Cancel the token to drain cleanly instead of dropping the socket.
let cancel = CancellationToken::new();
consumer.run_background(cancel).await?;
Compression needs Bluesky’s published dictionary on disk; point zstd_dictionary_location at it and set compression: true.
curl -o data/zstd_dictionary \
https://github.com/bluesky-social/jetstream/raw/refs/heads/main/pkg/models/zstd_dictionary
§ 07