bskyKit#
A Swift SDK for building Bluesky clients on Apple platforms. bskyKit provides a complete, type-safe interface to the Bluesky API with modern Swift concurrency support.
Overview#
bskyKit implements the app.bsky.* lexicons for the AT Protocol, giving you everything needed to build a full-featured Bluesky client:
- Read Operations - Fetch timelines, profiles, posts, threads, notifications, and social graphs
- Write Operations - Create posts, likes, reposts, follows, and blocks
- Rich Text - Automatic detection and creation of mentions, links, and hashtags with proper byte indexing
- Type Safety - Fully typed models for all API responses with
CodableandSendableconformance - SwiftUI Ready - Models conform to
Identifiablefor seamless use in SwiftUI lists
Built on CoreATProtocol for networking, authentication, and token management.
Requirements#
- Swift 6.2+
- iOS 26.0+ / macOS 26.0+ / watchOS 26.0+ / tvOS 26.0+ / Mac Catalyst 26.0+
Installation#
Swift Package Manager#
Add bskyKit to your Package.swift dependencies:
dependencies: [
.package(url: "https://tangled.org/@sparrowtek.com/bskyKit", branch: "main"),
]
Then add it to your target:
.target(
name: "YourApp",
dependencies: ["bskyKit"]
),
Or in Xcode: File > Add Package Dependencies and enter:
https://tangled.org/@sparrowtek.com/bskyKit
Quick Start#
Setup#
Configure the environment before making any API calls:
import bskyKit
import CoreATProtocol
// Configure with your PDS host and authentication tokens
await setup(
hostURL: "https://bsky.social",
accessJWT: "your-access-token",
refreshJWT: "your-refresh-token"
)
Reading Data#
Use BskyService for all read operations:
let service = await BskyService()
// Fetch a profile
let profile = try await service.getProfile(for: "alice.bsky.social")
print("\(profile.displayName ?? profile.handle) has \(profile.followersCount ?? 0) followers")
// Get your timeline
let timeline = try await service.getTimeline(limit: 50)
for item in timeline.feed {
print("\(item.post.author.handle): \(item.post.record.text)")
}
// Search for users
let results = try await service.searchActors(query: "swift developer", limit: 10)
Creating Content#
Use RepoService for write operations:
let repo = await RepoService()
let myDID = "did:plc:your-did-here"
// Create a simple post
let post = PostRecord.create(text: "Hello from bskyKit!")
let result = try await repo.createPost(post, repo: myDID)
// Create a post with rich text (auto-detected)
let richPost = PostRecord.create(
text: "Hey @alice.bsky.social check out https://example.com #swift"
)
// Mentions, links, and hashtags are automatically detected!
try await repo.createPost(richPost, repo: myDID)
// Like a post
try await repo.like(uri: postURI, cid: postCID, repo: myDID)
// Follow someone
try await repo.follow(did: "did:plc:someone", repo: myDID)
API Reference#
BskyService (Read Operations)#
Actor Operations#
| Method | Description |
|---|---|
getProfile(for:) |
Fetch a user profile by handle or DID |
getProfiles(for:) |
Fetch multiple profiles in one request |
getPreferences() |
Get authenticated user's preferences |
searchActors(query:limit:) |
Search users by name/handle/bio |
searchActorsTypeahead(query:limit:) |
Fast search for autocomplete |
Feed Operations#
| Method | Description |
|---|---|
getTimeline(limit:cursor:) |
Get home timeline |
getAuthorFeed(for:limit:cursor:) |
Get a user's posts |
getPostThread(uri:depth:) |
Get post with replies |
getPosts(uris:) |
Fetch multiple posts by URI |
getFeedGenerators(for:) |
Get custom feed info |
getLikes(uri:limit:cursor:) |
Get users who liked a post |
getRepostedBy(uri:limit:cursor:) |
Get users who reposted |
Graph Operations#
| Method | Description |
|---|---|
getFollows(for:limit:cursor:) |
Get who a user follows |
getFollowers(for:limit:cursor:) |
Get a user's followers |
getBlocks(limit:cursor:) |
Get your blocked accounts |
getMutes(limit:cursor:) |
Get your muted accounts |
Notification Operations#
| Method | Description |
|---|---|
listNotifications(limit:cursor:) |
Get notifications |
getUnreadCount() |
Get unread notification count |
updateSeen(at:) |
Mark notifications as read |
RepoService (Write Operations)#
High-Level Methods#
// Posts
createPost(_ post: PostRecord, repo: String) -> CreateRecordResponse
// Interactions
like(uri:cid:repo:) -> CreateRecordResponse
unlike(uri:repo:)
repost(uri:cid:repo:) -> CreateRecordResponse
unrepost(uri:repo:)
// Social Graph
follow(did:repo:) -> CreateRecordResponse
unfollow(uri:repo:)
block(did:repo:) -> CreateRecordResponse
unblock(uri:repo:)
Low-Level Record Operations#
createRecord(repo:collection:record:rkey:) -> CreateRecordResponse
deleteRecord(repo:collection:rkey:)
getRecord(repo:collection:rkey:) -> GetRecordResponse
listRecords(repo:collection:limit:cursor:) -> ListRecordsResponse
Rich Text#
bskyKit handles the complexity of AT Protocol rich text facets automatically.
Automatic Detection#
The easiest approach - facets are detected automatically:
let post = PostRecord.create(
text: "Hey @alice.bsky.social! Check https://swift.org #SwiftLang"
)
// post.facets contains 3 facets: mention, link, and hashtag
Manual Rich Text Processing#
For more control, use the RichText type directly:
let text = "Hello @bob.bsky.social!"
let richText = RichText.detect(in: text)
for facet in richText.facets {
switch facet.features[0] {
case .mention(let mention):
print("Mentioned: \(mention.handle ?? "unknown")")
// Resolve handle to DID before posting
case .link(let link):
print("Link to: \(link.uri)")
case .tag(let tag):
print("Hashtag: #\(tag.tag)")
}
}
Byte Index Handling#
AT Protocol uses UTF-8 byte indices, not character indices. bskyKit handles this automatically, but if you need manual conversion:
let text = "Hi 👋 there" // Emoji = 4 bytes
let richText = RichText(text: text)
// Convert character index to byte index
let charIndex = text.index(text.startIndex, offsetBy: 4)
let byteIndex = richText.byteIndex(from: charIndex) // Returns 7
// Convert byte index to character index
let charIdx = richText.characterIndex(from: 7)
Pagination#
All list endpoints support cursor-based pagination:
let service = await BskyService()
// First page
var timeline = try await service.getTimeline(limit: 50)
displayPosts(timeline.feed)
// Load more
while let cursor = timeline.cursor {
timeline = try await service.getTimeline(limit: 50, cursor: cursor)
displayPosts(timeline.feed)
}
Creating Posts with Embeds#
Reply to a Post#
let replyRef = ReplyRef(
root: PostRef(uri: rootPostURI, cid: rootPostCID),
parent: PostRef(uri: parentPostURI, cid: parentPostCID)
)
let post = PostRecord.create(
text: "This is my reply!",
reply: replyRef
)
try await repo.createPost(post, repo: myDID)
Quote Post#
let post = PostRecord(
text: "Check out this post!",
embed: .record(RecordEmbed(uri: quotedPostURI, cid: quotedPostCID))
)
try await repo.createPost(post, repo: myDID)
External Link Card#
let post = PostRecord(
text: "Great article",
embed: .external(ExternalEmbed(
uri: "https://example.com/article",
title: "Article Title",
description: "A brief description of the article"
))
)
try await repo.createPost(post, repo: myDID)
Models#
All models are Codable, Sendable, and most are Identifiable for SwiftUI compatibility.
Key Types#
| Type | Description |
|---|---|
Profile |
User profile with stats and viewer state |
Timeline |
Home timeline with cursor |
TimelineItem |
A post in the timeline (may include reply context) |
Post |
Full post with author, record, embed, and stats |
PostThread |
Thread view with replies |
Notification |
Notification with reason and content |
Feed |
Custom feed generator info |
Follows / Followers |
Social graph lists |
Notification Reasons#
public enum NotificationReason: String {
case like
case repost
case follow
case mention
case reply
case quote
case starterpackJoined
}
Thread Safety#
All services use @APActor for thread-safe access:
@APActor
func loadTimeline() async throws {
let service = BskyService() // Safe to create on APActor
let timeline = try await service.getTimeline()
// Process timeline...
}
Error Handling#
Errors are typed via AtError from CoreATProtocol:
do {
let profile = try await service.getProfile(for: "nonexistent.handle")
} catch let error as AtError {
switch error {
case .message(let msg):
// API error (e.g., "ProfileNotFound")
print("Error: \(msg.error) - \(msg.message ?? "")")
case .network(let networkError):
// Network/HTTP error
print("Network error: \(networkError)")
}
}
Testing#
bskyKit uses Swift Testing. Run tests with:
swift test
The test suite includes:
- Rich text detection (links, mentions, hashtags)
- Byte index conversion
- Model decoding
Related Packages#
- CoreATProtocol - Core networking layer (dependency)
License#
This project is licensed under an MIT license.
Contributing#
It is always a good idea to discuss before taking on a significant task. That said, I have a strong bias towards enthusiasm. If you are excited about doing something, I'll do my best to get out of your way.
By participating in this project you agree to abide by the Contributor Code of Conduct.