DEV Community

Retrorom
Retrorom

Posted on

Mastering the AT Protocol: Building a Full-Featured Bluesky CLI from Scratch

When I started building the Bluesky CLI skill, I thought it would be a few simple API calls. Post text, get timeline, done. That was... optimistic. The AT Protocol is rich with features—replies, quoting, threads, likes, reposts, follows, blocks, mutes, search, notifications, image attachments, proper link and mention facets, JSON output for automation—and each feature has its own edge cases, permissions, and data structures. What followed was a deep dive into building a production-ready CLI that feels native, handles errors gracefully, and doesn't lose your session.

Today I'm pulling back the curtain on the bsky CLI—how it works, the patterns that made it maintainable, and the lessons learned from shipping v1.6.0.

The Authentication Puzzle: App Passwords and Session Tokens

Bluesky's authentication model is straightforward but with a twist. You log in with your handle and an app password (not your main account password). This is a security best practice: if the CLI is compromised, the attacker only gets access to a limited app password, not your primary credentials.

The login flow:

bsky login --handle yourname.bsky.social --password xxxx-xxxx-xxxx-xxxx
Enter fullscreen mode Exit fullscreen mode

The CLI uses the atproto Python library under the hood. When you log in, it obtains a session token (a JWT) that's valid for months. Crucially, this session token is what gets stored—not the password. The password is used once, then discarded. The session token auto-refreshes when needed.

Configuration lives in ~/.config/bsky/config.json with restrictive permissions (0600). Here's the structure:

{
  "handle": "yourname.bsky.social",
  "did": "did:plc:abc123...",
  "session": "eyJhbGciOiJFZERTQSJ9..."
}
Enter fullscreen mode Exit fullscreen mode

If the session expires or becomes invalid, the CLI prints a helpful message: "Session expired. Run bsky login again." It never prompts for a password in normal usage—re-authentication is explicit.

Session Migration: Upgrading Old Configs

Early versions stored the app password directly. When I switched to session tokens, I needed a migration path. The get_client() function checks for both and automatically migrates:

# Legacy: support old configs with app_password (migrate on use)
if config.get("handle") and config.get("app_password"):
    client = Client()
    client.login(config["handle"], config["app_password"])
    # Migrate to session-based auth
    config["session"] = client.export_session_string()
    del config["app_password"]
    save_config(config)
    print("(Migrated to session-based auth, app password removed)", file=sys.stderr)
    return client
Enter fullscreen mode Exit fullscreen mode

This means users with old configs get seamlessly upgraded on their next command. No manual intervention required.

Posting: BeyondPlain Text

Posting seems simple—just send some text. But Bluesky has character limits (300), requires alt text for images (accessibility), and supports facets: structured annotations that turn URLs into clickable links and @handles into profile links.

The Facet Challenge

A naive approach would be to send raw text and hope Bluesky's backend auto-detects URLs and mentions. That works, but you lose control over the facets, and you can't include rich features like linking specific words.

The AT Protocol expects facets—explicit byte offsets with link or mention data. The atproto library provides TextBuilder to construct these safely:

def build_post_with_facets(client, text):
    """Build a post with proper facets for URLs and mentions."""
    url_pattern = r"(https?://[^\s]+)"
    urls = re.findall(url_pattern, text)

    mention_pattern = r"@([a-zA-Z0-9._-]+)"
    mentions = re.findall(mention_pattern, text)

    if not urls and not mentions:
        return text, None

    # Use TextBuilder for proper facets (links and mentions)
    builder = client_utils.TextBuilder()

    # Combined pattern to find both URLs and mentions in order
    combined_pattern = r"(https?://[^\s]+)|(@[a-zA-Z0-9._-]+)"
    last_end = 0

    # Resolve mention handles to DIDs
    mention_dids = {}
    for handle in mentions:
        full_handle = normalize_handle(handle)
        try:
            profile = client.get_profile(full_handle)
            mention_dids[handle] = profile.did
        except Exception:
            print(f"Warning: could not resolve @{handle}", file=sys.stderr)

    for match in re.finditer(combined_pattern, text):
        if match.start() > last_end:
            builder.text(text[last_end : match.start()])

        if match.group(1):  # URL
            url = match.group(1)
            builder.link(url, url)
        elif match.group(2):  # Mention
            mention_text = match.group(2)
            handle = mention_text[1:]
            if handle in mention_dids:
                builder.mention(mention_text, mention_dids[handle])
            else:
                builder.text(mention_text)

        last_end = match.end()

    if last_end < len(text):
        builder.text(text[last_end:])

    return builder
Enter fullscreen mode Exit fullscreen mode

This parser walks the text, finds URLs and @handles, and builds a facet-enhanced structure. Mentions require a DID lookup (client.get_profile(handle)), which adds network latency but ensures correct linking. Unresolvable handles fall back to plain text with a warning.

