Files
hanzo.ai/LLM.md
zeekay 4fd349aa06 beta: the portal says less, and opting in and sharing are one press
Prose that carried no decision or fact is gone: the hero, the questions, the
gate and every step are a heading and at most a line. What scores is said once
(the leaderboard step), and every claim the cloud does not keep is out.

Step 1 is one share card (the member's link, Copy, the device's share sheet on
phones, seven channels as icons, Customize behind a text button) and a quiet
invite-by-email card. Step 3 is one searched, grouped list with one control a
row: On when `opens` says so, Get access when it does not, Requested at once
after the press. Stars and follows moved under the leaderboard as Earn more.

The 1,800-line dashboard is a shell over share, rank, betas and alpha; what
they read is one hook (standing.ts); the questions are data (program.ts).
Dead access, card, hint and referral components are deleted. Every press is
44px, every input too, and a gate walks each screen at 390px.

scripts/brands and scripts/prebuild are TypeScript now; the three marks the
share row draws ship in a JSON only /beta loads. /beta's title and card say
what the page says.

Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
2026-10-26 03:29:13 -07:00

279 KiB
Raw Permalink Blame History

hanzo.ai

Project

Main Hanzo AI marketing site. Next.js 14 App Router (NOT Vite — migrated).

  • URL: https://hanzo.ai

  • Stack: Next.js 15 + React 19 + TypeScript + @hanzo/ui 8.x on @hanzo/gui

    • @hanzo/design tokens + Framer Motion. Tailwind v4 is still in the build and is being retired — see "UI substrate" below.
  • Node: >=22.18, stated in .nvmrc and engines and nowhere else. Two things force it: wrangler 4 refuses to start below 22, and pnpm build's second half (scripts/noindex.mjs) imports lib/publish.ts directly, which needs the type stripping Node does for itself from 22.18. The alternative was a second copy of that list in JavaScript, which is the thing the script exists to prevent.

  • App: the app shell, its apps and their libraries are @hanzo/app (github.com/hanzoai/app, renamed from @hanzo/rooms), shared with hanzo.team. A path below written components/workspace/X.tsx or components/bots/X.tsx is src/X.tsx or src/bots/X.tsx there; import it as @hanzo/app/X. This app states its addresses in lib/app.ts and mounts the router in components/app.tsx; the contract is that repo's LLM.md.

  • Dev: pnpm dev

  • Build: pnpm build

  • Bundler: Turbopack, in next dev and next build both (Next 16's default; no --webpack, no webpack() in next.config.mjs). react-native is react-native-web by turbopack.resolveAlias (exact names), and a .web file resolves before its native twin by turbopack.resolveExtensions. Turbopack applies that list to the project and transpilePackages only, never to the rest of node_modules, which is why react-native-svg is in transpilePackages: outside it Turbopack bundled its native build (Flow source from react-native, TurboModuleRegistry, which react-native-web does not export) and the build failed. Two things differ from webpack's export, both measured page for page: every page's head carries <link rel="preconnect" href="/" crossorigin> and <meta name="next-size-adjust"> (Turbopack's font manifest lists every page, though no page uses next/font), and Turbopack's JSON import is best-effort on the last bit of a float (benchmarks-shortlist.json's 0.9355326876513317 is bundled as …316), which moved five bar widths on /benchmarks by 1e-14 % and no figure shown. The font file is Zen-Variable.<hash>.woff2, its hash in letters, digits, - and _ rather than hex (scripts/preload-font.mjs).

  • Typecheck: pnpm typecheck — tsc here IS TypeScript 7, the native Go compiler, published under the stable typescript name with per-platform binaries in @typescript/typescript-<platform>-<arch>. The whole tree in ~1.4s at ~400% CPU. Do NOT add @typescript/native-preview: that is the pre-release channel of this same binary (it names it tsgo), it is behind stable, and installing it would mean two copies of one compiler.

  • Lint: pnpm lint — plain eslint ., parsed by @babel/eslint-parser because typescript-eslint refuses TS 7 outright (typescript-eslint#10940).

  • Deploy: Static export (output: export) → the PaaS Sites plane, slugs hanzo-ai, cloud-hanzo-ai and hanzo-bot, via .github/workflows/deploy.yml on GitHub (hanzo-inc/app). Cloudflare Pages is GONE — the workflow, the wrangler call and the credentials with it.

    Each host publishes its own bytes, one site-action call apiece. Handed several sites:, hanzoai/ci's action uploads the first and asks the plane to copy the rest from it (SITE_COPY_FROM), because the grant says upload.copy: true; hanzo-inc/cloud's completion never copies (copyKeys in apps/projects/grant.go has no caller). Until 2026-10-15 cloud.hanzo.ai and hanzo.bot served pages whose every /_next script and stylesheet 404'd, and each publish spent ~5 minutes comparing 8,480 files before uploading one page. Check a host with a chunk its own HTML names, never the HTML alone: curl -so /dev/null -w '%{http_code}' https://cloud.hanzo.ai/_next/static/css/<one>.css. Push to live is the build, and the rest hides beside it or after it (deploy.yml). The typecheck and lint run beside the build in a second worktree of HEAD over the same node_modules, so neither reads a snapshot the prebuild is rewriting. The prebuild's scripts that write files of their own run at once (scripts/prebuild.ts), and sync-oss asks every org's pages at once. The backing scan is eight tests over the sorted listing, on every core. node_modules and Turbopack's cache come from the org store (hanzoai/ci bin/cache over cloud's /v1/s3, the IAM token minted from the KMS pair inside the step) as ONE archive, keyed by the lockfile and next.config.mjs and saved from main after the publish; restored, node_modules needs no install. All of .next/cache: turbopack/ is the compile's store (some 320 MB, against webpack's 1.7 GB) and .rscinfo the server-action key it was built under. On six of dgx's cores a fresh checkout compiles in 4–5 s from both, 28–34 s from nothing and 61 s from the store without its key, where webpack took 78–79 s warm and 3.0–3.3 min cold. That key lives 14 days. The pnpm store is not carried: its 80,000 files unpacked and then linked cost more than installing from the registry. The job runs on xl-linux-amd64, a 32-core node to itself (deploy.yml says why). Measured on it with Turbopack: from its own cache the build step is 35 s (compile 3.0 s, 1,021 pages in 13.6 s on 31 workers), the job 81 s and push to published 85 s, of which checkout 5, restore 9, backing scan 5 and publish 17 (789 files changed; run 39176871954). From the cache saved before Turbopack the build was 47 s (compile 11.4 s), and publishing a commit that renames every chunk, some 6,500 files a host, 60 s (run 39176582223). webpack on the same node built in 90 s (compile 45 s, run 39176272685) and on heavy in 209 s (run 39169068050). The edge kept the old HTML 8–38 s after the origin had the new (s-maxage=60; no purge was seen to land).

    2026-10-20, push to live 85–93 s → 80–92 s (push returned → /_build.json names the commit; runs 39431048221…39399532473 before; 39441999954 89 s, 39444645274 80 s, 39446728514 92 s after, the last with a slow page generation, 19 s against 14). The edge served / 8.8 s after the origin (warm, polled every 0.5 s). What moved: the publish step 25 s → 13 s at ~720 files (hanzoai/ci v2.1.8: the origin's 20-asset sample asked at once instead of one by one, hashing on every core; a timing: line names each phase, e.g. scan 0.9, enqueue 0.4, hash 0.9, diff 0.9, upload 5.9, complete 2.2, readback 0.3); the post-build passes 8.6 s → 3.7 s (scripts/export.mjs reads each page once and writes it once, byte-identical output); the backing scan runs beside the key checks and rooting (7 s → 5). What still stands, per run: runner start 6–10 s (cloud), checkout 5, restore 9 (fetch 2, verify 0.6, unpack 5 — gVisor file creation), next build ~29 (compile 7.4, page data 4, generation 14 of which the last quarter of pages take 8), publish ~12. _build.json is an asset, so it lands in the publish's first phase, a few seconds before the pages. The export was not reproducible: a rebuild of one commit changed 720 files, because oss.json carried every repository's push date and every product page bundles it; the dates now live in lib/data/oss-pushed.json, imported by the landing alone, and a rebuild changes 125 (upload 5.8 s → 2.3 s, publish step 13 → 10; run 39447343282). sync-oss reads with the job's token (60 anonymous calls an hour on the runners' one address fell back to the committed snapshot half the time). The edge: cloud purges the tag site-hanzo-hanzo-ai on every publish and Cloudflare answers ok, but the ingress static plane sent no Cache-Tag, so pages wait out s-maxage=60. hanzoai/ingress c84972297 tags Sites plane pages; it is live once the AWS edge runs a release that carries it. Not yet fixed elsewhere: IAM refuses this repo's Actions ID token ("the assertion does not name a repository": GitHub's immutable sub is repo:hanzo-inc@308927965/app@1345862086:…), so bin/kms falls back to the KMS pair on every publish. @icons-pack/react-simple-icons is in optimizePackageImports: @hanzo/ui imports ten icons from its barrel, which was 6,908 of the build's 18,972 modules; the scripts the export ships are byte-identical with it (gates.yml still builds cold and on GitHub's cache).

    The gates (.github/workflows/gates.yml): what changed runs on one job; the whole suite on four shards (--shard, two workers each), each building its own export — no artifact. On CI the log is the report (list + json; each job writes its counts and failures to the run summary). Run them locally the way CI does from a checkout with no .env.local: Next bakes NEXT_PUBLIC_* from it into the export, and a local IAM URL or client id fails the login, impersonation and silent gates (some 30 tests) for a reason CI never has. TZ=UTC pnpm build && TZ=UTC pnpm gates --workers=8 is about eight minutes on dgx; the CI shape is CI=1 pnpm gates --shard=i/4.

    The claim that killed the native path for weeks — "hanzoai/hanzo.ai is a mirror, read-only, its Actions never fire" — is FALSE, and it is why a Cloudflare stopgap kept being re-added. Measured against the forge API: the repo is "mirror": true AND has_actions: true, with 1325 runs, deploy.yml green on push 5/5. A pull mirror on this Gitea runs its Actions perfectly well. Do not restore a second pipeline on the strength of that sentence.

    The publish itself is bin/site in hanzoai/ci, used through hanzoai/ci/.github/actions/site@v1 — hips, logo, flow and gallery make the identical calls, so the contract lives in one place. ONE credential, read from KMS: the job passes KMS_CLIENT_ID / KMS_CLIENT_SECRET and the action reads the deploy key from there, so it rotates by resealing. The 202 hands back a prefix-scoped 30-minute presigned POST grant, so CI holds no bucket key — never add SITES_S3_*.

    v1 is a FLOATING tag, and that is the whole risk surface. It is a branch wearing a version's name: hanzoai/ci moves it forward, and every consumer takes the new tree on its next run without editing anything. So a rename in that repo lands here whether or not this repo is ready for it — sitedeploy became site, the tag moved, and six repos' publishes broke at once.

    Its signature is unmistakable once you have seen it: EVERY step cancelled at 0s while the JOB reads failure. act resolves every step's action before it runs step 0, so an action path that no longer exists kills the job before any step can start. Nothing ran, so nothing can be blamed — it looks exactly like a broken runner or a red gate and is neither. Read the job log (GET /v1/repos/{owner}/{repo}/actions/jobs/{JOB_ID}/logs, the job id from action_run_job — not the run number, not a task id) and the last line names the missing action.yml outright.

    The gates run BEFORE the upload, so a red gate freezes the SITE. They are steps in the publishing job, which is the placement that makes "nothing ships ungated" true — and the cost is that a failing gate is indistinguishable from a broken deploy unless you look. It happened: four gates went red (111 pages inheriting the root layout's title, six redirect shells offered for indexing) and hanzo.ai published nothing for a day while pushes kept succeeding.

    A 520 on / while every named route answers 200 is STORAGE, not the site. The s3 deployment runs master + filer + gateway in ONE pod (weed server -filer -s3, Recreate, 1 replica). Every restart of it drops the master's volume map, the volume servers reconnect without fully re-registering, and any object whose chunk lives on a volume that did not come back reads as volume N not found. When that volume happens to hold hanzo-sites/hanzo/hanzo-ai/index.html, the apex root 520s and nothing else does — /models and /comparison keep serving, which is what makes it look like a page bug.

    Confirm it in one command, and do not guess from the browser:

    kubectl -n hanzo logs deploy/s3 --since=60s | grep -oE 'failed to stream [^ ]+' | sort -u
    

    The fix is a RE-PUBLISH, not a restart. Dispatching deploy.yml rewrites the object with fresh chunks and the root comes back within the run. Restarting s3 or the volume servers is the tempting move and it does not converge — the master will resolve the volume to the correct live pod IP while the filer's own lookup still refuses it, so you can cycle storage all day and change nothing. Restarting the master is also what CAUSES the next occurrence, so reaching for it is strictly negative.

    Same signature, same cure, for any single page: /comparison returned 200 with an empty body after 15s from this, and a re-publish fixed it. A publish that runs while storage is degraded is how the object gets stranded in the first place — sitedeploy sends a manifest and the server deletes keys absent from it, so an upload that fails mid-publish takes the live page with it.

    Read the STEPS, never hunt for the log — logs live in object storage, not on the git pod:

    SELECT s."index", s.name,
           CASE s.status WHEN 1 THEN 'success' WHEN 2 THEN 'failure'
                         WHEN 4 THEN 'skipped' ELSE s.status::text END,
           (s.stopped - s.started) AS secs
      FROM action_task_step s
     WHERE s.task_id = (SELECT t.id FROM action_task t WHERE t.job_id = <job>)
     ORDER BY s."index";
    

    A step's DURATION is the diagnosis. Publish to the Sites plane | failure | 1s cannot be an upload of 850 pages; Gates | failure | 92s is a real run that found something. And a later step reading skipped HIDES its own failure — fixing the gates is what revealed that the publish had been broken all along.

  • The GitHub repo is hanzo-apps/ai. hanzoai/hanzo.ai only redirects there. Push to the real name — a redirect is why gh reports runs under one repo while you push to another. Push to hanzogit too: that remote is what the forge builds from.

  • The apex IS the live origin. hanzo.ai and hanzo-ai.hanzo.app serve the same Sites-plane build; the DNS cutover this file used to gate on has happened. Measured: a green deploy.yml run, and both hosts immediately serving a header menu that existed only in that commit — which is the only honest check, because a route that predates the build proves nothing. Do NOT read server: cloudflare as evidence otherwise: Cloudflare fronts the sites edge too, so that header is byte-identical on both hosts and distinguishes nothing.

The apex is the landing, components/home/Home

app/page.tsx draws components/home/Home inside Apex (the MOVED and BOOT scripts, a signed-in reader gets the app; "The apex is the landing for a stranger" below), titled "Hanzo AI — Better, Faster, Cheaper. Pick Three.", canonical https://hanzo.ai. It is the page the owner read at /home2 and approved on 2026-10-16; /home2 is MOVED to / and the edge answers it with a 301. The order, the figures and the gate are "/ is the landing" below. The landing that stood before it (HomeLanding: a sign-in hero beside the builder's three-surface demo, the measured Advantage tiles, the plans, the FAQ) is gone with what only it used: Advantage, the demo's stack variant and its two captures.

The numbers are derived, never typed

Every figure on the landing is read when the page opens (lib/home, below), and a count of models is the price list's own (components/api/count). Three numbers were proposed for the landing and are deliberately absent, each for its own reason:

  • 600+ integrations. lib/integrations.ts holds 19, and the build emits 19 pages. Off by more than an order of magnitude.
  • 260+ MCP tools. Closer to real, and it argues against our own product: /mcp's thesis is "a catalog of 260 collapsed into 13", because a tool list is a prompt and two hundred near-duplicates make a model choose badly. The thirteen ARE the achievement; advertising the 260 sells the problem.
  • "the average company uses 400 SaaS products". There is no single true value — published 2025-26 figures run from ~106 per organization, to 152 under 500 employees against 660 over 10,000, to 831 once shadow IT is discovered. ReplaceTheCore says "more than a hundred, and several hundred at enterprise scale", which is true across all of them.

Also absent: "99% of what a business needs", which invites "99% of which workflows, measured how?" and has no measurement behind it. The section names the categories we ship first-party instead, each linked to a page a reader can open and check.

Proof is the agency's track record, and it is a different claim

Proof names Triller, Bellabeat, Casper Labs, Unikrn, Damon and Cover Build — every one has a case study in hanzo/agency/src/data/case-studies/. These companies came through Hanzo Agency, which built and shipped their products. That is NOT the sentence "these companies run Hanzo OS in production", and writing the second one would repeat the error lib/constants/partner-logos.ts was split up to prevent: a mixed list that quietly upgrades a vendor into a customer. Lux and Zoo are deliberately absent — counting your own ventures as customers is the same category error in a friendlier coat. No multiplier claims: the Damon study carries a "500X ROI" line, and a return figure without its methodology is one a reader cannot check.

Analogies stay out of the page

"Heroku for the age of AI" and "Apple-like vertical integration" explain the platform well in a room and are not on the site. An analogy asks a reader to hold someone else's brand in mind while deciding about ours. The page says the thing itself: the intelligence, applications, runtime, data and cloud are designed together.

Two faces, one export

This ONE static export (out/) serves TWO sites — split by host, not by build:

  1. hanzo.ai apex (Sites plane, slug hanzo-ai) — the landing. Route: app/page.tsx → components/home/Home, inside Apex. It lives at the app ROOT (outside (marketing)) so only the root layout wraps it, and it draws the same header and footer as every other page (components/home/shell.tsx). The composer opens /chat here.

  2. cloud.hanzo.ai (Sites plane, slug cloud-hanzo-ai) — the detailed product/marketing site. Its ROOT is app/(marketing)/cloud/page.tsx (components/cloud/CloudLanding), and every deep (marketing)/* page (docdb, vector, kv, iam, …) serves beneath it. deploy.yml hardlinks out/ to out-cloud/ and copies out/cloud.html over out-cloud/index.html, so the two sites differ by exactly one file and an apex / change never touches this host. Both slugs go up from the ONE job, behind the same gates, on the same KMS credential — sites: on the site action takes a slug=dir per line. Universe maps the host the way it maps the apex: a staticFiles middleware on s3://hanzo-sites/hanzo/cloud-hanzo-ai plus an IngressRoute in charts/app/values/hanzo/static-sites.yaml.

    It was ghcr.io/hanzoai/cloud-www — an image built by cloud.yml out of the Dockerfile's ARG SURFACE, pinned by tag AND digest in charts/app/values/hanzo/www.yaml. The pin is what broke, repeatedly: it stopped moving and the host served a month-old build of the same tree the apex was shipping hourly. A push-to-deploy site has no pin to forget. Dockerfile stays — team.yml still builds hanzo.team from it.

  • The old apex homepage (app/(marketing)/page.tsx, the components/landing/* sections) was relocated to /overview (app/(marketing)/overview/page.tsx) — kept on the detailed site, wrapped by the (marketing) Navbar/Footer. Surfaced in the landing nav under Research → Overview.

UI substrate — @hanzo/ui 8.x on @hanzo/gui

ONE component library, ONE token source, ONE way to style.

  • Components: @hanzo/ui (root export) for the component surface; @hanzo/ui/product for the gui-backed product layer (DataTable, StatusTag, PageHeader, SiteNav, MetricCard, …); @hanzo/gui for the primitives (YStack / XStack / Text / View / Sheet / Accordion).

  • v5 → v8 map: nearly every @hanzo/ui/<component> subpath is gone. tabs, progress, select, dialog → the package ROOT. dropdown-menu is the exception and no longer belongs in that list: @hanzo/ui/dropdown-menu is a live export again, re-added at 8.0.33 (bee0655dd, 2026-07-29) and present at 8.0.76, mapping to ./dist/backends/gui/dropdown-menu.js. accordion and sheet have no v8 equivalent → gui's own. Table* has none → DataTable from @hanzo/ui/product.

  • Config: gui.config.ts binds gui's token namespace to @hanzo/design's CSS custom properties, so $4 and var(--space-4) are one value. The semantic colours are gui TOKENS, not a gui THEME: gui republishes every theme key as a bare --<key> at :root, and for background that would make the property reference itself (CSS drops both sides of a cycle). background is therefore the ONE value mirrored from @hanzo/design's exported literals; everything else is a live var() reference.

  • ONE @hanzogui/web, always. @hanzo/gui and every @hanzogui/* are one release train published in lockstep; they must resolve to ONE version, because @hanzogui/web holds the config and theme singletons that createGui writes at import. Two copies means the copy a component reads from was never configured. Bumping @hanzo/gui alone is therefore never a bump — it is a split, and it fails twice over:

    • At type level, disguised as an API change. defaultConfig came from the old copy, so its AnimationDriver was a different declaration than the one the new createGui expects. createGui<Conf extends CreateGuiProps> then falls back to its CONSTRAINT — the same degradation this file already records under the font-size note — and the fallout reads like a library rewrite: AnimationsConfigObject demands a default key, GuiConfig circularly references itself, and Accordion "no longer accepts" width or children. None of that was real. gui's own apps/demos/src/AccordionDemo.tsx composes Accordion with exactly the props ours does. Do not adapt call sites to these errors — they are one dependency defect wearing four masks, and every "fix" would be damage.
    • At runtime, where the type gate cannot see it. Missing theme. on prerender — @hanzo/ui pulled its own old @hanzogui/toast/telemetry island, so /leadership read a config singleton nobody set. A green typecheck does not prove one copy; only the lockfile does.

    pnpm.overrides pins @hanzogui/{web,toast,telemetry} for this reason: @hanzo/ui still carries stale exact pins beneath caret ranges, and pnpm keeps a satisfied lockfile entry rather than re-resolving it. Verify with grep -c "@hanzogui/web': 8\.0\.0" pnpm-lock.yaml — the answer is 0, and node_modules/.pnpm will still show an orphaned dir, which is not a copy.

  • TWO COMPOSERS, BOTH SELF-DRAWN. @hanzo/composer is gui: Composer (the ring and halo, data-slot="prism"), Control (the round mic/send, data-slot="prism-control", fill for send) and Field (the input with its chrome off). The marketing bar and the footer/dock ask box use it. Signed in, /chat and /dev draw ONE composer, @hanzo/build's prompt (≥ 0.2.61) over @hanzo/ui/chat's Composer: + (files, agents, skills, Research, Memory), dictation with its device menu, voice mode or send, and under the frame the model, effort and mode; @hanzo/app' LLM.md "Chat and Dev are drawn alike" is its map. Neither needs a host rule, and there is no app stylesheet to hold one. The panel inside Composer is PILL from components/shell/pill.ts (the chrome's glass plus RING, a focusWithinStyle shadow in var(--ring): 40% white on dark, 50% black on light); any other field with its chrome off — the /models and /blockchain/directory searches — is a Field in a row wearing RING. e2e/gates/composer.spec.ts measures the radius, the focus ring and the typed ink in BOTH themes rather than trusting the markup. An empty /chat centres its opening and composer through center on @hanzo/ui/chat's Chat (≥ 8.27.46), which spaces the pair from outside, so the plan bars in the composer's foot change nothing; the Enso chip's panel is PopoverContent solid, which the model list it opens inherits.

  • There is no app stylesheet. app/layout.tsx imports the packages' sheets in cascade order — design's tokens and base, @hanzo/ui's glass, motion and document sheets — and gui's generated app/gui.css; e2e/gates/font.spec.ts fails on any other relative .css import. (A sheet of @imports alone also shipped as a 0-byte render-blocking chunk, since Next hoists the imports.) A placeholder's ink is placeholderTextColor on gui's Input ($placeholderColor, design's --text-tertiary); without it gui's ::placeholder rule reads an unset variable and the hint takes the typed ink, so an empty field reads as filled. unstyled zeroes the outline, so such a field states focusVisibleStyle={{ outlineWidth: 2 }} to keep design's focus ring.

  • group is a size container. gui writes $group-hover as a container query, so the element wearing group stops sizing to its content: a speak button with no width measured 18px, its padding and border, with the icon and label spilling out. Give a group an explicit width, or let one Text be the control so its hoverStyle colour reaches the icons through currentColor.

  • A $N size or leading needs a font scope. fontSize="$2" compiles to var(--f-size-2), declared on .font_body; a subtree portalled to <body> sits outside every such scope and the size silently falls back to the inherited 14px. A Text there names fontFamily="$body" itself.

  • A page's own form is size="lg" fill. The sign-in and /contact-sales fields are 46px on the raised ground: Input, Textarea and Picker (the native select, ≥ 8.27.48) take both props, and Picker wears the Input's hover and focus edge. The sign-in centres its label (Field center) and its error. No call site states a field's height.

  • No style={…}, and the lint says so. lint:design's inline-style (@hanzo/design ≥ 0.5.31) counts ANY style object; a value nothing else can express stays with a comment beginning inline-style: on its line or the line above. Type goes on @hanzo/ui/marketing's Line: its props are loose, so it takes a unitless leading (lineHeight="1.4286") and a color-mix() ink that gui's own Text types refuse. A frame's color-mix() ground or a container's textAlign/color rides $platform-web, still an atomic class. Four traps, each measured: gui's Text paints $color unless told color="inherit"; fontFamily="$mono" adds .font_mono, which resets line-height, tracking and weight to the face's $true (a command is render="code", which base.css sets in mono); a render="pre" Text wraps and loses the UA margin until it states whiteSpace="pre" and marginBlock="1em"; and .is_View sets the same four font properties, so text inside a View inherits $true leading, not the page's. A table is gui frames stating TABLE/ROW/CELL (components/pricing/kit.tsx); a cell is a Line render="td", which inherits like a plain cell, and the words in a Spare (a View cell) are a display="block" Line, or the row grows by the strut. Props gui types only as shorthands: text, shrink; an attribute like colSpan rides a spread. A motion host is a frame's render={<motion.div …/>}. A bare <svg> under base.css's max-width: 100% shrinks in a flex row: ProviderMark stands it in a View, whose base does not shrink.

  • render=, not tag= — that is how a gui component picks its host element. tag is not a gui prop: it leaks through as a DOM attribute and the page silently ships with zero <h1>/<section>.

  • More swaps that are not one-for-one. A Text is display: inline, so Text render="p" in a block parent drops its margins and its line box: say display="block" (a flex parent blockifies it anyway). A View is flex-shrink: 0 where a div shrank: a column that must give way says shrink={1}, a field beside an icon says flex={1} minW={0}, never width="100%", which crushed the icon to nothing. And a block parent turned into a stack stops collapsing margins, so a heading's mb and a lede's mt add up; a Text button keeps the space in <Icon/> Stop. DOM attributes gui does not type ride a spread ({...{ type: 'submit', title, src }}); a CSS property gui types no prop for (scrollSnapType) rides $platform-web; a color-mix() on a typed colour prop rides lib/mix.ts.

  • $platform-web goes through gui's expander, and a render element is a client prop. overscrollBehavior comes out as an empty overscroll-x rule (name the longhands), and a bare number on a property gui does not know ships no unit, so scrollMarginTop: 96 is dropped: write '96px'. A View-rendered <button> or footer <a> loses @hanzo/design's :where() 24px floor and inline-flex to gui's atomic classes, so it states minH={24} or display="inline-flex". And render={<Link/>} or render={<img/>} written in a server component reaches the browser as a lazy reference and the page dies with "Lazy element type must resolve to a class or function": the component that renders one says 'use client' (product Stack, PartnerLogoRow).

  • A field is a Text that renders it, and five more traps. Text render={<input …/>} (or <select>, <textarea>) keeps the element's own attributes and takes the frame, ink and size as props; color="inherit" where the field had color: inherit. A render element LOSES ITS CHILDREN — the Text's children replace them — so a select's options are the Text's children, and gui stamps its own data-slot over the element's, so a slot is a prop. Two gui elements swapped in one place with different render need keys, or React reuses one and its hooks change (error #310). Unwrapping a label's <span> leaves the icon the only element child, and base.css's :has(> svg:only-child) then centres a stretched link: keep the span. base.css paints h1–h4 --text-primary, so a converted heading states that ink, not its parent's. And $platform-web carries resize or wordBreak but not a fontFamily gui cannot name: that resets the face's whole rung (15px), so design's serif stays a commented style.

  • A control is @hanzo/ui's Button, never a Text that renders a button (e2e/gates/cta.spec.ts reads render={<button …/>} as a button with no handler). primary is the loud one and the unmarked default the quiet one; outline paints gui's Button sub-theme ground, louder than primary on light. A close is ghost icon-sm with lucide's X. Its label ellipsizes and never wraps, so a label wider than a phone's column is a Text child in a row that wraps (the create dialog's submit), and a row of Buttons says flexWrap="wrap" so a phone takes a second line instead of clipping. A Button drawn as <a href> through asChild is a link (≥ 8.27.49 drops the frame's role="button" when the host has an href). Every Button stands in a .t_Button sub-theme span (display: contents), so a selector that walks from it to a sibling goes through that span. A destructive action is variant="destructive": design's --destructive fill (red-600) under --destructive-foreground, 4.6:1 dark and 4.8:1 light, hover red-700 (@hanzo/design ≥ 0.5.32 gates every fill and its label at 4.5:1; @hanzo/ui ≥ 8.27.50 reads them, where gui's $red9 was 3.9:1). State WORDS (success, a refusal) are GOOD and BAD from lib/mix.ts: design's state hues mixed toward --foreground, because --state-success is 2.1:1 on light and the destructive fill reads 4.1:1 as text on dark — never var(--destructive) as ink.

  • A pane follows the theme. Calendar, Meet, Drive and the org switcher are gui theme props and design tokens, nothing else: ink $ink / $quiet / $soft / $faint (the last holds 4.5:1 on $panel and $hover, not on $raised); grounds $background, $panel, $hover, $raised, $sunken; edges $borderColor, --border-control, --border-selected; a menu or card --popover with --shadow-lg or --shadow-floating; a veil --surface-scrim; a lucide icon --foreground / --muted-foreground. A --white-NN rung or --neutral-9xx ground is the dark theme hardcoded, since .light does not restate them. /dashboard and /vibe are drawn the same way.

  • Every room is one base. No room has a hue of its own: a room-header icon tile, an event tile, a category tag (BOT AUTOMATION, MEETING) or a time chip is a neutral badge ($hover/$raised, $soft ink); a call to action is variant="primary", whose fill is design's --accent, so an accent @hanzo/appearance sets reaches every room's CTAs at once; never set a primary's color, or its label stays grey on the accent. Design's state tokens (--state-online, GOOD, BAD, the destructive fill) mean only a state that is true now: a live call, a speaking agent, an error. A kind wears a lucide glyph, never a colour emoji. The chrome — bar, menus, sheet, footer, rail, list, switcher, account menu, composer, docks, palette, dialogs — samples monochrome by computed colour in both themes; the help dock's accent swatches are the one hue, because a swatch shows what it picks. <Look /> from @hanzo/app, mounted once in app/providers.tsx, applies the person's appearance — theme included — on every page and keeps it with their IAM account.

  • On a phone a room's side column gives way. Below md Drive's places column is a drawer a "Drive menu" Button opens over a scrim, and a call's co-pilot is a sheet a "Co-pilot" Button opens over the stage, so the files and the stage take the full 390.

  • Provider: components/GuiProvider.tsx hands gui's generated CSS to the prerender via useServerInsertedHTML. Without it a statically exported page ships markup whose classes have no rules until hydration.

  • Tailwind is NOT gone, and the count is now a RATCHET rather than a sentence in this file. scripts/tailwind-floor.mjs counts every className/class attribute AND every string literal inside a className={…} expression — cn('text-xs', …) hides classes from a plain attribute scan — records the total per top-two-path-segment directory in tailwind.json, and --check fails BOTH ways: on growth, and on a shrink the floor did not follow. hanzo.yml runs it beside price literals. tailwind.config.ts (dead — v4 is CSS-first and never read it), tailwindcss-animate, components.json, @tailwindcss/typography and autoprefixer ARE gone. Convert files to gui props or to a shared kit; do not add new utility classes.

    There are no role classes. The site does not import @hanzo/design/roles.css: a page's type, controls and surfaces are @hanzo/ui/marketing components — Display, Title, Eyebrow, Lede, Claim for copy; Action (fill for the loud one), Chip, More for controls; Leaf, Cell, Lift, Reveal, Lattice for surfaces and the marks inside them; Pair, Pager, Run, Document. A page imports them from @hanzo/ui/marketing; components/ui's barrel keeps only the six gui primitives most pages render, because every member it names loads wherever it loads (an (app) page pays for nothing it does not draw). An internal href on one becomes the router link inside the component, from the Link context app/providers.tsx sets once — a server page never hands in render={<Link/>}, which can arrive as a lazy reference gui cannot render.

    Read the number off tailwind.json, never off this paragraph. Two figures have already gone stale here (63,151 / 1,560 / 485, then 58,424 / 1,293 / 456) and a third would too — which is why the file exists. The DISTINCT count is not what the ratchet holds, so re-measure it before quoting one; only the total and the file count are live.

    The lever is folding a duplicated page shape onto the component that already owns it, not translating classes one at a time. Fifteen pages have gone that way and they were two clusters, not fifteen decisions:

    /ledger /docdb /database /billing /node /network     ~275 each
    /console /insights /kms /kv /mq /pubsub /sql /storage /stream  ~310 each
    

    Each now passes ONE typed props object to components/product/ProductLanding and carries ZERO layout classes of its own.

    FIND THE NEXT CLUSTER BY MEASURING, NOT BY EYE. Take the set of distinct className literals per page and compare pairwise by Jaccard: the nine-page cluster above scored 100% against each other — nine pages, one class set, nobody had noticed. That measurement is what turned "rip out 55k classes" into one afternoon's work, and re-running it is the first thing to do before converting anything by hand. A page whose classes are a copy of a neighbour's is not a translation job, it is a missing caller.

    A shared kit is allowed to GROW to let pages shrink, and the ratchet will say so per directory: components/product went +8 while app/(marketing) went −2,794, because carrying those nine pages' own words needed an optional section eyebrow, a hero tagline line, and a terminal frame labelled with a FILE name rather than a language. Record both numbers; the trade is the point of the kit.

    A fourth cluster went the same way — /computer /desktop /gallery /world, 83–97% identical — and it is the one that shows why the clustering is done at a THRESHOLD and then read, never trusted: those four plus /gui /ui /treasury came back as one seven-page group only because union-find CHAINED them through a single 80% edge. The pairwise matrix showed two groups. Print the matrix before converting anything.

    A PROBE THAT RETURNS NOTHING BECAUSE THE PROBE IS BROKEN LOOKS EXACTLY LIKE AN ABSENCE. Those four pages each carry a tagline line, and the first reading of them reported tagline=None for all four — not because the line was missing but because the quoting in the throwaway print statement asking for it was wrong. It was caught by the text diff, not by re-reading the probe. When an extractor reports a field absent across EVERY member of a uniform group, that is a fact about the extractor until proven otherwise.

    STRIP // COMMENTS BEFORE COLLAPSING WHITESPACE. A feature array with a three-line comment above one entry becomes, after \s+ -> " ", a single line whose comment swallows the entry — so /hsm extracted five cards where the page had six, and shipped without "Keys never leave the hardware". The build, the typecheck and 100 gates were green; the text diff named the missing sentence.

    WHERE THE FOLD STOPS: /edge and /identity, and the reason is content, not effort. They score 81% on class vocabulary and read as the next cluster. They are not one: each carries THREE different card designs — an icon-in-a-circle row under the hero, an icon/title/body grid, and a card with a nested <ul> inside it. ProductLanding's pillar and feature cards are one icon, one title, one paragraph, so folding those pages either drops the bullet lists or moves the same classes into children, which is the same markup in a different file. A shared class VOCABULARY is not a shared SHAPE; check what the cards hold before believing the Jaccard score.

    Carry the words, and prove it rather than claiming it. Snapshot the rendered text of every page from out/ BEFORE the change, diff it after, and report what moved. On the nine that surfaced a real defect nothing else caught: a backtick inside pubsub's code sample arrived from the extractor already escaped and was escaped a second time on emit, so the published sample read []byte(\{...}`)`. A build, a typecheck and 100 gates were all green on it. The same check on the four caught three more: a tagline class the extractor never asked for, a lede whose command name is set in mono (so the lede is markup, not a string), and a whole platforms strip on /desktop that no other page in its cluster has.

  • Radix is GONE, and the primitives it used to serve now come from @hanzo/ui. Zero by four independent probes — package.json, pnpm-lock.yaml, node_modules/.pnpm (so nothing transitive either), and source imports. class-variance-authority, cmdk and shadcn are absent the same way; the only survivors of that era are clsx and tailwind-merge in one file each. What proves the migration landed rather than merely that the dependency left: the radix-shaped names this site imports — Dialog, DropdownMenu, Select, Tabs, Avatar, Progress, Popover — resolve through the @hanzo/ui root barrel, and all 30 of them are on it. Accordion comes from gui's own, as this file already says it must.

    Adoption is deeper than a class count suggests: 139 files import @hanzo/ui and 18 import @hanzo/gui. The one real second system left is icons — 400 files import lucide-react directly against 2 on @hanzogui/lucide-icons-2, which is the same "two vocabularies for one job" shape as the utility classes and is not tracked anywhere else.

  • No Tailwind class on this site is dead. All 1,293 resolve, measured against a FRESH compile of current source (@tailwindcss/postcss over app/globals.css, 204,712 bytes) plus every authored stylesheet, every inline <style> block, and the @hanzo/design tokens. bg-grid-white and scrollbar-hide were deleted from source; hover:border-white/30 and /50 do emit; prose survives only as this site's own rule. The bug family named here is closed — but keep the technique, because it is how the next one is found.

    Four traps manufacture false positives, and each one cost a wrong answer. Never subtract against a committed out/ — a build 56 seconds older than an edit reported sm:pb-28 dead when a fresh compile emits it. Tailwind escapes a leading digit as a hex code with a trailing space (2xl: is .\32 xl\:), so a naive \\(.) unescaper truncates the class; unescape properly or parse selectors. space-y-* emits with a >:not(:last-child) suffix rather than {. And stripping a variant to test its base is invalid — border-white/30 existing says nothing about hover:border-white/30.

  • Interpolated classes: 19 stem sites in source, and only two are classes. ts-c-${cov[i]} and ts-e-${r.ev} in app/(marketing)/calculator/page.tsx, both safe — that is the page's LOCAL ts- namespace in its own <style> block, and every union member is enumerated (four Cov values against four rules, five Ev against four, internal and repo sharing one). The other seventeen are React keys, ids and htmlFor, which Tailwind never had to see. text-${color} and from-${color} in components/commerce/UseCases.tsx are inside the comment documenting that bug's fix — the grep hits prose, not code.

    Why the remaining conversion is not a codemod. gui silently ignores a prop it does not know — in a JSX spread there is no error and no type failure, just an element that is not styled. This file already records five separate defects with that exact shape (animation vs transition, $gtSm vs $sm, tag vs render, lineHeight={1.1} → 1.1px, letterSpacing as a prop). A mechanical className→prop pass would therefore produce a build that is green and a site that is wrong, and this repo has no visual regression coverage to catch it: e2e/gates asserts four specific invariants over the export, and there is not one toHaveScreenshot in the suite. Screenshot baselines are the precondition for the rest of the migration, not an optional extra. Convert by route, verify by rendering.

    A breakpoint object is REPLACED by a spread, never merged with one. $sm={{ paddingVertical: '$10' }} {...GUTTER} where GUTTER carries its own $sm leaves only the gutter's half; JSX assigns whole keys, and $sm is one key. The base props sit beside it and land, so the element is right at 390px and quietly wrong at 640 — the sixth defect of the same shape. State one $sm per element, inline. Read the shipped markup to check: gui writes each breakpoint property as its own class (_pt-_sm_c-space-10) under @media (min-width: 640px), so a property that lost has no class at all.

  • Box and tw from @hanzo/ui are the intended path, and it is BLOCKED on two of the properties it needs most. tw maps a class to a gui style prop and hands back what it does not know in rest, so nothing is dropped by the adapter; Box renders that onto a gui element and passes rest through as a class name. Coverage over this site's vocabulary is real — 1,040 of 1,293 distinct classes convert, 97.5% of all uses — and the remainder is mostly gradients (bg-gradient-to-*, from-*, to-*), the site's own role classes, the ts- namespace, and group/container.

    Measured on /pricing at 1280x900, converting one 20-line component with four classes: geometry held EXACTLY (768px wide, 224px auto side margins, 48px beneath, page height 3891 both ways) while two computed properties regressed. textAlign reached the DOM as the raw attribute textalign="center" with no atomic class behind it and computed start instead of center; display never applied and the element became flex-column. The change was reverted.

    Both are the silent-drop shape again, one level down — the ADAPTER is honest and the element underneath cannot express the value. This is why geometry and a screenshot are not enough on their own: the picture was unchanged because a child happened to centre itself, and only reading computed style showed the loss. display (3,150 uses) and textAlign (807 uses) are the third and eighth most common properties tw emits, so this is roughly four thousand call sites, not an edge.

    The display half is substrate drift and is already fixed upstream: the INSTALLED @hanzo/ui 8.0.86 spreads props straight onto YStack, while the canonical tree states display="block" on it and records what taking the Stack's flex default instead cost — 77 of 225 elements on one page becoming flex containers nobody asked for. textAlign is NOT fixed there; canonical spreads it the same way, so an upgrade alone is unlikely to help and must be re-measured rather than assumed. Pin @hanzo/ui exactly before converting anything — the range here is ^8.0.83 and it currently floats to 8.0.86, which means the substrate can move underneath a migration mid-flight.

  • Plain CSS is named plainly (.prose, .lattice, .bare), parameterised by CSS custom properties. This is where styling lands when it is neither a gui prop nor a token. Do NOT invent utility classes — a utility vocabulary of our own is just Tailwind wearing our name.

    These names carry NO prefix. No package stamps a class on this site either: @hanzo/ui, @hanzo/composer, @hanzo/iam and @hanzo/build style themselves with gui props and say what a part is with data-slot (and how it moves with data-motion), so the compiled CSS selects no hz- name and no element wears one. A host rule against a package's slot is a defect in the package; fix it there and bump.

    Two words were already spent and are worth knowing, because they are the shape of the check rather than a list to memorise: .glass is @hanzo/ui's frosted material, so this site's chrome fill is .chrome (which is also what its two custom properties are called, so the material and its knobs read as one thing); and .grid is Tailwind's own display:grid, so the ruled backdrop is .lattice. Check a new name against the COMPILED stylesheet, not against this file — out/_next/static/css/*.css is every dependency's rules plus whatever Tailwind emitted for the classes actually used, and a word that is free here can be taken there.

Brand Colors (Monochrome)

The values live in @hanzo/design (tokens/colors.css), imported by app/layout.tsx. This site does NOT define them — a local :root/.dark copy is how hanzo.ai drifted from console/chat/app.

  • Primary: #ffffff (white)
  • Secondary: #d4d4d4 (neutral-300)
  • Hover: #a3a3a3 (neutral-400)
  • Brand CSS var: --brand: #e4e4e7
  • @hanzo/design is DARK-FIRST: :root is dark, .light retunes. Theme is an @hanzo/appearance axis (system | light | dark): bootScript({ base: { theme: 'dark' } }) in the head replays the last paint before first frame, apply() writes light/dark and gui's t_light/t_dark on <html>, and GuiProvider follows useScheme(). No theme library.
  • Code samples are components/code (<Code code lang />), highlighted at build. A server component: shiki 4 with @hanzo/ui's codeTheme (Dracula dark, GitHub Light light, from @hanzo/ui/core), each colour written as light-dark() so the page's color-scheme picks it. The tokens are the theme's; the ground is neutral, never Dracula's blue-violet #282a36 (the site is monochrome): var(--card) on dark, white on light. No highlighter reaches the browser (gate e2e/gates/code.spec.ts). A client page that shows code is split: page.tsx (server) owns the sample and passes <Code> as a prop to view.tsx (the client body) — ProductLanding's code.source, the blockchain pages' codeExamples (components/blockchain/sample), kit Sample's child, the guest-key card (components/models/guest). Transcripts (Session), agent prompts and ASCII diagrams are not code and stay as they are.
  • tokens/base.css is imported into layer(base). Bare, its element selectors (a { color: … }) outrank EVERY layered utility — unlayered wins regardless of specificity — and the header CTA rendered white-on-white.
  • Naming: lib/constants/brand.ts still carries the JS-side constants.

The globe is the one place colour is allowed

components/webgl/PointGlobe.tsx is the site's only WebGL motif — raw GL and inline GLSL, no dependency, code-split behind next/dynamic({ ssr:false }). ONE consumer now: HanzoNetworkSection, on /overview. It was three — the cloud hero and then the apex fold both became films (below), and a page gets ONE picture, not two. The sphere is the right picture for a section about a network and the wrong one for a landing page: it is the best abstract thing we draw, and a visitor who has not heard of Hanzo watched it and learned nothing about the product. It is not deleted and it is not homeless.

The chrome stays monochrome. What carries hue is CONVERSATIONS — great-circle paths between agents, spawned and retired continuously, each with a travelling head — in blue → violet → pink → amber. That split is the rule: if a viewer sees colour on this site, something is talking.

Two traps cost a full rebuild of this component, and neither announces itself:

  • WebGL clamps lineWidth to one DEVICE pixel. At DPR 2 that is half a CSS pixel, so a coloured LINE_STRIP on black is invisible — which is exactly how the previous globe's arcs and its nearest-neighbour mesh disappeared. Paths are drawn as runs of POINTS, whose size we control. Never reach for a line.
  • WEBGL_lose_context in an effect's cleanup is permanent for that canvas. React hands the SAME element back on a remount, so getContext returns the dead one, every shader reports a compile failure, and the component's own guard hides the canvas for good. Strict mode remounts everything once, so the globe was invisible in next dev while prod was fine. The explicit deleteBuffer/deleteProgram calls are what free the memory; the context dies with the element.

Sizing lives at the CALL SITE, and it is height, not width: the sphere's on-screen diameter is a fixed fraction of the canvas height (48° vertical FOV at a fixed camera distance) and owes nothing to how wide the canvas is. Fitting the canvas to a wide, short hero therefore yields a small globe adrift in black — overflow the section's height instead.

A hero film is assets, not code

cloud.hanzo.ai's fold is a film of the console — the real chrome, the real categories, the model count read from the price list (GET /v1/pricing), the Playground answering a prompt — and it ends on that console running, not on a wordmark the visitor already sees in the header. The badge, headline, paragraph and the separate ProductShot still it replaced are GONE rather than laid over it: the film says all four, and what stays beside it is only what a film cannot do — the three actions and a command to copy.

Two homes, and they do not mix:

hanzoai/frames the renderer — our fork of the OSS local HTML→mp4 renderer. Headless Chrome, no keys, no service.
@hanzo/frame the player — the ONE <Frame> primitive every Hanzo surface embeds. hanzoai/frame.

<Frame src="/cloud-hero" alt="…" /> is the whole call site. Two props, no options: src is the shared PREFIX of six rendered files and the names are the contract — -tall.mp4 / -wide.mp4 and a -first + -last still for each. The component picks the master by ORIENTATION (a 768×1024 tablet is portrait, and the wide master would lose 555px off each side), serves the still from <picture> so it is right in SSR with no JavaScript, gives a reduced-motion viewer the FINAL frame — the finished product — and creates no <video> at all for them, and never loops.

To give another page a film: copy film/cloud, edit the copy's film.mjs, make, then add one <Frame>. There is no per-page component and nothing to register. film/cloud/film.mjs writes BOTH masters from one source, because a phone is 0.56 wide-to-tall and a laptop 1.78 and hand-keeping two HTML files drifts the first time a line of copy changes; make composes, renders, cuts the stills with ffmpeg and publishes all six into public/. tall/ and wide/ are generated and gitignored — the generator and assets/ are the source.

House rules, learned the expensive way:

  • Film the real product. Real chrome, real copy, real numbers. The film reads its model count from the price list every page reads, so it cannot disagree with the page around it — and scripts/audit-model-counts.mjs already fails the build on a hand-typed one. No invented metric: the Playground rail shows CONTROLS (model, temperature, max tokens, stream), never a throughput or a cost this film made up.
  • Zen and Zen Mono only — @hanzo/design allows no third face. True black. The dimmest grey that passes hyperframes check's WCAG AA pass is #777; #6e6e6e fails at 4:1.
  • Mind the cover crop. A 1080-wide master on a 390×844 phone loses ~96px off EACH side, so nothing that matters may sit outside the middle 862px.
  • End on the product, held still for the last few seconds. That final frame is what every reduced-motion viewer is served, so it has to look used.
  • Give the <h1> a new home in the same commit. Taking the copy off the fold takes the heading with it, and a film cannot hold one — its message is the alt. cloud.hanzo.ai shipped a live page with ZERO <h1> this way. The heading moves to the first section that has words, and stays VISIBLE: a heading only a screen reader can reach is the message said twice, once badly. Nothing catches this — the export is valid, the build is green, and <h1> count is not one of the four things e2e/gates asserts. Check it by hand: grep -c '<h1' out/<page>.html must be exactly 1.

The apex fold is a film too, and it is a TRAILER rather than a product tour. film/hero → one <Frame src="/hero" …/> in components/home/Fold.tsx. Three movements of one console over 21s: the model catalog, the catalog's own tour told as the calls that build a business in an afternoon, and the ten layers those calls land on — held there for the last 3.3s, because that frame is what every reduced-motion reader is served. It reads what it shows from the files the page beside it reads (the price list for the models and the two counts, catalog.json for the tour and the categories), so the film and CloudCategories structurally cannot name different tens.

The heading did not go into the master; it moved down the page. It is real type in the section directly under the film, visible on the first scroll. The apex film this replaces had its headline in the pixels next to a sidebar naming seven categories over a section naming ten, so the page contradicted itself and every correction cost a re-render. film/hero therefore draws no sentence at all — its whole message in words is the alt, and that is COMPOSED from the catalog rather than typed, because an alt written by hand goes on describing a sequence the film has stopped having.

Still to film. Each of these takes its own film/<name> and one <Frame>: the ten category pages /products/{ai,compute,data,network,security,dev,infrastructure,observe,web3,apps}, and the 25 generated /cloud/<slug> primitive pages. None of them has a film yet, and none needs new code to get one.

cloud.hanzo.ai runs an argument, and /products keeps the index

The landing page used to open on "The AI cloud for agents and apps", show the ten categories, and then spend four thousand pixels listing every product as a card. All of it was true and none of it was a REASON: it described the inventory and left the reader to work out why owning one of these beats owning ten. The page is now an argument, one claim to a screen — headline, the cost avoided, the evidence, the ten layers, the terms — and it is 6.8k pixels instead of 7.2k desktop, 9.4k instead of 14k on a phone.

  • The pitch is "Ten integrated layers. One bill. No assembly tax." The assembly tax is the accounts, keys, SDKs, consoles, invoices, security reviews and glue code that stitching ten vendors together costs every year. The page COUNTS it in a table (n vs 1, seven rows) rather than asserting it, and then settles it with the tour: one org, one bearer, twelve real operations across six layers, no integration between them.
  • components/cloud/Layers.tsx is the ten, as rows off one spine, under the film/stack film of the same ten assembling. Each row is its number, its name (which IS the link to /products/<id>), its tagline, and every product in it named and linked as prose. Nothing is lost by dropping the card grid — every leaf is still one click from the landing page.
  • Depth is lib/data/stack.json, and only there. The catalog's order is the MENU's — AI first, because AI is the headline — and reading it as depth puts settlement ninth and AI under everything. The stack order was declared inside film/stack/film.mjs, which the PAGE cannot read: the list beside the film started at AI while the film stood on the chain, and film/layer got at it by regex-parsing its sibling's source. One file now, three readers — cloudLayers in cloud-primitives.ts, and both film generators. The film's alt is composed from the same order rather than typed, which is how it came to name a sequence the film no longer had.
  • components/cloud/Ladder.tsx renders the SHAPE of the bill from lib/plans.ts (GET /v1/billing/plans, @hanzo/plans as first paint). Prices and the credit each plan returns are read, never typed. "Predictable pricing" is only worth writing above the rows that charge.
  • CloudCategoryShowcase is still the index and still lives at /products. It is no longer rendered by CloudLanding. Do not put it back: the landing page and the catalogue browser are two different jobs.
  • Every count on that page is counted. layerCount and spell() in lib/data/cloud-primitives.ts give the headline, the section heading and the page metadata their number from the catalog — including the <title>, which is baked at build time and is exactly when the catalog is read. The model count stays a read-time question (useSummary, lib/api), and the sentence that would quote it is not drawn at all until the gateway answers.
  • The category prose map is keyed by the catalog's CURRENT id. CATEGORY in cloud-primitives.ts was still keyed platform after commerce renamed that category to infrastructure, so the lookup missed and the category rendered with the fallback icon and NO tagline — on the mega-menu column, the homepage grid, the orbit's centre and its own /products/infrastructure landing. A missing key is silent by construction here (?? Cloud, ?? ''), which is the right behaviour for a build and the reason nothing reported it.

A scroll reveal may not hide the content it reveals

initial={{ opacity: 0 }} IS RENDERED — framer-motion writes it into the style attribute of the exported HTML — so the shipped page states that a section is transparent and the only thing that can take it back is an IntersectionObserver firing after hydration. Every way that observer can be late or absent leaves real copy at zero forever. Measured live at 1440x900 before the fix: 4,380px of /about, 2,229px of the homepage, 972px of /solutions, and a blank band inside all ten sections of /cloud.

The tell was that reduced-motion and no-JS readers saw MORE than everyone else: each had a rescue rule forcing the start state visible, so the two audiences nobody designs for were the two the page worked for. There is no rescue rule now and no no-js class: with scripting off, every top-level route draws every text box a scripted reader sees (measured by deleting the old rule and looking for opacity 0). The old html.no-js .t_unmounted rule was worse than dead — it flattened every gui transform and forced every hover reveal open.

components/motion.tsx is now the only entry point to motion. It is a Proxy over framer's, and it grounds the start state once: for any element carrying whileInView, opacity, x and scale never reach the DOM and y is clamped to 8px. The element ships opaque and in position; what is left of initial is a nudge the entrance closes. Call sites keep their props, durations and stagger — 253 of them changed one import line and nothing else.

Horizontal offsets are DROPPED rather than clamped, because an x: -20 enter is wider than a 390px viewport for the length of the animation, which is the sideways scroll overflow-x: clip exists to swallow.

eslint.config.js enforces it: no-restricted-imports on the motion NAME from framer-motion, with components/motion.tsx the one exemption. Everything else framer exports still comes from framer.

The proxy cannot reach a component that was WIRED to stay hidden, and four on /analytics were. It grounds initial, so it can only help something whose end state is visible; AIFeatureCard read initial="hidden" animate="hidden" and animated TO the hidden state, which meant three 234px cards were invisible on every visit, scrolled to or not. Community ran ONE IntersectionObserver doing two jobs — starting the CountUp tallies AND holding 872px of cards at opacity 0 — so the rule is: an observer may start a count; it may not decide whether the copy exists. Those two triggers stay separate. When auditing, grep for animate= with a variant NAME and for useAnimation/controls.start, not just for whileInView.

⌘K searches the SITE, and the page list is derived

The palette is @hanzogui/shell's (HanzoCommandPalette), and it indexes three things merged into one ranked list: the doors (TRY_HANZO_GROUPS), this site's own pages, and the cloud taxonomy. It used to index the taxonomy alone, so typing pricing answered "No results for pricing" about a page the header links three inches above it.

app/ tree ── scripts/sync-pages.mjs ──> lib/data/pages.json ──> components/home/shell.tsx
             (walks routes(), keeps            top-level routes   <HanzoCommandPalette
              what is not private)                                 commands={pages + the
                                                                   nav's outbound rows}>

Derived, never listed. The sync runs at prebuild through the same policy() that writes the sitemap, so the palette cannot miss a published page or offer a withheld one, and nobody maintains a list. Top level only — everything deeper is the taxonomy, which the palette already indexes from catalog.json under the names commerce gives it. SPELLING in that script is an ORTHOGRAPHY table (api → API), not a taxonomy: it cannot add a page or withhold one.

Ranking lives in the package (search.ts, score): a contiguous run beats a scattered one, a word start beats a word middle, a name beats a description, and a scattered match must ANCHOR where a word does — without that anchor "docs" matches half the sentences on the site, since d·o·c·s walks through most prose. Descriptions are matched whole or not at all; only names are forgiving, which is what lets "machins" find Machines. Every query ends in an Ask row, so none of them dead-ends.

e2e/gates/palette.spec.ts drives the real export for all of it, plus a ceiling on the doors card. The palette needs HYDRATION, so open() retries the chord rather than pressing it once — load fires well before the ⌘K listener is attached, and a single press makes the whole suite flaky for a reason that is not the palette.

Key Files

app/(marketing)/<slug>/page.tsx   # Flat product pages — /dev, /chat, /vector, etc.
app/(marketing)/cloud/[slug]/     # Generated primitive overviews, from cloudPrimitiveSlugs
app/(marketing)/products/[categoryId]/   # Category landings, from categorySlugs
components/motion.tsx             # The ONE entry point to framer's `motion` (see above)
components/ui/chrome-text.tsx     # The display heading — solid ink, 33 surfaces
lib/api/
  read.ts                         # The ONE reader of plans, prices, models, templates, blueprints (in the browser)
  use.ts                          # Its hooks: loading / ready / unreachable
  build.ts                        # The build's read of the model list (page existence only, never a rate)
components/api/                   # Loading / Unreachable, the live count, a model's rate in a sentence
lib/data/
  catalog.json                    # The committed product snapshot (written by prebuild)
  pages.json                      # The published top-level routes (written by prebuild)
  cloud-primitives.ts             # Reads it; owns the prose and nothing else
scripts/
  catalog.mjs                     # The ONE probe — catalog + served document
  sync-catalog.mjs                # prebuild: fetch, gate on reachability, write the snapshot
  sync-pages.mjs                  # prebuild: walk app/, filter by policy(), write pages.json
  audit-catalog.mjs               # hanzo.yml gate: the drift ratchet
components/home/nav-data.ts       # The ONE nav tree (and the hosts the site links out to)
components/home/shell.tsx         # Bar and phone sheet draw the tree; the footer is the short set
app/_web.tsx                      # The gate in front of @hanzo/app App: the rail, the sidebar, Chat, Dev and Studio
app/dev/_door.tsx                 # /dev: a stranger's page or the app; old addresses moved to their paths
app/studio/_door.tsx              # /studio: Studio's landing for a stranger, the app in Studio (@hanzo/studio) for a member
lib/constants/brand.ts            # Brand tokens

The chrome is components/home/shell.tsx drawing components/home/nav-data.ts — see "The chrome: one tree, drawn three ways". components/navigation/ does not exist.

Header Menu (canonical) — hydrated from the commerce catalog

WHICH products exist is commerce's answer, not this repo's. The chain runs:

commerce catalog          scripts/sync-catalog.mjs        lib/data/catalog.json
/v1/commerce/catalog  ->  reachability + href resolve  ->  the committed snapshot
                                    |                              |
                          /v1/openapi.json (the gate)     lib/data/cloud-primitives.ts
                                                                   |
                                          mega-menu · /products/<id> · /cloud/<slug> · showcases

prebuild runs the sync, so the snapshot is re-fetched on every build or it is not shipped at all. It CANNOT be a browser fetch: output: 'export' ships static HTML and the API pins access-control-allow-origin to https://hanzo.ai exactly, so a preview deploy or a local render would get nothing and the menu would be empty.

Reachability is the inclusion gate. A product is written to the snapshot iff /v1/openapi.json carries its apiPath — exactly, or any path beneath it — which is what "this product exists" means, since the document is a projection of the routers cloud mounted. A product marked kind: "client" (the CLI, the SDKs, the API reference) is judged instead by whether this repo has a page for it, because it consumes the API and no apiPath can ever be right for it. So the menu is dynamic AND structurally unable to advertise a 404.

Measured 2026-08-13: 84 advertised · 53 rendered · 31 excluded (13 renamed · 7 client · 1 external · 10 absent — see the audit below).

The href is resolved against the filesystem by the same sync: a bespoke page where this repo publishes one (28 of the 53), /cloud/<slug> otherwise (25), and cloudPrimitiveSlugs builds a page for every one of the latter — so a leaf cannot be a dead link in either branch. Read off app/ rather than declared beside the product, because a table of "which routes have a page" is a second copy of what app/ already says, and the copy is what goes stale.

The split is by kind of fact. The catalog owns membership, name, slug, order, icon, docs and repo. cloud-primitives.ts owns the PROSE — a category's tagline, a leaf's blurb, and the long-form copy a generated overview renders. A product with no prose still renders (as its name plus the facts the catalog states); prose for a product the catalog dropped renders nowhere. Neither can resurrect a product or bury one, which is what makes COPY a copy deck rather than a second taxonomy.

The header does not draw the catalog. Its Products menu is the nav tree (components/home/nav-data.ts); the catalog feeds the /cloud/<slug> overviews and the showcases.

  • Product ↔ /v1/<svc> ↔ plan/usage mapping: docs/product-service-map.md (reconciled against ~/work/hanzo/cloud/subsystems/subsystems.go).

There is no second catalog. lib/data/product-taxonomy.ts held a nine-id taxonomy (data, compute, async, ml, observability, platform, apps, growth, cx) that no page rendered — it, and the three components that read it, are deleted. The bespoke /<slug> product pages import components/products/ProductFooter, not a template built on that list.

What hydration cost, and who fixes it

The hand list and the catalog had drifted far enough that reconciling them is a visible content change, not a refactor. 28 destinations left the products nav and 19 arrived. Four of the departures (/cloud/{cost,finetune,jobs,rerank}) were the known orphans the old agreement test allowlisted — they described products commerce does not sell, and they are gone for that reason.

The rest leave because commerce's catalog does not carry them: /analytics, /automations, /captable, /code, /commerce, /dataroom, /guard, /ingress, /insights, /ledger, /mq, /payments, /pricing, /pubsub, /sentinel, /sign, /solutions/rag, /team, /telemetry, /tunnel, and the eight web3.hanzo.ai leaves. Every one of those pages still builds, answers on its URL and sits in sitemap.xml — only the products menu stopped advertising them. Whole "Payments" category included: commerce files billing under Observe and has no payments category at all, which is why the agreement test was red in both directions before this.

None of that is fixable here. A product belongs in the menu when commerce carries it; the repair is a row in the catalog, and this site will pick it up on the next build. The reverse also holds — /dashboards, /hsm, vpc and the other 12 renamed/absent rows come back the moment the catalog is corrected, with no change to this repo.

The audit: 31 of what we advertise does not answer

scripts/audit-catalog.mjs gates the build on the catalog drifting FURTHER from what is served. It no longer stands between a reader and a 404 — the sync already drops the unreachable — so what it now protects is the MENU: every entry on its ratchet is a product the site would otherwise be selling.

There are five lists, and no two agree:

List Where Count
Site taxonomy lib/data/catalog.json 53 — the catalog, filtered to what answers
Docs prose hanzo-docs/docs (7 files) 67 capabilities / 8 movements
Curation manifest hanzoai/openapi capabilities.yaml 8 domains over 180 tags
Commerce catalog GET /v1/commerce/catalog?brand=hanzo 84 products
The served document GET /v1/openapi.json 1,809 paths

Only the last one is authority: a path in it exists by construction. Everything above it is editorial — including the catalog, which is why the site reads the two together rather than either alone.

Measured 2026-08-13, node scripts/audit-catalog.mjs:

84 products advertised · 53 answer (63%) · 31 do not
  renamed 13 · client 7 · external 1 · absent 10

Every one of those 31 is marked "status": "enabled" and every one is a live 404. Four different problems were hiding in one number:

  • renamed (13) — the capability is real, the apiPath is misspelt. vpc vs /v1/vpcs, wallet vs /v1/wallets, alerts vs /v1/o11y/alerts, indexer vs /v1/indexers, zero-trust vs /v1/networks. Pure catalog bugs, cheapest thing on the list, and each one is a menu leaf we are not selling until it lands.
  • client (7) — cli, sdks, ide, desktop, console, studio, and api itself. These CONSUME the API; no apiPath can ever be right. They need a kind field, not a path. A category error, not a gap. The sync already honours kind: "client": six of the seven have a page here and light up the day commerce sets the field — ide does not, and stays out.
  • external (1) — nodes is luxfi/node, served by Lux, not api.hanzo.ai.
  • absent (10) — advertised, enabled, and nothing serves it. This is the only bucket that is a commercial problem and the only one no code change in this repo can fix: edge, cdn, hsm, mpc, settlement, attestations, datasets, scores, score-configs, annotation-queues. (tokens left this list — cloud serves /v1/tokens/{chain}/{address} now, and the ratchet demanded its own line back, which is the whole point of it.)

Three of those have real repos that were simply never mounted into cloud — hanzoai/edge (Rust, 13 crates), hanzoai/hsm (a Go library that belongs behind /v1/kms, not beside it), hanzoai/cdn (our own asset bucket, never a tenant product). Shipping them is a mount, not a build.

site taxonomy  ->  commerce catalog  ->  served document
(catalog.json)     (/v1/commerce/catalog)   (/v1/openapi.json)
\___ built BY sync-catalog.mjs ___/
\___________ e2e/catalog-agreement.spec.ts ___________/
                                 \___ scripts/audit-catalog.mjs ___/

The first link is no longer an agreement between two hand lists — it is a derivation, so it cannot be got wrong. What replaced that question is the one a build-time snapshot actually raises: is the snapshot still true? A committed file goes stale silently, and the whole point of it is that the site deploys without the API — exactly the condition under which nobody would notice. So e2e/catalog-agreement.spec.ts reads the LIVE catalog and the LIVE document and holds the snapshot to both, re-measuring the inclusion gate rather than trusting it from build time.

scripts/catalog.mjs is the ONE probe, imported by the sync and the audit. The predicate — does the document carry this apiPath — is written once, because a second copy is how the gate comes to say a product is missing while the menu still advertises it.

Three rules make the sync safe to run unattended, and all four refusal paths are measured rather than reasoned about:

  • Never fail the build on an unreachable API. No network → keep the committed snapshot, exit 0. A marketing site must not need the API up to deploy. (The one fatal case is no snapshot on disk AND no API, which can only happen before the first sync — the taxonomy imports that file.)
  • Never regress. A payload that is not a catalog, a document with no paths, or a catalog rendering FEWER products than the committed snapshot is far more likely to be a degraded response than a product line being retired, and writing it would silently empty the menu. It is refused, and what it would have dropped is printed. A genuine retirement is accepted by deleting those rows from lib/data/catalog.json — the next run then sees no shrink.
  • Never go quiet. Every run prints advertised/rendered/excluded and the exclusions grouped by reason.

The audit keeps its own two rules: never fail on an unreachable API, and its KNOWN_UNSERVED ratchet may only shrink. A product that fails and is not on the list fails the run; an entry that starts answering fails the run too, so a fix is forced to delete its own exemption. Do not add entries to make it pass.

Removed (2026-05-07 cleanup)

  • Solutions dropdown — every link pointed to non-existent /industries/* and /solutions/* pages (50+ dead links). Component components/navigation/SolutionsMenu.tsx and the solutions-menu/ directory deleted; capabilitiesNav / industriesNav exports removed.
  • /products/<cat>/<x> URLs — menu now points to flat /<slug> URLs matching the actual route layout under app/(marketing)/.
  • /runtime menu item — no live page or published image; removed pending Runtime product launch.

Cruft sweep (done)

  • Removed components/shadcn-v4/ (unrouted Tailwind-migration demo) plus ~245 other provably-dead Vite→Next migration components — old homepage iterations (index3–index6, hero/, landing/, features-showcase/, animations/), replaced product/section trees (balancer, zen, observability, open-source, hanzoapp, hanzodev, the old pricing subset, …), and orphan utils (contexts/Web3Context, hooks/use-mobile, lib/og-image, ui/{code-block,masonry-grid,radix-button}). Each was confirmed unreachable by a full static import-graph walk from the App Router entrypoints (no dynamic imports exist, so the graph is complete).
  • Already resolved before this sweep: products-menu/product-data.ts (deleted; landing/FeatureShowcase.tsx reads navigation-data.ts), /home2, /referrals, /referral-program. /referral is the single surviving referrals page — keep it.

Solutions is one list

lib/data/solutions.ts holds every Teams, Industries and Use cases entry and the Reserved tier. /solutions is the index, /solutions/<slug> renders one entry through components/solutions/Solution.tsx (page-kit: hero with Try Hanzo + Contact sales, what it does, the products it runs on, security where a buyer asks, siblings), and the sitemap and the header's Solutions menu (components/home/nav-data.ts) read the same list. Add a solution there and nowhere else.

  • The file imports nothing. The menu ships it on every page, so the reserved tier's GPU rates are read from the price list when the page opens (components/solutions/Gpus.tsx), never in the data.
  • Compliance copy says only what /trust and /legal say: controls built to be compatible with SOC 2, report on request; PHI, card data and student records only under separate written terms, or self-hosted. e2e/unit/claims.spec.ts and e2e/unit/solutions.spec.ts hold it.
  • Retired addresses (/solutions/{rag,realtime,vision,audio,fine-tuning}, /solutions/{stacks,industries}/*, /solutions/capabilities, /fintech) are MOVED in lib/publish.ts and forward from the same [...slug] route.

Design System

  • Page shapes: components/marketing/page-kit.tsx (gui-native) — PageHero, Section, CardGrid, Cta, Prose, Page. Pages pass CONTENT, not styling. Prose is @hanzo/ui's Document: authored markup under [data-slot='document'], set by @hanzo/ui/styles/document.css.
  • A ul has UA margins and a 40px indent since Tailwind's preflight left (0e41045ea); base.css does not reset it. A list converted to gui states my/pl to keep that look, or m={0} p={0} to drop it — on purpose either way.
  • The display heading is @hanzo/ui's Display, and components/ui/chrome-text.tsx wears it. It states the WHOLE type — the display face at book weight (497), 1.19 leading, and one rung of the ramp per breakpoint, 32 / 40 / 52; a stated fontSize is the whole size. Splitting it — ink and leading in the component, scale at the call site — is how six measured pages came to state four mobile scales (one of them 41.6px, no rung of anything), three laptop scales, three weights and two whites for one h1. A page passes CONTENT and spacing, never type.
  • A set that is true all at once is swept, not listed — Run. The container is @hanzo/ui's Run, each DIRECT child says its place with data-beat={i}, and a light crosses the set once per cycle. It exists because prose states invariants one at a time and a reader has finished the first before the fourth exists, so six checks that hold SIMULTANEOUSLY render as six that happen in turn. Two props and nothing else: rest is the colour the sweep returns to (default --neutral-400), because the animation owns color for the whole cycle and would otherwise flatten a caller's own resting shade; step is the delay per beat (default 0.22s), short against the 5.4s cycle so several items are lit at once and none is ever "next". An ORDERED set says so by taking a step near cycle/n — LearnLoop's ring and Observability's trace walk; Security's invariants, Team's nine contexts, the eight interfaces and AgentRuntime's five hands sweep. Order is a property of the CONTENT, so the caller that has it says so and the component does not guess. It animates COLOUR only, so it composes with an entry reveal that owns transform and opacity, and @hanzo/ui/styles/motion.css stills it under reduced motion. Children must be DIRECT: a wrapper takes the animation and passes nothing to the chip whose border is the thing that lights, which is why Security and Observability emit flat cells in a <Fragment> rather than a row per item.
  • Hero: radial gradient bg (800px, blur 100px, 15% opacity)
  • Animation: framer-motion, 0.4s base, 0.05s stagger
  • Font: Zen, self-hosted by @hanzo/design, named as its --font-sans by tokens/fonts.css — one binding, one place. It is NOT next/font/google: that could only ever serve a Google-hosted family, which is what the gate in e2e/gates/font.spec.ts exists to keep out.

Mobile invariants (stated once, in @hanzo/design's base.css)

  • The page never scrolls sideways. overflow-x: clip on html AND body — the scrollport is the documentElement, so clipping body alone still let a 4px sideways scroll through on /about (a whileInView x:20 enter is 20px wider than a 390px viewport for the length of the animation).
  • The touch floor is pointer-conditional: 24px always (WCAG 2.5.8 AA), 44px under @media (pointer: coarse) (Apple HIG / WCAG 2.5.5 AAA — a thumb target). Still a POLICY, not a per-button decision: it had been written as a min-h-11 utility per footer link and consequently forgotten on the wordmark (24px), the search button (36px) and the GitHub icon (20px) of the same page. But stating 44px for EVERY pointer was the wrong policy — it stretched ten of the eleven controls on the homepage, turning the 21px "Chat with Enso" pills into 44px slabs. A mouse does not need a thumb target.
  • A floor needs a box, and it applies to the SMALLER SIDE. min-height does nothing to a non-replaced inline box, so every mark that claims a tap target — .tap and header/footer links alike — is given inline-flex unconditionally and the 44px minimum only under a coarse pointer. Stating the minimum without the box is the defect that reads as fixed and measures 17px; stating only the height leaves "Bot" at 21x44. Prose links are deliberately outside all of it: stretching a link inside a sentence tears the line.
  • The sheet carries the bottom inset, as the header carries the top one: the sheet (Sheet in components/home/shell.tsx) is position: fixed under the bar, so body's inset cannot reach it, and its own bottom padding adds env(safe-area-inset-bottom), as the bar's top padding adds the top inset. Write the env() out: gui's pt="safe" type-checks and ships padding-top: safe, which the browser drops.
  • Verify at 390px with Playwright before claiming mobile works.

No authoring widget ships

hanzo.app/edit.js used to load in app/layout.tsx on all 883 pages. It is an authoring tool, and this is a published static site: it mounted a launcher in a shadow root at z-index 2147483000, which on a phone covered 43x32px of the drawer's "Try Hanzo" — the one primary action — so the edge of the CTA opened an editor. Two rules in app/globals.css existed only to live beside it (a z-index clamp on [data-hanzo-edit], and 64px of .dock clearance to keep the composer above the launcher) and went with it. Do not put it back; the entry point for editing this site is this repo.

/account is a section, and its layout owns the frame

app/(marketing)/account/layout.tsx states the gutter, the measure and the rhythm once, and asks who is reading before a page renders. A page under it is its content and nothing else — no background, no <main>, no measure of its own, and no check of its own for a session.

  • The frame: 16px gutter, 32px from $sm; measure 1024, centred. Wider than page-kit's 896 because these are forms and tables rather than prose, and each form caps itself at 576 through components/account/form.tsx.
  • Form and Field are the two shapes every page here is made of. They carry the measure and the label-to-control spacing so a page never guesses at either; four pages had guessed differently, and one had reached for a <div className="space-y-2"> per field.
  • The session is settled ONCE. useIam() in the layout: nothing while it resolves, the sign-in prompt with no user, the page with one. A page that still narrows user writes if (!user) return null — that is a type narrowing, not a second answer to the question.
  • Sizes are gui props, because @hanzo/ui 8 reads props and not classes. <Avatar className="h-24 w-24"> renders at the component's default 32, and space-x-4 on a row of gui Buttons stacks them: a gui Button is a block-level flex box, so the margin utility never applies. Use size={96} and an XStack gap. This is the same silent-drop family the UI substrate section catalogues, met from the other direction.
  • /account is in PRIVATE (lib/publish.ts), so the export ships the frame with an empty measure and fills it after hydration. That is the honest static answer — a file cannot know who fetched it — and nothing indexes it.

The routes that reach it are the focused page ("The way in is one page" below): /login, /signup and /auth/callback draw components/shell/focus, and a page on its way somewhere is its Wait (the spinner over the title). It is the window under the bar tall (100svh less BAR_HEIGHT, the bar's stated height: --header is 0 until the bar measures itself, so a page sized from it paints half a bar off centre and scrolls until the script runs), never min-h-screen inside a layout that already has a header, which is what made a page with one line of text scroll 513px. The callback's Suspense fallback is a Wait too: as an empty box it shipped the landing route with no heading at all.

Measure these with JavaScript ON. react-native-web injects its stylesheet at runtime, so with scripting off the Spinner's r-height/r-width classes have no rules and its SVG falls back to the 300x300 default for a replaced element with no intrinsic size. Tailwind classes come from the external stylesheet and do apply, so a JS-off measurement looks authoritative and silently inflates anything gui sizes — it read a 36px spinner as 300 and put 264px of phantom height into every number around it.

Measured by asking docs.hanzo.ai, not by reading a sitemap — the sitemap lists pages that 404, so only an HTTP check counts. Of the 144 distinct docs.hanzo.ai URLs hanzo.ai published, 118 answered 404.

  • The footer's Quickstarts link was on every page. It is not this repo's: the shared chrome is @hanzogui/shell, and hanzo-registry.ts pointed quickstarts at /docs/getting-started, which does not exist — docs publishes /docs/quickstart. Fixed in hanzoai/gui and released as @hanzogui/shell 8.1.54; it reaches this site on the next dependency bump. Every other URL in that registry was checked the same way — 55 of them, and that was the only one that did not answer.
  • lib/constants/products-metadata.ts is the single source for every product's docs link, and 77 of its 88 products pointed at a page docs does not have. Its own contract already said what to do — "null means docs publishes nothing for this product yet. Say that rather than aiming at a near-miss page" — so 14 rows got the real target and 63 got null, which drops the button rather than shipping a dead end. That file also already records the previous instance of this exact class ("the 'Self-host' button 404ed" for eleven web3 pages), which is the tell that the table's declared values had never been VERIFIED against docs, only written.
  • 20 more page-level links had a single unambiguous target and were repointed.

The remap rule is conservative on purpose. A target counts only if it sits in a product namespace — /docs/<slug> or /docs/projects/hanzoai/<slug> — and there is exactly ONE. Never /docs/cli/*, /docs/openapi/* or /docs/mcp-tools/*: those are generated command, API and tool references, so /docs/services/cloud "matches" /docs/cli/cloudflare and a blind remap would replace a 404 with a WRONG page, which is worse because it looks like it worked.

99 links are still dead and they are not this repo's bug to fix. docs publishes 2,854 pages and has no product page for ~60 Hanzo products; 30 of them have only a generated /docs/openapi/<slug> API reference and 66 have nothing at all. Writing those pages is hanzo-docs work. Pointing the remaining CTAs at the docs index would trade a 404 for a generic landing on ~60 product pages, which is a content decision rather than a link fix.

Same audit as the docs links, same instrument — ask GitHub, then ask it again AUTHENTICATED, because the two answers are different facts:

  • 37 repos EXIST and are PRIVATE. Anonymous gets a 404, so the link is dead for every reader, but the remedy is "publish the repo", not "fix the link". 24 of them are on /open-source alone; the rest are on /kms, /world, /amqp and the blockchain pages. This is a decision for whoever owns those repositories and is not a link fix, so nothing here touched them.
  • 5 did not exist at all, and each resolved differently once checked: hanzoai/llm is hanzoai/ai; hanzo/platform is hanzoai/platform (wrong org); hanzo-ai/operative transferred to hanzo-inc/operative and is PRIVATE, so its "Open Source on GitHub" badge now points at the org its own metadata already declares; luxfi/nft is gone and luxfi/market AND luxfi/marketplace both exist, so the row points at the org rather than guess between two; hanzoai/evals has no successor.

THE NEAR MISS IS THE PART TO REMEMBER. hanzoai/eval is public and looks like the fix for hanzoai/evals — it is an expression evaluator forked from casbin/govaluate, nothing to do with an evaluation harness. Linking it would have turned a 404 into a card that opens the WRONG repository, which is worse because it looks like it worked. Same rule as the docs remap: a single unambiguous target, verified, or nothing.

components/landing/OpenSourceSection.tsx went from 7 cards to 5 for that reason — every remaining row is a public repo, because every row is a card that says open source.

What is still dead, after the sweep — and whose it is

Every external host the export links to was checked, 499 URLs across 12 hosts. What remains is not link rot this repo can fix; each line is somebody's deployment or content:

dead owner note
93 docs.hanzo.ai/docs/* hanzo-docs no product page exists; 30 have only a generated API reference, 63 have nothing
6 docs.hanzo.ai/docs/* was ours agents, crawl, functions, kms, llm carried a DOUBLED /docs prefix and the page sat one level up; repointed at the reference each redirect stub names. getting-started is the footer's, fixed in @hanzogui/shell 8.1.54 — committed, versioned, and never published: npm's latest is 8.1.53, so it reaches nobody until that release runs
37 github.com/* the repos' owners they EXIST and are PRIVATE, so publishing them is the fix
computer.hanzo.ai infra /computer's primary CTA, twice — three of that page's four links are dead
insights-app.hanzo.ai infra
cloud.hanzo.ai/ipfs cloud
blog.hanzo.ai/operative blog
standard.lux.network lux
zap-proto.github.io/zap/docs{,/whitepaper} zap
4 pkg.go.dev/github.com/hanzoai/* — amqp, flags, licensing, proto: the module pages cannot exist while the repos are private

Check the un-prefixed path before assigning a dead /docs/<x> to hanzo-docs. Six of the original 99 answered 200 at /<x>, which serves a Moved stub whose canonical names the real page — /docs/openapi/<x>/. A stub is not a target: it is noindex and it bounces on meta-refresh, so the link goes to what the canonical names.

Two hosts answer 403 to a script and are FINE: npmjs.com and openai.com block non-browser agents, and @hanzo/sdk is live — confirmed against registry.npmjs.org, which does not bot-block. Check a 403 against the API before recording it as dead; only 404 is evidence.

content/legal/*.md holds eleven documents — terms, dpa, privacy, acceptable-use, research-contribution, consumer-health, research-api, subprocessors, cookies, biometric-voice, and the Trust Center index. They are the ONE authoritative version of each; every Hanzo surface links to hanzo.ai/legal/<slug> rather than restating the text, and the documents cross-link to each other through that same namespace.

They shipped in the repository for months and the site rendered none of them. /trust — the page a security reviewer reads — offered the Data Processing Addendum at /dpa and the subprocessor register at /subprocessors, described both as published so "a reviewer reads them without asking and without waiting", and both addresses 404'd. Nothing caught it: a static export has no idea what an href means, and every other gate in the suite asks about a page's own bytes rather than about where it points.

  • lib/legal.ts reads them at build time. Front matter is split rather than YAML-parsed — the shape is fixed and authored here, key: value, split on the FIRST colon because a summary contains several. Files beginning _ are never routes: the implementation checklist is written for engineers.
  • app/(marketing)/legal/[slug] publishes each one, app/(marketing)/legal the index. Prose grew an html prop for markup authored as markdown; it goes on the prose container itself, because a document nested one level deeper is a single flex item and every paragraph gap disappears.
  • The status is rendered, never hidden. All eleven are DRAFT pending counsel, and the page says so above the text, from the document's own front matter. A reviewer relying on unexecuted terms because a page implied otherwise is the failure /trust exists to prevent.
  • e2e/gates/links.spec.ts is the gate that would have caught it: no page in the export links to an address the export does not serve. Mutation-checked in both directions — it names the dangling URL AND the page carrying it.

TWO COPIES OF THREE DOCUMENTS EXIST, and this did not create them. /privacy, /terms and /cookies are hand-written TSX that predate the markdown stack, and they are DIFFERENT DOCUMENTS — not stale renderings of the same text. The TSX privacy policy is thirteen generic sections ("1. Introduction", "2. Information We Collect", a section on SMS); the markdown one is nineteen, covering Enso routing, the four data-use states, research and training, Verified Commons, and US-state and European disclosures. Which is effective is a decision for counsel and not for a layout pass, so both stand and both say what they are. Resolving this is the next legal-stack step; do not silently delete either.

Static Export

next.config.ts uses output: 'export'. GitHub Pages has never been the target and is not a deploy surface on this estate — the export goes to the Sites plane.

output: export without trailingSlash writes a route as the FLAT file pricing.html, not pricing/index.html. That is why apps/sites in hanzoai/cloud has to try <rel>.html as a resolution candidate, and why a Next export served only its homepage before it did. Do not "fix" that by setting trailingSlash: true here: it rewrites every canonical URL to work around a server that now handles both conventions.

There is no SPA fallback and there must not be one. Every dynamic segment ships generateStaticParams, so the export emits real HTML per route. Do NOT reintroduce cp out/index.html out/404.html — that clobbers the export's real 404 with the homepage, so every broken inbound link answers 404 while rendering "What can I help with?", which is indistinguishable from a good visit.

One address per page; the edge answers the rest

One page per subject, too: the SDKs and the OpenAI and Anthropic shapes the gateway answers are /sdks; the work Hanzo shipped is /customers; where to start is /learn; /playground is the Playground product (the console's). /docs/sdk, /customers/proof, the profile views and the orphan rooms (/dashboard, /vibe, /beluga) are MOVED. A shelf with an empty keep writes no exception into its route.

A page has one address: https://hanzo.ai/<path>, no www, no trailing slash, no .html, and one self-canonical. Everything else is a 301 from the edge in one hop, query kept. The export has no server, so the rules live in hanzoai/universe charts/app/values/hanzo/static-sites.yaml, between the # BEGIN hanzo.ai edge markers, and lib/edge.ts is their source.

  • Retired addresses: MOVED (hand) and SHELVES (whole prefixes, e.g. /docs/* to docs.hanzo.ai) in lib/publish.ts, plus retired model pages in lib/data/retired.json. lib/moved.ts joins them against the current catalog (forward()). No page ships at a retired address, except a Go import path (/ai, /docdb, /status): ?go-get=1 is never redirected.
  • Hanzo Team is hanzo.team: /team and /team/details forward to https://hanzo.team, /team/<id> to https://hanzo.team/agents/ (its lib/agents.ts holds the teammates' words now), /team/chat to /chat. Links to the product name WORKSPACE (lib/host.ts).
  • After changing any of those: node scripts/edge.mjs <universe>/charts/app/values/hanzo/static-sites.yaml, commit universe, then sync only the resources it prints, pinned to that sha (hanzo-static-sites is manual). --check says whether universe is current.
  • Twins: .md (llms) and .txt (RSC) answer with X-Robots-Tag: noindex; they were ~900 unlinked duplicates of the pages.
  • Private (robots Disallow): /auth, /account, /login, /signup, /onboarding, /pay, /v1, /forward. ALTERNATE (/privacy, /terms, /cookies) stay up with canonicals to /legal/*, and /bot/details (hanzo.bot's root, the same page) with one to /bot; none is in the sitemap. MAIL (/email, the bodies of the emails the site sends) is noindex; HOSTED (/app) forwards to its host by its own universe route, which lib/edge.ts models.
  • Gates: e2e/gates/edge.spec.ts runs the rules over the export; sitemap.spec.ts holds every sitemap URL to 200, one self-canonical, no noindex, no redirect, a lastmod (the files' last commit, so deploy checks out the whole history), and fails any link to an address that redirects.
  • Production: node scripts/crawl.mjs [--json out.json] sitemap <gsc.zip|csv|txt> (≤5 req/s) judges each URL against the same rules. A Search Console export (Page indexing, a reason, Export, CSV) goes in as the zip or its Table.csv; its gone rows are what to extend MOVED from.

The apex is the landing for a stranger and the app for a member

/ is the marketing landing for a signed-out reader and the web app for a signed-in one. Same address, no trip: there is no server, so nothing about that is a redirect — middleware, redirect(), headers() and cookies() are all inert under output: 'export'. The export holds the landing, byte for byte, for crawlers and strangers; the app is drawn in the browser.

The app is @hanzo/app' App, two modes on a rail (app/_web.tsx is the gate in front of it; the desktop app's main window draws the same one):

rail            sidebar                      pane
[◧] toggle      Hanzo AI            ⌕        (agents)        ⇪  ⋯  ☰  ✎
[💬] Chat       + New chat / New run, Dev's places (@hanzo/build `nav()`)
[</>] Dev       Pinned · Recents             the conversation, or the builder
[⋯] More        (bubble) a conversation  /v1/agents/chat/conversations
[me] account    (dot) a run              /v1/agent/sessions, live off the org feed
  • The rail is the window's left edge, on the floor: the sidebar's toggle at its top-left, then Chat, Dev, and More (Tabs, Team, on hosts of their own), and the account's face at the foot. There is no Chat / Dev switch.

  • The sidebar shuts from the toggle or by pulling its edge past its floor, kept per browser (hanzo.build.rail); the rail stays. Its search mark opens the filter over Recents.

  • The conversation's header ends in icons alone: Share, the conversation's menu (the row's, ⋯), the side panel (the outline's mark) and New chat — never a second sidebar toggle.

  • Chat's pane is components/workspace/Chat.tsx, the same conversation /chat draws, under the app's own AiProvider; Paned (Shell.tsx) gives it the column beside it, so the summary toggle works here as in the rooms.

  • Dev's pane is <Builder host rail={false}>, cut as a pane of its own and never in Paned: the column beside a pane is Chat's (its pinned summary, useOpen().aside, which outlives a move to Dev), and in Dev nothing fills it, so drawn there it was an empty third pane right of the builder. The builder draws no rail and the column lists its places and runs (@hanzo/build nav()).

  • A project is a repository (@hanzo/build 0.2.52+). Dev's Projects is the org's repositories on the forge (GET /v1/git/repos: name, org, default branch, last updated, a search), with Link a GitHub repo (Sync: the person's GitHub connection, POST /v1/provider/github/user/connect, and POST /v1/provider/github/repos/import) and Create from template. One opens its workspace at /dev/<org>/<repo>: chat, preview, Files and Code on that repository, its site (the org's /v1/projects row built from it or of the same name) when it has one, and Publish otherwise makes it one. The boards that Projects listed before (/v1/task/projects) are Issues' project column only. A run names a project: New with none chosen opens the picker and sends nothing, so no project is made up from an ask's words (the platform's apps/coding/establish.go path is no longer reached from here).

  • Chat is the default, and the address says the mode, as a path (@hanzo/app lib/host talk, app, place): / and /chat a new conversation, /chat/<id> one of the reader's; /dev New, /dev/run/<id> a run, /dev/projects/<org>/<repo> a repository, /dev/projects/<slug> a site, /dev/<place> every other place; /studio Studio's New and /studio/<place> its places (c/<id>, library, explore, activity). The edge serves chat.html for every conversation, dev.html for every path under /dev and studio.html for every path under /studio (lib/edge.ts APP; the gates' export server does the same). /studio runs its own policy, Studio's engine, faces and previews beside the site's (STUDIO_EDGE). A move is a native history entry, so reload and Back work for every kind; a conversation just begun writes its id back with replaceState, and an emptied one (New chat anywhere) moves to /chat. The rail returns to where each mode was last. An address from before paths moves once, with no history entry of its own: /?chat= and /?at= by MOVED in app/page.tsx, and /chat?thread=, /dev?at=|run=|project=|view= and the builder's old grammar (/dev/sess_<id>, /dev/<org>/<repo>, /dev/-/<place>, /dev/<slug>) by the doors (app/(app)/chat/_door.tsx, app/dev/_door.tsx moved).

  • Recents stays current without a reload: the org feed moves a run's dot, and a conversation or run the pane opened that the list has not seen makes its store read again, once.

  • An empty answer is a failure, said: the gateway can close a streamed completion with 200 and no frames (every provider refused), and the SDK then records an empty turn with no error. Chat shows "No answer came back from " with Try again rather than silence.

  • Below md there is no rail and no sidebar: the bar carries the menu's toggle and Chat/Dev. The menu is @hanzo/app' (Drawer.tsx, 0.1.98): it stands behind on the floor (--pane-floor), the pane slides toward the end over it, raised, and a strip of the pane stays in sight as the press that closes it. The menu lists the column, then Studio, Tabs and More as rows, and its foot is the account and the accent Chat. No rule in app/layout.tsx draws any of it; the one that cut the old drawer like a floating card is gone.

  • app/page.tsx runs BOOT (lib/auth/session.ts) as the first child of [data-landing], ahead of the landing's markup: a locally-live access token (the SDK's fast path, never the refresh token) marks <html data-app> and sets the wrapper's own display: none before the rest is parsed — so the pitch never paints at a member, and no stylesheet rule is involved; Apex gives a landing it still draws back its display: contents.

  • app/_apex.tsx Apex renders the landing until hydration (so nothing mismatches), then: IAM loading → live(); IAM settled → isAuthenticated. Signed in, it renders the app (app/_web.tsx, a data-room box) through next/dynamic, so a stranger downloads none of it — about 200 KB of gzipped script, which put the landing's first load at 616 KB against e2e/gates/weight.spec.ts's 440. The mark follows the answer in a layout effect, both ways.

  • The Host is useDevHost() in components/workspace/dev.tsx, mirroring hanzo.build's src/app/root.tsx: memberships from the token's orgs claim, chooseOrg = pick() + reload to /, theme/chooseTheme from useLook(), signOut = IAM logout(), person from IAM with the token's claims as the answer before userinfo, admin via administers(). Every link stays on this site; open() refuses platform.hanzo.ai.

  • A sign-in with nowhere to return lands on / (DEFAULT_DESTINATION), and a signed-in /login goes there too; hanzo.team keeps /home.

  • First run is /onboarding (components/onboarding/flow.tsx, the bare shell). @hanzo/ui/onboarding draws each page: the terms for a new account ("Let's create your account", versions from lib/terms.ts, recorded by IAM at PUT /v1/iam/terms or with the code sign-up), Do more with Hanzo Pro, Plans that grow with you, pay at /pay (returns here) or free, Before your first chat, name, role. Progress is on the IAM user (/v1/iam/preferences). The callback and the rooms' gate (Orgs) send anyone with a step owed there (firstRun), with ?next= to go on to. A new account gets its personal organization at that moment (prepare), which is the one signup_completed.

  • The app is signed-in only. / at a place in the app (?chat), or any path under /dev (or bare /dev with an old query), with nobody signed in sends the reader to sign in and back (lib/auth/destination.ts enter), and so do every room and every Sign in control the app draws. BOOT (and on /dev AWAY, app/dev/_away.ts) marks such an address so the landing never paints on the way out. Bare /dev signed out is Dev's page, as bare / is the landing.

  • Nobody is sent to hanzo.id (lib/auth/client.ts, lib/auth/session.ts). IAM keeps its session cookie (__Host-hanzo_session, host-only) on hanzo.ai: universe iam-on-hanzo-ai answers /v1/iam/login, /signup, /verification-codes, /oauth/revoke and /oauth/logout here and passes the cookie through. So /login and /signup ask IAM once per tab, before drawing a form, with a POST to this origin carrying no credential (@hanzo/iam resume): IAM's single sign-on branch answers a code from the session (signed in, nothing typed, method: session) or login_required (the form). No redirect, no frame. The form is drawn anyway after 3 s. Only a person's browser asks: navigator.webdriver or a crawler's agent is drawn the form. / never asks. Sign-out is useSignOut: @hanzo/iam end (revoke both tokens and end-session, all on this origin, no navigation), then logout_completed and /login?from=logout, which does not ask. A session a person holds only at hanzo.id is not seen here; a provider sign-in still passes hanzo.id as redirects on the way to Google or GitHub, because that is the callback those providers have registered. Gate: e2e/gates/silent.spec.ts.

  • IAM's hosts (app/providers.tsx origin()): serverUrl and proxyBaseUrl are api.hanzo.ai, so discovery (/.well-known/openid-configuration at its root), the methods, the token, the refresh and userinfo are all asked there. Discovery there is hanzo.id's document (issuer and every endpoint on hanzo.id); @hanzo/iam checks neither issuer nor iss, so it only places the authorize page, which stays on hanzo.id. The calls still made to hanzo.id are @hanzo/app': the account row and appearance (/v1/iam/account, /v1/iam/preferences) go to where().iam, which is also the host of IAM's /account and /join pages. Every gate answers IAM through e2e/gates/issuer.ts, whose contract holds this.

e2e/gates/app.spec.ts measures it on the shipped bytes: the landing signed out; the app signed in, opening in Chat with the rail (toggle, Chat, Dev, More, the account) and no switch; Dev at /dev; the rail as a real control, by press and by key, with Back; a first Chat message with no codebase, streamed back, recorded and listed; a Dev run refused until a project is chosen, then sent with its repo; Recents narrowed by mode and by words, each row opening in its own mode; every address kind as a path through reload and Back, and each old query moved to its path once; Projects as the org's repositories, one opened at /dev/<org>/<repo> through reload and Back; no column beside Dev's pane with Chat's summary pinned; the drawer on a phone; and nothing cut off at 1920/1366/820/390. e2e/gates/apex.spec.ts asserts no frame shows the landing's header to a member.

Telemetry

All telemetry is one client, @hanzo/event, wired once in app/providers.tsx:

<AnalyticsProvider config={{ product: 'site', host: EVENT_HOST,
                             ingestKey, enabled }}>
  -> POST https://api.hanzo.ai/v1/event   body { batch: [Event...] }

Cloud resolves every dashboard as a lens over that one stream, so there is no second client. The only other tags are the ad channels', loaded by @hanzo/event's tag manager from the host's tag set in cloud (GET /v1/project/tags: GA4 and the Meta Pixel, each host its own stream) as the visitor's choice and region allow. The lenses:

Lens Host Reads
Errors sentry.hanzo.ai GET /v1/errors — the type:'error' events in hanzo.events
Web analytics analytics.hanzo.ai pageviews, referrers, sessions
Product insights insights.hanzo.ai funnels, retention, feature usage

analytics.hanzo.ai/v1/event is a different protocol — the Umami tracker endpoint, whose body is a bare array of hz.js envelopes. Pointing the SDK there answers 400 "expected array, received object". The address is api.hanzo.ai.

There is no Sentry DSN. @hanzo/event accepts one, and it authenticates a separate send to /v1/sentry/<projectId>. sentry.hanzo.ai does not read that store — it reads GET /v1/errors, which the event stream already writes (universe infra/k8s/ingress/routes.yaml). One credential covers every lens.

The ingest key

Anonymous traffic REQUIRES the key. The reserved $public tenant that once caught credential-less beacons is retired (cloud apps/analytics/public.go): an event lands in the org a credential names, or is refused. A keyless bundle has every logged-out pageview and error answered 401 ingest_key_required — a console error on every visit, which is how the defect surfaced live — so both lanes refuse to ship without the key: deploy.yml reads the built out/_next/static for a pk- key and then asks the endpoint whether it still resolves, and the Dockerfile (hanzo.team) refuses the empty ARG and greps the export after its own build.

A project pk- key is admitted at FULL capability (cloud apps/event/event.go keyAdmission): it names the org and the project, and identify, custom events and their properties all land. Only a request carrying no credential at all takes the projected anonymous lane. The prefix is pk- (cloud.PublishablePrefix); no other prefix is read as a key. A presented-but-unresolvable key fails closed with 403.

Mint with POST /v1/keys {"type":"publishable"}, store as deploy/PUBLISHABLE_KEY in KMS. /v1/keys is the one key surface: one noun, the method carries the operation, and the class is a field.

ONE name and ONE location, shared with hanzo.chat and hanzo.app, matching the default path of the shared hanzoai/ci reusable's build_secrets. The key is per-ORG, not per-property — one pk- resolves the org for every property, and the SDK's product field (site here) is what separates them in the warehouse. The env var carries only the prefix its bundler requires: NEXT_PUBLIC_ for Next here, VITE_ for Vite in hanzo.chat, over the same PUBLISHABLE_KEY suffix. .hanzo/workflows/deploy.yml reads it before the build, because NEXT_PUBLIC_* is inlined into the bundle at build time.

Capture

  • AnalyticsProvider fires the first pageview and captures errors; <Pageview/> counts route changes; ErrorBoundary catches React render errors, which never reach window.onerror.
  • <ObserveProvider> rides the same client through context for interaction capture ($click/$input/$change/$submit/$view), input values redacted. nav={false} leaves history alone, since <Pageview/> already counts routes.
  • Every event is filed in the hanzo org under the hanzo-ai project key, and no bearer is passed (the SDK sends token ?? key, and a bearer files it in the visitor's own org). Measured before: 2,177 signed-in hanzo.ai events sat in 55 other orgs, so the hanzo org held only the pre-login half of every funnel. cloud admits a project key at full capability, so identify lands and joins the anonymous id to the person in one org.
  • The client never sends the org; cloud stamps the tenant from the key. A tenant a client can name is a tenant a client can get wrong.
  • Consent is @hanzo/event's (consent.ts) and cloud's rule (apps/event/consent_policy.json), drawn by @hanzo/ui's one component (Consent from @hanzo/ui/consent, mounted in app/providers.tsx; no local copy). Its bar is Manage preferences, Accept all and a × that stores essential-only; its sheet is Cookie Preferences. Any link to #choices (the footer's "Privacy choices") opens the sheet: the handler is the component's own. cloud resolves the visitor's region from the edge and serves the rule with the tag config (GET /v1/project/tags → consent {region, mode, defaults, gpc, version}): opt-in regions (EU/EEA, UK, CH, Quebec, Brazil, China, unknown) start with nothing on and a banner; everywhere else everything is on with a notice and "Do not sell or share my personal information". GPC turns marketing and ads off in every region. The choice is cookie hz_consent (+ hz_consent_v, the policy version: a new version asks again). Our own stream is first party (@hanzo/event >= 0.3.61): it counts every visitor from the first page, whatever they chose and under GPC or DNT, so the provider takes no enabled from consent. Consent gates the third parties alone: Analytics is Google Analytics, Marketing the Meta pixel and every server send to an ad platform, Ads their personalization (startTags). Each event states the grants it went under and cloud forwards it under those. track is handed a Stream (app/providers.tsx) that sends on whichever client the provider holds when a held moment goes out. A runner's clock is UTC, so e2e/gates/conversion.spec.ts holds all of this.
  • One track(name, params) (lib/analytics/tags.ts, over @hanzo/event's) for every funnel moment, under one event_id: our stream always, every browser tag the choice and region allow, and each platform's name from the one table in @hanzo/events (cloud reads the same). No GA4 or Pixel id lives in this repo: startTags (called once in app/providers.tsx) fetches the host's tag set from cloud. tagsReady is the "tags have taken the page view" signal /forward waits on.
  • Which door a signed-out reader gets (lib/auth/destination door): enter() with no page sends a browser that has held a session here (localStorage hz_known, set by Identity in app/providers.tsx, kept through sign-out) to /login and a stranger to /signup, who has no password to type. A control labelled "Sign in" names LOGIN. The page it left rides as ?next= too (a room, a prompt in ?q=), and /login and /signup go on to any next on this site (onward, validated by safe). Gates: silent.spec.
  • A callback IAM refused (?error= in the address, or a failed exchange) logs IAM's code to the stream (captureError, code/description) and the console, and says it in words as the page's title ("We couldn't finish signing you in with Google."), with Try again to /login and the code small beneath. While it works it is the focused page's Wait, "Signing you in".
  • Plan usage is shares, and it lives on the usage settings only. GET /v1/ai/limits (read through @hanzo/app/lib/limits = @hanzo/ui's useLimits) draws PlanUsage on /settings/organization/usage and on the rooms' Settings → Usage (account menu → Usage, and "See usage"): plan, period, a bar per class of model, the terms in plain words, the continue-with-credits switch (PUT /v1/ai/limits, org admins) and the server's actions. /chat draws no bar and no near note: it shows one LimitedBanner over the composer only once the reader is turned away — a reply answered from a free model (X-Hanzo-Fallback) or a billing refusal — with the server's actions in its order (Continue with credits turns the choice on and asks again; a switch moves the picker) and See usage. No usage figure, count or cap renders anywhere (e2e/gates/limits.spec.ts).
  • The sign-in funnel, every step through track(): the panel drawn (signup_viewed {mode: login|signup}, Gate), the account asked for on /signup (signup_submitted {method}, Panel.commit), the login at the callback (login_completed {method}), the sign-up at the first organization (signup_completed {method}), and the first question a browser asks (first_action {action: chat_message}, Chat.speak, once per browser in localStorage hz_first.<action>). method is the way in, written to sessionStorage hz_signin_method when the person commits: email, password, code, google, github, hanzo.id (IAM's page), session (a silent ask answered).
  • A moment cloud states itself (a sales lead: POST /v1/marketing/leads) carries visit() in its body and is mirrored to the ad tags only, under the server's id (mirror), never sent to our stream a second time. Unfiled, it falls back to track.
  • The contract with cloud is the stream event's properties: event_id, consent (granted list), tags (ga,meta — which browser tags this call reached), the click (gclid|gbraid|wbraid|fbclid, clicked), fbc, fbp, cid (GA4 client id), userAgent, and value/currency/items (GA4 keys).
  • The click (lib/analytics/touch.ts): with Marketing, a landing's click ids replace the last set in cookie hz_touch for 90 days (cloud's lookback, the last click within the window), _fbc/_fbp are written when the Pixel has not; with Analytics, cid is _ga's or a first-party hz_cid.
  • identify(sub, {email, name}) — the subject joins the person across properties, and the traits are what make that subject legible. This reverses an earlier "never an email, PII-free by construction" rule, which was the wrong goal for first-party data about our own users: it wrote a warehouse of opaque subjects where every funnel could count people and none could say which person. Both traits come from the same hanzo.id userinfo response the subject is read from, so nothing new is collected. identify runs only once a session has resolved a user, and consent gates everything through useChoice(). Secret redaction in the SDK is unconditional.

Brand marks: real logos only, and one place they come from

components/models/ProviderMark.tsx is the ONE mark table. Bodies are copied UNMODIFIED from hanzoai/icons packages/static-svg/icons/<slug>.svg (our MIT lobe-icons fork) — mono currentColor on a 24-unit viewBox. The fork is not on npm and its barrel drags antd + @lobehub/ui in for a picture, so the subset is vendored, the same call hanzo.app and hanzo-docs made. Never draw a logo. A monogram is the documented last resort and only for a lab genuinely absent from the fork (14 of 59 today — community fine-tuners and brand-new labs); everything else has a real mark, so reach for the fork before inventing.

Three rules the page cost us before they were written down:

  • The model's family beats the lab that trained it. Our gateway namespace puts gpt-5, claude-opus-4.8, zen5 and enso all under hanzo, so reading the lab first drew the Hanzo H on every one of them. markOf takes the leading run of letters in the id first and falls back to the lab.
  • A family wears the mark of the lab that MAKES it. Enso is ours, the ensō CLOSED because the router completes the circle. Zen is Zoo Labs Foundation's, which the hero copy already says, so it wears Zoo's venn like every other maker here — zen -> zoo in OF. @hanzo/logo carries Hanzo's marks and nobody else's; it once shipped a ZEN_MARK beside ENSO_MARK, which drew our house glyph on someone else's models. The venn's disc is cut by an SVG <clipPath> in USER units: a CSS clip-path: circle(11.5px …) measures the rendered box, so it cuts at 96px and not at all at 18px. e2e/gates/marks.spec.ts holds all of it.
  • No chip. Marks render bare at the current text colour. The predecessor painted a CSS mask inside a rounded-lg box, which clipped the corners off every square mark — the H worst of all. Every source declares fill-rule="evenodd" on its root, so the wrapper repeats it: without it Mistral's inner square fills in and the mark is quietly wrong.
  • A row by its maker (components/models/Maker): a price-list id wears its lab's mark through ProviderMark, in the page's ink (ink); ours (Enso, its tiers, Kai) wear Hanzo's H on the landing's price list and boards and /enso's chart (owner, 2026-10-16), where /models still draws Enso's ring. It loads ProviderMark with lazy: the table is ~34 KB brotli, and statically imported it put /enso at 423 KB against its 400 (perf.spec); lazily, /enso is 379 KB.

The favicon is DERIVED, not drawn. scripts/sync-mark.mjs copies @hanzo/brand/assets/logo/favicon.svg into public/ in prebuild and refuses a mark that arrives stroked or with the wrong number of shapes. The hand-maintained copy it replaced painted the five shapes twice — a stroked .rim behind a .core — and then set both layers to one colour in each scheme a browser actually reports, so a 5.58px stroke bridged the gaps and served a solid blob everywhere except the no-preference default. One layer, one ink that flips with the scheme, is the only construction that cannot do that. console.hanzo.ai resolves an org's logo to this same URL.

public/logos/color/*.svg is a SECOND representation of some of these, for a reader that needs a URL (lib/constants/partner-logos.ts's <img>). Same canonical source, regenerate from it, never redraw. The mono set and the colour files only the accuracy-at-cost scatter read went with it.

The colour files carry each lab's brand hex in place of currentColor, set at generation time: an SVG behind <img> or <image> is its own document with no CSS context, so the host page cannot tint it. Regenerate them from the fork; never hand-edit. /logos/partners/ is a different set and unrelated.

What we say about frameworks

Two sentences get confused, and only one is a lie. "SOC 2 certified" claims a certificate from an auditor. "Compatible with SOC 2" claims our controls are built to that framework. The second is true and is what we say.

  • Say: "compatible with SOC 2", "built to", "continual internal audits", "audit report available upon request", "BAA available".
  • Never: "certified", "certification", "accredited", "attestation", "audited by independent/third-party auditors".
  • Never hedge: "audit in progress", "assessment planned", "SOC 2 ready", "readiness", "scoped per engagement", "working toward". A hedge reads as a claim to a buyer skimming, and it dates itself the day it ships.
  • Never state the negative either. Saying what we lack is what put "Hanzo does not hold SOC 2, ISO 27001, or FedRAMP" into /trust's meta description — the line a search engine prints for anyone searching whether we are certified.

ci/bin/certclaims enforces exactly this and runs in the build. It reads one line at a time, so keep a framework name and a document offer on separate lines or in separate entries.

  • No specific datacenter locations; use "Global High-Performance Edge"

/referral offers no code, because no endpoint redeems one

ReferralHeader already carried this lesson for the signup credit — "A page cannot offer money the biller will not pay" — and the coupon half outlived it. TryFreeCoupon displayed TRYFREE with a copy button, a Redeem button, and two cards promising a $5/mo Bot trial and $5 of compute credits. It POSTed to /v1/coupon/redeem, which api.hanzo.ai does not serve: cloud mounts commerce's api/resources leaf and deliberately not api/coupon, so the whole validate/redeem surface exists only in commerce's standalone route table. The promise was unkeepable at the endpoint, not merely unwired.

Redeemable codes are not a thing we sell. cloud's apps/marketing/promos.go compiles campaignsLive = false, purges its one campaign row on every boot, and states that credit enters an org only by a manual admin grant. Hanzo's real discount is the codeless platform promo (percentOff on a plan, applied by subscribe_card.go with nothing typed). Do not reintroduce a code field here.

The referral half is a separate, still-open defect. lib/hanzo/referrals.ts calls /v1/user/{id}/referrers, /v1/referrer and /v1/referrer/invite; the fleet serves /v1/referrals and /v1/referrals/claim. Different noun, so those reads answer 404 today and the page's stats render empty. Fixing it is a contract decision about the referral surface, not a rename.

/beta: choose a track, answer two questions, pay, then the rest (owner, 2026-10-22). Two required questions, and the second opens pay's card step for Pro (/pay/confirm/card?plan=dev&coupon=50OFFBETA, lib/checkout checkout): no plan picker, no sign-in first (pay signs the buyer in on that page), and nothing between the answers and the payment. What the code does is commerce's quote (lib/coupon: couponPercent, couponFirstCents and couponMonths, the monthly cycles it discounts), so the landing, the line under the last question, /pricing and the sign-in strip say the months commerce bills and never a number typed here. The lead is filed under the account's address: before checkout when signed in, on the paid return (?checkout=success) otherwise, from the answers this browser kept. Paid, the page offers the optional questions (onto the beta record, PUT /v1/marketing/beta), then the referral card: "For every friend you refer who subscribes to a paid plan, you get 1 week free." Nothing in the fleet grants that week yet: each one is granted by hand (an admin grant) until commerce carries the rule.

The questionnaire is three bars, and a third of it is the floor (owner, 2026-10-25). app/beta/page.tsx draws STEPS = 3 bars that fill by the questions passed, never a counter (no "3 of 10"). need = ceil(questions / 3): fewer answered and neither "Submit application" nor "Continue with plan" is open, a skip that would leave fewer than need reachable is disabled, and the plan shortcut (#beta-plan-shortcut) appears once need are answered. The portal is ONE component, components/beta/portal.tsx: app/beta/page.tsx draws it in the site's frame for a stranger, and _door.tsx hands a member the app in its Betas mode with the same portal as its pane (<App betas={<Portal />} />, @hanzo/app ≥ 0.2.27, which draws no sidebar there). A member without a record gets the form; right after filing, the plan step (#beta-plan-pro / #beta-plan-free, once, kept as chose); then the dashboard. Every share goes out through lib/hanzo/referrals tagged(link, channel): ref plus utm_source. The mailed program code is 50% off the first three months (cloud apps/marketing/offer.go, offerMonths).

A referral pays nothing, and the openapi read settles it

That check has now been made, so make it once. GET /v1/referrals answers {code, link, counts{total,signup,qualified}, referrals[]}, and a row is {id, referee, status, createdAt, qualifiedAt} — no money field on any shape. The served descriptions say why in as many words: claim "grants nothing, and neither does anything downstream of it… No credit is ever issued from this package", the admin directory "carries no amounts because this package issues none", and the sweep that advances signup → qualified on the referee's first metered spend "moves NO money".

So both amounts the site used to quote were invented. /pricing said "$20 in AI credits for every developer you refer" and /referral said "$5 in credits" for the same act. The $20 is a stale echo of referrerCreditCents: 2000 in commerce's config/referral-program.json, whose claim handler mints nothing and whose route api.hanzo.ai never mounts; the $5 traces to nothing at all. Credit still enters an org exactly one way — a manual admin grant.

What a qualified referral is worth is an affiliate commission: rateBps of Hanzo's MARGIN (default 2000 = 20% of margin, never of the customer's bill), levels 2 and 3 platform-wide at 5% and 2%, accrued as a payable and settled by wire or wallet. That is why "up to 5% at Partner tier" was wrong twice: 5% is the level-2 rate, and the served system has no tiers, only applied/approved/suspended. Only an approved affiliate accrues, so the earning callout links /affiliate.

The rate has one owner and this repo is not it. When wiring the page to the real endpoint, render counts and never reintroduce an amount — a referral is not given one to render.

/pricing is the cards pay sells from, read live from the plan authority

The top of /pricing is components/pricing/Plans.tsx: the plan-card design built for pay.hanzo.ai/cart (hanzo-inc/pay #5, after claude.ai's plan screen). Audience tabs come from the catalog, by pay's rule: contactSales is Enterprise, perSeat is Team, everything else Individual; only the personal/team/enterprise families are drawn (agency retainers sell from hanzo.agency). One card per plan. The "Save N% with annual" switch shows only where a row states annualTotal, and N is saving().

Every figure is read when the page opens, and none is baked. The export carries the loading line where a price goes; lib/api reads the backend in the browser (see "Plans, prices and the model table are read when a page opens").

  • Plans: usePlans() reads GET /v1/billing/plans (commerce's plan authority). Plans.tsx, PricingFAQ, /invite, the picker and the settings' plan names all read it; charge and saving live beside the cents→dollars conversion in lib/api/read.ts.
  • The rate card, every model's rate and the tool rates: usePricing() reads GET /v1/pricing once per page — tariff (the card cloud resolves per request from commerce's rate rows, identical to /v1/pricing/tariff), hanzoModels, thirdPartyModels, tools, summary. components/pricing/rates.ts is only the writer (micro-USD → money); the tool grid prices web calls from the card.
  • A SuperAdmin edits all three at admin.hanzo.ai (console: Subscription Plans, Rates, Catalog & Pricing → /v1/commerce/{plans,rates,catalog}/entries).
  • @hanzo/plans and @hanzo/app/lib/plans carry a bundled plan catalog with prices; no site module imports a value from either, so it stays off every page the site draws (the rooms' own screens still pull it — a rooms follow-up).

A year is sold. checkoutUrl() opens hanzo.ai/pay/cart?plan=…, and term() adds interval=year; the cart charges the row's annualTotal. pay's root forwards a named plan and its term to the cart, so hanzo.ai/pay?plan=… &interval=year works too. The FAQ's annual and plan answers are built from the catalog.

The checkout is hanzo.ai/pay. hanzo-inc/pay is mounted there from its own export (universe hanzo-ai-pay); pay.hanzo.ai 302s to it. Same origin, so a reader signed in here is signed in there, and a signed-out buyer is sent to /signup with signin.destination set to the checkout page they were on. Every checkout link reads BILLING_URL in lib/plans.ts; the funnel's checkout_started fires on a click into hanzo.ai/pay (INTENT, providers.tsx).

The Max checkout on /onboarding is components/onboarding/Checkout.tsx (Square Web Payments SDK through @hanzo/pay/web). Its RULES string is the page's own CSS, scoped to html[data-checkout]: the sticky bar scrolls away below 1024px (it is glass, and the form is long), overflow-x: clip, Square's card wrapper gets a block host (it is display:flex; margin:0 auto and a flex column shrinks and indents it), and the cookie banner is compact on a phone with --consent (its measured height) padding the page. Apple Pay and Google Pay are built from the same payments as the card (paymentRequest, applePay, googlePay; a rejection means "not on this device"), shown above the billing form, and end in the same /v1/billing/subscribe/card with the wallet's token as sourceId: one subscription path (verify then subscribe). A wallet sheet is opened first, from the press (Apple refuses one that is not), and the catalog price is checked before the token is spent. public/.well-known/apple-developer-merchantid-domain-association is Apple's domain proof for hanzo.ai (hex; scripts/wellknown.mjs refuses anything else); the domain is registered in Square's Apple Pay settings, which needs the production access token (KMS hanzo/prod:/commerce-secrets).

e2e/gates/pricing.spec.ts serves invented answers (e2e/gates/api.ts: a Pro at $23, an Enso at $7 in and $31 out) and holds the cards by tab and term to them, serves an edited plan and rate to prove the page follows the authority without a build, and blocks the backend to prove it says so rather than drawing a price.

DatastorePricing keeps a Monthly/Annual toggle and is NOT this. It is a rate card for a managed service off /v1/pricing/services, with its own discounts.annual.percent, and it discounts a displayed rate rather than selecting a subscription term. Different catalog, different endpoint.

-translate-* on a <Box> needs @hanzo/ui >= 8.0.108

tw decides what a negative class may set from the PROPERTY NAME (NEGATABLE = /^(margin|top|right|bottom|left|zIndex|translate)/), then negates the value. That handled a number and a bare percentage and not a transform FUNCTION, so -translate-x-1/2 reached the DOM as translateX(+half) — the centring idiom pushed a full width the wrong way. 8.0.108 adds TRANSFORM_FN and negates the argument inside the function.

It is the substrate's silent-drop family again, and its signature is that the element is placed exactly wrong rather than unplaced: read the computed transform off the positioned wrapper, where a correct one is NEGATIVE.

47 <Box> call sites across 44 files carry the idiom, against 57 on motion.div and 7 on a plain div — and only the first 47 ever reached the adapter, which is why the three look identical in source and one of them moved. Count them by walking BACK from the class to its opening tag: a grep for the class and the tag on ONE line reports 23, because most of this site writes the tag and the className on separate lines.

Plans, prices and the model table are read when a page opens

Owner, 2026-10-15: "do not embed JSON on every page; it can dynamically and should load from backend all plans pricing and other shit, not embed outdated shit." The root layout handed every page the plan catalog (lib/data/plans.json through lib/app.ts → configure({ plans })), and /pricing, /calculator, /customers and 33 benchmark pages carried the price list (lib/data/pricing.json, months stale twice). Both files, lib/data/models.json, templates.json, blueprints.json and the scripts that rewrote them (sync-pricing, sync-models, sync-templates, sync-blueprints) are gone.

lib/api is the one reader.

  • lib/api/read.ts: typed reads of api.hanzo.ai — plans() (/v1/billing/plans, cents → dollars), pricing() (/v1/pricing), summary() (/v1/pricing/summary), served() (/v1/models), templates(), blueprints() (/v1/blueprint/sbom). One promise per path per page load; a body that is not the document asked for is a failure (Unreachable), never an empty page. No snapshot exists to fall back to.
  • lib/api/use.ts: usePlans, usePricing, useSummary, useServed, useTemplates, useBlueprints → { state: 'loading' | 'ready' | 'unreachable' }. Every section renders loading first, so the export carries no number.
  • components/api/state.tsx: Loading, Unreachable ("api.hanzo.ai is unreachable … Nothing older is shown instead"), Ready, Inline (… / — inside a sentence); data-api names the state for gates.
  • components/api/count.tsx (Models, Tally: the price list's own counts), components/api/rate.tsx (Price, Pair, Call: a model's rate in a tier card or a sentence), components/models/Cost.tsx (a model page's rate), components/solutions/Gpus.tsx, lib/tally kaiPrice (Kai beside Jev, on the home, /kai and /benchmarks).

What the build still reads, and why. A static export must know which pages exist before anyone asks: scripts/sync-models.mjs (prebuild) saves what the pages need of /v1/models and /v1/pricing as lib/data/served.json, with no rate and no time in it, and every worker of the build reads that one file. lib/models/served.ts turns it into the model pages, the lab pages, the sitemap and their titles and descriptions. A catalogue that changed only its prices writes the same file, so the 600-odd model pages render byte-identical and the publish uploads none of them. When the API cannot answer, or lists under half the snapshot's models, the committed file stands and the deploy goes on (before this, every cloud restart during a build failed the deploy). lib/moved.ts reads the retired model pages against the export's own out/models/** (run scripts/edge.mjs after a build); the 150 pages the old snapshot kept for models the gateway no longer lists are in retired.json, and reach the edge on the next node scripts/edge.mjs <universe>/…/static-sites.yaml.

Two catalogs disagree, and the site picks an owner per fact. A lab's billed rate is /v1/models' (input_per_million, the passthrough included); ours is /v1/pricing's (/v1/models prices Enso Flash at $0.33/$1.65 where the price list says $2/$4). The table (lib/models/catalog) and a model's page (Cost) join them the same way (labRate, ownRate).

The backend owes three things (none fixable here): api.hanzo.ai sends no content-encoding (/v1/models is 398 KB over the wire, /v1/pricing 137 KB); there is no public per-model read (/v1/pricing/model/{name} 404s on any id with a slash, /v1/models/{id} 404s); and the CORS allow-list admits https://hanzo.ai alone, so a preview or local export shows the unreachable state by design (the dev server proxies /v1).

scripts/audit-price-literals.mjs still fails the build on a typed rate (e2e is read as fixtures, by kind); scripts/audit-model-counts.mjs on a typed count. The gates: e2e/gates/read.spec.ts (no script in the export carries a plan catalog, price list or model table; /pricing and /calculator ship the loading line), pricing, catalog, onboarding, coupon, upgrade — each serves e2e/gates/api.ts and blocks it.

Weight, measured (main abe1538c against this change, the same machine, the gate's own counting): the home's first load 452.7 → 437.4 KB gzip (budget 440); pages over perf.budget.json 32 → 9. The nine left are over for code and other datasets, not these: /settings* (rooms' settings code), /bot (connectors, 12.7 KB br), /captable, /dataroom, /sign, /sdks (the commerce catalog, oss.json and the ⌘K page list). Split, never raise.

/models explains the three families, and Zen is said once

/models is the ONE page that sets the families side by side, above the catalogue: Family ("Enso routes. Kai decides. Zen reasons.", a card each with one fact from its data, to /enso, /kai, /zen) and Versus (Enso against Zen: proprietary on Hanzo Cloud, or open weights you self-host). Each says its own idea, so neither card repeats the other.

/enso, /kai and /zen are about one family each and carry no cross-family section: at the app root, one frame (components/family/Hero.tsx: a chip, the promise, one line of what it is, Try Hanzo to /login and one more door; /enso draws its own, /'s, below in "/ is the landing"), the family's own argument, then Siblings (the one line back to /models) and Closing with the family's headline. e2e/gates/kai.spec.ts holds the split.

  • /enso: one endpoint (/v1/chat/completions and /v1/messages), the three presets (PRESETS, no score), the lead and the GPQA Diamond and LiveCodeBench boards read from the research runs when the page opens, managed on Hanzo Cloud.
  • /kai: the graph, typed answers (choice, score, noul), speed and cost (the first measured latency row, read on the server, and Kai's rate beside Jev's, read from the price list when the page opens; a figure the data lacks is not drawn), Hanzo's agents, the results, the call.
  • /zen: the two jobs (USES), the generations with Zen 7 muted, download or Hanzo Cloud.

Zen is said once, and named as Zen

lib/zen is the one statement of the Zen generations: the one that answers (NOW), the one before it (PREVIOUS), the one in research preview (NEXT) and what the family is chosen for (USES, and JOBS as one sentence). /models, /zen, /zen/models, Family, the Research menu and /research-access read it. NEXT is @hanzo/ui's RESEARCH entry (@hanzo/ui/models/catalog, the Node-safe half), which @hanzo/ui's ModelPicker appends to what the gateway serves, so a page and that picker cannot disagree about Zen 7; the gateway never serves it. The composer's model menu is @hanzo/build's own since 0.2.73 (Tune: Search models, Enso's row, the catalog by family) and lists only what GET /v1/models serves, so it offers no Zen 7 row; /research-access is where Zen 7 is asked for (e2e/gates/picker.spec.ts).

A Zen page's reading copy names no upstream checkpoint. Lineage goes to an Architecture field (the loader's exact string) or the License & attribution fine print, both data-upstream. lib/upstream holds the names and splits a description's lineage sentence into the fine print; e2e/gates/names.spec.ts reads every Zen page with data-upstream removed and fails on any of them.

The shell is three panes, and the grid is stated once

components/workspace/Shell.tsx draws the workspace as a CSS grid in gui props on its root (display="grid", gridTemplateAreas), and each child names its gridArea: rail down the left, bar across the top, and under the bar list, pane and side. On a phone ($max-md) the grid turns: bar, pane, and the rail along the bottom as the tab bar, with the list a fixed sheet between them. No stylesheet rule lays these out.

  • The rail is places — Rail: the org mark at the top (the real orgs from IAM, nothing invented; press it for the menu), then every place in [CHAT, ...PLACES] that shows() admits as an icon over its label, then Settings. A phone shows the first four and More, which opens the finder.
  • The list is what the place holds — Index(mode): under Chat and Home, recent conversations, then channels (Rooms + Team), then people, then agents, so anyone — person or agent — is one press away; Dev lists runs, Bots agents, Contacts people, Inbox rooms, Board projects; every other place still lists conversations, because what you have said is not a property of where you stand. Bots draws one half of the roster at a time (show).
  • The pane is the room, and side is the column a room fills through Beside (below).

The wrapper the grid places is [data-slot="list"]; the Sidebar inside it keeps the library's [data-slot="sidebar"], which is what the older gates and rail() address with .first(). e2e/gates/shell.spec.ts proves the geometry at 1440 and 390 — order of the columns, the bottom band, thumb-sized targets, nothing past the edge — and holds two screenshot baselines beside it.

The column beside a room is a SLOT, and the room fills it

Paned in components/workspace/Shell.tsx owns the column — its width, its edge, whether it is open — and hands the room an empty <aside> through the Slot context. The rooms' Frame and the app at / both lay their pane out with it, so Chat's column opens the same way in either. A room puts something there with <Beside>, which portals into it and hands its children an empty slot so what is beside a room cannot itself open a column. Nothing else may draw there.

In the app's frame (Paned framed) every surface on the ground is cut as a pane by gui props, never by a frame stylesheet rule: pane() (components/workspace/ground.ts) on the pane and the column, Framed read by Chat to give its composer the same cut, and @hanzo/build ≥ 0.2.34 cutting a run, its desk and its composers itself. A Dev run (route(at).kind === 'run') is two panes of the builder's own, so _web.tsx passes bare and the pane it stands in is drawn uncut.

The half that is easy to lose is the room's. 7d7fe5b2 replaced the slot with a hardcoded <SidePanel />; d08aff19 restored the slot ref and removed that call site — and did not restore Chat's <Beside><Work/></Beside>, which the same commit pair had deleted. So the button opened a column, the room put nothing in it, and the shipped page was valid, the build green, and e2e/{aside,artifact}.spec.ts red for a reason no gate names. The tell was an orphaned import: Chat.tsx imported Beside and used it nowhere, which is what a deleted feature looks like from the outside. pnpm lint has no unused-import rule, so nothing said so.

Work reads the conversation it is drawn from — the model answering, the fenced blocks an answer produced, the tool calls and their results — from the turns rather than a second list kept in step with them. It is drawn from Chat.tsx because it reads the conversation, and the conversation lives there.

components/workspace/SidePanel.tsx is deleted (1,538 lines, no importer). It was the hardcoded approach d08aff19 reverted, and it framed a developer's own 127.0.0.1 ports. Fill the column through the slot; do not add a second way.

A fetched list passes list() (lib/list.ts) before anything iterates it. x ?? [] covers null and undefined only; /v1/team/rooms answering {rooms: {}} or an error object where a page was expected reached .filter and threw in render ((s??[]).filter is not a function on the live site). rooms.ts and Shell.tsx's Team read through it. The @hanzo/ai hooks are already safe: http.collection() returns [] for a non-array. /home is the workspace room, not a marketing page; signed out, its hooks call the gateway on the anonymous client and every refusal resolves to an empty list.

/dev is Dev

The app is / (above), and /dev is the same app in Dev, its address the path under it. It is every "Try Hanzo Dev" link, the rooms' rail Dev, the console's "open in the builder" (https://hanzo.ai/dev?project=<slug>), dev.hanzo.ai's redirect and every address of hanzo.build (universe build-to-hanzo-ai, which carries a builder path as ?at=). DEV, app(path), under(pathname) and dev(ref) in @hanzo/app lib/host name it. The rooms' shell (Shell.tsx) draws no builder: its switch sends Dev here, and open.ts holds no builder selection.

One address, three answers, decided as / decides (app/dev/page.tsx, app/dev/_door.tsx Door, which is app/_apex.tsx Apex with Dev's two answers):

signed in            the builder: app/_web.tsx in the rooms' frame
                     (components/rooms `Room`, the frame app/(app)/layout.tsx
                     draws every room in). An old query (`?at=`, `?run=`,
                     `?project=`, `?view=`, canonicalized by @hanzo/build
                     `route`) is replaced by its path first, once.
signed out, /dev     Dev's page (components/dev/DevLanding): the landing the
                     export holds, server-rendered, public, in sitemap.xml.
                     Open Dev is `/login?next=%2Fdev`, so the sign-in comes back
                     here and the first answer takes over.
signed out, a place  any other path under /dev, or /dev with an old query
                     (`app/dev/_away.ts` `away`): sign in first (`enter`),
                     with the place as the destination.

AWAY and the rooms' BOOT are the landing's first two children, so the page never paints at a reader leaving for a place (AWAY) or at a live session (BOOT); Apex hands the box back its display: contents when it does draw the page. /dev left app/(app) for the app root to be a page: under the rooms' layout it was a room (ROOMS, noindex, the full-window frame), and a room cannot be read by a crawler. Gates: e2e/gates/dev.spec.ts (the page, its widths, Open Dev, every signed-out place, the builder signed in), login.spec.ts (Open Dev through a real sign-in and back), app.spec.ts (the builder).

The page states no figure. Every command on it was run against the binary it names (dev --help, dev login --help, dev cloud --help, hanzo dev --help); the latest release is GitHub's answer when the page opens (components/product/Release, api.github.com/repos/hanzoai/dev/releases/latest), and nothing is drawn when GitHub does not answer. Upstream projects are not named on it (hanzoai/dev's NOTICE is the one place that does).

The edge must serve /dev/*: lib/edge.ts APP is rendered into universe static-sites.yaml by scripts/edge.mjs (middleware hanzo-ai-app, chained in hanzo-ai-edge); until that sync is live a deep link or reload under /dev/ answers the 404 page, while moves inside the app work.

@hanzo/build is pinned in the catalog at 0.2.52+ (a project is a repository, <org>/<repo> addresses, nav, HostProvider, Who, Slack), @hanzo/app at 0.1.35+ (Dev's address is a path).

/gen is the harness's page

app/gen/page.tsx → components/gen/, at the app root beside /dev, /kai and /enso, linked from Products → Apps and tools beside Hanzo Dev. Gen (github.com/hanzoai/gen, MIT) runs other coding agents on Hanzo's rails; Dev is Hanzo's agent and Gen's default. The page leads with the install: curl -fsSL https://hanzo.sh | sh -s gen (or bare | sh, which installs every Hanzo tool, gen included), then gen (Hanzo Dev) and gen --<agent>; hanzo dev --<agent> is the same session inside the Hanzo CLI, which links the same crate. The latest release is GitHub's answer when the page opens (components/product/Release, api.github.com/repos/hanzoai/gen/releases/latest), absent when GitHub does not answer. The agents are components/gen/roster.ts, hanzoai/gen's own list (src/agents/mod.rs AGENTS plus src/backend.rs's four), each flag run against gen 0.1.0 and hanzo 8.5.732; Qwen Code and Kimi Code are left off because their names are Zen's upstream bases (lib/brand). Routing is said as the CLI says it: --cloud puts an agent's model calls on api.hanzo.ai only for an agent that can name another endpoint, and an agent's own account otherwise.

hanzo.app is where the app is got

hanzo.app is one page: this export's /app (components/app/Get.tsx) — Download (data/downloads.ts NATIVE: the desktop build once it is public, the CLI binary until then, the reader's platform first), Install CLI (the download page's own install line, copied), Open in web (lib/host.ts WEB, hanzo.ai/chat, which sends a signed-out reader to hanzo.ai's own sign-up). The edge serves it as hanzo.app's root from the hanzo-ai prefix with the export's assets (universe static-sites hanzo-app, indexFiles: [app.html]); every other hanzo.app address is the same one on hanzo.ai, and hanzo.ai/app forwards to hanzo.app. So the page has one home: lib/publish.ts gives it the policy hosted (canonical https://hanzo.app, not in this sitemap, no noindex). The nav's Products → DX Tools → Hanzo App links it (nav-data.ts APP).

hanzo.app signs nobody in: guest() covers the apex hanzo.app as well as the <slug>.hanzo.app aliases, so any sign-in there is asked for on hanzo.ai.

It reports to hanzo.app's own GA4 web stream; every host's stream is in its project's tag set in cloud, found by host (@hanzo/event siteKey). Gate: e2e/gates/get.spec.ts.

hanzo.chat is an address for the chat

The chat lives at hanzo.ai/chat and is never reachable signed out. hanzo.chat's root is this export's /forward (app/forward), served from the hanzo-ai prefix with the export's assets (universe infra/aws/routes/sites.yaml, routers hanzo-chat and hanzo-chat-rest, indexFiles: [forward.html]); every other hanzo.chat address 301s to the root with its query. The page lets the tag manager take the page view (tagsReady) — hanzo.chat's own GA4 stream, the Meta Pixel and our $pageview — then location.replaces to authorize(WEB + search + hash), waiting at most 3 s for a blocked tag. hanzo.chat is a guest() host, so it signs nobody in and never reaches hanzo.id. /forward is PRIVATE in lib/publish.ts (Disallow on hanzo.ai, out of the palette and sitemap; its canonical names hanzo.ai/chat). Gate: e2e/gates/forward.spec.ts.

Voice is Hanzo's own speech

The composer mic and Listen are @hanzo/voice through the rooms: the mic records (MediaRecorder) and posts each pause to /v1/audio/transcriptions as zen-scribe; Listen and the agents read through /v1/audio/speech as zen-voice-mini, each character in its Kokoro voice (@hanzo/app cast). The browser's recogniser and speechSynthesis stand in only on a refusal, and the control says so. Proof, signed in with a live token at /tmp/hz.tok: e2e/voice-live.spec.ts (fake mic playing a WAV Hanzo spoke; run by hand against a dev server or the live site).

Signed out, the home bar and the start composer dictate on the visitor's lane (components/shell/ear.ts, speech({ public: true }) → /v1/audio/transcriptions/public): no credential, a minute per recording, a daily allowance per visitor (PUBLIC_SCRIBE_DAILY in universe). Past it the browser's recogniser stands in and the mic's label says the free day is used. The pressed mic's composer gets the words (voice 0.1.14), since the shell's bar is on every page. Run the spec with --workers=2 or fewer: an org runs 4 speech requests at once.

Meet's Transcribe is useTranscript on /v1/audio/transcript: one open, then a push every 256 ms, some 240 a minute. The gateway admits the session once at the open; its pushes count against no request window. A push it cannot take yet (429, 5xx) waits out Retry-After and goes again in order (voice 0.1.15+), and a 429 the mic gives up on reads "Today's Hanzo dictation limit is reached. It resets at

Two publishers write to the Sites slug hanzo-ai (2026-08-16, open)

The site served at hanzo.ai deploys from git.hanzo.ai/z/hanzo-ai — a personal namespace whose 33 commits are all titled "Update from hanzo.app". It shares NO ancestor with this repo: git merge-base reports unrelated histories, and this tree is 1804 commits ahead of it.

Meanwhile this repo's own .hanzo/workflows/deploy.yml publishes to the SAME slug (sitedeploy --slug hanzo-ai). So two lanes with unrelated trees race for one slug and whichever ran last wins. That is the reason work can land here, pass review, and never appear on the site.

A RED GATE has the identical symptom, and it is the cheaper thing to rule out first. pnpm gates is a step in the publishing job, so a failing gate leaves the push green and the site untouched — which reads exactly like losing the race. The two are told apart by last-modified on any page: the collision REPLACES the build, so the timestamp moves and the content is someone else's; a red gate publishes nothing, so the timestamp does not move at all. Run pnpm build && pnpm gates locally before reaching for this section — a gate can be red because its SUBJECT was deliberately deleted, and then it is the gate that is stale rather than the site that is losing.

The container path in universe/charts/app/values/hanzo/a.yaml (ghcr.io/hanzoai/a:v0.1.0) is leftover — deploy.yml's own header records the move to the Sites plane and why: "all to hand back bytes."

To resolve, in this order, and steps 2-3 are unsafe before 1:

  1. RENAME forge hanzoai/hanzo.ai -> hanzo-apps/ai. Rename, not create: it keeps the history and the forge writes the redirect, so stale clones and the four repos already bitten by silent GitHub renames keep resolving. Needs forge admin — the git API binds create/mirror to the caller's org (hanzo) and exposes no rename.
  2. hanzo platform sites update hanzo-ai -> repo https://git.hanzo.ai/hanzo-apps/ai.
  3. Retire the builder lane writing the slug from z/hanzo-ai, or the rename fixes the address and leaves the collision.

The Category: Hanzo — The Open AI Cloud (Techstars '17)

Hanzo is the open AI cloud: the infrastructure agents, AI applications, and developers run on — from models and sandboxes through databases, identity, deployment, tools, workflows, observability, and billing. Run it on Hanzo Cloud, your own cloud, or your own hardware.

The Core Invariants

  • One Control Plane: https://api.hanzo.ai/v1/<anything> — shared IAM, org, project, actor context, policy, KMS, audit, and ledger.
  • Actor Context: Actor -> org -> principal -> service account -> project -> agent -> run -> session -> sandbox -> trace.
  • KMS + MPC + Egress: Secrets never enter raw sandbox memory. Egress routes through delegated identity with strict policy and spend bounds.
  • Unified @hanzo/ai SDK: Front-end workspace rooms (/chat, /dev, /home, /board, /drive, /inbox, /work) bind directly to @hanzo/ai hooks without intermediate mock state.

12 Hard Launch Gates

  1. CLI / Install: curl -fsSL https://hanzo.sh | sh -> hanzo up boots the full local cloud and bare-metal K3s sandbox engine in <200ms.
  2. IAM: Unified user, org, project, service account identities, delegation, PKCE, SCIM.
  3. KMS / MPC / Egress: Zero secrets in runtime agent environments; delegated wire egress.
  4. Model Cloud: OpenAI-compatible /v1/chat/completions, /v1/embeddings, /v1/models, Zen (qwen3+) & frontier routing.
  5. Agents: Persistent sessions, revision pinning, tools, hard budget caps, durable runs.
  6. MCP & Tools: 260+ tools; parity between REST, SDK, CLI, and MCP.
  7. Sandboxes & Bare-Metal K3s: Native platform microVM (Apple VZ on macOS, Firecracker on Linux) running K3s and agent sandboxes (Hanzo Dev, research agents, build runners) with zero Docker/Colima dependencies.
  8. Tasks / Durable Execution: Async workflows with stateful resumption, signal, retry, sleep.
  9. Postgres + Objects: S3-compatible storage and SQL data substrate.
  10. Functions + App Deploy: Zero-config repo -> container/function -> production URL + TLS.
  11. Connectors & Channels: Slack, GitHub, Discord, Telegram with user-delegated scopes.
  12. Observability & Billing: OpenTelemetry traces, distributed spans, cost attribution, and agent budgets.

The Unified Runtime: hanzo up

hanzo up is the single universal developer and node entrypoint:

  • Zero Third-Party VM Bloat: Uses native hypervisors (Apple Virtualization.framework on macOS, KVM on Linux, HCS on Windows) to boot a stripped ~35MB guest payload.
  • Embedded Bare-Metal K3s: Spins up real Kubernetes inside the microVM in <300ms, mounting local workspaces at near-native NVMe speeds via VirtioFS.
  • Agent Sandbox Superpower: AI agents (Hanzo Dev, OpenHands, research pipelines) run with full root and cgroup isolation inside the microVM. Secrets stay safe in host KMS; hanzoai/egress injects API/OAuth credentials on the wire.
  • Dual Local & Mesh Role: Operates as a blazing-fast local dev workspace while seamlessly connecting to the luxfi/node P2P gossip mesh to serve as a distributed backend node for api.hanzo.ai.

Multi-Cloud Compute Plane: /v1/visor (AWS, Azure, GCP, DO, Local)

/v1/visor is the single multi-cloud orchestration and cluster registry API (/v1/visor/clusters, /v1/visor/k8s, /v1/visor/fleet):

  • Bypass EKS Tax on AWS: Avoids the $0.10/hr ($73/mo) managed EKS control plane fee and expensive AWS NAT Gateways ($32/mo) by running self-hosted K3s on AWS Graviton (t4g.small ARM) paired with fck-nat and built-in ServiceLB.
  • Dynamic Spot Fleet Scaling: Worker nodes provision on AWS EC2 Spot Instances (c7g/t4g/g5g/p4d) via Karpenter/Autoscaler, delivering up to 70–90% cost savings with burst-to-zero capability.
  • BYO-Cluster Fleet Unification: Any Kubernetes cluster — local hanzo up microVM, AWS Graviton Spot Fleet, GCP GKE, Azure AKS, or DigitalOcean DOKS — attaches to the tenant's fleet via POST /v1/visor/clusters/attach with KMS-sealed kubeconfigs.
  • Single Scheduling Plane: AI workloads, agent sandboxes, and production applications schedule across the same unified control plane, moving transparently between local laptops and multi-cloud spot clusters.

Operator Control Plane: admin.hanzo.ai & Spot Pass-Through Economics

  • admin.hanzo.ai (Operator Plane): SuperAdmin interface over /v1/admin/* (compute, treasury, books, pricing, infra). Operators manage committed baseline hardware (long-term reserved instances, bare-metal GPU clusters, and wholesale capacity agreements).
  • Spot Pass-Through Pricing: On-demand agent burst compute provisions AWS/cloud spot instances dynamically, passing through the raw cloud spot price plus a nominal management fee (CLOUD_COMPUTE_FEE_CENTS / Visor meter), eliminating idle overhead for tenants.
  • One Binary, Two Topologies:
    • Local Topology (hanzo up): The hanzoai/cloud binary runs on the developer machine, binding to local SQLite, local microVMs, and ephemeral sandboxes.
    • Cloud Topology (api.hanzo.ai / admin.hanzo.ai): The exact same binary mounts into the multi-tenant cluster with encrypted per-org databases (HIP-0302), S3 durability replication (HIP-0107), and multi-cloud spot fleet scheduling.

Shell Chrome: Full-Width Header & Rail Alignment

  • Zero Inset Offset: header[data-hanzo-shell] breaks out of body's padding-left: var(--rail) via margin-left: calc(-1 * var(--rail)) so the header spans true 100vw from x=0.
  • Pixel-Exact Rail Alignment: BrandBox pill starts at x=8px, matching .mode vertical rail buttons (x=8px), with wordmark text centered over the chat/dev icon column.

Board & Work Item Management: /v1/todo

  • Forge as Primary Store: Boards correspond to repositories on the forge (git.hanzo.ai), with cards mapping directly to issues and columns to labels (backlog, todo, in_progress, done, canceled).
  • Resilient Fallback: When repository enumeration is pending or cold, card creation and updates fall back gracefully to the tenant org key (currentOrgId / general) rather than blocking with a refusal.

Production Release & Multi-Remote Synchronization (September 2026)

  • Unified Sovereign Agentic Stack: Synchronized 70+ commits across forge main, github.com/hanzo-apps/ai, and github.com/hanzoai/hanzo.ai. Integrated AgenticOsStack, AppBuilderHero, FloatingChatDock, FooterHeroComposer, and TrustedTicker.
  • Model Lab Canonicalization: Fixed modelPagePath in lib/models.ts and app/(marketing)/models/[org]/[model]/page.tsx to resolve via canonicalOrg(org), eliminating broken alias inlinks and ensuring clean static export params across all 541 models.
  • Vibe & Beluga Studio Workspaces: Restored /vibe (3-column collaborative workspace) and /beluga (ZenLM bioacoustic studio) with full @hanzo/gui responsive viewport containment (overflowX: auto, width: 100%).
  • Quality Gates & Static Export: 37/37 core gate tests and full 965-page responsive phone sweep (mobile.spec.ts) passing 100%. Export compiled to out/ with font preloads, canonical URLs, and llms.txt.
  • Live Deployment: Synced and pushed to GitHub main branches at hanzo-apps/ai and hanzoai/hanzo.ai.

Zero-Stub Implementation & 100% Coverage Verification (September 2026)

  • Zero Fake Stubs & Real Agent Wiring:
    • components/workspace/Vibe.tsx: Eliminated mock setTimeout generators. Connected prompt dispatch to live startRun() (startCoding()) from @/lib/hanzo/coding, tracking session IDs, real runtime tool call reflection, and authentic server refusal codes.
    • components/workspace/Beluga.tsx: Eliminated canned mock responses (*Happy echolocation clicks*). Wired audio/text prompt submissions to live /v1/chat/completions against model zen with bioacoustic system prompt and user history.
    • components/dashboard/KanbanBoard.tsx: Replaced static task dummy data with DEFAULT_TASKS and dynamic board() loading from /v1/todo.
    • components/dashboard/AgentsList.tsx: Replaced static agent fixtures with DEFAULT_AGENTS and dynamic /v1/agents synchronization on mount.
    • components/dashboard/data/agents/agent-data.ts: Purged external references; standardized fully on ZenLM 3 and Enso Auto.
  • Comprehensive Unit Testing (pnpm test:unit):
    • Added unit test suite in e2e/unit/ (28/28 tests passing):
      • e2e/unit/models.spec.ts: 100% coverage on formatPrice, getOrgAndSlug, canonicalOrg, modelPagePath, getModelContext, formatContext, orgDisplayName, and fetchModels.
      • e2e/unit/coding.spec.ts: CodingRefusal status code & error preservation, dispatchable target checks, startCoding / startRun session handling, and listTargets.
      • e2e/unit/durable.spec.ts: reads() engine state mapping, deterministic runName() generation, runs(), runOf(), and idempotent workflow dispatch with start().
      • e2e/unit/todo.spec.ts: COLUMNS closed set, PRIORITIES hierarchy, DEFAULT_CARDS, board(), projects(), open(), edit(), and claim().
  • Quality Gates & Semantic Headings:
    • Added semantic <h1> tags with full CSS containment to Vibe.tsx ({roomName}) and Beluga.tsx (Blue the Beluga).
    • Scoped button queries in e2e/gates/board.spec.ts (main.getByRole('button', ...)), columns.spec.ts, and workspace.spec.ts to prevent strict mode collisions when sidebars are already open.
    • All workspace room gates (workspace.spec.ts, thumbrooms.spec.ts, columns.spec.ts, board.spec.ts) passing green.
  • Cloud Backend Verification:
    • All backend app test suites in hanzoai/cloud passing green with HANZO_DEV=1 HANZO_SQLITE_INSECURE_DEV=1 GOWORK=off go test (apps/visor, apps/todo, apps/account, apps/billing, apps/coding, apps/sandbox, apps/research).

Workspace Beside Portals, Chat Starters & Free Lane Transparency (September 2026)

  • <Beside> Portal Slot Restoration (Shell.tsx):
    • Restored <aside> container element tag (render="aside") and DOM node ref callback (ref={(el) => setSlot(el instanceof HTMLElement ? el : null)}) in components/workspace/Shell.tsx.
    • Re-enabled React portals via <Beside> across all workspace rooms (/dev, /chat, /work), allowing child panes to project side controls (such as Side with Preview/Chat tabs and Work answering artifacts) directly into the right-hand panel while keeping RightPane for roster and directory views.
  • Chat Starters Drafting UX (Chat.tsx):
  • Free Lane Notice & Model Resolution (Chat.tsx & hooks/useAi.ts):
    • Restored standing data-sharing disclosure (freeCopy.notice) when isFree(model) is active on free routes, leaving paid routes clean and unencumbered.
    • Enhanced served() in hooks/useAi.ts to fall back gracefully to the first available model (models[0]?.id) when preferred models (enso-auto / enso-free) are not served in the gateway catalog.

Product-First Positioning & Workspace Resilience (September 2026)

  • Product-First Positioning (hanzo.ai):
    • hanzo.ai is product-first: "Claude.ai + Cursor + an open-source agentic cloud underneath it" (IDE, Browser, CLI, Desktop, Cloud; Agents, Models, Tools, Memory; Compute, Storage, Network; IAM, KMS/MPC, Egress, Policy).
    • Agency client history (Casper, Damon, Triller, Bellabeat, Unikrn, Cover, etc.) lives cohesively on hanzo.agency.
    • Talent & leadership hiring lives on sensei.group.
    • Replaced the agency client ticker in TrustedTicker.tsx with genuine engineering & open-source proof (300+ Repositories, Rust-Native Runtime, OpenAI-Compatible /v1, MCP Native, Local First, Private Deployment, Cross Platform).
    • The apex headline is "Ship apps. Agents. Companies." — see "The first screen is the ring and the builder building" below.
  • Workspace Array Resilience & Error Handling:
    • Guarded all hook lists against non-array payloads in @hanzo/ai (useAgents, useSessions, useThreads, usePeople, useChannels) with Array.isArray(value) ? value : [].
    • Hardened workspace components (Shell.tsx, Home.tsx, crew.tsx, Work.tsx) with defensive array checks on sessions, agents, and people so that unhandled 404s/error responses never throw TypeError: .filter is not a function.

The first screen is the sign-up and the commands

components/home/Start.tsx: "Better, faster, cheaper. Pick three." (with subtle flash animation on "Pick three.") over a line of what Hanzo is worth ("Save up to 90% on your existing AI plan, access frontier intelligence for less. Code up to 20X longer with a Hanzo AI plan...", aligned to the 420px login box measure) and the sign-up (Panel mode="signup": nothing asked of IAM on arrival, a sign-in starts only from a press), beside the Hanzo.Start window: the install (curl -fsSL hanzo.sh | sh), Claude Code on Hanzo (hanzo login, hanzo dev --claude --cloud), a reader's own Claude Code pointed at api.hanzo.ai, and SETUP, each a block copied whole. Every command runs today; the component's head says against which versions. The section is data-slot="hero", which is what the bar's Try Hanzo reaches (components/home/shell.tsx reach): it brings the panel into view and focuses it, and nothing loads.

THE WINDOWS MOVE AND FOLD (components/home/chrome.tsx Win). A window is rounded at 20px, its labels, bars and cells are pills and dots, and its title bar leads with a round control (minus folds the window to its bar, plus opens it; aria-expanded) and a mark or icon for what it holds (a caller's icon). From 1024px the bar is a handle: a pointer or a finger drags the window by a transform, held inside its own <section>, the arrow keys nudge it 12px (48 with Shift) while the bar has focus, Enter folds it, and the window taken hold of comes to the front. Narrower, the windows stay stacked and still. Only the bar drags; a Copy button or the glider inside stays a control of its own. The glider (Conway's, on an 8×8 torus, xl only) is dots, stops while pointed at, and a press on a cell turns it on or off.

ONE SETUP PROMPT. SETUP in components/home/prompts.ts is what every copy control puts on the clipboard: the landing's block, CopyPrompt's "Open in" rows and the agent row's button (/templates).

The browser belongs to one person, and the organization comes first

lib/auth/session.ts own(sub) binds every hanzo* localStorage key — conversations, contacts, the calendar, the board, the agent's persona, the SDK's selected org and project — to the subject recorded in hanzo:who. A different subject, or nobody (a sign-out), removes them before anything reads them; the SDK's own hanzo_iam_* session keys stay. It runs synchronously in Providers off the stored token's sub claim, so the first read of storage already sees a browser that is the reader's, and again in Identity once the SDK settles. e2e/gates/own.spec.ts measures all three cases on the shipped bytes.

The model. hanzo.chat is an address for this app's /chat (see "hanzo.chat is an address for the chat"): a reader who arrives there is counted and sent here. This app, /chat included, is an organization or a personal account at the catalog's first personal rung that charges (GET /v1/billing/plans, the dev row, never typed here), its AI usage included and usage metered past it; Team is per seat. A model call is the model's cost plus a 5% platform fee, pay as you go, no minimum. Local private AI and the open Zen weights are free forever; Enso has its own free and paid plans. /pricing states this whole in components/pricing/Plans.tsx from the same catalog. How the app asks — organization first — is the section below.

An organization owns every agent, credit and member, and it comes before any room. components/workspace/Orgs.tsx wraps Workspace in Room.tsx, so it stands in front of every room, and a signed-in reader meets, in order:

  • None yet — the token's orgs claim is empty (the SDK's list falls back to the owner claim, which is the application's org and nobody's to work in, so the gate reads the claim itself through session.ts orgs()). The screen is "Organizations — You do not belong to one yet — New organization". The dialog takes a name (or a personal organization named after the account), a template from /v1/templates, and posts POST /v1/account/orgs. Then it drops the access token (renew(), the refresh token stays, so the next load mints one that carries the new org) and leaves for checkout on the plan.
  • Several — pick the one this browser works in; hanzo:org:picked remembers it per person, because own() clears it with everything else. The chooser (@hanzo/app Pick, its LLM.md) shows each one's mark, slug, role and plan, filters as typed, founds one through IAM's onboarding, and gives a SuperAdmin every organization, stepped into through IAM's assume. Its monograms take their hue from @hanzo/tokens/css/data, which app/layout.tsx loads.
  • Not live — GET /v1/billing/tier, scoped to that organization, answers free: subscribe, or switch. Billing unreadable passes through.
  • Nobody — a visitor with no session gets the sign-in screen, in place. It draws IAM's own embedded Login (@hanzo/iam/views, re-exported from /react): email and password, a one-time code, and the same PKCE exchange handleCallback performs, so this is the one auth mounted here rather than a second one written here. It takes issuer, client, callback and organization from origin() in app/providers.tsx — the SAME derivation IamProvider is configured with, because a code is bound to the client that asked for it. The hosted page at hanzo.id is offered under the form as the fallback for what the embedded surface cannot finish. providers is deliberately not passed: the prop defaults to none and the discovery endpoint that would fill it is not deployed, so naming one would draw a button for a hop that may not exist.

NOTHING PRODUCT-SHAPED IS IN THE EXPORT, and that is what killed the flash. The pre-hydration branch is what the static file holds and what hydration's first render must agree with, so whatever it returns is what a reader sees before any answer exists. It used to return the room, which put the whole paid interface on disk: the browser painted it from the file, the SDK settled, billing answered, and only then did the ask appear over a product the reader had already been shown. It returns the veil now — the same element in both renders, so no mismatch and nothing to see. Measured on the shipped bytes: out/chat.html fell from 83,608 bytes carrying a composer, a textarea and the rail to 35,939 carrying none of them, and a signed-out load throttled to 40 KB/s draws zero product nodes for its whole first twelve seconds. The veil past hydration is unconditional for the same reason; it used to be hung only when a token was already in hand, which left a visitor arriving without one looking at the room while the SDK decided.

The cost is real and worth stating: the room is no longer in the export, so a gate about what a room draws is a gate about a signed-in reader, and signIn from e2e/session.ts has to come before goto.

AN ECOSYSTEM ORG IS KEYED ON THE ACCOUNT BILLING ANSWERED FOR. isPaidOrg (hooks/useTiers.ts) names the orgs that run on enterprise terms, and lib/hanzo/tier.ts reads it off data.user — the scope the server resolved, which is the X-Org-Id the request carried or the token's own org where it carried none. It used to split the JWT subject on a slash, which is a second key and never matched: a subject is a uuid, so the test was false for every real token and hanzo, lux and zoo all read as free. The PLAN is ours to state. The BALANCE is not, and the fabricated $1,000 that used to stand in for an empty wallet is gone — the gate that spends the money is on the server, so a figure invented in the browser buys nothing, and standing one in made a depleted org read as funded and turned every refusal into a mystery.

The plan is the catalog's first personal rung that charges (lib/plans.ts, the row /pricing sells), never a typed number; a year is sold where the row states annualTotal. Checkout is hanzo.ai/pay/cart in the same tab with ?return= back to the room, and the tier is read again when the tab is returned to. A template chosen at creation is forked (POST /v1/projects/fork) once the organization is live, then /dev opens.

No room opens with a tour. Coachmarks that dimmed a room 900ms after it mounted and took the next press are gone with @hanzo/app' Guide; a room is explained by its own empty state.

Agents are called by one word from their purpose. Dev builds, Des designs, Vi sees ahead; Sal sells, Mark markets, Su supports. The handle IS the name — cast.tsx keys the cast by it and carries only the likeness, the ground and the voice — and it rides the URL and the request unchanged.

Dev wears the memoji at /agents/dev.png — the owner's Hanzo-Memojis/Current/data-agent.png at 512px, black ground baked in so it fills the disc. It is the face on the landing's crew tile (Crew.tsx), in the cast (cast.tsx: dev and hanzo coder), and on the contacts catalog's Hanzo Coder; every Face and roleOf reader draws it from there.

Every room has a tour, and the chat tour plays a scene. components/workspace/tours.tsx holds one list of stops per room — chat, meet, calendar, contacts, inbox, drive, board — and the finders they point with; Guide takes room and marks each seen under hanzo:<room>:guide (chat keeps hanzo:chat:guide) and the preference <room>Guide. Every room route mounts <Guide room="…" /> inside its Room. The chat tour's fourth stop is scene: true: components/workspace/Scene.tsx plays a chat with the designer bot, Des, Vi and Alex brought in, the four lines they say, and the call it becomes with a live transcript — on a stage of its own, so nothing reaches the composer or the wire. e2e/gates/tour.spec.ts plays it through and opens five rooms' tours.

Every request names the organization. session.ts scope() is the one set of headers a raw fetch carries — the bearer and X-Org-Id for the SDK's selection when the token says the reader belongs to it — and useAi.ts builds the @hanzo/ai client with the same header and REBUILDS it when the selection changes, so every hook on it re-reads the new organization's threads, agents, people and rooms. Cloud honours X-Org-Id only for a member (pay's payer.ts says why), so the worst a stale selection can do is address the caller's own. e2e/gates/orgs.spec.ts measures all of it, including the header on the wire.

The daily free pool on /v1/chat/public is counted per visitor address at the edge, and its refusal says "sign in at https://hanzo.ai". A signed-in reader who sees that sentence sent the turn without a bearer — see useAi.ts's audience override and sanitizeStaleAudience(): the gateway's allowlist has no hanzo-ai.

Rooms live on /home

Home is the org's room. The rail's Team folders and the Rooms card both open a room INTO /home — components/workspace/Home.tsx resolves the selection key against the org's list and mounts Talk in place of its cards — and app/(app)/home/_workspace.tsx mirrors that key into the address as ?room=, the way /chat carries ?thread=. The Inbox is what other networks carry in, and only that; conversations.ts composes arrivals alone, keyed net:….

One reader and writer: components/workspace/rooms.ts. teamKey(room) is the key (team:<space>/<id> — both halves, because a room id is unique within a space and not across the org) and roomKey(key) is the address it names. useTeamRooms() reads the list and open({name, space, members}) posts a room and holds the 201 row, so the reader steps into it without a second read; the caller names itself as a member because the platform adds nobody. useRoomMessages(room, space) reads the transcript and POLLS it every 2 s while the room is open and the tab is showing — the team plane has no push a browser reads but its binary transactor socket, and a read costs a few hundred milliseconds — with one AbortController per read, aborted with the clock on unmount or room change. Rows are ordered (createdOn, id) and deduped by id. send(text) posts and answers the stored row, which the hook holds at once.

One name join: components/workspace/roster.ts. A message's author is an account — a person's IAM user id (the OIDC sub), an agent's userId from /v1/team/bots — and useRoster() reads /v1/iam/users?owner=<org> and /v1/team/bots into one Member[]; memberOf(members, id) is the lookup the transcript, the roster column and the mention chips all use. useMe() is the reader's own sub. Board keeps its own assignee merge because it keys on IAM <org>/<name> ids, a different key.

What you say is drawn before the platform answers. Talk appends a pending row at send — quiet ink, no stamp — and drops it when the send settles: the accepted row is already held by the hook, a refusal restores the draft and says say(e, 'this room', 'save') under the composer. Nothing the platform did not accept outlives one round trip. Mentions are plain @name in the text; the SERVER renders @<bot> as the mention node and wakes the agent, so the client sends no markup. The composer's chips and the roster's agent rows both put @name into the draft.

States, in the house ladder: the Rooms card says Sign in to see your rooms. / say(wrong, 'your rooms') / Reading. / No rooms yet. Open one and the conversation has somewhere to live.; an open key with no room says That room is not here. and offers Back to home; the roster column says Sign in to see who is here. / say(wrong, "this org's roster") / Reading the roster. / Just you.. Refusal ink is var(--destructive) — $destructive is not a theme key and paints nothing.

The gate is e2e/gates/room.spec.ts: the join, the poll (≥3 reads in 5 s), the pending row (visible within 300 ms of Enter, ahead of an 800 ms answer), the POST body (space + text), the agent's answer on the next read, the roster beside the room, and a new room posted with members: [me] and opened.

The two rooms beside this one state their own contracts in their module headers: /social in lib/social.ts, /auto in lib/auto.ts.

deck/ — the investor deck

One file, deck/index.html, thirty-one slides at 1920×1080 — fourteen of pitch, six of measurement appendix, eleven of platform appendix — opened flat in a browser or rendered by hyperframes. Nothing about it is a second design system: it reads the house brand, and every number on it is read from lib/data.

Type is Zen, from @hanzo/font. That package is where the faces are authored; @hanzo/design ships a copy of its output for surfaces that need @font-face inside a larger sheet, and the deck was carrying that copy one build behind. deck/assets/*.woff2 are @hanzo/font's own bytes, declared the way that package declares them — 100 900, font-display: block — with the site's fallback tails and Hanzo's voice, font-feature-settings: "ss01", the single-storey a. assets/LICENSE.txt travels with the binaries because the OFL asks for it.

There is no hue but other people's. The ladder is --ink #ffffff, --mid #a1a1aa, --dim #8a8a8a — the site's own greys, on black. The distinction the numbers need, ours against the field's, is carried by LUMINANCE AND FILL rather than by colour: what we measured is white, solid and half a weight heavier; what the field published is grey, hollow and light. One rule, learned on the score slide and good through the appendix. The markup says which is which — .ours present or absent on .bar, .row, .sq, .start, .tile, .pill, and td.ours / td.theirs in a table. A previous version used a warm orange for us and a cool blue for them, which is a palette this company does not have.

Colour appears exactly once, on marks that belong to somebody else. MARK in the page script holds artwork and brand hex together, one entry per company, and logo(name) is the only way a mark reaches a slide — deck/assets/logos/ was a second copy of the same artwork that nothing read, and is gone; cells(list) draws the lab wall and the deploy-target row from it. A company shows its MARK where we hold the real artwork and its NAME set in Zen where we do not, and there is no third option: five entries here were once shapes drawn to stand in for logos, Hugging Face and Qwen both the same generic circle, with invented brand hexes to match. Take path and hex from node_modules/simple-icons, which the site already vendors; a genuine path carries dozens of coordinates at three or more decimal places and a drawn one carries none, which is how those were found. logo refuses a missing mark rather than rendering nothing. Every mark is a single path painting currentColor, so a brand is one hex. Where a company's own mark is black — OpenAI, xAI, Techstars — it is white here, which is what they do on a dark ground themselves.

Nothing on a slide is typed. node deck/sync.mjs (run it after touching lib/data) copies bench.json, writes models.json out of GET /v1/pricing and GET /v1/models — the lab wall ranked by how many of each lab's models we carry, the catalog counts, and the output price beside each GPQA score — and writes platform.json out of catalog.json and families.json. The site keeps no copy of the price list, so the deck reads it from api.hanzo.ai, the owner, and a run with the API down stops and says which read failed. All three are fetched and applied INSIDE main() before the timeline is built, for the same reason the bench is: a seek suppresses GSAP callbacks, so whatever a [data-count] holds at build time is what every still frame shows.

The thesis is drawn, not asserted. Slides 2 and 3 carry the why-now: building software got easy, running the company did not. Neither states it in prose. Slide 2 draws the thirteen rooms under app/(app) — every name and line resolved from that room's own metadata, and dev drawn hollow, because it is one tile of thirteen and the argument is the count. Slide 3 is our own receipt: deck/census.mjs walks every git log under ~/work/{hanzo,lux,zoo} and writes assets/estate.json. It counts a commit ONCE and a repository once — walking directories double-counts, because git worktree gives one object store several working directories, and that read 202 repositories and 16,961 commits where there are 188 and 13,259. And it claims a FLOOR: an author name and an author mailbox disagree on a sixth of the commits (2,041 as zeekay <[email protected]> alone, a person's name at a service address), nothing in a log settles which, so both classifiers run, only their agreement is counted, and the disputed share is drawn on the slide rather than assigned. No 10× appears anywhere: it is a good thesis and not a number we measured, and every other figure here re-derives.

Every path the deck prints is put to the API before it is written. A snapshot is a moment, and the API singularised its segments: /v1/agents, /v1/functions, /v1/integrations and /v1/wallets all answer nothing, and six of the hero tour's twelve beats are retired. deck/sync.mjs verifies through scripts/catalog.mjs's one reader, drops a dead tour beat, and throws on a dead product path; an unreachable API keeps the last certified snapshot rather than writing an unchecked one. singular in scripts/catalog.mjs is the rule that resolves the rename — it replaced twelve hand-kept entries of which four had themselves gone stale.

The platform appendix is resolved, not written. A1–A6 are the measurements; A6 draws the whole API surface as a squarified treemap, one rectangle per family with its area in the ratio of the operations under it, and P0–P10 walk it: the twelve-call tour the hero tells, then one product from each of the ten layers. A product page declares ONE thing — data-family on its section — and sync.mjs resolves the name, the sentence (the document's own), the base path, the operation count, the page and the repository, and stops if the document has dropped that family. The same treemap is redrawn on each page with that family's rectangle lit, so the appendix is one instrument seen eleven times rather than eleven loose cards. sync.mjs also stamps every data-start from the slide's position, so inserting one renumbers the rest instead of asking a person to.

Slides are opacity: 0 until the paused timeline reveals them, so a flat open would be a black rectangle — #pager seeks to the middle of a slide's hold and counts the slides itself, and hides under hyperframes. The stage is fitted by arithmetic, not place-items: center: a 1920px child needs a negative offset when the window is narrower, and centring by scale pushes content off-screen.

Verify it the way it is read: node deck/shoot.mjs from the repository root pages all twenty-nine at 1280/1600/2560, then npx [email protected] check .. It seeks each slide to its own still frame BEFORE measuring — the paused timeline holds every part it has not reached yet 22px down, and measuring through that reports a phantom 22 on a dozen unrelated boxes — and it measures two things: any child leaving the stage rect, and any flex or grid box shrunk below its own content. The second is the failure a screenshot hides and an overflow check misses: nothing leaves the stage, the boxes just shrink and the text prints through the caption below it.

Publish with git subtree split --prefix=deck to hanzoai/deck (private, so a co-worker can merge their slides), and the history comes with it.

A Band is a slide

A Band with an id, and Enso, is a slide: it fills the screen (min-height: 100svh), its content sits at the middle, and the page settles on its top edge. The slide states all of that itself, in gui props and scroll-snap-align (components/home/band.tsx, components/home/Enso.tsx). The scroller's half, scroll-snap-type: y proximity on html, is @hanzo/design base's; the header's clearance is its scroll-padding-block: var(--header), so a slide carries no scroll margin of its own. The bar publishes --header from a measurement and the composer's band publishes --dock (components/shell/dock.ts); nothing declares either in CSS, and a page with no band has no dock. The product on a page is the product: the Bots slide (/agents/crew) shows a capture of the Bots room (components/home/Shot.tsx, public/shots/bots-1440-{light,dark}.webp), made from the shipped site by scripts/room-shots.mjs with the org's test agents hidden and sample names in. TWO PLATES PER ROOM, because a screenshot is the only thing on the page that cannot retune: the window frame around it is tokens and follows the reader, and one capture left the frame and its contents on opposite grounds. The script photographs each room under both colorSchemes and MEASURES what it got — mean luminance, light against dark — so a capture that ignored the scheme fails rather than shipping as two names for one picture. Shot takes the pair's stem and draws both; gui's $theme-light picks one off the t_light class @hanzo/appearance's boot script writes on <html> before first paint, which is why there is no flash and no hook. It cannot be prefers-color-scheme: the install default is dark, so the system preference is the page's theme only when the reader picks system. The plate not picked has no box, and a lazy image with no box is never fetched — measured at one image per load in Chromium, Firefox and WebKit, at 390, 960 and 1440, in both themes. So the landing carries two plate requests either way: 112.8 KB on the dark theme (49,710 + 65,774) and 115.2 KB on the light, against 104.3 KB for the two single plates before (42,758 + 64,032). The 8–11 KB is the recapture, not the pairing — pairing costs nothing, because only one of each pair is ever asked for. The hero plate is the LCP element at every width measured, which is why eager still means something: it spends a fetchpriority rather than a second download. A product slide is copy beside its figure: Split in components/home/band.tsx (two columns from 1024px) inside a wide Band; Bots and Cloud use it, and a Head in its copy column sets itself from the left edge there (the Says context) instead of centring over the page. Every slide fits one screen at 1280×800.

A title and its lede sit 14px apart wherever they stand. Title and Lede carry no margin of their own; a Head grid carries the beat as its gap, and a stack that is not one gives the Title under an Eyebrow, or the Lede under a Title, mt="$3.5" ($2.5 between two ledes).

/kai is the decision model's page

/kai is the decision model's page (app/kai/page.tsx → components/kai/), at the app root beside /enso and in the same frame. Every monospace figure that EXPLAINS the model carries an example tag in its own bar; the MEASURED results are their own band (components/kai/Results.tsx, #benchmarks), read from the run files as below. Links go only where something answers — there is no public model card or paper, so none is linked; the specification is HIP-1332 on github.com/hanzoai/hips (swap to hips.hanzo.ai once that page is served), and each results table links the benchmarks commit it is from. One model, one name: kai, never a numbered generation.

/kai's server HTML carries no Kai figure and no measured revision: the Decision Suite, its count ("16 of 16") and every board's revision are drawn after the page reads the runs and the price list (the section below), so curl of /kai shows none of them and that is the design, not a stale deploy. The one typed version is the status list's first row (STATUS in components/kai/KaiLanding), what production serves: kai-1.3 at the model id kai, kai-1.4 at kai-1.4 and the agentic preview kai-1.5 at kai-1.5 (universe kai.yaml, kai-1-4.yaml, kai-1-5's file; 2026-10-16). Change it when a pin moves.

Kai's results: the research API's runs, read when the page opens

bench post (hanzo-inc/kai) ──> GET api.hanzo.ai/v1/research/runs?org=hanzo
    ──> lib/api/research (one read a page load, lib/api/read) ──> hooks/useKai: derive() in lib/kai.ts
        ──> components/benchmarks/{board,Kai}.tsx (/benchmarks#kai)   components/kai/{Lead,Results}.tsx (/kai)
    GET api.hanzo.ai/v1/models ──> hooks/useSuite: the Decision Suite's cost row
  • Every Kai figure on /kai and /benchmarks is read when the page opens (owner, 2026-10-15): the research runs through lib/api/research, which rides lib/api/read (one read per page load; api() is the origin: api.hanzo.ai on the site, the page's own origin locally), useKai derives the boards from what it returns, and each section draws components/api/state's Ready: reading, the figures, or "api.hanzo.ai is unreachable" with nothing in their place. The export carries no Kai figure (e2e/gates/wins.spec.ts: no Kai row in the flight data; kai.spec.ts: no harness cell in /kai's HTML). lib/data/benchmarks-kai.json (scripts/kai.mjs) is no longer read by those pages; the gates serve it as the research API's answer, and the build-time KAI in lib/bench-data still feeds what is listed next. scripts/kai.mjs writes it only when the answer's revision passes its release gate against Jev (derive's sota); otherwise it prints gate does not pass, leaves the committed file byte for byte and exits 0, so the build goes on with it (e2e/unit/snapshot.spec.ts).
  • Still built from the snapshot (to convert): the shelf (lib/results.ts ledger and the /benchmarks/<slug> pages), Kai's line on the /models family card (components/family/Family.tsx), /kai/guide's accuracy line, BOARD.lede and BOARD.measured (lib/board), and the coding and tools boards' own snapshots.
  • Kai against Jev, and only Jev (owner, 2026-10-03). Kai started from Laya's weights, so no hanzo.ai page names Laya: WHO is kai, jev, and the API's laya runs stay in its answer, unread, and scripts/kai.mjs keeps out of the snapshot every run whose benchmark or metric names another system: one the answer lists, or the one a gate (gate.<system>) or a tally (beats.<system>) compares with, since on 2026-10-16 the answer stopped listing that system's own runs and five such Kai runs reached it. scripts/sync-oss.mjs leaves hanzoai/laya out of the OSS list and counts. e2e/gates/unlisted.spec.ts fails on the name anywhere in out/.
  • One revision a page: the newest Kai version with harness runs (by ended). Kai's cells read only runs whose version equals it exactly — no other build's run, not even a7's, which shares kai-1's weights sha. Jev is its newest run marked baseline. Every note names that revision (named in lib/kai: kai-1.4 since 2026-10-16), never "the served Kai": the model id kai serves kai-1.3, kai-1.4 is its own model id kai-1.4, and no kai-1.3 harness run is posted.
  • The Decision Suite leads both pages (owner, 2026-10-15): sixteen tasks Hanzo fixed on that day (lib/kai DECISION, specs), Kai beside Jev on each: the eleven harness suites Kai leads, choices of 16 and 77 options (cap.cardinality, Banking77), the same request three times (decision.stability), false alarms on real benign prompts (in the wild), and cost & deployment. The trolley benchmark is read and filed beside it, never as a row (owner, 2026-10-16). The board draws the tasks Kai wins and no other (table): a task Jev leads, or one whose runs are not yet public, is not drawn, counted or named. The count is said out of the suite's tasks only while Kai wins every one ("Kai wins 16 of 16 tasks", lib/kai share), else bare ("Kai wins 15 tasks"), never "15 of 16". Each row names the benchmark it reads; derive takes Kai's run of it at the page's revision (the run that row's board reads) and Jev's newest recorded one, so the suite moves with the revision and no id is pinned. A task whose runs are not yet public, or whose price is unread, holds the count back, so the count (the board's title, /kai's heading, first card and lead strip, /benchmarks' Kai heading and hero tile, the landing's Kai tile and Research figure) shows only once every task holds both figures. Every row names its public dataset and links its runs and the hanzoai/benchmarks folder that re-runs it (decision/, decision/capability/, decision/stability/, decision/jailbreak-suite/; the cost row has none); under the board, one "Re-run any row" link to decision/SUITE.md. Row 15 reads jailbreak.wild (category benign) and states, as its second read, jailbreak.guard3-sealed (real_benign) only while the revision has a run of it (kai-1.3's alone today, so kai-1.4's row states none); the cost row states the decision.latency runs' p50s by id (TIMING: stage a7, kai-1's weights; a cached decision in process on dgx's CUDA and evo's CPU, a cold one, and Jev's hosted call) beside the model list's rates: no kai-1.4 latency run is filed. lib/api/research keeps Kai's and Jev's runs on a decision benchmark and every system on an enso.* one, so no other baseline reaches a page. Under its title, the day the tasks it draws were fixed, and that the frozen harness's suites Kai leads are below. The cost row is GET /v1/models' input_per_million for kai and typesafe/jev-1.13. /kai leads, under the hero, with the suite's rows as headlines (lib/kai headlines, components/kai Lead), each only while its row is drawn (the timings ride the cost row). e2e/gates/suite.spec.ts serves the runs and an invented model list and reads every state, and gives Jev one task (e2e/gates/wins.ts lose) so the cut is always exercised.
  • One Kai figure leads wherever a page leads with Kai (owner, 2026-10-15): the landing's Kai tile and /benchmarks' hero tile are useLead (hooks/useSuite) over lib/tally kaiLead: table's claim ("16 of 16", to /benchmarks#decision-suite) while the suite is whole, else the harness suites Kai leads (to /benchmarks#kai). It draws nothing until the runs are read and the model list has answered or failed (useClaim), so the harness count never shows in the moment before the suite's; /benchmarks' Kai heading and /kai's heading wait the same way, and the /benchmarks hero's built lede states no Kai count. The landing reads its figures when it opens, like /benchmarks: the export carries none (lib/advantage.ts is gone), the band says it is reading until every read has answered or failed, then draws its tiles at once, and is not drawn when none holds. A link from another page to an anchor on /benchmarks lands before anything is read and the sections above it grow as the reads answer, so useAnchor scrolls to the address's anchor once the runs, the model list and the price list have answered, instantly (base.css's smooth scroll ends off target while a chart re-measures), and not if the reader has moved the page; an anchored Block stops clear of the chapter nav. e2e/gates/suite.spec.ts holds both pages to the same count, the held model list to no count, and the deep link to an uncovered title.
  • The landing's Kai tiles are the count and the price (owner, 2026-10-16): Enso's "5× less", the Decision Suite's "16 of 16" and kaiPrice's input price, as many across as there are, then a Kai panel (what Kai answers, how a Jev client moves over, links to docs.hanzo.ai/docs/decisions, docs.hanzo.ai/docs/guides/migrate/jev, /benchmarks#decision-suite and /kai) and Start free, Pricing, Every benchmark. No speed tile: the cost row's timings are stage a7 (kai-1's weights) in process on dgx and evo, the docs say Kai runs at api.hanzo.ai only, and no filed run times the served Kai the way Jev's hosted call is timed (a client over the network). Customer copy uses plain nouns.
  • The headline figures draw at once (useSettled in hooks/useSuite: the runs, the model list and the price list have each answered or failed). The landing's tiles wait on it; /benchmarks' lead holds its four places empty at their final height from first paint (board.tsx KEEP: 141, 161 and 181 px one, two and four across) and fills them together, so its layout shift is under 0.01 where it was 0.17–0.42 (e2e/gates/suite.spec.ts). A figure whose read failed gives up its place.
  • Gates time the page, not the runner's boot: on a starved runner /benchmarks hydrates in 8 s (10× CPU) to 25 s (20×), so a gate that opens a page waits for html[data-hydrated] (app/providers) before its timed waits, and a console-clean gate answers /v1/event, whose batch a slow run flushes to the export server.
  • A read waits 15 s for its reply (lib/api/read PATIENCE): a reply that does not start in that time is unreachable, so a section stops saying "Reading" and the landing draws the tiles whose reads answered (e2e/gates/fold.spec.ts, e2e/unit/patience.spec.ts); a body that has started is read with res.json() to the end however slowly it arrives. The body is not read through a stream reader: route-fulfilled answers read that way never finished in the Linux gates. The API sends every document uncompressed (the research runs are 1.5 MB).
  • The pages publish wins only, every table (owner, 2026-10-16: "for marketing we highlight what we win at; we do not reveal benchmarks we are still working on"; e2e/gates/wins.spec.ts). No table is published whole: a row Hanzo does not lead is not drawn, not counted and not named, and a count is of the rows drawn with no total beside it ("ahead of Jev on 31 languages", "Kai leads Jev on 11 suites"), the one exception every one of them (the suite's "16 of 16"). The frozen harness board is the suites Kai leads and their mean (the Jailbreak suite, which Jev leads, is gone, and with it UNPUBLISHED); MASSIVE language by language is the languages Kai leads (Languages is { ahead }); stability is drawn only while Kai leads it. The research API keeps every run; only what a page draws is chosen. The suite's false-alarm row, which Kai leads, names the attack question where its figures come from (lib/kai ATTACK), which the gate cuts by those names before it looks for the word. The leaderboard and index.wins are the suites Kai leads by at least half a point (accuracy, the mean over exactly those), a calibration pair only where Kai's error is lower, cardinality (cap.cardinality) only the sizes up to 160 where Kai is ahead, and latency only from <mode>/cpu-<dtype> measures, CPU being the device production answers on. A board with no run at the version is not drawn. Every board's note names the device its run records (device X; dtype Y in the run's notes). The harness board links the benchmarks commit; the others link their run's record at the API. Every cell is the value on file rounded at render; each row marks its best.
  • Every claim is a condition on the data. A headline figure (KAI.claims) shows only while the numbers under it still say what its label says. A rerun that loses a win drops its tile.
  • The headline is a condition too. "Kai passes the release gate against Jev." only when the revision's gate.jev run passes; else "Kai leads Jev on N suites, every one a recorded run." while it leads any; else "Kai beside Jev, on the same questions." The lede is the held claims' sentences. No superlative without a run: no "state of the art", "Pareto" or "sub-millisecond".
  • kai-1's runs are a7-512's harness predictions (11,099 of 11,099 answers identical, hanzo-inc/kai results/satori/kai-1/harness.pair.json), filed as kai-1@0834a74f…, on CUDA BF16. Its other run is cap.cardinality (CUDA F32). No gate, latency, joint, invariance, refresh, media, conformal or deploy run is filed for kai-1, so none of those is drawn. Runs once filed under kai-1's name from other builds carry revision: retracted in the API, which withdraws them from every listing. Self-hosting is not offered, so no page says Kai runs offline or self-hosted.
  • kai-1.4's runs are the unified release (weights c1856a77…, filed as kai-1.4@c1856a77…), every one read on evo's x86-64 CPU through kai serve --device cpu --reach 32768 (hanzo-inc/kai results/kai-1.4/unified/suite/): the 12 harness runs by bench post, and rows 12-15 by that folder's runs.py (cap.cardinality-cases-kai-1.4.c1856a77-kai at 4, 16 and 77 options, decision.stability-all-hijack-kai-1.4, jailbreak.<pool>-clean-h-kai-1.4), each granted public. A re-posted run lands its new version private: the 2026-10-16 bench post re-filed Jev's 12 harness runs and seven cap.* runs under a new commit stamp, same measures, and the anonymous read lost them (every suite row pending) until they were granted public again. Grant every id a post re-files, and read the anonymous answer after.
  • /kai/guide and /blog/kai-decision-models print only the public cookbook's facts (hanzoai/kai: the request, the SDK calls, 100 questions, trained 1,024 / reach 32,768, 13.9 s for 100 yes/no questions) and production's 2026-10-10 read (38 ms p50 for one yes/no), and which Kai each model id serves: kai kai-1.3, the default; kai-1.4 the unified version and kai-1.5 the agentic preview (tool relevance and when to call), each by id (the same as /kai's status row); the guide's accuracy line is derived from KAI. Bot and Team do not call Kai, so no page says they do.
  • e2e/unit/figures.spec.ts holds Kai.tsx to the index's rule (no digit in source); e2e/gates/benchmarks.spec.ts and kai.spec.ts abort the API's address to compare every rendered row and best mark to KAI, and answer it with a later revision (e2e/gates/revision.ts) to see the page swap.

The agent tools board (/benchmarks#tools, after coding) takes coding's path: lib/tools.ts, scripts/tools.mjs (prebuild) → lib/data/benchmarks-tools.json, hooks/useTools, components/benchmarks/Tools.tsx; one address a tool, /v1/research/runs?org=hanzo&benchmark=mcp.<tool>. /v1/research/benchmarks is not open anonymously, so a split's definition is its measured run's notes. What decides is read from each version a step at a time (X, then Y: Kai, BM25, else rules); the version is not printed, since two injection versions say "jailbreak", which e2e/gates/wins.spec.ts keeps off benchmark pages. Wins only (owner, 2026-10-16): a split is the current handler beside the one it replaced, drawn only while the current one is level or ahead on every measure both runs hold and ahead on one (Column.better, holds); the study's other arms (a part read alone, rules+kai-1.2) are experiments and are not drawn. On 2026-10-16 that leaves mcp.command, mcp.select and mcp.triage; mcp.injection is not drawn (the current handler raises more false alarms on two of its categories than the one it replaced).

  • The hero graph is scripted, not computed (components/kai/scenario.ts): twelve typed decisions over eight evidence streams, a confidence per decision with every source present and the share each source carries. Switching a source off re-runs only the decisions that read it (reach), and one the evidence cannot carry defers and names the source it needs. The numbers are exact on purpose — e2e/gates/kai.spec.ts asserts them, so a change to the script is a change to the gate in the same commit.
  • Two drawings, one state. The wide SVG and the tall one are both in the export and gui's $max-sm shows one (svg[data-shape]). Every fill and stroke is computed from the phase, the hovered source and the busy core, which React already holds — no stylesheet rule reads a data- attribute. The file ships every answer at opacity: 0, which the no-JS pass lifts, and draws [mask] only once the page runs; reduced motion settles at once.
  • Motion is script, and only when allowed. Streams scroll and flow by SMIL (<animateTransform>/<animate>), the ring and the flash play once through element.animate, the busy dot is @hanzo/design's hanzo-pulse-dot, and Passes settles its cells by element.animate when seen. None of it is rendered before the page knows motion is wanted, so the export is still.
  • The one hue is --state-online, and on this page it means what it means everywhere: something live — a decision still resolving.
  • The figures are Window from components/frame/Term.tsx, which is the frame Term always drew, lifted out so a code or diagram panel wears the same one.

components/family/Family.tsx — Enso routes, Kai decides, Zen reasons — is on /models only, cards in that order. Enso is the router for every modality. Enso 2 is not a product. Kai returns a choice. Zen reasons. No unmeasured figure is printed on a card; Enso's is its GPQA Diamond line, read when the page opens (components/enso/Lead Fact), and Kai's shows only while KAI.claims holds one.

Zen 6 is available now; Zen 7 is a research preview. Zen 6 is the Qwen3.8 generation (zenlm/zen6's base), so lib/engine.ts names a Qwen3.8 build Zen6-. @zenlm/models catalogs Zen 5 and earlier, so /zen counts Zen 6 from the public zenlm/zen6* repositories in lib/data/oss.json. /research-access is the Zen 7 preview.

Enso's boards and /enso's lead: the research API's enso.* runs

enso-bench (hanzoai/enso-bench) ──> GET api.hanzo.ai/v1/research/runs?org=hanzo
    ──> lib/api/research (one read a page load) ──> hooks/useEnso: derive() in lib/enso
        ──> components/benchmarks/Ladder (a board)   components/enso/Lead (Lead, Fact)
            components/enso/Chart (/enso's GPQA chart)   components/home/{Frontier,Board} (the landing's board)
            components/benchmarks/board (EnsoTitle, EnsoStat, Frontier, the hero tile)
  • Every Enso figure is read when a page opens (owner, 2026-10-15), from the runs enso.<bench>-<split>-<tag>-<system>: /enso (lead, note, both boards), /benchmarks/gpqa (the board and the ceiling), /models/enso (both boards), /benchmarks (the Enso section's title, figure, frontier and GPQA board, and the hero tile), the /models family card and the home Advantage tile. Each draws Ready/Inline: reading, the figures, or "api.hanzo.ai is unreachable" and nothing in their place. No Enso score is in the export. lib/data/leaderboard.json and lib/leaderboard.ts are gone: its Enso rows (98.0 / 96.0 / 92.9 for Ultra / Pro / Flash, and 194 / 190 / 184 of 198) were no run's, and 194/198 is the pool's ORACLE, which was published as a router tier and retracted. GPQA Diamond now leads with Enso routed (below). Its third-party rows (vendor-reported scores and output prices) went with it, and with them the accuracy-at-cost scatter, the reported-versus-measured browser, the shelf's Enso entries and the /benchmarks/livecodebench-v6 and /benchmarks/charxiv-reasoning pages they generated.
  • A board (lib/enso board) is one row a system: its frontier run, else its v1 run, else its fill run (the 2026-10 arms: Opus 5.5, Sonnet 5.5, Haiku 5.5, Fable 5.1). The three put the same prompts to a model (enso-bench harness/bench.py, unchanged since 2026-07-18; a model's prompt tokens are equal across them); v2 and max put others and sit on no board. The run is on the benchmark's main split, over the whole set, none whose notes say Degraded and none the brand policy keeps off a page (lib/brand). A row is the run: its system, accuracy (all), count, cost_usd, tag, date, notes, and for a router the arms its notes count (Routed arm per item). Selected by benchmark, split, tag and system, never by version. Two boards: GPQA Diamond (test, 198) and LiveCodeBench (v6-recent-175). Opus 4.8 has no LiveCodeBench frontier run, so its v1 run is read: 72.0% over all 175 (126; the 72.8% in its notes is over the 173 it answered), $39.43. A page draws published(b): every single model, and each Enso row only while it outscores every one of them (lib/tally topped), so enso-flash (both boards) and enso-ultra on LiveCodeBench are not drawn, and a board where none of ours leads is not drawn at all.
  • The lines (lib/enso lines, /enso's lead): the router (enso) against Opus 4.8 on each board, said only while Enso answers at least as many for less: "Enso 92.9% GPQA Diamond, at $2.20 — 5× less than Opus 4.8 (86.4%, $10.97)" and "LiveCodeBench 93.7% at $3.06 — 12.9× less than Opus 4.8 (72.0%, $39.43)" on 2026-07-19's runs; every percentage and multiple is computed from the runs. ", above the best single model" is added only while the router's count beats the pool oracle's best_single_accuracy (level); level is not a win and is not said. caveat is the note under them: the measured enso is enso-bench's router at the tag its run names, not today's catalog preset, and it names the arms the notes count (GPQA: gpt-5.6-sol 190, opus-4.8 5, fable-5 3; LiveCodeBench: all 175 to gpt-5.6-sol).
  • No ceiling is said (owner, 2026-10-16: no 96/98 without its label and no routing mechanism in copy). The pool-oracle@K<n> runs are read only for level, and lines says ", above the best single model" only while the router answers more (level is not a win and is not said); the Ladder's "Pool ceiling: with 4 arms some model answers 98.0%" note is gone from /benchmarks/gpqa, /benchmarks and /models/enso, and Enso carries no ceiling. The one 98.0 on the site is Enso Ultra on questions it has seen, with that set beside it (below).
  • Enso routed (lib/enso routing; owner, 2026-10-16). GPQA Diamond leads on the landing and /enso with two question sets, each named in plain words and nothing else (NEW, SEEN): "Historical multi-model research evaluation · new questions" (split heldout, the 159 questions outside the lockbox, each answered by a cascade tuned on the other folds) and "Historical multi-model research evaluation · questions it has seen" (tag seen… on the main split). The words before the set are the owner's of 2026-10-17, which replaced "Enso routed" on every page; the set after them stays, because the seen figures are a lookup over questions the cascade was tuned on. Never "tuned", "held out", "replay" or what is routed to; a seen figure never stands without its set (e2e/gates/ladder.spec.ts unlabelled).
    • New questions: the tiers more accurate than best-single (the strongest whole single-model run re-scored on the same 159), best first, each with usd_per_1k; best-single beside them as the comparator; the cheapest tier that is not more accurate, while it costs less, said by its cost alone ("the low-cost option"), never as a bar against the best single model. No routing (nothing drawn) while no tier leads. Each system's newest run by ended.
    • Filed 2026-10-16 from hanzo-inc/engine ec4d929 (policy/enso.policy.json, engine 1.6.6): enso.gpqa_diamond-heldout-1.6.6-{enso-ultra,enso,enso-flash} 152, 149, 144 of 159 at $14.76, $10.03, $3.26 per 1k, and -best-single 148 of 159 (gpt-5.6-sol, enso-bench's max run on those questions) at $5.16 per 1k: its recorded tokens at registry/prices.json rates, the basis every tier's cost uses. Not the policy's best_single, 146 of 159 at $11.77, which is the arm the training folds pick (grok-4.5 in one fold). The lockbox, 39 sealed questions, is filed as split lockbox (37, 36, 37) and not drawn. The lockbox figures reproduce from the policy's decisions and enso-bench's gold; the per-fold decisions behind the 159 are not in the file.
    • Questions it has seen: enso.gpqa_diamond-test-seen-1.6.6-{enso-ultra,enso,enso-flash}, 194, 191 and 189 of 198: each question right when an arm of the tier's deployed cascade answered it, scored from enso-bench's registry.matrix_full on evo, a blank counting wrong (whose per-arm correct counts equal the policy's). Flash's (deepseek-v4-pro, haiku-5.5, gpt-5.6-sol) was filed 2026-10-17 by the same replay, which gives the other two back exactly; no model was called. The policy's own per-question decisions at ec4d929, scored against gold, are 190, 187 and 182; no page states them. The 92.9% (184/198) the deleted leaderboard.json typed as Enso Flash is no Flash run's: 184/198 is the July router's test-frontier-enso, July Ultra's and the pool's best single arm. The deployed engine at ec4d929 has no such lookup in front of its cascades and serves no policy in production, so these are what the lookup would score, not what live Enso serves today. The policy's own ceiling, 196/198, is the pool's.
    • The tier the policy calls enso is named Enso on both sets (the price list bills enso and enso-pro as two SKUs). Runs' notes are "Enso routed; new questions" and "Enso routed; questions it has seen"; the earlier enso.gpqa_diamond-test-tuned-enso-ultra (a 2026-07 route table's replay, 147 of its picks outside the deployed pool) is retracted.
  • /enso's lead (lib/enso lines): Enso routed (said), then LiveCodeBench's list-price line while its runs hold it; the router's GPQA line, with its cost only while the runs hold one, is said only without a routing.
  • The GPQA chart (components/enso/Chart, lib/enso field; owner, 2026-10-17: "restore old chart with 92.9 96 98", "show single model comparison from all major manufacturers so the pareto frontier is clear") is the accuracy-at-cost scatter d0199249 removed, on /enso and the landing's section#pareto. Over the GPQA board's whole set (198), none degraded:
    • each single model at its best priced run (the most accurate, then the cheaper; any tag, max and v2 included), at the cost_usd it filed per question times 1,000, wearing its lab's mark (the price-list id sku maps its system to);
    • each tier's newest seen run, at its newest held-out run's usd_per_1k (the seen runs state no cost; same router, same table);
    • each published score in lib/data/gpqa-reported.json (price-list id, score as published, setting, kind lab or independent, publisher, source, model release date) whose model no priced run measured: at its list rates times the median input and output tokens a question took in the filed runs of its lab's models, or of every model when its lab has none. A dashed disc linking its source. A priced run of the same model, mapped by sku, takes its place with no code change (e2e/unit/field.spec.ts). Read 2026-10-17: Kimi K3 is Moonshot's own figure; GPT-6 Sol, GPT-6 Luna, GLM 5.3, Gemini 4 Argon and Grok 4.7 are Epoch AI's, none of their labs publishing one. ONE PRICE TABLE PER POINT KIND: cost_usd and usd_per_1k are both enso-bench's registry/prices.json; a reported point is the list's. No point under 70% is drawn (LEAST; owner, 2026-10-17: GPT-4o's 54.5% stretched the axis to 50), and the accuracy axis starts at the five under the lowest point. The frontier is lib/tally frontier over the drawn points: computed, said in the alt text ("The frontier … is Enso Flash, …") and each point's data-front, and not drawn (owner, 2026-10-17: "remove the line between dots"); a tier off it is not drawn, and the chart needs one on it beside three points that are not ours. The title (lib/enso claim) is "Highest score." while a tier is more accurate than every other point, else "Accuracy against cost." No legend and no caption (owner, 2026-10-17): what a point is (single model on the same 198, a tier on questions it has seen at its cost on new ones, a reported score and its source, estimated from list price) is in its tooltip and the SVG's alt text; each tier's tooltip ends its figure with "· questions it has seen", which unlabelled reads. ONE PLOT MEASURED TO ITS CARD (owner: "not so tall"): a ResizeObserver reads the card's width and the plot is laid out at it, one user unit a CSS px, so it fills the card. Wide, it is 2.5 times as wide as tall, 380 to 460px (430 at 1440), its cost axis a quarter past the cheapest and dearest points with the one-three ladder's ticks between; under 600px it is 1.3 times as tall as wide (382 at 360, 421 at 390) with a tick a decade. Type 12px and discs r 11 with 15px marks (ours r 13, halo 17) from a 1,000px card; under it 11px and discs r 9 (phone r 8). A glyph advances 0.6em or the whole pixel the browser snaps it to (11px draws 7px), whichever is wider. Labels (settle) name a model short (short: no snapshot stamp, no "Claude") and take the nearest seat clear of every disc and label: beside the dot, where it must be nearer that dot than any other and not level with another dot's row or column, then up to two lines off with a leader, beside no other dot; a model's drops its figure (kept in the tooltip) before it moves, and with no seat within two lines is left to its tooltip rather than drawn far away. Ours and the frontier's are always drawn, ours with name and figure. Six seating orders are tried and the layout that drops fewest labels, then moves ours, then the frontier's, least is kept. ladder.spec.ts measures every label box at 360, 390, 768, 1024, 1440 and 1920, light and dark: none on another or on a disc, each beside its own dot, 11px or more, no line drawn, the plot as wide as its card and no taller than 460px wide or 1.35 times its width on a phone. Under the chart on /enso, components/enso/Routed tables both sets with no caption, the new set named in its column head and each seen figure followed by its set, each row linking its run.
  • /enso#routing "Route it your way.": static (pin a model to a route or a tier), policy (cost, latency, capability, provider, data residency), trained (a router on your own usage and evals, the way Enso is trained), through "model": "enso", "consensus": true, fixed policies as console overrides (owner, 2026-10-17). It names no pool member (enso.spec.ts).
  • The frontier explorer (/benchmarks#frontier) is accuracy against what the run cost (log axis), one board at a time, every point a run; a router point some other run beats on both is left off, and a board is drawn only while a router point is on its frontier and it holds six priced runs.
  • Presets carry no score. PRESETS (lib/enso) is the catalog's Ultra / Pro / Flash: name, id, blurb, the rate the price list's (components/api/rate). No run is of a preset.
  • Not drawn: enso.hle and enso.charxiv (v1 and v2 only; the router trails single models on both — fable-5 38.2% against 29.4% on HLE's 500, gpt-5.5 85.5% against 78.0% on CharXiv's 200 — and the pages publish wins), enso.swebench_pro (a two-task pilot), enso.routing_latency (in process on dgx and evo; its notes say none of those paths serves production).
  • e2e/gates/ladder.spec.ts serves invented runs (e2e/gates/field.ts) and holds every surface above to derive over them, the boards to published, no pool, arm or ceiling on a board, and the unreachable state; leaderboard.spec.ts drives the explorer and wins.spec.ts holds its points to the frontier, both over the same runs. deck/ (the investor deck, not shipped) still types 98.0 / 96.0.

/ is the landing: components/home

app/page.tsx → components/home/Home, at the app root inside Apex. Owner, 2026-10-16: a typesafe.ai-grade animated page in Hanzo's monochrome, no rival decision model named, sign-up in one tap, the products, the research laid out plainly, and commands a reader copies; read at /home2, approved, and moved to / the same day (/home2 is MOVED to /, rendered into universe's hanzo-ai-to-root rule).

Start      the sign-up and the Hanzo.Start window (above, "The first screen")
Race       Coding.Tasks: coding.repair runs (lib/coding, useCoding: the build snapshot, then the live answer, which
           also takes it down): each side's median_s replayed as a clock and a bar, the paired speedup and
           interval, and the paths Hanzo Dev settled a task by that are resolved gains (lib/coding `gains`);
           both solved counts only while Hanzo Dev solved more (`ahead`). /benchmarks#coding-agent draws the same
Frontier   GPQA Diamond led by Enso routed (lib/home `gpqa`): the best tier on new questions set large against
           the best single model, the low-cost tier by its cost, and the board in two groups, new questions with
           what 1,000 cost and questions it has seen with that set beside each figure; without a routing, the
           router's board (lib/home `bars`, each row wearing its lab's mark) and lib/enso's list-price line
Pareto     Enso.Frontier: the GPQA chart (above, lib/enso `field`) under its computed title (`claim`) and one-line
           lede; not drawn while no tier is on its frontier
Speed      on a CPU only: the cost row's cached in-process p50 (lib/kai `faster`), the served loopback run's
           same-input and new-input p50s, one hosted decision call; + Zen 6 prefill from components/zen/desks, a
           field of dots filled in one second
Products   Chat, Dev, Kai, Enso, CLI/SDK/MCP, one line and one action each
Research   the tasks Kai wins as cells and the suite's claim figure (useClaim, only while whole; no task Jev leads) +
           LiveCodeBench's board as bars; drawn while either holds
Plans      "Build what's next": components/pricing/Plans, the cards /pricing sells from (GET /v1/billing/plans),
           every card's way in Try Hanzo with "No commitment · Cancel anytime", Max one card with a pill of
           its tiers (5x | 20x, read from the plan list); footer upgrade prompts and links omitted on landing
Questions  components/home/Questions: what Hanzo is, Claude Code, where the numbers come from, what it costs,
           your own cloud; no answer names a plan or a price

Every derivation is lib/home.ts; the components type no figure. No link on the page opens a research run's record: a router run's version and notes name the arms that answered it. Motion moves marks, never copy: a mark is a paused Web Animation from its first frame, plays when its section is seen (useSeen), and stands finished under reduced motion (useStill, a useSyncExternalStore over the media query); the dot field is @hanzo/ui/dots painted once and drifted as a composited transform while on screen; the glider and the prefill field run only while on screen.

/enso opens the way / does (owner, 2026-10-17: "the dot matrix cool animation in hero on landing page"), on the owner's words of the same day: "HANZO / ENSO", "The right intelligence. Every time.", the line on learning the best model and switching when a provider fails, and a "Consensus mode" block (Ultra's panel, which verifies before it picks: hanzo/enso-svc catalog.yaml). The same field, chrome's Opening (the first screen's clouds at 0.34, one implementation for both pages), the same Label kicker, Display at its own rungs (32 / 40 / 52) and the landing's measure, pads and gap. The promise is centred in its column; the columns split at 1280, where each of its lines holds at 52px, and narrower the figure sits under the copy as /'s window does. /enso fetches the field after it is interactive (components/enso/Opening, next/dynamic with ssr: false, which only a client module may say): it is decoration and absolutely placed, so nothing moves when it lands and the page's first load carries none of it. Label lives in components/home/label.tsx with no client directive, so a server component renders it without chrome's chunk; it names gui through @/components/ui, because the root @hanzo/ui barrel from a server module ships its ~150 client modules (measured: /enso's first load 434 KB brotli this way, 679 KB with label.tsx naming @hanzo/ui; weight.spec.ts holds the rule). e2e/gates/home.spec.ts gates /: it serves invented plans, prices, research and coding runs and holds every figure to values written in the spec by hand at 390, 1440 and 1920, with no sideways scroll, no text past the edge, a quiet console, no jev/typesafe in text or labels, no run link, the API-down state, a retracted race, the replay, the plans (Try Hanzo on each card, the Max pill moving the price, the line and the checkout), the FAQ, a window dragged, nudged and folded at 1440 and still at 390, the canonical, the sitemap and /home2's move.

The GPQA and LiveCodeBench cost_usd are enso-bench's own price table, not the price list. Each single-model run's cost is its token counts at a fixed rate: opus-4.8 at $15/$75 per M (78,121 × 15 + 130,593 × 75 = $10.9663, the run's figure to the cent), gpt-5.6-sol and gpt-5.5 at $1.25/$10, fable-5 and claude-5-sonnet at $3/$15. /v1/pricing lists opus-4.8 at $5/$25, gpt-5.6-sol and gpt-5.5 at $5/$30, fable-5 at $10/$50. At list rates the Opus 4.8 GPQA run is $3.66 and the "5× less" multiple does not hold; the router's $2.20 is mostly gpt-5.6-sol calls at $1.25/$10. As filed, gpt-5.6-sol answers more per dollar than the router (91.9% for $2.00 against 92.9% for $2.20); at list rates Opus 4.8 does ($3.66 against $4.06 for the router's tokens at enso's $4/$20). So the landing divides no two costs but the one list-price line lib/enso states (tile); re-cost the runs at the list (enso-bench) before a multiple leads any page, and before the "5× less" on /, /benchmarks and /enso is read as a list-price claim. /benchmarks' Ladder and /enso's Lead still print the router's arms by name.

Sales and the business sign-up: two doors, two hosts

/contact-sales is the one sales page (/contact/sales forwards to it). Step 1 is the work email, step 2 name, company, size, role, need and country. Submit is POST api.hanzo.ai/v1/marketing/leads (cloud apps/marketing/leads.go), anonymous: it files a crm.lead in the deployment's brand org, answers {lead: "lead_<24 hex>"}, and states generate_lead on the event plane server-side — the browser's anonymous lane drops named events, so the page never sends that one itself. The page then calls track('generate_lead') for GA4/Meta and sends the visitor to INTRO (cal.com/hanzo/30min; BOOKING is the cal.com/hanzo profile) with name, email and notes (answers plus the lead reference) in the query; the Booker (@hanzo/booker 8.3.6 prefill) starts filled in. A lead that cannot be filed (503 until the brand org installs the CRM) still reaches the calendar and says so. The mailbox the page shows, [email protected], is the one sales address (cta.spec.ts). Logos are the customers' own marks from lib/data/customers.ts, under "Products Hanzo built and launched" — never "trusted by" (see components/home/Proof.tsx). e2e/gates/sales.spec.ts runs both steps at 1440 and 390.

The business sign-up lives on hanzo.team, at /start (START in lib/host.ts). The team's session is that origin's, and /start already names the team, invites, and sends the cart plan=team&org=<org>; a sign-up here would sign the buyer into the wrong origin and build a second setup. Every "Get Team" on this site links there; hanzo.team's own gate is e2e/gates/signup.spec.ts in hanzo-apps/team.

/enterprise sells Enterprise in one ask ("Talk to sales" at the top and the end, both /contact-sales). Between them: what the plan includes and where it runs (the enterprise row), the SAVINGS calculator, your own GPUs, the proof, partners, security and questions.

  • Savings (components/sales/Savings, arithmetic in lib/savings; also on /calculator): the tokens a team uses a month, by model (the price list's featured lab models) or by spend and output share, priced three ways — at the providers' list rates as the price list carries them, on Hanzo Cloud moved to an Enso SKU plus the members on Team at the plan list's member month, and on the reader's GPUs on all month (730 hours) at the price list's on-demand GPU hour or the reader's own. The Enterprise agreement is quoted, so no total includes it and the GPU figure says so; it also says where those GPUs meet list and that sizing is done with the customer. Each figure says what it assumes. The page opens with an example and says so. "Keeping the same models through Hanzo" prices them at /v1/models' billed rates (passthrough included), which is the honest answer to "is a provider's model cheaper here".
  • Your own GPUs: Zen 6's prefill on one DGX Spark (lib/data/zen6-spark.json, scripts/zen6.py) and the Zen 6 Max rack (components/enterprise/Rack, NVIDIA's own figures with their pages, read 2026-10-12).
  • Proof: the research runs and six of the data room's studies.
  • Partners (components/sales/Partners): the programmes that exist — affiliates (/affiliate), White-label and the agency's plans as the plan list sells them — and the clouds Hanzo deploys on, captioned as vendors, not partners or customers. No company is named a partner: none is on record. /partners therefore stays MOVED to /affiliate, the programme that pays a partner; a partners page waits for partners who agree to be named.

e2e/gates/enterprise.spec.ts holds the one ask, the four places to run, the proof, the calculator against hand-written sums of the served rates, its unreachable state and the partners.

The chrome: one tree, drawn three ways

components/home/nav-data.ts holds the IA — COMPANY, MENUS, LOGIN, TRY, LEGAL — shaped after openai.com's sitemap, and components/home/shell.tsx draws it as the bar and the phone sheet. The footer is FOOT, four doors into that tree, so the rest is found on the page a door opens. Change the IA there and the bar and the sheet follow; e2e/gates/chrome.spec.ts holds each drawing to what it names and every row to a page the export carries (never a forward).

Hanzo AI ▾  Research ▾  Products ▾  Solutions ▾  Developers ▾  Learn ▾  ⌕    Log in ▾  [Try Hanzo]
  • The wordmark is the company menu. The name is a link home; the chevron beside it is its own control (Company) and opens Explore Company (About Us, Agency, Careers, News → /press) beside Resources (Brand Guidelines, Philosophy, Investors, Contact). Public Policy has no page, so no row. Customer stories live under Solutions and the footer's Company column.
  • Five menus, each with the shell's mark before its label (pulse, blocks, puzzle, code, book — MARKS from @hanzogui/shell, 16px, the size the old shell bar used). A lead column sets its rows in display type. Research leads with Releases (Enso 1, Kai 1, Zen). Products splits into AI Models, API Platform, Apps and tools, and Install. A destination has one row per menu: SDKs is Developers', the console is Log in's, Benchmarks is Research's. Solutions leads with All solutions, Enterprise, Customer stories, Pricing and Contact sales, then Teams, Industries and Use cases read from lib/data/solutions.ts, then Programs. Products ends on Hanzo OS: All products and the seven layers from lib/layers.ts, each of which links its products, so every product page is two clicks from the bar, and from the product index the footer opens.
  • Search closes the row and opens @hanzogui/shell's ⌘K palette.
  • Log in ▾ offers Hanzo App (/dev on this origin) and API Platform (platform.hanzo.ai); Try Hanzo is SIGNUP (/signup), the fold's Get started. A signed-in reader sees the shell's account menu and OPEN (Open Hanzo, /dev) instead; its menu's one row is Settings (/settings). This origin's own sign-in is /login, which the rooms send a stranger to.
  • Below 1024px the bar is the name, search and a menu button; the sheet is full-screen, one <details> per menu (one open at a time), then Log in and Try Hanzo, with the page under it held still. From 1024 to 1279 (gui's $lg to $xl) the marks drop so the labels fit.
  • The footer is four columns — Product, Research, Developers, Company — and a line for Terms, Privacy, Cookies, Privacy choices, and Legal. It does not repeat the menus, but every page the sitemap offers must be linked from some page's HTML (e2e/gates/sitemap.spec.ts), and a menu's rows are not in the HTML until it opens: /learn and /cookies had only the old footer, so Developers carries Learn and the line carries Cookies.
  • Opening: hover rests 70ms, leaving keeps it 160ms; a click pins it; ↓ steps in; Escape, a press outside or focus leaving closes it and hands focus back. The open bar and the plane share the opaque --chrome-panel ground, and the bar drops its backdrop filter while open — a filtered element is a backdrop root, and the veil inside it would blur the bar instead of the page.
  • The bar is glass on every page, at rest and scrolled ($background/72 under blur(20px) saturate(1.8); owner, 2026-10-22: "translucent not black bg"). It stands in the page's flow, so a hero whose ground is a picture or a light used to start BELOW it, and over the black page ground the bar read as a black band above the art (the creative pages' paintings, the kit's glow, the lit product heroes). Such a first screen spreads under(pt) (components/shell/dock.ts): mt: -ROOF and its own top padding plus ROOF, so its ground starts at the top of the window, behind the glass, and every word stays put. It is on Splash (the six creative models and Zen), PageHero (the kit pages), /edge, /identity, /functions, /playground, /operative, /realtime and /enso's dot field. Use it only on the section directly under the bar: anything before it would be overlapped. e2e/gates/glass.spec.ts holds the glass and those first screens at 0. The focused bar (sign-in, onboarding, a beta application) is @hanzo/app' Chrome under the focused pages' opaque glow (components/shell/focus.tsx BAR); on /beta the site's bar at rest keeps its glass and paints the same fixed glow on it, so the light is unbroken where the bar ends and a card scrolling under is dimmed and frosted.
  • The beta strip on / is polished blade steel ([data-slot="beta"] in the root layout's style string, components/home/beta.tsx): the spine with a faint top line, a ridge 5px down, the flat dark under the words, a pale temper line and the bright 1px edge along the BOTTOM; one / light at 70° from level in ::after, resting mid-strip, moved by transform from --tilt (set on the strip itself, never on <html>: a root custom property restyles the whole document every frame), brighter where it crosses the ridge and the edge (a vertical mask) than under the words. No repeating gradient: a 1px/3px grain read as stripes. White words hold 4.5:1 at the brightest steel under them, measured with the light anywhere (e2e/gates/steel.spec.ts).
  • No stylesheet rule draws any of it. The bar, the planes, the log-in card and the sheet are gui elements whose props are their whole style: hoverStyle and focusVisibleStyle for the lit pill and the rows, $lg/$xl for the phone bar and the marks, enterStyle for the 4px arrival, and display="grid" with gui's grid props for the company chevron, the menu button, the plane's columns, the log-in card and the sheet's doors. The shell's account menu paints with var(--white-92…); the bar carries the four rungs as light-dark() on its own style, so they flip to black only where the page is light. A breakpoint object is stated inline per element — never inside a spread preset — because a spread replaces it.

The shell's HanzoHeader has no company slot, no search in the row and no log-in menu, so the layout is drawn here from the shell's parts (marks, wordmark, palette, account menu); hanzo-design.allow.json records why shell-owned-chrome stays zero.

Zen's one page is /zen, beside /enso and /kai. /models/zen and /zen-models are in MOVED and forward there. Its layout carries the go-import tag, because hanzo.ai/zen is also a Go module path; so does /zen/models, the full Zen catalog.

Language is @hanzo/i18n's, and Arabic runs right to left

Language in app/providers.tsx mounts I18nProvider inside IamProvider: the person is the stored token's subject (subject()), and signed in the choice is kept with IAM (account: { base: api(), token }, the locale key alone), never in a support session. The export is English. @hanzo/i18n/boot's bootScript() is the head's first script (app/layout.tsx), so <html lang dir> is the reader's before the first paint; the words follow once the language's catalog chunk loads after hydration. @hanzo/i18n/rtl.css (in the layout's sheets) keeps code, URLs, email fields and [data-i18n-ltr] left to right and turns a [data-i18n-mirror] mark.

Every word this site writes on the landing, the bar and its menus, the phone sheet, the footer, the corner and sign-in is t()'s (@hanzo/i18n ≥ 0.5.3, whose catalogs carry every one of them in all 45 offered locales; the 33 review locales are partial until the next run), and the app is @hanzo/app (github.com/hanzoai/app), which does the same. The rules:

  • One sentence is one t(), its figures and names arguments: t('{speed} faster on the same coding tasks.', { speed }). A link or a styled run inside a sentence is an element argument and t returns a node: t("By continuing, you agree to Hanzo's {terms} and {privacy}.", { terms: <Link…/> }). Never a template string, never 'a' + b, never a sentence split across JSX.
  • The first argument is a literal. hanzoai/i18n's extractor reads t(…) and phrase(…) from the syntax tree into the shared English catalog (tools/extract --catalogs … --prune, run over this repo, @hanzo/app and the package together). Copy written as data — nav-data's labels, solutions' and layers' names, a plan word this site writes — is phrase('Pricing') where it is written and t(item.label) where it is drawn. A string the catalogs do not hold yet renders English until the next catalog release.
  • Not t()'s: user content, model output, names (people, orgs, customers, products standing alone: Hanzo Dev, Enso 1, Koma), code and commands, URLs, model ids, and what the API sends (plan names, descriptions and features, research figures, repository descriptions). Blog titles in Learn are the titles of English articles and stay as written. Dotted window names (Hanzo.Start, Coding.Tasks) are names. SETUP and a chip's prompt are instructions for an agent and stay English.
  • Figures read in order. In a right-to-left locale every t() argument is isolated by the package (Unicode isolate, <bdi> around an element). A figure drawn outside a sentence — a clock, "~43 tok/s", "1,150–1,400", a price — carries data-i18n-ltr on its inline element, or is a <bdi dir="ltr">.
  • A side is a logical gui prop: marginInlineStart, paddingInlineEnd, insetInlineEnd, borderStartEndRadius, textAlign="start"; a bar that grows from the start takes its transformOrigin from useI18n().dir. gui's shorthands (ml, pr, l, r) are physical. A logical prop on a component that states its own physical side races it in the cascade (both are one class of equal weight), so there the two sides trade by dir: @hanzo/ui's Input gutters in the settings search, the copy prompt's two joined halves. The layout's own rules say sides logically too (border-start-end-radius on [data-join]).
  • An arrow along the line wears data-i18n-mirror; a "words →" link is <Onward> (components/home/label.tsx), which keeps the arrow out of the sentence and turns it.
  • A gate or a stylesheet never selects by an English label: in another language it names nothing. The bar's hooks are [data-slot="home"], nav[data-slot="menus"], button[data-slot="search"]; the corner's [data-slot="corner-trigger"]; the plan tabs [data-audience].
  • Where a language is chosen: Settings → General draws the package's LanguagePicker; lib/language.ts useLanguage() is the corner's language row (see "Help and preferences are in the corner"). Both offer every locale but those waiting for review, and the reader's own whatever it is (offered), each named in its own script with its English name as the searchable hint.
  • Weight: the provider is ~3.3 KB brotli on every page; each catalog is its own chunk, fetched when chosen.
  • Not yet in the reader's language, and not this repo's: words drawn inside other packages with no words prop — @hanzo/ui's marketing Footer ("Terms & Policies", and its legal line pushed with ml: 'auto'), @hanzogui/shell's account menu (Sign out, Account, Billing) and the ⌘K palette's page titles, @hanzo/ui/consent's banner, @hanzo/build's model, effort and + rows, @hanzo/ui/chat's Send and Stop. The API's plan and model text is the API's. /settings (this site's account settings) and the marketing pages beyond the landing are not converted.
  • Gates: e2e/gates/translated.spec.ts (Arabic and French: the landing at 1440 and 390, every menu open, the phone sheet, sign-in, sign-up and the app signed in hold no authored English; Arabic is rtl, no sideways scroll, every range isolated), e2e/gates/rtl.spec.ts (the bar, a menu, Log in's card, the footer, the sheet and the settings column, English and Arabic, 1440 and 390), e2e/unit/language.spec.ts.

Help and preferences are in the corner

@hanzo/ui's Corner (@hanzo/ui/corner, ui 8.29.10) is mounted once, in app/providers.tsx after the page, through components/shell/help.tsx: a ? at the window's bottom START corner (bottom-right under dir="rtl") that opens one card, its script fetched once it is drawn (next/dynamic), so no page carries it in its first load. Help is Get help (/support), Docs (docs.hanzo.ai, a new tab) and Contact us (/contact); Preferences are Theme (the package's own, on @hanzo/appearance) and Language (lib/language.ts). No Currency row: the site bills in US dollars only. The card's words go through t().

  • Not in the app. The rooms (owns, components/shell/mode.ts) and the app /, /dev and /studio draw for a member (APP on <html>) keep the account at the rail's foot, bottom-left, and their Settings is where preferences live there, so the corner is not drawn. Every other page has it: marketing pages, the focused pages (/login, /signup, /pay) and a member reading /pricing.
  • The cookie question comes first: while the banner asks (@hanzo/event asks) the corner is not drawn, so the two never share the foot.
  • A band the page fixes at the foot clears it: /start's composer band pads its start side on a phone (components/home/FooterHeroComposer.tsx), and the corner is mounted after the page so it paints over that band's fade.
  • Theme writes this device's layer. Corner writes through <Hanzo>'s Look context when there is one; this site mounts its own gui provider and @hanzo/ui does not export Look, so for a member whose IAM account holds a theme, the account's wins again on the next load (rooms' <Look/>). Settings → General saves to the account.
  • Gates: e2e/gates/help.spec.ts (the Help list, a theme kept across a reload, Arabic setting lang/dir and moving the corner, no sideways scroll at 390, none in the app, none while the banner asks), e2e/unit/language.spec.ts.

/templates opens on twelve finished products

/templates draws the agent row (data-slot="agent-strip") and the templates, “Start from a product.”, each card saying “Edit in Hanzo” and linking /chat?template=<slug>.

The carousel hides the wireframe catalog shots in lib/catalog-shots.json, construct, saas-landing, and the pages that still read as a template marketplace: soar (150×150 portraits), unity (logoipsum), launch, mint, folio (“Get this template”), deploy, cipher, forge, and circle. What remains is twelve finished products. Category chips are the categories those twelve actually use. The templates are read from /v1/templates when the page opens (useTemplates). The Create screen reads ?template= after sign-in. e2e/gates/templates.spec.ts holds the twelve.

Menus: the column under the pointer is the one being read

In an open menu the other columns step back to 45%: Columns in components/home/shell.tsx holds which column the pointer is in and gives the others opacity={0.45}, eased by gui's quickest. Leaving a column restores them all, so the gap between two columns dims nothing.

The ci reusable lives under .github at the v1 tag

hanzoai/ci at tag v1 (which moves with main) carries the reusable at .github/workflows/build.yml; a branch also named v1 still has the old .hanzo/ copy, which is what every ref-by-name read (raw, contents) answers. The forge resolves uses: against the tag, so a caller naming .hanzo/workflows/build.yml@v1 is refused before a run exists (PrepareRun: … object does not exist [rel_path: .hanzo]) and no run appears at all. .hanzo/workflows/cicd.yml names the .github path. The forge blocks raw HTTP reads under /.git*, so read that file from GitHub.

hanzo.id ships from the forge repo hanzo-inc/id

The forge copy hanzoai/id is a mirror and a mirror sync creates no run; hanzo-inc/id is the regular repo the id repo's own sync workflow already names, and a push to its main runs cicd.yml. The image is declared in the id repo's hanzo.yml (images: [id]); imgver takes the next patch above what GHCR holds, and universe pins tag + digest in charts/app/values/hanzo/id.yaml.

  • High-Fidelity Customers & Case Studies Page (/customers, /case-studies):
    • Complete dark theme overhaul with @hanzo/font (Zen and Zen Mono) and high-fidelity contrast styling.
    • Metric proof strip: 169M+ Trillerfest audience reached, Techstars '17 foundation, 1M context window (Zen MoDE), and 187 ns goroutine live agent spawn latency.
    • Interactive sector filtering across Autonomous & IoT, Health & Bio, Media & Streaming, and Next-Gen Commerce.
    • Executive case study spotlights: Damon Motorcycles (Jay Giraud), SKULLY (Marcus Weller), Bellabeat (Sandro Mur), Triller (Vincent Butta), Karma (Antje Worring), Keek (Darius Mirshahzadeh) with metric badges, tags, and quotes.
    • Architecture pillars: Consensus-Native Security & KMS, Ultra-Low Latency & High Density, Universal Model Gateway & Open Tooling.
    • Partner ecosystem rows: "Backed by" (Techstars '17, Lux, Zoo Labs) and "Runs on" (Amazon AWS, DigitalOcean, Google Cloud, Microsoft Azure, NVIDIA).
    • Validated by Playwright verification gates (e2e/gates/page-kit.spec.ts).
  • hanzo.build and hanzo.app publish nothing from here. hanzo.build redirects to hanzo.ai/dev and hanzo.app serves this export's /app (above); deploy.yml publishes hanzo-ai, cloud-hanzo-ai and hanzo-bot only.
  • Full Verification & Deployment (Run 35052343339):
    • CI/CD deployment run completed green in 17m 4s across all steps: static export, publishable key verification, endpoint resolution, Playwright verification gates, multi-root generation, and 6-site parallel upload.
    • Live HTTP 200 responses verified on https://hanzo.ai/customers, https://hanzo.build, https://dev.hanzo.ai, https://hanzo.ai/chat, and https://hanzo.ai/dev.

Benchmarks & AWS S3 Live Origin Architecture

  • Retracted (2026-10-15): an earlier /benchmarks overhaul wrote a "12 frontier benchmarks" matrix and hero tiles (GPQA-Diamond 98.0%, LiveCodeBench 92.0%, HLE 34.6%, "10× cheaper vs Fable 5", …) from the leaderboard snapshot. No run holds most of them, and 98.0% is the pool's oracle; they are gone with the snapshot. Enso's figures are the research runs (see "Enso's boards and /enso's lead"). Zen branding stands: customer-facing copy names Zen6, Zen6-Coder, Zen6-Flash, never an upstream base.
  • AWS S3 Live Production Origin:
    • Cloudflare apex DNS hanzo.ai routes to the AWS NLB edge in us-east-1 (100.24.244.37). Traefik ingress terminates TLS and serves static assets via staticFiles middleware backed by AWS S3 bucket s3://hanzo-sites/hanzo/hanzo-ai/ (and s3://hanzo-sites/hanzo/cloud-hanzo-ai/).
    • Production deployments must sync exported static files from out/ directly to AWS S3 using AWS CLI profile hanzo-prod:
      aws s3 sync out/ s3://hanzo-sites/hanzo/hanzo-ai/ --profile hanzo-prod
      aws s3 sync out-cloud/ s3://hanzo-sites/hanzo/cloud-hanzo-ai/ --profile hanzo-prod
      
    • Followed by purging Cloudflare edge cache for zone bac51c3900e73fd28aa75a59898bcee0:
      curl -s -X POST "https://api.cloudflare.com/client/v4/zones/bac51c3900e73fd28aa75a59898bcee0/purge_cache" \
        -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
        -H "Content-Type: application/json" \
        --data '{"purge_everything":true}'
      

Signing in happens on this site

/login and /signup draw IAM's sign-in in this site's chrome (components/auth/panel.tsx: Gate is the page, Panel the card). IAM is the authority; nothing of a credential is kept here.

  • Order on arrival (Gate): a guest host hands off to hanzo.ai; signed in → takeDestination(); a tab that has not asked → ask(login, here) (prompt=none, lib/auth/session.ts), so a hanzo.id session signs in with nothing typed; otherwise the panel.
  • The ways in: a provider is one top-level hop (login({ additionalParams: { provider } })), never a popup, and it sets the hanzo.id session. Email → password (loginWithPassword) or an emailed code (sendLoginCode + loginWithCode); /signup proves the address with a code, then @hanzo/iam signup creates the account and signs it in. Each returns a PKCE-bound code and the browser goes to /auth/callback like every other way.
  • Google One Tap is offered on /login and /signup only (components/auth/tap.ts, Panel tap, which Gate sets and the landing's hero does not: the prompt over a hero was removed in 72ae61b7). Google's gsi/client is loaded once, and prompt() runs while the first screen shows, IAM offers Google, and nothing has been pressed; a press, a step or a leave cancel()s it. A tapped account is NOT a sign-in: only the credential's email is read, unverified, as the login_hint of the same top-level IAM hop the Google button makes, so IAM stays the one authority. Never offered to a browser driven by a script (navigator.webdriver) or one sending Do Not Track or Global Privacy Control. The client is IAM's provider-google (113591532635-…, the id in IAM's authorize redirect; public). Google shows it only on origins in that client's Authorized JavaScript origins (Google Cloud → Credentials); an origin missing there logs "The given origin is not allowed for the given client ID" and nothing appears. Measured 2026-10-22: cloud.hanzo.ai and hanzo.id allowed, hanzo.ai and www.hanzo.ai not. Gate: e2e/gates/tap.spec.ts.
  • Transport: IAM's credential routes (/v1/iam/login, /signup, /verification-codes) answer only identity origins cross-origin, at the cloud (hanzo-inc/cloud sessionRoutes) and at the Cloudflare edge (universe infra/cf-zones/cors.py), by design. So the panel calls THIS origin (proxyBaseUrl), and universe static-sites iam-on-hanzo-ai answers those three paths on hanzo.ai/www with the cloud, Host rewritten to api.hanzo.ai, with every Set-Cookie dropped (hanzo-ai-iam): the page gets a code and no issuer session, so no script here can mint codes from one. The dev server's /v1 proxy does the same locally. A host that does not answer them (cloud.hanzo.ai, hanzo.bot) types the credential on the issuer's page with login_hint.
  • The one sign-in page for Hanzo. Every Hanzo site on another origin (platform.hanzo.ai, console.hanzo.ai, studio.hanzo.ai, …) sends a signed-out person to /login (or /signup) with its OWN authorize request as the query, and this page finishes it (foreign → @hanzo/iam asked/ask, ≥ 0.21.32): signed in here, "Continue as" names the site by its callback's host (lib/auth/asker.ts) and leaves for it; otherwise the panel, whose every way in mints that site's code. While asking, @hanzo/iam lists the methods, sends the code and signs in for THAT client in ITS org (/v1/iam/auth/application), so an admin-console request finds an operator in admin; a provider hop carries the request's nonce, and its challenge when it sent one (query). A native app's callback (hanzo://, loopback) still gets "An app on this device" and the Handed sheet. A Hanzo site on the web (ours: an https callback on hanzo.ai, *.hanzo.ai, a visits host or hanzo.team) is answered from the session here on arrival, with no sheet, so a person signed in on hanzo.ai passes straight into platform, studio or admin (owner's direction, 2026-10-16): its code reaches only the callback IAM registered for that client, bound to the state and PKCE challenge that site's own page holds. A native app or another site still gets the sheet, and a session IAM will not spend for that app draws the panel.
  • Sign-in is email first, the link by default (owner's direction, 2026-10-21; components/auth/panel.tsx, lib/auth/link.ts). The first screen is the providers (Apple, Google, GitHub, each only as IAM offers it) and the address alone ("Continue with email"); IAM is asked once whether an account holds it (identify). No password is the default anywhere.
    • A known address (or one IAM says nothing of on /login) is sent its link and code at once, on every page that draws the panel, password or not (owner, 2026-10-22): never "Enter your password" first. The next page waits for them: the mailbox, "To continue, click the link sent to" over the address, then Enter verification code, Resend email, Use a different email, and "Enter password instead" when the account has a password. With no sign-in in the tab LINGER (5 s) after the link went out, the link was opened on another device and the page turns to the code form on its own: the address again, "If the link shows a verification code instead of signing you in, enter it here." (italicized, smaller font), "Not seeing the email in your inbox? Try sending again. Wrong email? Change email address", the code and a full-width "Verify email address" (gated on a fake clock, login.spec). Its links are the page's ink, underlined. A send that fails leaves the password page, said why. A code that finds no account (SignupRequired) brings the terms ("No account for this email yet") and is made with no password.
    • A new address: /login sends it to /signup typed for it. /signup sends the link at once, no password asked, and the account is made once the link or its code proves the address: after the terms with no password (createAccountWithCode), or, where "Set a password" was pressed while the link waited, with that password, typed twice beside the terms (signup).
    • The link is IAM's (hanzoai/iam otp IssueLink): the app's registered redirect URI on the request's Origin, /auth/callback#code=…&email=…. The callback hands the code to the tab waiting on it (BroadcastChannel signin.link), which finishes the sign-in, and follows it in once the token lands. With no tab waiting, the browser that asked (localStorage mark, ten minutes) signs in where the link opened. Anywhere else it shows the code to type where the sign-in began, and spends nothing until pressed, so a mail scanner opening the link signs nobody in. e2e/gates/link.spec.ts drives all three. The one address is /auth/callback; there is no second route for the link.
    • The email is IAM's design (internal/otp/signin.html, pinned whole in testdata/signin.html): "Your Hanzo sign-in link", the code in the preheader, a Sign in button, the code in mono, HTML with its plain-text alternative, light and dark. Its mark is public/email/mark.png (https://hanzo.ai/email/mark.png, IAM's <site>/email/mark.png), a 132px PNG derived from @hanzo/brand by scripts/email-mark.mjs; the link gate holds it served. The code field says autocomplete="one-time-code" and inputmode="numeric" (Code, components/auth/steps.tsx), which is what lets Safari fill a code from Mail; WebOTP's @hanzo.ai #123456 line is read from an SMS only, so the email carries none.
    • An app that makes no accounts (admin-console) asks the password first, the link a press away: the admin org has no email provider, so a SuperAdmin's code cannot be sent. An app that sends no code asks for the password, and on /signup makes the account from it and the terms.
  • This export's other hosts sign in here too. cloud.hanzo.ai, hanzo.bot and bot.hanzo.ai (@hanzo/app visits) keep a session of their own but draw no sign-in: /login and /signup there, the panel's Continue and its provider buttons leave for hanzo.ai/login (or /signup) with the host's own request (lib/auth/signin.ts useLeave), and the code comes back to that host's /auth/callback. Not from /login?from=logout: this page answers those hosts from its session, so leaving there would sign the person straight back in. hanzo.app and hanzo.chat sign nobody in and hand off as before.
  • A second factor finishes on the issuer's page (SecondFactor from @hanzo/iam ≥ 0.21.20): IAM holds that ceremony in an issuer cookie.
  • Consequence: an email sign-in here does not start a hanzo.id session, so other apps' silent sign-in does not see it; a provider sign-in does.
  • ?plan=<paid> on /signup goes on to /pay?plan=… (aim(), lib/auth/destination.ts). e2e/gates/login.spec.ts holds all of it.

The way in is one page: components/shell/focus

Every page a person passes on the way in or on to pay is drawn by ONE module, and differs only in its words: /login and /signup with every step of the panel, the terms that make an account, /forget, /join, /login/mfa, /auth/callback (waiting, refused, the link's code), /auth/initiate, /auth/logout, and /onboarding (waiting, the terms, the question). The bar over them is @hanzo/app Chrome (Focused in components/home/shell.tsx): the wordmark home at the start, the account at the end. Under it:

  • Focus: a section the window under the bar tall (BAR_HEIGHT: the bar's 60px, its hairline and the notch, stated rather than read off --header, which is 0 in the export's first paint), its column at the sign-in card's measure (MEASURE, 420; the question's two cards ask 760), centred across and down it by equal padding (88 above and below), Hanzo's mark at the foot (data-slot="mark"). title and line head the column 14px apart, then 24px to the body. Hero (components/home/Hero) is Focus with the sign-in panel, a switch and the terms line 16px apart, as data-slot="hero" and #hero-title (lib/auth/sale HOLD hides it by that slot).
  • Title: pay's title, @hanzo/ui Display at --text-4xl (32px), --text-3xl (26px) under 640, weight 400, -0.015em, balanced. Line: Lede at --text-lg (15px) in the quiet ink. A page states no type.
  • Glow: the light, defined once: radial-gradient(ellipse 55% 100% at 50% 0%, light-dark(white, --white-08), transparent 70%), 560px, on var(--background), fixed to the window. It is the section's first child (absolute, so the strip of an offer above it keeps its own ground), and it paints the bar the same way, so bar and page are one ground and no band shows where the bar ends. /beta draws the same Glow. Chrome takes no ground of its own, so that is a host rule on header[data-slot="masthead"], written once in Glow; a ground prop on rooms' Chrome would retire it.
  • Wait: the spinner over a title, for a page on its way somewhere.
  • LARGE: every control on these pages, 56px at 1rem (pay's .pay-cta).

The panel's terms step is the next page of the same flow, so it is a Focus too, laid over the page IN PLACE: inside the page's column, whose layer (z 1) sits under the bar's, padded down by the bar's height. It used to be portalled to above the modals, which covered the bar (the wordmark went with it on "Create your account") and left the font scope, so its title fell to the inherited 14px. Every bare page (components/shell/mode bare) is quiet: no closing call under it (components/marketing/Closing). e2e/gates/focus.spec.ts holds all of it at 1440 and 390: the wordmark at the top left and uncovered, the account at the far right with nothing past it, the light on page and bar, the column centred within 2px across and 4px down, one title size, the mark.

Support sessions: /auth/initiate and the banner

IAM's impersonation (hanzoai/iam internal/oidc/impersonate.go) opens this site as a person for a SuperAdmin. The token carries imp: true and the operator in act, lives ≤ 15 min, and has no refresh token.

  • The client is hanzo-app, the one this export signs in as on hanzo.ai (clientForHost → @hanzo/app CLIENT). IAM redeems a hint only for the client it was issued for, so IAM_IMPERSONATION_APPS and cloud's site→client map name hanzo-app. It is shared with hanzo.app, hanzo.chat, hanzo.team and hanzo.bot: a hint is redeemable wherever that client lists a redirect_uri, and the URL cloud builds decides the site.
  • /auth/initiate?iss=&login_hint= (OIDC Core §4): a support sign-in or nothing. iss must equal the discovery issuer of the configured server and the hint must start impersonation: (initiation()), else a plain refusal. The query leaves the address in a layout effect; useSignin starts a fresh PKCE sign-in with the hint unchanged. Nothing is cleared first; the callback lands an imp token on / with no funnel event, bind or claim.
  • No referrer: Next writes stylesheets and chunks before anything the page says, so their requests carried the hint as Referer. The edge sends /auth/* with Referrer-Policy: no-referrer (lib/edge.ts NOREFERRER, rendered by scripts/edge.mjs as middleware hanzo-ai-noreferrer + route hanzo-ai-auth, priority 155); e2e/gates/export.ts serves the same header.
  • components/auth/Impersonation.tsx, mounted once beside Identity: the fixed banner (Exit, no dismiss) and isolate() of the earlier session's refresh token, so the SDK ends the session itself 60 s before expiry. Every end — Exit, that one, a token found expired — is leave: RFC 7009 revoke at proxyBaseUrl, tokens cleared, / loaded, where own() empties the person's keys.
  • Nothing as the person: Identity skips identify, bind and claim; <Look/> is wrapped in Personal; the tag request asks as nobody. The rooms' own useLook (@hanzo/app) still reads preferences with the support token on a room.
  • lib/auth/impersonation.ts is byte-identical to hanzo-inc/platform's and moves to @hanzo/iam. Gates: e2e/unit/impersonation.spec.ts, e2e/gates/impersonation.spec.ts (IAM through e2e/gates/issuer.ts).

Settings: /settings and /settings/organization/*

Settings is the account's one home. The header's account menu and the phone sheet open /settings; /account, /account/settings, /account/billing, /account/invoices and /account/usage forward to their Settings pages (components/account/Forward.tsx), and so does /account/organization, to Members. Members holds both ways in, the emailed invitation and the shared invite link (LINK_SEATS joins); Organization holds the logo (IAM's avatar route, through mark). e2e/gates/members.spec.ts holds both.

Settings is one tree, and none of it is Dev's (owner, 2026-10-22: "don't stick all settings under /dev"). The app reaches it through @hanzo/app ≥ 0.1.100 settings(section) (lib/host): the account menu's rows, ⌘, / Ctrl+,, the palette and every builder Settings section open the page here through the router, and nothing writes /dev/settings. Every address once written there is MOVED (lib/publish.ts) to the page settings() names for its last segment, so the edge 301s it; a section with no page of its own (the builder's Account, Privacy, Memory, Notifications…) lands on General. e2e/unit/host.spec.ts holds the rows to settings(). Machines and Environments, which the builder files under its Settings, stay Dev's at /dev/machines and /dev/environments.

app/(app)/settings is a room (lib/data/rooms.json, noindex; the export holds only the veil). components/settings/Frame.tsx draws the column and the page as two panes (pane() from components/workspace/ground.ts) on the frame's floor, asks who is reading once, and hands pages useStanding(): the acting org (session.org()), IAM's role for it from the token's orgs claim (session.role()), admin = owner|admin of THAT org, the org row, and the tier. Controls are drawn from admin; every write is still the server's to refuse, and its words are shown (lib/hanzo/org.ts say).

General           PUT /v1/iam/account (AccountContext), Look
Organization      name: POST /v1/iam/organizations/profile · address: GET|PUT /v1/settings/organization
                  delete: DELETE /v1/iam/organizations/admin/<org> · team: POST /v1/iam/organizations
Members           lib/hanzo/team.ts: roster = memberships ∪ accounts that live in the org;
                  invitations create/send/withdraw; role POST /v1/iam/memberships; remove POST /v1/iam/delete-membership
Billing           /v1/billing/{tier,balance,methods,invoices}; money moves on /pay (payPage)
Usage             GET /v1/ai/limits → PlanUsage (shares, credits switch) + @hanzo/usage UsagePanel for no paid plan
API keys          /v1/account/keys: many per person, created beside each other, revoked one by id; an admin sees the org's
Service accounts  /v1/iam/service-accounts (IAM admits home-org admins only)

Two IAM facts the pages report rather than hide: IAM v1.34.120 never changes an existing member's role (EnsureMembership is create-only), so a role change reads back and says IAM kept it; and a person cannot found an org (POST /v1/iam/organizations needs AdminOf the new org), so "Convert to team" shows IAM's refusal until HIP-0527's create ships. POST /v1/account/orgs is not used for it: an additional org made there gets no member. e2e/gates/organization.spec.ts holds the role split, the writes and the phone sheet.

Script budget, questions, module pages

  • Over perf.budget.json? Split, never raise. A prerendered next/dynamic section ships as a fetchPriority="low" preload, not a <script>, so it leaves the gate's figure and the critical path while staying in the HTML. Wrap it in <Suspense>: with SSR on and no loading, next/dynamic adds no boundary and the whole segment's hydration waits on the chunk. /pricing loads APIPricing and PricingFAQ this way. A part that exists only after a press is { ssr: false }: General's memoji form (components/settings/Memoji.tsx) carries useAi and @hanzo/ai.

  • A question list is @hanzo/ui's Disclosure (components/ui/faq.tsx, and @hanzo/ui/marketing's Faq): a native <details>, answer in the document, opens with no script. gui's Accordion mounted answers only after hydration, so an early click did nothing. Playwright gives <summary> no role: a gate finds it with locator('summary', { hasText }), never getByRole('button').

  • A Go module page can say what the module is. ABOUT in app/(marketing)/[...mod]/page.tsx adds a body under the facts; ci says what Hanzo CI is. Retiring a module address through MOVED breaks go get wherever the module's go.mod declares hanzo.ai/<name> (DECLARED in lib/go-modules.ts): the go tool reads its tag at that address.

  • /beta is one portal in two frames. components/beta/portal.tsx is drawn by app/beta/page.tsx in the site shell for a stranger and by app/beta/_door.tsx as the pane of the app's Betas mode for a member (no sidebar there). Referral invitations draft via native prefilled mailto:, every share link carries ?ref= plus utm_source=<channel> (tagged), and only genuine referees/rankings are listed. The flow (picker, questions, gate, plan) is portal.tsx; its questions are program.ts; a member's four steps are dashboard.tsx over share.tsx, rank.tsx, betas.tsx and alpha.tsx, and everything they read about the member (link, standing, flags, actions, the ranked waitlist and the listing choice) is one hook, standing.ts useStanding. The score rules are said once, in rank.tsx. A beta row has one control: On when opens(id, flags, staff) says so (a link Open beside it where the beta has an address), Get access when it does not, Requested once asked; the cloud has no per-beta off (the one switch a member holds is <Experimental />, for every public beta, first on the page), so the row never shows a switch it cannot honour. The share row's Bluesky, Reddit and Hacker News marks live in lib/data/share-marks.json (written by scripts/brands.ts) so no other page carries them. Every press in the portal is 44px (Quiet for a quiet link) and a gate walks each screen at 390px.

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.