TypeScript SDK
The mdfromx package, a typed client for the /api/v1 post, profile and search routes, in plain TypeScript and as an Effect service.
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, so they match the API at the version you installed. It comes in two flavors in one package:
mdfromxruns onfetchwith no runtime dependencies, in Node 18+, Bun, Deno, browsers and Workers.mdfromx/effectis an Effect service whose calls returnEffectandStream, with one tagged error per problem code and every response decoded with Effect Schema.
npm install mdfromx
# Effect flavor only: effect is an optional peer dependency
npm install mdfromx effect
Plain TypeScript
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 | ConvertResponse / string |
posts.replies(id) |
Replies | BrowseResponse |
profiles.get(handle) / profiles.markdown(handle) |
Profiles | BrowseResponse / string |
profiles.followers(handle) / profiles.following(handle) |
Connections | BrowseResponse |
profiles.importPosts(handle) |
Bulk import | ImportSuccessResponse |
profiles.streamPosts(handle) |
Bulk import as NDJSON | AsyncIterable<ImportStreamEvent> |
search.get(query) / search.markdown(query) |
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
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) and are handed out by the maintainer through GitHub 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 describes. A short page is not the end of the list.
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
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: 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).
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
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 anHttpClient, so you choose the transport and can swap it in tests. Its config takes the sameapiKey,baseUrlandretryas the plain client, and the key falls back to theMDFROMX_API_KEYconfig value.- The error channel is
MdfromxFailure: one tagged class per error code (RateLimited,NotFound,ImportBusy, …),UnknownApiErrorfor an unlisted provider code, Effect’sHttpClientErrorfor transport failures, andSchemaErrorwhen a response does not match the contract. - Paginated lists and
profiles.streamPostsareStreams. They pull pages lazily, soStream.takestops 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 covers what /api/v1 itself promises. Source: packages/sdk.