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
279 KiB
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/ui8.x on@hanzo/gui@hanzo/designtokens + Framer Motion. Tailwind v4 is still in the build and is being retired — see "UI substrate" below.
-
Node: >=22.18, stated in
.nvmrcandenginesand nowhere else. Two things force it: wrangler 4 refuses to start below 22, andpnpm build's second half (scripts/noindex.mjs) importslib/publish.tsdirectly, 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 writtencomponents/workspace/X.tsxorcomponents/bots/X.tsxissrc/X.tsxorsrc/bots/X.tsxthere; import it as@hanzo/app/X. This app states its addresses inlib/app.tsand mounts the router incomponents/app.tsx; the contract is that repo's LLM.md. -
Dev:
pnpm dev -
Build:
pnpm build -
Bundler: Turbopack, in
next devandnext buildboth (Next 16's default; no--webpack, nowebpack()in next.config.mjs). react-native is react-native-web byturbopack.resolveAlias(exact names), and a.webfile resolves before its native twin byturbopack.resolveExtensions. Turbopack applies that list to the project andtranspilePackagesonly, never to the rest of node_modules, which is why react-native-svg is intranspilePackages: 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 isZen-Variable.<hash>.woff2, its hash in letters, digits,-and_rather than hex (scripts/preload-font.mjs). -
Typecheck:
pnpm typecheck—tschere IS TypeScript 7, the native Go compiler, published under the stabletypescriptname 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 ittsgo), it is behind stable, and installing it would mean two copies of one compiler. -
Lint:
pnpm lint— plaineslint ., parsed by@babel/eslint-parserbecause typescript-eslint refuses TS 7 outright (typescript-eslint#10940). -
Deploy: Static export (
output: export) → the PaaS Sites plane, slugshanzo-ai,cloud-hanzo-aiandhanzo-bot, via.github/workflows/deploy.ymlon 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 saysupload.copy: true; hanzo-inc/cloud's completion never copies (copyKeysin 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/cibin/cacheover 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.rscinfothe 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 onxl-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 onheavyin 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.jsonnames 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; atiming: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.mjsreads 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.jsonis 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 inlib/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 tagsite-hanzo-hanzo-aion every publish and Cloudflare answers ok, but the ingress static plane sent noCache-Tag, so pages wait outs-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 immutablesubisrepo:hanzo-inc@308927965/app@1345862086:…), so bin/kms falls back to the KMS pair on every publish.@icons-pack/react-simple-iconsis inoptimizePackageImports: @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 bakesNEXT_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=8is about eight minutes on dgx; the CI shape isCI=1 pnpm gates --shard=i/4.The claim that killed the native path for weeks — "
hanzoai/hanzo.aiis 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": trueANDhas_actions: true, with 1325 runs,deploy.ymlgreen 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/sitein hanzoai/ci, used throughhanzoai/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 passesKMS_CLIENT_ID/KMS_CLIENT_SECRETand 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 addSITES_S3_*.v1is a FLOATING tag, and that is the whole risk surface. It is a branch wearing a version's name:hanzoai/cimoves 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 —sitedeploybecamesite, the tag moved, and six repos' publishes broke at once.Its signature is unmistakable once you have seen it: EVERY step
cancelledat0swhile the JOB readsfailure.actresolves 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 fromaction_run_job— not the run number, not a task id) and the last line names the missingaction.ymloutright.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. Thes3deployment 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 asvolume N not found. When that volume happens to holdhanzo-sites/hanzo/hanzo-ai/index.html, the apex root 520s and nothing else does —/modelsand/comparisonkeep 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 -uThe fix is a RE-PUBLISH, not a restart. Dispatching
deploy.ymlrewrites 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:
/comparisonreturned200with 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 —sitedeploysends 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 | 1scannot be an upload of 850 pages;Gates | failure | 92sis a real run that found something. And a later step readingskippedHIDES 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.aionly redirects there. Push to the real name — a redirect is whyghreports runs under one repo while you push to another. Push tohanzogittoo: that remote is what the forge builds from. -
The apex IS the live origin.
hanzo.aiandhanzo-ai.hanzo.appserve the same Sites-plane build; the DNS cutover this file used to gate on has happened. Measured: a greendeploy.ymlrun, 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 readserver: cloudflareas 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.tsholds 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.
ReplaceTheCoresays "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:
-
hanzo.ai apex (Sites plane, slug
hanzo-ai) — the landing. Route:app/page.tsx→components/home/Home, insideApex. 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/chathere. -
cloud.hanzo.ai (Sites plane, slug
cloud-hanzo-ai) — the detailed product/marketing site. Its ROOT isapp/(marketing)/cloud/page.tsx(components/cloud/CloudLanding), and every deep(marketing)/*page (docdb, vector, kv, iam, …) serves beneath it.deploy.ymlhardlinksout/toout-cloud/and copiesout/cloud.htmloverout-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 thesiteaction takes aslug=dirper line. Universe maps the host the way it maps the apex: astaticFilesmiddleware ons3://hanzo-sites/hanzo/cloud-hanzo-aiplus an IngressRoute incharts/app/values/hanzo/static-sites.yaml.It was
ghcr.io/hanzoai/cloud-www— an image built bycloud.ymlout of theDockerfile'sARG SURFACE, pinned by tag AND digest incharts/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.Dockerfilestays —team.ymlstill buildshanzo.teamfrom it.
- The old apex homepage (
app/(marketing)/page.tsx, thecomponents/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/productfor the gui-backed product layer (DataTable,StatusTag,PageHeader,SiteNav,MetricCard, …);@hanzo/guifor 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-menuis the exception and no longer belongs in that list:@hanzo/ui/dropdown-menuis 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.accordionandsheethave no v8 equivalent → gui's own.Table*has none →DataTablefrom@hanzo/ui/product. -
Config:
gui.config.tsbinds gui's token namespace to @hanzo/design's CSS custom properties, so$4andvar(--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 forbackgroundthat would make the property reference itself (CSS drops both sides of a cycle).backgroundis therefore the ONE value mirrored from @hanzo/design's exported literals; everything else is a livevar()reference. -
ONE
@hanzogui/web, always.@hanzo/guiand every@hanzogui/*are one release train published in lockstep; they must resolve to ONE version, because@hanzogui/webholds the config and theme singletons thatcreateGuiwrites at import. Two copies means the copy a component reads from was never configured. Bumping@hanzo/guialone is therefore never a bump — it is a split, and it fails twice over:- At type level, disguised as an API change.
defaultConfigcame from the old copy, so itsAnimationDriverwas a different declaration than the one the newcreateGuiexpects.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:AnimationsConfigObjectdemands adefaultkey,GuiConfigcircularly references itself, andAccordion"no longer accepts"widthorchildren. None of that was real. gui's ownapps/demos/src/AccordionDemo.tsxcomposes 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/uipulled its own old@hanzogui/toast/telemetryisland, so/leadershipread a config singleton nobody set. A green typecheck does not prove one copy; only the lockfile does.
pnpm.overridespins@hanzogui/{web,toast,telemetry}for this reason:@hanzo/uistill carries stale exact pins beneath caret ranges, and pnpm keeps a satisfied lockfile entry rather than re-resolving it. Verify withgrep -c "@hanzogui/web': 8\.0\.0" pnpm-lock.yaml— the answer is 0, andnode_modules/.pnpmwill still show an orphaned dir, which is not a copy. - At type level, disguised as an API change.
-
TWO COMPOSERS, BOTH SELF-DRAWN.
@hanzo/composeris gui:Composer(the ring and halo,data-slot="prism"),Control(the round mic/send,data-slot="prism-control",fillfor send) andField(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'sprompt(≥ 0.2.61) over@hanzo/ui/chat'sComposer:+(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 insideComposerisPILLfromcomponents/shell/pill.ts(the chrome's glass plusRING, afocusWithinStyleshadow invar(--ring): 40% white on dark, 50% black on light); any other field with its chrome off — the /models and /blockchain/directory searches — is aFieldin a row wearingRING.e2e/gates/composer.spec.tsmeasures 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 throughcenteron@hanzo/ui/chat'sChat(≥ 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 isPopoverContent solid, which the model list it opens inherits. -
There is no app stylesheet.
app/layout.tsximports the packages' sheets in cascade order — design's tokens and base, @hanzo/ui's glass, motion and document sheets — and gui's generatedapp/gui.css;e2e/gates/font.spec.tsfails on any other relative.cssimport. (A sheet of@imports alone also shipped as a 0-byte render-blocking chunk, since Next hoists the imports.) A placeholder's ink isplaceholderTextColoron gui'sInput($placeholderColor, design's--text-tertiary); without it gui's::placeholderrule reads an unset variable and the hint takes the typed ink, so an empty field reads as filled.unstyledzeroes the outline, so such a field statesfocusVisibleStyle={{ outlineWidth: 2 }}to keep design's focus ring. -
groupis a size container. gui writes$group-hoveras a container query, so the element wearinggroupstops sizing to its content: a speak button with no width measured 18px, its padding and border, with the icon and label spilling out. Give agroupan explicit width, or let oneTextbe the control so itshoverStylecolour reaches the icons throughcurrentColor. -
A
$Nsize or leading needs a font scope.fontSize="$2"compiles tovar(--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 namesfontFamily="$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,TextareaandPicker(the native select, ≥ 8.27.48) take both props, andPickerwears 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'sinline-style(@hanzo/design ≥ 0.5.31) counts ANY style object; a value nothing else can express stays with a comment beginninginline-style:on its line or the line above. Type goes on@hanzo/ui/marketing'sLine: its props are loose, so it takes a unitless leading (lineHeight="1.4286") and acolor-mix()ink that gui's ownTexttypes refuse. A frame'scolor-mix()ground or a container'stextAlign/colorrides$platform-web, still an atomic class. Four traps, each measured: gui'sTextpaints$colorunless toldcolor="inherit";fontFamily="$mono"adds.font_mono, which resets line-height, tracking and weight to the face's$true(a command isrender="code", which base.css sets in mono); arender="pre"Text wraps and loses the UA margin until it stateswhiteSpace="pre"andmarginBlock="1em"; and.is_Viewsets the same four font properties, so text inside a View inherits$trueleading, not the page's. A table is gui frames statingTABLE/ROW/CELL(components/pricing/kit.tsx); a cell is aLine render="td", which inherits like a plain cell, and the words in aSpare(a View cell) are adisplay="block"Line, or the row grows by the strut. Props gui types only as shorthands:text,shrink; an attribute likecolSpanrides a spread. A motion host is a frame'srender={<motion.div …/>}. A bare<svg>under base.css'smax-width: 100%shrinks in a flex row:ProviderMarkstands it in a View, whose base does not shrink. -
render=, nottag=— that is how a gui component picks its host element.tagis 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
Textisdisplay: inline, soText render="p"in a block parent drops its margins and its line box: saydisplay="block"(a flex parent blockifies it anyway). A View isflex-shrink: 0where a div shrank: a column that must give way saysshrink={1}, a field beside an icon saysflex={1} minW={0}, neverwidth="100%", which crushed the icon to nothing. And a block parent turned into a stack stops collapsing margins, so a heading'smband a lede'smtadd up; aTextbutton 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; acolor-mix()on a typed colour prop rideslib/mix.ts. -
$platform-webgoes through gui's expander, and arenderelement is a client prop.overscrollBehaviorcomes out as an emptyoverscroll-xrule (name the longhands), and a bare number on a property gui does not know ships no unit, soscrollMarginTop: 96is dropped: write'96px'. A View-rendered<button>or footer<a>loses @hanzo/design's:where()24px floor andinline-flexto gui's atomic classes, so it statesminH={24}ordisplay="inline-flex". Andrender={<Link/>}orrender={<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
Textthat 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 hadcolor: 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 owndata-slotover the element's, so a slot is a prop. Two gui elements swapped in one place with differentrenderneed 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 paintsh1–h4--text-primary, so a converted heading states that ink, not its parent's. And$platform-webcarriesresizeorwordBreakbut not afontFamilygui cannot name: that resets the face's whole rung (15px), so design's serif stays a commented style. -
A control is
@hanzo/ui'sButton, never aTextthat renders a button (e2e/gates/cta.spec.tsreadsrender={<button …/>}as a button with no handler).primaryis the loud one and the unmarked default the quiet one;outlinepaints gui's Button sub-theme ground, louder thanprimaryon light. A close isghosticon-smwith lucide'sX. Its label ellipsizes and never wraps, so a label wider than a phone's column is aTextchild in a row that wraps (the create dialog's submit), and a row of Buttons saysflexWrap="wrap"so a phone takes a second line instead of clipping. A Button drawn as<a href>throughasChildis a link (≥ 8.27.49 drops the frame'srole="button"when the host has an href). Every Button stands in a.t_Buttonsub-theme span (display: contents), so a selector that walks from it to a sibling goes through that span. A destructive action isvariant="destructive": design's--destructivefill (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$red9was 3.9:1). State WORDS (success, a refusal) areGOODandBADfromlib/mix.ts: design's state hues mixed toward--foreground, because--state-successis 2.1:1 on light and the destructive fill reads 4.1:1 as text on dark — nevervar(--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$paneland$hover, not on$raised); grounds$background,$panel,$hover,$raised,$sunken; edges$borderColor,--border-control,--border-selected; a menu or card--popoverwith--shadow-lgor--shadow-floating; a veil--surface-scrim; a lucide icon--foreground/--muted-foreground. A--white-NNrung or--neutral-9xxground is the dark theme hardcoded, since.lightdoes 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,$softink); a call to action isvariant="primary", whose fill is design's--accent, so an accent @hanzo/appearance sets reaches every room's CTAs at once; never set a primary'scolor, 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 inapp/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
mdDrive'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.tsxhands gui's generated CSS to the prerender viauseServerInsertedHTML. 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.mjscounts everyclassName/classattribute AND every string literal inside aclassName={…}expression —cn('text-xs', …)hides classes from a plain attribute scan — records the total per top-two-path-segment directory intailwind.json, and--checkfails BOTH ways: on growth, and on a shrink the floor did not follow.hanzo.ymlruns it besideprice literals.tailwind.config.ts(dead — v4 is CSS-first and never read it),tailwindcss-animate,components.json,@tailwindcss/typographyandautoprefixerARE 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/marketingcomponents —Display,Title,Eyebrow,Lede,Claimfor copy;Action(fillfor the loud one),Chip,Morefor controls;Leaf,Cell,Lift,Reveal,Latticefor 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 internalhrefon one becomes the router link inside the component, from theLinkcontextapp/providers.tsxsets once — a server page never hands inrender={<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 eachEach now passes ONE typed props object to
components/product/ProductLandingand carries ZERO layout classes of its own.FIND THE NEXT CLUSTER BY MEASURING, NOT BY EYE. Take the set of distinct
classNameliterals 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/productwent +8 whileapp/(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=Nonefor 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 intochildren, 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,cmdkand shadcn are absent the same way; the only survivors of that era areclsxandtailwind-mergein 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/uiroot barrel, and all 30 of them are on it.Accordioncomes from gui's own, as this file already says it must.Adoption is deeper than a class count suggests: 139 files import
@hanzo/uiand 18 import@hanzo/gui. The one real second system left is icons — 400 files importlucide-reactdirectly 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/postcssoverapp/globals.css, 204,712 bytes) plus every authored stylesheet, every inline<style>block, and the@hanzo/designtokens.bg-grid-whiteandscrollbar-hidewere deleted from source;hover:border-white/30and/50do emit;prosesurvives 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 reportedsm:pb-28dead 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/30existing says nothing abouthover:border-white/30. -
Interpolated classes: 19 stem sites in source, and only two are classes.
ts-c-${cov[i]}andts-e-${r.ev}inapp/(marketing)/calculator/page.tsx, both safe — that is the page's LOCALts-namespace in its own<style>block, and every union member is enumerated (fourCovvalues against four rules, fiveEvagainst four,internalandreposharing one). The other seventeen are React keys, ids andhtmlFor, which Tailwind never had to see.text-${color}andfrom-${color}incomponents/commerce/UseCases.tsxare 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 (
animationvstransition,$gtSmvs$sm,tagvsrender,lineHeight={1.1}→1.1px,letterSpacingas 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/gatesasserts four specific invariants over the export, and there is not onetoHaveScreenshotin 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}whereGUTTERcarries its own$smleaves only the gutter's half; JSX assigns whole keys, and$smis 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$smper 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. -
Boxandtwfrom@hanzo/uiare the intended path, and it is BLOCKED on two of the properties it needs most.twmaps a class to a gui style prop and hands back what it does not know inrest, so nothing is dropped by the adapter;Boxrenders that onto a gui element and passesrestthrough 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, thets-namespace, andgroup/container.Measured on
/pricingat 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.textAlignreached the DOM as the raw attributetextalign="center"with no atomic class behind it and computedstartinstead ofcenter;displaynever 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) andtextAlign(807 uses) are the third and eighth most common propertiestwemits, so this is roughly four thousand call sites, not an edge.The
displayhalf is substrate drift and is already fixed upstream: the INSTALLED@hanzo/ui8.0.86 spreads props straight ontoYStack, while the canonical tree statesdisplay="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.textAlignis 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/uiexactly before converting anything — the range here is^8.0.83and 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 withdata-motion), so the compiled CSS selects nohz-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:
.glassis @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.gridis Tailwind's owndisplay:grid, so the ruled backdrop is.lattice. Check a new name against the COMPILED stylesheet, not against this file —out/_next/static/css/*.cssis 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:
:rootis dark,.lightretunes. Theme is an @hanzo/appearance axis (system | light | dark):bootScript({ base: { theme: 'dark' } })in the head replays the last paint before first frame,apply()writeslight/darkand gui'st_light/t_darkon<html>, andGuiProviderfollowsuseScheme(). No theme library. - Code samples are
components/code(<Code code lang />), highlighted at build. A server component: shiki 4 with @hanzo/ui'scodeTheme(Dracula dark, GitHub Light light, from@hanzo/ui/core), each colour written aslight-dark()so the page'scolor-schemepicks 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 (gatee2e/gates/code.spec.ts). A client page that shows code is split:page.tsx(server) owns the sample and passes<Code>as a prop toview.tsx(the client body) — ProductLanding'scode.source, the blockchain pages'codeExamples(components/blockchain/sample), kitSample'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.cssis imported intolayer(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.tsstill 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
lineWidthto one DEVICE pixel. At DPR 2 that is half a CSS pixel, so a colouredLINE_STRIPon 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_contextin an effect's cleanup is permanent for that canvas. React hands the SAME element back on a remount, sogetContextreturns 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 innext devwhile prod was fine. The explicitdeleteBuffer/deleteProgramcalls 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.mjsalready 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/designallows no third face. True black. The dimmest grey that passeshyperframes check's WCAG AA pass is#777;#6e6e6efails 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 thealt. 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 thingse2e/gatesasserts. Check it by hand:grep -c '<h1' out/<page>.htmlmust 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.tsxis the ten, as rows off one spine, under thefilm/stackfilm 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'sorderis 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 insidefilm/stack/film.mjs, which the PAGE cannot read: the list beside the film started at AI while the film stood on the chain, andfilm/layergot at it by regex-parsing its sibling's source. One file now, three readers —cloudLayersincloud-primitives.ts, and both film generators. The film'saltis 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.tsxrenders the SHAPE of the bill fromlib/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.CloudCategoryShowcaseis still the index and still lives at/products. It is no longer rendered byCloudLanding. Do not put it back: the landing page and the catalogue browser are two different jobs.- Every count on that page is counted.
layerCountandspell()inlib/data/cloud-primitives.tsgive 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.
CATEGORYincloud-primitives.tswas still keyedplatformafter commerce renamed that category toinfrastructure, 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/infrastructurelanding. 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.tsheld 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 importcomponents/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
apiPathis misspelt.vpcvs/v1/vpcs,walletvs/v1/wallets,alertsvs/v1/o11y/alerts,indexervs/v1/indexers,zero-trustvs/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, andapiitself. These CONSUME the API; noapiPathcan ever be right. They need akindfield, not a path. A category error, not a gap. The sync already honourskind: "client": six of the seven have a page here and light up the day commerce sets the field —idedoes not, and stays out. - external (1) —
nodesisluxfi/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. (tokensleft 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.
The chain, and where each link is held
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). Componentcomponents/navigation/SolutionsMenu.tsxand thesolutions-menu/directory deleted;capabilitiesNav/industriesNavexports removed. /products/<cat>/<x>URLs — menu now points to flat/<slug>URLs matching the actual route layout underapp/(marketing)/./runtimemenu 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 oldpricingsubset, …), 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.tsxreadsnavigation-data.ts),/home2,/referrals,/referral-program./referralis 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
/trustand/legalsay: 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.tsande2e/unit/solutions.spec.tshold it. - Retired addresses (
/solutions/{rag,realtime,vision,audio,fine-tuning},/solutions/{stacks,industries}/*,/solutions/capabilities,/fintech) are MOVED inlib/publish.tsand 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.Proseis @hanzo/ui'sDocument: authored markup under[data-slot='document'], set by@hanzo/ui/styles/document.css. - A
ulhas UA margins and a 40px indent since Tailwind's preflight left (0e41045ea); base.css does not reset it. A list converted to gui statesmy/plto keep that look, orm={0} p={0}to drop it — on purpose either way. - The display heading is @hanzo/ui's
Display, andcomponents/ui/chrome-text.tsxwears 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 statedfontSizeis 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 oneh1. 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'sRun, each DIRECT child says its place withdata-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:restis the colour the sweep returns to (default--neutral-400), because the animation ownscolorfor the whole cycle and would otherwise flatten a caller's own resting shade;stepis the delay per beat (default0.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.cssstills 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-sansbytokens/fonts.css— one binding, one place. It is NOTnext/font/google: that could only ever serve a Google-hosted family, which is what the gate ine2e/gates/font.spec.tsexists to keep out.
Mobile invariants (stated once, in @hanzo/design's base.css)
- The page never scrolls sideways.
overflow-x: cliponhtmlANDbody— the scrollport is the documentElement, so clipping body alone still let a 4px sideways scroll through on /about (awhileInViewx: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 amin-h-11utility 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-heightdoes nothing to a non-replaced inline box, so every mark that claims a tap target —.tapand header/footer links alike — is giveninline-flexunconditionally 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 (
Sheetincomponents/home/shell.tsx) isposition: fixedunder the bar, sobody's inset cannot reach it, and its own bottom padding addsenv(safe-area-inset-bottom), as the bar's top padding adds the top inset. Write theenv()out: gui'spt="safe"type-checks and shipspadding-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 throughcomponents/account/form.tsx. FormandFieldare 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 narrowsuserwritesif (!user) return null— that is a type narrowing, not a second answer to the question. - Sizes are gui props, because
@hanzo/ui8 reads props and not classes.<Avatar className="h-24 w-24">renders at the component's default 32, andspace-x-4on a row of gui Buttons stacks them: a gui Button is a block-level flex box, so the margin utility never applies. Usesize={96}and anXStack gap. This is the same silent-drop family the UI substrate section catalogues, met from the other direction. /accountis inPRIVATE(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.
The docs links were 82% dead, and products-metadata is where most of it lived
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, andhanzo-registry.tspointedquickstartsat/docs/getting-started, which does not exist — docs publishes/docs/quickstart. Fixed inhanzoai/guiand 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.tsis 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 — "nullmeans 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 gotnull, 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.
An "open source" card must open, and 43 of 210 GitHub links did not
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-sourcealone; the rest are on/kms,/world,/amqpand 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/llmishanzoai/ai;hanzo/platformishanzoai/platform(wrong org);hanzo-ai/operativetransferred tohanzo-inc/operativeand is PRIVATE, so its "Open Source on GitHub" badge now points at the org its own metadata already declares;luxfi/nftis gone andluxfi/marketANDluxfi/marketplaceboth exist, so the row points at the org rather than guess between two;hanzoai/evalshas 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 |
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.
The legal documents are DATA, and /legal/<slug> is the one address
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.tsreads 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)/legalthe index.Prosegrew anhtmlprop for markup authored as markdown; it goes on theprosecontainer 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.tsis 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) andSHELVES(whole prefixes, e.g./docs/*to docs.hanzo.ai) inlib/publish.ts, plus retired model pages inlib/data/retired.json.lib/moved.tsjoins them against the current catalog (forward()). No page ships at a retired address, except a Go import path (/ai,/docdb,/status):?go-get=1is never redirected. - Hanzo Team is hanzo.team:
/teamand/team/detailsforward to https://hanzo.team,/team/<id>to https://hanzo.team/agents/ (itslib/agents.tsholds the teammates' words now),/team/chatto/chat. Links to the product nameWORKSPACE(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-sitesis manual).--checksays whether universe is current. - Twins:
.md(llms) and.txt(RSC) answer withX-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, whichlib/edge.tsmodels. - Gates:
e2e/gates/edge.spec.tsruns the rules over the export;sitemap.spec.tsholds 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 itsTable.csv; itsgonerows are what to extendMOVEDfrom.
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 ownAiProvider;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 inPaned: 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/buildnav()). -
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, andPOST /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/projectsrow 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'sapps/coding/establish.gopath is no longer reached from here). -
Chat is the default, and the address says the mode, as a path (@hanzo/app
lib/hosttalk,app,place):/and/chata new conversation,/chat/<id>one of the reader's;/devNew,/dev/run/<id>a run,/dev/projects/<org>/<repo>a repository,/dev/projects/<slug>a site,/dev/<place>every other place;/studioStudio's New and/studio/<place>its places (c/<id>,library,explore,activity). The edge serveschat.htmlfor every conversation,dev.htmlfor every path under/devandstudio.htmlfor every path under/studio(lib/edge.tsAPP; 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 withreplaceState, 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=byMOVEDin 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.tsxmoved). -
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.tsxrunsBOOT(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 owndisplay: nonebefore the rest is parsed — so the pitch never paints at a member, and no stylesheet rule is involved;Apexgives a landing it still draws back itsdisplay: contents. -
app/_apex.tsxApexrenders the landing until hydration (so nothing mismatches), then: IAM loading →live(); IAM settled →isAuthenticated. Signed in, it renders the app (app/_web.tsx, adata-roombox) throughnext/dynamic, so a stranger downloads none of it — about 200 KB of gzipped script, which put the landing's first load at 616 KB againste2e/gates/weight.spec.ts's 440. The mark follows the answer in a layout effect, both ways. -
The Host is
useDevHost()incomponents/workspace/dev.tsx, mirroring hanzo.build'ssrc/app/root.tsx: memberships from the token'sorgsclaim,chooseOrg=pick()+ reload to/, theme/chooseThemefromuseLook(),signOut= IAMlogout(),personfrom IAM with the token's claims as the answer before userinfo,adminviaadministers(). 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/logingoes there too; hanzo.team keeps/home. -
First run is
/onboarding(components/onboarding/flow.tsx, the bare shell).@hanzo/ui/onboardingdraws each page: the terms for a new account ("Let's create your account", versions fromlib/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 onesignup_completed. -
The app is signed-in only.
/at a place in the app (?chat), or any path under/dev(or bare/devwith an old query), with nobody signed in sends the reader to sign in and back (lib/auth/destination.tsenter), and so do every room and every Sign in control the app draws.BOOT(and on /devAWAY,app/dev/_away.ts) marks such an address so the landing never paints on the way out. Bare/devsigned 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: universeiam-on-hanzo-aianswers /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/iamresume): IAM's single sign-on branch answers a code from the session (signed in, nothing typed,method: session) orlogin_required(the form). No redirect, no frame. The form is drawn anyway after 3 s. Only a person's browser asks:navigator.webdriveror a crawler's agent is drawn the form./never asks. Sign-out isuseSignOut: @hanzo/iamend(revoke both tokens and end-session, all on this origin, no navigation), thenlogout_completedand /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.tsxorigin()):serverUrlandproxyBaseUrlare api.hanzo.ai, so discovery (/.well-known/openid-configurationat 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 neitherissuernoriss, 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 towhere().iam, which is also the host of IAM's /account and /join pages. Every gate answers IAM throughe2e/gates/issuer.ts, whosecontractholds 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
AnalyticsProviderfires the first pageview and captures errors;<Pageview/>counts route changes;ErrorBoundarycatches React render errors, which never reachwindow.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, soidentifylands 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 (Consentfrom@hanzo/ui/consent, mounted inapp/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 cookiehz_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 noenabledfrom 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.trackis handed aStream(app/providers.tsx) that sends on whichever client the provider holds when a held moment goes out. A runner's clock is UTC, soe2e/gates/conversion.spec.tsholds all of this. - One
track(name, params)(lib/analytics/tags.ts, over @hanzo/event's) for every funnel moment, under oneevent_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 inapp/providers.tsx) fetches the host's tag set from cloud.tagsReadyis the "tags have taken the page view" signal/forwardwaits on. - Which door a signed-out reader gets (
lib/auth/destinationdoor):enter()with no page sends a browser that has held a session here (localStoragehz_known, set byIdentityin 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 anynexton this site (onward, validated bysafe). 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'sWait, "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'suseLimits) drawsPlanUsageon /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 oneLimitedBannerover 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 localStoragehz_first.<action>).methodis the way in, written to sessionStoragehz_signin_methodwhen 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) carriesvisit()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 totrack. - 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, andvalue/currency/items(GA4 keys). - The click (
lib/analytics/touch.ts): with Marketing, a landing's click ids replace the last set in cookiehz_touchfor 90 days (cloud'slookback, the last click within the window),_fbc/_fbpare written when the Pixel has not; with Analytics,cidis_ga's or a first-partyhz_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.identifyruns only once a session has resolved a user, and consent gates everything throughuseChoice(). 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,zen5andensoall underhanzo, so reading the lab first drew the Hanzo H on every one of them.markOftakes 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 -> zooin OF.@hanzo/logocarries Hanzo's marks and nobody else's; it once shipped aZEN_MARKbesideENSO_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 CSSclip-path: circle(11.5px …)measures the rendered box, so it cuts at 96px and not at all at 18px.e2e/gates/marks.spec.tsholds all of it. - No chip. Marks render bare at the current text colour. The predecessor
painted a CSS mask inside a
rounded-lgbox, which clipped the corners off every square mark — the H worst of all. Every source declaresfill-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 withlazy: 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()readsGET /v1/billing/plans(commerce's plan authority).Plans.tsx,PricingFAQ,/invite, the picker and the settings' plan names all read it;chargeandsavinglive beside the cents→dollars conversion inlib/api/read.ts. - The rate card, every model's rate and the tool rates:
usePricing()readsGET /v1/pricingonce 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.tsis 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/planscarry 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 rendersloadingfirst, 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-apinames 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/tallykaiPrice(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/completionsand/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]thatshows()admits as an icon over its label, then Settings. A phone shows the first four andMore, 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.Botsdraws one half of the roster at a time (show). - The pane is the room, and
sideis the column a room fills throughBeside(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:
- 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 bindscreate/mirrorto the caller's org (hanzo) and exposes no rename. hanzo platform sites update hanzo-ai-> repohttps://git.hanzo.ai/hanzo-apps/ai.- 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/aiSDK: Front-end workspace rooms (/chat,/dev,/home,/board,/drive,/inbox,/work) bind directly to@hanzo/aihooks without intermediate mock state.
12 Hard Launch Gates
- CLI / Install:
curl -fsSL https://hanzo.sh | sh->hanzo upboots the full local cloud and bare-metal K3s sandbox engine in <200ms. - IAM: Unified user, org, project, service account identities, delegation, PKCE, SCIM.
- KMS / MPC / Egress: Zero secrets in runtime agent environments; delegated wire egress.
- Model Cloud: OpenAI-compatible
/v1/chat/completions,/v1/embeddings,/v1/models, Zen (qwen3+) & frontier routing. - Agents: Persistent sessions, revision pinning, tools, hard budget caps, durable runs.
- MCP & Tools: 260+ tools; parity between REST, SDK, CLI, and MCP.
- 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.
- Tasks / Durable Execution: Async workflows with stateful resumption, signal, retry, sleep.
- Postgres + Objects: S3-compatible storage and SQL data substrate.
- Functions + App Deploy: Zero-config repo -> container/function -> production URL + TLS.
- Connectors & Channels: Slack, GitHub, Discord, Telegram with user-delegated scopes.
- 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.frameworkon 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/egressinjects API/OAuth credentials on the wire. - Dual Local & Mesh Role: Operates as a blazing-fast local dev workspace while seamlessly connecting to the
luxfi/nodeP2P gossip mesh to serve as a distributed backend node forapi.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.smallARM) paired withfck-natand built-inServiceLB. - 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 upmicroVM, AWS Graviton Spot Fleet, GCP GKE, Azure AKS, or DigitalOcean DOKS — attaches to the tenant's fleet viaPOST /v1/visor/clusters/attachwith 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): Thehanzoai/cloudbinary 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.
- Local Topology (
Shell Chrome: Full-Width Header & Rail Alignment
- Zero Inset Offset:
header[data-hanzo-shell]breaks out ofbody'spadding-left: var(--rail)viamargin-left: calc(-1 * var(--rail))so the header spans true 100vw fromx=0. - Pixel-Exact Rail Alignment:
BrandBoxpill starts atx=8px, matching.modevertical 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, andgithub.com/hanzoai/hanzo.ai. IntegratedAgenticOsStack,AppBuilderHero,FloatingChatDock,FooterHeroComposer, andTrustedTicker. - Model Lab Canonicalization: Fixed
modelPagePathinlib/models.tsandapp/(marketing)/models/[org]/[model]/page.tsxto resolve viacanonicalOrg(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/guiresponsive 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 toout/with font preloads, canonical URLs, andllms.txt. - Live Deployment: Synced and pushed to GitHub main branches at
hanzo-apps/aiandhanzoai/hanzo.ai.
Zero-Stub Implementation & 100% Coverage Verification (September 2026)
- Zero Fake Stubs & Real Agent Wiring:
components/workspace/Vibe.tsx: Eliminated mocksetTimeoutgenerators. Connected prompt dispatch to livestartRun()(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/completionsagainst modelzenwith bioacoustic system prompt and user history.components/dashboard/KanbanBoard.tsx: Replaced static task dummy data withDEFAULT_TASKSand dynamicboard()loading from/v1/todo.components/dashboard/AgentsList.tsx: Replaced static agent fixtures withDEFAULT_AGENTSand dynamic/v1/agentssynchronization on mount.components/dashboard/data/agents/agent-data.ts: Purged external references; standardized fully onZenLM 3andEnso 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 onformatPrice,getOrgAndSlug,canonicalOrg,modelPagePath,getModelContext,formatContext,orgDisplayName, andfetchModels.e2e/unit/coding.spec.ts:CodingRefusalstatus code & error preservation,dispatchabletarget checks,startCoding/startRunsession handling, andlistTargets.e2e/unit/durable.spec.ts:reads()engine state mapping, deterministicrunName()generation,runs(),runOf(), and idempotent workflow dispatch withstart().e2e/unit/todo.spec.ts:COLUMNSclosed set,PRIORITIEShierarchy,DEFAULT_CARDS,board(),projects(),open(),edit(), andclaim().
- Added unit test suite in
- Quality Gates & Semantic Headings:
- Added semantic
<h1>tags with full CSS containment toVibe.tsx({roomName}) andBeluga.tsx(Blue the Beluga). - Scoped button queries in
e2e/gates/board.spec.ts(main.getByRole('button', ...)),columns.spec.ts, andworkspace.spec.tsto 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.
- Added semantic
- Cloud Backend Verification:
- All backend app test suites in
hanzoai/cloudpassing green withHANZO_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).
- All backend app test suites in
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)}) incomponents/workspace/Shell.tsx. - Re-enabled React portals via
<Beside>across all workspace rooms (/dev,/chat,/work), allowing child panes to project side controls (such asSidewithPreview/Chattabs andWorkanswering artifacts) directly into the right-hand panel while keepingRightPanefor roster and directory views.
- Restored
- Chat Starters Drafting UX (
Chat.tsx): - Free Lane Notice & Model Resolution (
Chat.tsx&hooks/useAi.ts):- Restored standing data-sharing disclosure (
freeCopy.notice) whenisFree(model)is active on free routes, leaving paid routes clean and unencumbered. - Enhanced
served()inhooks/useAi.tsto 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.
- Restored standing data-sharing disclosure (
Product-First Positioning & Workspace Resilience (September 2026)
- Product-First Positioning (
hanzo.ai):hanzo.aiis 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.tsxwith 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) withArray.isArray(value) ? value : []. - Hardened workspace components (
Shell.tsx,Home.tsx,crew.tsx,Work.tsx) with defensive array checks onsessions,agents, andpeopleso that unhandled 404s/error responses never throwTypeError: .filter is not a function.
- Guarded all hook lists against non-array payloads in
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
orgsclaim is empty (the SDK's list falls back to theownerclaim, which is the application's org and nobody's to work in, so the gate reads the claim itself throughsession.tsorgs()). 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 postsPOST /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:pickedremembers it per person, becauseown()clears it with everything else. The chooser (@hanzo/appPick, 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, whichapp/layout.tsxloads. - Not live —
GET /v1/billing/tier, scoped to that organization, answersfree: 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 exchangehandleCallbackperforms, so this is the one auth mounted here rather than a second one written here. It takes issuer, client, callback and organization fromorigin()inapp/providers.tsx— the SAME derivationIamProvideris 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.providersis 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 rideslib/api/read(one read per page load;api()is the origin: api.hanzo.ai on the site, the page's own origin locally),useKaiderives the boards from what it returns, and each section drawscomponents/api/state'sReady: 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-timeKAIin lib/bench-data still feeds what is listed next.scripts/kai.mjswrites it only when the answer's revision passes its release gate against Jev (derive'ssota); otherwise it printsgate 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.tsledger and the/benchmarks/<slug>pages), Kai's line on the/modelsfamily card (components/family/Family.tsx),/kai/guide's accuracy line,BOARD.ledeandBOARD.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:
WHOiskai,jev, and the API'slayaruns stay in its answer, unread, andscripts/kai.mjskeeps 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.mjsleaveshanzoai/layaout of the OSS list and counts.e2e/gates/unlisted.spec.tsfails on the name anywhere inout/. - One revision a page: the newest Kai version with harness runs (by
ended). Kai's cells read only runs whoseversionequals it exactly — no other build's run, not even a7's, which shares kai-1's weights sha. Jev is its newest run markedbaseline. Every note names that revision (namedin lib/kai:kai-1.4since 2026-10-16), never "the served Kai": the model idkaiserves kai-1.3, kai-1.4 is its own model idkai-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/kaishare), else bare ("Kai wins 15 tasks"), never "15 of 16". Each row names the benchmark it reads;derivetakes 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 todecision/SUITE.md. Row 15 readsjailbreak.wild(categorybenign) 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 thedecision.latencyruns' 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/researchkeeps Kai's and Jev's runs on a decision benchmark and every system on anenso.*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 isGET /v1/models'input_per_millionforkaiandtypesafe/jev-1.13. /kai leads, under the hero, with the suite's rows as headlines (lib/kaiheadlines, components/kai Lead), each only while its row is drawn (the timings ride the cost row).e2e/gates/suite.spec.tsserves the runs and an invented model list and reads every state, and gives Jev one task (e2e/gates/wins.tslose) 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/tallykaiLead: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.tsis 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, souseAnchorscrolls 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 anchoredBlockstops clear of the chapter nav.e2e/gates/suite.spec.tsholds 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 stagea7(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 (
useSettledin 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.tsxKEEP: 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 withres.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 itUNPUBLISHED); MASSIVE language by language is the languages Kai leads (Languagesis{ 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/kaiATTACK), which the gate cuts by those names before it looks for the word. The leaderboard andindex.winsare 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 Yin 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.jevrun 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 askai-1@0834a74f…, on CUDA BF16. Its other run iscap.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 carryrevision: retractedin 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 askai-1.4@c1856a77…), every one read on evo's x86-64 CPU throughkai serve --device cpu --reach 32768(hanzo-inc/kairesults/kai-1.4/unified/suite/): the 12 harness runs bybench post, and rows 12-15 by that folder'sruns.py(cap.cardinality-cases-kai-1.4.c1856a77-kaiat 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-16bench postre-filed Jev's 12 harness runs and sevencap.*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/guideand/blog/kai-decision-modelsprint 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:kaikai-1.3, the default;kai-1.4the unified version andkai-1.5the 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 fromKAI. Bot and Team do not call Kai, so no page says they do.e2e/unit/figures.spec.tsholdsKai.tsxto the index's rule (no digit in source);e2e/gates/benchmarks.spec.tsandkai.spec.tsabort the API's address to compare every rendered row and best mark toKAI, 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.tsasserts 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-smshows 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 adata-attribute. The file ships every answer atopacity: 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 throughelement.animate, the busy dot is @hanzo/design'shanzo-pulse-dot, andPassessettles its cells byelement.animatewhen 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
Windowfromcomponents/frame/Term.tsx, which is the frameTermalways 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 drawsReady/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.jsonandlib/leaderboard.tsare 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-v6and/benchmarks/charxiv-reasoningpages they generated. - A board (lib/enso
board) is one row a system: itsfrontierrun, else itsv1run, else itsfillrun (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);v2andmaxput others and sit on no board. The run is on the benchmark's main split, over the whole set, none whose notes sayDegradedand 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 byversion. 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 drawspublished(b): every single model, and each Enso row only while it outscores every one of them (lib/tallytopped), soenso-flash(both boards) andenso-ultraon 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'sbest_single_accuracy(level); level is not a win and is not said.caveatis the note under them: the measuredensois 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 forlevel, andlinessays ", 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, andEnsocarries noceiling. 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" (splitheldout, 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" (tagseen…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.tsunlabelled).- New questions: the tiers more accurate than
best-single(the strongest whole single-model run re-scored on the same 159), best first, each withusd_per_1k;best-singlebeside 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 byended. - 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-single148 of 159 (gpt-5.6-sol, enso-bench'smaxrun 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'sbest_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 splitlockbox(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'sregistry.matrix_fullon 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-questiondecisionsat 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'stest-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 ownceiling, 196/198, is the pool's. - The tier the policy calls
ensois named Enso on both sets (the price list billsensoandenso-proas two SKUs). Runs' notes are "Enso routed; new questions" and "Enso routed; questions it has seen"; the earlierenso.gpqa_diamond-test-tuned-enso-ultra(a 2026-07 route table's replay, 147 of its picks outside the deployed pool) is retracted.
- New questions: the tiers more accurate than
- /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 scatterd0199249removed, 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,
maxandv2included), at thecost_usdit filed per question times 1,000, wearing its lab's mark (the price-list idskumaps 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,kindlab 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 bysku, 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_usdandusd_per_1kare 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/tallyfrontierover the drawn points: computed, said in the alt text ("The frontier … is Enso Flash, …") and each point'sdata-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/ensoclaim) 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", whichunlabelledreads. 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.tsmeasures 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.
- each single model at its best priced run (the most accurate, then the
cheaper; any tag,
- /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.hleandenso.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.tsserves invented runs (e2e/gates/field.ts) and holds every surface above toderiveover them, the boards topublished, no pool, arm or ceiling on a board, and the unreachable state;leaderboard.spec.tsdrives the explorer andwins.spec.tsholds 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 inlib/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./partnerstherefore staysMOVEDto/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 —
MARKSfrom@hanzogui/shell, 16px, the size the old shell bar used). Aleadcolumn 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 fromlib/data/solutions.ts, then Programs. Products ends on Hanzo OS: All products and the seven layers fromlib/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 (
/devon this origin) and API Platform (platform.hanzo.ai); Try Hanzo isSIGNUP(/signup), the fold's Get started. A signed-in reader sees the shell's account menu andOPEN(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$lgto$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-panelground, 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/72underblur(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 spreadsunder(pt)(components/shell/dock.ts):mt: -ROOFand its own top padding plusROOF, so its ground starts at the top of the window, behind the glass, and every word stays put. It is onSplash(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.tsholds the glass and those first screens at 0. The focused bar (sign-in, onboarding, a beta application) is @hanzo/app'Chromeunder the focused pages' opaque glow (components/shell/focus.tsxBAR); 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 bytransformfrom--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:
hoverStyleandfocusVisibleStylefor the lit pill and the rows,$lg/$xlfor the phone bar and the marks,enterStylefor the 4px arrival, anddisplay="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 withvar(--white-92…); the bar carries the four rungs aslight-dark()on its ownstyle, 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 andtreturns 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 readst(…)andphrase(…)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 — isphrase('Pricing')where it is written andt(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.SETUPand a chip'spromptare 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 — carriesdata-i18n-ltron 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 itstransformOriginfromuseI18n().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 bydir: @hanzo/ui'sInputgutters in the settings search, the copy prompt's two joined halves. The layout's own rules say sides logically too (border-start-end-radiuson[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.tsuseLanguage()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 withml: '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 isrtl, 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 (APPon<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.
Cornerwrites through<Hanzo>'sLookcontext when there is one; this site mounts its own gui provider and @hanzo/ui does not exportLook, 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 settinglang/dirand 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(ZenandZen 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).
- Complete dark theme overhaul with
- 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-aiandhanzo-botonly. - 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, andhttps://hanzo.ai/dev.
Benchmarks & AWS S3 Live Origin Architecture
- Retracted (2026-10-15): an earlier
/benchmarksoverhaul 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 namesZen6,Zen6-Coder,Zen6-Flash, never an upstream base. - AWS S3 Live Production Origin:
- Cloudflare apex DNS
hanzo.airoutes to the AWS NLB edge inus-east-1(100.24.244.37). Traefik ingress terminates TLS and serves static assets viastaticFilesmiddleware backed by AWS S3 buckets3://hanzo-sites/hanzo/hanzo-ai/(ands3://hanzo-sites/hanzo/cloud-hanzo-ai/). - Production deployments must sync exported static files from
out/directly to AWS S3 using AWS CLI profilehanzo-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}'
- Cloudflare apex DNS
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): aguesthost 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/iamsignupcreates 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, whichGatesets and the landing's hero does not: the prompt over a hero was removed in72ae61b7). Google'sgsi/clientis loaded once, andprompt()runs while the first screen shows, IAM offers Google, and nothing has been pressed; a press, a step or a leavecancel()s it. A tapped account is NOT a sign-in: only the credential's email is read, unverified, as thelogin_hintof 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.aiandhanzo.idallowed,hanzo.aiandwww.hanzo.ainot. 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/cloudsessionRoutes) and at the Cloudflare edge (universeinfra/cf-zones/cors.py), by design. So the panel calls THIS origin (proxyBaseUrl), and universe static-sitesiam-on-hanzo-aianswers those three paths on hanzo.ai/www with the cloud, Host rewritten to api.hanzo.ai, with everySet-Cookiedropped (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/v1proxy does the same locally. A host that does not answer them (cloud.hanzo.ai, hanzo.bot) types the credential on the issuer's page withlogin_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/iamasked/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 inadmin; 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, avisitshost 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 (BroadcastChannelsignin.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.tsdrives 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 intestdata/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 ispublic/email/mark.png(https://hanzo.ai/email/mark.png, IAM's<site>/email/mark.png), a 132px PNG derived from @hanzo/brand byscripts/email-mark.mjs; the link gate holds it served. The code field saysautocomplete="one-time-code"andinputmode="numeric"(Code, components/auth/steps.tsx), which is what lets Safari fill a code from Mail; WebOTP's@hanzo.ai #123456line 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
adminorg 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.
- 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
- 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.tsuseLeave), 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 (
SecondFactorfrom @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").titleandlinehead 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, asdata-slot="hero"and#hero-title(lib/auth/saleHOLDhides it by that slot).Title: pay's title, @hanzo/uiDisplayat--text-4xl(32px),--text-3xl(26px) under 640, weight 400, -0.015em, balanced.Line:Ledeat--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, onvar(--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 sameGlow. Chrome takes no ground of its own, so that is a host rule onheader[data-slot="masthead"], written once inGlow; a ground prop on rooms'Chromewould 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/appCLIENT). IAM redeems a hint only for the client it was issued for, soIAM_IMPERSONATION_APPSand cloud's site→client map namehanzo-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.issmust equal the discoveryissuerof the configured server and the hint must startimpersonation:(initiation()), else a plain refusal. The query leaves the address in a layout effect;useSigninstarts a fresh PKCE sign-in with the hint unchanged. Nothing is cleared first; the callback lands animptoken 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/*withReferrer-Policy: no-referrer(lib/edge.tsNOREFERRER, rendered byscripts/edge.mjsas middlewarehanzo-ai-noreferrer+ routehanzo-ai-auth, priority 155);e2e/gates/export.tsserves the same header. components/auth/Impersonation.tsx, mounted once besideIdentity: the fixed banner (Exit, no dismiss) andisolate()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 — isleave: RFC 7009 revoke atproxyBaseUrl, tokens cleared,/loaded, whereown()empties the person's keys.- Nothing as the person:
Identityskips identify, bind and claim;<Look/>is wrapped inPersonal; the tag request asks as nobody. The rooms' ownuseLook(@hanzo/app) still reads preferences with the support token on a room. lib/auth/impersonation.tsis byte-identical to hanzo-inc/platform's and moves to @hanzo/iam. Gates:e2e/unit/impersonation.spec.ts,e2e/gates/impersonation.spec.ts(IAM throughe2e/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 prerenderednext/dynamicsection ships as afetchPriority="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 noloading, next/dynamic adds no boundary and the whole segment's hydration waits on the chunk. /pricing loadsAPIPricingandPricingFAQthis way. A part that exists only after a press is{ ssr: false }: General's memoji form (components/settings/Memoji.tsx) carriesuseAiand@hanzo/ai. -
A question list is @hanzo/ui's
Disclosure(components/ui/faq.tsx, and@hanzo/ui/marketing'sFaq): 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 withlocator('summary', { hasText }), nevergetByRole('button'). -
A Go module page can say what the module is.
ABOUTinapp/(marketing)/[...mod]/page.tsxadds a body under the facts;cisays what Hanzo CI is. Retiring a module address through MOVED breaksgo getwherever the module'sgo.moddeclareshanzo.ai/<name>(DECLAREDinlib/go-modules.ts): the go tool reads its tag at that address. -
/betais one portal in two frames.components/beta/portal.tsxis drawn byapp/beta/page.tsxin the site shell for a stranger and byapp/beta/_door.tsxas the pane of the app's Betas mode for a member (no sidebar there). Referral invitations draft via native prefilledmailto:, every share link carries?ref=plusutm_source=<channel>(tagged), and only genuine referees/rankings are listed. The flow (picker, questions, gate, plan) isportal.tsx; its questions areprogram.ts; a member's four steps aredashboard.tsxovershare.tsx,rank.tsx,betas.tsxandalpha.tsx, and everything they read about the member (link, standing, flags, actions, the ranked waitlist and the listing choice) is one hook,standing.tsuseStanding. The score rules are said once, inrank.tsx. A beta row has one control: On whenopens(id, flags, staff)says so (a linkOpenbeside 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 inlib/data/share-marks.json(written byscripts/brands.ts) so no other page carries them. Every press in the portal is 44px (Quietfor 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.