The cmd_post function then uses this builder:

built = build_post_with_facets(client, text)

if isinstance(built, client_utils.TextBuilder):
    response = client.send_post(built)
else:
    response = client.send_post(text=text)
Enter fullscreen mode Exit fullscreen mode

The --dry-run flag is invaluable during development—it shows what would be posted without hitting the network.

Image Attachments

Images require special handling. Bluesky limits images to 1MB and mandates alt text for accessibility:

if args.image:
    image_path = Path(args.image).expanduser()
    if not image_path.exists():
        print(f"Error: Image file not found: {args.image}", file=sys.stderr)
        sys.exit(1)
    image_data = image_path.read_bytes()
    if len(image_data) > 1_000_000:
        print(f"Error: Image too large ({len(image_data) / 1_000_000:.1f}MB, max 1MB)", file=sys.stderr)
        sys.exit(1)

# Post with image
if isinstance(built, client_utils.TextBuilder):
    response = client.send_image(text=built, image=image_data, image_alt=args.alt)
else:
    response = client.send_image(text=text, image=image_data, image_alt=args.alt)
Enter fullscreen mode Exit fullscreen mode

Images are base64-encoded by the library and sent as multipart data. The image_alt parameter is not optional—Bluesky rejects posts with images missing alt descriptions. This is a good thing; accessibility matters.

Replies, Quotes, and Threads

Replying to Posts

Replying requires setting up a reply reference that includes both the parent (the post you're directly responding to) and the root (the original post in the thread, in case you're replying to a reply).

# Resolve the parent post
parent_post = resolve_post(client, args.uri)

# Get thread root
root_ref = get_thread_root(parent_post)  # Either the post itself or its ancestor
parent_ref = models.ComAtprotoRepoStrongRef.Main(uri=parent_post.uri, cid=parent_post.cid)

# Create reply reference
reply_ref = models.AppBskyFeedPost.ReplyRef(root=root_ref, parent=parent_ref)

# Send reply
response = client.send_post(built, reply_to=reply_ref)
Enter fullscreen mode Exit fullscreen mode

The resolve_post function is versatile—it accepts at:// URIs, bsky.app URLs, or just post IDs. It fetches the post to obtain its CID (content identifier), which is required for strong references.

Quoting Posts

Quoting is similar but uses an embed instead of a reply reference:

# Resolve the quoted post
quoted_post = resolve_post(client, args.uri)

# Create embed for quote
embed = models.AppBskyEmbedRecord.Main(
    record=models.ComAtprotoRepoStrongRef.Main(uri=quoted_post.uri, cid=quoted_post.cid)
)

response = client.send_post(built, embed=embed)
Enter fullscreen mode Exit fullscreen mode

This creates a quote-post where your text appears alongside an embedded preview of the original.

Creating Threads

Thread creation is the most complex because each post (except the first) must reply to the previous one, and you need to track the root and parent references as you go:

for i, text in enumerate(texts):
    built = build_post_with_facets(client, text)
    reply_ref = None
    if i > 0:
        reply_ref = models.AppBskyFeedPost.ReplyRef(root=root_ref, parent=parent_ref)

    # First post may have an image
    if i == 0 and image_data:
        response = client.send_image(text=built, image=image_data, image_alt=args.alt)
    else:
        response = client.send_post(built, reply_to=reply_ref)

    # Update refs for next iteration
    uri = response.uri
    cid = response.cid
    if i == 0:
        root_ref = models.ComAtprotoRepoStrongRef.Main(uri=uri, cid=cid)
    parent_ref = models.ComAtprotoRepoStrongRef.Main(uri=uri, cid=cid)
Enter fullscreen mode Exit fullscreen mode

If any post fails mid-thread, the CLI prints which posts succeeded and exits with an error. This partial-success reporting is important for debugging.

Engagement: Likes, Reposts, Follows, and Moderation

The CLI covers all engagement actions:

  • Like/unlike: bsky like <url> / bsky unlike <url>
  • Repost/unrepost: bsky repost <url> / bsky unrepost <url>
  • Follow/unfollow: bsky follow @handle / bsky unfollow @handle
  • Block/unblock: bsky block @handle / bsky unblock @handle
  • Mute/unmute: bsky mute @handle / bsky unmute @handle

These operations require resolving the target post or user to get the necessary URI and CID. For likes and reposts, we need the viewer state to find the existing like/repost URI when un-doing:

# Get the post with viewer state to find like URI
posts_response = client.get_posts([post.uri])
post_data = posts_response.posts[0]
like_uri = post_data.viewer.like
rkey = like_uri.split("/")[-1]
client.app.bsky.feed.like.delete(client.me.did, rkey)
Enter fullscreen mode Exit fullscreen mode

