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]>
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 namepipon both platforms, same functions, same events (changed,restored).index.tsis the only JS binding, and it states the instance shape itself because expo-modules-core'sNativeModuleexport types the constructor, not the instance. App code never imports it —lib/pip.tsis the facade, and itsStatusis the slice of a Run the card renders.modules/widget— the same pattern one size smaller: native nameWidget, one functionset(stateJson), facadelib/widget.ts. iOS persists into App Groupgroup.ai.hanzo.mobilewhere the WidgetKit extension intargets/widget(generated by @bacons/apple-targets) reads; Android into app prefs whereWidgetProviderreads 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.