How I share links with my agents

ai, agents, tooling, open-source5 min read

On this page

When I want an agent to read a page, I give it an xmd link: https://xmd.imfrank.dev/?url= followed by the URL. The agent gets the content as Markdown and nothing else. There’s no login, no API key, and no MCP server to set up. If the agent can make a GET request, it can use xmd. The code is at github.com/franklinjavier/xmd.

I use it with Grok Bot and Claude, for example to read the replies to a post on X.

Raw HTML is mostly noise

I tested it on a reply from @jack. A plain curl on x.com/jack/status/2110642522355638381 returns 160 KB of HTML. The post text is in there, between the “Log in” and “Sign up” buttons, but the replies aren’t. Through xmd with mode=replies, the same URL becomes 3.5 KB of Markdown with the post and 13 replies.

Link Original HTML xmd output
X post, replies mode 160 KB, no replies 3.5 KB, post + 13 replies
MDN, status 418 208 KB 3.8 KB
My StyleX post (Portuguese edition) 49 KB 12.5 KB, the Markdown the site serves itself
Excalidraw (SPA, needs JavaScript) 6.9 KB, almost all script 885 bytes, the text of the start screen

Numbers from October 21, 2026.

Using it

Paste the URL straight into the agent’s chat, a curl call, or a script:

https://xmd.imfrank.dev/?url=https://x.com/jack/status/2110642522355638381&mode=replies

The https:// is optional, so ?url=example.com works too. If the link has a ? or & in it, URL-encode it, or its parameters get mixed up with xmd’s. In a terminal, curl does the encoding for you:

curl -G 'https://xmd.imfrank.dev/' --data-urlencode 'url=https://example.com'

There’s also a CLI. It needs Bun 1.4 or later. To install it straight from GitHub:

bun add -g github:franklinjavier/xmd

Or clone the repo:

git clone https://github.com/franklinjavier/xmd && cd xmd && bun install && bun link

The install takes 34 MB on disk, mostly @cloudflare/puppeteer, which the endpoint uses to open a browser. The command is xmd, and x-md works too:

xmd https://example.com
xmd --mode replies https://x.com/jack/status/2110642522355638381

The CLI also asks the site for its Markdown first. When it has to convert, it does it locally with a built-in HTML converter. Workers AI and Browser Run, which come up next, only exist on the endpoint.

xmd only converts when the site has no Markdown

X links go to the public FxTwitter API, which returns the post as JSON without asking for a key. xmd builds the Markdown from that JSON.

For every other link, xmd asks the site for its own Markdown first. The request goes out with an Accept: text/markdown header. If HTML comes back, xmd looks for a <link rel="alternate" type="text/markdown"> on the page. If there isn’t one, it tries the same URL with .md on the end, the llms.txt convention. Only when all of that fails does the HTML go to Workers AI’s toMarkdown. Cloudflare’s docs say that conversion is free for most formats.

My blog answers in Markdown when it gets that header:

$ curl -sI -H 'Accept: text/markdown' https://franklinjavier.com/pt-br/blog/stylex-nao-deixou-o-site-mais-rapido/
HTTP/2 200
content-type: text/markdown; charset=utf-8
vary: Accept

Run that post through xmd and the Markdown comes straight from the site, with no conversion:

x-xmd-engine: native
x-xmd-source: native

MDN doesn’t return Markdown for any of those attempts, so the header shows a conversion:

x-xmd-engine: ai
x-xmd-source: ai

Some pages have no content until JavaScript runs. When the conversion comes back empty or looks like one of those pages, xmd opens the link in Browser Run, Cloudflare’s headless Chrome (formerly Browser Rendering), and converts the rendered HTML. On Excalidraw that took 5 seconds in one test and 7 in another, and it returned the text on the screen, down to “Pick a tool & Start drawing!”.

x-xmd-source and x-xmd-engine carry the same value and tell you where each response came from:

Value Where it came from
native The site's own Markdown (via Accept, rel=alternate, or the .md URL)
x X post, via FxTwitter
ai HTML converted by Workers AI
browser Page rendered in Browser Run, then converted
fallback Built-in converter, which also passes plain text through unchanged

On X, mode decides what comes along

Sometimes a post is a reply to someone, or the fifth post in a thread. The mode parameter decides what comes with it:

Mode What goes into the Markdown
post Just the linked post. This is the default.
ancestors The conversation from the first post down to the linked one, up to 32 levels.
thread The author's thread, in order. Other people's replies are left out.
replies The linked post and the replies to it from anyone, in chronological order. Permalinks and metrics go into a reference list at the end.

The start of replies on @jack’s post:

## Replies to @jack

### 1/14 · jack (@jack)

@marcportermagee if you want. or use the screen.

### 2/14 · The Nth ° (@The_future_i)

@jack @marcportermagee Jack. Help a Jack.

A Chrome extension, so you don’t build the URL by hand

The repo also has a Chrome extension in extension/. One click copies the xmd link for the current tab and opens it in a new tab. It isn’t on the Chrome Web Store. To install it:

curl -fsSL https://xmd.imfrank.dev/extension.sh | sh

The script downloads the extension to ~/xmd-extension and prints the steps to load the folder in Chrome.

Where it breaks

replies only gets one page of replies. @jack’s post shows 24 replies, and 13 came back. FxTwitter returns a single page, xmd doesn’t paginate, and the output ends with a note that there may be more. Deleted and protected posts don’t come back at all.

The conversion isn’t perfect either. On the MDN page, the “Skip to main content” links survive, along with a scrap of the original HTML, &lt;!doctype html>, right before the title.

The browser has a quota. Cloudflare’s free plan allows 10 minutes a day, 3 browsers at once, and one new instance every 20 seconds. When the browser is busy, xmd returns 503 with Retry-After: 60. When the daily quota runs out, it returns 503 until midnight UTC. xmd also caps the browser at 3 pages per minute per IP.

The endpoint accepts 30 uncached requests per minute per IP, then returns 429. It only fetches public pages: localhost, private IPs, and ports other than 80 and 443 get a 400 with “Blocked target”.