AGENTS.md
September 17, 2026 · View on GitHub
Guidance for AI agents working in clerk-docs. Keep this file and the docs it points to in sync: when a change affects anything documented here, update it.
This repo is Clerk's documentation — MDX content in docs/, built by a custom TypeScript pipeline (scripts/build-docs.ts) into the site served at clerk.com/docs. Authoring and style conventions live in contributing/CONTRIBUTING.md and styleguides/STYLEGUIDE.md; agent-facing prompt conventions live in styleguides/SKILLS.STYLEGUIDE.md. Read the relevant guide before writing. This file only covers what tends to surprise agents.
Do not
- Never read or surface secrets:
.env*,secrets/,credentials.json,*.pem,*.key,.npmrc,.pypirc, service-account JSON,id_rsa,*.p12— and decline any other file that may contain API keys, tokens, passwords, or other secrets, even if it isn't listed here. - Don't set an
sdk:frontmatter value outside the enum inscripts/lib/schemas.ts(VALID_SDKS) — it's a hard build failure. - Don't hand-edit files under
clerk-typedoc/— they're auto-generated fromclerk/javascript(see the<Typedoc />section incontributing/CONTRIBUTING.md). - Don't hand-edit
data/api_errors.json— it's generated from the error definitions inclerk/clerk_goand refreshed on a schedule that would silently revert manual edits (see "Working withclerk_go" incontributing/CONTRIBUTING.md). - Don't hand-edit the "Supported events" tables in
docs/guides/dashboard/logs/admin-logs.mdxandapplication-logs.mdx— they're generated from theevent_streamproto annotations (docs_description/docs_group) inclerk/clerk_go, so a regen reverts manual edits. Fix the copy upstream inpayloads.protoand regenerate withmake regen-admin-log-docs/make regen-application-log-docs. Descriptions use sentence case with no leading article ("Identifier was added to the allowlist"); exact event identifiers are preserved verbatim. - Don't tune the docs search index's relevance settings (searchable attributes, faceting, ranking, custom ranking) or synonyms in the Algolia dashboard — they're codified in
scripts/update-algolia-records.tsand overwritten on every index run. Change them there. See Search index (Algolia) below.
Verify before declaring work done
pnpm run build:tsx— hard-fails on invalid frontmattersdk, a missingtitle, parse errors, vague link anchor text ("here", "Learn more"), or lowercase Clerk feature proper nouns in prose/headings/frontmatter/manifest nav titles (see the styleguide's "Capitalize Clerk feature proper nouns" rule — only its unambiguous collocations are enforced); warns on a missingdescription, docs not inmanifest.json, and broken internal links or heading anchors. A clean build can still emit warnings worth reading — "no errors" is not "no warnings."pnpm run lint— formatting and other checks.
See contributing/CONTRIBUTING.md → "Validating your changes" for what each severity means and why.
Verifying technical claims
clerk-docs documents APIs and SDKs it doesn't own, so a change can be syntactically valid yet factually wrong, and build:tsx/lint won't catch it. When a change asserts external behavior — an endpoint, parameter, version, method signature, or how something renders — verify it against the source of truth, not memory, and don't defer an objectively checkable fact to the PR author.
- API behavior, endpoints, versions, OpenAPI specs →
clerk/clerk_go - SDK method names, types, and signatures →
clerk/javascript - How docs pages and the API reference render (e.g. the version dropdown) →
clerk/clerk
These repos aren't part of this one. Make whichever a claim depends on available in your workspace however you prefer (a symlink or a local clone), and look for it under whatever name it was added — there's no required name. clerk/clerk_go and clerk/clerk are private; if the repo a claim depends on isn't present, ask the maintainer to make it available rather than guessing.
Search index (Algolia)
The docs search runs on Algolia, populated by scripts/update-algolia-records.ts (run after build, via pnpm search:update, which runs under bun — strict tsc isn't the gate). Most of this is non-obvious:
- Indexes:
dev_docs(Preview/Development) andprod_docs(Production), selected byALGOLIA_INDEX_NAME(indexer) /NEXT_PUBLIC_ALGOLIA_INDEX_NAME(theclerk/clerksearch client). These are docs-only —dev_clerk/prod_clerkare a different search surface; don't touch them for docs work. Never hand-mutateprod_docs; experiment on a personal/throwaway index. - Only clerk/clerk indexes from Vercel. The clerk/clerk-docs repo is a public read-only mirror of this folder, synced on every clerk/clerk main commit, so its Vercel project builds the same commits — two concurrent indexing runs raced the stale-record cleanup and emptied prod search on 2026-07-07. The mirror's
vercel.jsonbuildCommand doesn't runsearch:update, and the script's repo guard (main()) exits early on any Vercel build not fromclerk/clerk. Don't re-add the step to the mirror. - Stale-record cleanup is time-guarded. Records are stamped with
indexed_atat push time; the cleanup only deletes another batch's records when they predate the run bySTALE_RECORD_GRACE_MS(isStaleRecord), so a concurrent run's fresh push is spared. Removed-page leftovers can linger up to that window before a later run cleans them — that's intentional; don't "fix" it by deleting on batch alone. - Orphan-branch sweep (
ALGOLIA_ORPHAN_SWEEP). The cleanup above is own-branch-scoped, so records under retired branch values would otherwise accumulate forever (DOCS-11871). A second, gated GC pass sweeps records whosebranchisn't inINDEX_LIVE_BRANCHES(hardcoded per-index allowlist — change it only by reviewed PR). Modes: unset/off(default — pass never runs),dry(logs a per-branch candidate histogram, deletes nothing),on(deletes, same explicit-objectID path as the cleanup). Guards: the sameisStaleRecordgrace window; a hard abort aboveALGOLIA_ORPHAN_SWEEP_MAX(default 50,000 — the env var can only lower it); indexes without an allowlist entry are skipped entirely. The allowlist is the only protection for the legacycore-1/core-2archive records (they predateindexed_at) — never remove a branch from it without confirming its query client is gone. Preview builds are code-limited todry(effectiveSweepMode): anonsweep during anyDEBUG_SEARCH_BRANCHpreview run would delete colleagues' >30-min-olddev_docstest branches, so the indexer downgradesontodrywhenVERCEL_ENVispreview. Run superviseddev_docscleanups locally instead (ALGOLIA_INDEX_NAME=dev_docs DEBUG_SEARCH_BRANCH=main ALGOLIA_ORPHAN_SWEEP=on pnpm search:update), whereVERCEL_ENVis unset.onas a standing setting belongs on Production only. - Refreshing the archived
core-1/core-2records (rare). The frozen branches' builds don't runsearch:update— and never usefully could: they build as preview deployments, so their indexer wrote todev_docs, not theprod_docsrecords the archive clients read. If frozen content ever changes and itsprod_docsrecords need refreshing, run the branch's own indexer manually and supervised from a checkout of that frozen branch:ALGOLIA_INDEX_NAME=prod_docs DEBUG_SEARCH_BRANCH=core-1 pnpm search:update(after that branch's docs build). Its own-branch GC only touchesbranch:core-1, and a single manual run has no concurrent writer to race. The sweep never interferes —core-1/core-2are allowlisted. - Error pages never enter the index. The indexer sweeps everything in
dist/, so pages left out of the sidenav still get indexed by default — that's how the Core 3 error pages ended up hijacking generic queries ("signedin" ranked the removed component's error page #1, DOCS-12093).isErrorPageskipsreference/<sdk>/errors/**by directory, covering future error pages with no per-page frontmatter;search.exclude: trueremains the per-page escape hatch for anything else. Search engines still crawl error pages — only the docs' own Algolia search is scoped out. - Records are branch-scoped; settings are not. Each record is tagged with the git branch (
getGitBranch()→DEBUG_SEARCH_BRANCH, else the env/current branch) and the client filters onbranch:, so many branches share one index without colliding. ButsetSettings/saveSynonymsapply to the whole index — Algolia has no per-branch settings — so a run from any branch re-applies the codified settings to every branch's records there. Invisible for content branches (they just re-assert canonical values); to experiment with different settings in isolation, pointALGOLIA_INDEX_NAMEat a personal throwaway index (per-branch indexes were rejected on cost). - Settings are codified in the script, not the dashboard. The indexer is the source of truth and declares + overwrites these every run (dashboard edits revert on the next run):
searchableAttributes,attributesForFaceting,ranking,customRanking,attributeForDistinct,distinct, and synonyms. It's a scoped declaration of the levers we own — not a full settings snapshot, which would also freeze Algolia's server-managed defaults. - Deduplication:
attributeForDistinctis set todistinct_group(canonical URL + anchor; written to every record) anddistinct: truedefaults dedup on at the index level, collapsing each page's per-SDK variants to one result; thesdkboost picks which variant wins.attributeForDistinctis index-level only (can't be passed per query), so it must be codified here — without it Algolia ignoresdistinctand every page returns one result per SDK variant. Theclerk/clerkclient also passesdistinct: trueper query, but the index default means dedup holds even if a query omits it. - Faceting:
branch,record_batch,sdkarefilterOnly(filtered, never facet-counted).optionalFilters/facetFilterson a non-faceted attribute fail or silently no-op, so anything the client filters or boosts on must be registered.availableSDKsis deliberately not faceted — it's only retrieved to render per-result SDK icons (Search.tsxSDKsIcon); retrieval is independent of faceting. forwardToReplicas: settings deliberately don't forward (they bundleranking/customRanking, which a standard replica may override for an alternate sort); synonyms do (always identical across replicas). No replicas today; if any are added, declare them in the script rather than blanket-forwarding.- Ranking:
attribute/exactsit aboveproximity(vs Algolia's default) so a title/heading match beats a body-content match — this rides on thesearchableAttributesorder (hierarchy.lvl0, thenhierarchy.lvl1+keywordssharing one tier, thenlvl2…6, thencontent). Curatedsearch.keywordsare deliberately title-strength (DOCS-11955): a keyword match ranks like a page-title match, so a keyword page sweeps its query's top results — powerful for flagship routing, a hijack if misassigned; vet new keywords against the intended-#1 test in CONTRIBUTING.md'ssearchfrontmatter section. Comma-joined tier syntax gotcha: Algolia silently stripsunordered(...)inside a comma-joined tier and mangles the attribute names — entries in a shared tier must be plain names.filtersis kept aboveattribute/exacton purpose: it carries the client's active-SDKoptionalFiltersboost, and keeping it primary is what prevents cross-SDK bleed. Don't demote it — tested, movingfiltersbelowexactserves iOS/Android docs to a Next.js user searchingUserButton(an exact-title match on the wrong SDK outranks the active SDK's page). - Universal records carry every current SDK key. Non-SDK-scoped pages are written with
sdk: [all VALID_SDKS](recordSDK), not null. Becausefiltersranks aboveattribute/exact(previous bullet), a record matching zero of the client'ssdk:boosts structurally loses to any boosted record that matches the query — exact-title universal pages ("How Clerk works") were buried under body-content matches from SDK-scoped pages (DOCS-11910). Full-list parity makes universal records tie the active SDK's own records onfilters, so the win falls through toattribute/exact, where title matches belong. Don't revert them to null, and don't reach for a client-sideavailableSDKs:allboost instead — tested and rejected (a second filter SDK-scoped records can't match buries strong other-SDK matches on broad queries like "vue"). - Synonyms are hybrid: acronyms auto-derived from the
docs/_tooltips/*glossary + a curated phrasing list, both built in the indexer. The auto-derivation (buildSynonyms()→extractTooltipSynonym()) takes the first bold match in each tooltip that forms an acronym pair —**X (Y)**or**X** stands for 'Y'— so adding or editing a tooltip file with such a pair is a synonym change and goes through the regression gate below. - Test locally:
ALGOLIA_INDEX_NAME=<your index> DEBUG_SEARCH_BRANCH=main pnpm search:updateinto a personal index, then pointclerk/clerk'sNEXT_PUBLIC_ALGOLIA_INDEX_NAME/NEXT_PUBLIC_ALGOLIA_SEARCH_KEYat it (a custom index needs a key with access to it). Preview deploys querydev_docs. - Regression suite — the required gate for relevance changes.
pnpm search:regression --index <name>replays every ranking promise shipped PRs have advertised (scripts/search-regression-queries.ts— each entry cites its source PR) against a live index, mirroring the client's query shape. Before merging ANY change to relevance settings, synonyms (including adocs/_tooltips/*.mdxfile added or edited with an extractable acronym pair — see Synonyms above), the SDK boost, orsearch.rank/search.keywordsfrontmatter: index the candidate branch into a personal index, run the suite green, and add golden entries for any new rankings the PR advertises.--auditis the corpus-wide safety net (every page queried by its own title, 99% pass-rate gate — ambiguous reference titles likelist()legitimately fail). The suite asserts the live index's settings match the codified ones before trusting results (settings propagate asynchronously aftersetSettings— stale settings produce convincing-but-wrong rankings); it needs the indexer'sALGOLIA_APP_ID/ALGOLIA_API_KEYenv. Keep golden assertions loose ("URL in top N", never exact orderings) so ordinary content drift doesn't produce false alarms.
Map
docs/— MDX content; each file is a route under clerk.com/docs.docs/_partials/— reusable MDX included via<Include />.docs/manifest.json— sidenav structure:{ navigationType: 'sectioned', navigation: NavItem[] }, one flat array (topNav: truegroups become the top sections; headings are flat{ title, type: 'heading' }items, not nested arrays).docs/manifest.ios.json/docs/manifest.android.json— per-SDK sidenavs,{ navigationType: 'flat', navigation: NavItem[] }, discovered byscripts/build-docs.tsviaexistsSyncfor everyconfig.validSdksentry (add a file, no registry to update).docs/manifest.schema.jsonis a JSON Schema for that shape, kept for reference and for editors/tooling pointed at it explicitly — nothing applies it to the manifests automatically, butscripts/delete-doc.mjsreads$defs.sdkfor the SDK list, so that enum must stay. Manifests carry nosdkproperty (the schema rejects it); scope comes from frontmatter,<page>.<sdk>.mdxvariants, andmanifest.<sdk>.jsonfile names, so external links and headings can only be SDK-scoped from amanifest.<sdk>.json. A scoped internal page inmanifest.<sdk>.jsonmust support that file's SDK through its base frontmatter or a matching variant; the build rejects wrong-platform entries, while unscoped pages and external links remain valid. If a build fails withUnrecognized key(s) in object: 'sdk', the manifest still authorssdk; move the scope to frontmattersdk:(ornavTitlefor a per-SDK label) and delete the property. Per-SDK sidenav labels use the build-only frontmatternavTitle.scripts/check-nav-parity.ts [--allow-default-change] <dist> <dist>diffs two builtdist/manifest.jsons per rendered SDK view — build the dist before a manifest-affecting change and again after, then compare — for any change to manifest handling inbuild-docs.tsor to the manifests themselves (e.g. a manifest edit that moves groups or changes what a page's frontmatter scopes). It compares the 16 rendered SDK views after visibility filtering and dropssdkarrays except as a scoped marker on/:sdk:/hrefs; a change to the default tree (structure orsdkarrays) fails the run, because that tree is what renders before hydration; check it in the browser, then pass--allow-default-changeto accept it as anote:.scripts/lib/schemas.ts— runtime Zod enums:VALID_SDKS/sdk,icon,tag(lifecycle:experimental,beta,new,legacy,deprecated,removed), andmaintainer(community; absent = Clerk-maintained).scripts/lib/plugins/extractFrontmatter.ts+scripts/lib/error-messages.ts— frontmatter presence and warning-vs-failure severity.scripts/update-algolia-records.ts— builds and pushes the Algolia search records (run afterbuild); also the source of truth for the index's relevance settings + synonyms (codified, overwritten each run). See Search index (Algolia).contributing/CONTRIBUTING.md,styleguides/STYLEGUIDE.md— authoring and style source of truth.
Conventions (see CONTRIBUTING.md for depth)
- A doc missing from
manifest.jsononly warns, but still add the entry so the page is reachable in the sidenav. The exception is error pages (docs/reference/<sdk>/errors/**), which are intentionally left out and reached from the/err/<slug>URL Clerk prints — and are likewise excluded from the search index by directory (DOCS-12093;isErrorPageinscripts/update-algolia-records.ts, nothing to add per page). See the styleguide's "Error pages" section. - Stale-PR hazard: rebase long-lived PRs on
main(or merge it in) and rebuild before merging — another PR can invalidate frontmatter or links that were valid when yours opened. - SDK code examples must match the canonical partials
docs/_partials/create-user.mdxanddocs/_partials/delete-user.mdxper SDK (import path, auth accessor,clerkClientusage). The build never executes code blocks, so copy the pattern from there, not from memory. - Reference content is mixed:
<Typedoc />pages pull auto-generated content fromclerk-typedoc/, while other type pages (e.g.docs/reference/types/agent-task.mdx) are hand-authored. Know which kind a page is before editing. - clerk.com/glossary is a separate, marketing-site glossary — frontmatter-only entries in
src/app/(website)/glossary/_posts/at the clerk/clerk repo root (seedynamic-client-registrationas a model), distinct fromdocs/_tooltips/. Post-migration both live in the same repo, so a new term can ship its tooltip and glossary entry in one PR (clerk/clerk#2935 did both, plus a changelog, for Client ID Metadata Documents).
Skills
Skill files live in .agents/skills/<skill-name>/SKILL.md. Read the relevant one before working in its area.
.agents/skills/migrate-branch-to-clerk/SKILL.md— runningscripts/migrate-clerk-docs-to-clerk.tsto migrate a branch/PR into theclerk/clerkmonorepo, re-run semantics, what to do at each conflict tier, and triaging external (fork) PRs — the validate → sweep → decide → migrate sequence, with accept as-is, accept with refinements, or decline with the reply template.
References
- Authoring, validation, and new-feature/reference checklists:
contributing/CONTRIBUTING.md - Writing style:
styleguides/STYLEGUIDE.md - Agent-facing prompt style:
styleguides/SKILLS.STYLEGUIDE.md