# x.md docs > Read X posts, search, and profiles as Markdown or JSON. Start with one request. # Start reading X Source: https://mdfromx.com/docs x.md turns a public X URL into Markdown your agent can read. The rule is one sentence: replace `x.com` with `mdfromx.com` and keep the rest of the URL. Nothing to install, no key, no login. 1. **Tell your agent the rule** Paste this into the chat, or into `AGENTS.md`, `CLAUDE.md`, or whatever instruction file your agent reads: ```text wrap To read an X (Twitter) post, thread, or profile, swap x.com for mdfromx.com in the URL and fetch it. For search, fetch https://mdfromx.com/search?q=. The response is Markdown. ``` Any agent that can fetch a URL now reads X. No further setup. 2. **Ask it something** ```text wrap Summarize https://x.com/trq212/status/2052809885763747935 and its replies. ``` The agent fetches `https://mdfromx.com/trq212/status/2052809885763747935` and gets the post text, source links, and thread context back as Markdown. Add `?thread=off` to that URL to read only the requested post. 3. **Give it tools instead (optional)** If your agent speaks MCP, connect it once and skip the prompt entirely. Claude Code: ```bash claude mcp add --transport http x-md https://mdfromx.com/mcp ``` Codex, Cursor, OpenCode, Antigravity, and Amp are on [the MCP page](/docs/mcp#connect). Skills-aware agents can [install the `browse-x` skill](/docs/agents) instead. ## Reading it yourself The same host swap works in a browser or a script. The API is documented in full under [Posts and threads](/docs/posts), [Search](/docs/search), and [Profiles](/docs/profiles); the short version: ```bash # A post, as Markdown curl -sS 'https://mdfromx.com/trq212/status/2052809885763747935' # Search, as JSON with fields instead of prose curl -sS 'https://mdfromx.com/search?q=typescript&feed=latest&format=json' # An account's last year, replies included, as raw JSON in seconds curl -sS 'https://mdfromx.com/paulg/posts?since=2025-09-01&max_posts=2000' ``` Lists go up to **100 per page**; pick Latest, Top, Photos, Videos, or Users with `feed`. JSON responses include the rendered `markdown` too. [See response formats](/docs/responses). For a whole account history use the [bulk import](/docs/bulk-import). ## When to use x.md Reach for x.md when the job is reading one piece of public X content right now: - An agent needs the text of a public X post, thread, or X Article and cannot run a browser or log into X. - You are resolving an `x.com`, `twitter.com`, or `t.co` link found in a document, issue, changelog, or chat log. - You want a public profile's bio and latest posts without holding X credentials. - You are onboarding a user and need their last 500–2000 posts, replies included, before they finish reading the welcome screen. - You are checking who a public account follows, or who follows it. - You need X search results as structured data rather than a rendered timeline. - You are saving a post into notes or a vault — `?format=obsidian` emits YAML frontmatter. One plain `GET`, no key, no OAuth. Send `Accept: text/markdown` for Markdown or `Accept: application/json` for JSON. ## When not to use it - **Anything that writes.** x.md never posts, replies, follows, likes, bookmarks, or sends direct messages, and it accepts no X credentials. - **Private, protected, suspended, or deleted content.** Never available; no parameter unlocks it. - **X Lists, direct messages, notifications, the home timeline, analytics.** Not supported. - **Firehose or multi-account collection.** The service is rate limited and cached for interactive use. One account's history is different: [import it](/docs/bulk-import) instead of paging. - **Guaranteed completeness.** Results come from public upstream providers and can be delayed or truncated. Check `warnings` rather than inferring. - **Anything needing an SLA.** x.md is a free, best-effort, MIT-licensed project. ## For agents | Surface | Where | | --- | --- | | OpenAPI 3.1 description | [`/openapi.json`](https://mdfromx.com/openapi.json) | | MCP server | `https://mdfromx.com/mcp` — see [the MCP page](/docs/mcp) | | Documentation index for LLMs | [`/llms.txt`](https://mdfromx.com/llms.txt) | | Versioned machine routes | [`/api/v1/*`](/docs/versioning) | | Error codes | [Catalogue](/docs/errors) | Any documentation page can be fetched as Markdown: append `.md` to its URL, or send `Accept: text/markdown`. ## Next steps **[Posts and threads](/docs/posts)** Read a post, follow a thread, or include the surrounding conversation. **[Search](/docs/search)** Find posts, photos, videos, or people with the same feeds as X. **[Profiles](/docs/profiles)** Read an account and its followers or following. **[Import a post history](/docs/bulk-import)** Thousands of posts in one request, replies included, walked in parallel and remembered. **[For agents](/docs/agents)** The MCP server, the skill, and what an unattended client should handle. **[Pagination](/docs/pagination)** Fetch the next page with a cursor. **[Error catalogue](/docs/errors)** Every machine code, what it means, and how to recover. **[Versioning](/docs/versioning)** What `/api/v1` promises and how a deprecation is announced. **[Self-hosting](/docs/self-hosting)** Run the service from your own Vercel project. --- # x.md for agents Source: https://mdfromx.com/docs/agents ## When to use x.md Reach for x.md when the job is reading one piece of public X content right now: - The text of a public X post, thread, or X Article, without a browser or an X login. - Resolving an `x.com`, `twitter.com`, or `t.co` link found in a document, issue, changelog, or chat log. Swap the host for `mdfromx.com` and keep the path. - A public profile's bio and latest original posts, without holding X credentials. - Who a public account follows, or who follows it. - X search results as structured data rather than a rendered timeline. - Saving a post into notes or a vault — `?format=obsidian` emits YAML frontmatter. ## When not to use it - **Anything that writes.** x.md never posts, replies, follows, likes, bookmarks, or sends direct messages, and it accepts no X credentials. A write attempt is `405`. - **Private, protected, suspended, or deleted content.** Never available; no parameter unlocks it. - **X Lists, direct messages, notifications, the home timeline, analytics.** Not supported. - **Bulk or firehose collection.** Rate limited and cached for interactive use; a page walk stops at ten pages of twenty items. - **Guaranteed completeness or liveness.** Upstream providers can be delayed or partial. Check `warnings` rather than inferring. - **Anything needing an SLA or support commitments.** Free, best effort, MIT-licensed. ## Three ways to call it **Plain HTTP** No key, no OAuth, no SDK. One `GET`. ```bash curl -sS -H 'Accept: text/markdown' \ 'https://mdfromx.com/trq212/status/2052809885763747935' ``` Ask for `Accept: application/json` (or `?format=json`) when you need fields. Every route, parameter, and response schema is in [`/openapi.json`](https://mdfromx.com/openapi.json); the stable machine paths are [`/api/v1/*`](/docs/versioning). **MCP** The same reads as Model Context Protocol tools, over Streamable HTTP, no key required. ```text https://mdfromx.com/mcp ``` [Connect an agent to the MCP server](/docs/mcp). **Agent skill** A small, read-only skill for coding agents. ```bash bunx skills add pc-style/x-md -g -y --skill browse-x ``` The skill calls the hosted service. It does not need a local x.md checkout, X login, or provider key. ## Use the browse-x CLI From a checkout, run the included script: ```bash bun skills/browse-x/scripts/browse-x.ts search 'typescript' --feed latest bun skills/browse-x/scripts/browse-x.ts search 'design' --feed photos --limit 10 bun skills/browse-x/scripts/browse-x.ts search 'vercel' --feed users --json bun skills/browse-x/scripts/browse-x.ts profile vercel --full bun skills/browse-x/scripts/browse-x.ts status 'https://x.com/trq212/status/2052809885763747935' --thread off ``` | Option | Effect | | --- | --- | | `--json` | Structured response | | `--full` | Expanded metadata | | `--limit 1-20` | Browse result count | | `--cursor TOKEN` | Continue a browse result | | `--page 1-10` | Walk to a numbered page | | `--headers` | Print response headers | | `--nocache` | Bypass application caching | The script exits with `2` for invalid arguments and `1` for network or API errors, and with `3` after printing `Retry-After` when a rate limit rejects the call. ## What an unattended client should handle | Signal | Action | | --- | --- | | `RateLimit` / `RateLimit-Remaining` | Pace against the tightest policy. [Limits](/docs/reliability#rate-limits). | | `429` + `Retry-After` | Sleep that many seconds, then retry once. Do not poll. | | `Deprecation` / `Sunset` | Follow `Link; rel="successor-version"`. [Versioning](/docs/versioning). | | `code` in a problem body | Branch on it, never on `detail`. [Error catalogue](/docs/errors). | | `nextCursor` | Continue; stop only when it is absent. [Pagination](/docs/pagination). | | `degraded` / `X-Search-Degraded` | Results are web-indexed snippets, not a live timeline. | ## Machine-readable entry points | Document | Purpose | | --- | --- | | [`/llms.txt`](https://mdfromx.com/llms.txt) | What x.md is for, when to use it, and every route, in one text file | | [`/openapi.json`](https://mdfromx.com/openapi.json) | OpenAPI 3.1: parameters, response schemas, error bodies, quotas, lifecycle | | [`/api`](https://mdfromx.com/api) | A small JSON index for an agent that has only the domain | | `https://mdfromx.com/mcp` | MCP server over Streamable HTTP — see [the MCP page](/docs/mcp) | | [`/.well-known/api-catalog`](https://mdfromx.com/.well-known/api-catalog) | RFC 9727 linkset | | [`/.well-known/ard.json`](https://mdfromx.com/.well-known/ard.json) | Agentic Resource Discovery catalog | ## Read docs as Markdown Each documentation page has a **Copy as Markdown** action. Append `.md` to any docs URL, send `Accept: text/markdown`, or add `?mode=agent`. The [documentation manifest](https://mdfromx.com/llms.txt) lists every page. :::warning Requested URLs and queries are sent to the hosted service and its data providers. Keep secrets out of queries and URLs. ::: --- # Import a post history Source: https://mdfromx.com/docs/bulk-import One request, one account, as much history as you ask for. `GET /{handle}/posts` walks X's timeline **in parallel**, keeps every field upstream sends, and remembers what it collected so the next import only fetches what is new. Set `X_MD_API_KEY` to your issued API key (see [Authentication](#authentication)). ```bash curl -sS -H "Authorization: Bearer $X_MD_API_KEY" \ 'https://mdfromx.com/paulg/posts?since=2025-09-01&max_posts=2000' ``` For a complete interaction graph, combine this route with [the circle integration guide](/docs/x-circle). Imports contain the account’s own posts, replies and reposts; incoming mentions use search. ## Authentication Set `X_MD_API_KEY` to the API key you were issued, then send it in the `Authorization: Bearer` header shown in each example. This is an x.md API key, not an X account token. Do not put the key in the URL; query-string keys and custom headers are not accepted. ```bash export X_MD_API_KEY='your-api-key' ``` Deployments that require authentication reject requests without a valid key with `401 unauthorized`. On deployments that allow public access, the header is optional and a valid key uses your key's import allowance. An invalid or disabled key returns `401 invalid_key`. Check the response's `X-Api-Key-Status` header for `valid` to confirm the key was accepted. The same header applies to JSON imports, NDJSON streams and `index=true` requests on both URL forms. See [authentication details](/auth.md) for how keys are issued and revoked. ## Quickstart 1. **Pick a range** Three shapes cover an onboarding slider: **Last 3 months** ```bash curl -sS -H "Authorization: Bearer $X_MD_API_KEY" \ "https://mdfromx.com/api/v1/profiles/paulg/posts?since=$(date -u -d '-90 days' +%F)&max_posts=2000" ``` **Last year** ```bash curl -sS -H "Authorization: Bearer $X_MD_API_KEY" \ "https://mdfromx.com/api/v1/profiles/paulg/posts?since=$(date -u -d '-1 year' +%F)&max_posts=5000" ``` **Everything available** ```bash curl -sS -H "Authorization: Bearer $X_MD_API_KEY" \ 'https://mdfromx.com/api/v1/profiles/paulg/posts?max_posts=5000' ``` Without `since` the walk goes as far back as X serves — roughly the 3200 most recent timeline entries — and `meta.floor_reached` is `true` when it got there. 2. **Read the body** ```json { "profile": { "screen_name": "paulg", "name": "Paul Graham", "followers": 2081394, "…": "…" }, "posts": [ { "id": "2097693527450816692", "text": "…", "created_at": "Wed Sep 09 14:28:04 +0000 2026", "author": { "screen_name": "paulg", "…": "…" }, "replying_to": { "screen_name": "tlbtlbtlb", "status": "2097690000000000000" }, "likes": 412, "replies": 37, "retweets": 21, "quotes": 4, "views": 88120, "bookmarks": 63, "url": "https://x.com/paulg/status/2097693527450816692" } ], "meta": { "count": 1379, "since": "2025-09-01T00:00:00.000Z", "until": "2026-09-09T19:30:47.788Z", "oldest": "2026-06-19T08:02:11.000Z", "newest": "2026-09-09T14:28:04.000Z", "truncated": false, "floor_reached": false, "windows": 46, "pages": 130, "duration_ms": 7822, "archive": { "count": 1379, "served": 0, "added": 1379, "walked": ["refresh"], "…": "…" } } } ``` `posts` is newest first and raw: every field FxTwitter returns is passed through untouched — `id`, `text`, `created_at`, `author`, `replying_to`, `quote`, `reposted_by`, `media`, `poll`, `likes`, `replies`, `retweets`, `quotes`, `views`, `bookmarks`, `lang`, `source`, `url`. 3. **Stream it if you render as you go** ```bash curl -sN -H "Authorization: Bearer $X_MD_API_KEY" \ 'https://mdfromx.com/api/v1/profiles/paulg/posts?since=2026-06-01&format=ndjson' ``` ```text {"post":{"id":"2097693527450816692","text":"…"}} {"post":{"id":"2097379186289607139","text":"…"}} … {"meta":{"count":787,"…":"…"},"profile":{"…":"…"}} ``` One JSON object per line. Posts arrive newest first; the trailing `meta` line means the walk finished. If it fails part-way the last line is `{"error": }`. The selected posts are emitted after the walk completes, so JSON and NDJSON contain the same set. Headers are sent immediately. Do not assume the first post arrives immediately. ## Parameters | Parameter | Default | Behavior | | --- | --- | --- | | `since` | — | Oldest post to include. Omit to go as far back as upstream allows. | | `until` | now | Newest post to include, inclusive. | | `max_posts` | `500` | Most posts to return, newest first, maximum **5000**. `meta.truncated` is `true` when the range held more; continue with `until=` and drop the one boundary post you already have. | | `with_replies` | `true` | Include the account's replies. Replies are where tone, opinions and short-form style live; that is why they are on by default here and off on the paged profile read. | | `with_reposts` | `true` | Include reposts. A repost is returned as the **original** post with `reposted_by` set, so it carries the original's `id` and `created_at`. | | `only_replies` | `false` | Replies only. | | `concurrency` | `16` | Parallel upstream chains, maximum **32** on the public contract. Higher values are not a speed guarantee. Queue requests when `503 import_busy` is returned. | | `format` | `json` | `json` or `ndjson`. | | `refresh` | `false` | Re-walk the whole range instead of serving what the archive already holds. Use it when you need current engagement counts. | | `index` | `false` | `true` answers with the archive index only — no walk, no import quota spent. | Dates accept `2026-01-01`, a full ISO datetime, or a unix timestamp in seconds or milliseconds. Both surfaces take the same parameters: `/{handle}/posts` and `/api/v1/profiles/{handle}/posts`. ## The archive: collect once, reuse forever Every import stores what it walked, per account. The next import of that account reads the archive and only walks the gaps: - **top-up** — newer than what is covered (a user comes back a week later: one or two pages, not the whole range) - **backfill** — older than what is covered (the slider moves from 3 months to a year: only the extra nine months are walked) - nothing at all — the same request within a minute, or a range the archive already covers down to X's floor Ask what is held without spending anything: ```bash curl -sS -H "Authorization: Bearer $X_MD_API_KEY" \ 'https://mdfromx.com/api/v1/profiles/paulg/posts?index=true' ``` ```json { "handle": "paulg", "archive": { "handle": "paulg", "count": 2820, "oldest": "2026-01-29T15:32:56.000Z", "newest": "2026-09-09T14:45:53.000Z", "covered_since": "2026-01-01T00:00:00.000Z", "covered_until": "2026-09-09T22:48:23.000Z", "updated_at": "2026-09-09T22:48:23.000Z", "floor_reached": true }, "persistent": true } ``` Every import response carries the same block as `meta.archive`, plus `served` (posts that came from the archive), `added` (entries fetched and written by this request, including updates and overlap; not newly unique archive IDs) and `walked` (which gaps were walked). On the hosted service the archive lives in a shared Redis and survives deploys; `persistent: false` means an in-memory fallback that lasts for the life of the process. :::note Archived posts keep the engagement counts from the walk that stored them. Text and metadata do not change; likes and views do. Pass `refresh=true` when those numbers matter. ::: ## Coverage and completion `count` is the number of posts in this response. `archive.count` is the total number of unique post IDs stored for the account; it can be larger than the response. `archive.served` counts returned IDs not fetched again by this request. `archive.added` counts entries fetched by its walks, including overlap and existing IDs updated. These counters do not necessarily add up to `count`. `truncated=true` means the limit or an incomplete walk prevented full coverage. Follow `next_until` when present, preserving the same `since` and deduplicating IDs. A `warnings` array requires a retry of the **same range**, not merely pagination past the gap. `floor_reached=true` means the walk exhausted accessible history, including reaching the account’s creation date. It does not prove all posts ever published are available. Both flags may be false when the requested `since` was reached without exhaustion. Reposts carry the original post’s time, not the repost event’s time. Date-filtered archive responses use the original timestamp. An exact repost-time history cannot be reconstructed from this data. `next_until` paginates original-post time to the millisecond, taken from the post ID, so posts sharing a second are not lost between pages; send it back unchanged. Use profile `nextCursor` pagination when timeline position and repost coverage matter. An archive-only response may omit `profile`; use `include_posts=false` on the profile route for identity and avatar enrichment. Filtered imports may need to inspect up to 5000 unfiltered entries to fill the requested count. They can return fewer matching posts with `truncated=true`. Previously collected posts retain their engagement values until refreshed. The archive is a collected snapshot, not proof that an old post remains public or unchanged. ## Limits and recovery - **Metering:** no key is needed. Every request spends one import, continuations included. Without a key the limit is 3 imports per hour (`import-ip`) **and** 5 per day (`import-ip-day`) per IP. Both windows are enforced, and a request either one refuses spends neither. An issued key gets 60 per 15 minutes by default, and per-key overrides are advertised in `RateLimit-Policy`. A refusal is `429 rate_limited`, with `Retry-After` and the exhausted policy at `r=0` in `RateLimit`. The MCP tool `x_md_import_posts` spends the same allowances. `index=true` and HEAD spend only the general request allowance. - **Depth:** X exposes a limited recent timeline, often around 3200 entries. Counts include replies, reposts and conversation context upstream; the number of returned own posts may be smaller. - **Concurrency:** the hosted service admits three fresh walks at once across instances. Each caller (an address or an API key) holds at most one, and one is held for API-key callers. Excess demand returns `503 import_busy` with `Retry-After: 5`. Busy rejections refund the import allowance; the ordinary request limit still applies. Archive-only reads do not use a fresh-walk slot. This is an admission limit, not a throughput guarantee. - **Time:** the route allows 120 seconds. Actual duration depends on activity, range, cache state and provider availability. Give JSON imports a 125-second client timeout. NDJSON can end with a problem line despite HTTP 200. - **Retry:** on 429, `import_busy`, `upstream_unavailable` or `upstream_rate_limited`, wait at least `Retry-After`, then add jitter. Retry the identical request at most twice before reporting a recoverable failure. Do not reduce the specified wait or retry 400/401 errors. - **Parameters:** booleans accept only `true`, `false`, `1`, or `0`; invalid counts and conflicting aliases return 400. `only_replies=true` conflicts with `with_replies=false`. A successful HTTP response is not a completeness guarantee: inspect `meta.truncated`, `meta.warnings`, `meta.floor_reached`, and the returned date bounds before labeling an import complete. --- # Error catalogue Source: https://mdfromx.com/docs/errors Every failure answers with a structured document, never a stack trace or a generic HTML error page. It carries a stable machine `code`, a human `detail`, and a `resolution` telling the caller what to do next. ## The shape Errors follow [RFC 9457 problem details](https://www.rfc-editor.org/rfc/rfc9457), served as `application/problem+json`: ```json { "type": "https://mdfromx.com/docs/reliability#rate-limited", "title": "Rate limit exceeded", "status": 429, "detail": "Too many live search lookups from this IP. Slow down and retry shortly.", "instance": "https://mdfromx.com/api/v1/search?q=vercel", "code": "rate_limited", "resolution": "Wait the number of seconds in the `Retry-After` header, then retry. Cached responses do not count against the allowance.", "documentation_url": "https://mdfromx.com/docs/reliability#errors", "error": "Too many live search lookups from this IP. Slow down and retry shortly.", "retry_after": 43 } ``` | Field | Always present | Meaning | | --- | --- | --- | | `type` | yes | Stable URI identifying the problem type. It dereferences to documentation; it is not a lookup key. | | `title` | yes | Short, stable summary of the problem type. Does not change between occurrences. | | `status` | yes | The HTTP status code, repeated in the body so a logged payload is self-contained. | | `detail` | yes | What went wrong with *this* request. Written for a human; can change between occurrences. | | `instance` | yes | Absolute URL of the request that failed. | | `code` | yes | The machine identifier. **Switch on this.** | | `resolution` | yes | What the caller should do next, in one sentence. | | `documentation_url` | yes | Where the codes are documented. | | `error` | yes | Alias of `detail`, kept for clients written against the older `{ error, code }` shape. | | `retry_after` | on retryable errors with a delay | Seconds to wait. It mirrors `Retry-After` when headers can still be written; a terminal NDJSON error carries it only in the body. | | `streamed_posts` | on an NDJSON terminal error | Number of post lines already sent before the error line. | | `links` | sometimes | Related routes, each `{ label, href }`. A `404` on an unknown path uses this to suggest where to go instead. | Branch on `status` for the class of failure and on `code` for the specific one. Do not parse `detail`, and do not compare `title` strings. ### Media type The body is `application/problem+json; charset=utf-8`. A caller whose `Accept` header names `application/json` — and not `application/problem+json` — gets the same bytes labelled `application/json; charset=utf-8` instead, so a strict client-side content-type check still passes. ### Headers on an error | Header | On | Meaning | | --- | --- | --- | | `Cache-Control: no-store` | every problem response | Errors are never stored, by us or by a CDN. The recoverable `404` document sends `public, max-age=0, must-revalidate` instead, so a cache must revalidate it before reusing it. | | `Link: …; rel="help"` | every problem response | This documentation. | | `Link: …; rel="service-desc"` | every error | [`/openapi.json`](https://mdfromx.com/openapi.json). | | `Retry-After` | `429`, `503`, and partial import `502` | Seconds to wait. Takes precedence over the `RateLimit` reset if the two disagree. | | `Allow` | `405` | The methods this route accepts. | | `Deprecation`, `Sunset` | errors from a deprecated route | See [versioning](/docs/versioning). | The negotiated `404` is the exception: instead of `rel="help"` it carries the site discovery links — `alternate`, `sitemap`, `describedby`, `service-desc`, and `api-catalog` — and puts the recovery routes in the body's `links` array. ## Request errors — `400` The request never reached a provider. Fix the request; retrying it unchanged will fail identically. | Code | Title | Meaning | What to do | | --- | --- | --- | --- | | `missing_url` | Missing url parameter | A post read arrived with no post to read. | Add `?url=`, or call `/{handle}/status/{id}` directly. | | `invalid_url` | Invalid X status URL | `url` was not parseable as a URL. | Pass a public x.com or twitter.com status URL in `url`, for example `?url=https://x.com/jack/status/20`. | | `unsupported_host` | Unsupported host | The URL parsed, but points somewhere other than X. | Only x.com and twitter.com status URLs are supported. Rewrite the host and retry. | | `invalid_path` | Not a status permalink | An X URL that is not a status permalink, such as a profile or a search page. | Use a permalink shaped like `https://x.com/{handle}/status/{id}`. | | `invalid_params` | Invalid handle or status id | The `handle`/`id` pair was missing or malformed. | Provide `handle` (1-15 word characters) and a numeric `id`. | | `invalid_option` | Invalid query option | A query option was malformed or out of range, such as an unparseable date, `since` not earlier than `until`, or a count above its maximum. | Fix the parameter named in `detail`. Dates are ISO dates, ISO datetimes, or unix timestamps; `since` must be earlier than `until`; counts are positive integers within the documented maximum. | | `invalid_handle` | Invalid X handle | The handle is not a possible X handle. | Handles are 1-15 characters of letters, digits, or underscores, with no leading `@`. | | `invalid_resource` | Unsupported browse resource | `resource` on the legacy `/api/browse` alias was not one of the four. | Use `resource=profile`, `search`, `followers`, or `following`. | | `invalid_format` | Unsupported format | `format` was not a format this route can render. | Use `format=markdown`, `format=obsidian`, or `format=json`. Browse accepts markdown or json. | | `invalid_thread` | Invalid thread parameter | `thread` was neither a keyword nor a count in range. | Use `thread=off`, `full`, `conversation`, or a number from 2 to 100. | | `invalid_userinfo` | Invalid userinfo parameter | `userinfo` was not a known level. | Use `userinfo=off`, `author`, or `all`. | | `invalid_context` | Invalid context parameter | `context` was not a known mode. | Use `context=full` or `context=thread`. | | `invalid_replies` | Invalid replies parameter | `replies` was not a known ordering. | Use `replies=top`, `recent`, or `off`. | | `invalid_mode` | Invalid mode parameter | Reserved; no route emits it today. `?mode=agent` is the only view mode, and pages ignore other values. | See the parameter tables at [/docs/responses](/docs/responses). | | `missing_query` | Missing search query | A search arrived with no query, or an empty one. | Add `?q=`, for example `/search?q=vercel`. | | `invalid_body` | Invalid JSON body | A request body was sent that is not well-formed JSON. The public read routes take no body. | Send a well-formed JSON object, or omit the body entirely. | ## Authentication — `401` The public read API needs no credentials. These appear only when a credential was offered and rejected, or a private route was reached. | Code | Title | Meaning | What to do | | --- | --- | --- | --- | | `invalid_key` | Invalid or disabled API key | An `Authorization: Bearer` key was present but unknown or revoked. | Remove the Authorization header to call anonymously, or present a valid `Authorization: Bearer `. | | `unauthorized` | Unauthorized | A non-public route, such as the admin API. | This route is private. The public read-only API needs no credentials; see [/openapi.json](https://mdfromx.com/openapi.json). | ## Not found — `404` | Code | Title | Meaning | What to do | | --- | --- | --- | --- | | `not_found` | Not found | The post or profile is confirmed missing or inaccessible. An ambiguous provider miss is never enough to establish this. | Confirm the post or profile is public and still exists on x.com, then retry. Deleted and protected content is never available. | | `route_not_found` | API route not found | The path is not a route on this service. | Discover the public API through [/api](https://mdfromx.com/api), [/openapi.json](https://mdfromx.com/openapi.json), or [/.well-known/api-catalog](https://mdfromx.com/.well-known/api-catalog). | A `404` is a routing dead end for an agent, so both forms carry `links` pointing at the routes that do exist. Follow them rather than guessing at another path. The `404` document is the one error with more than one representation: it is negotiated from `Accept`, so a browser gets HTML, an agent asking for `text/markdown` gets the same recovery links as prose, and `application/json` (or `?format=json`) gets the problem document above. ## Method and negotiation — `405`, `406` | Code | Title | Meaning | What to do | | --- | --- | --- | --- | | `method_not_allowed` | Method not allowed | Anything other than `GET`, `HEAD`, or `OPTIONS` on a read route. x.md never writes to X. | Use GET, HEAD, or OPTIONS; the `Allow` response header lists what this route accepts. | | `not_acceptable` | No acceptable representation | The `Accept` header excluded every representation this route can produce. On the site pages a `406` answers as `text/plain` listing what is on offer, since there is by definition no acceptable JSON to send. | Request `text/markdown`, `application/json`, or `text/html`, or omit the Accept header. | ## Limits — `429` | Code | Title | Meaning | What to do | | --- | --- | --- | --- | | `rate_limited` | Rate limit exceeded | A quota is exhausted. The response says which one in its `RateLimit` header, at `r=0`. | Wait the number of seconds in the `Retry-After` header, then retry. Cached responses do not count against the allowance. For scripted or sustained search, ask for an API key: https://mdfromx.com/auth.md. | `Retry-After` and `retry_after` are the same number of seconds. Sleep for it; do not poll. The quotas themselves are in [rate limits](/docs/reliability#rate-limits). ## Service and upstream — `5xx` x.md reads third-party providers that fail independently of it, so these are usually transient. Retry with exponential backoff and jitter; do not retry in a tight loop. | Code | Title | Meaning | What to do | | --- | --- | --- | --- | | `internal_error` | Unexpected error | A bug or an unhandled condition in x.md itself. | Retry with exponential backoff. If it persists, open an issue at [github.com/pc-style/x-md/issues](https://github.com/pc-style/x-md/issues). | | `upstream_error` | Upstream provider error | A data provider failed or answered with something unusable. | Retry with backoff. x.md reads a third-party provider that can fail independently. | | `upstream_unavailable` | Upstream profile unavailable | The provider failed, or returned a profile miss such as `User not found` that x.md could not confirm with a second source. | Wait for `Retry-After`, then retry. Do not treat the profile as deleted: an account confirmed missing answers 404 `not_found`. | | `upstream_rate_limited` | Upstream provider is rate limiting x.md | The provider throttled x.md itself, not you. | Wait the number of seconds in `Retry-After`, then retry. Bulk imports back off per page automatically; lower `concurrency` if this repeats. | | `import_busy` | Fresh import capacity is busy | A fresh bulk import could not be admitted: an import for this handle is already running, the shared or per-caller import limit is reached, or the shared store is unavailable. | Wait at least Retry-After seconds, add jitter, then retry the same request. Queue fresh imports rather than increasing concurrency. | | `partial_upstream_failure` | Partial upstream failure | A timeline page stayed unavailable after retries and the stream could not finish. `streamed_posts` counts lines already delivered. | Wait for the error body's `retry_after`, then retry the import and deduplicate by post ID. | | `search_unavailable` | Search temporarily unavailable | Live search has no healthy session. Photos, Videos, and Users have no fallback. | Wait the number of seconds in `Retry-After`, then retry. Photos, Videos, and Users need configured sessions. | | `admin_unconfigured` | Admin is not configured | The deployment's private admin route has no token set. Not part of the public API. | Set `X_MD_ADMIN_TOKEN` on the deployment. This route is not part of the public API. | ## Retry policy in one table | Status | Retry? | How | | --- | --- | --- | | `400`, `405`, `406` | No | Fix the request first. The same request always fails the same way. | | `401` | No | Fix or drop the credential. | | `404` | No | Follow `links`, or confirm the content is public. A retry does not undelete a post. | | `429` | Yes | After `Retry-After` seconds, exactly once per window. | | `500`, `502` | Yes | Exponential backoff with jitter. Honor `Retry-After` when present. | | `503` | Yes | After `Retry-After` seconds. `search_unavailable` on Photos, Videos, or Users may stay down for a while. | ## The same catalogue, machine-readable Anything on this page can be read by a program instead: - [`/openapi.json`](https://mdfromx.com/openapi.json) — `x-error-catalog` lists every code with its status, title, and resolution, and each operation lists the codes it can actually emit, with example bodies. - [`/api`](https://mdfromx.com/api) — the `errors.codes` array, in a much smaller document. Related: [errors, limits, and caching](/docs/reliability) · [versioning and deprecation](/docs/versioning). --- # MCP server Source: https://mdfromx.com/docs/mcp ## The x.md MCP server `https://mdfromx.com/mcp` is a Model Context Protocol server that exposes the same read-only X routes this documentation covers, as tools an agent can call directly. | | | | --- | --- | | Endpoint | `https://mdfromx.com/mcp` | | Transport | Streamable HTTP | | Authentication | None. `Authorization: Bearer ` is optional and raises your search allowance | | Protocol revisions | `2026-07-28`, `2025-11-25`, `2025-06-18` | | Sessions | None. The server is stateless and never issues an `Mcp-Session-Id` | | Server name | `io.github.pc-style/x-md` | ## Connect One URL, no key, no OAuth: point your client at `https://mdfromx.com/mcp` and the six tools below show up. Pick your client. **Claude Code** ```bash claude mcp add --transport http x-md https://mdfromx.com/mcp ``` Then ask Claude Code to read an X post. It picks `x_md_get_post` on its own. **Codex** ```bash codex mcp add x-md --url https://mdfromx.com/mcp ``` Codex CLI, the IDE extension, and the ChatGPT desktop app share this configuration. The equivalent entry in `~/.codex/config.toml`: ```toml [mcp_servers.x-md] url = "https://mdfromx.com/mcp" ``` **Cursor** Add to `~/.cursor/mcp.json` for every project, or `.cursor/mcp.json` for one: ```json { "mcpServers": { "x-md": { "url": "https://mdfromx.com/mcp" } } } ``` **OpenCode** Add to `opencode.json` in the project, or `~/.config/opencode/opencode.json` globally: ```json { "$schema": "https://opencode.ai/config.json", "mcp": { "x-md": { "type": "remote", "url": "https://mdfromx.com/mcp" } } } ``` **Antigravity** Add to `~/.gemini/config/mcp_config.json`, or `.agents/mcp_config.json` in the workspace. Antigravity uses `serverUrl`, not `url`: ```json { "mcpServers": { "x-md": { "serverUrl": "https://mdfromx.com/mcp" } } } ``` **Amp** ```bash amp mcp add x-md https://mdfromx.com/mcp ``` Or in `~/.config/amp/settings.json`: ```json { "amp.mcpServers": { "x-md": { "url": "https://mdfromx.com/mcp" } } } ``` **Other clients** Most clients accept the common `mcpServers` shape with a Streamable HTTP `url`: ```json { "mcpServers": { "x-md": { "type": "http", "url": "https://mdfromx.com/mcp" } } } ``` If your client asks for a transport, choose Streamable HTTP. There is no session and no login step. ### Try it without a client A plain `tools/list` from the command line, to see what a connected agent sees: ```bash curl -sS -X POST https://mdfromx.com/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' ``` A client that sends `Accept: text/event-stream` gets the reply as a single SSE frame; anything else gets `application/json`. Both carry the same JSON-RPC envelope. ## Tools The six `x_md_` tools are read-only and annotated `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. None of them post, reply, like, follow, or read protected accounts, direct messages, or Lists. `submit_feedback` is the one tool that writes, and it writes to the x.md feedback inbox, never to X, so it is annotated `readOnlyHint: false` and `idempotentHint: false`. | Tool | Use it when | Arguments | | --- | --- | --- | | `x_md_get_post` | You have an x.com or twitter.com status link and need its text, author, media, metrics, quoted post, or replies | `url`, or `handle` + `id`; `format`, `thread`, `max_posts`, `context`, `replies`, `userinfo`, `full` | | `x_md_get_profile` | You need to know who an account is or what it has posted recently | `handle`; `page`, `limit`, `cursor`, `full` | | `x_md_search_posts` | You need current public discussion on a topic, or to locate an account by name | `q`; `feed`, `page`, `limit`, `cursor`, `full` | | `x_md_get_followers` | You need to sample or size an account's audience | `handle`; `page`, `limit`, `cursor`, `full` | | `x_md_get_following` | You need to map who an account pays attention to | `handle`; `page`, `limit`, `cursor`, `full` | | `x_md_import_posts` | You need an account's post history over a date range, beyond one profile page | `handle`; `since`, `until`, `max_posts`, `with_replies`, `with_reposts`, `only_replies`, `refresh` | | `submit_feedback` | The user hits a bug, asks for something x.md does not support, has a question, or wants to say what worked | `message`; `title`, `kind`, `sentiment`, `contextUrl` | Each read result carries the rendered Markdown as text content (JSON for `x_md_import_posts`) plus `structuredContent` matching the tool's `outputSchema`; `submit_feedback` returns a one-line confirmation and the stored entry's `id`. Paging works exactly as it does over HTTP: `limit` caps at 20, `page` caps at 10, and deep walks follow `next_cursor`. ```bash curl -sS -X POST https://mdfromx.com/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"x_md_get_profile","arguments":{"handle":"pcstyle53","limit":3}}}' ``` ### Bulk import `x_md_import_posts` is the [bulk import](/docs/bulk-import) route as a tool: the same archive, coverage and continuation rules, the same validation, and the same allowance. Its text content is the JSON body the HTTP route returns. `structuredContent` holds `posts` (newest first, raw post objects), `meta` (the route's `meta`, with `truncated`, `covered_since`, `next_until` and `warnings`) and, when older posts remain, `next_arguments`. That is the full argument object for the next call: pass it back unchanged, and drop the one boundary post you already have. When `meta.warnings` is present, part of the range could not be read, so call again with the same arguments before you continue. A call returns at most 100 posts, the cap every MCP list tool shares, and `max_posts` defaults to 100. Every call, continuation included, spends one bulk import from the allowance the HTTP routes spend. Without a key that is **3 per hour and 5 per day per address**. With a key it is the key's import allowance, 60 per 15 minutes by default. A spent allowance comes back as an `isError` result with code `rate_limited` and the seconds to wait. The response also carries `RateLimit` headers with the exhausted policy (`import-ip`, `import-ip-day` or `import-key`) at `r=0`, plus `Retry-After`. A keyed response is always `Cache-Control: private, no-store`. ```bash curl -sS -X POST https://mdfromx.com/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"x_md_import_posts","arguments":{"handle":"NASA","since":"2026-11-01","max_posts":20}}}' ``` ## Resources `resources/list` returns the static agent documents, so a client can read the service contract without an extra HTTP round trip of its own. | URI | Type | | --- | --- | | `https://mdfromx.com/llms.txt` | `text/plain` | | `https://mdfromx.com/llms-full.txt` | `text/plain` | | `https://mdfromx.com/openapi.json` | `application/json` | | `https://mdfromx.com/index.md` | `text/markdown` | | `https://mdfromx.com/mcp/server-card` | `application/mcp-server-card+json` | ## Errors Revision `2026-07-28` made the protocol stateless. There is no `initialize` handshake: a client declares its version on every request, in the `MCP-Protocol-Version` header and in `params._meta` under `io.modelcontextprotocol/protocolVersion`. Two more headers mirror the body so a gateway can route and filter without parsing JSON-RPC: `Mcp-Method` on every request, and `Mcp-Name` on `tools/call` and `resources/read`, carrying `params.name` or `params.uri`. A name that is not plain ASCII travels as `=?base64??=` and is decoded before comparison. Every mirrored header **must** agree with the body — a mismatch is `400` with `-32020`, because a proxy routing on one while the server executes the other is the split-brain that code exists to stop. An unsupported version is `400` with `-32022` and the list we do speak; an unimplemented method is `404` with `-32601`, so a client can tell this endpoint from one that never hosted MCP. `server/discover` returns the supported versions, capabilities, and identity in a single call; it exists only in this revision, just as `initialize` and `ping` exist only before it, and asking for either from the wrong era is `-32601`. Results in that revision carry `resultType: "complete"`, the server identity under `io.modelcontextprotocol/serverInfo`, and — on `server/discover`, `tools/list`, `resources/list`, `resources/templates/list` and `resources/read` — the `ttlMs` and `cacheScope` freshness hints. The lists are compiled into the deployment and change only on deploy, so they advertise an hour; a resource body is fetched live and advertises five minutes. Nothing varies per caller, so every hint is `cacheScope: "public"`. Handshake-era clients are unaffected. `initialize` still negotiates `2025-11-25` and earlier and gets exactly the result shape its own revision defines, with no `resultType` and no cache hints. This server has always been session-less, which is the stateless core `2026-07-28` standardised, so both eras run through one dispatch. Protocol mistakes come back as JSON-RPC errors: `-32700` for an unparseable body, `-32600` for a message that is not a JSON-RPC 2.0 request, `-32601` for an unknown method, `-32602` for an unknown tool, an unknown resource URI, or arguments that miss the schema, and `-32603` for anything unexpected. An invalid-arguments error carries the tool's `inputSchema` in `error.data` so a client can correct itself in one turn. Upstream failures are different: a rate limit, a protected account, or a missing post comes back as a successful `tools/call` result with `isError: true` and a message naming the HTTP status, the machine code, and the seconds to wait. That keeps a recoverable X-side problem out of the transport layer. Every `tools/call` spends one unit of the same `api-ip` allowance the REST routes charge, and a profile, follower, following or search call answered from the application cache gives it back and is served even once the allowance is spent. Any other call over the allowance gets HTTP `429` with `Retry-After` and a JSON-RPC error `-32000` whose `error.data` is `{ status: 429, code: "rate_limited", policy: "api-ip", retryAfter }`. ## Discovery | Document | What it is | | --- | --- | | [`/server.json`](https://mdfromx.com/server.json) | MCP registry manifest | | [`/mcp/server-card`](https://mdfromx.com/mcp/server-card) | Server card, served as `application/mcp-server-card+json` | | [`/.well-known/mcp/server-card.json`](https://mdfromx.com/.well-known/mcp/server-card.json) | The same card at the well-known path | A plain `GET https://mdfromx.com/mcp` returns that card plus the supported protocol revisions and a worked example, so the endpoint is legible before a client speaks JSON-RPC. ## Deviations worth knowing `GET` with `Accept: text/event-stream` answers `405`: this server sends no unsolicited messages, so there is no standalone stream to open. The official SDK treats that as expected. `DELETE` answers `405` for the same reason sessions do not exist. JSON-RPC batching is rejected with `-32600`, having been removed from the protocol in revision `2025-06-18`; that is also why `2025-03-26`, which required batches, is not offered, and an `initialize` asking for it is answered with `2025-11-25`. A request whose `Origin` header is not a valid origin is refused with `403`. Any real origin is allowed, because the endpoint is public and read-only. --- # Pagination Source: https://mdfromx.com/docs/pagination Every list route paginates the same way — search, profiles, followers, and following, on both the permalink surface and `/api/v1/*`. Cursors are the preferred form; numbered pages exist for the cases where you cannot keep one. ## The shape | Request parameter | Type | Default | Bounds | | --- | --- | --- | --- | | `cursor` | opaque string | — | The `nextCursor` from the previous response, unmodified. | | `page` | integer | `1` | `1`–`10`. Larger values are clamped to `10`. Ignored when `cursor` is present. Cursors have no such ceiling. | | `limit` | integer | `20` | `1`–`100`. Larger values are clamped to `100`. Search feeds served by own accounts (`photos`, `videos`, `users`, or a fallback) answer at most `20` and say so in `limit`. | | Response field | Type | Meaning | | --- | --- | --- | | `nextCursor` | string, optional | Continuation token. **Absent means there is nothing more to fetch.** | | `page` | integer | The effective page this response represents. | | `limit` | integer | The effective limit applied to this response. | Anything that is not a positive integer — a negative number, `0`, or text — falls back to the default rather than erroring. ## Prefer cursors Pass `nextCursor` back as `cursor` with the same route, query, feed, and options: ```js const url = new URL('https://mdfromx.com/api/v1/search') url.search = new URLSearchParams({ q: 'typescript', feed: 'latest', format: 'json', limit: '20' }).toString() for (let page = 0; page < 3; page++) { const response = await fetch(url) if (!response.ok) throw new Error(`Request failed: ${response.status}`) const data = await response.json() console.log(data.posts) if (!data.nextCursor) break url.searchParams.set('cursor', data.nextCursor) } ``` Stop when `nextCursor` is absent — not when a page comes back short. Only the last page of a list is shorter than `limit`. ## Exact pages A page holds exactly `limit` items. x.md assembles it from as many upstream pages as it takes (X serves ~30 posts or ~70 accounts at a time) and cuts it precisely. When the cut falls inside an upstream page, the continuation carries an offset — a cursor like `fxtwitter:DAAHCg…@15` — so the next request re-reads that page and skips what you already have. Nothing is lost, nothing repeats. A profile page continues from an exact position instead: its cursor is minted at the last post returned. Cursor mode is still the cheap path: `limit=100` costs four or five upstream pages once, where `page=5` walks every earlier block again. For an account's whole history do not page at all — [import it](/docs/bulk-import). :::warning Treat cursors as opaque. Do not decode them, strip their `fxtwitter:` or `xsearch:` prefix, or reuse one with a different route, query, or feed. A search cursor names the provider that issued it, and returning it elsewhere is undefined. ::: Markdown responses carry the same thing as a **Continue** link at the end of the page, alongside a **Next page** link. ## Request a numbered page ```bash curl -sS 'https://mdfromx.com/api/v1/search?q=typescript&feed=latest&page=2' ``` Numbered pagination walks every preceding upstream page to reach the one you asked for, so `page=5` costs five upstream calls where a cursor costs one. It is slower, spends more of your [search allowance](/docs/reliability#rate-limits), and stops at `page=10`. Use it for a bounded scan, or when a cursor cannot be carried across process boundaries; use cursors for everything else. A supplied `cursor` takes precedence: `page` is ignored when both are sent. ## Where pagination does not apply - **Degraded search.** When live search is unavailable, Latest and Top can fall back to web-indexed snippets. Those responses carry `X-Search-Degraded: true`, no `nextCursor`, and only a first page. - **Post reads.** `/api/v1/posts` and `/{handle}/status/{id}` return a conversation, not a list. Their size is controlled by `thread`, `context`, and `replies` — see [posts and threads](/docs/posts). ## Machine-readable [`/openapi.json`](https://mdfromx.com/openapi.json) describes the same contract twice: as `cursor`, `page`, and `limit` parameters with their bounds on every list operation, and as an `x-pagination` object naming the request parameters, the `nextCursor` response field, the limits, and the termination rule in one place. --- # Posts and threads Source: https://mdfromx.com/docs/posts ## Read a post `GET /{handle}/status/{id}` — the permalink form. Swap `x.com` for `mdfromx.com` and keep the path. ```bash curl -sS 'https://mdfromx.com/trq212/status/2052809885763747935?thread=off' ``` `GET /api/v1/posts` — the same reader on the [versioned surface](/docs/versioning), for a client that holds a URL rather than a path. Identify the post either with `url`, or with the `handle` and `id` pair. ```bash # By URL curl -sS -G 'https://mdfromx.com/api/v1/posts' \ --data-urlencode 'url=https://x.com/trq212/status/2052809885763747935' \ --data-urlencode 'thread=off' # Or by handle and id curl -sS 'https://mdfromx.com/api/v1/posts?handle=trq212&id=2052809885763747935' ``` Both forms take the same options and return the same body. Without `thread=off`, the default response includes available parents, the author's thread, and top replies. Each returned post links to its X source. Media permalinks resolve to the same post: `/{handle}/status/{id}/photo/1` and `/video/1` read the post they belong to. ## Control the conversation ```bash # Author thread, capped at 20 posts curl -sS 'https://mdfromx.com/trq212/status/2052809885763747935?context=thread&thread=20' # Expanded details, without unrelated replies curl -sS 'https://mdfromx.com/trq212/status/2052809885763747935?full=true&replies=off' ``` | Parameter | Default | Values | | --- | --- | --- | | `url` | — | A public `x.com` or `twitter.com` status permalink. `/api/v1/posts` only | | `handle`, `id` | — | Alternative to `url`: a handle of 1–15 word characters and a numeric status id. `/api/v1/posts` only | | `thread` | `full` | `off`, `full`, `conversation`, or a count from `2` to `100` | | `context` | `full` | `full` for surrounding context; `thread` for the direct author chain | | `replies` | `top` | `top`, `recent`, `off` | | `userinfo` | `off` | `off`, `author`, `all` | | `full` | `false` | `true` adds dates, stats, and expanded details | | `format` | `markdown` | `markdown`, `json`, `obsidian` | | `nocache` | `false` | `true` bypasses application caching | Post boolean parameters accept `true`, `1`, or `yes`. The thread count is separate from the 20-result limit on search and profile routes. :::note `GET /api/convert` is the older name for `/api/v1/posts`. It still works and takes the same parameters, but it is deprecated: its responses carry `Deprecation` and `Sunset` headers and it stops answering on 2027-09-15. See [versioning](/docs/versioning). ::: ## Media and articles Responses include available image links, video URLs, thumbnails, and media variants. X Articles are included when the source supplies their content. Media is linked, not downloaded; CDN links can expire. :::warning Missing or gated context can produce a shorter thread. Inspect JSON `warnings` before assuming a conversation is complete. ::: ## Save to Obsidian ```bash curl -sS 'https://mdfromx.com/trq212/status/2052809885763747935?format=obsidian' > post.md ``` Obsidian output includes frontmatter and expanded details regardless of `full`. ## Share in chat Paste an x.md status URL into Discord, Telegram, or Slack for a rich preview. Preview bots receive Open Graph HTML. Ordinary requests receive Markdown; explicit `format` or `Accept` takes precedence. `GET /oembed?url={original X URL}` returns the oEmbed metadata; `GET /api/v1/oembed` is the same document on the versioned surface. --- # Profiles and connections Source: https://mdfromx.com/docs/profiles ## Read a profile `GET /{handle}` — the permalink form. It returns the profile and its recent posts: original posts only by default, up to 100 per page with `limit`. Add `with_replies=true` and `with_reposts=true` to get the account's replies and reposts too. The response contains at most `limit` posts. Filtering, deduplication, upstream depth and a bounded page budget can produce a shorter response; follow `nextCursor` even after a short page. ```bash curl -sS 'https://mdfromx.com/vercel?full=true' ``` `GET /api/v1/profiles/{handle}` — the same read on the [versioned surface](/docs/versioning). ```bash curl -sS 'https://mdfromx.com/api/v1/profiles/vercel?full=true' ``` ## Read connections `GET /{handle}/followers` and `GET /{handle}/following` list who follows an account and who it follows, up to 100 per page, raw JSON with `format=json`, and no ceiling on how far a cursor goes. ```bash curl -sS 'https://mdfromx.com/vercel/followers' ``` The same two lists sit under `/api/v1/profiles/{handle}`: ```bash curl -sS 'https://mdfromx.com/api/v1/profiles/vercel/following?format=json' ``` | Permalink route | Versioned route | JSON fields | | --- | --- | --- | | `/{handle}` | `/api/v1/profiles/{handle}` | `profile` and `posts` | | `/{handle}/followers` | `/api/v1/profiles/{handle}/followers` | `users` | | `/{handle}/following` | `/api/v1/profiles/{handle}/following` | `users` | Both forms take the same options and return the same body; the versioned ones are the [stable machine surface](/docs/versioning). Use a handle without `@` in the URL. Connection data depends on upstream availability. ## Options | Parameter | Default | Behavior | | --- | --- | --- | | `include_posts` | `true` | Set `false` to fetch only identity/profile, including `avatar_url`, without reading a timeline. Useful for circle enrichment. | | `limit` | `20` | Items per response, maximum **100**. An upper bound, not a promised count. Follow `nextCursor` until absent. | | `with_replies` | `false` | Profile only. `true` includes the account's replies. | | `with_reposts` | `false` | Profile only. `true` includes reposts. A repost is the original post with `reposted_by` set, so it carries the original's `id` and `created_at`. | | `until` | — | Profile only. ISO date, ISO datetime, or unix timestamp: the page starts just below that instant, so you can jump to a date without walking cursors. Ignored when `cursor` is present. | | `cursor` | — | Opaque `nextCursor` from the previous response. No page ceiling: keep following it. | | `page` | `1` | Page 1–10; prefer cursors. Ignored when `cursor` is present. | | `full` | `false` | `true` or `1` adds dates, metrics, and expanded profile details. | | `format` | `markdown` | `markdown` or `json`. Browse routes do not accept `obsidian`. | | `nocache` | `false` | `true` or `1` bypasses the application cache. | Follow `nextCursor` to continue. [See pagination](/docs/pagination). ## Import a post history For hundreds or thousands of posts at once — replies included, raw JSON, streamed — use the bulk route instead of paging: ```bash curl -sS 'https://mdfromx.com/api/v1/profiles/paulg/posts?since=2025-09-01&max_posts=2000' ``` It walks the timeline in parallel, remembers what it collected, and answers repeat imports from that archive. [Import a post history](/docs/bulk-import) covers it end to end. :::note Pinned posts are not identified, and X Lists are unsupported. ::: --- # Errors, limits, and caching Source: https://mdfromx.com/docs/reliability ## Errors Every failure answers with an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem document, served as `application/problem+json`. It carries a stable machine `code`, a human `detail`, and a one-sentence `resolution`. ```json { "type": "https://mdfromx.com/docs/reliability#invalid-handle", "title": "Invalid X handle", "status": 400, "detail": "Invalid X handle", "instance": "https://mdfromx.com/api/v1/profiles/not%20a%20handle", "code": "invalid_handle", "resolution": "Handles are 1-15 characters of letters, digits, or underscores, with no leading @.", "documentation_url": "https://mdfromx.com/docs/reliability#errors", "error": "Invalid X handle" } ``` Branch on `status` for the class of failure and on `code` for the specific one. `error` is an alias of `detail`, kept for clients written against the older `{ error, code }` shape. Each problem's `type` is a stable URI that points back into this documentation. | Status | Typical cause | What to do | | --- | --- | --- | | `400` | Missing query, invalid handle, unsupported parameter value | Fix the request. Retrying it unchanged fails identically. | | `401` | An API key was offered and rejected | Drop the header to call anonymously. The public API needs no credentials. | | `404` | Confirmed missing or inaccessible content, or an unknown path | Follow the `links` in the body. Check the public source URL on x.com. An ambiguous provider miss uses `503`. | | `405` | A write method on a read-only route | Use `GET`, `HEAD`, or `OPTIONS`; read `Allow`. | | `406` | An `Accept` header that excludes every representation | Ask for `text/markdown`, `application/json`, or `text/html`. | | `429` | A quota is exhausted | Sleep for `Retry-After` seconds, then retry once. | | `502`, `503` | Provider failure, ambiguous profile miss, or live search has no healthy session | Honor `Retry-After` when present; retry with backoff and jitter. | | `500` | A bug in x.md | Retry with backoff; if it persists, open an issue. | **[The full error catalogue](/docs/errors)** lists every machine `code` with its meaning and its recovery step. ### Error types A problem's `type` URI lands on its code's row below. Each code links to its section of the [error catalogue](/docs/errors), which gives its meaning and recovery step. | Code | Status | Title | | --- | --- | --- | | [`missing_url`](/docs/errors#request-errors--400) | `400` | Missing url parameter | | [`invalid_url`](/docs/errors#request-errors--400) | `400` | Invalid X status URL | | [`unsupported_host`](/docs/errors#request-errors--400) | `400` | Unsupported host | | [`invalid_path`](/docs/errors#request-errors--400) | `400` | Not a status permalink | | [`invalid_params`](/docs/errors#request-errors--400) | `400` | Invalid handle or status id | | [`invalid_option`](/docs/errors#request-errors--400) | `400` | Invalid query option | | [`invalid_handle`](/docs/errors#request-errors--400) | `400` | Invalid X handle | | [`invalid_resource`](/docs/errors#request-errors--400) | `400` | Unsupported browse resource | | [`invalid_format`](/docs/errors#request-errors--400) | `400` | Unsupported format | | [`invalid_thread`](/docs/errors#request-errors--400) | `400` | Invalid thread parameter | | [`invalid_userinfo`](/docs/errors#request-errors--400) | `400` | Invalid userinfo parameter | | [`invalid_context`](/docs/errors#request-errors--400) | `400` | Invalid context parameter | | [`invalid_replies`](/docs/errors#request-errors--400) | `400` | Invalid replies parameter | | [`invalid_mode`](/docs/errors#request-errors--400) | `400` | Invalid mode parameter | | [`missing_query`](/docs/errors#request-errors--400) | `400` | Missing search query | | [`invalid_body`](/docs/errors#request-errors--400) | `400` | Invalid JSON body | | [`invalid_key`](/docs/errors#authentication--401) | `401` | Invalid or disabled API key | | [`unauthorized`](/docs/errors#authentication--401) | `401` | Unauthorized | | [`not_found`](/docs/errors#not-found--404) | `404` | Not found | | [`route_not_found`](/docs/errors#not-found--404) | `404` | API route not found | | [`method_not_allowed`](/docs/errors#method-and-negotiation--405-406) | `405` | Method not allowed | | [`not_acceptable`](/docs/errors#method-and-negotiation--405-406) | `406` | No acceptable representation | | [`rate_limited`](/docs/errors#limits--429) | `429` | Rate limit exceeded | | [`internal_error`](/docs/errors#service-and-upstream--5xx) | `500` | Unexpected error | | [`upstream_error`](/docs/errors#service-and-upstream--5xx) | `502` | Upstream provider error | | [`partial_upstream_failure`](/docs/errors#service-and-upstream--5xx) | `502` | Partial upstream failure | | [`import_busy`](/docs/errors#service-and-upstream--5xx) | `503` | Fresh import capacity is busy | | [`upstream_unavailable`](/docs/errors#service-and-upstream--5xx) | `503` | Upstream profile unavailable | | [`upstream_rate_limited`](/docs/errors#service-and-upstream--5xx) | `503` | Upstream provider is rate limiting x.md | | [`search_unavailable`](/docs/errors#service-and-upstream--5xx) | `503` | Search temporarily unavailable | | [`admin_unconfigured`](/docs/errors#service-and-upstream--5xx) | `503` | Admin is not configured | Bulk NDJSON imports end with `{"meta":…}` after all post lines. If one timeline page remains unavailable after retries, the import can finish with `meta.warnings: ["page_missing"]`, `truncated: true`, and `floor_reached: false`; retry the range to fill the gap. If it cannot continue, the last line is an RFC 9457 `error`. After posts have streamed its code is `partial_upstream_failure` and `streamed_posts` gives their count; earlier failures can use codes such as `upstream_unavailable` or `internal_error`. An HTTP 200 on a stream does not by itself mean the import completed. ## Rate limits Limits exist so the service stays responsive, not to meter usage: it is free and there is nothing to buy. Every response says how much room is left, so a client can pace itself instead of discovering the limit by hitting it. | Policy | Quota | Window | Keyed by | Applies to | | --- | --- | --- | --- | --- | | `api-ip` | 600 | 60s | client IP | Every public API route. | | `search-ip` | 5 | 60s | client IP | Live search lookups by anonymous callers. Charged only when a search misses the cache and reaches a provider. | | `search-key` | 30 | 60s | API key | The same burst gate for a key holder. | | `account-ip` | 10 | 900s | client IP | Account-backed feeds (Photos, Videos, Users, and the Latest/Top live fallback), drawn from a shared public pool. | | `account-key` | the key's own | 900s | API key | A key holder's account-backed allowance, instead of the public one. | Cache hits are free: a response served from the application cache never reaches a counter. An uncached search's own response already counts that search, so `r=0` on a success means the next uncached search will be refused. Only the policies that apply to the route you called are advertised, so a profile read reports `api-ip` alone and a search reports three. ### Response headers Two forms go out together on every measured response — success, error, and cache miss alike. `RateLimit-Policy` and `RateLimit` are the [IETF structured fields](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/): a comma-separated list of named policies, `q` = quota, `w` = window seconds, `r` = units remaining, `t` = seconds until that policy's window resets. `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset` are the older single-value form that most clients already read. They describe **the tightest policy** — the one closest to exhaustion — because they can only describe one. A search from an anonymous caller with a full allowance: ```http RateLimit-Policy: "api-ip";q=600;w=60, "search-ip";q=5;w=60, "account-ip";q=10;w=900 RateLimit: "api-ip";r=599;t=60, "search-ip";r=5;t=60, "account-ip";r=10;t=900 RateLimit-Limit: 5 RateLimit-Remaining: 5 RateLimit-Reset: 60 ``` Read that as: 599 requests left this minute overall, but only 5 uncached searches, and 10 account-backed lookups left in the current 15-minute window. Pace against `search-ip`, the one the compatibility triple picked out. The same caller after spending the search burst: ```http RateLimit: "api-ip";r=594;t=43, "search-ip";r=0;t=17, "account-ip";r=10;t=900 RateLimit-Limit: 5 RateLimit-Remaining: 0 RateLimit-Reset: 17 Retry-After: 17 Cache-Control: no-store ``` The response is `429` with a `rate_limited` problem document. `Retry-After` and the exhausted policy's `t` are the same number of seconds; if they ever disagree, `Retry-After` wins. Sleep for it — do not poll, and do not retry on a shorter interval hoping for a different answer. A `503` from live search carries `Retry-After` the same way. The anonymous allowance is sized for interactive use. A script or service that searches all day will keep hitting it; ask for an API key instead, which brings its own search allowance. Keys are issued by hand: open an issue describing the workload, as [auth.md](https://mdfromx.com/auth.md) explains. The `/api/*` routes list all five, plus `Retry-After`, in `Access-Control-Expose-Headers`, so a browser client can read them cross-origin. ### Cached responses lie about quota x.md's successful responses are cacheable, and a CDN replays the origin's headers verbatim. On a cache hit those `RateLimit` values were computed for whoever caused the cache to fill, minutes ago, possibly from another IP. **Ignore `RateLimit` on any response with a positive `Age` header**, which is what the specification tells clients to do. `X-Cache` and `x-vercel-cache` say the same thing in x.md's own terms. Rejections are never cached — a `429` carries `Cache-Control: no-store` — so a `429` you receive is always about you. If the shared counter store is unreachable, the front door fails open and reports a full quota — honest about the throttling actually in effect, which in that window is none. Account-backed search is the exception: it fails closed and answers `429` rather than risk overspending the shared pool. ## Caching Successful post conversions cache for about one hour by default. Profiles cache for 30 minutes, search and replies for 10 minutes, and follower lists for 6 hours. Degraded search results are rechecked after about one minute. `nocache=true` skips x.md's application cache. It cannot bypass a provider's own cache. Use it when freshness matters, rather than on every request — a cached read is faster and costs you no search allowance. `X-Cache` reports the application cache status and `X-Source` the provider that served the result. ## Incomplete results :::warning Deleted or protected posts, missing replies, provider delays, and unavailable media can all reduce the response. A successful response does not guarantee an entire conversation or all matching search results. ::: Latest and Top search can fall back to web-indexed snippets. Check `degraded` or `X-Search-Degraded`; snippets may be truncated, contain no metrics, and have different ordering. Photos, Videos, and Users require live search. ## Public data and privacy x.md is read-only. It does not post, follow, or like on your behalf. Public X Lists and private posts are not supported. Successful responses may be cached and served to other callers requesting the same resource. Related: [error catalogue](/docs/errors) · [versioning and deprecation](/docs/versioning) · [response formats](/docs/responses). --- # Response formats Source: https://mdfromx.com/docs/responses **Markdown** Markdown is the default. `full=true` expands metadata without changing the format. ```bash curl -sS 'https://mdfromx.com/search?q=typescript&full=true' ``` **JSON** Add `format=json` or send `Accept: application/json`. ```bash curl -sS -H 'Accept: application/json' \ 'https://mdfromx.com/search?q=typescript' ``` Every route answers in both forms, on both surfaces: the permalink routes (`/{handle}`, `/search`, `/{handle}/status/{id}`) and the versioned routes (`/api/v1/*`) return identical bodies. Only post routes accept `format=obsidian`; browse routes take `markdown` or `json`. ## Browse responses | Field | Meaning | | --- | --- | | `resource` | `profile`, `search`, `followers`, or `following` | | `profile` | Profile details on the profile route | | `posts` | Posts on profile and post-search routes | | `users` | Accounts on Users search and connection routes | | `query`, `feed` | Effective search query and feed, when searching | | `page`, `limit` | Effective pagination settings | | `nextCursor` | Optional continuation token | | `markdown` | Rendered version of the same result | | `source` | Provider that served the result | | `cache` | Application cache status | | `degraded` | `true` when search uses web-indexed snippets | Not every field is present on every route. Profile and post metadata can also be absent when upstream data is incomplete. ## Post conversion responses Conversion JSON includes `markdown`, `posts`, the URL, compact flag, warnings, count, provider source, format, and cache status. It is a different shape from browse JSON. ## Error responses Failures are [RFC 9457 problem documents](/docs/errors), not HTML: `application/problem+json` with a stable `code`, a human `detail`, and a `resolution`. A caller that asks for `application/json` gets the same body under that content type. ## Useful headers | Header | Meaning | | --- | --- | | `X-Source` | Provider used for this response | | `X-Cache` | Application cache status | | `X-Result-Count` | Number of items in a browse response | | `X-Search-Degraded` | `true` for web-indexed search fallback | | `RateLimit-Policy` | The quotas that apply to this route, as `"name";q=;w=` | | `RateLimit` | What is left of each, as `"name";r=;t=` | | `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` | The same state for the tightest policy, in the older single-value form | | `Retry-After` | Seconds to wait after a rate limit or temporary search outage | | `Deprecation`, `Sunset` | Present only on a [deprecated route](/docs/versioning) | | `Link` | Discovery: `service-desc`, `help`, `successor-version` | | `Vary: Accept` | The response was negotiated from your `Accept` header | The `/api/*` routes list the rate-limit, `Retry-After`, `Deprecation`, `Sunset`, and `Link` headers in `Access-Control-Expose-Headers`, so a browser client can read them cross-origin. Inspect headers with `curl -i`. [Handle failures](/docs/reliability). --- # TypeScript SDK Source: https://mdfromx.com/docs/sdk `mdfromx` is a typed client for the `/api/v1` post, profile and search routes. (`/api/v1/oembed` is left out: it only serves x.md's own link previews.) Its types are generated from the [OpenAPI document](https://mdfromx.com/openapi.json), so they match the API at the version you installed. It comes in two flavors in one package: - `mdfromx` runs on `fetch` with no runtime dependencies, in Node 18+, Bun, Deno, browsers and Workers. - `mdfromx/effect` is an Effect service whose calls return `Effect` and `Stream`, with one tagged error per problem code and every response decoded with Effect Schema. ```bash npm install mdfromx # Effect flavor only: effect is an optional peer dependency npm install mdfromx effect ``` ## Plain TypeScript ```ts import { Mdfromx } from 'mdfromx' const x = new Mdfromx() const { posts, markdown } = await x.posts.get('https://x.com/jack/status/20') const profile = await x.profiles.get('jack', { limit: 5 }) const results = await x.search.get('bun lang:en', { feed: 'top' }) ``` Options use camelCase and map to the documented query parameters (`requireLive` is `require_live`, `maxPosts` is `max_posts`). Flags such as `full` and `nocache` are booleans. | Call | Route | Returns | | --- | --- | --- | | `posts.get(post)` / `posts.markdown(post)` | [Posts](/docs/posts) | `ConvertResponse` / `string` | | `posts.replies(id)` | Replies | `BrowseResponse` | | `profiles.get(handle)` / `profiles.markdown(handle)` | [Profiles](/docs/profiles) | `BrowseResponse` / `string` | | `profiles.followers(handle)` / `profiles.following(handle)` | Connections | `BrowseResponse` | | `profiles.importPosts(handle)` | [Bulk import](/docs/bulk-import) | `ImportSuccessResponse` | | `profiles.streamPosts(handle)` | Bulk import as NDJSON | `AsyncIterable` | | `search.get(query)` / `search.markdown(query)` | [Search](/docs/search) | `BrowseResponse` / `string` | A post can be a status URL, a numeric id, or `{ handle, id }`. Every JSON response already carries the rendered `markdown`; the `markdown()` calls skip the JSON when the text is all you need. ### Options ```ts const x = new Mdfromx({ apiKey: 'xmd_…', // optional; falls back to MDFROMX_API_KEY on Node, Bun and Deno baseUrl: 'https://mdfromx.com', // the default; every x.md host serves the same API retry: { maxRetries: 2, maxDelayMs: 30_000 }, // the default; `false` turns retries off fetch: customFetch, // optional }) ``` Keys are optional. They raise the live-search allowance ([Rate limits](/docs/reliability#rate-limits)) and are handed out by the maintainer through [GitHub issues](https://github.com/pc-style/x-md/issues). In a browser, an API key is visible to anyone who opens the page, so call the API from a server when you use one. ### Pagination `profiles.pages`, `profiles.followersPages`, `profiles.followingPages` and `search.pages` follow `nextCursor` until it stops coming, as [Pagination](/docs/pagination) describes. A short page is not the end of the list. ```ts for await (const page of x.search.pages('bun', { limit: 50, maxPages: 4 })) { for (const post of page.posts ?? []) console.log(post.url) } ``` ### Streaming a bulk import ```ts for await (const event of x.profiles.streamPosts('jack', { since: '2026-01-01', maxPosts: 2000 })) { if ('post' in event) save(event.post) else console.log(`imported ${event.meta.count}, truncated: ${event.meta.truncated}`) } ``` Breaking out of the loop cancels the download. When the walk fails part-way, the loop throws after the posts that did arrive. A stream that ends without its final `meta` line was cut off, so the loop throws `stream_incomplete` rather than ending as if the import were done. ### Errors and retries Every non-2xx answer throws `MdfromxError` with the [problem details](/docs/errors): `status`, `code`, `problem` and `retryAfter`. `code` is typed with every documented code. Empty input, such as a blank handle or post, is rejected before any request with the code the API uses for it (`invalid_handle`, `invalid_option` or `missing_url`). ```ts import { MdfromxError } from 'mdfromx' try { await x.profiles.get('jack') } catch (error) { if (error instanceof MdfromxError && error.code === 'rate_limited') { console.log(`try again in ${error.retryAfter}s`) } } ``` A `429` or `503` is retried up to twice, waiting the `Retry-After` the API sent (1s and then 2s when it sent none). The client gives up instead of waiting longer than `maxDelayMs`, so an import quota that resets in fifteen minutes fails fast with `retryAfter` set. Other statuses are never retried. ## Effect ```ts import { Effect, Stream } from 'effect' import { FetchHttpClient } from 'effect/http' import { Mdfromx } from 'mdfromx/effect' const program = Effect.gen(function* () { const x = yield* Mdfromx const post = yield* x.posts.get('https://x.com/jack/status/20') const pages = yield* Stream.runCollect(x.search.pages('bun', { maxPages: 3 })) return { post, pages } }).pipe( Effect.catchTag('RateLimited', error => Effect.fail(`retry in ${error.retryAfter}s`)), ) program.pipe( Effect.provide(Mdfromx.layer()), Effect.provide(FetchHttpClient.layer), Effect.runPromise, ) ``` - `Mdfromx.layer(config)` needs an `HttpClient`, so you choose the transport and can swap it in tests. Its config takes the same `apiKey`, `baseUrl` and `retry` as the plain client, and the key falls back to the `MDFROMX_API_KEY` config value. - The error channel is `MdfromxFailure`: one tagged class per [error code](/docs/errors) (`RateLimited`, `NotFound`, `ImportBusy`, ...), `UnknownApiError` for an unlisted provider code, Effect's `HttpClientError` for transport failures, and `SchemaError` when a response does not match the contract. - Paginated lists and `profiles.streamPosts` are `Stream`s. They pull pages lazily, so `Stream.take` stops the requests too. - Responses are decoded with the generated schemas, exported as `Schemas` (`Schemas.Post`, `Schemas.BrowseResponse`, ...). Fields the API adds later are kept, not stripped. ## Versions The SDK has its own semver, starting at `0.1.0`, independent of the API's `/api/v1`. A new SDK release follows API changes; [Versioning](/docs/versioning) covers what `/api/v1` itself promises. Source: [`packages/sdk`](https://github.com/pc-style/x-md/tree/main/packages/sdk). --- # Search X Source: https://mdfromx.com/docs/search ## Search for posts `GET /search?q={query}` — the permalink form, mirroring `x.com/search`. ```bash curl -sS -G 'https://mdfromx.com/search' \ --data-urlencode 'q=from:vercel release' \ --data-urlencode 'feed=latest' \ --data-urlencode 'limit=20' ``` `GET /api/v1/search?q={query}` — the same search on the [versioned surface](/docs/versioning). Same parameters, same body. ```bash curl -sS -G 'https://mdfromx.com/api/v1/search' \ --data-urlencode 'q=from:vercel release' \ --data-urlencode 'format=json' ``` Use `--data-urlencode` for spaces, hashtags, and query operators. The query is passed to the search provider. ## Choose a feed | `feed` | Results | | --- | --- | | `latest` | Recent matching posts. Default. | | `top` | Top matching posts. | | `photos` | Matching posts with photos. | | `videos` | Matching posts with videos. | | `users` | Matching account profiles. | Feed names are case-insensitive. `media` is an alias for `photos`; unknown values fall back to `latest`. **Photos** ```bash curl -sS 'https://mdfromx.com/search?q=architecture&feed=photos' ``` **Videos** ```bash curl -sS 'https://mdfromx.com/search?q=animation&feed=videos' ``` **Users** ```bash curl -sS 'https://mdfromx.com/search?q=typescript&feed=users&full=true' ``` ## Parameters | Parameter | Default | Behavior | | --- | --- | --- | | `require_live` | `false` | `true` returns 503 when live search is unavailable instead of substituting web snippets. Use it for circle scoring. | | `q` | Required | Nonempty search query. | | `feed` | `latest` | One of the five feeds above. | | `limit` | `20` | Results per response, maximum **100** on `latest` and `top`. Feeds served by own accounts (`photos`, `videos`, `users`, or a fallback when the primary provider is down) answer at most 20 per request and report that in `limit`. | | `since` | — | Oldest post to match: ISO date, ISO datetime, or unix timestamp. Applied as X's `since_time:` operator, so every provider honours it. | | `until` | — | Newest post to match, same formats, applied as `until_time:`. | | `cursor` | — | Opaque `nextCursor` from the previous response. | | `page` | `1` | Page 1–10; prefer cursors for continuation. | | `full` | `false` | `true` adds dates and metrics, or profile details for Users. | | `format` | `markdown` | `markdown` or `json`. | | `nocache` | `false` | `true` bypasses the application cache. | Browse boolean parameters also accept `1`. [Pagination examples](/docs/pagination). ## Read user results in JSON Post feeds return a `posts` array. The Users feed returns a `users` array instead. ```js const response = await fetch( 'https://mdfromx.com/search?q=typescript&feed=users&format=json' ) if (!response.ok) throw new Error(`Search failed: ${response.status}`) const { users, nextCursor } = await response.json() for (const user of users) console.log(user.screen_name, user.name) ``` ## Availability and limits Search is the one route with a tight allowance: **5 uncached requests per minute per IP** (`search-ip`), plus **10 account-backed lookups per IP per 15-minute window** (`account-ip`) for Photos, Videos, Users, and the Latest/Top live fallback. Issued keys instead have a burst allowance of 30 uncached requests per minute and their own account-backed allowance shown in `RateLimit-Policy`; scripted or sustained search should use one ([how to ask for a key](https://mdfromx.com/auth.md)). Cache hits do not spend search quota, but still use the general API allowance. Each page of a numbered page walk counts as one request; a cursor costs one. Every response reports what is left in its `RateLimit` headers, so a client can pace itself rather than discover the limit: ```http RateLimit: "api-ip";r=599;t=60, "search-ip";r=5;t=60, "account-ip";r=10;t=900 RateLimit-Remaining: 5 ``` A rejected request is `429` with `Retry-After` in seconds and a `rate_limited` problem document. [Full limit and header reference](/docs/reliability#rate-limits). :::warning When live search is unavailable, Latest and Top may return web-indexed snippets. These responses include `X-Search-Degraded: true` and a note in the Markdown. Their ordering, coverage, and text can differ from live results, and they do not paginate. Photos, Videos, and Users return `503` when their live source is unavailable. They never substitute unfiltered web snippets. ::: See [errors, limits, and caching](/docs/reliability) and the [error catalogue](/docs/errors). An upstream empty page with a continuation or a repeated cursor returns `warnings` (`empty_page` or `repeated_cursor`). Retain the current cursor and retry later; neither an empty page nor an absent continuation with warnings proves complete coverage. Search indexing can omit replies; use `(@handle OR to:handle)` and supplement with direct reply samples for interaction circles. --- # Self-hosting Source: https://mdfromx.com/docs/self-hosting ## Run locally ```bash git clone https://github.com/pc-style/x-md.git cd x-md bun install bun run dev ``` The Vite server runs the landing page and local API handlers. Run `bun run docs:dev` separately for the Blume documentation server. ## Build and deploy ```bash bun run build vercel --prod ``` The build generates Blume docs, then the Vite landing page into `dist`. The included Vercel configuration serves `/docs`, the static assets, and the API rewrites together. - docs/ - index.mdx - (api)/ - posts.mdx - search.mdx - profiles.mdx - pagination.mdx - responses.mdx - reliability.mdx - meta.ts - agents.mdx - self-hosting.mdx - blume.config.ts - theme.css ## Live search provider Latest and Top try FxTwitter, then the custom-built live search provider, then an optional Firecrawl web fallback. Photos, Videos, and Users use the live provider directly and return `503` when it is not configured. The provider is configured through `X_SEARCH_SESSIONS_JSON`; the expected shape is defined in `lib/xsearch.ts`. For local development, use the gitignored `accounts.local.json`. Treat its contents as secrets and keep them out of version control. The provider budgets its own upstream capacity per 15-minute window, shared across post and user searches. Failed attempts and page walks count. Unhealthy entries are taken out of rotation in the current process and retried later; health is per instance. ## Your own upstream, or a pool of them Profiles, posts, and bulk imports read `https://api.fxtwitter.com` by default. That public instance allows about 1000 requests a minute per source IP, which caps a bulk import's sustained fan-out around 16 chains. Point x.md at your own [FxEmbed](https://github.com/FxEmbed/FxEmbed) deployment instead — FxEmbed is itself a Cloudflare Worker, and Workers is its documented self-host path: ```bash FXTWITTER_BASE_URL=https://fx.example.workers.dev ``` Or give it a pool. Requests rotate across every base; one that answers `429` sits out for its `Retry-After` while the walk continues on the others, and the import route allows 32 chains **per base**: ```bash FXTWITTER_BASE_URL=https://fx-a.example.workers.dev,https://fx-b.example.workers.dev,https://api.fxtwitter.com ``` How high `concurrency` can go on your own instance depends on that deployment's capacity and X's own per-token limits, which FxEmbed manages; x.md still backs off on any `429` it returns. `bench/RESULTS-scale.md` in the repository is the reference run against the public instance; `bun scripts/benchmark-import.ts --scale` reproduces it against whatever `FXTWITTER_BASE_URL` names, so you can measure your own pool. The caps in `lib/import.ts` (`IMPORT_MAX_POSTS`, `IMPORT_MAX_CONCURRENCY_PER_BASE`) and the allowances in `lib/quotas.ts` are the only limits; there are no hosted-only ones. ## The post archive Bulk imports store what they walk, per account, so repeat imports only fetch the gap. With the shared Redis below configured the archive is durable across instances and deploys; without it, it lives in process memory for the life of the function. Keys are `hist:{handle}:index`, `hist:{handle}:ids` (a sorted set by post time), and `hist:{handle}:posts`. Budget roughly 2 KB per post; a 3000-post account is about 6 MB. ## Deploying the fast stack The shape that removes every artificial ceiling: 1. **FxEmbed on Cloudflare Workers** — your own upstream, no per-IP cap. FxEmbed routes by `Host`, so put your API hostname in its `API_HOST_LIST`. It works best with X account credentials (`credentials.json`: `authToken` + `csrfToken` per account, the same pair as x.md's search sessions) and spreads requests across them; without credentials it runs on guest access with lower limits. Its capacity is therefore roughly the number of X accounts behind it, and one Cloudflare account on the free tier covers 100k requests a day. 2. **x.md on Vercel with Fluid Compute** — the import function is declared with a 120 s `maxDuration` in `vercel.json`; Fluid allows it and keeps streaming responses open. 3. **Upstash Redis** via the Vercel marketplace — the archive and the rate-limit counters. Set `FXTWITTER_BASE_URL` to your pool (public instance last), deploy, and verify with one import followed by `?index=true`. ## Shared counters Attach Upstash Redis through the Vercel marketplace or CLI. The service automatically uses either environment-variable pair: - `KV_REST_API_URL` and `KV_REST_API_TOKEN` - `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` These share per-IP and provider counters across instances. Without them, counters fall back to process memory. Live-provider checks fail closed when the counter store is unavailable, protecting the shared pool; the general request limiter still fails open. Redeploy after connecting environment variables. ## Historical statistics PostHog is optional. Connect its Vercel Marketplace integration to the production environment, choose your data region, and review its billing terms. It supplies `NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN` and `NEXT_PUBLIC_POSTHOG_HOST`; the API reads these on the server only. Alternatively, set `POSTHOG_PROJECT_TOKEN` and `POSTHOG_HOST` (for example, `https://eu.i.posthog.com`). Redeploy after configuring them. Production API functions send a `request_completed` event with the route category, method, HTTP status, duration in milliseconds, application-cache result, key-validation status, public/key access, and degraded-search flag. Use these for request volume by endpoint, error and `429` rates, latency percentiles, and application-cache hit rate. Failed requests also send `request_failed` with the error class and the raised error code (for example `firecrawl_error`). A request's events, including `request_completed` and `request_failed`, go to PostHog in one batch call when the response finishes, in the background with a three-second timeout; failures do not change API responses. Events recorded after that flush go in their own call. `endpoint_called` is the exception: it is sent on arrival, so a function that crashes or times out is still counted. The high-volume events are sampled and carry `sample_rate`, so weight their counts by its inverse: `endpoint_called` is kept for one request in ten, and each `cache_hit` and `cache_miss` is kept one time in ten on its own. Volume and cache hit rate stay exact on `request_completed`. `request_completed` events contain no search text, URLs, client IPs, bearer tokens, or key labels. Verified keys use a pseudonymous ID derived from their internal ID and the PostHog project token; rotating that token changes the pseudonyms. Public requests all share the `anonymous` ID. Each `request_completed` also carries `caller_id`: the key pseudonym for verified keys, otherwise a hash of the client IP under a salt derived from the server's Redis token (`KV_REST_API_TOKEN` or `UPSTASH_REDIS_REST_TOKEN`) that changes every UTC day, so you can count unique callers per day but not link them across days. Without that token there is no anonymous `caller_id`. Person profiles, geolocation enrichment, and session replay are not enabled by this instrumentation. These statistics cover completed function responses, not requests served by Vercel's CDN, aborted requests, or platform errors before the handler runs. They are not billing-grade totals. Local development, previews, admin routes, and CORS preflights are excluded. Redis remains the source of truth for live limits and capacity; PostHog is only for historical analysis. ### Homepage and docs web analytics Set `VITE_POSTHOG_KEY` to the project token and `VITE_POSTHOG_HOST` to its ingestion host or managed reverse proxy (this deployment uses `https://p.pcstyle.dev`). These are public build-time values, not personal API credentials. Rebuild after changing them. Capture requires a production build with `VERCEL_ENV=production`; local development and previews do not send events. The SDK's `ui_host` points to EU PostHog. In PostHog's Web Analytics settings, add your public site URL (for this deployment, `https://mdfromx.com`). The homepage and the `/docs` pages send pageviews and page leaves, including navigations between docs pages; the homepage also sends `conversion_requested` and `skill_install_command_copied`. Anonymous browser IDs persist in local storage to measure returning visitors; session and browser/device properties support Web Analytics. No person profiles are created. Event URLs contain only the origin and the page path, without fragments or query strings. Traffic sources are kept: the referrer reduced to its origin, the referring domain, and the `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, and `utm_term` tags; every other query parameter is dropped. Uncaught errors are sent as `$exception` events carrying only the exception type, message, and stack frames, with query strings and fragments removed from any URL inside them. Form contents, automatic clicks, and recordings are excluded. The proxy receives the browser's source IP, while event properties disable IP-based enrichment. This covers the homepage and docs only, not converted posts, the about, contact, privacy, and terms pages, or admin. API events remain separate from browser visits. Vercel Web Analytics on the homepage and docs is unchanged. The admin dashboard includes no analytics scripts. ### Error tracking Unexpected errors that escape an API handler are captured with `posthog-node` and flushed before the original error is rethrown. They use the server analytics token and ingestion host above, with the same production-only gate. Exception properties keep the type, message, and stack frames; URL queries, fragments, and source context are removed. Request headers, bodies, and caller identities are not attached. For browser source maps, set the build-only variables `POSTHOG_API_KEY` (a personal key permitted to upload error-tracking artifacts), `POSTHOG_PROJECT_ID`, and `POSTHOG_API_HOST` (for EU projects, `https://eu.posthog.com`). The API host is separate from the ingestion host in `POSTHOG_HOST`. `bun run build` uploads the landing and docs bundle maps and removes them from the output. Builds without upload credentials do not generate source maps. To test browser capture locally, use the same project token for `VITE_POSTHOG_KEY` and `POSTHOG_PROJECT_TOKEN`, then run `VERCEL_ENV=production bun run build` followed by `bun run preview`. Check `.env.local` after running the wizard: it overrides `.env`, and an older token sends events to a different project. This local build enables capture without deploying the site. Keep ordinary development and Vercel previews excluded from capture. ## Optional configuration | Variable | Purpose | | --- | --- | | `FIRECRAWL_API_KEY` | Conversion fallback and degraded Latest/Top search | | `CONTEXT_DEV_API_KEY` | Additional post-conversion fallback | | `CACHE_TTL_SECONDS` | Post conversion cache lifetime; default 3600 | | `CACHE_DISABLED` | Disable application caching | | `CACHE_PERSIST` | Control persistent application caching | :::tip FxTwitter is the primary conversion provider, followed by X syndication and configured fallbacks. Inspect `X-Source` when diagnosing a response. ::: --- # Versioning and deprecation Source: https://mdfromx.com/docs/versioning x.md is meant to be integrated once and then left running. This page is the promise about when that integration can break, and how much warning it gets. ## Two surfaces | Surface | Example | Contract | | --- | --- | --- | | Versioned machine surface | `GET /api/v1/search?q=vercel` | `v1` is the current major version. Breaking changes ship as `/api/v2/*`, never inside `v1`. | | Permalink product surface | `GET /search?q=vercel` | Mirrors the shape of an `x.com` URL. Unversioned, stable, not deprecated. | The two surfaces run the same code and return the same bodies. Pick the versioned paths when you are writing a client that has to keep working unattended; pick the permalink paths when you are rewriting an `x.com` URL you already hold. The versioned surface is: ```text GET /api/v1/posts # ?url= or ?handle= + ?id= GET /api/v1/profiles/{handle} GET /api/v1/profiles/{handle}/followers GET /api/v1/profiles/{handle}/following GET /api/v1/search # ?q= GET /api/v1/oembed # ?url= ``` ## What can change inside v1 These are additive and ship without a version bump: - New optional query parameters. - New fields in a JSON response body, and new keys inside existing objects. - New response headers. - New values in an open-ended field such as `source`. - Changes to the rendered Markdown: wording, ordering, and layout are presentation, not contract. Ignore JSON fields you do not recognise, and do not parse the Markdown as if it were a data format. If you need fields, ask for `format=json` or send `Accept: application/json`. These are breaking, and ship only under a new path prefix: - Removing or renaming a query parameter, a JSON field, or a route. - Changing the type of an existing field, or narrowing what an existing parameter accepts. - Changing the meaning of an existing status code or error `code`. `/api/v1/*` keeps working for **at least 12 months** after its successor is published. ## How a deprecation is announced A deprecated route keeps answering exactly as before and adds three signals to **every** response it produces, success and error alike: ```http Deprecation: @1789430400 Sunset: Wed, 15 Sep 2027 00:00:00 GMT Link: ; rel="successor-version", ; rel="deprecation"; type="text/html", ; rel="sunset"; type="text/html" ``` | Header | Specification | How to read it | | --- | --- | --- | | `Deprecation` | [RFC 9745](https://www.rfc-editor.org/rfc/rfc9745) | A structured-field Date: `@` followed by Unix seconds. `@1789430400` is `2026-09-15T00:00:00Z`. A date in the future means the route *becomes* deprecated then. | | `Sunset` | [RFC 8594](https://www.rfc-editor.org/rfc/rfc8594) | An HTTP-date after which the route may stop answering. Never earlier than the `Deprecation` date. | | `Link; rel="successor-version"` | [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288) | The route to move to. Safe to follow automatically. | | `Link; rel="deprecation"` | RFC 9745 | This page. | | `Link; rel="sunset"` | RFC 8594 | This page. | The `/api/*` routes list `Deprecation`, `Sunset`, and `Link` in `Access-Control-Expose-Headers`, so a browser client can read them cross-origin. ## Scheduled for deprecation | Route | Successor | Deprecation date | Sunset | | --- | --- | --- | --- | | `GET /api/convert` | `GET /api/v1/posts` | 2026-09-15 | 2027-09-15 | | `GET /api/browse` | `GET /api/v1/profiles/{handle}`, `/followers`, `/following`, `GET /api/v1/search` | 2026-09-15 | 2027-09-15 | Both aliases send `Deprecation` and `Sunset` on every response *now*, before the date the `Deprecation` field names. That is the point of a future date: it is notice that the route becomes deprecated on 2026-09-15, not a claim that it already is. `/api/browse` selects its successor by `resource`: `profile`, `followers`, and `following` map to the matching `/api/v1/profiles/{handle}` route, and `search` maps to `/api/v1/search`. A call that names no resolvable resource still carries `Deprecation` and `Sunset`, but no `successor-version` link. Both aliases keep working unchanged until the sunset date. Nothing else is deprecated. The permalink routes are not aliases and are not going away. :::note Nothing has been removed yet. This table is what the sunset window looks like when it is used, not a backlog of pending breakage. ::: ## Check it from code Deprecation is visible on a `HEAD`, so a client can check without spending a read: ```bash curl -sSI 'https://mdfromx.com/api/convert?url=https://x.com/jack/status/20' \ | grep -iE '^(deprecation|sunset|link):' ``` A reasonable client policy: log a warning when `Deprecation` is present, follow `rel="successor-version"` on the next deploy, and fail loudly if `Sunset` is less than 30 days away. The same policy is machine-readable in two places: - [`/openapi.json`](https://mdfromx.com/openapi.json) — the `x-api-lifecycle` object carries `current_major_version`, `supported_versions`, `minimum_support_window_months`, `deprecation_signals`, and every deprecated operation with its dates and its replacement: `successor` when one route replaces the alias, `successor_parameter` plus `successors` for `/api/browse`, which picks its replacement from `resource`. The operations themselves carry `x-successor-version` (`/api/convert`) or `x-successor-version-map` (`/api/browse`), alongside OpenAPI's own `deprecated: true`. - [`/api`](https://mdfromx.com/api) — the `versioning` object repeats the same dates in a smaller document, for an agent that has only the domain. Its `/api/browse` entry carries `successor_parameter` and `successors` rather than a single `successor` string, so parse both shapes. ## Release notes Changes are tagged on `main` and listed in [CHANGELOG.md](https://github.com/pc-style/x-md/blob/main/CHANGELOG.md). The hosted API at `mdfromx.com` runs the latest tag. x.md is a free, MIT-licensed, best-effort project. This policy describes how change is *signalled*; it is not an SLA. If a route is broken by an upstream provider rather than by a release, the failure shows up as a [`502` or `503` problem document](/docs/errors), not as a version bump. --- # Build an interaction circle Source: https://mdfromx.com/docs/x-circle A circle needs both directions of interaction. Reading someone’s own posts alone misses people replying to or mentioning them. This integration uses only the documented public API at `https://mdfromx.com`. Run the [server-side reference and benchmark](https://github.com/pc-style/x-md/blob/main/scripts/circle-benchmark.ts) with Bun and your server environment. It reports stage coverage, missing profiles, retries and wall time; `--fresh` forces a fresh own-post import and bypasses x.md’s search, reply and profile caches. Upstream caches are outside the client’s control. Set `X_MD_UNTIL` to the same ISO datetime for fresh and repeat benchmark runs. It is not browser code. ## Keep the key on your server Store the issued key as server-only `X_MD_API_KEY`. Send `Authorization: Bearer …` and `Accept: application/json` on each request. Do not use a public framework environment prefix, put keys in URLs, or embed them in JavaScript delivered to the browser. Your browser should call your own authenticated backend; validate handles there and queue work per user. CORS permits the API headers, but CORS does not protect a key embedded in a bundle. Check `X-Api-Key-Status: valid`. An invalid key returns 401; retrying it does not help. Customers need no upstream account, private endpoint, SSH access or service-owner environment variables. ## Data contract | Need | Public request | Read | | --- | --- | --- | | Identity and avatar | `/api/v1/profiles/{handle}?include_posts=false&format=json` | `profile.id`, `screen_name`, `name`, `avatar_url`, `protected` | | Own posts, replies, reposts | `/api/v1/profiles/{handle}/posts?since={ISO}&max_posts=1000&format=json` | `posts`, `meta`; replies and reposts default to true | | Timeline-position pagination | `/api/v1/profiles/{handle}?with_replies=true&with_reposts=true&limit=100&format=json` | `posts`, opaque `nextCursor` | | Incoming mentions | `/api/v1/search?q=(%40{handle}%20OR%20to%3A{handle})&feed=latest&since={ISO}&limit=100&require_live=true&format=json` | `posts`, `nextCursor`; the live fallback can return at most 20 per request | | Direct reply fallback | `/api/v1/posts/{id}/replies?limit=100&format=json` | `posts`; a bounded recent sample, without pagination or exhaustive coverage | | Parent/thread context | `/api/v1/posts?url={encodedStatusURL}&context=full&replies=recent&format=json` | `posts` with `context` of `parent`, `post`, `thread` or `reply` | | Missing member avatars | identity-only route above for each missing handle | `profile`; use at most four concurrent lookups | For a 120-day circle, freeze `until` once and compute `since = until - 120 days`. Run own posts and incoming search concurrently. Reuse `profile` from a fresh import for identity; call the identity-only endpoint if it is absent. Follow every returned search cursor, keeping the same query, feed and dates. Stop at your explicit page budget, repeated cursor, or absent cursor; a short page alone is **not** completion. If `warnings` reports an empty or repeated upstream page, stop, retain the current cursor for a later retry, and label search coverage incomplete. Deduplicate post IDs within each direction. A capped search is a sample, not a full 120-day mention history. ## Build interactions Use `author.screen_name` to distinguish outgoing from incoming posts, case-insensitively. Outgoing reposts have `reposted_by.screen_name` matching the owner and their original author is the recipient. Their timestamp is the original publication time; exact repost event times are unavailable. For replies, `replying_to` is normally an object with `screen_name` and `status`; older data may instead contain an array of handles and `replying_to_status`. Preserve that distinction. For quotes use `quote.author.screen_name`. Visible mentions are `raw_text.facets` with `type: "mention"`, `original: "@handle"`, and an index at or after `raw_text.display_text_range[0]`. This avoids counting implicit reply prefixes as explicit mentions. If an entity field is absent, report unknown coverage rather than inventing it from arbitrary text. Exclude self-interactions and deduplicate `(post ID, other handle, direction, kind)`. One compatible scoring rule weights reply 3, quote 2.5, mention 1.5 and repost 1, halves weight every 30 days, and combines outgoing score `a` and incoming score `b` as `a + b + 2 * sqrt(a * b)`. Keep source and coverage alongside scores: a larger score from a larger sample is not evidence of a closer relationship. Search indexing can omit replies even on a successful search. Supplement search using the direct-replies route for a bounded selection of the owner’s posts with replies. Deduplicate those post IDs before fetching. These samples omit unmentioned quotes and other incoming activity; label the result “search + reply sample” (or “reply sample” after total search failure), never “mentions complete”. Do not score degraded web search snippets. ## Recovery and capacity For retryable 429/502/503 responses, parse the problem document and `Retry-After`. A queued retry must wait at least that long plus jitter, and retry at most twice. The reference circle client defers a failed search immediately and returns its available timeline and reply samples; it reports `search_resume` and `search_retry_at` for background recovery. It does not hold the entire circle open for a search quota window or issue another search before that deadline during the same run. Other stages retry inline only for waits up to 60 seconds; longer waits return a failure for queued recovery without shortening the server’s deadline. `search_unavailable` without an explicit wait can use a 30-second delay. Keep successfully collected pages when a later page fails and label the circle partial; retry the failed cursor later. For imports with `meta.warnings`, retry the same range. For count-limited imports use `meta.next_until`, deduplicating boundary posts. See [bulk import](/docs/bulk-import). The issued key’s response headers are authoritative. A key advertising 50 imports per 900 seconds is a valid override of the default 60. Search has separate burst and window allowances; ten mention pages can spend ten account-backed searches. These quotas are budgets, not reserved throughput. The service admits three fresh import walks at once, one per caller with one held for API keys, and rejects excess work with `503 import_busy`; queue those requests and honor the delay. Expose useful completion data to your user: own posts read, incoming interactions read, date bounds, search/reply source, missing profiles, and whether any stage was capped or failed. Do not label a partial circle complete because one HTTP request returned 200.