Files
docs/LLM.md
T
zooqueenandHanzo Dev dae9acbb32 Say how the site actually ships
RELEASE.md described six ways to build an image and the pin that made one live,
and none of that decides what docs.hanzo.ai serves: the host is a route onto
s3://hanzo-sites/hanzo/docs and the publish is the deploy. It is one lane now,
so the runbook is one page: what fires it, what the job proves before it
publishes, how to check the live site, and which parts of the image lane are
still here and cold.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-15 16:25:16 -07:00

22 KiB

Hanzo Docs Framework

Fork of Hanzo Docs with all packages renamed to @hanzo/docs-* namespace.

How this ships

Runbook: RELEASE.md. One lane:

push  ->  github.com/hanzo-docs/docs         origin; GitHub Actions is OFF here
  ->  git.hanzo.ai/hanzoai/docs              a pull mirror, 10-minute interval
      .github/workflows/deploy.yml           the forge reads this directory natively
  ->  pnpm build --filter=docs               NEXT_EXPORT=1 -> apps/docs/out
  ->  hanzoai/ci .github/actions/site        Sites project `docs`
  ->  s3://hanzo-sites/hanzo/docs            what docs.hanzo.ai serves

The host is a route, not a workload: Host(docs.hanzo.ai) -> Middleware docs-static -> that S3 prefix, declared in hanzoai/universe charts/app/values/hanzo/static-sites.yaml. Nothing is pinned and nothing rolls; the publish IS the deploy.

The other workflows in .github/workflows/ run on the same forge: lint.yml and test.yml on pull requests, release.yml on push (the npm packages), sync-zen-pricing.yml daily, deploy-cloud.yml for apps/cloud (Sites project hanzo-cloud), and eight deploy-*-docs.yml that still wrangler pages deploy their app. Each wrangler one is the ONLY deploy of its host, so it stays until that host has a Sites project: give the app output: 'export', publish a slug, move the DNS, delete the workflow.

The image lane is cold. ghcr.io/hanzoai/docs, the root Dockerfile, hanzo.yml's images: and the docs app in universe served this host before the Sites plane did. The pod still runs and no router names it. Building or pinning it publishes nothing.

The export gate is in scripts/check-export.sh, which the deploy runs on apps/docs/out and the Dockerfile runs in its build stage. This site fails by exporting nothing — a valid layer, a valid image, a 404 — and apps/<app>/ export.require names the sections that must not silently vanish (docs/studio/ is a submodule; a checkout that does not recurse drops it while page count and nav stay green).

Canonical model — the one way to do docs

