browsing-bluesky — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited browsing-bluesky (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
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, 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"if is_authenticated():
print(f"Logged in as: {get_authenticated_user()}")
else:
print("Using public access")
# Clear session if needed (e.g., switching accounts)
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:
#### 1. Quick scan with trending topics (~500 tokens)
topics = get_trending_topics(limit=10)
# Returns: {topics: [{topic, display_name, description, link}, ...],
# suggested: [...]}#### 2. Rich trends with post counts and actors
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#### 3. Targeted exploration of selected trends
posts = search_posts(trend["topic"], limit=25)#### 4. Optional: Firehose for velocity monitoring or long-tail discovery
Prerequisites: Install Node.js dependencies once per session:
cd /home/claude && npm install ws https-proxy-agent 2>/dev/nulldata = sample_firehose(duration=30) # Full firehose sample
data = sample_firehose(duration=20, filter="python") # Filtered sampleReturns 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
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.