AGENTS.md@clients/docs · git:20260904.e27a62c · 2026-09-04 · sha256 dea7ebfd89acc5d7
AGENTS.md@clients/docs git:20260904.e27a62cB
Immutable. This exact content is served forever at /api/v1/blob/dea7ebfd89acc5d7.
# clients/docs: Agent Guidance
Applies to all code under `clients/docs/`. Subordinate to [`clients/AGENTS.md`](../AGENTS.md) and the root [`AGENTS.md`](../../AGENTS.md).
`@vellumai/docs` is the public docs site: an SSR Next.js app (standalone output) serving www.vellum.ai/docs. It is a member of the root bun workspace.
## URL rules: everything public lives under `/docs`
Ingress only routes `/docs/*` to this app. Every URL the app emits publicly MUST be `/docs`-prefixed:
- Pages are authored under `src/app/docs/` with no `basePath`.
- The search API is `/docs/api/search`, markdown mirrors are `/docs/<path>.md` (index: `/docs/index.md`), the agent index is `/docs/llms.txt`, the sitemap is `/docs/sitemap.xml`, and assets are served from `public/docs/`.
- Next's build assets are served under the prefix too: `assetPrefix: "/docs"` in `next.config.ts` plus the first `beforeFiles` rewrite (`/docs/_next/*` → `/_next/*`). Without both halves, stylesheets and scripts resolve to `/_next/*`, which ingress routes to a different backend. `verify-parity.ts` asserts asset subresources load.
- The only exception is `/api/health`: GKE BackendConfig health checks hit the pod directly and bypass ingress path rules.
- Canonical URLs are absolute `https://www.vellum.ai<path>` via `createMetadata` (`src/lib/metadata.ts`). Never change a page's `path:` value; URLs did not change in the migration, and native clients link the legal paths (`/docs/privacy-policy`, `/docs/vellum-terms-of-use`, `/docs/prohibited-use`) directly.
- Links to non-docs Vellum surfaces (signup, login, the assistant app) are cross-app now: absolute URLs from `src/lib/routes.ts`.
## The `%5Fmd` folder encoding trick
The markdown mirror route lives at `src/app/docs/%5Fmd/[[...slug]]/route.ts` and serves `/docs/_md`. A folder literally named `_md` is private to the app router (underscore prefix) and would be excluded from routing; `%5F` is the URL-encoded `_`, which registers the public route. The filesystem walkers (sitemap route discovery, the markdown/search generators) treat `%5F`-encoded names as private the same as a literal underscore prefix, and the attribution proxy excludes the `/docs/_md` URL path. Do not "fix" the folder name.
## Authoring content
- One `page.tsx` per route. Each exports `metadata = createMetadata({...})` and renders a content component from `src/app/docs/_components/`.
- Assets go in `public/docs/` as WebP under 200 KB, with intrinsic `width`/`height` declared on `next/image` (all images are `unoptimized`, so declared dimensions are what prevents layout shift). OG images are PNG for scraper compatibility; `public/docs/og.png` is the default OG image via `createMetadata`.
- The sitemap, search index, markdown mirrors, and `llms.txt` all derive from the filesystem tree at build time; adding or moving a page needs no list updates. Redirect-only stub pages must be added to `REDIRECT_STUB_ROUTES` in `src/lib/discover-docs-routes.ts` so the sitemap skips them.
- `scripts/platform-route-snapshot.json` is the Phase 1 route-parity baseline; `bun scripts/verify-parity.ts <base-url>` checks the tree and a running instance against it.
### Embedding a how-to video
A page whose topic has a walkthrough video offers it as an alternative to reading, via `<DocsVideo>` (`_components/docs-video.tsx`) placed immediately after the page's lede and before the first `<section>`.
The card is a facade: it ships a self-hosted poster and swaps in the YouTube player only once someone presses play, so a page nobody watches costs no third-party script, no YouTube cookie, and no request to Google. Its markup is a real link to the watch URL that JavaScript upgrades in place, which is what carries the video into the Markdown mirrors, `llms.txt`, and the search index — an iframe leaves nothing behind in any of them.
Adding one:
1. Grab the poster. YouTube's frame is 1280x720; keep the declared dimensions in `src/lib/docs/videos.ts` in step if you ever source one elsewhere.
```bash
curl -sfL "https://i.ytimg.com/vi/<VIDEO_ID>/maxresdefault.jpg" \
| cwebp -q 78 -resize 1280 0 -o public/docs/video-<slug>.webp -- -
```
2. Add the entry to `DOCS_VIDEOS` in `src/lib/docs/videos.ts`. Title, description, runtime, and upload date feed both the card and its `VideoObject` structured data, so take the title and `lengthSeconds`/`uploadDate` from the watch page rather than retyping them.
3. Render `<DocsVideo video="<slug>" />` in the page's content component. Referencing the same slug from a second page is free and keeps one copy of the metadata.
`src/lib/docs/videos.test.ts` fails if an entry has no committed poster, so a forgotten step 1 does not reach a deploy.
Colors inside the card that sit on the poster (the play button) must avoid `bg-white` and `text-emerald-*`: `docs-theme.css` remaps both to theme surfaces inside `.docs-shell`, which is right for cards and wrong for chrome over a photograph that does not change with the theme.
## Generated artifacts
| Command | Output |
| --------------------------------- | ------------------------------------------------------------------------- |
| `bun run docs:search:index` | `public/docs/search-index.json` |
| `bun run generate:agent-markdown` | `generated/md/**`, `generated/md/docs-index.json`, `public/docs/llms.txt` |
Both run automatically via `predev`/`prebuild`. Both drop decoration before converting: `<script>` (JSON-LD), `<iframe>`, anything `aria-hidden="true"`, and `alt=""` images, so structured-data payloads and arrow glyphs stay out of agent-facing prose. All outputs are gitignored. Next standalone output omits `public/` and `generated/`, so the Dockerfile copies both into the runtime image explicitly.
## Theme contract
- Dark mode is stamped on `<html>` as BOTH the `.dark` class (docs-theme.css selectors) and `data-theme="dark"` (design-library `tokens.css` custom variant). Any code that changes the theme must set both.
- Storage key precedence when reading: `device:theme` (the assistant SPA's key) first, then `vellum_theme` (the shared platform key), then system preference. The platform-only `velvet` value counts as dark.
- The theme picker writes BOTH keys (`vellum_theme` and `device:theme`); the pre-hydration bootstrap in `src/app/layout.tsx` and `docs-theme-picker.tsx` implement the contract. The apps share the www.vellum.ai origin, so do not rename either key.
## Attribution contract (`src/proxy.ts`)
- The proxy emits one `page_view` JSON line to stdout per real page load. The BigQuery log sink and dbt (`stg_marketing_events__page_views`) parse the exact field names (`source`, `event`, `vid`, `path`, `referrer`, `timestamp`, `utm_*`, click IDs, `utm_resolution`). Do NOT change field names or the single-line JSON shape.
- The `vellum_vid` cookie is 90-day HttpOnly, `Domain=.vellum.ai` when the Host header ends with `vellum.ai`. Read the Host header directly; `nextUrl.hostname` returns the bind address behind the GKE load balancer.
- Prefetch suppression (`Next-Router-Prefetch`, `Next-Router-Segment-Prefetch`, `Purpose`/`Sec-Purpose`) is load-bearing: dbt carries a scrubber for a historical phantom-prefetch bug. `skipMiddlewareUrlNormalize: true` in `next.config.ts` keeps those headers visible to the proxy; do not remove it.
- This app's Kubernetes container name is `docs`. Page_view lines only reach BigQuery after the Phase 2 platform Terraform change extends the pageview sink filter (currently `container_name="nextjs"`) to container `docs`.
## Content ownership: this tree is canonical
`src/app/docs/` is the only source of the public docs. The platform repo's docs tree (`vellum-assistant-platform/web/src/app/(marketing)/docs`) was deleted by "Docs migration Phase 4" (platform #9705), so there is no upstream to sync from and no follow-up copy to keep in step. Copy edits, new pages, and factual corrections land here directly.
The rules that governed the migration still constrain the content itself:
- The root `AGENTS.md` "Generic Examples" rule applies (this repo is public): no real names in docs copy. The migration replaced the personal names it found with that rule's placeholders, mapping each original to one placeholder consistently so a page still reads as being about the same person. Keep new copy on placeholder personas.
- Pages are authored against this app's import paths (`@/app/docs/`), `/docs`-prefixed WebP asset paths, and cross-app links absolutized to `https://www.vellum.ai/...`.
## Behavior ported from the platform app
Behavior ported from the platform app that intentionally differs:
- Attribution referrer/click-id classification is stricter than the platform emitter: empty click-id params (e.g. a bare `?gclid=`) emit no paid attribution, referrer domains match at hostname boundaries (exact host or dot-suffix, never substring), and `copilot.bing.com` classifies as GEO. The emitted JSON key set is unchanged.
- Search extraction/ranking adds element-boundary spacing during text extraction, indexes standalone headings unconditionally as their own chunks with level-aware scoping, and returns matched-term snippets.
- Tailwind has no class-keyed dark variant. The pre-hydration bootstrap stamps both `.dark` and `data-theme="dark"` on `<html>`; `dark:` utilities key off `data-theme` (the design-library `tokens.css` custom variant) while `docs-theme.css` selectors key off `.dark`.
- The mobile nav drawer is refactored around a shared `NavPanelShell` (`_components/nav-panel-shell.tsx`) with a ref-counted body-scroll lock (`_components/body-scroll-lock.ts`) and single-owner Cmd/Ctrl+K registration (`DocsSearch registerShortcut`). `docs-nav.tsx`, `releases-nav.tsx`, and `docs-nav-context.tsx` therefore keep this app's shell structure; edit nav item data inside it rather than restoring the per-drawer copies the port replaced.
- Release anchor/month formatting is centralized in `src/lib/releases-server.ts` (`releaseAnchor`, `monthLabel`) and shared by `releases-content.tsx` and `releases-nav.tsx` so sidebar links always match article IDs; the platform version keeps local copies of these helpers. `releases-content.tsx` is hand-merged during syncs.
- The TOC peek urchin is a **hand-maintained** trimmed port, `_components/peek-character.tsx`, not a byte-identical copy of the platform's `AnimatedAvatar` + full character catalog: it inlines only the urchin body, curious eyes, and teal color, drops the streaming morph, and suppresses all animation (including blink/twitch) under `prefers-reduced-motion: reduce`, where the platform passes `ignoreReducedMotion`. Sync procedure: the SVG path data and transform math must match `packages/avatar-catalog` and `assistant/src/avatar/svg-compositor.ts`; re-copy the urchin/curious/teal definitions when the catalog changes. `table-of-contents.tsx` is hand-merged during syncs: mirror the platform structure but keep the local `PeekCharacter` import in place of `AnimatedAvatar`.
## Deferred items and known divergences from the platform app
- The React compiler is off (`reactCompiler` unset; the platform app enables it). Turning it on requires `babel-plugin-react-compiler`.
- `/docs/releases` is the only `force-dynamic` route. It fetches the public releases API at request time (`revalidate: 60`, 10 s timeout, fail-soft to an empty list). `RELEASES_API_URL` overrides the base URL; `DJANGO_INTERNAL_URL` supports in-cluster fetch without a code change.
- Search is lexical only; the platform's unreachable embeddings mode was dropped during the port.
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->