Full ADR: apps/docs/content/docs/contributing/docs-architecture.mdx (rendered at docs.hanzo.ai/docs/contributing/docs-architecture). Summary:

  • docs.hanzo.ai is the hub. ONE Fumadocs build (apps/docs) → ONE Sites project docs. The federated per-section deploy is retired (stale origins 530'd). Do not reintroduce per-section builds.
  • IA is two levels of separator, and one word each. The ROOT sidebar (content/docs/meta.json) is nine sections a developer walks in order — Get started · Concepts · Capabilities · Reference · Build with · Surfaces · Programs · Integrate · Open source. Inside Capabilities (content/docs/openapi/meta.json, generated) is the second level: the nine DOMAINS — Identity & Trust · Intelligence · Data · Streams · Observability · Commerce · Platform · Applications · Chain — from apps/docs/openapi-specs/ capabilities.yaml, which is tracked HERE and is the one editorial decision left in this repo. Both levels are icon-bearing separators (---[Icon]Name---, resolved by lucideIconsPlugin), NOT folder moves — files stay flat so URLs/openapi back-links don't break. Counts are never written twice. How many capabilities there are is a fact about cloud's document, printed by the generator on /docs/openapi; authored prose links to it rather than restating a number that goes stale the next time cloud ships. check-capabilities is what holds the taxonomy, the document and the pages to one set. No stutter. A folder's own index.mdx is already its landing page — the tree builder resolves it before it reads pages. Naming index in pages demotes the page to a child of the folder carrying the folder's own name, so the sidebar reads Agents > Agents; 191 folders named it and 188 of them carried the same title, so that is how many rows said it twice. Same rule one level down: a folder wrapping exactly ONE page is a folder that should not exist, which is why a single-tool MCP product is published as a page and not a directory. API reference and SDKs are separated surfaces (/docs/openapi, /docs/sdks) reached via the top nav (components/layouts/shared.tsx), deliberately NOT in the root sidebar descent. The landing (app/(landing)/page.tsx — its own route group, because it carries the docs chrome from components/layouts/docs.tsx while (home) keeps the marketing chrome for /blog) + docs home (content/docs/index.mdx) lead with the decentralized spine ("the AI cloud you can run yourself"); content/docs/network.mdx = self-host/hanzo.network, content/docs/architecture/philosophy.mdx = the engineering pedagogy. Landing breadth stats trace to real sources: the domains from capabilities.yaml and the capability count from cloud's document, 157 models (ai/conf/models.yaml), 706 connectors (cloud/clients/automations/catalog/ catalog.json), 6 SDK langs (openapi/CLAUDE.md). Count from data, never invent.
  • One canonical home per doc. Content enters the single build via exactly one of three orthogonal mechanisms, chosen by kind:
    1. Authored (first-party prose) → MDX under content/docs/<section>/. Small/core sections live in-repo; team-owned sections live in a hanzo-docs/<team> content repo mounted as a git submodule at content/docs/<team>/. Exemplar: hanzo-docs/studio-docs → content/docs/studio/.
    2. Generated (never hand-written, .gitignored) → API reference from hanzoai/cloud's openapi.yaml at the release openapi-specs/.spec-lock pins, via scripts/sync-openapi.sh + scripts/gen-openapi-pages.ts (source-derived: an endpoint's sentence is written next to its handler and travels here unaltered); SDK reference from the ZAP SDK generator into content/docs/sdks/<lang>/. Every reader of the document goes through scripts/openapi-doc.ts, which owns the path — no script names the file. A generated MDX fragment is the third shape here, for a fact an authored page needs mid-sentence: scripts/gen-key-types.ts writes generated/key-types.mdx and content/docs/api-keys.mdx pulls it in with <include>. It sits outside content/ because source.config globs **/*.mdx there and would give a fragment its own URL — and the path is relative to the INCLUDING file, so from content/docs/ it is ../../generated/. One directory short renders the page body empty with the frontmatter and chrome intact, which looks like a working page.
    3. Ported (upstream OSS, mirrored with attribution, tracked) → content/docs/projects/<upstream>/. Port, don't re-author. Carry upstream LICENSE + NOTICE. GPL stays GPL. This is a committed snapshot, not build output: scripts/sync-project-docs.ts refreshes it on demand (pnpm --filter docs sync:projects) and you read the diff before it ships, the same contract openapi-specs/ has. It used to be BOTH — gitignored and re-mirrored by every build, so a build rewrote 965 committed files, put 298 repos' docs on disk that production never had, and died on 54 imports (@site/...) no alias resolves. The builder alone skipped the sync, which meant the command a person runs and the command that ships the site compiled different sites.
  • Repos: hanzo-docs/docs = framework + hub (canonical; hanzoai/docs redirects here). hanzo-docs/<team> = authored content only, NO framework. hanzoai/cloud openapi.yaml = the API source of truth; hanzoai/openapi's hanzo.yaml was a projection of it on its own clock and this repo no longer reads it. All docs live in the hanzo-docs org: the hub at hanzo-docs/docs, each team's content at hanzo-docs/<team>.
  • Standalone vs hub: default is a hub section. Standalone deploy only if ALL of: ≈150+ pages or fast OSS-upstream churn, independent versioning, direct audience. Standalone runs its own copy of this framework; the hub links out, never copies.
  • Serving: the Sites plane — s3://hanzo-sites/hanzo/docs, served by the ingress staticFiles middleware on Host(docs.hanzo.ai). The sibling hosts below are still CF Pages hanzo-docs (token from KMS, never hard-coded). No nginx/caddy, and no pod for a static export.

Known dedup debt (rollout, not done): the apps/*-docs legacy apps (base-docs, bootnode-docs, bot-docs, cloud, dev-docs, dns-docs, flow, gui-docs, insights-docs, platform, pulsar-docs, spec, tasks-docs, team, visor, zen-docs, zt-docs) predate the unified content/docs/ model — migrate their content into content/docs/<section>/ (or a hanzo-docs/<team> submodule) then archive the app.

Two build guards, and why prose needs them

scripts/pre-build.ts ends with two checks that fail the build. Both exist for the same reason: a generated page cannot be wrong about the API, an authored one can, and a reader cannot tell which kind of page they are on.

  • check-endpoints.ts — no page may name a /v1/… route on api.hanzo.ai that the document does not serve.
  • check-keys.ts — no page may spell an API key a way cloud does not mint, and api-keys.mdx must teach every class the document carries. Both directions, so renaming, adding or removing a key class in cloud stops the build here instead of publishing a stale page.

The key classes come from Document.keys (openapi-doc.ts), read out of the /v1/keys prose — cloud's own Go doc comments, lifted by zipdoc. secretKey(doc) is the one accessor generators use for "the key a server presents"; three of them had it as a literal, which is how three GENERATED pages came to teach hk-.

What was wrong: docs.hanzo.ai documented three key types — hk- "API Key", sk- "Secret Key", hz- "Widget Key". Cloud mints two (cloud.APIKeyPrefixes: pk-, sk-) and refuses anything else, so a reader's first call failed asking for a key nobody can issue. hk-/hz- were on 149 files. Note the shape of the mistake: sk- was present and described wrongly (as an org-level provider credential; it resolves to the USER), so a find-and-replace would have left the page confidently wrong. Cloud's apps/platform/secretshape.go still lists hk- in its secret-DETECTION table — harmless, it only scans, but it is where the invention came from.

Branch Convention

  • main — Production branch. A push here builds the export and publishes it to the Sites plane, so landing on main IS publishing (RELEASE.md). All Hanzo work lands here.
  • dev — Tracks upstream Hanzo Docs/dev. Used for upstream sync merges only.
  • upstream remote — points to hanzoai/docs

Architecture

pnpm workspace monorepo with turbo. Two apps, 24 packages, 24 examples.

~/work/hanzo/docs/
├── apps/
│   ├── docs/           # Main docs site (hanzoai.github.io/docs)
│   └── zap-docs/       # Zap protocol docs
├── packages/
│   ├── core/           # @hanzo/docs-core - source loading, search, i18n
│   ├── mdx/            # @hanzo/docs-mdx - MDX processing, collections
│   ├── base-ui/        # @hanzo/docs-base-ui - headless UI (@base-ui/react)
│   ├── radix-ui/       # @hanzo/docs-ui - full UI with Radix primitives
│   ├── openapi/        # @hanzo/docs-openapi - OpenAPI docs generation
│   ├── typescript/     # @hanzo/docs-typescript - auto type tables
│   ├── twoslash/       # @hanzo/docs-twoslash - TypeScript code hints
│   ├── cli/            # @hanzo/docs-cli - scaffolding & customization
│   ├── story/          # @hanzo/docs-story - component stories
│   ├── tailwind/       # @hanzo/docs-tailwind - Tailwind CSS utils
│   ├── press/          # @hanzo/docs-press - minimal setup
│   ├── hanzo-docs/     # @hanzo/docs - unified wrapper re-exporting all
│   ├── content-collections/ # @hanzo/docs-content-collections
│   ├── mdx-remote/     # @hanzo/docs-mdx-remote - remote MDX
│   ├── obsidian/       # @hanzo/docs-obsidian - Obsidian vault adapter
│   ├── python/         # @hanzo/docs-python - Python docgen
│   ├── doc-gen/        # @hanzo/docs-docgen - doc generation
│   ├── create-app/     # @hanzo/docs-create-app - project scaffolding
│   ├── create-app-versions/ # version tracking for create-app
│   ├── shared/         # shared utilities
│   ├── stf/            # @hanzo/docs-stf (upstream dependency)
│   ├── mdx-runtime/    # @hanzo/mdx-runtime
│   ├── eslint-config-custom/ # shared ESLint config
│   └── tsconfig/       # shared TypeScript config
└── examples/           # 24 example apps (Next.js, Astro, React Router, etc.)

Package Naming Convention

One brand: every workspace package publishes under @hanzo/docs-*. The rename rule (applied when merging from the upstream fork) drops the upstream prefix and re-scopes to @hanzo/docs-:

  • <basename> (unscoped upstream) → @hanzo/docs-<basename>
  • @<scope>/<basename> (scoped upstream) → @hanzo/docs-<basename>

Canonical workspace packages and their paths:

Hanzo name Path
@hanzo/docs-core packages/core
@hanzo/docs-mdx packages/mdx
@hanzo/docs-ui packages/radix-ui
@hanzo/docs-base-ui packages/base-ui
@hanzo/docs-openapi packages/openapi
@hanzo/docs-preview packages/preview
@hanzo/docs-typescript packages/typescript
@hanzo/docs-twoslash packages/twoslash
@hanzo/docs-cli packages/cli
@hanzo/docs-story packages/story
@hanzo/docs-tailwind packages/tailwind
@hanzo/docs-language packages/language
@hanzo/docs-local-md packages/local-md
@hanzo/docs-sanity packages/sanity
@hanzo/docs-vite packages/vite
@hanzo/docs-basehub packages/basehub
@hanzo/docs-mdx-remote packages/mdx-remote
@hanzo/docs-stf packages/stf
@hanzo/create-docs packages/create-app
@hanzo/docs-create-versions packages/create-app-versions

Important: packages/radix-ui publishes as @hanzo/docs-ui (Radix variant). The base-ui variant (@hanzo/docs-base-ui) uses @base-ui/react instead of Radix.

External deps kept verbatim (real upstream npm packages, NOT renamed): fuma-cli, fuma-content, @fumari/json-schema-ts, and the third-party search adapters (typesense / trieve) documented under apps/docs.

Upstream Sync

Remote upstream points to the upstream fork. Local dev tracks upstream/dev.

To merge upstream changes:

git checkout dev && git pull upstream dev
git checkout -b merge-upstream-YYYY-MM-DD main
git merge dev
# Resolve conflicts, then re-apply the package rename (table above)
# Merge into main when ready

After merge, re-apply the rename with the canonical script (scripts/rebrand-packages.mjs — masks the external KEEP-list, then maps each upstream name to its @hanzo/docs-* form).

Key Patterns

Source Config (source.config.ts)

import { defineConfig, defineDocs } from '@hanzo/docs-mdx/config';

Source Loader (lib/source.ts)

import { docs } from '@/.source';
import { loader } from '@hanzo/docs-core/source';

export const source = loader({
  baseUrl: '/docs',
  source: docs.toHanzoDocsSource(), // API name kept from upstream
});

Page Component

import { DocsPage, DocsBody } from '@hanzo/docs-ui/layouts/docs/page';
import defaultMdxComponents from '@hanzo/docs-ui/mdx';

The docs grid is three columns

layouts/docs/slots/container.tsx (both UI packages — they are parallel forks, so a change to one without the other diverges them): var(--fd-sidebar-col) minmax(0, 1fr) var(--fd-toc-width). Rails pin to the edges, the page takes what is between them.

It was five columns, with minmax(min-content, 1fr) gutters centring a band capped at --fd-layout-width, and the sidebar AREA spanned the leading gutter as well as its own column. Measured on the live site at /docs/api-keys: at 1920 the columns were 176.5 232 1052 268 176.5 and the nav — 232px, right-aligned in its area by items-end — began 176px from the left edge; at 2560 it began 496px in. The empty strip was inside the sidebar's own bordered, filled card, so every pixel a wider display gained went there. Now 232 1405 268 and 232 2045 268, nav at x=0.

--fd-layout-width no longer bounds this grid. It was doing two jobs at once — bounding the reading measure and bounding the band — and the page slot already owns the first (max-w-[900px]), so what was left was only the job that made the gap. The article therefore stays 900px and does not move; what moved is the nav (flush left) and the toc (flush right), and the page COLUMN absorbed the gutters. Widening the article past 900px is a separate typography decision, untaken.

Narrow viewports are unchanged by construction: below md, --fd-sidebar-width is 0 and the sidebar is a fixed drawer outside the grid, and --fd-toc-width is 0 until a toc exists.

Layouts

  • @hanzo/docs-ui/layouts/docs - Standard docs layout
  • @hanzo/docs-ui/layouts/home - Homepage layout
  • @hanzo/docs-ui/layouts/notebook - Notebook layout
  • @hanzo/docs-ui/layouts/flux - Alternative flux layout (new)

Build

pnpm install          # Install deps
pnpm build            # Build all packages + apps
pnpm dev              # Dev server

# Individual packages
pnpm build --filter @hanzo/docs-core
pnpm build --filter @hanzo/docs-ui

Build tool: tsdown (all packages except hanzo-docs wrapper which uses tsup).

Compatibility

  • Next.js 15-16+ with App Router
  • React 19+
  • Tailwind CSS 4+ (see "UI framework debt" — this is the thing being removed)
  • pnpm 10+

UI framework debt — where the tailwind actually is

House rule is that @hanzo/gui is the only UI framework: it compiles to React Native, so an app built on it runs on web, iOS and Android. Tailwind classes and Radix primitives are DOM-only and cap an app at the browser. This repo is the furthest thing from compliant, and the reason is structural, so measure before planning anything.

Tailwind here is LIVE, not inert. 21 apps each carry a real postcss.config.mjs with @tailwindcss/postcss, 60 CSS entrypoints begin @import "tailwindcss", and 66 package.json files declare a tailwind dep. Two of those entrypoints — packages/radix-ui/css/style.css and packages/base-ui/css/style.css — ship inside the published npm tarballs, so the framework's product is tailwind. Counted with a utility-token regex over tracked files: 39,433 class tokens across 537 source files.

The blocker is that the UI layer is two parallel forks of Fumadocs. packages/radix-ui publishes as @hanzo/docs-ui (Radix) and packages/base-ui as @hanzo/docs-base-ui (Base UI) — ~110 files / 13.5k lines each, the same components twice, kept in step by .cursor/skills/radix-base-ui-sync. Both names are banned by the house rule, apps/docs depends on both at once, and every app and example in the workspace consumes them. Complying means rebuilding that layer once on @hanzo/gui and deleting both — a Fumadocs rewrite, not a patch. Do not start it piecemeal: swapping one primitive inside a component whose markup is still tailwind lowers a grep count and changes nothing real.

Radix → @hanzo/gui primitive map (checked against ~/work/hanzo/gui/pkgs/ui, not guessed). Present: presence→animate-presence, popover, select, dialog, accordion, tabs, collapsible, tooltip, direction→ core/use-direction. Genuinely missing: navigation-menu and scroll-area (scroll-view is the React Native scroller, not a custom-scrollbar area). Those two must be built in @hanzo/gui before packages/radix-ui can be retired. collapsible exists but apps/gui-docs has no page for it — a docs gap here, not a missing primitive.

@zenlm/ui ships classes that style nothing. packages/zenlm-ui publishes dist/ as JS + d.ts with no CSS at all, declares no tailwind dep and has no postcss config, yet styles its four components entirely with tailwind utilities (and shadcn's bg-muted / text-muted-foreground tokens). Inside apps/zen-docs they happen to resolve because that app compiles tailwind; for any other npm consumer they are dead strings. Note apps/zen-docs pins @zenlm/ui: ^1.0.6 from the registry, not workspace:*, so pnpm 11 (link-workspace-packages defaults to false) serves the published tarball — editing packages/zenlm-ui does not change what zen-docs renders until a release. Converting it to @hanzo/gui style props is the highest-value next increment and fixes the inertness by construction, because style props travel with the component.

Out of scope when counting: content/docs/projects/** is a snapshot of upstream repos, so tailwind there is upstream's and a refresh overwrites edits. Two more grep hits are not tailwind at all — the Java/Spring IAM guide uses Bootstrap in a Thymeleaf template, and the IAM login-customization guide uses custom class names with their own <style> blocks.

Shadcn is gone (packages/shadcn + examples/next-shadcn deleted). It existed to emit npx shadcn@latest add ... into rendered docs, which puts a DOM-only library in the reader's app.

Authored docs are converted. The commerce recipes/storefront pages and zen5.mdx teach @hanzo/gui now. Keep it that way: a snippet teaching tailwind produces tailwind in someone's app, so examples count as shipping surface.

Baseline when touching this repo

pnpm types:check is not green and never was: 50 of 72 tasks fail at rest. npx vitest run is 13 failed / 203 passed. Judge a change by the delta against those sets, not by a clean run you will never get. And docs#types:check failing tells you nothing about whether a page renders — MDX compile errors do not surface in tsc. zen5.mdx typechecked clean while the loader skipped the entire page (<50ms in a table parses as a JSX tag; fence such values in backticks). Run the app and look at it.

Landing Apps (Moved Out)

Landing page apps were moved to their ecosystem repos:

  • Hanzo apps → ~/work/hanzo/apps/
  • Lux apps → ~/work/lux/apps/
  • Zoo apps → ~/work/zoo/apps/

apps/zap-docs and apps/liquid are gone; their deploy workflows were deleted because they built directories that do not exist. See apps/ for what is here.