GET /pricing.md200 text/markdownAn explicit .md URL always asks for the twin. A missing twin answers with a markdown 404 instead of your HTML error page.
Agentside by Notra
Agentside serves a markdown twin of your pages to the agents that read your site and lets you A/B test what each of them sees. It is built on Effect and runs in any framework that speaks fetch.
npm install agentsideOne config
Routes map a path to a source. A source can be a literal string, a function that returns markdown or a remote URL. Return null when a page has no twin and Agentside steps aside.
The handler takes a standard Request and returns a standard Response, so the same config runs in your server middleware, an edge worker or a plain fetch handler.
Routes and sourcesimport { agentside, remote } from "agentside";
export const twins = agentside({
routes: {
"/": "# Acme\n\nAcme sends invoices your customers actually pay.",
"/pricing": () => renderPricingMarkdown(),
"/blog/[slug]": ({ params }) => getPost(params.slug)?.markdown ?? null,
"/docs/[...path]": remote(({ params }) => `https://cms.acme.dev/docs/${params.path}.md`),
},
skip: ["/api", "/assets"],
});
export const onRequest = (context, next) => twins.handle(context.request, () => next());Content negotiation
Agentside reads the URL, the Accept header and the user agent to decide who gets markdown. Anything it cannot serve passes through to your app, and an Accept header it cannot satisfy gets a 406.
GET /pricing.md200 text/markdownAn explicit .md URL always asks for the twin. A missing twin answers with a markdown 404 instead of your HTML error page.
Accept: text/markdown200 text/markdownClients that prefer markdown in their Accept header get it from the same URL, with q-values weighed against text/html.
User-Agent: GPTBot/1.2200 text/markdownKnown AI agents are detected by user agent and tagged with a vendor and a purpose of training, search or user action.
Accept: text/html200 text/html + LinkBrowsers get your page untouched. When a twin exists it carries a Link header with rel="alternate" so agents can find it.
A/B variants per agent
Give a route a list of variants and target them by agent name, vendor or purpose. The first variant that matches is served and every response carries an X-Agentside-Variant header.
onServe hands you an exposure event for each read, so a GEO team can line up variants with the citations and answers that follow.
A/B variantsagentside({
routes: { "/pricing": () => renderPricingMarkdown() },
variants: {
"/pricing": [
{ id: "pricing-search", purposes: ["search"], source: remote("https://cms.acme.dev/pricing-v2.md") },
{ id: "pricing-short", agents: ["ChatGPT-User", "Claude-User"], source: shortPricing },
],
},
onServe: (event) => analytics.track("agent_read", event),
});{
"path": "/pricing",
"variant": "pricing-search",
"agent": { "name": "OAI-SearchBot", "vendor": "OpenAI", "purpose": "search" },
"status": 200,
"cached": true,
"durationMs": 0.21
}Error handling
Every source runs inside Effect with retries, a timeout and typed failures. When something goes wrong Agentside picks the most useful answer it can still give.
If the experiment source throws or times out, the agent reads your control twin and onError tells you why.
A negotiated request whose twin is missing or broken gets your regular page, so agents never see a 404 or a 500 you did not choose.
A missing twin returns 404 and a broken one returns 503 with Retry-After, both as markdown that an agent can read.
Network errors and 5xx responses retry with exponential backoff, and every source has a timeout that defaults to five seconds.
Twins live in an Effect cache with a five minute TTL. Concurrent requests for the same twin share a single render.
agentside/effect exposes NotFound, RenderFailed, RemoteFailed and SourceTimeout as tagged errors you can match on.
Benchmarks
Lower is better. Agentside and Dualmark 0.11 run through the same harness on Bun 1.4 (Apple M5). The AI Inbx column is their own measurement of their homegrown setup on a real 39 KB pricing page, so read it as indicative.
| Scenario | AI Inbx (homegrown) | ||
|---|---|---|---|
| Agent gets a markdown page, cached | 2.67 µs | 2.89 µs | 41.2 µs (no cache) |
| Agent gets a markdown page, cache off | 15.4 µs | 3.3 ms | 41.2 µs |
| Agent detection | ~50 ns | 160 to 240 ns | ~500 ns |
| Browser request getting HTML | 1.47 µs | 20.5 µs | 2.26 µs |
| Agent hits a page whose render throws | Serves the HTML page | 500 | Request throws |
| Agent hits a page with no markdown version | Serves the HTML page | HTML page, or 406 for Accept: text/markdown alone | 404 |
| Prerender twins to static files | agentside/build, fails the build on errors | Yes, fails the build on errors | Next.js static params only |
The docs cover every framework guide and walk you through moving over from Dualmark.