Agentside by Notra

Give AI agents the markdown version of every page

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 agentside

One config

Describe your twins once and mount them as middleware

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 sources
middleware.ts
import { 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

Same URL for people and agents

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/markdown

An 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/markdown

Clients 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/markdown

Known 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 + Link

Browsers 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

Test what each agent reads

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 variants
variants
agentside({
  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),
});
onServe event
{
  "path": "/pricing",
  "variant": "pricing-search",
  "agent": { "name": "OAI-SearchBot", "vendor": "OpenAI", "purpose": "search" },
  "status": 200,
  "cached": true,
  "durationMs": 0.21
}

Error handling

Agents never pay for a broken twin

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.

A failing variant falls back to control

If the experiment source throws or times out, the agent reads your control twin and onError tells you why.

A failing twin falls back to HTML

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.

Explicit .md URLs answer honestly

A missing twin returns 404 and a broken one returns 503 with Retry-After, both as markdown that an agent can read.

Remote sources retry and time out

Network errors and 5xx responses retry with exponential backoff, and every source has a timeout that defaults to five seconds.

Successes are cached and failures are not

Twins live in an Effect cache with a five minute TTL. Concurrent requests for the same twin share a single render.

Typed errors when you want them

agentside/effect exposes NotFound, RenderFailed, RemoteFailed and SourceTimeout as tagged errors you can match on.

Benchmarks

Faster than Dualmark and homegrown setups

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.

ScenarioAgentsideAI Inbx (homegrown)Dualmark by Dodo Payments
Agent gets a markdown page, cached2.67 µs2.89 µs41.2 µs (no cache)
Agent gets a markdown page, cache off15.4 µs3.3 ms41.2 µs
Agent detection~50 ns160 to 240 ns~500 ns
Browser request getting HTML1.47 µs20.5 µs2.26 µs
Agent hits a page whose render throwsServes the HTML page500Request throws
Agent hits a page with no markdown versionServes the HTML pageHTML page, or 406 for Accept: text/markdown alone404
Prerender twins to static filesagentside/build, fails the build on errorsYes, fails the build on errorsNext.js static params only

Run the benchmark harness yourself

Ship markdown twins in an afternoon

The docs cover every framework guide and walk you through moving over from Dualmark.