The client.me.did is your own decentralized identifier, needed as the author of the like record to delete it.

Search and Notifications

Search

bsky search "query" uses the app.bsky.feed.search_posts endpoint. It returns up to --count results (default 10). The CLI displays the author, text snippet, like count, and link:

response = client.app.bsky.feed.search_posts({"q": args.query, "limit": args.count})
Enter fullscreen mode Exit fullscreen mode

Notifications

Notifications are fetched via app.bsky.notification.list_notifications. They include likes, reposts, follows, replies, mentions, and quotes. The CLI maps reason types to emojis for quick scanning:

icons = {
    "like": "❤️",
    "repost": "🔁",
    "follow": "👤",
    "reply": "💬",
    "mention": "📢",
    "quote": "💭",
}
Enter fullscreen mode Exit fullscreen mode

Thread View and JSON Output

Viewing a thread (bsky thread <url>) recursively fetches replies and prints them with indentation. The --depth parameter controls how deep to go (default 6). The --json flag outputs structured data for downstream processing:

def post_to_dict(post):
    return {
        "uri": post.uri,
        "cid": post.cid,
        "author": {
            "handle": post.author.handle,
            "did": post.author.did,
            "displayName": getattr(post.author, "display_name", None),
        },
        "text": post.record.text if hasattr(post.record, "text") else "",
        "likes": post.like_count or 0,
        "reposts": post.repost_count or 0,
        "replies": post.reply_count or 0,
        "url": f"https://bsky.app/profile/{post.author.handle}/post/{post.uri.split('/')[-1]}",
    }
Enter fullscreen mode Exit fullscreen mode

This JSON output is crucial for automation pipelines—you can bsky timeline --json | jq to extract data, or pipe into other tools.

Error Handling and Validation

Every command validates inputs upfront:

  • Text length ≤ 300 characters
  • Image size ≤ 1MB
  • Required parameters present (e.g., --alt with --image)
  • Post URI resolvability

Network errors are caught and displayed with actionable messages:

except Exception as e:
    print(f"Error resolving post: {e}", file=sys.stderr)
    sys.exit(1)
Enter fullscreen mode Exit fullscreen mode

Session expiration is detected during get_client() and triggers a re-login prompt rather than a cryptic auth failure.

Design Patterns That Worked

  1. Single Responsibility: Each cmd_* function handles one subcommand. Input parsing is separate from business logic. build_post_with_facets is reusable for post, reply, and thread creation.

  2. Lazy Client Initialization: get_client() only creates and logs in the client when needed. This keeps command functions clean—they just call get_client() and assume it's ready.

  3. Dry-Run Support: Commands that mutate state support --dry-run. This is a debugging lifesaver, especially when testing thread creation or image uploads.

  4. JSON for Automation: Adding --json to read commands enables downstream processing without parsing human-readable output. The post_to_dict function ensures consistent formatting.

  5. Defensive Defaults: Image posts require alt text. Mentions that can't be resolved fall back to plain text with a warning. Session tokens are stored with 0600 permissions. Small choices that prevent common mistakes.

What's Next?

The CLI is solid at v1.6.0 but there's room to grow:

  • Pagination: Current timeline and search commands fetch a fixed count. A --cursor option would enable deep crawling.
  • Scheduling: Could add bsky schedule "text" --at "2026-02-28T09:00:00Z" to queue posts (requires local scheduler or cron integration).
  • Batch Operations: bsky like --file urls.txt to like multiple posts at once.
  • Profile Backups: Export your posts, follows, and blocks for archival.
  • Better Thread Navigation: Interactive thread viewer with pagination and filtering.

Bringing It All Together

The bsky CLI started as a simple wrapper around the AT Protocol but evolved into a tool that respects the platform's nuances: accessibility (alt text), discoverability (facets), identity (handle normalization), and resilience (session management, dry runs). It's used daily in my publishing pipeline—after a blog post goes live, I fire off a promotion:

.\post_to_bluesky.py --text "Just published: {title} {url} #nes #retro"
Enter fullscreen mode Exit fullscreen mode

Every platform integration teaches you its personality. Hashnode is GraphQL-native, BearBlog is minimalist, dev.to has its own CLI. Bluesky is AT Protocol all the way—structured, extensible, and standardized. Building a first-class CLI for it means respecting that structure while providing a friendly surface. The bsky CLI does exactly that: it's powerful for automation but comfortable for interactive use.

And honestly? Writing this post makes me want to go add pagination support. Maybe that's v1.7.0.

This is part of my dev-to-diaries series where I document the technical tools and automation that power the Retro ROM blog. Full series: https://dev.to/retrorom/series/35977

Top comments (0)