Commit Graph
6923 Commits
Author SHA1 Message Date
zeekay a3bd53d0f0 docs: the Guides destination is one word, and every language names its own package
The nav said Guides while the URL, the page title and the sidebar heading all
said Start, and the two guide folders a reader would look for under it sat
beside it instead. Now /docs/guides is the address, the title and the heading,
with migrate (40 pages) and integrations (17) nested inside it. 64 exact
redirect rules carry every old address.

The generator owns the flows and no longer wipes the directory it shares with
those folders, and its meta.json reads them off disk, so adding a guide folder
is a directory rather than an edit here.

On the landing page each code tab carries the install line for the package it
imports, so a reader copying the Python example installs hanzoai and not the
orchestration library that shares the name. Rust joins the row — the crate
exists and the tab did not. The Go install line named github.com/hanzoai/go,
which resolves to nothing; the module is go-sdk.
v0.1.3
2026-08-23 16:01:49 -07:00
zeekay 1771326bd0 Eight links pointed into repositories a reader cannot open
Probed every external link the authored tree carries, 164 of them against a
control that 404s: 154 reachable, and the rest all one shape — hanzo-js/iam,
hanzo-rs/sdk, hanzobot/ts and hanzoai/node are PRIVATE, so a reader following
them gets GitHub's 404. Same defect the project pages had, in prose this time.

The Rust page was the worst of it. It did not merely link a private repo, it told
readers to DEPEND on one:

    hanzo = { git = "https://github.com/hanzo-rs/sdk" }

while the crate is published — `hanzo` 0.2.0, "Hanzo — the Open AI Cloud SDK for
Rust", confirmed through the crates.io API and again through the sparse index.
It says `hanzo = "0.2"` now, which is both installable and shorter.

The IAM page's five links went into a private repo's own docs/ directory for
things this site already publishes: the IAM reference is 160 pages at
/docs/openapi/iam, authentication is /docs/api-keys, billing is /docs/credits.
The package link is @hanzo/iam 0.21.16 on npm, which a reader can install.

pdf.hanzo.ai is not a host: it answers exactly as an unknown subdomain does, so
the wildcard is replying, not a service.

Two links to hanzoai/node are LEFT as they are, in a page hidden from the rail.
That repo is private today and intended to be public; the link is right about
where the code will be, and rewriting it would only have to be undone.
2026-08-23 15:07:24 -07:00
zeekay eec7ae3478 The one link on the site that led nowhere now leads to the catalogue it promised
Checked every internal link in the authored tree: 18,879 of them, and exactly one
pointed at no page — /docs/models, named three times by the pricing generator as
"the live catalogue, every model id and its current rate".

The catalogue existed and was reachable from nothing. ModelsCatalog is registered
for MDX and used by no page in the tree; it reads api.hanzo.ai/v1/models in the
reader's browser, which answers 200 with 536 models right now.

So the page is the component plus what a reader needs around it: the id goes in
the `model` field, the request is otherwise identical on every surface, and a
rate belongs to the model rather than to the surface you reach it through.

