---
title: Bluesky
description: Connect Bluesky accounts with AT Protocol OAuth, publish records, and use typed AT Protocol operations with explicit reconciliation.
---

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](https://atproto.com/specs/oauth): 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

1. **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.

2. **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.

3. **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.

4. **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.

```ts
import { ConnectionManager, blueskyOAuth } from "@opencoredev/social-sdk/server";

const provider = blueskyOAuth({
  clientId: "https://app.example.com/oauth/client-metadata.json",
  clientKey: { kid: "key-1", privateJwk: signingKey }, // omit for a public client
  redirectUri: "https://app.example.com/oauth/bluesky/callback",
  scope: "atproto transition:generic",
  sessionSink: {
    async save({ account, session, attempt }) {
      // Encrypt and store the session for attempt.tenantId and account.ref.accountId.
    },
  },
});

const started = await manager.begin({
  backend: "direct",
  tenantId,
  principalId,
  platforms: ["bluesky"],
  redirectUri: "https://app.example.com/oauth/bluesky/callback",
  allowedRedirectUris: ["https://app.example.com/oauth/bluesky/callback"],
  provider,
  loginHint: "alice.bsky.social",
});
```

Then build the adapter from the stored session:

```ts
import { createSocial, connectedAccountRef } from "@opencoredev/social-sdk";
import { bluesky } from "@opencoredev/social-sdk/bluesky";
import { blueskyOAuthTransport, parseBlueskyOAuthSession } from "@opencoredev/social-sdk/server";

const session = parseBlueskyOAuthSession(await loadDecryptedSession(accountId));
const transport = blueskyOAuthTransport(session);

const social = createSocial({
  backend: bluesky({
    auth: { service: transport.service, did: transport.did },
    session: transport,
  }),
});

const account = connectedAccountRef({
  backend: "default",
  platform: "bluesky",
  accountId: session.did,
});
```

The transport sends each request to the session's PDS with `Authorization: DPoP` and a fresh proof bound to the access token. When the PDS asks for a new DPoP nonce, it sends the request once more with that nonce. It does not refresh tokens. Call `refreshBlueskyOAuthSession(session, { clientId, clientKey })` before `expiresAt`. Refresh re-resolves the account's DID and fails if its PDS now names a different authorization server. Refresh tokens rotate, so save the result with a compare-and-set write. A reused or expired refresh token returns `reconnect_required`.

An access JWT from an app password still works:

```ts
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

### Publish a text post

The adapter validates grapheme length and creates rich-text URL facets automatically.

```ts
const result = await social.posts.publish({
  targets: [{ account }],
  content: { text: "A Bluesky post with a link https://example.com" },
});
```

### Publish with images

The adapter enforces a 10 MiB image limit and a four-image maximum. HTTPS remote image hosts must pass the configured `allowMediaHost` policy. A stream is bounded while read, but the adapter needs a replayable source for retries.

```ts
const result = await social.posts.publish({
  targets: [{ account }],
  content: {
    text: "Screenshots attached",
    media: [
      { kind: "image", source: { kind: "https-url", url: "https://cdn.example.com/a.png" } },
      { kind: "image", source: { kind: "https-url", url: "https://cdn.example.com/b.png" } },
    ],
  },
});
```

### 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.

```ts
const content = { text: "🙂 @alice.example https://example.com" };
const options = {
  languages: ["en-US"],
  mentions: [{ byteStart: 5, byteEnd: 19, did: "did:plc:alice" }],
};
```

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. Preparation rejects split character boundaries, overlapping mention/link spans, and malformed language tags. The managed adapters reject these options because they have no verified mapping for them.

### 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.

```ts
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. 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('default', { 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 (default 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.

### 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`.

```ts
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);
```

### Search actors, likes, lists, and reports

Use `native.searchActors` or `native.searchActorsTypeahead` 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; list views and list
mutes use `app.bsky.graph.muteActorList` and `unmuteActorList`. 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.

### Receive events

Bluesky has no webhooks, so `webhooks.verify` is `unsupported-by-platform` and the adapter has no `webhooks` member. Subscribe to the relay firehose (`com.atproto.sync.subscribeRepos`) or Jetstream over WebSocket instead. See [Consuming the firehose](https://bsky.network/docs/consuming-the-firehose).

## Native operations

The typed native module covers reposts and quote posts, deletion, graph relationships,
likes, actor search, lists, moderation reports, notification reads and seen state, profile
reads/updates, feed discovery, search, and `chat.bsky` conversation/message operations. Video upload
is not supported yet. Chat responses remain provider-controlled moderation states; persist native
references and reconcile explicitly. Bluesky chat consent and moderation requirements still apply.

`deleteComment({ account, commentId, context })` takes a reply's `at://` URI and calls
[`com.atproto.repo.deleteRecord`](https://docs.bsky.app/docs/api/com-atproto-repo-delete-record)
for the `app.bsky.feed.post` record. It only deletes records in the authenticated account's
repository. A URI from another DID raises `unauthorized` before any request is sent.

## 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.
- Published posts cannot be edited, so `posts.update` is declared `unsupported-by-platform`.
- 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`, wrong `token_type`, missing `atproto` scope, bad `sub`) or identity verification fails, the helper tries once to revoke the new tokens at the authorization server's `revocation_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. The helper does not revoke tokens in any other case, such as when an account disconnects.
- 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. `localhost` names, 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 with `unauthorized`, including IPv4-mapped IPv6 addresses. `blueskyOAuthTransport` applies the same check to the stored `pdsUrl`.
- The egress check does not resolve DNS, so a public hostname that resolves to a private address still passes. On servers that can reach internal networks, pass `assertEgressAllowed` to `blueskyOAuth`, `refreshBlueskyOAuthSession`, and `blueskyOAuthTransport`, and inject a `fetch` that pins resolved addresses. The hook runs before every request and redirect hop; throwing from it rejects the request.
- Handle resolution through `https://<handle>/.well-known/atproto-did` follows 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-Nonce` header, as the AT Protocol OAuth specification requires; a response without one fails with `upstream_failure`. PDS responses without a nonce are returned as they are, because the PDS has already processed the request and rejecting it would hide the outcome of a write.
- `blueskyOAuthClientMetadata` publishes 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 any `alg` other than `ES256` or `use` other than `sig`. 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.
- 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.
