How to make SvelteKit pages readable by AI agents

Serve the same page as HTML to browsers and as Markdown or JSON to anything that asks with an Accept header or a .md extension. vela enable content-negotiation wires sveltekit-negotiate into your hooks, and each page decides what its Markdown looks like.

3 min read content-negotiationmarkdownaiagentssveltekit

More of a site’s readers are programs now: coding agents fetching your docs, assistants summarising a post, scripts checking a status page. They all get the HTML, then guess at where the content is in it. HTTP has had a better answer since the start: the client says what it can read in the Accept header, and the server picks a representation. vela enable content-negotiation sets that up so a page can answer text/markdown with clean Markdown and application/json with its data, from the same load that renders the HTML.

This page is served that way. Add .md to its URL, or fetch it with Accept: text/markdown, and you get the Markdown this tutorial was written in.

Prerequisites

Any SvelteKit project. No backend needed, and it does not have to be one vela created. The output below is from a vela project. In a plain npx sv create project the example page lands at src/routes/negotiate because there is no (public) group, <Negotiate /> goes into src/routes/+layout.svelte, and src/hooks.server.ts is created if the project has none.

Run the command

$ vela enable content-negotiation
✔ Installed sveltekit-negotiate
✔ Created 4 files, modified 3
  src/lib/negotiate.ts
  src/routes/(public)/negotiate/+page.server.ts
  src/routes/(public)/negotiate/+page.svelte
  src/hooks.server.ts
  src/hooks.ts
  src/routes/(public)/root-layout.svelte

What was generated

The formats

One module declares which types the site can serve and the URL extension that stands in for each header:

src/lib/negotiate.ts
import { createNegotiation } from "sveltekit-negotiate";

export const { handle, reroute, negotiate, Negotiate } = createNegotiation({
  "text/markdown": { extension: ".md" },
  "application/json": { extension: ".json" },
});

The hooks

The server hook reads the Accept header or the extension, lets the page render, and swaps the HTML response for the negotiated payload on the way out. It runs first in the sequence:

src/hooks.server.ts
import { env } from "$env/dynamic/private";
import { sequence } from "@sveltejs/kit/hooks";
import { handlePocketbase } from "@velastack/pocketbase";
import { handle as handleNegotiate } from "$lib/negotiate";

export const handle = sequence(
  handleNegotiate,
  handlePocketbase({
    pocketbaseUrl: env.POCKETBASE_URL,
    superuserEmail: env.POCKETBASE_SUPERUSER_EMAIL,
    superuserPassword: env.POCKETBASE_SUPERUSER_PASSWORD,
  }),
);

The universal reroute makes /about.md hit the /about route:

src/hooks.ts
import { reroute as negotiateReroute } from "$lib/negotiate";

export const reroute = ({ url }) => negotiateReroute(url.pathname);

A <Negotiate /> component is added to the public layout. It carries the payload out of the page render so the hook can pick it up.

An example page

/negotiate shows the whole idea in a dozen lines. The load returns its data for the HTML as usual, and spreads in a Markdown version. The handler only runs when Markdown was asked for:

src/routes/(public)/negotiate/+page.server.ts
import { negotiate } from "$lib/negotiate";

export const load = async ({ locals }) => {
  const message = "Hello, content negotiation!";

  return {
    message,
    ...negotiate(locals, {
      "text/markdown": () => `# ${message}`,
    }),
  };
};

Try it

$ vela dev
curl -H "Accept: text/markdown" http://localhost:5173/negotiate
# Hello, content negotiation!

curl http://localhost:5173/negotiate.md
# Hello, content negotiation!

A browser still gets the page. A page that has no handler for the requested type answers 406 Not Acceptable rather than HTML, so a client never mistakes one for the other. Every HTML response on a negotiable URL also carries Vary: accept, so a shared cache does not hand HTML to the next client asking for Markdown.

Make a real page negotiable

The blog is the obvious candidate: posts are Markdown already. Its content module exposes the raw source of a post, so a server load next to the post route can hand it over:

src/routes/(public)/blog/[slug]/+page.server.ts
import { negotiate } from "$lib/negotiate";
import { getBlogPost, getBlogPostRaw } from "$lib/content";

export const load = async ({ params, locals }) => {
  const post = getBlogPost(params.slug);

  return {
    ...negotiate(locals, {
      "text/markdown": () => getBlogPostRaw(params.slug) ?? "",
      "application/json": () =>
        post && { slug: post.slug, title: post.title, tags: post.tags },
    }),
  };
};

Now /blog/hello-world.md is the post as written, and /blog/hello-world.json is its metadata. The HTML page is unchanged.

Tip
Keep the Markdown handler cheap and the output honest: the frontmatter is usually worth a heading and a one-line description at the top, and root-relative links should become absolute, since the reader is not on your site.

Going further

Related tutorials

How to write a Markdown blog with SvelteKit

Posts are Markdown files in your repo. vela enable blog sets up mdsvex, a post list, tag pages and an RSS feed in one command, so publishing a post means adding a file and pushing.

4 min read

How to give users API keys for a SvelteKit app

Open your app's data as a REST API and let users create and revoke their own keys. vela enable api serves PocketBase's API under /api; vela enable api-keys adds the keys page, hashed secrets and Bearer-token access, with collection rules still in charge.

3 min read

How to let clients edit a SvelteKit site themselves

An admin bar on your own site where editors change copy in place, upload images and publish, with no redeploy. vela enable cms hosts the CMS inside your app, or points a static site at a hosted one, and you mark up which text is editable.

4 min read