← Back to Blog

Fix The Markdown For Agents Warning On AWS CloudFront — Lambda@Edge Pattern

Fix The Markdown For Agents Warning On AWS CloudFront — Lambda@Edge Pattern

If you ran a URL through the Agent Runtime Readiness audit and the third check came back amber, you saw:

Host did not return Markdown content when Accept: text/markdown was requested. Enable Cloudflare Markdown for Agents or implement content negotiation at your origin.

If your site sits behind AWS CloudFront, neither suggestion is a one-toggle fix — there is no managed Markdown-for-Agents feature in CloudFront as of writing. The fix is a Lambda@Edge function attached to your distribution. Here's the working pattern.

CloudFront Functions vs Lambda@Edge — and why this needs Lambda@Edge

CloudFront has two compute layers, and the choice matters for this use case.

CloudFront Functions are tiny, fast, JavaScript-only, run at every edge location, and have a sub-millisecond budget. They can read and modify request and response headers. They cannot fetch a different body, do async work, or call external services. For Markdown for Agents you need to either return a different body or fetch a different origin object — neither is possible from CloudFront Functions.

Lambda@Edge is full Node.js (or Python), runs at the regional edge cache, can do async fetches, and can rewrite the request URL or replace the response body. For this feature, this is the right layer.

The trade-off is latency and cost. Lambda@Edge invocations run at regional edge caches (a smaller set than every-edge-location), so cold-start latency is higher than CloudFront Functions. But for Accept: text/markdown requests — which are coming from AI runtimes, not human visitors waiting for a page — the latency is acceptable.

Pattern A — Rewrite the request URL to a .md companion (viewer-request trigger)

If your origin (S3 bucket, custom origin, app server) already serves the markdown source files alongside the HTML, the cleanest implementation is a viewer-request Lambda that rewrites the request URL when Accept: text/markdown is present.

// markdown-negotiation/index.js
'use strict';

exports.handler = async (event) => {
  const request = event.Records[0].cf.request;
  const headers = request.headers;
  const accept = (headers['accept'] && headers['accept'][0] && headers['accept'][0].value) || '';

  if (!/text\/markdown/i.test(accept)) return request;

  // Rewrite /blog/foo/ → /blog/foo/index.md
  // Rewrite /blog/foo  → /blog/foo.md
  let uri = request.uri;
  if (uri.endsWith('/')) uri += 'index.md';
  else if (!uri.endsWith('.md')) uri += '.md';

  request.uri = uri;
  return request;
};

Deploy this as a Lambda@Edge function (Node.js 20.x), then attach it to your CloudFront distribution's behavior on the Viewer Request event. The function rewrites the URL before CloudFront fetches from origin; your S3 bucket or custom origin serves the .md file directly.

You also need to make sure CloudFront caches the markdown response separately from the HTML response. In your distribution behavior:

  1. Set the Cache Policy to one that includes Accept in the cached headers (create a custom Cache Policy if needed — CloudFront's managed CachingOptimized policy doesn't include Accept).
  2. Set the response Vary header to Accept via a Response Headers Policy or via a separate origin-response Lambda.

Without one or the other, CloudFront will serve cached HTML to a markdown request (or vice versa).

Pattern B — Convert HTML to markdown in origin-response (origin-response trigger)

If your origin is a CMS or app server that doesn't have markdown source available, convert the HTML response in flight using an origin-response Lambda.

// markdown-converter/index.js
'use strict';
const TurndownService = require('turndown');

exports.handler = async (event) => {
  const cf = event.Records[0].cf;
  const request = cf.request;
  const response = cf.response;

  const accept = (request.headers['accept'] && request.headers['accept'][0] && request.headers['accept'][0].value) || '';
  if (!/text\/markdown/i.test(accept)) return response;

  // Origin must return body inline; configure Lambda to include body
  const body = response.body || '';
  const mainMatch = body.match(/<main[^>]*>([\s\S]*?)<\/main>/i);
  const target = mainMatch ? mainMatch[1] : body;

  const td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
  const md = td.turndown(target);

  response.body = md;
  response.bodyEncoding = 'text';
  response.headers['content-type'] = [{ key: 'Content-Type', value: 'text/markdown; charset=utf-8' }];
  response.headers['vary'] = [{ key: 'Vary', value: 'Accept' }];
  return response;
};

