zeekayandClaude Opus 5.5 28cebd13e9 A host the brand does not state is a guest-only copy
The bundle is served on more hosts than its IAM clients return to: the Sites
plane publishes it a second time at chat2.hanzo.app, and a fork runs it on its
own domain. There the silent probe and the Log in button both started an
authorize whose redirect_uri the issuer has never registered, and the issuer
answers that with its own error page instead of sending the browser back.

brand.ts now says whether the host is one the brand states (`stated`) and
whether a sign-in may start here at all (`signsIn`: a stated host, the desktop,
or loopback). The probe runs only on a stated host, which also covers the
loopback case it special-cased. On any other host, Log in and Sign up hand the
visitor to the brand's own /login, which signs in and returns there. Stated
hosts behave exactly as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 10:18:13 -07:00
2026-09-01 10:16:38 -07:00

Hanzo Chat

A chat client, and the package the desktop is built from. Vite and React draw it, @hanzo/ui supplies the components on the @hanzo/gui runtime, and @hanzo/ai is the only thing that speaks to a server.

One bundle serves three products. src/brand.ts states a record per brand — its organization, issuer, name, mark, and the hosts it answers on — and wear() writes the title, icon and theme onto the document before the first render, so hanzo.chat, lux.chat and zoolabs.io are one build wearing three faces. Adding a brand is adding a record; nothing derives a name from a hostname, and no name for the product comes from the server.

The wire

There is no HTTP client here. src/data/origin.ts builds one createAiClient per origin and that client owns the base URL, the bearer, the stale-token retry and the SSE decode, so no path to the API appears in this repository at all. A model's address is origin/model, so choosing a model chooses where it runs — the estate under the visitor's IAM token, or an engine on this machine under the key the shell started it with. Nothing downstream of the picker knows there is more than one place.

what call route
a turn chat.completions.create({stream:true}) POST /v1/chat/completions
history threads.list(), threads.get(id) GET /v1/agents/chat/conversations
the menu models.list() GET /v1/models
the visitor account.get() GET /v1/ai/account

Identity is @hanzo/iam, handed to the client structurally as one token source with one refresh, so no credential logic lives here either. src/data/api.ts names four addresses and they are all the issuer's, because they are full-page navigations the SDK does not make. VITE_HANZO_API moves the API without moving the identity, which is the one arrangement a local cloud needs.

Where the server has no route, src/data/missing.ts says so and names the method it waits on. Nothing is stubbed and nothing is disabled: a control that cannot be pressed is a promise, so it is absent instead.

Layout

One home per concern. A thing belonging to two of them belongs to neither and goes down a level.

module owns
src/shell the page — providers, routes, the sign-in gate, the palette
src/rail the set of conversations, and a row's verbs
src/thread one conversation rendered — turns, parts, markdown
src/compose the draft and its journey out — the payload, the turn in flight
src/data the client, the session, the store, and what the server cannot do
src/settings which model answers, and every preference behind it

The rest are rooms reached from the palette: agents, artifact, automations, boards, channels, mcp, plugins, presence, projects, shortcuts, tasks, terminal, theme.

Styling

No Tailwind, no Radix, no shadcn, and no cn(). Not an omission to fill in later — a utility class is a browser-only instruction, so each one caps the app at the browser and forks the scale it renders at. Styling is theme tokens and component props resolved by the gui config that <Hanzo> mounts, which is why one tree renders on web, native and desktop and why the type scale moves for the whole product from one place.

Building on it

The package publishes its modules as built JavaScript beside their declarations, so anything under src is reachable by its own path and a consumer compiles nothing:

import { Thread } from '@hanzo/chat/thread/Thread'
import { Compose } from '@hanzo/chat/compose/Compose'
import { client, ESTATE } from '@hanzo/chat/data/origin'

@hanzo/vite states the resolution the runtime needs — react-native-web, the web-first extension order, one copy of gui — so a consumer's config is that call and its own plugins:

import { hanzo } from '@hanzo/vite'
import react from '@vitejs/plugin-react'

const config = hanzo(
  { plugins: [react()], define: { 'process.env': '{}' } },
  { root: import.meta.dirname },
)

export default {
  ...config,
  optimizeDeps: {
    rollupOptions: { resolve: { extensions: (config.resolve as { extensions: string[] }).extensions } },
  },
}

@hanzo/desktop is this package plus what only a machine has, so a desktop is a fork of that rather than a second copy of this.

Verifying

CI=true pnpm install --no-frozen-lockfile
pnpm dev          # localhost:3090, /v1 proxied so calls stay same-origin
pnpm typecheck
pnpm test         # 39 assertions, node --test, no framework
pnpm build

noUnusedLocals is on, so dead code fails the build — un-export a symbol and the compiler names it if nothing uses it. To prove the brand table without a browser, import src/brand.ts once per host with a stubbed globalThis.window.location.hostname; to prove the routes without a token, grep the built bundle for /v1/.

S
Description
Hanzo Chat — free, single-player AI chat with every model. Open source.
Readme
1.1 GiB
Languages
TypeScript 99.5%
HTML 0.5%