No count in the prose. The table is fetched when the page loads, so it is the
catalogue the gateway is serving rather than a number written down when the page
was built and wrong by the next model.
2026-08-23 14:52:43 -07:00
zeekay c9097ce9d3 Redirects that pointed at pages we no longer publish now point at ones we do
Of 47 rules, 26 named a target that does not exist — the whole /docs/services/*
and /docs/skills/* families, plus /docs/getting-started and /docs/llm. They were
dead twice over: nothing read the file at all until the emitter landed, and more
than half of what it would have emitted led nowhere.

Resolved against what the tree actually serves, not against what the paths look
like: 24 repointed, mostly to the capability's own reference page — /kms to
/docs/openapi/kms, /docs/cron to /docs/openapi/tasks, /docs/apm to
/docs/openapi/o11y, /docs/analytics to /docs/openapi/event, which is where that
capability went when it was renamed.

/gpus and /gpu are DELETED rather than repointed. There is no GPU capability in
the published contract and no successor page, so any target would be a guess,
and the emitter's own rule is that moving a reader before telling them nothing
is there is worse than the 404 it replaces.
2026-08-23 13:36:11 -07:00
zeekay 3c7a7d6a03 feat(font): replace Geist with Zen 2026-08-23 13:33:43 -07:00
zeekay 132aa8e8fa Say MCP, endpoint or edge instead of door in the authored docs
The generators stopped emitting the metaphor in f1178b6b85. This is the rest of
it: the hand-written pages, the site's own components, and the two configs.

"door" named five different things, so each site now names its own: the MCP
server where it is POST /v1/mcp, endpoint where it is one address that accepts
requests, entry point where it is the single way in, the edge where a credential
is validated, address where it is a location. Three sentences that were only the
metaphor now state the fact instead.

/llms.txt was publishing it to every agent that reads the site.

docs-hero's DOORS list is renamed PATHS, which is what its own doc comment
already called it — three paths ordered by how much you type. It is local to
that file and exported nowhere, so nothing outside it moves.

MCP_DOOR, cat.door, doors(), type Door and the `doors:` key in capabilities.yaml
keep their names: renaming those is a cross-file change to a tracked schema, not
a prose edit. capabilities.ts keeps the one comment quoting pubsub's package doc,
because stripping it would leave a rationale that explains nothing.

Measured: authored pages 39 -> 1 (a smart-home camera nickname, a literal door),
scripts 151 -> 45, all 45 identifiers. docs-mdx exits 0; the two script test
files fail 5/30 identically with and without this change.
2026-08-23 13:31:24 -07:00
zeekay d2e1771698 Publish a glossary, and name things plainly in the generated reference
One idea had several names, so every reader paid a translation. The glossary
states one name per idea and gives the aliases other systems use, so a reader
arriving with a different word can find ours.

It is written against a census of what the corpus actually says, not against
recall: 416 prose pages, ranked by use. The words that carry the docs and were
never defined — caller, run, operation, capability, surface — are defined; the
general terms nothing outside a glossary would use are not.

The reference generators said "door" for three different things, and being
generated it reached 1,962 pages. They now name what they mean: MCP where it is
the MCP server, Address where it is a location. The nav label is where the link
goes rather than a slogan. Measured: door 1,962 -> 103 files, the label 2,373 ->
1, the Door column 121 -> 0. The last 103 carry cloud's own doc comments through
openapi.yaml and are fixed there.

`pnpm lint` could not run at all: the collections alias lived only in
next.config.ts, which rolldown never reads, so lint failed at module resolution
before reading a page — a check that never reported it was not running. The
alias is now one list, projected twice, because Turbopack resolves it relative
to the app root and rolldown demands an absolute path.
2026-08-23 13:14:55 -07:00
zeekay 1ef26e66c1 The CLI entry copies hanzo code, not hanzo dev
Pinning dev chose the reader's coding agent for them. The bare form runs
whichever they configured — dev, Claude Code and Codex are all first-class — so
the command names the task and leaves the agent to their settings.
2026-08-23 13:14:10 -07:00
zeekay d455ca3be0 llms.txt named 26 of its 35 sections by directory, and the agent menu was one flat list
The index every agent reads opened its sections with raw slugs — api-keys, apps,
chat, concepts, console, credits — because the heading came from a hand-kept map
of eight and everything absent from it printed its folder name. Every one of
those sections already carries a title: a folder's meta.json, or the page's own
frontmatter. It reads the tree the sidebar renders now, so a section is named
the same way in both places and adding one needs no edit here. Measured after
generating: 26 raw-slug headings, then 0, across 34 sections.

The eight curated headings stay. "API reference — one page per capability, one
per operation" says more than "openapi" does, which is the whole reason to write
one by hand.

The menu is two groups rather than eight items. Ours open directly — Hanzo App
in the browser, Hanzo CLI as a command, because it runs in the reader's terminal
against their repo and takes a task rather than a URL. Everything under "Use
with any agent" is the same page handed to somebody else's: the prompt, the
markdown twin, ChatGPT, Claude. Skills sits below both, since it is the door to
every capability rather than to this page.
2026-08-23 12:36:22 -07:00
zeekay b892ba9932 Every markdown twin an agent reads was headed "# services:"
getSection was a hand-kept table and it had drifted both ways: four names in it
(commerce, llm, mcp, services) match no directory on disk, and thirteen that do
exist — cli, start, apps, concepts, migrate, mcp-tools among them — matched
nothing and fell to a default of "services", which is not a directory either.

It has three callers. Two are dead: both read it as var(--<section>-color, ...)
and no --*-color token is defined anywhere in the app, so they have always
resolved to the fallback. The third is the markdown twin, where the wrong word
was doing real damage — measured on the live site, /llms.mdx/docs/cli reads
"# services: CLI" and /docs/quickstart reads "# services: Quickstart". That is
the surface every Copy Markdown, View Markdown and Open-in-ChatGPT link hands a
model.

The section is derived from the path now, so it cannot drift: adding a section
is adding a folder.

The twin loses the prefix entirely rather than gaining a better one. It was a
lookup inherited from the docs framework's own manual — framework / ui /
headless / mdx / cli, none of which we publish — and a heading that names the
page is true where one naming a section we do not have is worse than none.
Verified: "# Quickstart", "# CLI", "# SDKs", same byte size otherwise.
2026-08-23 12:05:45 -07:00
zeekay 14ff9e1c00 the nav row hides on a phone again: its tracks are classes, not an inline style 2026-08-23 11:46:36 -07:00
zeekay 6e5ffde46f the front door and the docs chrome state their tracks, on @hanzo/ui 2026-08-23 11:31:52 -07:00
zeekay 6a4bc1de49 A sidebar row is a name, so the API reference stops printing sentences
The operation page title was the summary cut to 80 characters. Measured over the
tree: 1,849 of 2,792 titled pages read as a truncated sentence, and every one of
them was in the API reference — so the whole of /docs/openapi rendered rows like
"Registers a new webhook subscription for the caller's org and answers 20…".

A row is scanned, not read. The title is derived from the address now: the method
gives the verb, the last real path segment gives the subject. Delete webhook, Get
webhook, List deliveries, Test webhook. The sentence loses nothing — it is
already the page description, directly under the name.

Derived rather than authored, so it cannot rot: 2,253 operations named, longest
33 characters, mean 13, none over 40. Re-measured after generating: 1,849 -> 0.

One judgement is a LIST rather than a rule. A trailing word can be an action on
the noun before it (/webhook/{id}/test) or a collection under it
(/webhook/{id}/deliveries), and nothing in the document separates them; the
actions we serve are named. A word missing from that list is wrong quietly —
"Create sync" instead of "Sync repo" — rather than a rule that guesses.
2026-08-23 11:26:55 -07:00
zeekay 660342d729 the docs shell names the row that absorbs the leftover, and the footer reflows on its column 2026-08-23 10:59:31 -07:00
zeekay 5db09dba92 The language strip shows extensions
Six full language names in a tab strip is six wide chips for a choice that is
secondary to the one above it. A developer already scans .ts and .py in editor
tabs, and they fit the row now that it sits at the right of the surface header.

The name is not replaced, it is joined:  still says TypeScript, because
prose says 'the Rust SDK' and never '.rs'. The strip reads . One table,
two projections, so a language cannot be called one thing in a tab and another
in a sentence.

Applied to both writers — the generated flow pages through the SDK table, and
the hand-written quickstart.
2026-08-23 10:48:43 -07:00
zeekay cc99e76a51 The coding agents were reachable and documented nowhere
hanzo code runs a coding agent in your repo and takes three — Hanzo Dev, Claude
Code, Codex — each named several ways (positional, flag, --backend, and the
hanzo dev shorthand). None of it appeared in the docs: grepping the whole tree
for 'hanzo code claude' or '--backend' returned nothing.

The generated CLI reference could not carry it, and that is not a gap in the
generator. It is built from operations that call api.hanzo.ai, so /docs/cli/code
correctly documents the code INTELLIGENCE API — context, file, index — which
shares the word and is a different thing. The page says so, and says how to tell
them apart: a first argument that names a backend selects one, anything else is
a task.

Written from src/commands/code/backend.rs, where every accepted spelling lives
in one table — dev, claude|claude-code|cc, codex — so the page and the binary
cannot disagree about what is accepted.

Also moves  out of 'Build with' into Guides. The Guides nav link opens
/docs/start, so the word led to a page that was not in the section it named,
while Guides held migrate and integrations without it. Build with is now exactly
the surfaces you call.
2026-08-22 21:07:55 -07:00
zeekay 48ef3e0ea7 Quickstart steps say what you do
"Your first call", "Something that costs money" and "Run the whole cloud
yourself" named the intent and not the act, so the table of contents read as a
description of the page rather than the sequence a reader is performing. They are
"List the models", "Send a completion" and "Run the cloud locally" — each the
command directly under it.

The cost framing the old title carried moves into the body, next to the command
that spends, which is where someone about to run it is looking.

Verified against the CLI's own command table rather than assumed: `models list`,
`chat completions`, `billing balance` and `usage summary` all exist. `auth
login`, `version` and `up` are absent from it by construction — that table is
derived from API operations and those three are local commands.
2026-08-22 21:03:49 -07:00
zeekay 993ed6da44 "Search or ask AI" now has a door for the asking half
The field said search OR ask; only searching worked. Full-text matches TERMS, and
a question is mostly terms that appear in no page — measured against production:
"api key", "quickstart" and "kms" all return results, "how do I get an api key"
returns none. The reader most in need of an answer got the emptiest screen, under
a label that had promised otherwise.

Ask AI was removed from this app deliberately — its floating button landed in the
same corner as the Hanzo Edit launcher and the two overlapped — and the label was
never taken down with it.

This is not that button coming back, and not a chat surface bolted into a search
dialog. It is the door this product already gives everywhere else: hand the
question to the reader's own agent, carrying the setup that points it at Hanzo's
models and the skills at hanzoskills.com. Same prompt as the Use Agent control,
imported rather than restated, so there is one thing to keep true.

It sits LAST in the list, because a question is the fallback when the terms did
not land rather than a competitor to a page that did — and it appears from three
characters, so it is there before the reader has finished discovering that
nothing matched.
2026-08-22 18:54:07 -07:00
zeekay 2d1fa7338b A nested tab row is a refinement, not a second widget
Picking SDK opened a bordered, filled box inside the bordered, filled box that
offered it — a frame in a frame, with the six languages reading as a separate
control bolted underneath the surface row.

One level down is the SAME choice refined: which language, having picked the
SDK. So it shares the parent's frame — no border, no fill, no margin — and its
triggers sit at the RIGHT of the row, smaller, where a secondary choice belongs.

Nesting is read from the context the parent already provides, so nothing has to
be passed and no caller changes. No negative offset to tuck the row into the
header: that would assume a header height, and the header grows whenever a
label wraps.
2026-08-22 18:23:25 -07:00
zeekay fd9b006d31 One sparkle, and the quickstart stops warning about a command nobody typed
THE ICON. `Sparkles` is one four-pointed star with two small ones trailing it
diagonally, and it stood for intelligence in five places — the Use Agent control,
the landing card, the model catalogue's badge for our own families, the
capability icon map and the pricing page. `Sparkle` is the same star with the two
trailing ones removed: one mark, four legs, which is what the brand's own
geometry is made of.

THE WARNING. The quickstart interrupted "log in" to explain that `hanzo login` is
not a command and would instead open an agent and ask it about logging in. That
is a note about the CLI's argument parsing placed where a reader is trying to log
in — it teaches the wrong string by naming it, and the section already shows the
right one on the line above. `hanzo auth login` and nothing else.

The one remaining warn callout on these pages stays: it says a write REPLACES
rather than merges, which is a fact about what the call does to your data.
2026-08-22 18:19:34 -07:00
zeekay c93407fb90 the png that outvoted the favicon is gone, the code theme has one home, and MCP leads
icon.svg was already fixed to the brand mark, but icon.png stayed: Next
emits a link for every icon convention it finds, so an opaque 256px tile
sat beside the SVG and browsers that prefer PNG still drew the tile.

lib/shiki.ts said vesper while source.config.ts restated catppuccin twice
next to it, so the file named for the theme lost. Catppuccin italicises 24
scopes to vesper's 7, which is why the quickstart rendered 'npm i -g hanzo'
with the command word slanted.
2026-08-22 18:16:46 -07:00
zeekay fbcd60038d The front page teaches the same install the quickstart does
It showed two blocks: a terminal with curl hanzo.sh, and pip install hanzoai.
So the front door taught one install line and /docs/quickstart taught four, and
the language a reader saw first was Python because it was the only one there.

Both now lead with npm — the one most readers already have — and carry the same
tabs in the same order: npm, Script, Homebrew, pip to install; CLI, TypeScript,
Python, Go, HTTP to use. Measured before writing them: npm 'hanzo' 8.5.90 ships
bin:hanzo, hanzo.sh serves a 12,752-byte installer, and PyPI 'hanzo' 0.4.4 does
provide the command while 'hanzoai' 8.5.89 is the API client — so the install
tab and the client tab name different packages on purpose.

Use Agent renders on every page now. It returned null off /docs, which took it
off the front page — the page a reader is most likely to hand to an agent. Only
the two Markdown items are gated, since only a doc page has a markdown twin.

Mission Control is not a thing and Studio was an empty folder that would render
a heading over nothing; both leave the rail, and Interfaces is Apps.
2026-08-22 18:08:32 -07:00
zeekay cf8cb09cb4 A capability is four surfaces, and none of them linked to the others
`ai` is an API reference, a `hanzo ai` command tree, an MCP tool and an SDK
method. Each is generated by a different script into a different section, and
measured before this: all three linked to the API reference and NONE of them
linked to each other. A reader on the CLI page had no route to the tool that does
the same thing; a reader who arrived from a model had no route to the reference.

One row on all three, naming the four with the current one in bold, so the set is
the same everywhere and only your position in it changes. 273 pages, 126
capabilities.

IT IS A POST-PASS, and that is the whole design. Each generator names its pages
with its OWN spelling of the capability and the spellings differ in both
directions — the MCP page for the `affiliates` tool is filed under the product
`affiliate`, and the CLI tree for `prompt` is at `prompts`. So no generator can
be handed its peers' sets: whichever runs first reads a directory that is not
written yet, and DERIVING a peer's spelling is a second copy of a decision that
generator owns. Both failures are measured rather than argued — 48 broken links
deriving MCP names from the vendored tool list, then 26 more in the other
direction once the order was flipped to fix it.

Reading the finished directories has neither problem: the pages that exist are
the addresses that exist. A capability whose spellings genuinely differ loses the
link rather than getting a guessed one — a missing link costs a reader a search,
a wrong one costs a 404 and the credibility of the rest of the row. Now 0 broken
of 273, and the pass is idempotent: a second run rewrites nothing.
2026-08-22 18:05:01 -07:00
zeekay c8d7a31fbb A public docs site stops advertising repos nobody can open
Of 113 projects listed, 43 linked to GitHub URLs that 404 for any reader: 39
private, 4 deleted. kms, rust-sdk, node, mq and treasury were among them — a
reader following the Open source rail to an SDK got a 404 from GitHub.

Visibility is the rule, so there is no dead-repo list to keep. operator and paas
are private and drop out on their own; both were superseded (paas says so in its
own description, '[SUPERSEDED by hanzoai/platform] ... kept for history'), and
hanzoai/id is here because it is public as of today.

Manifest 113 -> 70. Removed 25 flat pages for private repos and 5 directories
for repos that no longer exist under the org (arc, frappe, runtime, s3-cli,
video). Nothing left in the tree resolves to a non-public repo.

The stale manifest was the March snapshot, tracked in git while its output
directory is ignored, so it survived every checkout that rewrote the tree around
it. The sync already filters private repos when it builds the list; what was
missing was ever regenerating what it had written.
2026-08-22 18:02:00 -07:00
zeekay 8f5909cb19 A slow page is not rebuilt twice more for being slow
The deploy builds per sha and never cancels, deliberately: sharing a concurrency
group put every run in `waiting` until the next push swept it, and no image was
pushed at all. The cost of that correctness is that a burst of pushes runs
several full 6,099-page builds at once on one box.

Under that contention pages cross the 60-second default and are RETRIED —
measured on a five-build burst, /docs/console, /docs/credits and /docs/enso each
failed "attempt 1 of 3" while the run was two-thirds through its pages. A retry
does not make a contended page faster. It makes the same page cost three times,
on the box that was already the constraint.

Three minutes is well above what any page here takes with the box to itself, so
the normal path is unchanged; only the pathological one stops multiplying.
2026-08-22 17:55:24 -07:00
zeekay 5552f4eba2 The halftone breathes, because frozen it was a gradient drawn out of dots
It painted a still radial falloff — animate was off on the argument that a
full-hero canvas repainting every frame is a battery cost on a page whose job is
to be read. True, and the reason the motion is what it is rather than a reason to
have none: one ring travelling outward every 12.6 seconds, at an amplitude that
modulates the field without ever extinguishing it.

Outward from the same origin the envelope falls off from, so it reads as
emanating from behind the headline. A diagonal or a scroll has a DIRECTION, and a
direction under centred type points somewhere the page does not go.

The envelope is separated from the motion because it is the half that must not
move: it is what holds the field dim where the type sits. Measured over a full
period across the field, the wave stays within [0.600, 1.000] of it, the value
never leaves [0, 1], and under the headline the field peaks at 0.35 of full ink.

Reduced motion gets the still field, and gets it on the FIRST paint — the state
starts still and the query only ever turns it on, so a reader who asked for less
motion never sees it start and stop. That is also what shows if the script never
runs.
2026-08-22 17:53:25 -07:00
zeekay 8f651176d1 The front door offers the agent path too, beside the install line
hanzoskills leads with "copy setup prompt"; the docs hero led with a curl line
and nothing else, so a reader arriving with an agent already open had to scroll
to find that out — if they found it at all.

It is beside the install command because those are the two ways to start, and the
SAME control the doc pages carry, so what it copies here is what it copies there:
one prompt, written in one place, which now sets the agent up to use Hanzo's
models and skills rather than merely to call the API.

It also repairs something I broke an hour ago. Moving AgentActions out of the top
bar and into the table of contents aligned it with the right rail on doc pages —
and this page has no table of contents, so the front door was left with no way to
hand anything to an agent at all.
2026-08-22 17:47:44 -07:00
zeekay 85c0315ba9 The copied prompt sets the agent up to USE Hanzo, not just to call it
It installed the CLI and the MCP server and stopped there, which leaves an agent
able to reach Hanzo's control plane while still doing its own thinking somewhere
else and still guessing at endpoints. Two steps close that, and both are the
agent's to execute rather than the reader's to be told about.

MODEL CALLS. Hanzo serves an OpenAI-compatible API, so pointing at it is a base
URL and a key and nothing else — the prompt now sets that pair and names where a
framework reads it. It is honest about the limit: some hosts do not let an agent
redirect its own inference, and the completion checks require it to SAY so rather
than report the step done.

SKILLS. Every capability publishes one — the calls it serves, written for an
agent to execute — and they were a trailing sentence after the real instructions.
Now they are a step, with the reason a skill beats a guess: it states the
operation, its arguments and its refusals, where a guess produces a call that
404s or one that succeeds against the wrong address. No install: the MCP server
already reaches them.
2026-08-22 17:42:17 -07:00
zeekay 73302c7bcc One favicon for one company, and the bar offers both controls
THE MARK. docs shipped the brand mark inset ~13% inside a black rounded tile,
with two shaded paths the brand mark does not have, while hanzo.ai serves it bare
and full-bleed. Two favicons for one company: on a light tab strip hanzo.ai
showed a clean H and docs showed a black tile, so the two did not read as the
same product. icon.svg is now byte-identical in geometry to hanzo.ai's, colour
rule included — black on light, white on dark, which a tile cannot do because its
background is opaque. The .ico and the two .png are regenerated from it, taking
the light fill because a raster cannot follow the strip and black-on-black is the
one outcome that shows nothing.

THE BAR CARRIED ONLY THE KEY. The column at the foot of the rail offers Sign in
then Get an API key; the top-bar copy — which is what shows on the front page and
on every phone, where the rail is collapsed or a drawer — offered the key alone.
So in exactly the states a first-time reader is in, the commitment was the only
thing on screen and there was no way to sign in beside it. Both now, sign in
first, short labels because that row is a fixed 56px.

AND "SURFACES" IS "INTERFACES". Not "Apps": this section already contains a page
called apps, and a heading sharing a name with one of its own rows reads as a
mistake. Interfaces says what they are — the places a person reaches Hanzo.
2026-08-22 17:39:17 -07:00
zeekay ad3eae731e the landing grid states its track list, not a pair of bounds
@hanzo/ui 8.2.2 moved Grid to its own subpath and replaced min/max with
one columns prop. The {min,max} form is still here because that is what
this page needs: a column never below 260px and never more than three,
which no breakpoint can express.
2026-08-22 17:26:38 -07:00
zeekay ac0efb31b8 Use Agent belongs to the right rail, not to the bar above the article
It sat last in the top bar, which is its own grid area — "sidebar header toc" —
so the bar stops exactly where the TOC column starts. Measured at 1600px: the
button ends at x=1307 and the TOC runs 1317–1585. Flush in its own bar, and
lined up with nothing a reader can see; no amount of padding fixes that, because
the control and the column it should agree with are in different columns.

The TOC has a header slot for precisely this. Rendered there it shares the
column: its left edge is "On this page"'s left edge and the two read as one rail.
It also follows the rail rather than the bar — below xl the TOC folds into a
popover and this goes with it, which is where someone on a narrow window looks
for what acts on the page.

The comment under the title claimed the actions had moved TO the top bar, which
is now the opposite of what happens, so it says where they are instead.
2026-08-22 17:17:13 -07:00
zeekay 497c3584f6 The rail says Hanzo AI, ends on the key, and stops nesting one org
Four things in the sidebar, each of which said something untrue about the shape
of the site.

THE NAME. It read "Hanzo", which is a product on a page documenting several; the
company is Hanzo AI. And the mark sat beside the wordmark, saying the same thing
twice. They are alternatives now, chosen by the space each has: open, the
wordmark alone; collapsed, the mark alone, because a rail is a strip and a
wordmark does not fit one. Both places render one slot, so one component reading
the collapse state serves the sidebar's header and the top bar it hands off to.

THE KEY ENDS THE RAIL. "API keys" was third from the top, above the whole
product, when getting a key is the commitment you arrive at rather than the third
thing you read. It sits at the foot now, under its own Account heading — appended
without one it inherited "Open source", which it is not.

ONE ORG IS NOT A LEVEL. Projects › hanzoai › arc put every project three deep
behind a folder that can only ever have one value, since `orgs` is configured to
exactly one. The root lists each project directly, so the rail loses the step and
every /docs/projects/hanzoai/… URL still resolves — the files did not move, only
the listing. The generator branches on the org COUNT rather than dropping the
dimension, so the level returns by itself the day a second org is configured and
actually distinguishes something.
2026-08-22 17:01:00 -07:00
zeekay 931fb7ec04 47 project slugs were served by two files, and prune could not see one of them
An older generation of this sync wrote <slug>.mdx at the org root; the current
one writes <slug>/index.mdx. Both resolve to the same route, so 47 slugs had two
sources — and in 27 of them the flat page read 'Docs are not available yet'
while the folder beside it held the repo's actual documentation. agent, base and
bot are the shape: a 134-byte stub next to a folder of 21, 16 and 48 files.

pruneProjectOutput skipped files outright, so it could never reach them; the
duplicate could only be removed by hand, and would come back. It prunes files
now, and removes a flat page whenever a folder serves the same route.

The 47 are deleted. The 37 flat pages with no folder are KEPT: 35 of them are
live repos — go-sdk, rust-sdk, kotlin-sdk, node, mq, operator among them — whose
folder never synced, so deleting them would delete the only page those SDKs
have. They resolve when the sync next produces a folder.
2026-08-22 16:11:15 -07:00
zeekay 3065b86666 Every product page opened by naming a Go package
`gofmt` and every linter require a package doc to start with the package's own
name, so the synopsis all three generators cut from arrives as "Package ads is
your paid ad campaigns". That is correct Go and wrong product prose twice over:
it names an identifier no caller can see, and where the package is not named
after the capability it names the WRONG one — `hanzo audit` read "Package
auditlog is …" and `hanzo authz` read "Package serve is …", two products
introducing themselves as something a customer cannot find.

Stripped where it is READ, not where it is written, because it is not wrong
where it is written — the same move zip makes dropping a handler's own name from
a lifted doc comment. One call at the loader every generator shares, so the CLI's
group table, the API reference and the MCP tool pages are all fixed by it rather
than by three copies of the same trim: 204 pages, 0 left.

Only an exact leading `Package <ident> is` goes, and what follows is
re-capitalised; prose that merely opens with the word keeps it.
2026-08-22 16:07:33 -07:00
zeekay a3875ae310 A project page carries its README, and a fork says whose work it is
Measured against the GitHub API: 303 public hanzoai repos, 83 of them forks of
somebody else's project, 12 of those forks of Unity and BayatGames engine demos.
The sync kept eight fields per repo and dropped fork, parent and license — so a
page could present another project under our org with no attribution and no
terms, and the docs listed FPSSample and RedRunner as Hanzo projects.

A repo with no docs/ directory got 'Docs are not available yet for this project',
which is true of the directory and false of the project: every one of them has a
README. The README is the page now. Above it, for a fork, whose work it is and
under what licence — before the work, not in a footnote.

Terms are printed only where GitHub can identify them. NOASSERTION is that
identification failing, not a licence, so it prints nothing rather than a guess.

The twelve samples are a LIST, not a rule, because 'is this ours' is a judgement
no API field makes: a fork of ClickHouse is a Hanzo project (hanzoai/datastore)
and a fork of a platformer is not.

Verified against live repos before shipping — datastore resolves to
ClickHouse/ClickHouse under Apache-2.0 with a 4913-byte README; sqlite is ours,
has no detectable licence, and correctly prints no licence line.
2026-08-22 15:51:16 -07:00
zeekay 28b0a837ca The front door's rows come from the column, not the window
The three doors carried `sm:grid-cols-3 md:grid-cols-1 lg:grid-cols-3`, which
reads 3 -> 1 -> 3, and the comment above it said why: at 768 the sidebar has
appeared but the viewport has not grown to pay for it, so a viewport query says
"wide" while the column is 500px. A breakpoint cannot see the sidebar. `Grid`'s
auto-fill measures the COLUMN, so the sidebar is part of the arithmetic and one
number replaces the cascade. Both grids here lose their breakpoints and keep
their shape: measured at the three real container widths, the doors are 1/1/3
and the domains 1/2/3, exactly what the classes produced.

The domains grid is min=220 rather than 240 because the column at 768 is 457px
and 240 needs 492 for a second track -- 40px in one number is the whole gap
between 2-up and 1-up, and 240 shipped a regression until it was measured.

The hero's radial gradient becomes a Dots halftone, which is the mark; a
gradient carries none of it. `fade` masks the canvas on both axes so the field
ends in the page rather than on an edge. Its shape is a function and functions
do not cross the server/client boundary, so it lives in components/hero-field.tsx
-- that file exists for that reason and says so.

Display type moves onto the measured optics: leading 1.0, not 1.05, which at
display size is the difference between one block and two lines; and #e5e5e5
rather than #fff, because pure white halates on a near-black ground.

@hanzo/ui and @hanzo/gui are pinned exactly. Dots landed in 8.1.20 and
Screen/Fill in 8.1.17, so a ^8.0.x resolution silently lacks them.
react-native-web is @hanzo/ui's declared peer: Grid is a plain div, but it
reads getTokenValue from the gui root and so pulls the whole barrel.
2026-08-22 15:48:01 -07:00
zeekay 6c69027ed4 47 redirects were written for a server that stopped reading them
public/_redirects is Cloudflare Pages format and was the source of truth while
docs.hanzo.ai was on Pages. It is served by hanzoai/static now, which never
reads the file, so every rule was inert: /docs/api and /api-reference answered
404, and /api answered a 301 to /api/ that is the server's own trailing-slash
rule rather than anything from the file. The tell is that /api/ then 404s too.

The file stays the one place a redirect is written; post-build emits it into the
export. A page rather than a 301, because a static export cannot set a status —
canonical for crawlers, a meta refresh for script-off, location.replace so the
alias does not enter history. When the serving side can read a redirects file,
delete this and keep the file.

Two properties worth the code they cost. A page we emit is marked, so a real
page is never shadowed by a redirect and a stale one is rewritten when its
target moves — without the mark the first run makes every alias look like a real
page to the second, and it silently stops updating. And a rule whose target does
not exist is reported and skipped rather than emitted: moving a reader before
telling them nothing is there is worse than the 404 it replaces.

Measured on this export: 15 emitted, 2 already real pages, 26 pointing nowhere,
4 wildcards that need a serving-side rule. The 26 are listed on every build.
2026-08-22 15:25:08 -07:00
zeekay c1fed7c8ca Guides are a section, an enum is a list, and a command page shows a command
Four things a reader hits, fixed where each breaks.

MIGRATE AND FRAMEWORKS ARE GUIDES. Both are routes from where you are to here —
39 vendors and 16 frameworks, each a how-to — and they sat under Build beside the
API document, the SDKs and the CLI, which are reference. Build is reference now
and the two how-to sets have a section that says what they are.

THE `op` ENUM WAS UNREADABLE, and the door was never at fault. It publishes one
line per operation, `name — what it does`, newline separated; a markdown table
cell cannot hold a line break, so twelve operations arrived as a single run-on
sentence in the one field a caller most needs. The lines are a list now and the
cell points at it. Six tool pages, the busiest ones.

A CLI PAGE SHOWED NO COMMAND BEING RUN. Two hundred rows of what exists and not
one invocation, which tells a reader the surface and not how to start. Each page
opens with one, chosen rather than written — the shallowest placeholder-free
list the capability actually serves, so it moves with the surface and can never
name a command the table does not carry. 88 of 96; the other 8 serve no read, and
show nothing rather than something invented.

AND THE GATE COULD NOT READ THE DECISION IT WAS REFUSING. enso, games and
proof-of-ai are deliberately out of the sidebar, published and reachable by link,
and 7526c9941b said so in meta.json — in a block check-routes does not look at.
So the build went on failing for three pages whose absence was the point, which
is the same defect as an undeclared orphan wearing the opposite sign. The gate
reads `unlisted.pages` now: 6 disagreements to 0, and the decision is stated once
in the file the sidebar is.
2026-08-22 15:22:16 -07:00
zeekay d3af7b6b2b One search, not a filter that could not find what search finds
The sidebar carried a filter matching titles and URLs offline; the top bar
carried a magnifier for the real dialog. Two controls, and the one a reader
reaches first was the weaker: remember a sentence rather than a title and it
returned nothing, with no hint that full text lived elsewhere.

The slot now opens the same dialog Cmd+K does — full text, every page, and the
AI answer on one surface, because 'find the page' and 'answer my question' are
one intention and a reader should not pick a door before knowing which they
wanted.

It renders as a button rather than an input: an input takes focus and swallows
the first keystrokes before the dialog mounts, and a field you cannot type into
is worse than a button that looks like one. filterPages and its getPages() call
went with the filter.
2026-08-22 15:14:09 -07:00
zeekay 09d8422261 CLI pages read as the CLI, not as the API behind it
Every row carried the HTTP method and path it calls, which is how the command is
implemented rather than how it is used — the widest column in the table was the
one a CLI reader never needs. The reference is linked once, at the top.

Rows arrived in whatever order the document lists its operations, so esign read
audit, download, fields, recipients, send, get, list, create. They are grouped by
the noun the command acts on now, nouns and commands both alphabetical, so
looking for the one that downloads a document is scanning rather than reading.

The count went with it: a page opening '13 commands' says nothing a reader wants
and is wrong the next time the surface moves.
2026-08-22 15:06:57 -07:00
zeekay 98fb11e560 The Start page is generated, so the prose belongs in the generator
The rewrite went into content/docs/start, which is generated output and
gitignored — the next build would have put the counting back. It is in
gen-flow-pages now, where it survives.

publishableKey is the twin of secretKey, read off the same declaration, so the
page states both key classes from the document rather than from a literal — and
says nothing about the browser class where a deployment does not declare one.
2026-08-22 15:04:43 -07:00
zeekay 32f76982bd Use Agent, at the right edge, and a Start page that states facts
The control is named for what it does — hand this page to an agent — and it
carries Hanzo Dev beside ChatGPT and Claude, which takes a command rather than a
URL because it runs in the reader's terminal. It sits last in the row, at the
edge the eye ends on.

The nav row began 16px inside the sidebar's edge while the page text begins 32px
in, so the two never lined up; measured on the live site at 1280 and closed.

The Start page counted things at the reader: six journeys, four ways, 117
products, 'the whole product in miniature'. A count goes stale and none of it
said what the surfaces ARE — that a command, a method, a path and a tool are one
operation under four names, which is the only reason learning one teaches the
others. It says that now, in a table, and each task says what it does in a
sentence with a verb in it.
2026-08-22 15:02:07 -07:00
zeekay 4150535a7e Sign in comes before the key
The cheap step first: most readers already have an account, and getting a key is
the commitment you end on rather than the one you open with.
2026-08-22 14:55:26 -07:00
zeekay fa4ff016d2 The three unlisted pages are a decision, not an absence
enso, games and proof-of-ai were taken out of the rail deliberately and added
back an hour later by an audit that read them as unreachable — which they are,
on purpose. They stay published and reachable by link.

meta.json is JSON and cannot carry a comment, so the reason is a key in the file
somebody edits when they decide to put them back.
2026-08-22 14:38:11 -07:00
zeekay 024482e20f Three sections were published and unreachable, so the sidebar names them
The root meta.json listed 34 entries and the tree holds 37. enso, games and
proof-of-ai each ship a real page, answer their URL, and appear in no rail — the
exact shape the pre-build check was written for, which is why it refused every
build since the root was rewritten and no docs image has shipped since.

Placed where each belongs rather than appended: enso opens Surfaces, because it
is the one you meet first; games sits in Build beside the other paths from
nothing to something running; proof-of-ai follows zap under Concepts, where how
the network verifies and pays is already the subject.
2026-08-22 14:03:34 -07:00
zeekay 0acd338833 Small labels stop shouting on the front page too
The hero eyebrows lost their letter-spaced capitals a commit ago; the landing
page has its own markup and kept them. Same treatment: sentence case at the size
they already were.
2026-08-22 14:00:06 -07:00
zeekay 69e128711f One control for handing a page to an agent, and seven rows at the root
Three controls served one intention and none of them served it well. Copy
Markdown and an Open menu sat under the title on doc pages; Copy prompt existed
only on the front page, which is the one page a reader is least likely to want
to hand over. AgentActions is the one control, in the bar, on every page:
copy the prompt, copy or view the Markdown twin, open in ChatGPT or Claude, and
follow through to the skills at hanzoskills.com.

Everything it offers is derived from the pathname, which is what lets it live in
the bar instead of being rebuilt into each route — and the prompt it copies is
lib/agent-setup-prompt, the one that already existed, rather than a second one
written beside it.

The root rail is seven rows. Three sections wrapped a single item each, which is
a row costing a click and saying nothing the item did not; SDKs, CLI and MCP are
destinations that take over the rail when entered, so at the root they are rows
beside their siblings under Build.

Capitals stop shouting: an eyebrow reading HANZO sat directly above a heading
reading "Build Anything with Hanzo", so the mark stays and the word goes, and the
remaining small labels are sentence case at the size they always were.

Enso, the game builder and Proof of AI leave the rail. The files stay.
2026-08-22 13:36:24 -07:00
zeekay e29c359111 A deploy that never runs is not a slower deploy, it is an absent one
docs.hanzo.ai served a seven-hour-old build while main moved eleven commits, and
nothing looked broken: every deploy run reported `cancelled`, which reads as an
infrastructure hiccup.

`cancel-in-progress: false` does not mean "do not cancel". It selects which
statuses a newer run in the same group sweeps — the kill list is {waiting,
blocked} always, and gains {running, cancelling} only when the flag is true. A
run still waiting for a runner is forfeit to the next push either way. This repo
takes a push every few minutes and the fleet hands out a runner more slowly than
that, so every deploy was swept before it started. Measured: 1ab21251, 3446e7e2
and a hand-dispatched run all ended cancelled, and GHCR holds no image for any
of them.

Per-sha, no run is another run rival. A pull request keeps a shared group, where
a newer push to the same branch genuinely does supersede the older one. Same fix,
same reasoning, as hanzo-inc/cloud cicd.yml.
2026-08-22 13:20:38 -07:00
zeekay 6b658ef7ff The destination owns the rail, and a section is a thing you open
The sidebar was one list of the whole site: 35 always-visible entries, the
same on every page, so which surface you were in was invisible and every
destination scrolled through everyone else's pages.

Two changes to the one renderer every layout shares.

A separator now OWNS the siblings that follow it, so a section collapses as
a unit and only the section holding the page you are reading is open. The
tree is flat on disk either way — grouping at render is what keeps the rail
to a handful of rows without moving 35 files.

A folder at the top of the tree is a place the nav can send someone, so a
reader inside one sees that destination and nothing else, with a way back
out. Read off the tree's own shape rather than a list of destinations kept
beside it: a second list would disagree with the tree the first time
somebody added a folder.

Measured on the production build at 1280x880: /docs is 10 rows, /docs/openapi
11, neither scrolls; opening a domain takes it to 27 and it still does not.
Reading /docs/cli opens Reference and leaves the other six closed.

Leaf rows lose their icons — depth already says where a row sits, and a glyph
per row is what made the column unscannable. The chevron stays; it is the one
glyph saying something the text does not.

Nine sections become seven, "Get started" becomes the pages under it rather
than a name repeating its first entry, and the API key control gains a short
twin in the top bar for the two states where the rail is not on screen: a
collapsed rail, and mobile, where it is a drawer. Both read one hook, so they
cannot disagree about who is signed in.

Also: the surface-gap table leaves the generated pages, and contributing/style
writes down the house rules and the traps that have actually cost us answers.
2026-08-22 13:16:41 -07:00
zeekay 4047bbbc36 migrate: 39 vendors, each starting with a call that runs
Fifteen guides became thirty-nine, and every one now opens with the shortest
path from nothing to a working request: mint a key, the first useful call, the
call that makes it useful, then the two or three capabilities to learn first.

Added: netlify render fly heroku · clerk workos okta · supabase firebase
planetscale upstash algolia pinecone · datadog sentry posthog grafana · openai
replicate modal langsmith · stripe temporal vault.

Written one CTO per vendor with an adversarial verifier on each, which is what
caught the defects: fly cited POST on a route the spec serves GET-only;
supabase counted /v1/tasks operations that are all 307 redirects and mapped
match_documents() onto /v1/ai/vectors, which is store CRUD rather than the
query (POST /v1/ai/rag/query); datadog's own table contradicted the example
below it.

Then checked independently rather than trusting the reports: all 238
api.hanzo.ai calls across the guides resolve against openapi.yaml, matching
templated paths so .../projects/acme/apps/web/deploy is recognised as
.../{project}/apps/{app}/deploy. Zero bad paths. Every table well-formed.

The onboarding step mints at POST /v1/account/keys. /v1/keys does not exist —
it is what hanzoai/ci's bin/site prints in its own error message, so anyone
following that hits nothing.

index.mdx and meta.json are written by hand, not by the fleet: 39 agents
contending over two shared files is the one reliable way to corrupt them. Nav,
files and index cross-check at 39 each with no orphan in any direction.
2026-08-22 12:13:31 -07:00