For origin-response Lambda to receive the response body, the function must be configured with Include Body enabled. The body size limit is 1 MB for viewer-response, 1 MB for origin-response — most article pages are well under this, but be aware of the limit if your pages are large.

The conversion runs once per cache miss; subsequent requests for the same URL with Accept: text/markdown are served from CloudFront's cache without re-invoking Lambda.

Cache Policy and Vary header — the part that breaks deployments

The most common reason this implementation appears to work in curl but fails in the audit is that CloudFront is serving cached HTML to the audit's markdown request. CloudFront's default Cache Policy does not include the Accept request header in the cache key. Two requests to the same URL with different Accept values get the same cached response.

Fix:

  1. Create a custom Cache Policy that includes Accept in Headers (Cache key and origin requests → Headers → Include the following headers → Add Accept).
  2. Attach the new policy to the distribution behavior the Lambda is attached to.
  3. Add a Response Headers Policy that always includes Vary: Accept on responses (or set it from the Lambda as in Pattern B above).

Until both are in place, your edge cache will serve the wrong shape to the wrong client unpredictably.

Verifying the fix

curl -s -H "Accept: text/markdown" -i https://yourdistribution.cloudfront.net/some-page/ | head -10

Look for content-type: text/markdown and vary: Accept in the response. Re-run the Agent Runtime Readiness audit — the third check should pass.

If the audit still warns, the most common causes are:

  • Cache key didn't include Accept. Test by adding a query string cachebust (?_t=12345) to the URL — if the markdown response shows up, your Lambda is working but the cache is collapsing. Fix the Cache Policy.
  • Lambda@Edge replication is not complete. When you publish a Lambda@Edge function or attach it to a behavior, replication to all regional edge caches takes a few minutes. Wait 5 minutes and re-test.
  • The audit's proxy is being blocked. Some CloudFront WAF rules return a challenge to bot user agents. The audit's proxy uses a generic UA; if your WAF is aggressive, the audit may never reach Lambda. Check WAF logs.

What this costs

Lambda@Edge is billed per invocation and per GB-second. A typical viewer-request rewrite Lambda runs in single-digit milliseconds and costs essentially nothing per request — pennies per million invocations. The origin-response Lambda with HTML-to-markdown conversion is more expensive (the conversion takes 50-200 ms depending on page size), but still in the dollars-per-month range for a moderately trafficked site, and only fires on cache misses.

Related reading

Fact-check notes and sources

If you're managing CloudFront as part of a larger stack and want a lighter operating model that avoids the AWS console-drag, The $20 Dollar Agency covers the build-your-own-web stance from the operations side.

This post is informational, not legal, security, or SEO-consulting advice. Mentions of AWS, CloudFront, Lambda@Edge, and other third parties are nominative fair use; no affiliation is implied.

← Back to Blog

Accessibility Options

Text Size
High Contrast
Reduce Motion
Reading Guide
Link Highlighting
Accessibility Statement

J.A. Watte is committed to ensuring digital accessibility for people with disabilities. This site conforms to WCAG 2.1 and 2.2 Level AA guidelines.

Measures Taken

  • Semantic HTML with proper heading hierarchy
  • ARIA labels and roles for interactive components
  • Color contrast ratios meeting WCAG AA (4.5:1)
  • Full keyboard navigation support
  • Skip navigation link
  • Visible focus indicators (3:1 contrast)
  • 44px minimum touch/click targets
  • Dark/light theme with system preference detection
  • Responsive design for all devices
  • Reduced motion support (CSS + toggle)
  • Text size customization (14px–20px)
  • Print stylesheet

Feedback

Contact: jwatte.com/contact

Full Accessibility StatementPrivacy Policy

Last updated: April 2026