> ## Documentation Index
> Fetch the complete documentation index at: https://margin.manicule.dev/llms.txt
> Use this file to discover all available pages before exploring further.
> For every page in one file, fetch https://margin.manicule.dev/llms-full.txt (large).
> To search and read these docs as tools, connect the MCP server at https://margin.manicule.dev/api/mcp.
> Every agent surface: https://margin.manicule.dev/.well-known/llms-manifest.json

# Extensions (/extensions)

<!-- agent-signals: reading_time_min: 5 · est_tokens: 2059 · updated: 2026-10-13 -->
Related: [Margin](/index.md), [Configuration](/configuration.md), [Deploy](/deploy.md), [Agents and discovery](/agents.md), [Link relations](/link-relations.md)

> Replace the single-site request policy, resolution, or telemetry module.

The framework serves one documentation site out of the box. Everything a host
decides per request — which site a request serves, which content version,
whether to redirect, how to tag the CDN cache, what telemetry to attach — goes
through one seam: a module exporting `extensions: DocsExtensions`. The types
live in `src/extensions/types.ts`; that file is the whole contract.

You do not need an extension to deploy. Replace the default only when you host
several sites from one deployment, route by hostname, or attach your own
telemetry.

## Choosing the module

Set `DOCS_EXTENSIONS=./extensions.ts` and rebuild to select a root-level module.

The server imports `#docs-extensions`. `vite.config.ts` and
`vitest.config.ts` alias it to:

* `src/extensions/default.ts` when `DOCS_EXTENSIONS` is unset, or
* the file `DOCS_EXTENSIONS` names, resolved against the framework's root
  directory when relative.

The choice is made at build time, so set `DOCS_EXTENSIONS` in the build
environment. TypeScript resolves `#docs-extensions` to the default through
`tsconfig.json` `paths`; your module must export the same name and type:

For a local `extensions.ts` at the repository root, extend the working default
and add the hooks you need:

```ts
import type { DocsExtensions } from "./src/extensions/types"
import { extensions as defaultExtensions } from "./src/extensions/default"

export const extensions: DocsExtensions = {
    ...defaultExtensions,
    telemetry: {
        eventProperties(request) {
            return { docsHost: new URL(request.url).host }
        },
    },
}
```

With `DOCS_EXTENSIONS` set, the test suite resolves `#docs-extensions` to your
module, so only code that imports `#docs-extensions` exercises it; the default
extension's own tests import `src/extensions/default.ts` directly.

## The contract

### `resolveSite(request): Promise<SiteResolution>`

Called once per request (the result is shared by every reader of that
request). It returns:

| Field             | Meaning                                                                                                                                                                                                                                 |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `site`            | The `DocsSite` the request serves, or `null` when the request names no site. Production answers `null` with 404; previews render an empty, uncacheable page.                                                                            |
| `versionSelector` | Which content version to serve: `"current"` (the site's live pointer), `{ kind: "commit", sha }` (an exact ingested commit) or `{ kind: "preview", shaPrefix }` (a preview build by commit prefix; an ambiguous prefix serves nothing). |
| `preview`         | `true` makes every response `no-store` and `noindex`.                                                                                                                                                                                   |
| `canonicalHost`   | Production only: when set and different from the request's host, the request is answered with a 308 to this host. `null` never redirects.                                                                                               |

The server, not the extension, applies the serving gates: a version is served
only when it is `ready` and the site's `status` is `active`, read live on every
request. An extension may therefore cache `site` without delaying a suspension.

`DocsSite` carries the fields the server needs from the `docs_site` row: `id`,
`slug` (the public identifier used in analytics, cache tags and URLs), `name`,
`description`, `repo` (`{ owner, name, branch, subdir }` or `null`),
`analyticsEnabled` (`false` suppresses the site's configured analytics
integrations) and `webmcpEnabled`.

### `requestPolicy(request): RequestPolicy`

Called on every request before any database read, so it must be cheap and
synchronous.

| Field          | Meaning                                                                                                                                  |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `hstsPreload`  | `true` sends HSTS with `preload; includeSubDomains`; `false` sends a plain `max-age`. Only enable it for domains you control end to end. |
| `cacheTag`     | The `Vercel-Cache-Tag` for purgeable responses: at most 256 characters, no commas. `null` sends no tag.                                  |
| `preview`      | Treat the request as a preview without consulting the database.                                                                          |
| `shortCircuit` | A `Response` to return immediately for the whole request, or `null`.                                                                     |

### `telemetry`

All hooks are optional.

* `eventProperties(request, resolution)` adds properties to every analytics
  event.
* `groups(resolution, request)` returns an analytics group (`groupType`,
  `groupKey`, optional `properties`) for the event and its one-time group
  identify, or `null`. `resolution` is `null` when the site lookup failed, so
  a host can still group by the request.
* `onEvent(event)` receives every observability event the framework emits.
* `visitorKey(request, resolution)` is the stable input the cookieless
  visitor and session ids hash alongside the address and user agent.
  Defaults to the served site's slug (the request host when no site
  resolves).

### `webmcpRollout(site, posthog)` (optional)

Turns WebMCP on for a site in addition to `site.webmcpEnabled`. It runs on
every page render and must never block: answer from a cache and refresh in the
background.

### `rateLimitScope(site)` (optional)

The identity the public rate limiter and the feedback signature key on.
Defaults to `site.id`.

### `clientIp(request)` (optional)

The caller's address for the public rate limiters (chat, feedback), stored
only hashed. Return `undefined` to fall back to the default: Vercel's
`x-vercel-forwarded-for`, then `x-forwarded-for`. A host behind another proxy
(Cloudflare, say) answers with that proxy's client-address header — only for
requests it knows came through the proxy.

## The default extension

`src/extensions/default.ts` serves a single site:

* **Site:** the `docs_site` row whose `slug` is `SITE_SLUG` (default `docs`).
  Lookups — found or missing — are cached for 30 seconds; a failed lookup is
  remembered for 5 seconds and re-thrown, never answered from a stale row.
* **Version:** `{ kind: "commit", sha: DOCS_CONTENT_SHA }` when the build baked
  the commit it ingested, so each deployment serves exactly its own content;
  `"current"` otherwise (local development).
* **Preview:** `true` when `VERCEL_ENV` is `preview`.
* **Canonical host:** the host of `SITE_ORIGIN` in production, unless
  `DOCS_CANONICAL_REDIRECT=0`; never on previews or in development.
* **Request policy:** no HSTS preload, cache tag `site-<slug>`, no
  short-circuit.
* **Telemetry, WebMCP rollout, rate-limit scope:** none; the defaults apply.

| Variable                  | Default | Effect                                                                                              |
| ------------------------- | ------- | --------------------------------------------------------------------------------------------------- |
| `SITE_SLUG`               | `docs`  | Which `docs_site` row to serve. Lowercase letters, digits and inner hyphens, at most 55 characters. |
| `DOCS_CONTENT_SHA`        | unset   | The commit the build ingested; set by the build.                                                    |
| `DOCS_CANONICAL_REDIRECT` | `1`     | `0` disables the production redirect to the `SITE_ORIGIN` host.                                     |
