No-login access to public X/Twitter threads β built for AI agents.
Give it any public status URL; it returns the reconstructed thread (posts, authors, timestamps, quoted posts), every photo and video as files on disk, and a machine-readable manifest. No login. No API keys. No browser. Deterministic, stdlib-only Python.
python3 xthread-agent.py "https://x.com/<user>/status/<status_id>" --out media/Website Β· Why it exists Β· For AI agents Β· Endpoint matrix Β· Releases
X locked down its public GraphQL endpoints. As of 2026, the standard toolbox is broken in specific, well-defined ways β this tool routes around all of them (see the endpoint matrix for the full autopsy):
| Approach | Status in 2026 |
|---|---|
gallery-dl guest-token + TweetResultByRestId |
β Dead β guest token activates, GraphQL returns empty payload |
yt-dlp on status URLs |
β Dead β same GraphQL wall |
| Nitter (all public instances) | β Dead β timeouts / HTTP 451 |
Direct x.com scrape (headless or curl) |
β Blocked β empty shell for datacenter IPs |
api.vxtwitter.com |
β Alive again (re-verified 2026-09-24) β used as the fallback decoder slot |
| sotwe.com / twstalker.com mirrors | β Blocked β Cloudflare 403 |
syndication.twitter.com timeline endpoint |
|
cdn.syndication.twimg.com/tweet-result |
β Works β single tweets only, no traversal |
api.fxtwitter.com/status/<id> |
β Works β full tweet JSON incl. multi-video "amplify" media |
| unrollnow.com/status/ | β
Works β public thread walk; |
xthread-agent = Thread Walker (UnrollNow β ThreadReaderApp fallback) +
Metadata Decoder (FixTweet, with vxtwitter fallback) + Thread Reconstructor
(replying_to_status chain) + Media Fetcher (twimg CDN) β dual-homed where
it matters, honest everywhere.
The deliverable is a versioned envelope β thread_manifest.json β validated
against a bundled draft-07 JSON Schema,
next to the downloaded media. Illustrative excerpt (the schema is the
contract):
{
"schema_version": "3.0",
"source": { "tool": "xthread-agent", "version": "3.2.0", "generated_at": "β¦" },
"request": { "input": "https://x.com/jack/status/20", "status_id": "20",
"canonical_url": "https://x.com/i/web/status/20" },
"status": "ok",
"thread": {
"root_status_id": "20", "tweet_count": 1,
"walker_slot": "unrollnow",
"chain_reconstructed": true, "degraded_to_root_only": false
},
"posts": [
{
"id": "20",
"url": "https://x.com/i/web/status/20",
"text": "just setting up my twttr",
"created_at_iso": "2006-03-21T20:50:14.000Z",
"author": { "screen_name": "jack", "name": "jack", "followers": "β¦" },
"metrics": { "likes": "β¦", "retweets": "β¦", "views": "β¦" },
"media": {
"photos": [ { "url": "β¦pbs.twimg.com/β¦", "file": "20_p1.jpg", "downloaded": true } ],
"videos": []
},
"thread_position": 1
}
],
"errors": [],
"metadata": { "duration_sec": "β¦" }
}status is ok (posts, no errors), partial (posts but something degraded β
see errors[]), or empty (nothing harvested; fail-closed). Every degraded
path names itself with a stable error code instead of guessing.
| Verified | Value |
|---|---|
| Live endpoints re-verified | 2026-09-24, 3/3 β walker, both decoders, CDN (matrix) |
| Redundancy | 2 walker slots + 2 decoder slots β dual-homed discovery and decode |
| Input handling | 24 URL forms accepted, 10 rejected with stable codes (incl. t.co one-hop expansion) |
| Offline tests | 135 in ~1 s β no network, synthetic fixtures only |
| CI matrix | Python 3.9 β 3.13, every push |
| Runtime dependencies | 0 β Python stdlib only |
| Largest live-verified transfer | 167 MB 4K MP4 + poster β atomic write, Content-Length-verified |
| Media actually verified live | JPEG photo (1455Γ980), 4K MP4, poster frames, real manifests |
status URL
β
βΌ
βββββββββββββββββββββββββββββββββ
β 0. NORMALIZE + VALIDATE β proper URL parsing (x.com / twitter.com,
β β bare status id β mobile/www hosts, /photo /video suffixes,
βββββββββββββββββ¬ββββββββββββββββ t.co shortlinks resolved one hop)
βΌ
βββββββββββββββββββββββββββββββββ
β 1. THREAD WALK (2 slots) β GET unrollnow.com/status/<root_id>
β regex-extract every β β ordered, deduped candidate IDs
β candidate ID β (ThreadReaderApp fallback slot;
β β root ALWAYS kept; capped at 50;
β β slot named in walker_slot)
βββββββββββββββββ¬ββββββββββββββββ
βΌ
βββββββββββββββββββββββββββββββββ
β 2. METADATA DECODE β GET api.fxtwitter.com/status/<id>
β (3 retries, backoff) β β text, author, stats, media[]
β 404s = media IDs, skipped β fallback slot: api.vxtwitter.com
βββββββββββββββββ¬ββββββββββββββββ
βΌ
βββββββββββββββββββββββββββββββββ
β 3. CHAIN RECONSTRUCTION β true self-reply chain from
β walk UP to thread start, β replying_to_status β unrelated
β walk DOWN through replies β same-author recommendations are
βββββββββββββββββ¬ββββββββββββββββ excluded, not harvested
βΌ
βββββββββββββββββββββββββββββββββ
β 4. CDN DOWNLOAD β video.twimg.com/β¦mp4 (best variant;
β atomic (.part + rename) β m3u8-only videos fall back to the
β resumable, size-verified β highest-bitrate mp4 in formats[])
βββββββββββββββββ¬ββββββββββββββββ
βΌ
βββββββββββββββββββββββββββββββββ
β 5. thread_manifest.json β enveloped result: source, request,
β (schema_version 3.0) β thread stats, posts[], errors[],
β β metadata β UTF-8, atomic write
βββββββββββββββββββββββββββββββββ
- UnrollNow pages embed recommendations, not just the conversation.
Same-author tweets that do not reply to the root appear alongside thread
members; the walker's output is treated strictly as candidates. Chain
membership is decided by
replying_to_statusfrom the decoder β never by page order. (v2.0.0 harvested recommendations as thread members; v3 does not.) - The root is always harvested, even for short legacy IDs the walker's regex cannot see (v2.0.0 silently dropped the root in that case).
- A 404/451 from the decoder is a filter signal, not an error. It arrives
both as a 200 body with
code: 404and as a real HTTP status β both are handled as instant, retry-free filters. - FixTweet decodes multi-video "amplify" tweets, and its
formats[]array carries mp4 variants with bitrates β so even videos whose primary URL is HLS-only can usually be downloaded as mp4 by variant selection. video.twimg.comandpbs.twimg.comneed no auth once you have the URL. All authentication burden sits in front of discovery, not delivery.- Media downloads are restricted to
*.twimg.comover https, verified againstContent-Length, streamed, and atomically renamed β a truncated transfer or a compromised decoder payload cannot corrupt local files.
# stdlib only β Python 3.9+, nothing to install
python3 xthread-agent.py "https://x.com/<user>/status/<id>"Output layout:
media/
βββ <tweetid>_v1.mp4 # videos (best-quality mp4)
βββ <tweetid>_v1_poster.jpg # poster frames
βββ <tweetid>_p1.jpg # photos
βββ thread_manifest.json # everything, mapped (envelope, schema 3.0)
python3 xthread-agent.py <status_url_or_id> [--out DIR] [--no-download]
[--json] [--quiet] [--version]| Flag | Purpose |
|---|---|
--out DIR |
Output directory (default x_thread_media) |
--no-download |
Manifest only β resolve, decode, reconstruct; skip media |
--json |
Machine-readable summary on stdout (all logs stay on stderr) |
--quiet |
Suppress log lines |
--version |
Print version |
Exit codes: 0 = at least one post harvested, 1 = nothing harvested / error,
2 = usage error.
python3 xthread-agent.py "https://x.com/<user>/status/<id>" --json --quiet{
"ok": true,
"status": "ok",
"root_id": "β¦",
"canonical_url": "https://x.com/i/web/status/β¦",
"tweets": 4,
"videos": 11,
"photos": 2,
"downloaded": 13,
"failed_downloads": 0,
"out_dir": "media",
"manifest_path": "media/thread_manifest.json",
"errors": 0,
"duration_sec": 86.3
}status is ok (posts, no errors), partial (posts but something degraded β
see errors), or empty (nothing harvested). The full detail β posts in
thread order, authors, timestamps, quoted posts, media URLs, local file paths,
per-stage errors β lives in thread_manifest.json
(JSON Schema).
New in v3.2: thread.walker_slot names the discovery slot that served the
walk (unrollnow, threadreaderapp, or none when degraded to root-only).
python3 xthread-agent.py "<status_url>" --json --quiet # run
cat <out>/thread_manifest.json # inspectExit code 0 = at least one post harvested. 1 = nothing. 2 = usage error.
Logs are always on stderr; --json results are always on stdout, so
the two can be piped safely.
agent.md is the complete operating manual β an AI agent reading only that
file can run this tool end-to-end without asking a human a single question.
agents.md defines the role prompts each internal stage must conform to.
demo.py is a minimal runnable example of programmatic consumption.
For MCP-compatible agent hosts (Claude Desktop, Zed, custom hosts),
mcp_server.py exposes the harvester as tools over the standard stdio
transport β still stdlib-only, still no login:
python3 mcp_server.py # speaks MCP on stdin/stdout; logs on stderr| Tool | What it does |
|---|---|
extract_thread |
Full harvest: thread reconstruction + media downloads; returns the envelope |
lookup_status |
Metadata-only (--no-download equivalent): text, authors, timestamps, media URLs |
read_manifest |
Returns an existing thread_manifest.json verbatim (refuses any other filename) |
get_schema |
Returns the JSON Schema for the envelope contract |
The server never reimplements the pipeline β each tool call shells out to
xthread-agent.py as a subprocess with a hard timeout, so the CLI contract,
schema, and politeness rules stay the single source of truth. Register it in
your MCP client config as a stdio command, e.g.
{"command": "python3", "args": ["/path/to/mcp_server.py"]}.
| File | Purpose |
|---|---|
agent.md |
Agent entry point β how to run, the JSON contract, decision tree |
agents.md |
Role prompts for the internal agent roster (the spec) |
mcp_server.py |
MCP wrapper β exposes the harvester as MCP tools over stdio (stdlib-only) |
demo.py |
Minimal end-to-end consumption example |
schema/thread-result.schema.json |
JSON Schema (draft-07) for the manifest envelope |
docs/research-blog.md |
Research chronicle: the X lockdown and the bypass architecture |
docs/endpoint-matrix.md |
Living reference: every endpoint, its status, its failure signature |
PROJECT_CONTEXT.md |
Why this exists, design decisions, fragile parts β for future maintainers |
RELEASE_NOTES.md |
Version history |
PUBLISHING.md |
The exact PyPI publishing checklist (Trusted Publishing) |
CONTRIBUTING.md |
How to contribute |
| Portfolio site | This project's GitHub Pages home (source: site/) |
- No login. No cookies. No OAuth. No browser. Public content only.
- No GUI. No interactive prompts. 100% non-interactive CLI.
- No LLM at runtime. Deterministic state machine.
- stdlib only. Single file, no pip installs, Python 3.9+.
- Logs on stderr, data on stdout. Always pipe-safe.
- Files stay under the output directory. Media URLs are restricted to X's CDN hosts; remote IDs are validated before use in filenames.
- Python 3.9+ (the tool itself). Tests also run on stdlib
unittest. - Outbound HTTPS to
unrollnow.com,threadreaderapp.com(fallback walker),api.fxtwitter.com,api.vxtwitter.com(fallback only),t.co(only for shortlink inputs),video.twimg.com,pbs.twimg.com.
# from PyPI (recommended β console script + importable module, zero deps)
pip install xthread-agent
xthread-agent "https://x.com/<user>/status/<id>" --json --quiet
# or the original zero-install way: one file, nothing to install
python3 xthread-agent.py "https://x.com/<user>/status/<id>" --json --quietLive on PyPI since 2026-09-27 (xthread-agent 3.2.0): the wheel carries
xthread_agent/__init__.py, a byte-identical copy of xthread-agent.py
enforced by a drift-guard test β the single-file design constraint
(PROJECT_CONTEXT.md Β§5.1) is intact. Future releases publish automatically
when a v* tag is pushed (test β build β smoke β GitHub Release β PyPI).
python3 -m unittest discover -s tests -p "test_*.py" -v # 135 offline tests, ~1sThe suite covers URL normalization (including t.co expansion), both walker
slots, both decoders (including the vxtwitter fallback and HTTP
404/451/429 paths), chain reconstruction, payload mapping, atomic downloads,
the envelope contract, CLI behavior, the MCP wrapper (protocol framing,
tools, error paths), and package/source sync β all against synthetic
fixtures (no network). CI (.github/workflows/ci.yml) runs the same suite on
Python 3.9β3.13 on every push and PR; live endpoints are deliberately never
probed from CI. For a real end-to-end run, use demo.py against any public
status URL of your choice; keep the request rate polite and test against
content you control where possible.
- Videos: downloaded as mp4 at the best available quality (the decoder's
primary URL, or the highest-bitrate mp4 variant when the primary is HLS).
Video metadata includes duration, dimensions, format, and the full variant
list. Videos that genuinely have no mp4 variant are reported with
downloadable: falseand areason(hls_only) β never silently dropped. - Photos: downloaded at source resolution with alt text and dimensions.
- Posters: every downloaded video gets its poster frame.
- Quoted posts: recorded with author, text, timestamp, and media URLs (quoted media is not downloaded β it belongs to the quoted post, and this keeps runs polite and output directories honest).
"No login" here means: the tool never presents credentials, cookies, session
tokens, or a browser fingerprint, and it never touches x.com itself. It
reads three public surfaces that any visitor can reach without
authentication: an unrolling service (thread candidates), two open
link-decoder workers (per-tweet JSON), and X's own media CDN (bytes). This
works because X's authentication wall guards discovery APIs, while the CDN
serves whatever URL a decoder already resolved. It does not mean the
access is officially supported by X or guaranteed to last β see
Limitations and the endpoint matrix.
- Dependency on free third-party services. If UnrollNow or FixTweet change
or gate datacenter IPs, functionality degrades (root-only harvest, or
empty). The fallback decoder slot mitigates but does not eliminate this. - Retweet URLs resolve to the original post. For a retweet URL the
decoder returns the original tweet's payload, so
posts[0].idis the original post ID whilerequest.status_id/thread.root_status_idstay the requested ID. The harvested content is exactly what that URL publicly shows (the retweeted post). - Linear self-reply chains only. Threads where the author branches into multiple replies get the first-seen branch; cross-author reply trees are out of scope by design.
- Deleted/protected/age-restricted content fails closed (
status: empty) β nothing here bypasses access controls. - HLS-only videos (rare) are reported but not downloaded.
- A slow-drip CDN transfer is cut off at the transfer deadline; the retry restarts the file rather than resuming by byte range.
- UnrollNow outages degrade the walk to root-only; the manifest records
this (
degraded_to_root_only: true) so callers can tell. - One run at a time per output directory. Concurrent runs can interleave.
- "Publicly accessible" β "officially supported." Every technique here depends on surfaces X does not officially expose to tools.
This tool reaches only publicly accessible content and depends on third-party public services. Using it may be subject to β and is your responsibility under β X's Terms of Service, the terms of the third-party services involved, applicable copyright law, data-protection law, and any other rules that apply to you or your jurisdiction. Nothing in this repository grants any license to the content harvested: posts and media belong to their authors and rights holders. Do not use the tool to harass, dox, or invade privacy; do not republish harvested media commercially; archive responsibly and credit creators. If your use case requires guaranteed, sanctioned access, use the official X API.
For personal archiving and research. Respect creators: whoever curated the thread you harvest added value; the underlying media belongs to its original rights holders. Don't repost harvested media commercially. Don't use this tool to invade anyone's privacy β it only reaches public content that any visitor can see.
Does it really need no login?
It never presents credentials, cookies, session tokens, or a browser
fingerprint, and never touches x.com itself. It reads three public surfaces:
an unrolling service, two open link-decoder workers, and X's own media CDN.
That works because X's auth wall guards discovery APIs while the CDN serves
bytes to anyone holding a resolved URL. It does not mean X sanctions or
guarantees this access.
Is this allowed by X's Terms of Service? Reaching publicly accessible content through third-party surfaces may be subject to X's Terms of Service, the third parties' terms, copyright and data-protection law β your responsibility, your jurisdiction. The tool reaches only what any visitor can see, fails closed on protected content, and grants no license to harvested media. If you need guaranteed, sanctioned access, use the official X API.
What happens when an upstream service dies?
Nothing explodes: discovery is dual-homed (UnrollNow β ThreadReaderApp),
decoding has a fallback slot (vxtwitter), and every failure lands in the
envelope's errors[] with a stable code. Worst case is a root-only harvest or
an honest status: empty. Slots are designed to be replaced behind their
contracts.
Why does a retweet URL resolve to the original post?
The decoder returns the original tweet's payload for a retweet URL, so the
harvested content is exactly what that URL publicly shows (the retweeted
post). posts[0].id is the original post ID; request.status_id and
thread.root_status_id stay the ID you asked about, so nothing is hidden.
Why stdlib-only Python? Deployment story: copy one file into a bare sandbox and run it. No pip, no node, no ffmpeg β anywhere Python 3.9+ exists, the agent works. The PyPI package exists for convenience and is byte-identical to the single file, enforced by a drift-guard test.
MIT. See LICENSE.
- FixTweet / FxTwitter β the public worker that decodes tweet payloads
- vxtwitter β the fallback decoder slot
- UnrollNow β public thread unrolling service
- ytagent β sibling project; the "document which doors remain open" philosophy started there