Browse Bluesky content via API and firehose - search posts, fetch user activity, sample trending topics, read feeds and lists, analyze and categorize accounts...
Access Bluesky content through public APIs and real-time firehose. Supports optional authentication for personalized feeds. Includes account analysis for categorization.
Add skill directory to path and import:
import sys
sys.path.insert(0, '/path/to/skills/browsing-bluesky') # or use .claude/skills symlink path
from browsing_bluesky import (
# Core browsing
search_posts, get_user_posts, get_profile, get_feed_posts, sample_firehose,
get_thread, get_quotes, get_likes, get_reposts,
get_followers, get_following, search_users,
# Trending
get_trending, get_trending_topics,
# Account analysis
get_all_following, get_all_followers, extract_post_text,
extract_keywords, analyze_account, analyze_accounts,
# Authentication utilities
is_authenticated, get_authenticated_user, authenticated_identity,
clear_session
)
Authentication enables personalized feeds (like Paper Skygest) that require knowing who's asking.
export BSKY_HANDLE="yourhandle.bsky.social"
export BSKY_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx"
Two credential pairs are recognised, and the prefix is the only difference:
| Pair | Identity |
|---|---|
MUNINN_BSKY_HANDLE / MUNINN_BSKY_APP_PASSWORD |
muninn — preferred |
BSKY_HANDLE / BSKY_APP_PASSWORD |
owner — used when no Muninn pair is set |
BSKY_IDENTITY=muninn|owner picks one outright, and a pair named that way is
never substituted for: asking for muninn with no Muninn pair set reads as
public rather than quietly reading as the owner. An unrecognised value raises.
This matters because a booted container holds both pairs. Until
2026-08-28 the unprefixed one won, so every authenticated read — the following
timeline, and the mutes, blocks and labelers that shape all of them — came back
as the account owner with nothing in the output saying so, from a skill
documented as read-only that was nonetheless holding an app password carrying
write scope on his account. On a machine with only the documented BSKY_*
pair, nothing changes.
BSKY_IDENTITY is configuration, not auth failure, and raises insteadif is_authenticated():
print(f"Logged in as: {get_authenticated_user()}")
else:
print("Using public access")
# Which pair answered — a handle alone does not say whether it was chosen
# or simply the only one set.
authenticated_identity() # {'identity': 'muninn', 'handle': ..., 'did': ...}
# Clear session if needed (e.g., switching accounts or identities)
clear_session()
Use search_posts() with query syntax matching bsky.app advanced search:
event sourcing"event sourcing"from:acairns.co.uk or use author= paramsince:2025-01-01 or use since= param#python mentions:user domain:github.comCombine query syntax with function params for complex searches.
get_profile(handle) for context (bio, follower count, post count)get_user_posts(handle, limit=N)search_posts(query, author=handle)Recommended workflow — trending API first, firehose for deep dives:
topics = get_trending_topics(limit=10)
# Returns: {topics: [{topic, display_name, description, link}, ...],
# suggested: [...]}
trends = get_trending(limit=10)
for t in trends:
print(f"{t['display_name']} — {t['post_count']} posts ({t['status']})")
# Each trend includes: topic, display_name, link, started_at,
# post_count, status, category, actors
posts = search_posts(trend["topic"], limit=25)
Prerequisites: Install Node.js dependencies once per session:
cd /home/claude && npm install ws https-proxy-agent 2>/dev/null
data = sample_firehose(duration=30) # Full firehose sample
data = sample_firehose(duration=20, filter="python") # Filtered sample
Returns dict with keys:
{startTime, endTime, durationSeconds} — sampling time range{totalReceived, totalPosts, postsPerSecond, filter, languages} — volume metrics and language breakdown[[word, count], ...] — top 50 words (count >= 3)[[bigram, count], ...] — top 30 bigrams (count >= 2)[[trigram, count], ...] — top 20 trigrams (count >= 2)[[entity, count], ...] — top 25 handles/hashtags (count >= 2)[{text, altTexts, hasImages}, ...] — first 50 matching postsget_feed_posts() accepts:
https://bsky.app/profile/austegard.com/lists/3lankcdrlip2fhttps://bsky.app/profile/did:plc:xxx/feed/feednameat://did:plc:xxx/app.bsky.graph.list/xyzThe function extracts the AT-URI from URLs automatically.
Fetch full thread context for a post with parents and replies:
thread = get_thread("https://bsky.app/profile/user/post/xyz", depth=10)
# Returns: {post: {...}, parent: {...}, replies: [...]}
Discover posts that quote a specific post:
quotes = get_quotes("https://bsky.app/profile/user/post/xyz")
for q in quotes:
print(f"@{q['author_handle']}: {q['text'][:80]}")
Get users who engaged with a post:
likes = get_likes(post_url)
reposts = get_reposts(post_url)
# Accepts both URLs and AT-URIs
likes = get_likes("at://did:plc:.../app.bsky.feed.post/...")
Every parsed post carries an images field — a list of
{alt, url, transcription} dicts, one per embed image. The legacy
image_alts: list[str] field is preserved (non-empty alts only).
When alt text is missing and the image content matters, opt in to model
transcription via the transcribe parameter on any post-fetch function
(get_user_posts, search_posts, get_feed_posts, get_thread,
get_quotes):
# Routine/bulk work (zeitgeist, inbox review, news scans) —
# gemini-2.5-flash-lite is the recommended default. Cheapest production
# model anywhere ($0.10/$0.40 per 1M tokens), ~95% accuracy on dense
# screenshots in May 2026 benchmarks:
posts = get_user_posts("ayourtch.bsky.social", limit=40, transcribe="gemini-lite")
# Token-perfect transcription, still cheap:
posts = get_user_posts(..., transcribe="gemini-flash")
# Frontier model with thinking_level=minimal — for cases where the image
# content needs reasoning, not just transcription:
posts = get_user_posts(..., transcribe="gemini-3.5-flash")
# Anthropic single-vendor option (note: empirically weaker prompt-following
# than Gemini on dense transcription — Haiku tends to summarize rather
# than transcribe):
posts = get_user_posts(..., transcribe="haiku")
# Interactive sessions where image is part of the active task and you want
# conversation context to inform interpretation (only available on Anthropic):
thread = get_thread(post_url, transcribe="opus")
# Default (no transcription) — current behavior preserved:
posts = get_user_posts("ayourtch.bsky.social", limit=40)
Policy is invariant across all callers: images with non-empty alt text are
never transcribed (the author already described the image; trust it).
Only images with missing or empty alt are sent to the model. Network or
API failures leave transcription as None; callers degrade silently.
Cost/quality empirics (May 2026, n=3 dense terminal screenshots, single run each — sample size is small, treat as directional):
| Alias | Latency | $/image | Chord-token recall |
|---|---|---|---|
gemini-lite |
~8s | ~$0.001 | 95% |
gemini-flash |
~10s | ~$0.003 | 100% |
gemini-3.5-flash |
~10s | ~$0.014 | 100% |
haiku |
~7s | ~$0.008 | 18% (summarizes) |
opus |
~20s | ~$0.12 | 91% |
Requires either ANTHROPIC_API_KEY (or API_KEY in /mnt/project/claude.env)
for the haiku / opus aliases, or CF AI Gateway credentials in
/mnt/project/proxy.env for the gemini-* aliases. Transcription only
fires when the parameter is set, so callers without the relevant
credentials can simply pick a different alias or leave the feature off.
Navigate follower/following relationships:
followers = get_followers("handle.bsky.social")
following = get_following("handle.bsky.social")
# Returns list of actor dicts with handle, display_name, did, description, etc.
Search for users by name, handle, or bio:
users = search_users("machine learning researcher")
for u in users:
print(f"{u['display_name']} (@{u['handle']}): {u['description'][:100]}")
https://api.bsky.app/xrpc/ for unauthenticated readshttps://bsky.social/xrpc/ for authenticated requestsapp.bsky.unspecced.getTrends (rich) and app.bsky.unspecced.getTrendingTopics (lightweight)wss://jetstream1.us-east.bsky.network/subscribeAll API functions return structured dicts with:
uri: AT protocol identifiertext: Post contentcreated_at: ISO timestampauthor_handle: User handleauthor_name: Display namelikes, reposts, replies: Engagement countslinks: Full URLs extracted from post facets (post text truncates URLs with "...")image_alts: Alt text from embedded imagesurl: Direct link to post on bsky.appProfile function returns: handle, display_name, description, followers, following, posts, did
Analyze accounts for categorization by topic. Fetches profile and posts, extracts keywords, and returns structured data for Claude to categorize.
# Analyze accounts you follow
results = analyze_accounts(following="yourhandle.bsky.social", limit=50)
# Analyze your followers
results = analyze_accounts(followers="yourhandle.bsky.social", limit=50)
# Analyze specific handles
results = analyze_accounts(handles=["user1.bsky.social", "user2.bsky.social"])
analysis = analyze_account("user.bsky.social")
# Returns: {handle, display_name, description, keywords, post_count, followers, following}
Stopwords parameter filters domain-specific noise:
"en": English (general purpose, default)"ai": AI/ML domain (filters tech boilerplate)"ls": Life Sciences (filters research methodology)results = analyze_accounts(following="handle", stopwords="ai")
Requires: extracting-keywords skill with YAKE venv for keyword extraction.
results = analyze_accounts(
following="handle",
exclude_patterns=["bot", "spam", "promo"] # Skip accounts matching these
)
For large account lists beyond the 100 limit of get_following/get_followers:
all_following = get_all_following("handle", limit=500) # Handles pagination
all_followers = get_all_followers("handle", limit=500)
Each analyzed account returns:
{
"handle": "user.bsky.social",
"display_name": "User Name",
"description": "Bio text here",
"keywords": ["keyword1", "keyword2", "keyword3"],
"post_count": 20,
"followers": 1234,
"following": 567
}
Claude uses bio + keywords to categorize accounts by topic without hardcoded rules