Bluesky
Connect Bluesky accounts with AT Protocol OAuth, publish records, and use typed AT Protocol operations with explicit reconciliation.
To post to Bluesky from a server, create an app.bsky.feed.post record in the account’s repository with com.atproto.repo.createRecord, authenticated as that account. The Social SDK Bluesky adapter makes that call through social.posts.publish, adds link facets for URLs in the text, uploads attached images, and returns the new record’s URI and CID as a typed outcome.
For Bluesky’s hosts, app passwords versus OAuth, facets, and rate limits in one place, read the Bluesky API guide.
npm install @opencoredev/social-sdkpnpm add @opencoredev/social-sdkyarn add @opencoredev/social-sdkbun add @opencoredev/social-sdknub add @opencoredev/social-sdkaube add @opencoredev/social-sdkimport { createSocial, connectedAccountRef } from "@opencoredev/social-sdk";
import { bluesky } from "@opencoredev/social-sdk/bluesky";
const social = createSocial({
backend: bluesky({
auth: {
service: "https://bsky.social",
did: process.env.BLUESKY_DID!,
accessJwt: process.env.BLUESKY_ACCESS_JWT!,
},
}),
});
const account = connectedAccountRef({
backend: "default",
platform: "bluesky",
accountId: process.env.BLUESKY_DID!,
});
const result = await social.posts.publish({
targets: [{ account }],
content: { text: "Posted from TypeScript with Social SDK https://social-sdk.dev" },
});
This sample uses an access JWT from an app password, which suits a single account you own. For accounts your users connect, use the OAuth flow below.
The direct Bluesky adapter accepts an access JWT or a DPoP-bound OAuth session, together with a DID and HTTPS service URL. For user-facing sign-in, use blueskyOAuth from @opencoredev/social-sdk/server. It implements the AT Protocol OAuth profile: handle and DID resolution, PDS and authorization server discovery, pushed authorization requests, PKCE, DPoP-bound tokens, the iss callback check, and identity verification after the token exchange. It has no runtime dependencies. The official @atproto/oauth-client-node client remains a supported alternative.
Setup
Publish client metadata
Serve a client metadata document at an HTTPS URL. That URL is your clientId. Build the
document with blueskyOAuthClientMetadata so the required fields are present. A confidential
client adds an ES256 public key through jwks or jwksUri and keeps the private key in a
server-side secret store. For local development, blueskyLoopbackClientId returns an
http://localhost client ID, which is always a public client.
Start the connection with a login hint
Pass the handle, DID, or server URL the user typed as loginHint to ConnectionManager.begin.
A handle is resolved through DNS and then HTTPS and must match its DID document. A server URL
such as https://bsky.social starts a flow where the user picks the account at the server. Set
defaultServer on the provider to use a server when no hint is given.
Store the session
On completion the provider checks the callback iss, exchanges the code with DPoP, and confirms
that the returned DID’s PDS names the same authorization server. The verified session goes to
sessionSink. It contains the access token, refresh token, and private DPoP key, so encrypt it
and scope reads to the tenant.
Configure the adapter
Load the stored session with parseBlueskyOAuthSession and pass
blueskyOAuthTransport(session) as the adapter’s session. Never put tokens or DPoP keys in
browser code.
The following server-side example uses an explicit in-memory connection store for one
process. Use a durable ConnectionStore in production. encryptedSessions, pinnedFetch,
and assertPublicEgress below are application services, not SDK exports. The egress policy
must resolve every destination, reject private/reserved addresses, and pin the approved
address in the transport. OAuth fails closed when either the policy hook or transport is
missing. Apply the same pair to connection, refresh, and resource requests.
import {
ConnectionManager,
MemoryConnectionStore,
blueskyOAuth,
blueskyOAuthClientMetadata,
} from "@opencoredev/social-sdk/server";
import { encryptedSessions, pinnedFetch, assertPublicEgress } from "./application-services.js";
const clientId = "https://app.example.com/oauth/client-metadata.json";
const redirectUri = "https://app.example.com/oauth/bluesky/callback";
const scope = "atproto repo:app.bsky.feed.post?action=create blob:image/*";
const secureRequests = { fetch: pinnedFetch, assertEgressAllowed: assertPublicEgress };
const metadata = blueskyOAuthClientMetadata({ clientId, redirectUris: [redirectUri], scope });
// Serve metadata at clientId. This example is a public client.
const store = new MemoryConnectionStore();
const manager = new ConnectionManager({ store });
const provider = blueskyOAuth({
clientId,
redirectUri,
scope,
...secureRequests,
sessionSink: {
async save({ account, session, attempt }) {
// This write must be atomic: rejection leaves no readable session behind.
await encryptedSessions.save(attempt.tenantId, account.ref.accountId, session);
},
},
});
const started = await manager.begin({
backend: "direct",
tenantId,
principalId,
platforms: ["bluesky"],
redirectUri,
allowedRedirectUris: [redirectUri],
provider,
loginHint: "alice.bsky.social",
});
// Save started.attempt.id in the authenticated browser session, then redirect
// the browser to started.authorizationUrl.
In the callback handler, recover the attempt ID from the authenticated application session. Keep the same tenant and principal and validate the callback before selecting an account. The provider saves the verified OAuth session during discovery.
const callback = new URL(callbackUrl);
const discovered = await manager.discover({
attemptId,
tenantId,
principalId,
callbackUrl,
returnedState: callback.searchParams.get("state") ?? "",
allowedRedirectUris: [redirectUri],
provider,
});
const selected = discovered[0];
if (!selected) throw new Error("No Bluesky account was discovered");
const grants = await manager.select({
attemptId,
tenantId,
principalId,
selectedAccountIds: [selected.ref.accountId],
});
const grant = grants[0];
if (!grant) throw new Error("No connection grant was saved");
const account = grant.account;
Reuse that saved account reference when constructing the client. encryptedSessions.load
returns decoded JSON (JsonValue) for the session parser. Load credentials only
after checking the tenant’s grant. Inspect session.scopes: servers can return a partial
grant, so a successful sign-in does not prove every requested operation was authorized.
import { createSocial } from "@opencoredev/social-sdk";
import { bluesky } from "@opencoredev/social-sdk/bluesky";
import { blueskyOAuthTransport, parseBlueskyOAuthSession } from "@opencoredev/social-sdk/server";
const session = parseBlueskyOAuthSession(await encryptedSessions.load(tenantId, account.accountId));
const transport = blueskyOAuthTransport(session, secureRequests);
const adapter = bluesky({
backend: "direct",
auth: { service: transport.service, did: transport.did },
session: transport,
});
const social = createSocial({ backends: { direct: adapter } });
The transport sends a fresh access-token-bound DPoP proof to the session’s PDS. It
replays an explicit 400/401 nonce challenge at most once when the body is replayable.
It does not refresh tokens. Call refreshBlueskyOAuthSession(session, { clientId, ...secureRequests }) before expiresAt; confidential clients also supply clientKey.
Refresh re-resolves the DID and rejects a changed authorization server. Store rotating
refresh tokens with compare-and-set. Reused or expired refresh tokens require reconnecting.
transition:generic is a broad transitional grant, not a repository permission named
repo. It explicitly excludes chat.bsky. Prefer granular repository, blob, and RPC
permissions or a provider-supported permission set (include:<permission-set-nsid>)
from the permission specification. For example,
chat reads use rpc:chat.bsky.convo.listConvos?aud=did:web:api.bsky.chat%23bsky_chat;
request the other chat methods separately, or use the transitional
atproto transition:generic transition:chat.bsky grant. The chat scope requires
transition:generic to function. Include every requested scope in client metadata’s scope as well as the
connection request, and inspect the returned scopes. The minimal posting example above
does not authorize chat, deletion, video service authentication, or every native helper.
As a separate credential-ready alternative, an access JWT from an app password still works.
createBlueskySession({ service, identifier, password }) signs in with
com.atproto.server.createSession and returns { did, handle, accessJwt, refreshJwt }.
Bluesky limits sign-in to 30 calls per 5 minutes and 300 per day per account
(rate limits), so store the session instead of
signing in for each job. Access JWTs are short-lived: before or after one expires, call
refreshBlueskySession({ service, refreshJwt }), which calls
com.atproto.server.refreshSession with the refresh token. The refresh token rotates on
every call, so save the returned session atomically. A lost refresh response is
ambiguous_outcome because the old token may already be spent, and an expired one is
reconnect_required. Neither helper logs tokens.
A taken-down account normally cannot sign in. Pass allowTakendown: true to
createBlueskySession so the PDS issues a session anyway, which lets the owner export their
repository; the returned session then has active: false and status: "takendown", and most
writes still fail.
To sign out, end the grant instead of only deleting your copy. For an app-password session,
deleteBlueskySession({ service, refreshJwt }) calls com.atproto.server.deleteSession with
the refresh token, and both tokens stop working. For an OAuth session,
revokeBlueskyOAuthSession(session, options) from @opencoredev/social-sdk/server revokes the
refresh token (or the access token when there is none) at the authorization server’s
revocation_endpoint, with a DPoP proof. It fails with unsupported_capability when the server
advertises no revocation endpoint. Delete the stored session either way.
import {
bluesky,
createBlueskySession,
refreshBlueskySession,
} from "@opencoredev/social-sdk/bluesky";
let session = await createBlueskySession({
service: "https://bsky.social",
identifier: process.env.BLUESKY_HANDLE!,
password: process.env.BLUESKY_APP_PASSWORD!,
});
// Later, when the access JWT has expired:
session = await refreshBlueskySession({
service: "https://bsky.social",
refreshJwt: session.refreshJwt,
});
const adapter = bluesky({
auth: { service: "https://bsky.social", did: session.did, accessJwt: session.accessJwt },
});
const social = createSocial({
backend: bluesky({
auth: {
service: process.env.BLUESKY_SERVICE!,
did: process.env.BLUESKY_DID!,
accessJwt: process.env.BLUESKY_ACCESS_JWT!,
},
}),
});
The adapter sends relative XRPC paths to session.fetchHandler, so a session from the official client also works. examples/snippets/bluesky-oauth.ts and examples/snippets/bluesky-sdk-session.ts show that route. Do not extract an OAuth token into accessJwt; OAuth tokens are DPoP-bound and fail as bearer tokens.
Examples
Read timelines and feeds
The native feed reads map directly to Bluesky’s documented XRPC views. getTimeline reads the
authenticated account’s default timeline, getFeed reads a feed generator by AT-URI, and
getAuthorFeed reads one actor with an optional provider filter. Each returns the native feed
views and an optional cursor; pass that cursor unchanged to the next call. Timeline reads require
the account’s authenticated session, while feed-generator and author-feed views are public AppView
reads when the configured service permits them. Native calls bypass normalized middleware, so keep
tenant authorization around the account reference.
const timeline = await native.getTimeline({ account, limit: 30 });
const custom = await native.getFeed({
account,
feed: "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot",
});
const author = await native.getAuthorFeed({
account,
actor: "did:plc:z72i7hdynmk6r22z27h6tvur",
filter: "posts_and_author_threads",
});
See the official viewing feeds guide and
the getTimeline HTTP reference.
getAuthorFeed also takes the posts_with_video filter and includePins: true, which puts the
actor’s pinned post at the top of the first page. More feed reads:
native.getListFeed({ account, listUri })reads recent posts from a list’s members (app.bsky.feed.getListFeed).native.listFeeds({ account })returns Bluesky’s suggested feed generators (app.bsky.feed.getSuggestedFeeds).native.getActorFeeds({ account, actor })lists the feeds an actor created, andnative.getFeedGenerator({ account, feed })andnative.getFeedGenerators({ account, feeds })return generator views; the single read also reportsisOnlineandisValid.
Read posts and threads
native.getPost({ uri }) hydrates one post. native.getPosts({ account, uris }) hydrates up to
25 in one app.bsky.feed.getPosts call and returns the post views it found; missing or deleted
posts are left out, so match results by uri.
native.getPostThread({ uri, depth, parentHeight }) calls app.bsky.feed.getPostThread.
depth is how many reply levels to include (0 to 1000, Bluesky default 6) and parentHeight how
many ancestors (0 to 1000, default 80). The normalized social.comments.list(post) reads the
same thread view and returns its direct replies as comments, one page with no cursor; use the
native read with depth for nested replies. social.comments.reply(comment, { text }) publishes
a reply post whose reply.root and reply.parent strong references come from the comment’s
post.
native.getPostThreadV2({ account, anchor, above, below, branchingFactor, sort }) calls
app.bsky.unspecced.getPostThreadV2, the flat thread view Bluesky’s app uses. above (default
true) includes parents, below is reply depth (0 to 20, default 6), branchingFactor is
replies per level (0 to 100, default 10), and sort is newest, oldest (default), or top.
It returns thread as a list of items with a depth, plus hasOtherReplies; when that is true,
native.getPostThreadOtherV2({ account, anchor }) reads the hidden and other replies. Bluesky
marks both endpoints unspecced and unstable, so their shapes may change.
native.getQuotes({ account, uri, cid }) lists posts that quote a post, and
native.getRepostedBy({ account, uri, cid }) lists the actors who reposted it. Both page with a
cursor; cid narrows to one version of the record.
Post metrics
social.analytics.getPostMetrics(post) reads the post view and reports likeCount,
repostCount, replyCount, quoteCount, and bookmarkCount as the likes, reposts,
replies, quotes, and bookmarks metrics (bookmarks map to the canonical saves). They are
lifetime counts with unknown freshness. Bluesky has no impressions, reach, or view counts.
Publish a text post
The adapter enforces 300 graphemes and 3,000 UTF-8 bytes and creates rich-text facets automatically: a app.bsky.richtext.facet#link facet for each URL and a app.bsky.richtext.facet#tag facet for each hashtag. Facet offsets are UTF-8 byte offsets, as the posts guide requires.
const result = await social.posts.publish({
targets: [{ account }],
content: { text: "A Bluesky post with a link https://example.com #TypeScript" },
});
Hashtags, tags, and content warnings
Hashtags in the text become facet#tag facets with the same detection rules as Bluesky’s own
client: a # (or full-width #) at the start or after whitespace, at least one character that is
not a digit or punctuation, trailing punctuation dropped, and at most 64 characters. #2026 is
not a tag. A # inside a URL stays part of the link.
options.tags adds up to 8 post-level hashtags to the record’s tags field without showing them
in the text. Each tag needs 1 to 64 graphemes, at most 640 bytes, and no spaces; a leading # is
removed.
options.contentLabels writes self-labels (content warnings) as a
com.atproto.label.defs#selfLabels value on the post. Bluesky’s app honours porn, sexual,
nudity, and graphic-media on posts; other values are rejected before any request.
await social.posts.publish({
targets: [{ account, options: { tags: ["release", "sdk"], contentLabels: ["graphic-media"] } }],
content: { text: "Before and after photos #surgery" },
});
Link cards
content.link becomes an app.bsky.embed.external card with the URL, title, and
description you supply. The SDK does not fetch the page to fill them in, so missing fields are
sent as empty strings. For a thumbnail, upload the image first with native.uploadBlob (an
image/* blob of at most 1,000,000 bytes) and pass the returned reference as
options.linkThumbnail. A post carries one media embed, so a link card cannot be combined with
images or a video; put the URL in the text instead and it still gets a link facet.
const native = social.native("default", { acknowledgeUnsafe: true })!;
const thumb = await native.uploadBlob({ account, blob: thumbnailBlob, mimeType: "image/jpeg" });
await social.posts.publish({
targets: [{ account, options: { linkThumbnail: thumb } }],
content: {
text: "New guide",
link: { url: "https://example.com/guide", title: "Guide", description: "How it works" },
},
});
Some sites publish their content as AT Protocol records too (for example Standard.site
documents). native.getEmbedExternalView({ account, url, canonicalUri }) calls
app.bsky.embed.getEmbedExternalView, which checks the record against the URL and returns
enhanced card data (article, publication, gallery, or livestream) and associatedRefs. Pass
those strongRefs as options.linkAssociatedRefs when you publish the card, and the SDK writes
them to external.associatedRefs so clients can render the richer card. An empty response means
the record could not be verified; publish a plain card and leave linkAssociatedRefs out.
Upload blobs
native.uploadBlob({ account, blob, mimeType }) calls com.atproto.repo.uploadBlob and returns
the { $type: "blob", ref, mimeType, size } reference that records embed. Use it for link-card
thumbnails, video caption files, and profile avatars and banners (PNG or JPEG, at most
1,000,000 bytes each). The PDS enforces size and type when a record references the blob, and
deletes a blob that no record references within a few minutes, so upload just before the write.
Publish with images
Each image must be at most 2,000,000 bytes; Media specifications lists every media limit. One to four images are sent as an
app.bsky.embed.images embed. Five to ten images are sent as an app.bsky.embed.gallery
embed, whose lexicon requires alt text and an aspect ratio on every item, so supply width and
height for each image; the lexicon allows 20 items but asks clients to stop at 10. The gallery
lexicon is newer than the images embed, and the AppView’s published OpenAPI document does not
list a gallery view yet, so clients that do not know it may not show the images. Use four or
fewer for the widest support.
Supply an image/* MIME type through mimeType or the Blob’s type. Width and height
are optional for one to four images, but must be supplied together as positive integers. Streams need an
explicit MIME type and are bounded while read. Remote HTTPS images are denied by
default; configure allowMediaHost: host => host === "cdn.example.com" on the adapter
before using that exact host. The example uses a local Blob instead.
import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
const bytes = new Uint8Array(await readFile("./screenshot.png"));
const blob = new Blob([bytes], { type: "image/png" });
const fingerprint = createHash("sha256").update(bytes).digest("hex");
const result = await social.posts.publish({
targets: [{ account }],
content: {
text: "Screenshot attached",
media: [{ kind: "image", source: { kind: "blob", blob, fingerprint } }],
},
});
Languages and mentions
Set options.languages to at most three BCP 47 language tags. The adapter validates and canonicalizes them, then writes the native langs field. For a linked mention, supply an already-resolved DID and UTF-8 byte offsets in options.mentions. It performs no automatic handle lookup.
const content = { text: "🙂 @alice.example https://example.com" };
const options = {
languages: ["en-US"],
mentions: [{ byteStart: 5, byteEnd: 19, did: "did:plc:alice" }],
};
const result = await social.posts.publish({ targets: [{ account, options }], content });
The emoji occupies four UTF-8 bytes, so the mention begins at byte five after the space. The DID must come from a trusted identity lookup; plain @handle text alone is not resolved into a mention. Supply at most 100 mention spans. Each span must start with @ and fit within the UTF-8 text. Preparation rejects split character boundaries, overlapping mention/link spans, and malformed language tags. Through managed backends, Zernio maps languages (1 to 3 BCP-47 codes) to its langs field; Post for Me, PostFast, Postiz, and Buffer reject languages at preparation with options.unmapped. No managed adapter maps mentions, so all five reject it.
Quote posts
Set options.quote to the quoted post’s { uri, cid } to publish a quote post with an
app.bsky.embed.record embed. Because it goes through social.posts.publish, a quote keeps
languages, mentions, hashtags, tags, self-labels, and reply controls. Add images, a video, or a
link card to the same post and the embed becomes app.bsky.embed.recordWithMedia, which holds
the quoted record and the media together.
await social.posts.publish({
targets: [{ account, options: { quote: { uri: quotedUri, cid: quotedCid }, languages: ["en"] } }],
content: { text: "This is the thread to read", media: [screenshot] },
});
native.quotePost({ account, post, text }) remains for a plain text quote with link facets
only.
Threads
Bluesky has no thread object: a self-thread is a reply chain where each post’s reply.root
is the first post and reply.parent is the previous one. social.posts.publishSequence with
replyToPrevious: true publishes the items in order and builds those references from each
published post. If one item fails, the later items on that account are not submitted, and the
result lists what was published so you can resume.
const thread = await social.posts.publishSequence({
idempotencyKey: "launch-thread",
replyToPrevious: true,
items: [
{ targets: [{ account }], content: { text: "1/3 We shipped the SDK" } },
{ targets: [{ account }], content: { text: "2/3 Here is what changed" } },
{ targets: [{ account }], content: { text: "3/3 Docs: https://social-sdk.dev" } },
],
});
Each post is a separate write. com.atproto.repo.applyWrites cannot chain a whole thread
atomically, because a reply’s strong reference needs the parent’s CID, which the PDS computes.
Reply controls and quote controls
options.replyControls limits who can reply to a root post. The adapter writes an
app.bsky.feed.threadgate record with the same record key as the post. Rules are
{ kind: "mention" }, { kind: "follower" }, { kind: "following" }, and
{ kind: "list", listUri }, written as threadgate#mentionRule, #followerRule,
#followingRule, and #listRule. Up to 5 rules combine: anyone matching one of them can reply.
An empty array lets nobody reply. Reply controls are rejected on replies because a threadgate
applies to the thread’s root post.
options.disableQuotes: true writes an app.bsky.feed.postgate record with an
app.bsky.feed.postgate#disableRule embedding rule, so other posts cannot quote this one.
When either option is set, the post and its gates go in one com.atproto.repo.applyWrites
transaction: they are created together or not at all. The adapter generates the shared record
key as a TID before the write.
await social.posts.publish({
targets: [
{
account,
options: { replyControls: [{ kind: "following" }, { kind: "mention" }], disableQuotes: true },
},
],
content: { text: "Replies limited to people I follow" },
});
For posts that already exist, the native module edits the gates with compare-and-swap. Each
call reads the current record and writes it back with its CID as swapRecord, so a concurrent
change fails instead of being lost; a missing gate is created with the post’s record key.
native.setThreadgate({ account, postUri, allow })replaces the reply rules.allow: nullreopens replies, and[]closes them. Hidden replies are kept.native.setQuotesDisabled({ account, postUri, disabled })adds or removes the postgatedisableRule.native.detachQuote({ account, postUri, quoteUri, detached })adds or removes a quoting post in the postgate’sdetachedEmbeddingUrislist (at most 50), which detaches your post from someone else’s quote of it.native.hideReply({ account, replyUri, hidden })adds or removes a reply in the root post’s threadgatehiddenReplieslist (at most 300). Only the root post’s author can hide replies, and creating a gate for this does not restrict who can reply.
The posting account needs write access to app.bsky.feed.threadgate and
app.bsky.feed.postgate (for OAuth, repo:app.bsky.feed.threadgate and
repo:app.bsky.feed.postgate, or transition:generic). See the
thread gates guide.
Batch writes
native.applyWrites({ account, writes, validate, swapCommit }) sends one
com.atproto.repo.applyWrites transaction of create, update, and delete operations on the
account’s repository. Each record value needs a $type equal to its collection, and a batch
holds 1 to 200 writes (the reference PDS limit). swapCommit makes the PDS reject the batch if
the repository moved past that commit. The result returns the commit and one entry per write
with its URI, CID, and validation status. Every write counts toward the PDS write limit of
5,000 points per hour and 35,000 per day: 3 per create, 2 per update, 1 per delete
(rate limits).
Drafts and scheduling
AT Protocol has no scheduled publishing. A record is visible as soon as it is written, and the
post’s createdAt is a timestamp the client declares; setting it in the future does not delay
the post. A publish request with a schedule is rejected (see Not supported).
Run scheduled posts from your own job runner.
Bluesky does keep private server-side drafts through app.bsky.draft.*. native.createDraft,
native.listDrafts, native.updateDraft, and native.deleteDraft call createDraft,
getDrafts, updateDraft, and deleteDraft. A draft holds 1 to 100 posts (text up to 10,000
bytes each), with optional languages, self-labels, a link URL, a quote, reply controls, and quote
blocking that apply when it is published. Publishing a draft is your call: read it, then pass its
posts to posts.publish or publishSequence.
Drafts can keep images and video too, but Bluesky stores no media bytes with a draft: each item
is a path on the device that saved it, which deviceId and deviceName identify. A draft post
holds one kind: images (1 to 4, written as embedImages), gallery (1 to 20, written as
embedGallery), or video (one, written as embedVideos, with up to 20 WebVTT captions stored
as text, each up to 10,000 UTF-8 bytes). Each item has a path of up to 1,024 UTF-8 bytes and
optional alt text of up to 2,000 graphemes. Upload the files only when the draft is published, from the device that has them.
const { id } = await native.createDraft({
account,
draft: { posts: [{ text: "Announcement for Monday" }], replyControls: [{ kind: "following" }] },
});
await native.createDraft({
account,
draft: {
deviceId: "laptop-1",
posts: [{ text: "Launch photos", images: [{ path: "/Users/me/launch.jpg", alt: "Stage" }] }],
},
});
Like a post
adapter.native.likePost accepts an acting account and the target post’s native { uri, cid }. The target may belong to another author.
import { connectedAccountRef } from "@opencoredev/social-sdk";
import { bluesky } from "@opencoredev/social-sdk/bluesky";
const adapter = bluesky({
backend: "direct",
auth: { service, accessJwt, did },
});
const like = await adapter.native!.likePost({
account: connectedAccountRef({ backend: "direct", platform: "bluesky", accountId: did }),
post: { uri: postUri, cid: postCid },
});
// Persist like.uri: unlikePost accepts only a like-record URI
// belonging to the configured DID, not the target post URI.
It creates an app.bsky.feed.like record in the acting account’s repository and returns that record’s URI/CID. When the user found the post through someone’s repost, pass that repost record as via: { uri, cid }. The like stores it as a via strongRef (an AT-URI plus CID), and Bluesky sends the reposter a like-via-repost notification (likeViaRepost in notification preferences). via must point at an app.bsky.feed.repost record; anything else is rejected before a request is sent. Native calls bypass the client’s tenant authorization and concurrency middleware; authorize the acting account before invoking them. Mutations use bounded transport and do not retry a lost response; reconcile uncertain acceptance before submitting again.
Read threads natively
Bluesky replies preserve the native AT URI and CID through the adapter’s native post metadata. Native reads and thread reads are available through social.native('direct', { acknowledgeUnsafe: true }); this path bypasses normalized middleware and must be wrapped by your tenant policy.
Search posts natively
native.searchPosts calls app.bsky.feed.searchPosts. Supply a non-empty query and, optionally,
sort (latest or top), since, until, mentions, author, lang, domain, url, and
tags. Pages contain provider post objects, an optional cursor, and an optional hitsTotal.
Bluesky documents the cursor as best effort; it may not traverse every hit. The endpoint accepts
1–100 results per request. The SDK sends 50 by default; the lexicon default when omitted on the wire is 25, and time bounds use ISO dates or datetimes with an
inclusive since and exclusive until.
The normalized search facade supports the provider’s recent horizon. Bluesky does not define an
all horizon for this endpoint, so scope: "all" is rejected before a request is sent.
native.searchPostsV2 calls app.bsky.feed.searchPostsV2, the structured search. Every list
filter has an include form and an exclude form: authors/excludeAuthors,
mentions/excludeMentions, domains/excludeDomains, urls/excludeUrls,
embeddedAtUris/excludeEmbeddedAtUris, hashtags/excludeHashtags (a leading # is
removed; 64 graphemes each), and languages/excludeLanguages. The flags hasMedia,
hasVideo, repliesOnly, excludeReplies (not both), following (only accounts the viewer
follows), and allTime (search beyond the recent window) narrow it further, and
replyParentUri or threadRootUri restrict it to one conversation. sort is recent or
top, query is optional when filters are set, and queryLanguage hints at ja, zh, ko,
th, or ar text. Results include hitsTotal and detectedQueryLanguages when Bluesky returns
them.
const page = await native.searchPostsV2({
account,
query: "release notes",
authors: ["alice.bsky.social"],
excludeReplies: true,
hasMedia: true,
});
Read and change relationships
The normalized graph facade maps to AT Protocol profile and graph procedures. Relationship pages use
app.bsky.graph.getFollowers, getFollows, getBlocks, and getMutes; follow and block create
repository records, while unfollow and unblock delete those records. Mute and unmute use
muteActor and unmuteActor.
const profile = await social.graph.getProfile(account, { handle: "alice.bsky.social" });
const followers = await social.graph.listRelationships(account, { kind: "followers", limit: 50 });
await social.graph.follow(profile.ref);
await social.graph.mute(profile.ref);
Native graph calls add the provider options the facade does not model:
native.getFollowers({ account, actor, sort })andnative.getFollowspasssort:latest(newest first) ortop. Omit it for the AppView default order.native.mute({ account, did, onlyReposts: true })hides only that account’s reposts, andonlyQuoteposts: truehides only its quote posts. Set both to hide reposts and quote posts while keeping the account’s own posts, or omit both for a full mute. Each call replaces the stored mute scope.native.follow({ account, did, via })records theapp.bsky.graph.starterpackrecord the follow came from, so the pack’s join statistics credit it. A new account that signed up through a pack setsjoinedViaStarterPack(the pack’s AT-URI and CID) withnative.editProfile({ account, changes: { joinedViaStarterPack: { uri, cid } } }).
const top = await native.getFollowers({ account, actor: "alice.bsky.social", sort: "top" });
await native.mute({ account, did: "did:plc:alice", onlyReposts: true });
await native.follow({ account, did: "did:plc:alice", via: { uri: packUri, cid: packCid } });
Profiles, suggestions, and relationships
native.getProfiles({ account, actors })returns detailed profile views for up to 25 DIDs or handles (app.bsky.actor.getProfiles).native.resolveIdentity({ account, identifier })resolves a handle or DID withcom.atproto.identity.resolveIdentity; the server verifies the handle and DID point at each other and returns{ did, handle, didDoc }, withhandle.invalidwhen they do not.native.resolveHandle({ account, handle })returns just the DID (com.atproto.identity.resolveHandle), andnative.resolveDid({ account, did })returns the DID document (resolveDid). These go through the connected PDS; the OAuth helper’s own resolution stays separate because it runs before any account is connected.native.describeRepo({ account, repo })callscom.atproto.repo.describeRepoand returns the repository’sdid,handle,handleIsCorrect,collections(the record types it holds), anddidDoc.native.getSuggestions({ account })pages suggested follows (app.bsky.actor.getSuggestions), andnative.getSuggestedFollowsByActor({ account, actor })returns accounts similar to one actor.native.getRelationships({ account, actor, others })returns follow and block relationships betweenactor(default: the connected account) and up to 30 others (app.bsky.graph.getRelationships).native.getKnownFollowers({ account, actor })pages the followers ofactorwhom the connected account also follows.native.getVerificationState({ account, actor })decodes the profile view’sverificationobject:verifiedStatus,trustedVerifierStatus, and each verification’s issuer, record URI, and validity. Verifications areapp.bsky.graph.verificationrecords, and Bluesky counts one only when a verifier it trusts issued it, so the SDK reads verification state and does not issue verifications.
Reposts and bookmarks
native.repostPost({ account, post }) creates an app.bsky.feed.repost record and returns its
URI and CID. Keep that URI: native.unrepostPost({ account, repostUri }) (un-repost) deletes
that record and accepts only repost URIs in the connected account’s repository. The profile or
post view’s viewer.repost field also holds the URI. repostPost also takes via, the repost
record the post was seen through, stored as a via strongRef so the original reposter gets a
repost-via-repost notification (repostViaRepost in notification preferences).
const repost = await native.repostPost({ account, post: { uri, cid } });
await native.unrepostPost({ account, repostUri: repost.uri });
Bookmarks are private to the account. native.createBookmark({ account, post }),
native.deleteBookmark({ account, uri }), and native.getBookmarks({ account }) call
app.bsky.bookmark.createBookmark, deleteBookmark, and getBookmarks. Bluesky currently
accepts post records only.
Thread mutes, list memberships, and starter packs
native.muteThread({ account, rootUri }) and native.unmuteThread stop or restore notifications
from a thread and its replies. Mutes are private.
native.getListBlocks and native.getListMutes page the moderation lists the account blocks or
mutes. native.getListsWithMembership({ account, actor, purposes }) returns the account’s own
curate and mod lists, each with actor’s list item if they are a member, which is how you show
“add to list” state.
Starter packs are app.bsky.graph.starterpack records that point at a reference list. Create the
list with native.createList({ purpose: "app.bsky.graph.defs#referencelist", ... }), add members
with addListItem, then call native.createStarterPack({ account, name, listUri, description, feeds }) (name 1 to 50 graphemes, description up to 300, at most 3 feeds).
native.getStarterPack, getStarterPacks (up to 25), getActorStarterPacks, and
searchStarterPacks read them, and deleteStarterPack removes one.
native.searchStarterPacksV2({ account, query }) uses app.bsky.graph.searchStarterPacksV2
and adds hitsTotal. native.getStarterPacksWithMembership({ account, actor }) pages the
account’s own starter packs, each with actor’s list item when they are already in it
(app.bsky.graph.getStarterPacksWithMembership), which is how you show “add to starter pack”
state.
Trends and discovery
Bluesky’s discovery endpoints live under app.bsky.unspecced, which Bluesky documents as
unstable, so their responses may change without notice. The SDK passes their views through.
native.getTrends({ account, limit })(getTrends, 1 to 25) andnative.getTrendingTopics({ account, viewer, limit })(getTrendingTopics, withtopicsandsuggestedlists) read trending topics.native.getSuggestedUsers({ account, category, limit })(1 to 50) suggests accounts, optionally for a category.native.getSuggestedFeedsandnative.getSuggestedStarterPacks(1 to 25) suggest feeds and starter packs, andnative.getTaggedSuggestionsreturns tagged actor and feed suggestions.native.getPopularFeedGenerators({ account, query, limit, cursor })pages popular feeds, optionally matchingquery.
Feed feedback
When a feed generator sets acceptsInteractions, clients can tell it what the user thought.
native.sendFeedInteractions({ account, feedServiceDid, feed, interactions }) calls
app.bsky.feed.sendInteractions with the atproto-proxy header set to
<feedServiceDid>#bsky_fg, which is how Bluesky’s app routes the call to the generator. Take
feedServiceDid from the generator view’s did. Each interaction has an event such as
app.bsky.feed.defs#requestMore, #requestLess, #interactionSeen, or #clickthroughItem,
the post AT-URI as item, and the feedContext and reqId the feed returned, echoed back so
the generator can match the signal to what it served.
await native.sendFeedInteractions({
account,
feedServiceDid: generator.did,
feed: generator.uri,
interactions: [{ item: postUri, event: "app.bsky.feed.defs#requestLess", feedContext }],
});
Age assurance
Some regions require age assurance before certain content is shown.
native.getAgeAssuranceConfig({ account }) returns the per-region rules
(app.bsky.ageassurance.getConfig), and native.getAgeAssuranceState({ account, countryCode, regionCode }) returns the account’s state (status and access) and metadata
(getState). native.beginAgeAssurance({ account, email, language, countryCode, regionCode })
calls app.bsky.ageassurance.begin, which emails the user a verification link and returns the
new state. The email address is sent to Bluesky; the SDK does not log it.
Search actors, likes, lists, and reports
Use native.searchActors (SDK default 50) or native.searchActorsTypeahead (SDK default 8) for actor discovery. Likes use
app.bsky.feed.getLikes and getActorLikes for reads and repository records for writes. List
creation and item changes use app.bsky.graph.list and listitem records. Read lists with
native.getList and native.getLists. native.muteList and native.unmuteList call
app.bsky.graph.muteActorList and unmuteActorList; native.blockList and
native.unblockList manage list blocks. List blocks are
app.bsky.graph.listblock records that the adapter creates and removes with repository procedures.
Moderation reports call com.atproto.moderation.createReport and require an authenticated
moderation service. native.createModerationReport takes an optional modTool: { name, meta }
that tells moderators which tool filed the report: name identifies the tool (for example
"my-app/2.1") and meta holds any JSON context for tracing. An empty name is rejected.
Labelers publish moderation labels. native.getLabelerServices({ account, dids, detailed }) reads
labeler service views through app.bsky.labeler.getServices; detailed: true adds each
service’s label definitions and policies. native.queryLabels({ account, uriPatterns, sources })
calls com.atproto.label.queryLabels for labels on matching subjects. A pattern is a full AT-URI
or a prefix ending in *, and pages hold up to 250 labels.
Receive events
Bluesky has no signed webhooks (see Not supported). For the connected account’s mention/reply inbox,
use social.notifications.list(account) and social.notifications.markSeen(account).
The native notification reads add the provider’s filters:
native.listNotifications({ account, reasons, priority, seenAt })filters by reason (for example["mention", "reply", "quote"]) and to priority accounts.native.getUnreadNotificationCount({ account })returns the unread count fromapp.bsky.notification.getUnreadCount.native.getNotificationPreferences({ account })reads notification preferences per type, andnative.putNotificationPreferences({ account, preferences })changes only the types you pass throughapp.bsky.notification.putPreferencesV2and returns the full result. Each type setslist(in-app) andpush; most also takeinclude: "all" | "follows", and chat takesinclude: "all" | "accepted".native.getGroupedNotifications({ account, feed, limit, cursor })callsapp.bsky.notification.getGroupedNotifications, which Bluesky marks unstable. It returns notifications already grouped the way the app shows them (likes, reposts, follows, likes and reposts via a repost, replies, mentions, and more) ingroups, with the referenced profiles and posts inrelatedViews.feedisall(default),people-i-follow,conversations,followers, oractivity, andlimitis 1 to 50.- Activity subscriptions notify the account when someone posts or replies.
native.listActivitySubscriptions({ account })pages the accounts it is subscribed to, andnative.putActivitySubscription({ account, subject, post, reply })subscribes to a DID (both flagsfalseunsubscribes). Polling notifications forsubscribed-postgives a pull-based feed of those accounts’ new posts.
The relay firehose (com.atproto.sync.subscribeRepos) and Jetstream serve network
indexing. Filtering a stream by the connected DID watches that account’s authored
records, not mentions or replies written by others. Recipient matching requires reading
other authors’ post records and inspecting their mentions and reply references. See
Consuming the firehose.
Jetstream decodes the firehose into JSON and filters it on the server. The SDK does not open sockets; it builds the subscription and decodes frames:
blueskyJetstreamSubscription({ collections, dids, kinds, cursor })returns the WebSocketurlandprotocolsfor Jetstream v2 (network.bsky.jetstream.subscribeEventsonjetstream.us-east.bsky.networkby default). It accepts up to 100 collections (an NSID or anapp.bsky.feed.*wildcard) and 10,000 DIDs.version: 1builds the legacy/subscribeURL withwantedCollectionsandwantedDidsinstead.parseBlueskyJetstreamEvent(frame)decodes a v2 envelope or a v1 event into a typed commit (operation,collection,rkey,uri,record) or an identity, account, or sync event. Delivery is at least once, so key handlers onuri, and reconnect with the lastseqascursor.
import {
blueskyJetstreamSubscription,
parseBlueskyJetstreamEvent,
} from "@opencoredev/social-sdk/bluesky";
const { url, protocols } = blueskyJetstreamSubscription({
collections: ["app.bsky.feed.post"],
kinds: ["commit"],
});
const socket = new WebSocket(url, [...protocols]);
socket.onmessage = (frame) => {
const event = parseBlueskyJetstreamEvent(String(frame.data));
if (event?.kind === "commit" && event.operation === "create") console.log(event.uri);
};
Native operations
The typed native module covers reposts, quote posts, deletion, thread and quote gates, batch writes, drafts, graph relationships, likes, bookmarks, actor search, lists, starter packs, labelers, moderation reports, notifications, profiles and live status, account preferences, feeds, search, chat, and video processing helpers. Native calls require application authorization.
Video captions and GIF presentation
options.videoCaptions attaches up to 20 WebVTT caption tracks to a video post as
app.bsky.embed.video captions. Upload each .vtt file first with
native.uploadBlob({ account, blob, mimeType: "text/vtt" }); each must be at most 20,000 bytes.
Pass { lang, file } per track, where lang is a BCP 47 tag. options.videoPresentation sets
the embed’s presentation hint: gif asks clients to loop the clip like a GIF, default shows a
normal player. Both options are rejected on posts without a video.
const en = await native.uploadBlob({ account, blob: englishVtt, mimeType: "text/vtt" });
await social.posts.publish({
targets: [{ account, options: { videoCaptions: [{ lang: "en", file: en }] } }],
content: {
text: "With captions",
media: [{ kind: "video", source: { kind: "media-ref", ref } }],
},
});
Upload and publish video
media.upload accepts one non-empty MP4 Blob of at most 300,000,000 bytes and returns
a processing-job reference. Videos up to 50,000,000 bytes go up in one
app.bsky.video.uploadVideo request; larger ones use the
multipart video upload. The application polls outside the SDK. Check
native.getVideoUploadLimits explicitly if you need quota information; uploads do not
perform automatic quota or email preflight. The service can require verified email and
available daily allowance. The official Bluesky app currently limits clips to ten minutes;
that app limit alone does not establish a protocol-wide duration constraint. The SDK
validates bytes, not duration.
import { setTimeout as delay } from "node:timers/promises";
const native = social.native("direct", { acknowledgeUnsafe: true });
if (!native) throw new Error("Bluesky native adapter is unavailable");
const limits = await native.getVideoUploadLimits({ account });
if (!limits.canUpload) throw new Error("Video upload is unavailable for this account");
const mp4 = new Blob([new Uint8Array(await readFile("./clip.mp4"))], { type: "video/mp4" });
const fingerprint = createHash("sha256")
.update(new Uint8Array(await mp4.arrayBuffer()))
.digest("hex");
const ref = await social.media.upload(
{ kind: "video", mimeType: "video/mp4", source: { kind: "blob", blob: mp4, fingerprint } },
account,
);
// This bounded polling loop belongs to the application, outside the SDK.
let job = await native.getVideoJobStatus({ account, jobId: ref.mediaId });
for (let attempt = 1; !job.blob && attempt < 12; attempt++) {
if (job.state === "JOB_STATE_FAILED") throw new Error("Video processing failed");
await delay(5_000);
job = await native.getVideoJobStatus({ account, jobId: ref.mediaId });
}
if (!job.blob) throw new Error("Video is not ready; defer publication and check later");
const result = await social.posts.publish({
targets: [{ account }],
content: {
text: "Watch this",
media: [{ kind: "video", source: { kind: "media-ref", ref }, width: 1920, height: 1080 }],
},
});
Publish checks the job once more and creates the record only if the processed MP4 blob is ready. It never waits for processing or polls. Handle failed jobs explicitly before publishing.
Multipart video upload
native.uploadVideoMultipart sends a video in parts, so a large file does not depend on one
long request. It calls app.bsky.video.startUpload with the exact size, sends each part with
app.bsky.video.uploadPart in order, one at a time, and then calls app.bsky.video.finishUpload.
The service picks the part size; the last part carries the remainder. Like uploadVideo, it
returns the processing job without waiting. Poll getVideoJobStatus with the returned jobId,
which can differ from the upload job ID when the service recognizes a video it already has.
Every multipart call carries a service token with the same scope as the single-request upload:
audience is the account’s PDS service DID (pdsDid, or read from the session’s DID document),
method is com.atproto.repo.uploadBlob, and it lasts 30 minutes. The SDK mints a new token
before a part when the current one expires within a minute, and always mints one before
finishing, because the video service keeps that token to store the processed blob on the PDS.
This follows Bluesky’s own app (src/lib/media/video/multipart/ in
bluesky-social/social-app,
checked 2026-11-04).
durationMs, width, and height are optional hints the service uses only to fail early. The
SDK never retries a request and never aborts a session on its own. A failure after
startUpload keeps the upload job ID in error.details.uploadJobId, and a part or finish
request with a lost response is ambiguous_outcome. Resume with the individual steps, or
release the reserved quota with native.abortVideoUpload:
import { SocialError } from "@opencoredev/social-sdk";
try {
const job = await native.uploadVideoMultipart({ account, video: mp4, name: "clip.mp4" });
console.log("processing job", job.jobId);
} catch (error) {
const jobId = error instanceof SocialError ? error.details?.["uploadJobId"] : undefined;
if (typeof jobId !== "string") throw error;
const status = await native.getVideoUploadStatus({ account, jobId });
if (status.state !== "created") throw error;
for (let partNumber = 1; partNumber <= status.partCount; partNumber++) {
if (status.receivedParts.includes(partNumber)) continue;
const start = (partNumber - 1) * status.partSizeBytes;
const part = mp4.slice(start, start + status.partSizeBytes);
await native.uploadVideoPart({ account, jobId, partNumber, part });
}
const job = await native.finishVideoUpload({ account, jobId });
console.log("processing job", job.jobId);
}
native.startVideoUpload opens a session without sending bytes. getVideoUploadStatus reports
created, finishing, completed, failed, aborted, or expired, with the received part
numbers and, once completed, the processing job. abortVideoUpload works only while the session
is created; a session that already ended reports its outcome instead.
Delete a reply
native.deletePost({ account, post: { uri, cid }, context }) deletes a reply’s native
app.bsky.feed.post record through
com.atproto.repo.deleteRecord.
Alternatively use social.posts.removeFromPlatform with the normalized post reference.
Deletion is restricted to the configured repository; another DID’s URI is rejected
before any request is sent.
Chat
Chat calls go to the PDS with the atproto-proxy: did:web:api.bsky.chat#bsky_chat header,
which forwards them to Bluesky’s chat service. There is no normalized social.messages
facade. OAuth needs separate chat permissions as described above; transition:generic
excludes chat. Authorize the acting account in the application before every native call.
Chat consent and provider moderation still apply; retain native references and reconcile
outcomes.
Reading:
native.listConversations({ account, kind, status, unreadOnly, lockStatus })pageschat.bsky.convo.listConvos, optionally onlydirectorgroupchats,acceptedorrequeststatus, unread ones, or groups bylockStatus(unlocked,locked, orlocked-permanently).native.getConversation({ account, conversationId })reads one.native.listMessages({ account, conversationId })pageschat.bsky.convo.getMessages.native.getChatLog({ account, cursor })readschat.bsky.convo.getLog: an event log of new messages, deletions, reactions, read changes, and conversations starting or ending. Store the returned cursor and pass it back to poll only what changed since.native.getUnreadConversationCounts({ account, includeGroupChats })callschat.bsky.convo.getUnreadCountsand returnsunreadAcceptedConvosandunreadRequestConvos. The app notification unread count does not include chats.includeGroupChatsdefaults totrueon Bluesky’s side.
Starting and accepting conversations:
native.getConvoForMembers({ account, members })gets or creates the direct conversation with up to 10 member DIDs, so you can message someone without an existing conversation ID.native.getConvoAvailabilitychecks first whether the members allow a chat, without creating one.- Message requests from people the account does not follow arrive in the request inbox.
native.listConvoRequests({ account })pages them (Bluesky marks this endpoint unstable), andnative.acceptConvo({ account, conversationId })moves one to the accepted list.
Sending:
native.sendMessage({ account, conversationId, text, mentions, embedPost }) sends one message with rich-text facets and an optional embed. Text is limited to 1,000 graphemes and 10,000 bytes. Links and hashtags in the text become facets automatically, mentions adds DID mentions at UTF-8 byte offsets as for posts, and embedPost: { uri, cid } shares a post as an app.bsky.embed.record embed. embedJoinLink: code instead shares a group chat join link as a chat.bsky.embed.joinLink card; a message carries one embed, so the two cannot be combined.
native.sendMessageBatch({ account, items }) sends up to 100 messages, to one or several
conversations, in one chat.bsky.convo.sendMessageBatch call.
const { id } = await native.getConvoForMembers({ account, members: [bobDid] });
await native.sendMessage({
account,
conversationId: id,
text: "Have you seen this?",
embedPost: { uri: postUri, cid: postCid },
});
Managing conversations:
native.addReactionandnative.removeReactionadd or remove a single-emoji reaction on a message. Both are idempotent.native.markConversationRead({ account, conversationId, messageId })callsupdateRead, andnative.markAllConversationsRead({ account, status })callsupdateAllReadand returns how many conversations changed.native.muteConversationandnative.unmuteConversationcallmuteConvoandunmuteConvo.native.leaveConversationcallsleaveConvo. Leaving a group removes the membership; leaving a direct chat only hides it for this user.native.deleteMessageForSelf({ account, conversationId, messageId })hides a message for this user only. Other members still see it.
Chat settings and account data:
- Who may message the account is the
chat.bsky.actor.declarationrecord described under Account declarations (allowIncoming,allowGroupInvites). native.getChatNotificationPreferences({ account })andnative.putChatNotificationPreferences({ account, chat, chatRequest })callchat.bsky.notification.getPreferencesandputPreferences. These are the chat service’s own settings, separate fromapp.bsky.notificationpreferences.chatcovers accepted conversations andchatRequestcovers message requests; each hasinclude(allorfollows) andpush(a boolean). Send one or both; the call returns the stored pair.native.exportChatAccountData({ account })downloads the account’s chat data fromchat.bsky.actor.exportAccountData. The service answers with JSON Lines, and the SDK returns one object per line, reading at most 50 MB.native.deleteChatAccount({ account })callschat.bsky.actor.deleteAccount, which deletes the account’s chat data and cannot be undone.
Group chats
Bluesky’s group chats are in beta: the chat.bsky.group.* endpoints are marked unstable while
under active development, so their shapes may change. native.createGroupChat({ account, name, members }) creates a group with up to 49 other member DIDs. Names for createGroupChat and
renameGroupChat take 1 to 50 graphemes and at most 500 bytes. Group creation is not
idempotent, so repeating it makes a second group. native.renameGroupChat,
native.addGroupChatMembers, and native.removeGroupChatMembers call editGroup,
addMembers, and removeMembers. Added members start in request status and must accept.
Administration (owners and admins; other members get InsufficientRole):
native.lockGroupChat({ account, conversationId })callschat.bsky.convo.lockConvoso members cannot send messages, andnative.unlockGroupChatcallsunlockConvo. A group that Bluesky moderation locked cannot be unlocked (ConvoLockedByModeration).native.getConvoMembers({ account, conversationId, limit, cursor })pages members throughchat.bsky.convo.getConvoMembers.native.listMutualGroups({ account, subject })pages the groups this account shares with another DID (chat.bsky.group.listMutualGroups).
Join links and join requests:
native.createJoinLink({ account, conversationId, joinRule, requireApproval })callschat.bsky.group.createJoinLink.joinRuleisanyoneorfollowedByOwner, andrequireApproval: truemakes joins wait for an admin. A group has at most one enabled link (EnabledJoinLinkAlreadyExists).native.editJoinLinkchanges the rule or approval setting, andnative.disableJoinLinkandnative.enableJoinLinkswitch the link off and on.native.getJoinLinkPreviews({ account, codes })previews up to 50 codes; disabled and invalid codes come back asdisabledJoinLinkPreviewViewandinvalidJoinLinkPreviewView.native.requestJoinGroupChat({ account, code })joins through a link and returnsstatus:joined(with the conversation) orpendingwhen approval is required.native.withdrawJoinRequestcancels a pending request.- Admins page pending requests with
native.listJoinRequests({ account, conversationId }), then callnative.approveJoinRequestornative.rejectJoinRequestwith the requester’s DID asmember.native.markJoinRequestsReadcallsupdateJoinRequestsRead.
const link = await native.createJoinLink({
account,
conversationId: groupId,
joinRule: "followedByOwner",
requireApproval: true,
});
await native.sendMessage({
account,
conversationId: otherId,
text: "Join us",
embedJoinLink: String(link["code"]),
});
Edit the profile
native.editProfile({ account, changes }) changes only the fields you pass on the
app.bsky.actor.profile/self record: displayName (up to 64 graphemes), description (256),
pronouns (20), website, avatar, banner, and pinnedPost. null removes a field. It
reads the stored record with com.atproto.repo.getRecord, merges the changes, and writes it back
with the read CID as swapRecord, so a concurrent edit makes the write fail instead of being
overwritten; read again and retry. An account without a profile record gets one created.
Avatars and banners are PNG or JPEG blobs of at most 1,000,000 bytes; upload them first with
native.uploadBlob.
const avatar = await native.uploadBlob({ account, blob: pngBlob, mimeType: "image/png" });
await native.editProfile({ account, changes: { displayName: "Ada", avatar, description: null } });
To pin a post to the profile, call native.pinPost({ account, post: { uri, cid } }), which sets
the profile’s pinnedPost strong reference with the same compare-and-swap; post: null unpins.
Only this account’s own posts can be pinned. getAuthorFeed({ includePins: true }) shows it.
native.updateProfile({ account, profile, swapRecord }) still replaces the entire record. A
partial object erases the fields it leaves out, so pass the full stored record and its CID. The
public native.getProfile response is an AppView, not that record; its CDN image URLs are not
upload blobs.
Live status
native.setLiveStatus({ account, url, title, description, thumb, durationMinutes }) writes the
app.bsky.actor.status/self record with status app.bsky.actor.status#live and an
app.bsky.embed.external card for the stream URL. Bluesky shows a live badge on the avatar
until durationMinutes pass; the lexicon requires at least 1 minute and leaves the maximum to
each app. A new call replaces the current status, and native.clearLiveStatus({ account })
deletes it. The thumbnail is an image blob from native.uploadBlob.
Account preferences
native.getPreferences({ account }) returns the private preference list from
app.bsky.actor.getPreferences: saved and pinned feeds, muted words, content-label settings,
thread and feed view settings, and more, each tagged with an app.bsky.actor.defs#... $type.
native.putPreferences({ account, preferences }) writes them with
app.bsky.actor.putPreferences, which replaces the whole list. Read, change the entries you
need, and write the full array back, or other settings are lost. For example, muted words live
in the app.bsky.actor.defs#mutedWordsPref entry’s items.
native.updateList does use the fetched CID as swapRecord. Omitting description
preserves it; an empty string clears it. Changed descriptions clear stale
descriptionFacets.
Account declarations
Bluesky keeps several account-wide choices as singleton records (record key self) in the
account’s own repository. The SDK reads them with com.atproto.repo.getRecord and writes them
with com.atproto.repo.putRecord, so they need repository write permission for the collection.
native.setNotificationDeclaration({ account, allowSubscriptions })writesapp.bsky.notification.declaration.allowSubscriptionsisfollowers,mutuals, ornoneand controls who may subscribe to the account’s new posts and replies.native.getNotificationDeclaration({ account, actor })returnsundefinedwhen there is no record, which Bluesky treats asfollowers.native.setChatDeclaration({ account, allowIncoming, allowGroupInvites })writeschat.bsky.actor.declaration.allowIncoming(who may start a direct chat) andallowGroupInvites(who may add the account to group chats) each takeall,following, ornone.native.getChatDeclarationreads it back.native.getChatStatus({ account })callschat.bsky.actor.getStatusthrough the chat proxy and returnschatDisabled,canCreateGroups, andgroupMemberLimit.native.setContentVisibility({ account, hideFromAlgorithmicRecommendations: true })writesapp.bsky.actor.contentVisibilityDeclaration, a request that algorithmic feeds and recommendations leave the account’s posts out. It is a request to consumers, not access control.native.getContentVisibilityreads a missing record (RecordNotFound) asfalse, as the lexicon requires; any other failed read is an error, notfalse.
await native.setNotificationDeclaration({ account, allowSubscriptions: "mutuals" });
await native.setChatDeclaration({ account, allowIncoming: "following", allowGroupInvites: "none" });
await native.setContentVisibility({ account, hideFromAlgorithmicRecommendations: true });
Profile links and reference list opt-outs
Profile links are app.bsky.actor.link records. native.createProfileLink({ account, url, title, icon }) creates one: url must be https, title is up to 40 graphemes, and icon is a
PNG, JPEG, or WebP blob of at most 100,000 bytes from uploadBlob. A link record alone is not
shown; set which links appear, and in what order, with up to 10 references in the profile’s
links field. native.deleteProfileLink({ account, uri }) deletes a link record.
const blog = await native.createProfileLink({ account, url: "https://example.com", title: "Blog" });
await native.editProfile({ account, changes: { links: [blog] } });
A starter pack shows the members of a reference list. To ask to be left out of someone else’s
list, native.optOutOfReferenceList({ account, listUri }) creates an
app.bsky.graph.referencelistoptout record whose subject is the DID-based AT-URI of the
app.bsky.graph.list. The AppView applies it only while the list’s purpose is
app.bsky.graph.defs#referencelist. native.deleteReferenceListOptOut({ account, uri })
withdraws it.
Publish a custom feed
A custom feed has two halves: a feed generator service you run, and an app.bsky.feed.generator
record that announces it.
native.putFeedGenerator({ account, rkey, did, displayName, description, avatar, acceptsInteractions, contentMode }) creates or replaces the record. rkey becomes the last
segment of the feed’s AT-URI, did is the service DID (usually a did:web), displayName is 1
to 24 graphemes and description up to 300 (links and hashtags in it become
descriptionFacets). acceptsInteractions: true tells clients the generator accepts
app.bsky.feed.sendInteractions feedback, and contentMode set to
app.bsky.feed.defs#contentModeVideo marks a video feed. native.deleteFeedGenerator({ account, uri }) removes it.
The SDK runs no server, but it builds the two XRPC responses a generator serves:
blueskyFeedGeneratorDescription({ did, feeds, links })returns the body forGET /xrpc/app.bsky.feed.describeFeedGenerator.parseBlueskyFeedSkeletonRequest(url)validates aGET /xrpc/app.bsky.feed.getFeedSkeletonrequest (feed,limit1 to 100 with a default of 50,cursor), andblueskyFeedSkeleton({ feed, cursor, reqId })builds its response. Each item has apostAT-URI, an optionalreason("pin"or{ repost }), and an optionalfeedContextof up to 2,000 characters that clients echo back withsendInteractions.
blueskyFeedGeneratorMethod(url) tells you which of the two methods a request path names
(app.bsky.feed.describeFeedGenerator or app.bsky.feed.getFeedSkeleton), or undefined for
any other path, so one route can serve both. The parser does not verify the requesting user’s
service-auth JWT; verify it yourself before you personalize a feed.
import {
blueskyFeedGeneratorDescription,
blueskyFeedSkeleton,
parseBlueskyFeedSkeletonRequest,
} from "@opencoredev/social-sdk/bluesky";
// GET /xrpc/app.bsky.feed.getFeedSkeleton
const { feed, limit, cursor } = parseBlueskyFeedSkeletonRequest(new URL(request.url));
const posts = await myIndex.page(feed, limit, cursor);
return Response.json(blueskyFeedSkeleton({ feed: posts.map((post) => ({ post })) }));
Label stream
Labelers publish labels in real time over com.atproto.label.subscribeLabels, a WebSocket of
binary DAG-CBOR frames. native.queryLabels polls; the stream pushes. The SDK opens no socket:
blueskyLabelStreamUrl({ service, cursor })builds thewss://URL from the labeler’s service endpoint (from its DID document). Pass the lastseqyou processed ascursorto replay what you missed.parseBlueskyLabelFrame(frame)decodes one binary frame into{ kind: "labels", seq, labels },{ kind: "info", name, message }(for exampleOutdatedCursor), or{ kind: "error", error, message }(for exampleFutureCursor). Unknown message types returnundefined. Each label hassrc(the labeler DID),uri, optionalcid,val,neg(true when it removes an earlier label),cts, optionalexp, and the rawsigbytes. The SDK does not verify label signatures; checksigagainst the labeler’s#atproto_labelkey before you trust a label from an untrusted relay.
import { blueskyLabelStreamUrl, parseBlueskyLabelFrame } from "@opencoredev/social-sdk/bluesky";
const socket = new WebSocket(blueskyLabelStreamUrl({ service: labelerUrl, cursor: lastSeq }));
socket.binaryType = "arraybuffer";
socket.onmessage = (message) => {
const event = parseBlueskyLabelFrame(new Uint8Array(message.data));
if (event?.kind === "labels") lastSeq = event.seq;
};
Labeler declaration
A labeler announces itself with an app.bsky.labeler.service record (record key self).
native.putLabelerService({ account, policies, reasonTypes, subjectTypes, subjectCollections })
creates or replaces it. policies.labelValues lists every label the labeler may emit, and
policies.labelValueDefinitions defines custom labels: an identifier of lowercase letters and
hyphens, severity (inform, alert, or none), blurs (content, media, or none), an
optional defaultSetting (ignore, warn, or hide) and adultOnly, and at least one locale
with a name (up to 64 graphemes) and description. Omitting reasonTypes or
subjectCollections accepts every report reason or record type. The record only declares the
labeler; you still run the labeling service itself.
Accounts, app passwords, and migration
These wrap the PDS account procedures. Most need a full-scope session: an app-password session cannot manage app passwords, email, or account status.
App passwords:
native.createAppPassword({ account, name, privileged })callscom.atproto.server.createAppPasswordand returns{ name, password, createdAt }. The password is shown only once, so store it as a secret.privileged: truealso allows chat.native.listAppPasswords({ account })returns names and creation dates, never passwords, andnative.revokeAppPassword({ account, name })(revokeAppPassword) ends every session made with it.
Handle and identity:
native.updateHandle({ account, handle })callscom.atproto.identity.updateHandle. The new handle must already resolve to the account’s DID (DNS TXT record or/.well-known/atproto-did) unless it is under one of the PDS’s own domains.native.refreshIdentity({ account, identifier })asks the service to re-resolve a handle or DID right away after you change DNS or the handle.
Account status and email:
native.deactivateAccount({ account, deleteAfter })callscom.atproto.server.deactivateAccount. The account’s content stops being served untilnative.activateAccount(activateAccount);deleteAfterasks the PDS to delete it after that time.- Deletion is two steps:
native.requestAccountDelete({ account })emails a token, thendeleteBlueskyAccount({ service, did, password, token })callscom.atproto.server.deleteAccount. It cannot be undone. native.requestEmailConfirmationandnative.confirmEmail({ account, email, token })confirm the address.native.requestEmailUpdate({ account })returnstokenRequired, andnative.updateEmail({ account, email, token, emailAuthFactor })sets a new address and can require an emailed sign-in code.requestBlueskyPasswordReset({ service, email })andresetBlueskyPassword({ service, token, password })reset a forgotten password without a session.
Account creation and account migration between PDS hosts:
describeBlueskyServer({ service })readscom.atproto.server.describeServer: the server’s DID,availableUserDomains, and whether an invite code or phone verification is required.createBlueskyAccount({ service, handle, email, password, inviteCode, ... })callscom.atproto.server.createAccountand returns a session.- To move an existing account to another PDS, run these steps in order. On the old PDS, mint a
token with
native.getServiceAuth({ account, aud: newPdsDid, lxm: "com.atproto.server.createAccount" }), then callcreateBlueskyAccounton the new PDS with the samedidand that token asserviceAuthToken; the new account starts deactivated. Download the repository CAR from the old PDS withcom.atproto.sync.getRepo(a public streaming download; see Not supported), import it withnative.importRepo({ account, car })on the new PDS (com.atproto.repo.importRepo; for a large repository raise both the adapter’stimeoutMsand the context’sretryBudget.maxElapsedMs, since the shorter one stops the request and the adapter default is 30 seconds), and copy each blob thatnative.listMissingBlobsreports withuploadBlob. For adid:plc, readnative.getRecommendedDidCredentialson the new PDS, callnative.requestPlcOperationSignatureandnative.signPlcOperationon the old one with the emailed token, andnative.submitPlcOperationon the new one. Finish withactivateAccounton the new PDS anddeactivateAccounton the old one.
Contact import
Bluesky can match the account’s address book against other users who opted in, following its contact import RFC. The flow is privacy sensitive and opt-in, and Bluesky treats the endpoints as beta.
native.startPhoneVerification({ account, phone })texts a code to the user’s own number (E.164, such as+12125550123).native.verifyPhone({ account, phone, code })returns a short-livedtoken.native.importContacts({ account, token, contacts })callsapp.bsky.contact.importContactswith up to 1,000 E.164 numbers and returns each matched profile with thecontactIndexof the number it matched.
native.getContactMatches pages matches later, native.dismissContactMatch({ account, subject })
hides one, native.getContactSyncStatus returns when contacts were last synced and how many
matched, and native.removeContactData deletes the imported contacts and phone verification.
Phone numbers are personal data: get the user’s consent, send only what they chose to share, and
never log them.
Raw requests
native.request(input, context) sends one XRPC request the SDK does not wrap and checks input.account against the configured DID and context.backendInstance. On pds it uses the app-password accessJwt, or the OAuth session’s DPoP transport when session is configured, and adds atproto-proxy: did:web:api.bsky.chat#bsky_chat to chat.bsky.* methods unless you set that header. AppView methods such as app.bsky.* reach the AppView through the PDS. On video it mints a service token for the method named in the path, with audience did:web:<video host>; upload videos with native.uploadVideo, which needs the PDS audience.
Hosts: pds (the default) is the configured auth.service origin; video is videoService, by default https://video.bsky.app, and takes only /xrpc/<nsid> paths.
// `native` is social.native("default", { acknowledgeUnsafe: true }); `account` is the
// connected account and `context` an AdapterOperationContext for its backend instance.
const response = await native.request(
{ account, method: "GET", path: "/xrpc/app.bsky.actor.getPreferences" },
context,
);
if (response.responseType === "json") console.log(response.status, response.body);
path is relative to the host’s base URL and starts with /; put parameters in query. Before any request, the adapter rejects absolute URLs, // paths, .. segments, encoded separators, ? or # in the path, and unknown host names. It also rejects credential and framing headers such as Authorization, Cookie and Content-Type. It does not follow redirects. A body has a kind of json, text, bytes, form or urlencoded, and its kind sets Content-Type; text and bytes also take a contentType. Set responseType to "text" or "bytes" for a body that is not JSON. Response bodies are capped at 2 MiB, and maxResponseBytes raises the cap to at most 16 MiB. The result has status, responseType, body and lowercase headers. Name extra headers in responseHeaders; set-cookie is never returned. A non-2xx response rejects with a SocialError mapped like the adapter’s other calls.
The raw layer adds no retries, but a configured OAuth session may replay an explicit DPoP nonce challenge once, as described above, or refresh its own credentials, and the official client’s session can do both. It adds no idempotency key and records no outcome, so you reconcile the writes you send. A write rejects with ambiguous_outcome when it fails without a definitive answer: a timeout, a cancellation after dispatch, a 5xx, or a 2xx body that cannot be decoded. Like other native calls it skips the client’s tenant authorization, so check the tenant’s grant for the account first. Prefer a typed method when one exists.
Limits and requirements
- The adapter returns the record URI/CID as the platform reference when creation succeeds. Network failures after submission are ambiguous; reconcile before retrying.
- A write counts as done only when the answer is the one its lexicon documents. A created or replaced record must come back with HTTP 200, a CID, and the AT-URI of the repository, collection, and record key that was written; a delete needs HTTP 200 and a JSON object; a procedure with no output needs HTTP 200; chat writes need the conversation or message view they changed. Any other answer after the request was sent is
ambiguous_outcomewithreconcile-first. - The built-in OAuth transport does not refresh tokens on its own. Refresh with
refreshBlueskyOAuthSession, or let the official client’s session handle it if you use that client. - If a token exchange or refresh returns a usable token but the response then fails validation (missing
DPoP-Nonce, wrongtoken_type, missingatprotoscope, badsub) or identity verification fails, the helper tries once to revoke the new tokens at the authorization server’srevocation_endpoint, when the server advertises one. It revokes the refresh token, or the access token if no refresh token was issued. This is a single best-effort request with no retry. A failed revocation is ignored, and the original error is thrown. If atomic session persistence rejects, the helper also attempts one revocation of the unsaved grant and rethrows the original storage failure, even if revocation fails. Disconnecting an account does not invoke this helper. - Handle, DID, PDS, and authorization server URLs come from documents other parties control, so every request passes an egress check first. Only HTTPS is allowed.
localhostnames, URLs with credentials, and IP-literal hosts in loopback, private, link-local, carrier-grade NAT, multicast, documentation, and other reserved IPv4 and IPv6 ranges are rejected withunauthorized, including IPv4-mapped IPv6 addresses.blueskyOAuthTransportapplies the same check to the storedpdsUrl. - The built-in literal check cannot resolve DNS. Every OAuth helper therefore requires both
assertEgressAllowedand an injected address-pinnedfetch. The hook runs before every request and redirect hop; it must reject private resolutions. A custom fetch alone or a policy hook paired with ordinary fetch is not sufficient DNS-rebinding protection. - Handle resolution through
https://<handle>/.well-known/atproto-didfollows up to three HTTP redirects, as the handle specification allows. Each hop is followed manually and checked again. OAuth metadata requests do not follow redirects. - Pushed authorization, token, and refresh responses must carry a
DPoP-Nonceheader, as the AT Protocol OAuth specification requires; a response without one fails withupstream_failure. A PDS response without a nonce also fails. The transport setshealthtoprotocol-errorand blocks further use. A write producesambiguous_outcomewithreconcile-first; the originalResponseis available as the error’s non-serializedcause. Inspect it privately for reconciliation, never log its body or headers, and do not resubmit blindly. blueskyOAuthClientMetadatapublishes only EC P-256 public keys. It rejects any key with private members (d,p,q,dp,dq,qi,oth,k), any other key type or curve, and anyalgother thanES256oruseother thansig. Unknown members are dropped from the published key.- Public clients get sessions of about two weeks and must reconnect after that. Confidential clients can refresh for longer, as set by the authorization server.
- OAuth account connectivity uses authenticated
com.atproto.server.checkAccountStatus; public profile display does not establish credential validity. A 403 ismissing_permission, while a 401 requires reconnecting. App-password policy and application account membership remain application responsibilities. - These are fixture-tested protocol operations, including the OAuth flow against mocked servers. No live account verification is recorded in this repository.
Media specifications
posts.publish checks the media count, image MIME types and Blob sizes, and the width and height fields before it uploads anything. media.upload checks a video’s type and size. The PDS checks blob types and sizes against the lexicon when a record references the blob, and the video service checks the video itself.
| Media | Requirement | Bluesky limit | Checked by the SDK before sending |
|---|---|---|---|
| Post | Embeds | One embed per post: images, a gallery, one video, or a link card | Yes: a video with other media fails as media.mixed, and documents fail as media.unsupported_kind. |
| Image | Count | Up to 4 in app.bsky.embed.images |
Yes: one to four images use this embed. |
| Gallery | Items | app.bsky.embed.gallery allows 20; the lexicon asks clients to stop at 10. Every item needs alt text and an aspect ratio |
Yes: more than ten images fail as media.too_many, and five to ten need width and height on every image, else media.aspect_ratio_required. |
| Image | Format | image/* |
Declared MIME type must be image/*, else media.mime_type. Remote HTTPS images are refused unless allowMediaHost allows the host. |
| Image | File size | 2,000,000 bytes maximum, formerly 1,000,000 | Yes: a Blob over 2,000,000 bytes fails as media.too_large. Streams and allowed remote images stop reading at the same limit. |
| Image | Aspect ratio | Optional on app.bsky.embed.images; required in a gallery |
width and height must be supplied together as positive integers, else media.aspect_ratio. |
| Video | Format | video/mp4 |
media.upload requires one video/mp4 Blob, else invalid_input. |
| Video | File size | 300,000,000 bytes maximum, formerly 100 MB | Yes: an empty file or one over 300,000,000 bytes fails as invalid_input. |
| Video | Duration | The lexicon sets none. The video service applies daily account limits and needs a verified email on Bluesky-hosted accounts | No. Read the limits with native.getVideoUploadLimits. |
| Video | Captions | Up to 20 WebVTT (text/vtt) tracks, 20,000 bytes each |
Yes: options.videoCaptions rejects more than 20 tracks or a blob reference that is not text/vtt of at most 20,000 bytes. |
| Link card | Thumbnail | image/*, 1,000,000 bytes maximum |
Yes: options.linkThumbnail must be an image/* blob reference of at most 1,000,000 bytes. |
Sources: the app.bsky.embed.images, app.bsky.embed.gallery, app.bsky.embed.video, and app.bsky.embed.external lexicons in the AT Protocol repository, and Bluesky’s Uploading Video guide for account limits, checked 2026-11-04. Bluesky’s Posts in-depth guide still says 1,000,000 bytes per image in its prose; its sample code and the lexicon say 2,000,000.
Not supported
- Push notifications:
app.bsky.notification.registerPushandunregisterPushregister a device token with Bluesky’s push service for a registered clientappId. Push delivery runs through that service for Bluesky’s own apps, so a third-party server cannot use it; pollnotifications.listorgetUnreadNotificationCountinstead. - Scheduling: AT Protocol has no scheduled publishing. A record is visible as soon as it is written, and a future
createdAtdoes not delay it, soposts.scheduleisunsupported-by-platform. - Post edits: published posts cannot be edited, so
posts.updateis declaredunsupported-by-platform. - Webhooks: Bluesky has no signed webhooks, so
webhooks.verifyisunsupported-by-platformand the adapter has nowebhooksmember. Usesocial.notifications.list(account)andsocial.notifications.markSeen(account)for the mention and reply inbox. com.atproto.sync.getRepo: the public, streaming repository download used during account migration is not wrapped.app.bsky.contact.sendNotification: a system endpoint that needs role authentication, so the SDK does not wrap it.- Starter pack edits: the Bluesky app can rename a starter pack and change its description or feeds, which replaces the
app.bsky.graph.starterpackrecord. The SDK creates and deletes starter packs but has no update method. To edit one, read the record withcom.atproto.repo.getRecordand sendcom.atproto.repo.putRecordwith the read CID asswapRecordthroughnative.request. (Lexicons checked 2026-11-05.) chat.bsky.moderation.updateActorAccess: it changes another account’s chat access and is for the chat service’s moderation tooling, not for account holders, so the SDK does not wrap it.