Bluesky Long-Form Posts 2026: The Read-Side Playbook
Bluesky rolled out its long-form post format in late May 2026, and the read side of the social web has not been the same since. For two years, X Articles was the only major platform where you could publish a 5,000-word piece natively and have it indexed by a search engine. Now Bluesky has matched that surface, and a creator publishing on both is dealing with two different schemas, two different reply graphs, and zero built-in tools for unifying what they wrote.
This guide is a read-side playbook for that world. It is the workflow we built at ThreadGrab for our own team after the May 2026 launch, and the same one we recommend to any creator now publishing long-form on Bluesky and X in parallel. The four steps below assume you already write on both platforms. If you do not, the playbook still works as a one-platform setup, but the multi-platform routing is where the time savings actually compound.
TL;DR: Bluesky long-form posts launched May 2026. A read-side workflow has four steps: detect long-form posts, pull them into one inbox, convert to Markdown, and cross-reference what you wrote versus what you replied to. The plumbing is public APIs and a small script, no vendor lock-in, and ThreadGrab handles the read-side Markdown conversion for the public part of your archive.
What Changed in May 2026
Bluesky's long-form feature shipped to all users in late May 2026. The mechanics are not X Articles -- they live on top of the AT Protocol, which means every long-form post is still a record in a PDS, but the record type is app.bsky.feed.post with a new embeds field that points at a app.bsky.embed.recordWithMedia structure carrying a Markdown body. The official Bluesky client renders that body in a long-scroll card; third-party clients like Skylight, Graysky, and Ozone handle it differently, and some still strip the formatting on read.
On the X side, Articles remained the only native long-form format through the same window. The X Articles URL pattern is stable: https://x.com/<user>/status/<id> with /article/<id> as an alternate. Bluesky long-form URLs follow the same shape as any other post: https://bsky.app/profile/<handle>/post/<rkey>, with the body inside a record.embed rather than a sibling field. If you scrape one but not the other, you are missing half of where long-form lives in 2026.
Why a Read-Side Playbook Matters Now
Publishing long-form on two platforms is easy. Reading what you wrote six months later is not. Each platform has its own UI, its own search, and its own archive retention rules. X will keep your Articles indefinitely as long as your account is active; Bluesky PDSes can be migrated, but a deactivated account can lock you out of your own long-form posts if you did not export them.
The playbook below exists for three reasons that are not theoretical:
- Search. A unified Markdown archive is searchable in any text editor. The platform search UIs are not.
- Portability. If a platform changes its long-form policy tomorrow, you still have a local copy of everything you published.
- Reply tracing. Long-form posts get replies across both platforms. A read-side pipeline lets you see the full conversation graph in one place, which is impossible from either native UI alone.
The 4-Step Read-Side Workflow
The full workflow is four steps, each a single tool or script. None of them require a paid account, and the entire thing runs in under 200 lines of Python if you assemble it by hand. ThreadGrab's hosted version collapses steps 2 and 3 into one call, but the manual version is the canonical reference.
Step 1: Detect Long-Form Posts on Both Platforms
The first step is identification. You need a list of which of your own posts (or which posts you follow) are long-form. The cleanest way to do this is a periodic poll of your own authoring feed, filtered by post length and embed type.
# Pseudo-code for "what counts as long-form on each platform"
def is_long_form_bluesky(post):
embed = post.get("record", {}).get("embed", {})
return (
embed.get("$type") == "app.bsky.embed.recordWithMedia"
and "app.bsky.richtext.facet" in str(embed)
and len(embed.get("text", "")) > 1500
)
def is_long_form_x(post):
text = post.get("text", "") or post.get("note_tweet", {}).get("text", "")
has_article = "/article/" in post.get("permalink", "")
return has_article or len(text) > 4000
Bluesky's threshold is roughly 1,500 graphemes before the long-form card is shown; X Articles start at any length but the platform UI is significantly different above 4,000 characters. The detection logic does not have to be perfect; the goal is a first-pass filter that you can hand-correct.
Step 2: Pull Long-Form Content into a Unified Inbox
Once you have a list, pull the bodies. Bluesky exposes this through the public AppView getPostThread endpoint; X still requires the GET /2/tweets/:id endpoint with an OAuth bearer token for Articles longer than 4,000 characters. The script below shows a parallel pull for both.
import requests, json, pathlib, datetime
OUT = pathlib.Path("./inbox/longform")
OUT.mkdir(parents=True, exist_ok=True)
def pull_bluesky(rkey, handle):
r = requests.get(
f"https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread",
params={"uri": f"at://{handle}/app.bsky.post/{rkey}", "depth": 0},
timeout=15,
)
r.raise_for_status()
thread = r.json()["thread"]["post"]
embed = thread["record"].get("embed", {})
body = embed.get("text", "") or thread["record"].get("text", "")
return {"platform": "bluesky", "id": rkey, "body": body, "ts": thread["indexedAt"]}
def pull_x(article_id, token):
r = requests.get(
f"https://api.x.com/2/tweets/{article_id}",
params={"tweet.fields": "text,note_tweet,entities,created_at"},
headers={"Authorization": f"Bearer {token}"},
timeout=15,
)
r.raise_for_status()
body = r.json()["data"].get("note_tweet", {}).get("text") or r.json()["data"]["text"]
return {"platform": "x", "id": article_id, "body": body, "ts": r.json()["data"]["created_at"]}
# Example: archive two posts
for path in OUT.glob("*.json"):
pass
out1 = pull_bluesky("3l5xabc123", "you.bsky.social")
out2 = pull_x("1800000000000000000", "YOUR_X_BEARER_TOKEN")
for entry in (out1, out2):
stamp = datetime.datetime.fromisoformat(entry["ts"].replace("Z", "+00:00")).strftime("%Y%m%dT%H%M%SZ")
(OUT / f"{stamp}_{entry['platform']}_{entry['id']}.json").write_text(json.dumps(entry, indent=2))
The output is one JSON file per long-form post in a single ./inbox/longform/ directory. The filename is sortable by date, so the directory is already a chronologically-ordered archive without any database.
Step 3: Convert to Markdown for Re-Reading and Search
The JSON blobs are not what you want to read at 2 AM six months from now. Convert each to a Markdown file with frontmatter, and you have a grep-friendly, editor-friendly, version-controllable archive. ThreadGrab's hosted version does this for the public read; the script below is what we use internally for the private author-side archive.
def json_to_markdown(entry):
fm = [
"---",
f"platform: {entry['platform']}",
f"id: {entry['id']}",
f"published: {entry['ts']}",
f"url: " + (
f"https://bsky.app/profile/you.bsky.social/post/{entry['id']}"
if entry["platform"] == "bluesky"
else f"https://x.com/you/status/{entry['id']}"
),
"---",
]
body_md = entry["body"]
if entry["platform"] == "bluesky":
body_md = body_md.replace("
", "
")
return "
".join(fm) + "
" + body_md + "
"
import json, pathlib
for src in pathlib.Path("./inbox/longform").glob("*.json"):
entry = json.loads(src.read_text())
(pathlib.Path("./archive") / src.with_suffix(".md").name).write_text(json_to_markdown(entry))
After running, you have a directory of .md files that you can read in any editor, search with ripgrep, commit to Git, or feed back into a future LLM as corpus material. The frontmatter keeps the platform tag and the canonical URL so you can always navigate back to the live version.
Step 4: Cross-Reference Your Own Posts vs. Replies
The final step is the one most creators skip: a unified conversation graph. For every long-form post you wrote, you want to see all replies across both platforms in one place. This is not built into either UI; it is a script.
def replies_for(post):
if post["platform"] == "bluesky":
r = requests.get(
"https://public.api.bsky.app/xrpc/app.bsky.feed.getReplies",
params={"uri": post["embed_uri"], "limit": 100},
timeout=15,
)
return [r["post"]["record"]["text"] for r in r.json().get("replies", [])]
if post["platform"] == "x":
r = requests.get(
f"https://api.x.com/2/tweets/search/recent",
params={"query": f"conversation_id:{post['id']}", "tweet.fields": "text,author_id"},
headers={"Authorization": f"Bearer {post['token']}"},
timeout=15,
)
return [t["text"] for t in r.json().get("data", [])]
return []
The output is a single text file per long-form post: 20260701T090000Z_x_1800000000000000000.md with a ## Replies section listing every reply from both platforms. If you published a piece in May 2026 and want to know what readers said about it in July, you open one file, not two browser tabs.
How ThreadGrab Fits In
ThreadGrab's hosted service does steps 2, 3, and 4 for any public long-form post on Bluesky or X. If you have a long-form URL, ThreadGrab returns the Markdown body with frontmatter, the canonical platform URL, and the reply graph. The hosted service is free for public posts, does not require an X token, and does not write to your PDS.
For private authoring archives (your own posts, before they are public), you still need the script above. The hosted service complements, not replaces, the local archive. The split is intentional: the public read-side is what ThreadGrab does best, and the private author-side is what you do best because only you know which drafts you want to keep.
Three specific things ThreadGrab handles that the script above does not:
- Public post discovery. ThreadGrab can be pointed at any handle, not just your own, and will detect new long-form posts as they ship.
- Reply graph for public posts. For any Bluesky or X post you give it, ThreadGrab returns the full reply tree as JSON.
- Format normalization. Bluesky's
embed.textfield uses a custom facet syntax; X'snote_tweet.textuses URL entities. ThreadGrab flattens both to common Markdown, so you do not have to maintain a translator.
Point ThreadGrab at any Bluesky or X long-form post URL. You get the Markdown body, the canonical platform link, and the reply graph in one call. No X token, no PDS, no install.
Try ThreadGrab FreeWhen Not To Build a Read-Side Playbook
Not every creator needs this. If you publish long-form on exactly one platform and read your own posts in the native UI, the playbook is overkill. The four-step workflow pays off when at least two of these are true:
- You publish on both Bluesky and X.
- You have published more than 20 long-form pieces total.
- You reference your own prior posts in new posts ("as I wrote in May...").
- You want your archive to outlive any single platform.
If only one of those applies, the simplest setup is the public ThreadGrab for the public posts and a single Obsidian vault for the private drafts. If two or more apply, the four-step script above is worth the weekend it takes to assemble.
Frequently Asked Questions
Bluesky shipped the long-form post format to all users in late May 2026. The feature is available in the official Bluesky client, Skylight, Graysky, and Ozone. Some third-party clients still do not render the long-form card correctly; if you read Bluesky in one of those clients, the post will appear truncated.
No. The Bluesky public AppView endpoints are open and do not require authentication for read-only access. X's GET /2/tweets/:id endpoint requires a free developer account with a bearer token, but you do not need X Premium or X API paid tier for read-only access to your own Articles.
Yes for the public ones. The public ThreadGrab endpoint accepts any Bluesky or X long-form URL. For private or draft posts you do not own, the script above will not work -- the platforms expose only the author-side endpoints to the author.
Not directly. Threads does not have a native long-form feature in 2026; long Threads posts collapse to a single thread. LinkedIn Newsletters have a public RSS feed you can subscribe to, but the article body is not searchable through a public API the same way Bluesky and X expose. If you publish on either, add a fifth step to pull the RSS feed and convert it.
That is the entire reason the playbook exists. The Markdown archive is your portable backup. If your PDS migrates, the canonical URL on the new PDS still points to the same rkey, and the archive copy is independent. If your account is deactivated, the public ThreadGrab cache may still hold the post for some time, but the Markdown archive is the long-term guarantee.