Bluesky Posts, Threads, Feeds & Trending Topics (no login)
Pricing
from $1.05 / 1,000 bluesky data rows
Bluesky Posts, Threads, Feeds & Trending Topics (no login)
Public Bluesky data as rows: an account's posts, the replies and quote posts of any post, or any public custom feed - with text, language, engagement counts, hashtags and links. Plus today's trending topics with post counts, and bulk account profiles. Keyless AT Protocol API, UTC dates.
Pricing
from $1.05 / 1,000 bluesky data rows
Rating
0.0
(0)
Developer
Samat Makatov
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
4 days ago
Last modified
Categories
Share
Public Bluesky data as rows: an account's posts, the replies and quote posts of any post, or any public custom feed - with text, language, engagement counts, hashtags and links. Plus today's trending topics with post counts, and bulk account profiles. Keyless AT Protocol API, UTC dates.
Five questions, one run each: what an account posted, what people replied to one post, who quote-posted it, what is in a public feed, which topics are trending right now, and how big the audience of a handle is. Everything is read from the public AT Protocol AppView, which answers JSON without an account, a key or a cookie, so there is no login, no browser and no proxy in the way. Rows carry the post text, the declared language, likes, reposts, replies, quotes and bookmarks, hashtags, mentions, linked pages and the post link; every timestamp is UTC ISO-8601.
Need it tailored to your workflow? Missing a field, a filter, another source or a different output format? Open an issue and describe what you need — we read every request and adapt the actor.
Use cases
- Brand and media monitoring after a forum closes its feeds: list the posts of the accounts you watch (
accountPosts) on a schedule withsinceHours: 24andonlyNew, and every run holds only what is new. - Community and support work: turn one post link into its discussion (
threadReplies) with the depth of every reply, so you can answer the whole branch instead of the newest comment. - PR after an announcement: collect who quote-posted it and what they wrote (
includeQuotes), the reaction a reply tree never shows. - Dataset and agent input: harvest any public feed by its link (
customFeed), filtered to the languages you can process, and keep the columns small withfields. - Social listening and newsrooms: read the topics the network is discussing this hour with a post count and a start time (
trendingTopics), then feed a topic'sfeedUriback intocustomFeedto read the posts behind it. - Influencer and competitor research: resolve a list of handles to followers, following and post counts in one go (
profiles, 25 accounts per request).
Input
Examples live in prefill: the Console form opens filled in, so a first run from the Console works without editing anything. prefill is a Console feature only - a run started from the API, a schedule, an integration or MCP gets the defaults below and nothing else, so it names its own handles, postUrls or feedUrls (without them the run ends at once with a message that names the missing field). Defaults only change behaviour, never the subject of a search.
| Field | Type | Default | Used by | Notes |
|---|---|---|---|---|
mode | string | accountPosts | - | accountPosts, threadReplies, customFeed, trendingTopics, profiles - see Modes |
handles | string[] | prefill ["bsky.app","nytimes.com"] | accountPosts, profiles | Handles (nytimes.com), @handle, profile links or DIDs |
accountFilter | string | posts_no_replies | accountPosts | Which half of the timeline - see Account filters |
postUrls | string[] | prefill one post link | threadReplies | Web links (bsky.app/profile/<handle>/post/<id>) or AT-URIs |
threadDepth | number | 3 | threadReplies | 0 = the post alone, 1 = direct replies, max 10 |
includeQuotes | boolean | false | threadReplies | Also write the posts quoting the given post |
feedUrls | string[] | prefill the "What's Hot" feed | customFeed | Web links (bsky.app/profile/<handle-or-did>/feed/<id>) or AT-URIs |
trendStatus | string | any | trendingTopics | any, trending, cooling - see Topic statuses |
languages | string[] | all | post modes | Keep posts whose first declared language matches - see Languages |
minLikes | number | none | post modes | Like floor at fetch time |
sinceHours | number | none | post modes | Rolling UTC window, 1-8760 hours; in accountPosts paging stops at its edge, in the other post modes it filters what the source returns |
onlyNew | boolean | false | post modes | Skip posts written by earlier runs of the same actor or task; only posts actually written are remembered |
includeReposts | boolean | false | accountPosts, customFeed | Keep what the account amplified, marked isRepost: true |
maxItems | number | 50 | - | Hard row cap, 1-1000; split over the accounts, feeds or post links you named (in profiles: the number of accounts read) |
fields | string[] | all | - | Keep only these columns, in this order; the identifying columns always stay (see below) |
The closed lists (mode, accountFilter, trendStatus, languages) take only the values listed in Reference: Apify checks them before the run starts and refuses a misspelled value with the list of accepted ones, so a run never quietly answers a different question. Handles are forgiving - spaces, a leading @, capital letters and a pasted profile link (https://bsky.app/profile/nytimes.com) are all read as the handle. A handle that does not exist, a deleted post or an account that hides itself from logged-out viewers produces one row with found: false and the reason in notFoundReason - never a silent empty success.
fields is free text: letter case is corrected (AuthorHandle -> authorHandle), a name that is not a column of the mode is left out and named in the status message with the nearest column, and a list in which no name is a column of the mode stops the run with the list of valid columns instead of quietly writing every column. The identifying columns stay in every row whatever fields lists: rowType, postUri, sourceRef, found in the post modes; rowType, topic in trendingTopics; rowType, handle, found in profiles.
Reference
Modes
mode | What one row is | Source endpoint | Dataset view |
|---|---|---|---|
accountPosts | A post of an account you named, newest first | author feed, paged by cursor | Posts |
threadReplies | The post itself (replyDepth: 0) and every reply the source serves, plus quote posts when asked | post thread (+ quotes) | Posts |
customFeed | A post of a public feed, in the feed's own order | feed generator | Posts |
trendingTopics | A topic trending right now, with its post count and feed link | trends | Trending topics |
profiles | An account with follower, following and post counts | profiles, 25 per request | Profiles |
Account filters
| Value | What the source returns |
|---|---|
posts_no_replies | Only what the account published itself - the "what did this brand say" list (default) |
posts_with_replies | The whole timeline, including the account's replies to other people |
posts_and_author_threads | Posts plus the threads the account wrote to itself |
posts_with_media | Only posts with an image or a video attached |
The filter is applied by the source, so posts outside it are never fetched and never charged.
Topic statuses
trending (still gaining posts) and cooling (past its peak) are the statuses you can filter by. The source also uses others - saturating was seen on 2026-10-01, stale on 2026-10-02 - and those rows are kept, untouched, when trendStatus is any. Most topics in a typical answer are not trending: of the 25 topics served on 2026-10-01 only three were, and at 06:00 UTC on 2026-10-02 none was (4 cooling, 21 stale). A trending-only run is therefore short by nature and can be empty in quiet hours - the status message then lists the statuses the source did serve.
Languages
en English, pt Portuguese, ja Japanese, de German, es Spanish, fr French, it Italian, nl Dutch, tr Turkish, ru Russian, uk Ukrainian, pl Polish, ko Korean, zh Chinese, id Indonesian, fa Persian, th Thai, ar Arabic.
Pick the two-letter codes from this list; any other value (a name like English, a three-letter code like eng) is refused by Apify before the run starts. The language is declared by the app that published the post, not detected here: a post of a news account often declares none, and such posts are dropped when languages is set. A tag like en-US is reported as language: "en", with the full tag kept in langs.
Links and AT-URIs
| What you have | What it means |
|---|---|
bsky.app/profile/nytimes.com | handles: ["nytimes.com"] |
bsky.app/profile/bsky.app/post/3mwolmfws5k2r | A post: handle + record id -> at://<did>/app.bsky.feed.post/3mwolmfws5k2r |
bsky.app/profile/bsky.app/feed/whats-hot | A feed: handle + feed id -> at://<did>/app.bsky.feed.generator/whats-hot |
/profile/did:plc:…/feed/b74101f118b8 (a topic's link) | The feed behind a trending topic - paste it into feedUrls |
Both shapes work in postUrls and feedUrls; the handle is resolved to its DID before the URI is built, which is what makes quote posts and third-party feeds answer at all.
Examples
What two newsrooms published, newest first
{ "mode": "accountPosts", "handles": ["nytimes.com", "theonion.com"], "accountFilter": "posts_no_replies", "includeReposts": false, "maxItems": 20 }
The discussion under one post, three levels deep
{ "mode": "threadReplies", "postUrls": ["https://bsky.app/profile/bsky.app/post/3mwolmfws5k2r"], "threadDepth": 3, "maxItems": 25 }
Who quote-posted an announcement (no reply tree)
{ "mode": "threadReplies", "postUrls": ["https://bsky.app/profile/aster.id/post/3mwqgsd4cgs2a"], "threadDepth": 0, "includeQuotes": true, "maxItems": 15 }
English posts of a public feed
{ "mode": "customFeed", "feedUrls": ["https://bsky.app/profile/bsky.app/feed/whats-hot"], "languages": ["en"], "maxItems": 20 }
What is trending right now
{ "mode": "trendingTopics", "trendStatus": "trending", "maxItems": 20 }
Audience numbers for a handful of accounts
{ "mode": "profiles", "handles": ["bsky.app", "nytimes.com", "theonion.com", "atproto.com"], "maxItems": 10 }
Monitoring run for a schedule: only new posts of the last day that travelled
{ "mode": "accountPosts", "handles": ["nytimes.com"], "sinceHours": 24, "minLikes": 10, "onlyNew": true, "fields": ["authorHandle", "text", "likeCount", "createdAt", "url"], "maxItems": 50 }
Output
A real row from the cloud run YDFsTLQ4jo3cXAXu5 (2026-10-01, accountPosts):
{"rowType": "post","postUri": "at://did:plc:eclio37ymobqex2ncko63h4r/app.bsky.feed.post/3mwtnkjsrpk2i","postId": "3mwtnkjsrpk2i","cid": "bafyreietviuynzhap4qo3ecpqf2k7ocxpmersfw4khjg5ukoe3jyesrihe","url": "https://bsky.app/profile/nytimes.com/post/3mwtnkjsrpk2i","authorHandle": "nytimes.com","authorDisplayName": "The New York Times","authorDid": "did:plc:eclio37ymobqex2ncko63h4r","text": "Death row inmate Christa Pike is in critical condition after Tennessee's failed attempt to execute her, her lawyers said. Follow live updates:","language": null,"langs": [],"createdAt": "2026-10-01T20:01:17.935Z","indexedAt": "2026-10-01T20:01:18.568Z","likeCount": 13,"repostCount": 1,"replyCount": 4,"quoteCount": 6,"bookmarkCount": 0,"isReply": false,"isRepost": false,"isQuotePost": false,"replyToUri": null,"replyToHandle": null,"threadRootUri": "at://did:plc:eclio37ymobqex2ncko63h4r/app.bsky.feed.post/3mwtnkjsrpk2i","replyDepth": null,"quotedPostUri": null,"quotedAuthorHandle": null,"hashtags": [],"mentions": [],"links": [],"externalUrl": "https://nyti.ms/4jAP7nD","externalTitle": "Live Updates: Christa Pike in Critical Condition After Botched Tennessee Execution","embedType": "external","imageCount": 0,"imageAlts": [],"labels": [],"sourceKind": "accountPosts","sourceRef": "nytimes.com","found": true,"notFoundReason": null,"fetchedAt": "2026-10-01T20:08:51.972Z"}
Post row (rowType: "post")
| Field | Type | Always filled | Meaning |
|---|---|---|---|
rowType | string | yes | post |
postUri | string | yes | AT-URI of the post |
postId | string | yes | Record id - the last part of the post link |
cid | string | yes | Content id of this version of the post |
url | string | yes | Link to the post in the web app |
authorHandle | string | yes | Who wrote it (for a repost: the original author) |
authorDisplayName | string | no | Display name at fetch time |
authorDid | string | yes | Stable account id |
text | string | yes | Post text; empty for an image-only or video-only post |
language | string | no | First declared language as a two-letter code; empty when the app declared none |
langs | string[] | yes | All declared language tags, as declared (["en-US"]) |
createdAt | string | yes | When the author posted it, UTC |
indexedAt | string | no | When the source indexed it, UTC |
likeCount, repostCount, replyCount, quoteCount, bookmarkCount | number | yes | Engagement at fetchedAt |
isReply | boolean | yes | The post answers another post |
isRepost | boolean | yes | The row came into the feed as a repost |
isQuotePost | boolean | yes | The post embeds another post |
replyToUri | string | no | The post this one answers |
replyToHandle | string | no | Author of that parent, when the source served it in the same answer |
threadRootUri | string | yes | In threadReplies: the post the thread was read from; otherwise the root of the post's own thread |
replyDepth | number | no | threadReplies only: 0 for the read post, 1 for a direct reply |
quotedPostUri | string | no | The quoted post |
quotedAuthorHandle | string | no | Author of the quoted post |
hashtags | string[] | yes | Tags, without the # |
mentions | string[] | yes | Mentioned handles |
links | string[] | yes | URLs in the text |
externalUrl, externalTitle | string | no | The link card attached to the post |
embedType | string | no | images, video, external, quote, quoteWithMedia, feedCard, listCard, starterPackCard |
imageCount | number | yes | Attached images |
imageAlts | string[] | yes | Non-empty alt texts of those images |
labels | string[] | yes | Moderation labels on the post |
sourceKind | string | yes | The mode that produced the row |
sourceRef | string | yes | The exact input value that produced it (handle, post link or feed link) |
found | boolean | yes | false on a row that explains why an input gave nothing |
notFoundReason | string | no | Why, in words: deleted post, unknown handle, optedOutOfLoggedOutVisibility |
fetchedAt | string | yes | When this run read it, UTC |
Trend row (rowType: "trend")
topic (readable name), topicId, description, category, status, postCount, startedAt (UTC), sampleAuthors (up to 10 handles seen in the topic), feedUri (paste into feedUrls; null on the rare topic whose link is not a feed), url, fetchedAt. Example from run SnSQV1ePRYRMdTQjK: "Celebrity Traitors series 2 premieres", category entertainment, status trending, postCount: 784, started 2026-10-01T17:54:23.570Z.
Profile row (rowType: "profile")
handle, did, displayName, description, followersCount, followsCount, postsCount, createdAt (UTC), avatarUrl, bannerUrl, labels, verifiedStatus (valid, none, …), url, found, notFoundReason, fetchedAt. Image URLs are reported as strings; no image is downloaded.
Dataset views: Posts (the three post modes), Trending topics, Profiles. Each run also writes a SUMMARY record into the default key-value store (linked from the run's output as summary) with the request count, rows read and written, rows per account/feed/post, what was skipped (reposts, languages, like floor, time window, already-seen posts, replies the source hides), every input the source would not serve and every input not read because the run stopped first.
Use it from code / agents
curl -X POST "https://api.apify.com/v2/acts/yadroo~bluesky-public-posts/run-sync-get-dataset-items?token=$APIFY_TOKEN" \-H "Content-Type: application/json" \-d '{"mode":"accountPosts","handles":["nytimes.com"],"sinceHours":24,"maxItems":20}'
import { ApifyClient } from 'apify-client';const client = new ApifyClient({ token: process.env.APIFY_TOKEN });const run = await client.actor('yadroo/bluesky-public-posts').call({ mode: 'trendingTopics', trendStatus: 'trending', maxItems: 20 });const { items } = await client.dataset(run.defaultDatasetId).listItems();
from apify_client import ApifyClientclient = ApifyClient(os.environ["APIFY_TOKEN"])run = client.actor("yadroo/bluesky-public-posts").call(run_input={"mode": "customFeed","feedUrls": ["https://bsky.app/profile/bsky.app/feed/whats-hot"],"languages": ["en"],"fields": ["authorHandle", "text", "likeCount", "createdAt", "url"],"maxItems": 20,})items = client.dataset(run["defaultDatasetId"]).list_items().items
For an agent, fields is the cheap way to keep the context small: five columns instead of forty, in the order you name them. apify call yadroo/bluesky-public-posts works the same way from the CLI.
MCP: add https://mcp.apify.com to Claude / Cursor / any MCP client and call the yadroo/bluesky-public-posts tool with the same JSON input.
Pricing
Pay per event: $0.001 per run start plus $0.0015 per dataset row (FREE tier). Every run is charged the start event, including a run that ends with no rows.
| Run | Rows | Cost |
|---|---|---|
| A thread or a short monitoring run | 20 | $0.001 + 20 × $0.0015 = $0.031 |
| The default run | 50 | $0.001 + 50 × $0.0015 = $0.076 |
| A feed harvest at the cap | 1000 | $0.001 + 1000 × $0.0015 = $1.501 |
The row price follows your Apify plan (Bronze -10 %, Silver -20 %, Gold and above -30 %); the start event is the same on every plan, and platform usage is included in these prices. maxItems is a hard cap, so a run can never cost more than you planned, and sinceHours, minLikes and onlyNew cut rows before they are written - a schedule that finds nothing new costs the start event alone. The run also respects your Maximum cost per run: it stops writing at the last row that budget pays for and ends with "Stopped at your spending limit: N rows delivered"; with onlyNew, only the rows delivered are remembered, so the next run picks up the rest.
Limits & FAQ
- No keyword or hashtag search. The source's post-search route answers HTTP 403 to callers without a session - checked from this runner and from an Apify run on 2026-10-01 - so this actor does not offer it, and nothing here pretends to. The closest replacement: take a topic from
trendingTopicsand read itsfeedUriwithcustomFeed, or use a public feed that already collects your subject. - Only what is public without a login. Accounts that ask not to be shown to logged-out viewers come back as one row with
found: falseandnotFoundReason: "optedOutOfLoggedOutVisibility"; no other route is tried. - No follower or following lists, no liked-by lists - by design, not by accident. This actor reads posts, public profile cards, topics and feeds.
- Engagement counts are a snapshot. The same post read tomorrow has different numbers, which is why every row carries
fetchedAt. languageis declared, not detected. The posting app writes it; accounts that leave it out getlanguage: nulland are dropped by alanguagesfilter.- Trending topics are an unstable route. They live in the source's own "unspecced" namespace and may change shape or disappear; the run then ends with a readable message instead of a crash, and the other four modes are untouched. At most 25 topics are served per call.
- Third-party feeds can be empty or slow. A feed is built by whoever published it; when it serves nothing, the run says so per feed in the status message and in
SUMMARYinstead of failing. - Deleted or unknown posts give HTTP 400 at the source and become one
found: falserow with the reason - a typo in a list of ten links never disappears silently. - Rate limits. The source asks callers to back off on HTTP 429 and to keep concurrency low; requests are therefore serial with a pause, and a 429 is retried after the delay the source names. A typical run makes 2-6 requests.
- Hidden replies. Replies removed by their author or by moderation are not served; they are counted in
SUMMARYrather than guessed at. - Repeats are billed once. A post an input serves twice (a feed repeating it, a reply that is also a quote post) is one row; the same post reached from two inputs (two accounts, two thread links) is one row per input, each with its own
sourceRef. Inprofiles, a handle and a DID that name one account give one row. - Run timeout. A run that gets close to its timeout stops starting new requests, saves what it has read and ends successfully with "Stopped before the run timeout: N rows saved". A typical run takes seconds; the default timeout is 15 minutes.
- Thread depth costs rows, not requests: the source returns the whole branch in one call, so
threadDepth: 10andthreadDepth: 1cost the same request and differ only in rows written.
Made by Yadroo. Sibling actors: google-news-search, hackernews-search, stackexchange-search, rss-to-json, crypto-news.