| name | upgrade-from-atproto-api |
| description | Migrate code from @atproto/api to @bsky/sdk + @atproto/lex — import mappings, agent-to-client patterns, string format types |
Migrating from @atproto/api to @bsky/sdk + @atproto/lex
The @atproto/api package has been superseded by two packages:
@bsky/sdk — Bluesky-specific actions, moderation helpers, rich text, and the api constants object.
@atproto/lex — the generic AT Protocol client, lexicon utilities, and schema types. A peer dependency of the sdk; install both. (@atproto/lex-password-session is separate, only needed for password-based sessions.)
npm install @bsky/sdk @atproto/lex
Import Client and other lex utilities from @atproto/lex — not from its underlying sub-packages (@atproto/lex-client, @atproto/lex-schema), which shouldn't be depended upon directly.
1. Client Setup
There are two common client patterns.
import { Client } from '@atproto/lex'
import { PasswordSession } from '@atproto/lex-password-session'
import { api } from '@bsky/sdk'
import { app, com } from '@bsky/sdk/lexicons'
const session = await PasswordSession.login({
service: 'https://bsky.social',
identifier: 'alice.bsky.social',
password: 'xxxx-xxxx-xxxx-xxxx',
onUpdated: (data) => saveToStorage(data),
onDeleted: (data) => clearStorage(data.did),
})
const bskyClient = new Client(session, {
service: api.app.service,
})
const publicBskyClient = new Client(api.app.urlPublic)
To target a different service for a single request (e.g. the account host
itself, or a labeler), pass service in the per-request options rather than
constructing another client — { service: null } reaches the account host
directly:
const records = await bskyClient.call(
com.atproto.repo.listRecords,
{ repo: bskyClient.assertDid, collection: 'app.bsky.feed.post' },
{ service: null },
)
Deployments that already keep separate clients for the account host vs the
Bluesky API can continue to do so — construct each client from the same
session:
const accountClient = new Client(session)
const bskyClient = new Client(session, { service: api.app.service })
api constants reference:
| Constant | Value |
|---|
api.app.did | 'did:web:api.bsky.app' |
api.app.service | 'did:web:api.bsky.app#bsky_appview' |
api.app.url | 'https://api.bsky.app' |
api.app.urlPublic | 'https://public.api.bsky.app' |
api.chat.did | 'did:web:api.bsky.chat' |
api.chat.service | 'did:web:api.bsky.chat#bsky_chat' |
api.chat.url | 'https://api.bsky.chat' |
api.moderation.did | 'did:plc:ar7c4by46qjdydhdevvrndac' |
api.moderation.service | 'did:plc:ar7c4by46qjdydhdevvrndac#atproto_labeler' |
Labeler caveat: When no labeler headers are sent, the Bluesky API applies its own moderation service by default — so if you don't introduce other labelers, no labeler configuration is needed at all. The atproto-accept-labelers header works by replacement, not addition: sending any labelers header replaces that server-side default. So when you introduce other labelers, keep the Bluesky labeler active by setting it as an app labeler — app labelers are always included in the header with the ;redact parameter (allowing takedowns), which a plain labelers entry does not set:
import { Client } from '@atproto/lex'
import { api } from '@bsky/sdk'
const client = new Client(session, {
service: api.app.service,
appLabelers: [api.moderation.did],
labelers: ['did:plc:mycustomlabeler...'],
})
(Client.configure({ appLabelers }) sets the same thing globally for all client instances; prefer the per-client option.) Defaults cascade app → client → request: a per-request labelers array replaces the client's, labelers: null disables the header for that request, and appLabelers: null opts a client or request out of the app labelers.
2. Import Mappings
Client / Session classes
Old (@atproto/api) | New | Package |
|---|
AtpAgent | Client | @atproto/lex |
Agent | Client | @atproto/lex |
BskyAgent | Client | @atproto/lex |
AtpBaseClient | Client | @atproto/lex |
CredentialSession | PasswordSession | @atproto/lex-password-session |
Sessions
Old (@atproto/api) | New | Package | Notes |
|---|
AtpSessionData | SessionData | @atproto/lex-password-session | Shape for persisted session data; renamed for clarity |
AtpSessionEvent | removed | — | Session event types now inferred from PasswordSession callbacks |
AtpPersistSessionHandler | removed | — | Use onUpdated/onDeleted callbacks on PasswordSession instead |
AtpAgentLoginOpts | removed | — | Merged into PasswordSession.login() parameters |
AtpAgentGlobalOpts | removed | — | Replaced by Client constructor options |
Preferences Types
Old (@atproto/api) | New | Package | Notes |
|---|
BskyPreferences | BskyPreferences | @bsky/sdk | Still available, now exported from SDK root (via types.ts) |
BskyFeedViewPreference | BskyFeedViewPreference | @bsky/sdk | Still available, now exported from SDK root (via types.ts) |
BskyThreadViewPreference | BskyThreadViewPreference | @bsky/sdk | Still available, now exported from SDK root (via types.ts) |
BskyInterestsPreference | BskyInterestsPreference | @bsky/sdk | Still available, now exported from SDK root (via types.ts) |
Errors
Old (@atproto/api) | New | Package |
|---|
XRPCError | XrpcResponseError / XrpcFailure | @atproto/lex |
Utility / codec functions
Old (@atproto/api) | New | Package |
|---|
jsonStringToLex | lexParse | @atproto/lex |
jsonToLex | jsonToLex | @atproto/lex |
lexToJson | lexToJson | @atproto/lex |
stringifyLex | lexStringify | @atproto/lex |
BlobRef | BlobRef | @atproto/lex |
asPredicate | schema.$matches (e.g. app.bsky.feed.post.$matches) | @bsky/sdk/lexicons |
parseLanguage | removed — use isLanguageString | @atproto/lex |
Type utilities
Old (@atproto/api) | New | Package |
|---|
$Typed<T> | $Typed<V, T> | @atproto/lex-schema (via @atproto/lex) |
Un$Typed<T> | Un$Typed<V> | @atproto/lex-schema (via @atproto/lex) |
Constants
Old (@atproto/api) | New | Package |
|---|
BSKY_LABELER_DID | api.moderation.did | @bsky/sdk |
DEFAULT_LABEL_SETTINGS | DEFAULT_LABEL_SETTINGS | @bsky/sdk |
Moderation
Old (@atproto/api) | New | Package |
|---|
moderatePost | moderatePost | @bsky/sdk/moderation |
moderateProfile | moderateProfile | @bsky/sdk/moderation |
moderateUserList | moderateUserList | @bsky/sdk/moderation |
moderateFeedGenerator | moderateFeedGenerator | @bsky/sdk/moderation |
moderateNotification | moderateNotification | @bsky/sdk/moderation |
moderateStatus | moderateStatus | @bsky/sdk/moderation |
ModerationUI | ModerationUI | @bsky/sdk/moderation |
ModerationDecision | ModerationDecision | @bsky/sdk/moderation |
interpretLabelValueDefinition | interpretLabelValueDefinition | @bsky/sdk/moderation |
interpretLabelValueDefinitions | interpretLabelValueDefinitions | @bsky/sdk/moderation |
hasMutedWord | hasMutedWord | @bsky/sdk/moderation |
matchMuteWords | matchMuteWords | @bsky/sdk/moderation |
LABELS | LABELS | @bsky/sdk/moderation |
Rich text
Old (@atproto/api) | New | Package |
|---|
RichText | RichText | @bsky/sdk/richtext |
RichText.detectFacets(agent) | rt.detectFacets(resolver) — see note | @bsky/sdk/richtext |
sanitizeRichText | sanitizeRichText | @bsky/sdk/richtext |
UnicodeString | UnicodeString | @bsky/sdk/richtext |
MENTION_REGEX, URL_REGEX, etc. | same names | @bsky/sdk/richtext |
detectFacets resolver change: The method now takes either a Client or a HandleResolver from @atproto-labs/handle-resolver instead of an agent. Passing a Client resolves handles via com.atproto.identity.resolveHandle:
const rt = new RichText({ text: 'Hello @alice.bsky.social!' })
await rt.detectFacets(bskyClient)
await rt.detectFacets(myHandleResolver)
const rt2 = await RichText.resolve('Hello @alice.bsky.social!', {
resolver: bskyClient,
})
The Client-backed resolver is also exported directly for standalone use:
import { ClientHandleResolver } from '@bsky/sdk/utils'
const resolver = new ClientHandleResolver(bskyClient)
const did = await resolver.resolve('alice.bsky.social')
Utils
Old (@atproto/api) | New | Package |
|---|
sanitizeMutedWordValue | sanitizeMutedWordValue | @bsky/sdk/utils |
validateNux | validateNux | @bsky/sdk/utils |
nuxSchema | nuxSchema | @bsky/sdk/utils |
savedFeedsToUriArrays | removed — compute from savedFeeds directly | — |
Removed without direct replacement
Old (@atproto/api) | Notes |
|---|
mock / BskyAgent.mockResolveHandles | removed; test with a fake HandleResolver function |
isDid / asDid / assertDid | use isStringFormat(v, 'did') / asStringFormat(v, 'did') from @atproto/lex |
AtUri | AtUri from @atproto/syntax (still a direct dependency) |
lexicons export (Lexicons instance) | use generated schema objects from @bsky/sdk/lexicons |
getSavedFeedType | internal only; moved to preference actions |
validateSavedFeed | internal only; moved to preference actions |
3. Agent Methods → Actions / Client Calls
The Agent/AtpAgent/BskyAgent class has been replaced by:
Client from @atproto/lex for raw XRPC calls.
- Action functions from
@bsky/sdk for higher-level operations (preferences, records, graph, notifications).
Record / graph sugar methods
These agent methods now correspond to named action functions called via client.call(action, input):
| Old agent method | New action | Import |
|---|
agent.post(input) | client.call(post, input) | @bsky/sdk |
agent.deletePost(uri) | client.call(deletePost, uri) | @bsky/sdk |
agent.like(uri, cid) | client.call(like, { uri, cid }) | @bsky/sdk |
agent.deleteLike(uri) | client.call(deleteLike, uri) | @bsky/sdk |
agent.repost(uri, cid) | client.call(repost, { uri, cid }) | @bsky/sdk |
agent.deleteRepost(uri) | client.call(deleteRepost, uri) | @bsky/sdk |
agent.follow(did) | client.call(follow, { did }) | @bsky/sdk |
agent.deleteFollow(uri) | client.call(deleteFollow, uri) | @bsky/sdk |
agent.upsertProfile(fn) | client.call(upsertProfile, fn) | @bsky/sdk |
agent.mute(actor, opts?) | client.call(muteActor, { actor, ...opts }) | @bsky/sdk |
agent.unmute(actor) | client.call(unmuteActor, { actor }) | @bsky/sdk |
agent.muteModList(uri) | client.call(muteActorList, { list: uri }) | @bsky/sdk |
agent.unmuteModList(uri) | client.call(unmuteActorList, { list: uri }) | @bsky/sdk |
Branded input types: Unlike the old agent methods, action inputs use the lex string format types — URIs are AtUriString, DIDs are DidString, etc. Values read from API responses already carry these types; for plain strings from your own storage, validate at the boundary with asStringFormat(v, 'at-uri') / asStringFormat(v, 'did') (see § 5).
Preferences methods
Preference reads and writes target the user's account host by default (like the client's own record helpers), regardless of the client's service setting — so they work unchanged on a client configured to proxy to the Bluesky API. getPreferences and updatePreferences accept an optional service in their input to override:
await client.call(getPreferences)
await client.call(getPreferences, { service: 'did:web:example.com#svc' })
await client.call(updatePreferences, (prefs) => prefs)
await client.call(updatePreferences, {
update: (prefs) => prefs,
service: 'did:web:example.com#svc',
})
| Old agent method | New action |
|---|
agent.getPreferences() | client.call(getPreferences) |
agent.setAdultContentEnabled(v) | client.call(setAdultContentEnabled, v) |
agent.setContentLabelPref(key, val, labelerDid?) | client.call(setContentLabelPref, { key, value: val, labelerDid }) |
agent.addSavedFeeds(feeds) | client.call(addSavedFeeds, feeds) |
agent.removeSavedFeeds(ids) | client.call(removeSavedFeeds, ids) |
agent.updateSavedFeeds(feeds) | client.call(updateSavedFeeds, feeds) |
agent.overwriteSavedFeeds(feeds) | client.call(overwriteSavedFeeds, feeds) |
agent.setFeedViewPrefs(feed, prefs) | client.call(setFeedViewPrefs, { feed, ...prefs }) |
agent.setThreadViewPrefs(prefs) | client.call(setThreadViewPrefs, prefs) |
agent.setPersonalDetails({ birthDate }) | client.call(setPersonalDetails, { birthDate }) |
agent.setInterestsPref({ tags }) | client.call(setInterestsPref, { tags }) |
agent.addMutedWord(word) | client.call(addMutedWord, word) |
agent.addMutedWords(words) | client.call(addMutedWords, words) |
agent.upsertMutedWords(words) | client.call(upsertMutedWords, words) (deprecated) |
agent.updateMutedWord(word) | client.call(updateMutedWord, word) |
agent.removeMutedWord(word) | client.call(removeMutedWord, word) |
|
Renamed app-state preferences methods (dropped bskyApp prefix)
| Old agent method | New action |
|---|
agent.bskyAppQueueNudges(nudges) | client.call(queueNudges, nudges) |
agent.bskyAppDismissNudges(nudges) | client.call(dismissNudges, nudges) |
agent.bskyAppSetActiveProgressGuide(guide) | client.call(setActiveProgressGuide, guide) |
agent.bskyAppUpsertNux(nux) | client.call(upsertNux, nux) |
agent.bskyAppRemoveNuxs(ids) | client.call(removeNuxs, ids) |
Renamed notification method
| Old agent method | New |
|---|
agent.countUnreadNotifications(params) | client.call(app.bsky.notification.getUnreadCount, params) |
Bluesky API passthrough methods (17 total)
These agent shortcuts now map directly to client.call(schema, params) with the schema imported from @bsky/sdk/lexicons:
import { app, com } from '@bsky/sdk/lexicons'
await client.call(app.bsky.feed.getTimeline, { limit: 50 })
await client.call(app.bsky.feed.getAuthorFeed, { actor: 'alice.bsky.social' })
await client.call(app.bsky.feed.getActorLikes, { actor: 'alice.bsky.social' })
await client.call(app.bsky.feed.getPostThread, { uri: 'at://...' })
await client.call(app.bsky.feed.getPosts, { uris: ['at://...'] })
await client.call(app.bsky.feed.getLikes, { uri: 'at://...' })
await client.call(app.bsky.feed.getRepostedBy, { uri: 'at://...' })
client.(app..., { : })
client.(app..., { : [] })
client.(app..., {})
client.(app..., { : })
client.(app..., { : })
client.(app..., { : })
client.(app..., { : })
client.(app..., {})
client.(app..., {})
client.(app..., { : [] })
getPost recipe
The old agent.getPost({ repo, rkey }) sugar is replaced by client.get() using the generated schema:
import { app, com } from '@bsky/sdk/lexicons'
import { AtUri } from '@atproto/syntax'
const post = await client.get(app.bsky.feed.post, {
repo: 'alice.bsky.social',
rkey: '3k2...',
})
const uri = new AtUri('at://alice.bsky.social/app.bsky.feed.post/3k2...')
const post2 = await client.get(app.bsky.feed.post, {
repo: uri.hostname,
rkey: uri.rkey,
})
4. Sessions
Password-based sessions (replacing CredentialSession)
import { Client } from '@atproto/lex'
import {
PasswordSession,
type SessionData,
} from '@atproto/lex-password-session'
const session = await PasswordSession.login({
service: 'https://bsky.social',
identifier: 'alice.bsky.social',
password: 'xxxx-xxxx-xxxx-xxxx',
onUpdated: (data: SessionData) => {
localStorage.setItem('session', JSON.stringify(data))
},
onDeleted: (data: SessionData) => {
localStorage.removeItem('session')
redirectToLogin()
},
})
const client = new Client(session)
const saved: SessionData = JSON.(.()!)
resumed = .(saved, {
: .(, .(data)),
: .(),
})
resumedClient = (resumed)
session.()
Key differences from CredentialSession:
- No constructor; use static
PasswordSession.login() or PasswordSession.resume().
onUpdated and onDeleted hooks replace manual session.on('update', ...) listeners.
- Token refresh is fully automatic; no need to call
refreshSession().
OAuth
For user-facing apps, use OAuth via @atproto/oauth-client. The resulting session object can be passed directly to new Client(oauthSession). See @atproto/oauth-client docs for setup.
5. String Format Types
AT Protocol uses branded string types (type aliases with a narrow shape) for values that must meet format requirements — DIDs, AT-URIs, handles, datetimes, and more. These are now available from @atproto/lex (which re-exports them from @atproto/syntax and @atproto/lex-schema).
Available types and helpers
import {
type DidString,
type HandleString,
type AtUriString,
type DatetimeString,
type NsidString,
type RecordKeyString,
type AtIdentifierString,
asDatetimeString,
assertDatetimeString,
isDatetimeString,
ifDatetimeString,
asAtUriString,
assertAtUriString,
isAtUriString,
ifAtUriString,
asAtIdentifierString,
assertAtIdentifierString,
isAtIdentifierString,
ifAtIdentifierString,
isStringFormat,
asStringFormat,
assertStringFormat,
ifStringFormat,
} from '@atproto/lex'
Reading vs constructing
Reading (ingress validation at trust boundaries): When you receive data from the network or user input, validate and narrow the type explicitly:
import { asStringFormat, type DidString } from '@atproto/lex'
function processIncoming(rawDid: string): DidString {
return asStringFormat(rawDid, 'did')
}
Constructing (from known-valid sources): When you construct values from your own code or generated data, you can cast directly — generated lexicon builders (schema.$build(...)) already produce correctly-typed values, so no runtime validation is needed.
import { app, com } from '@bsky/sdk/lexicons'
const post = app.bsky.feed.post.$build({
text: 'Hello!',
createdAt: new Date().toISOString(),
})
Per-boundary idiom: Validate at the first point you receive external data. After that, pass the typed values through your app without re-validating:
import { asStringFormat, asAtUriString } from '@atproto/lex'
function handleRepostRequest(rawActor: string, rawUri: string) {
const actorDid = asStringFormat(rawActor, 'did')
const postUri = asAtUriString(rawUri)
return doRepost(actorDid, postUri)
}
Note: A planned helper on the lex Client for object-level "upcast" (e.g. asserting an entire record object matches a schema) is not yet in @atproto/lex. Use schema-level $assert() / $matches() / $parse() on generated schema objects until then:
import { app, com } from '@bsky/sdk/lexicons'
const unknown: unknown = fetchedRecord
app.bsky.feed.post.$assert(unknown)
Replacing old isDid / asDid etc.
The types.ts helpers that lived in @atproto/api (isDid, asDid, assertDid, isAtprotoProxy, etc.) are removed. Use the generic string-format helpers or the per-format exports from @atproto/lex / @atproto/syntax:
import { isStringFormat, asStringFormat } from '@atproto/lex'
isStringFormat(value, 'did')
asStringFormat(value, 'did')
6. Worked End-to-End Example
Login, create three clients, post with rich text, read the timeline, and moderate a post for display:
import { Client } from '@atproto/lex'
import {
PasswordSession,
type SessionData,
} from '@atproto/lex-password-session'
import { api, post } from '@bsky/sdk'
import { moderatePost, type ModerationOpts } from '@bsky/sdk/moderation'
import { RichText } from '@bsky/sdk/richtext'
import { app, com } from '@bsky/sdk/lexicons'
const session = await PasswordSession.login({
service: 'https://bsky.social',
identifier: 'alice.bsky.social',
password: 'xxxx-xxxx-xxxx-xxxx',
onUpdated: (data: SessionData) => saveSession(data),
onDeleted: () => clearSession(),
})
const bskyClient = new (session, {
: api..,
})
publicBskyClient = (api..)
rt = ({ : })
rt.(bskyClient)
{ uri, cid } = bskyClient.(post, {
: rt.,
: rt.,
})
.(, uri, cid)
timeline = bskyClient.(app..., {
: ,
})
prefs = bskyClient.(app..., {})
: = {
: session.,
: {
: ,
: {},
: [{ : api.., : {} }],
: [],
: [],
},
}
( feedItem timeline.) {
modDecision = (feedItem., moderationOpts)
ui = modDecision.()
(ui.) {
}
record = feedItem.. app....
.(record.)
}