Mobile — architecture spec
Status (2026-05-15): Mobile is the original Fabric product type — RN/Keel-based mobile apps generated by AI, hosted in the iOS
- Android host shells in
appunvs/{ios,android}/. Pipeline is the longest-running of the four project types; treat this doc as the baseline reference the other project types (Web / Desktop / WeChat MP) diverge from.
Mobile is the first of Fabric’s 4 project-type pipelines
(see project-types). Output target: a
React Native bundle compiled to Hermes bytecode, loaded into a
KeelView inside the user’s host shell on real iOS / Android
devices. Reference: how Expo Go loads bundles into a tester app —
but the appunvs host shell additionally provides Chat + Profile tabs
and lives in the user’s own pocket.
Pipeline overview
Chat turn
(user → fabric-server AI)
│
▼ fs_write tool calls into project workspace
┌──────────────────────────┐
│ fabric-server workspace │ = git repo at fabric/server/workspaces/<proj>/
│ (TypeScript / TSX + │ RN-flavoured AI prompt; Tier 1 + 2
│ keel/MODULES allowlist)│ module allowlist enforced at bundle time
└──────────────────────────┘
│
▼ publish_project tool fires the build
┌──────────────────────────────────────┐
│ cloudbuild.HarnessBuilder │ POST harness
│ {ProjectType: "mobile"} │ /v1/projects/{id}/build
└──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ harness: preview.Manager.Build │ metro `react-native bundle`
│ in the project's runner-mobile │ inside the project's own runner
└──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ harness uploads the bundle to the │ artifact.Store.Put →
│ content-addressed object store │ _artifacts/<sha256hex>
│ and returns ONLY a manifest: │
│ {entry:"index.bundle", files[], …} │ no bytes cross fabric-server
└──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ fabric-server records the manifest │ app_bundles.manifest (jsonb)
│ on the bundle row and signs a │ → Bundle.URI = presigned
│ short-lived URL for the entry │ download URL
└──────────────────────────────────────┘
│
▼ SSE event /project/events → host shell
┌──────────────────────────┐
│ User's iOS/Android host │
│ Preview tab refreshes │
│ KeelView, loads new │
│ bundle from URL │
└──────────────────────────┘
Components
Host shells (appunvs/{ios,android}/)
The user-facing apps that live on real devices. Both shells are 3-tab apps with identical IA:
| Tab | iOS implementation | Android implementation |
|---|---|---|
| Chat | SwiftUI ChatView (message list + tool pill UI + input bar) | Compose ChatScreen |
| Preview | SwiftUI host for KeelView (UIView) | Compose host for KeelView (FrameLayout) |
| Profile | SwiftUI account / device / usage UI | Compose equivalent |
The shells embed Keel (Keel.xcframework / keel-sdk.aar) for
bundle runtime. They do not depend on KAS Update — the mobile host
shell fetches bundles directly from fabric-server’s artifact endpoint
(KAS Update is for non-appunvs RN devs).
Auth: each host shell holds a session JWT (issued by fabric-server’s
/auth/signup or /auth/login); the JWT scopes API calls to the
User’s workspace.
See auth.
Keel SDK (keel/)
Provides KeelView, KeelSDK, KeelHostModule. Per-instance
Hermes runtime — every KeelView.loadBundle(at:) boots a fresh
runtime; reset() tears it down. Cross-bundle state cannot leak.
Host bridge surface: AI bundles import { host } from '@keel-ai/host'
for identity / storage / network / publish / subscribe.
Tier 1 + Tier 2 module allowlist (keel/MODULES) is enforced
at metro bundle time by keel/cloud/build-image/metro.config.js.
fabric-server (fabric/server/)
Backend for everything that’s not on-device:
- Auth:
users/devicestables, session + device JWT (seeauth) - AI agent loop (
internal/ai/): one Chat turn → stream of Token / ToolCall / ToolResult / Finished frames. Tools includefs_write(write to workspace git repo) andpublish_project(trigger build → publish). Seeprovidersfor the 3 production AI providers + 1 Stub. - Workspace (
internal/workspace/): per-Project bare git repo; every AIfs_writebecomes a commit. - Build (
internal/cloudbuild/): just the seam. TheBuilderinterface returns anartifact.Manifest, and its one implementation isHarnessBuilder(ProjectType: "mobile"for this pipeline) — a thin client that POSTs/v1/projects/{id}/buildto the harness. metro runs in the project’srunner-mobilecontainer, not here; fabric-server has no Node toolchain and no source checkout. A harness is required — the oldLocalStub/DockerBuilder(appunvs/cloud-build-mobile) are deleted, and there is no local fallback. - Artifact (
internal/artifact/): the READ side of the content-addressed store —artifact.S3against an S3-compatible bucket (Aliyun OSS in prod/staging, MinIO in local dev), the same bucket the harness uploads to (_artifacts/<sha256hex>).LocalFSis unit-tests only.SignURLmints the short-lived download URL the host shell fetches the bundle from. - Project events (
/project/eventsSSE endpoint): fanout to the user’s host shells when a new bundle becomes ready.
What’s in Mobile GA
| Component | Status |
|---|---|
| iOS host shell (Chat + Preview + Profile) | ✅ shipped |
| Android host shell (Chat + Preview + Profile) | ✅ shipped |
Keel SDK (Keel.xcframework, keel-sdk.aar) | ✅ shipped |
KeelView + per-instance Hermes | ✅ shipped |
Host bridge (@appunvs/host) | ✅ shipped |
| Tier 1 / 2 module allowlist | ✅ shipped |
| fabric-server auth + AI agent loop | ✅ shipped |
metro bundling in the harness (runner-mobile + cloudbuild.HarnessBuilder{ProjectType:"mobile"}) | ✅ shipped |
Content-addressed publish (harness uploads → manifest → app_bundles.manifest, goose 00013) | ✅ shipped |
/project/events SSE fanout | ✅ shipped |
cloudbuild.DockerBuildercloud-build-mobile docker image | ❌ retired — superseded by the harness path above |
Project-events SSE shape
Same shape across all 4 project types (see
architecture for the cross-type contract):
event: bundle_ready
data: {
"project_id": "proj_abc",
"version": 17,
"runtime": "rn_keel",
"bundle_uri": "https://<bucket>.<oss-endpoint>/_artifacts/<sha256hex>?<signature>"
}
bundle_uri is the short-lived presigned object-store URL minted from the
manifest’s entry hash (artifact.Store.SignURL) — the host fetches the bundle
straight from the bucket, never through fabric-server.
Host shell’s Preview tab subscribes via SSE. On bundle_ready for
the currently displayed project, it calls
KeelView.loadBundle(at: bundle_uri) to remount.
Tier 1 / 2 / Forbidden modules
keel/MODULES is the canonical allowlist of npm packages AI
bundles may import:
- Tier 1: core RN + first-party Keel modules (
@keel-ai/router/@keel-ai/wechat/ etc). No friction. - Tier 2: vetted community modules (
react-native-svg,react-native-screens, etc). Pre-bundled into the build image; no autolink dance. - Forbidden: anything with native modules that aren’t on Tier 1 or 2. Build fails at metro time with a clear error.
Keel Go runs the same allowlist — non-Tier-1 native modules require Keel Dev Client.
Connection to Mortar BaaS
AI bundles import { mortar } from '@mortar-ai/client' for AppUser
data (auth / data / files / realtime). Mortar is a 可选 plugin
— bundles can also call host bridge network.request to hit any
HTTP backend.
⚠️ Fabric itself does NOT run on Mortar — user accounts, project metadata, ai_turns, builds, bundles all live in Appunvs’s own infrastructure (fabric-server’s Postgres + Redis, the harness’s runners, and the Appunvs-operated artifact bucket) — same pattern as an AI builder not running its own editor on the BaaS it provides users. Mortar holds only the AI bundle’s own end-user-facing data.
See architecture § 责任划分.
Decision points (revisit if pain surfaces)
/project/eventsSSE vs WebSocket. An early build used WebSocket; Mobile switched to SSE-based events (simpler client; works through more proxies; reconnect handled by the standardEventSourceAPI). Revisit if bidirectional push from host → fabric-server becomes common (currently rare; most writes are HTTP POST).LocalFS artifact store vs OSS— settled. Published artifacts always live in S3-compatible object storage, never on a service’s local disk: Aliyun OSS via its S3-compatibility endpoint in prod/staging (bucketappunvs-artifacts, cn-shenzhen), MinIO locally (shared/deploy/compose.local.yml).LocalFSsurvives only in unit tests. This is what lets the harness write and fabric-server read without any shared volume.- Host shell branding. Mobile ships as “Fabric” on both stores. A later iteration revisits whether enterprise customers need their own branded shell (likely yes; planned for the Enterprise pack).
What Web/Desktop/WeChat inherit from Mobile
The non-mobile project types all reuse Mobile’s substrate:
- Same fabric-server (auth, AI agent, workspace, project state machine, SSE events)
- Same
cloudbuild.Builderinterface — and in fact the same single implementation: web / mobile / desktop all usecloudbuild.HarnessBuilder, differing only by theproject_typeit sends, and all return the sameartifact.Manifest. Each compiles in its own harness runner. (The old server-side*DockerBuilders +cloud-build-*images,MultiBuilder/MultiResult, and every local stub were deleted.) WeChat is the one exception —cloudbuild.WeChatBuilderpublishes to Tencent and returns a receipt, not a manifest. - Same project-events SSE schema
- Same host shell Chat + Profile tabs (Preview tab swaps out per project type — KeelView for Mobile, webview iframe for Web/Desktop, QR for WeChat)