@vercel/agent-readability
v0.4.0
Published
Detect AI agents. Serve them markdown. Audit your site against the Agent Readability Spec.
Maintainers
Readme
@vercel/agent-readability
Detect AI agents. Serve them markdown. Audit your site against the Agent Readability Spec.
Install
npm install @vercel/agent-readabilityOr audit without installing:
npx @vercel/agent-readability audit https://vercel.com/docsQuick Start
Next.js middleware.ts:
import { withAgentReadability } from '@vercel/agent-readability/next'
export default withAgentReadability({
rewrite: (pathname) => `/api/docs-md${pathname}`,
})
export const config = { matcher: ['/docs/:path*'] }SvelteKit hooks.server.ts:
import { handleAgentReadability } from '@vercel/agent-readability/sveltekit'
import { sequence } from '@sveltejs/kit/hooks'
export const handle = sequence(
handleAgentReadability({ rewrite: (p) => `/api/docs-md${p}` }),
)Nuxt server/middleware/agent.ts:
import { defineAgentMiddleware } from '@vercel/agent-readability/nuxt'
export default defineAgentMiddleware({
getMarkdown: async (pathname) => {
const doc = await fetchDoc(pathname)
return doc.markdown
},
})Agents hitting /docs/* get markdown instead of HTML.
How Detection Works
Three layers, checked in order:
- Known UA patterns. 30+ agents (ClaudeBot, GPTBot, Cursor, Perplexity, etc.)
- Signature-Agent header. ChatGPT agent via RFC 9421.
- sec-fetch-mode heuristic. Unknown bots lacking browser fingerprints.
Optimizes for recall over precision. Serving markdown to a non-AI bot is cheap. Missing an AI agent is not.
Core API
isAIAgent(request)
import { isAIAgent } from '@vercel/agent-readability'
const result = isAIAgent(request)
// { detected: true, method: 'ua-match' }Accepts any object with headers.get() (Request, NextRequest, etc.)
Returns:
{ detected: true, method: 'ua-match' | 'signature-agent' | 'heuristic' }{ detected: false, method: null }
acceptsMarkdown(request)
import { acceptsMarkdown } from '@vercel/agent-readability'
if (acceptsMarkdown(request)) {
return new Response(markdown, {
headers: { 'Content-Type': 'text/markdown', 'Vary': 'Accept' },
})
}shouldServeMarkdown(request)
Combines detection + content negotiation.
import { shouldServeMarkdown } from '@vercel/agent-readability'
const { serve, reason } = shouldServeMarkdown(request)
// serve: true, reason: 'agent' | 'accept-header'generateNotFoundMarkdown(path, options?)
Markdown body for missing pages. Return with 200 on the canonical URL (agents discard 404 bodies).
import { generateNotFoundMarkdown } from '@vercel/agent-readability'
const md = generateNotFoundMarkdown('/docs/missing', {
baseUrl: 'https://example.com',
})
return new Response(md, {
headers: { 'Content-Type': 'text/markdown', 'Vary': 'Accept' },
})Keep suggested page links canonical (/docs/page) and negotiate markdown with
Accept: text/markdown rather than exposing .md page URLs in not-found
responses.
Pattern Exports
AI_AGENT_UA_PATTERNS, TRADITIONAL_BOT_PATTERNS, SIGNATURE_AGENT_DOMAINS,
and BOT_LIKE_REGEX are all exported.
Next.js Adapter
withAgentReadability(options, handler?)
Works with Next.js 14 and 15 (Pages and App Router).
import { withAgentReadability } from '@vercel/agent-readability/next'
export default withAgentReadability({
docsPrefix: '/docs',
rewrite: (pathname) => `/en/llms.mdx/${pathname.replace('/docs/', '')}`,
onDetection: async ({ path, method }) => {
await trackMdRequest({ path, detectionMethod: method })
},
})onDetection runs via event.waitUntil() and does not block the response.
Composing with existing middleware
export default withAgentReadability(
{ rewrite: (p) => `/md${p}` },
(req, event) => i18nMiddleware(req, event),
)Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| docsPrefix | string | '/docs' | URL prefix to intercept |
| rewrite | (pathname: string) => string | required | Maps request path to markdown route |
| onDetection | (info) => void \| Promise<void> | - | Analytics callback (runs in waitUntil) |
agentReadabilityMatcher
Excludes Next.js internals and static files. Use for site-wide detection:
import { withAgentReadability, agentReadabilityMatcher } from '@vercel/agent-readability/next'
export default withAgentReadability({
docsPrefix: '/',
rewrite: (pathname) => `/md${pathname}`,
})
export const config = {
matcher: agentReadabilityMatcher,
}SvelteKit Adapter
handleAgentReadability(options)
Returns a Handle function. Requires SvelteKit 2+. Uses event.fetch()
for zero-cost internal routing to your +server.ts markdown routes.
// hooks.server.ts
import { handleAgentReadability } from '@vercel/agent-readability/sveltekit'
import { sequence } from '@sveltejs/kit/hooks'
export const handle = sequence(
handleAgentReadability({
docsPrefix: '/docs',
rewrite: (pathname) => `/api/docs-md${pathname}`,
onDetection: ({ path, method }) => {
console.log(`Agent detected: ${method} on ${path}`)
},
}),
)Automatically guards against infinite loops (isSubRequest), skips client
navigation requests (isDataRequest), and falls through if the rewrite
target returns non-OK.
Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| docsPrefix | string | '/docs' | URL prefix to intercept |
| rewrite | (pathname: string) => string | required | Maps request path to +server.ts route |
| onDetection | (info) => void \| Promise<void> | - | Fire-and-forget analytics callback |
Nuxt Adapter
defineAgentMiddleware(options)
Wraps defineEventHandler. Requires h3 1.8+ (ships with Nuxt 3/4).
Uses a getMarkdown callback instead of rewrite since Nuxt has no
zero-cost internal fetch.
// server/middleware/agent.ts
import { defineAgentMiddleware } from '@vercel/agent-readability/nuxt'
export default defineAgentMiddleware({
docsPrefix: '/docs',
getMarkdown: async (pathname, event) => {
const doc = await queryContent(pathname).findOne()
return doc.body
},
})getMarkdown can return a string (auto-wrapped with text/markdown headers)
or a Response for full control.
Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| docsPrefix | string | '/docs' | URL prefix to intercept |
| getMarkdown | (pathname, event) => string \| Response \| Promise<...> | required | Returns markdown content |
| onDetection | (info) => void \| Promise<void> | - | Fire-and-forget analytics callback |
Audit CLI
npx @vercel/agent-readability audit https://sdk.vercel.ai25 weighted checks across 4 categories. Score 0-100. Failed checks include fix suggestions you can paste into your coding agent.
CI
- name: Audit agent readability
run: npx @vercel/agent-readability audit ${{ env.SITE_URL }} --min-score 70 --jsonExit code 1 if score is below threshold.
| Flag | Description |
|------|-------------|
| --json | Output as JSON |
| --min-score <n> | Exit with error if score < n |
Caching
Set Vary: Accept on markdown responses so CDNs don't serve cached
HTML to agents (or cached markdown to browsers).
The SvelteKit and Nuxt adapters set Vary: Accept automatically. The
Next.js adapter rewrites the URL, so your markdown route handler must
set Vary: Accept and Content-Type: text/markdown.
Edge Runtime
Core library and all adapters use Web APIs only. Works in Vercel Edge
Runtime, Cloudflare Workers, and any Request/Response environment.
CLI requires Node.js 20+.
License
MIT
