md4a-fastapi
md4a-fastapi adds Markdown for Agents content negotiation to FastAPI applications.
When an agent sends Accept: text/markdown, an existing HTML route can return a
compact Markdown representation while normal browser requests remain unchanged.
The package also includes a client and standalone service for discovering whether remote pages natively serve Markdown, then buffering or storing verified Markdown responses.
Status: early alpha. APIs may change before the first stable release.
Features
- ASGI middleware designed for FastAPI
- WSGI middleware for Flask and other WSGI applications
Accept: text/markdownnegotiation for GET requests- HTML-to-Markdown conversion with transparent pass-through for other media types
- Synchronous provider hook for hand-authored Markdown
- In-memory and filesystem-backed content stores
- Remote Markdown discovery with redirect, host allowlist, timeout, and size controls
- Standalone FastAPI service and
md4acommand-line interface
Installation
python -m pip install md4a-fastapi # FastAPI and Flask middleware
python -m pip install 'md4a-fastapi[server]' # adds the md4a command, client, and service
With uv, use uv add md4a-fastapi or uv pip install md4a-fastapi the same way.
pip install md4a is the same install under a shorter name: the md4a
distribution depends on md4a-fastapi at the same version and adds no code.
| Install | What you get | Extra dependencies |
|---|---|---|
md4a-fastapi |
ASGI and WSGI middleware, content stores | beautifulsoup4, markdownify, fastapi |
md4a-fastapi[server] |
also the remote client, standalone service, md4a command |
httpx, anyio, uvicorn[standard] |
md4a-fastapi[flask] |
the same as the base install | none |
The WSGI middleware needs nothing beyond the base install and never imports
FastAPI, so a Flask application can use the base install as it is. [flask] and
[wsgi] are accepted as names for it. Without the server extra, importing
md4a.client or running md4a says which package to install.
Development setup
Using uv (recommended):
git clone https://github.com/adamtasteslikegood/md4a-fastapi.git
cd md4a-fastapi
uv sync --extra test
This creates or updates the project virtual environment in .venv, installs
the package in editable mode, and installs the test tools. No activation is
needed when using the uv run commands below. If you prefer to activate the
environment, use source .venv/bin/activate on macOS/Linux or
.venv\Scripts\Activate.ps1 in Windows PowerShell, then run the commands
without the uv run prefix.
Using Python's built-in venv and pip:
git clone https://github.com/adamtasteslikegood/md4a-fastapi.git
cd md4a-fastapi
python -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e '.[test]'
FastAPI middleware
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from md4a import FileStore, add_md4a
app = FastAPI()
add_md4a(app, store=FileStore(".md4a-cache"))
@app.get("/", response_class=HTMLResponse)
def home() -> str:
return "<h1>Hello agents</h1><p>This remains HTML for browsers.</p>"
Request either representation from the same URL:
curl http://localhost:8000/
curl -H 'Accept: text/markdown' http://localhost:8000/
For hand-authored Markdown, provide a callable that returns Markdown or None:
pages = {"/": "# Hello agents\n"}
add_md4a(app, provider=lambda key: pages.get(key), convert_html=False)
Provider and cached content take precedence. Otherwise, when convert_html=True,
the middleware executes the normal route, converts a successful HTML response,
caches it by path and query string, and responds as text/markdown. JSON and other
response types pass through unchanged.
Converted responses retain application headers, including security headers, CORS,
and repeated Set-Cookie fields. md4a recalculates the content type and length,
merges Accept into Vary, and removes HTML-specific validators, digests, and
range metadata. Compressed, partial, and trailer-bearing responses pass through
unchanged.
Only successful HTML and Markdown responses are buffered, up to max_html_bytes
(5 MB by default; pass max_html_bytes= to add_md4a). Everything else, including
streaming responses and bodies over the limit, is forwarded as it arrives and is
neither converted nor cached. Bodies are decoded with the charset declared in
Content-Type (UTF-8 when absent); a response that cannot be decoded is returned
unchanged and is not cached.
For headers that must be present on every response, register your security/CORS
middleware after add_md4a(app) so it wraps md4a. Cache hits and provider
responses skip the wrapped application; stores hold Markdown text only, not
response headers. Use caching for public, non-personalized content, and keep
authentication outside md4a: route-level authentication does not run on these
shortcuts. See middleware ordering.
Converting only the content
By default the whole HTML document is converted, navigation and footer included.
content_selector is a CSS selector; only its first match is converted.
strip_selectors are CSS selectors whose elements are removed first:
add_md4a(app, content_selector="main", strip_selectors=[".related", "form"])
A page the content selector does not match is converted whole. Provider, cached, and application-authored Markdown are not affected.
Escaping untrusted text
Text on a page can contain Markdown syntax that an agent would read as a link or
image, for example a user comment such as [Free coupons](https://evil.example).
Two options keep such text inert. Both are off by default.
add_md4a(app, escape_misc=True, literal_selectors=['[data-md="plain"]'])
escape_misc=Trueescapes Markdown punctuation ([,],#,>, and the like) in all page text, so it cannot form links, images, or headings. Some text gains backslashes, which Markdown readers drop.literal_selectorsare CSS selectors for elements whose text is emitted as an inline code span. Escaping does not stop bare URLs and email addresses from being linked, but nothing inside a code span is, so use this for user-written text. Whitespace is collapsed to single spaces and nested markup is kept as text. A matched link, image, or code element is replaced by the span, so its own syntax cannot wrap the text; in a table cell, pipes are escaped.
Use the two together: without escape_misc, a stray backtick in the page's own
text can open a code span that runs into the literal one. Both options work the
same in the WSGI middleware.
Metadata and structured data
Three options, all off by default, follow the output format of Cloudflare's Markdown for Agents:
add_md4a(
app,
content_selector="main",
frontmatter=True,
json_ld=True,
content_signal="ai-train=no, search=yes, ai-input=yes",
)
frontmatter=Truestarts the Markdown with a YAML block holdingtitle,description, andimagefrom the page's meta tags.<meta name="title">and<meta name="description">win overog:titleandog:description, andimagecomes fromog:image. Only fields with a value are written, and there is no block when none are. Values are quoted, so page text cannot add YAML keys.json_ld=Trueends the Markdown with each valid<script type="application/ld+json">block from the page, such as Recipe or Article schema, as a fencedjsonblock. Invalid JSON,NaN/Infinity, and data nested too deeply to parse are left out; text that cannot be sent as UTF-8 stays escaped.content_signaladds a Content-Signal header to the Markdown responses md4a sends, whether converted, cached, or from a provider. An application response that already hasContent-Signalkeeps its own, and is not stored, since the store keeps only the body and a cache hit would otherwise get the fallback. Cloudflare's default isai-train=yes, search=yes, ai-input=yes.
Frontmatter and JSON-LD are read from the whole document, so <head> metadata is
kept even with content_selector. They apply to converted HTML only; provider,
cached, and application-authored Markdown is served as it is.
Flask and other WSGI applications
MarkdownForAgentsWSGIMiddleware applies the same negotiation to a WSGI
application. It takes the same options as add_md4a:
from flask import Flask
from md4a import FileStore, MarkdownForAgentsWSGIMiddleware
app = Flask(__name__)
app.wsgi_app = MarkdownForAgentsWSGIMiddleware(app.wsgi_app, store=FileStore(".md4a-cache"))
@app.get("/")
def home() -> str:
return "<h1>Hello agents</h1><p>This remains HTML for browsers.</p>"
The rules above apply unchanged: provider and cached content take precedence,
only successful HTML and Markdown GET responses up to max_html_bytes are
buffered, application headers are kept, and compressed, partial, and oversized
responses pass through. The cache key is the percent-encoded SCRIPT_NAME plus
PATH_INFO, followed by ? and the query string when there is one.
A response the application sends through the legacy write() callable passes
through unconverted. Importing md4a.wsgi, or MarkdownForAgentsWSGIMiddleware
from md4a, does not import FastAPI or Starlette, although the base install
still includes them.
Use WSGI conversion for ordinary buffered page responses, not streaming HTML
iterables. The middleware must see the complete eligible body before it can choose
converted headers or an unchanged size/decoding fallback, so that conversion path
cannot preserve PEP 3333 asynchronous block-boundary scheduling. Responses whose
initial status and headers establish pass-through return the application's original
iterable, including wsgi.file_wrapper objects used for optimized file transfer.
As with ASGI, cache and provider hits return before the wrapped application runs, so wrap only public, non-personalized pages and keep authentication outside md4a.
Cache lifetime
Cached Markdown is served until the store drops it, and by default a store never does: a converted page stays as it was first rendered. Give the store a lifetime, bound it, or stop caching responses:
from md4a import FileStore, MemoryStore, add_md4a
add_md4a(app, store=MemoryStore(ttl=300, max_entries=1000)) # 5 minutes, 1000 pages
add_md4a(app, store=FileStore(".md4a-cache", ttl=3600)) # 1 hour, on disk
add_md4a(app, cache_responses=False) # convert every request
ttlis in seconds. An entry that old is treated as missing, so the route runs again and the fresh result is stored.max_entries(MemoryStoreonly) drops expired entries, then the oldest stored, when the limit is reached. The cache key includes the query string, so set it when callers control the query.store.delete(key)andstore.clear()remove entries by hand, for example after publishing a change.cache_responses=Falsestops md4a writing responses to the store. Content already in the store, and provider content, is still served.
Caching and CDNs
A URL that md4a negotiates has two representations, HTML and Markdown, so a
shared cache must key both on the Accept request header. md4a adds Accept to
Vary on every GET and HEAD response whose media type is text/html or
text/markdown: converted Markdown, and the HTML the application sends to
browsers. It merges with the application's own Vary and leaves Vary: * alone.
Other responses are untouched. Pass vary_html=False to stop it adding Vary
to responses it does not convert.
md4a sets no Cache-Control; converted responses keep the application's caching
headers, minus validators such as ETag that described the HTML bytes.
Cloudflare
By default Cloudflare ignores Vary when it caches. That is safe while HTML is
not cached, which is Cloudflare's default. If a "Cache Everything" or other Cache
Rule makes negotiated pages cacheable, whichever representation is cached first
is served to everyone. Choose one of these:
-
Keep negotiated pages out of the cache. Exclude their paths from any rule that makes HTML eligible for cache.
-
Bypass the cache for Markdown requests. Add a Cache Rule with the custom filter expression below and Cache eligibility: Bypass cache, placed after any rule that makes those pages eligible. Browsers keep getting cached HTML; agents always reach the origin.
any(lower(http.request.headers["accept"][*])[*] contains "text/markdown")
Header values keep the client's casing and
containsis case-sensitive, while md4a readsAcceptcase-insensitively, so the expression lowercases eachAcceptvalue first. To check it on a staging zone with a cold cache, request Markdown with unusual casing, then HTML, and confirm the HTML request gets HTML:curl -sI -H 'Accept: Text/Markdown' https://staging.example.com/page | grep -i content-type curl -sI -H 'Accept: text/html' https://staging.example.com/page | grep -i content-type
-
Cache both representations. In a Cache Rule for those pages, set Vary for
accepttonormalize, with media typestext/htmlandtext/markdown. Cloudflare then stores one version per normalizedAcceptvalue and forwards the normalizedAcceptto the origin. This needsVary: Accepton both representations, which md4a sends unlessvary_html=False.
Cache Rules, including Bypass and the Vary setting, are available on every plan. See Cloudflare's Vary and Cache Rules settings documentation.
For other CDNs and reverse proxies, make sure they honor Vary: Accept or add
Accept to the cache key for these pages.
Remote discovery and standalone service
These need the server extra: python -m pip install 'md4a-fastapi[server]'.
With the uv-managed project environment, run the CLI with uv run:
# Show all commands
uv run md4a -h
# Verify that the origin returns text/markdown, then save it
uv run md4a fetch https://blog.cloudflare.com/the-agentic-internet/ -o article.md
# Start the standalone API
uv run md4a serve --host 127.0.0.1 --port 8000
After installing with pip, use the same commands without the uv run prefix:
# Show all commands
md4a -h
# Verify that the origin returns text/markdown, then save it
md4a fetch https://blog.cloudflare.com/the-agentic-internet/ -o article.md
# Start the standalone API
md4a serve --host 127.0.0.1 --port 8000
curl -H 'Accept: text/markdown' \
'http://127.0.0.1:8000/fetch?url=https%3A%2F%2Fblog.cloudflare.com%2Fthe-agentic-internet%2F'
The uv and pip commands above start the same standalone FastAPI service. Keep
the server running in one terminal and use the curl request from another.
The remote client sends Accept: text/markdown and only considers the page
available when the origin confirms Content-Type: text/markdown. Redirects are
validated before following them, and responses are capped at 5 MB by default.
Standalone configuration:
| Variable | Default | Purpose |
|---|---|---|
MD4A_CACHE_DIR |
.md4a-cache |
Directory for fetched Markdown and its index |
MD4A_ALLOWED_HOSTS |
unrestricted | Comma-separated hostname allowlist |
MD4A_ALLOW_PRIVATE_ADDRESSES |
off | Set to 1, true, or yes to permit non-public destinations |
The client refuses destinations that are, or resolve to, loopback, private,
link-local, or other non-public addresses, on the first request and on every
redirect, and connects to the address it checked. This applies to allowlisted hosts
too. Behind an environment proxy the proxy resolves names, so only literal addresses
and hosts that bypass the proxy are checked; see SECURITY.md. For local development, pass allow_private_addresses=True to
MarkdownForAgentsClient, --allow-private-addresses to md4a fetch, or set
MD4A_ALLOW_PRIVATE_ADDRESSES for the service.
Always set MD4A_ALLOWED_HOSTS before exposing the standalone fetch service to
untrusted users. An unrestricted URL-fetching endpoint can create an SSRF risk.
Documentation
Project scope
md4a-fastapi focuses on FastAPI/ASGI integration, a WSGI middleware with the same
behavior, and verified Markdown retrieval.
It does not attempt to reproduce a browser, execute JavaScript, or claim that
converted Markdown is identical to a publisher-authored agent representation.
License
Licensed under the MIT License.
Metadata
Release files for md4a-fastapi 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| md4a_fastapi-0.2.0.tar.gz | 49.9 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| md4a_fastapi-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 80.9 kB
Release files / md4a_fastapi-0.2.0.tar.gz
| Download URL | md4a_fastapi-0.2.0.tar.gz |
|---|---|
| Size | 49.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
935bf46cf5631f7acbcfa93757c173de0b3e636c7e752bc514e5e8c5babfd778
|
|
BLAKE2b-256 checksum How to use checksums |
1aaaec11782e7d7f17039533e6fdf0683dfcd8bba0a75a79cea52db27fb2c1cd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.
Transparency logRelease files / md4a_fastapi-0.2.0-py3-none-any.whl
| Download URL | md4a_fastapi-0.2.0-py3-none-any.whl |
|---|---|
| Size | 30.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
47bc47342df4ad78ddfde9d87a378e445aa334e3dae18f1e216df41e2c657b28
|
|
BLAKE2b-256 checksum How to use checksums |
cfe68fddcb0be4904486dded52957b3e5ee806d0c2f660e9bcc8dcb8c6a86686
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.
Transparency logSecurity for md4a-fastapi 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for md4a_fastapi-0.2.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.