Files
zooqueenandHanzo Dev 684a864bf9 auth: the redirect the convention derives, so nothing can drift
IAM provisioning derives a desktop client's one redirect as
<scheme>://oauth/<app>; the app now spells hanzo://oauth/mobile through the
same rule instead of a hand-picked hanzo://auth, and the hanzo-mobile
registration in the universe provision document declares NOTHING beyond
type: desktop — two sides of one convention cannot disagree.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-05 23:16:49 -07:00

5.3 KiB

hanzo mobile — the app that follows you out

The native Hanzo AI app: Expo SDK 57 + expo-router + TypeScript on @hanzo/gui 8.1.1 (our RN-native Tamagui). Stack, scripts and layout are in package.json and the tree — this file carries only what those cannot tell you.

The run registry is the spine

lib/runs.ts holds every long piece of work — a chat turn, an image generation, the voice pipeline, an agent task — as one shape in one place. PiP, the home-screen widget and the Tasks tab are subscribers to it; no feature talks to those surfaces directly. Start, update and end a run and every surface follows; render a surface from anything else and the product splits into disagreeing copies. It is pure TS with a tiny emitter so tests drive it without a mounted tree, and snapshots are rebuilt on write and handed out by reference so useSyncExternalStore consumers stay stable between emits.

PiP is two implementations with one face, and the iOS one is frames for a reason

iOS only miniaturizes video layers: AVPictureInPictureController takes an AVSampleBufferDisplayLayer contentSource, and no API puts a live view hierarchy in that window. So modules/pip/ios renders the status card into pixel buffers and feeds them as frames; the OS supplies X/expand/drag. That is why lib/pip.ts forwards run status across the bridge on iOS — throttled to one per second with the trailing update kept — while Android forwards nothing: Android PiP miniaturizes the live activity, so components/pip/PipCard.tsx overlays the React tree while inPip and the tiny window shows it. The two faces must read identically — wordmark, phase, detail, progress, elapsed — one product surface.

The product rule lives once, in follow() inside lib/pip.ts: an active run arms auto-enter and streams status; no run disarms and stops. iOS auto-enters on background through the contentSource pipeline (pod floor 15.1); Android uses setAutoEnterEnabled on API 31+ and onUserLeaveHint below. The audio background mode written by plugins/pip.js is what keeps the process drawing after backgrounding — remove it and the iOS card freezes mid-run.

Ownership map of modules/

  • modules/pip — the native contract. ONE registered name pip on both platforms, same functions, same events (changed, restored). index.ts is the only JS binding, and it states the instance shape itself because expo-modules-core's NativeModule export types the constructor, not the instance. App code never imports it — lib/pip.ts is the facade, and its Status is the slice of a Run the card renders.
  • modules/widget — the same pattern one size smaller: native name Widget, one function set(stateJson), facade lib/widget.ts. iOS persists into App Group group.ai.hanzo.mobile where the WidgetKit extension in targets/widget (generated by @bacons/apple-targets) reads; Android into app prefs where WidgetProvider reads and redraws.
  • Both bindings are requireOptionalNativeModule: null on web and in Expo Go, so every facade call degrades to a silent no-op and callers never branch on platform.

Prebuild owns the native projects — ios/ and android/ are output, never in git. The three plugins are the whole native delta: plugins/pip.js (manifest PiP flags + configChanges, iOS background mode), plugins/widget-android.js (manifest receiver + res copy; replace, never append, so re-running prebuild cannot stack duplicates), plugins/widget-ios.js (App Group entitlement + composes the targets plugin).

zen naming law

zen5* are TEXT models; zen-<noun> is a MODALITY (zen-image, zen-vl, zen-voice, …). zen-omni, zen-coder, zen3-*, zen4-* do not exist — never write them. Default chat model is zen5 when the live catalog carries it, and the fallback in lib/models.ts matches /^zen\d/ and never /^zen-/, because a modality id in the chat slot answers wrongly instead of failing loudly. Image generation is zen-image; a turn with an image attached switches to zen-vl.

Refusals and honesty

lib/api.ts is the one boundary. refusal(status, detail) is the estate mapping: 401 is the credential (never loop back to login), 402 is credits, 403 is permission (never suggest retry), everything else is the service and is the only case where "try again" is honest. reason() is the ONE reader of what a server said — strict JSON only, key material redacted, capped, "" for an HTML error page. Every surface — sessions, image, voice, the auth key probe — speaks through these two; a second copy is how surfaces drift into different sentences for the same refusal. Empty is honest everywhere: an empty catalog renders an empty list, an unbound endpoint says so inline, and the widget before first sync says so instead of faking a conversation.

One credential slot

lib/store.ts keeps ONE slot in expo-secure-store: { kind: 'iam' | 'key', token, refresh?, exp? }. An IAM session from hanzo.id (OIDC PKCE, public client hanzo-mobile, redirect hanzo://oauth/mobile) and a pasted sk- key are both bearer values against api.hanzo.ai, so one shape holds both and bearer() never cares which. Registering the hanzo-mobile client in IAM is the one server-side seed still needed; the sign-in screen says so and the key path works today. Secrets never touch AsyncStorage or a file.