Keel — Zero to production tutorial
Status: reference tutorial. Follow this end to end and you’ll ship a real React Native app to App Store + Google Play + at least one China market (Huawei AppGallery), with OTA updates working.
Estimated time: 3-4 hours of hands-on work (excluding store account onboarding + 软著 申请, which run in parallel and take days).
Audience: a React Native developer who knows the basics (useState,
<Text>, FlatList) but hasn’t used Keel before. No prior Expo
experience required.
You’ll cross-link to:
architecture.md— for the 5-layer designsubmit.md§ 11 — for credentials schema per marketcustom-domain.md— for self-hosted Update../examples/— for more sample projects
1. What you’ll build
A simple Notes app:
- list all notes
- tap a note to edit (title + body)
- swipe to delete
- everything persists across cold-starts
Stack:
@keel-ai/router— file-based routing (App Router style)@react-native-async-storage/async-storage— community native module for persistence (no Mortar dep, no Supabase, no backend at all — pure local storage)- 2 screens: list (
app/index.tsx) + edit (app/notes/[id].tsx)
By the end, the same app runs on iOS App Store, Google Play, and Huawei AppGallery, and you can push a bug-fix as an OTA without re-submitting to any store.
┌─────────────────┐ ┌──────────────────────┐
│ app/index.tsx │ ──tap──▶│ app/notes/[id].tsx │
│ (note list) │ ◀─back──│ (note editor) │
└─────────────────┘ └──────────────────────┘
│ │
└────────── AsyncStorage ────┘
(one JSON blob keyed by id)
2. Prerequisites
Minimum versions:
| Tool | Version | Why |
|---|---|---|
| Node | ≥ 20.10 | Native fetch + corepack stable |
| Java | 17 LTS | Required by Android Gradle Plugin 8.x |
| Xcode | ≥ 15 | Required for iOS 17 SDK |
| macOS | 13.5+ | Xcode 15 minimum |
| Android SDK | API 34 | Required by RN 0.85 |
Skip Xcode / macOS if you only target Android — KAS Build runs the iOS
toolchain in the cloud, but keel start against iOS Simulator needs a
local Mac.
Install the CLI
npm install -g @keel-ai/cli
keel --version # → @keel-ai/cli 0.1.x
Run keel doctor
keel doctor
Sample output (all green):
✓ Node 20.11.0 (≥ 20.10)
✓ Java 17.0.10 (Eclipse Temurin)
✓ Xcode 15.3 (iOS SDK 17.4)
✓ Android SDK API 34
✓ keel.json found at ./keel.json (or skipped — we're not in a project)
If a check fails, the doctor tells you exactly what to install — fix each one before continuing. Don’t skip this step: every issue caught here saves a 20-minute round-trip on a cloud build.
3. Create the project
keel create -t blank notes-app
cd notes-app
The blank template gives you a single App.tsx and a keel.json.
We’ll add file-based routing in §4.
Scaffolded structure
notes-app/
├── App.tsx ← entry component (we'll replace this)
├── index.tsx ← AppRegistry.registerComponent
├── package.json
├── keel.json ← project + endpoint config (see below)
├── tsconfig.json
├── ios/ ← native iOS shell (autogenerated)
└── android/ ← native Android shell (autogenerated)
keel.json:
{
"project": "notes-app",
"endpoint": {
"ota": "https://api.keel.appunvs.com",
"cloud_build": "https://api.keel.appunvs.com"
},
"runtime_version": "1.0",
"channel": "production",
"platforms": ["ios", "android"]
}
The endpoint.ota URL points at Keel’s hosted Update server. For
self-hosted, see custom-domain.md.
Free vs Pro: the
keel.appunvs.comhosted Update server is free up to 10k MAU; seepricing.md. Self-hosting carries no licensing fee (Apache 2.0 license).
4. File-based routing
Replace the single-screen App.tsx with the App Router-style app/
tree.
Install the router
keel install @keel-ai/router
keel install is RN-aware — it picks the version of @keel-ai/router
compatible with your keel.json’s runtime_version, and runs the
necessary autolink steps. Same as plain npm install for JS-only
deps, but checked.
Create the app/ directory
mkdir -p app/notes
Three files:
app/_layout.tsx — root navigator
import { Stack } from '@keel-ai/router';
import { RouteProvider } from '@keel-ai/router/internal';
import { loadRoutes } from '@keel-ai/router';
// require.context walks the app/ tree at runtime today.
// A metro plugin will emit this statically later — same API, faster boot.
const ctx = require.context('./', true, /\.(tsx|ts)$/);
const routes = loadRoutes(ctx);
export default function Root() {
return (
<RouteProvider value={routes}>
<Stack screenOptions={{ headerTitle: 'Notes' }} />
</RouteProvider>
);
}
app/index.tsx — note list (route /)
import { useEffect, useState } from 'react';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { useRouter } from '@keel-ai/router';
import AsyncStorage from '@react-native-async-storage/async-storage';
type Note = { id: string; title: string; body: string };
const STORAGE_KEY = 'notes-app:notes';
export default function NoteList() {
const router = useRouter();
const [notes, setNotes] = useState<Note[]>([]);
useEffect(() => {
AsyncStorage.getItem(STORAGE_KEY).then((raw) => {
if (raw) setNotes(JSON.parse(raw) as Note[]);
});
}, []);
const addNote = async () => {
const id = String(Date.now());
const next = [...notes, { id, title: 'Untitled', body: '' }];
setNotes(next);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
router.push(`/notes/${id}`);
};
const deleteNote = async (id: string) => {
const next = notes.filter((n) => n.id !== id);
setNotes(next);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
};
return (
<View style={styles.container}>
<FlatList
data={notes}
keyExtractor={(n) => n.id}
renderItem={({ item }) => (
<Pressable
onPress={() => router.push(`/notes/${item.id}`)}
onLongPress={() => deleteNote(item.id)}
style={styles.row}
>
<Text style={styles.rowTitle}>{item.title}</Text>
<Text style={styles.rowBody} numberOfLines={1}>{item.body}</Text>
</Pressable>
)}
ListEmptyComponent={
<Text style={styles.empty}>No notes yet — tap + to add one.</Text>
}
/>
<Pressable style={styles.fab} onPress={addNote}>
<Text style={styles.fabText}>+</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#FFFFFF' },
row: { padding: 16, borderBottomColor: '#E2E8F0', borderBottomWidth: 1 },
rowTitle: { fontSize: 16, fontWeight: '600', color: '#0F172A' },
rowBody: { fontSize: 13, color: '#64748B', marginTop: 4 },
empty: { textAlign: 'center', marginTop: 48, color: '#94A3B8' },
fab: {
position: 'absolute', right: 24, bottom: 24,
width: 56, height: 56, borderRadius: 28,
backgroundColor: '#0E7490', justifyContent: 'center', alignItems: 'center',
},
fabText: { color: '#FFFFFF', fontSize: 28, lineHeight: 28 },
});
app/notes/[id].tsx — editor (route /notes/:id)
import { useEffect, useState } from 'react';
import { StyleSheet, Text, TextInput, View } from 'react-native';
import { useLocalSearchParams, useRouter } from '@keel-ai/router';
import AsyncStorage from '@react-native-async-storage/async-storage';
type Note = { id: string; title: string; body: string };
const STORAGE_KEY = 'notes-app:notes';
export default function NoteEditor() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const [note, setNote] = useState<Note | null>(null);
useEffect(() => {
AsyncStorage.getItem(STORAGE_KEY).then((raw) => {
if (!raw) return;
const all = JSON.parse(raw) as Note[];
setNote(all.find((n) => n.id === id) ?? null);
});
}, [id]);
const save = async (patch: Partial<Note>) => {
if (!note) return;
const updated = { ...note, ...patch };
setNote(updated);
const raw = await AsyncStorage.getItem(STORAGE_KEY);
const all = raw ? (JSON.parse(raw) as Note[]) : [];
const next = all.map((n) => (n.id === id ? updated : n));
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
};
if (!note) return <Text style={styles.loading}>Loading…</Text>;
return (
<View style={styles.container}>
<TextInput
style={styles.title}
value={note.title}
onChangeText={(t) => save({ title: t })}
placeholder="Title"
/>
<TextInput
style={styles.body}
value={note.body}
onChangeText={(t) => save({ body: t })}
placeholder="Note body…"
multiline
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16, backgroundColor: '#FFFFFF' },
title: { fontSize: 22, fontWeight: '700', color: '#0F172A', marginBottom: 12 },
body: { fontSize: 16, color: '#1E293B', textAlignVertical: 'top', flex: 1 },
loading: { textAlign: 'center', marginTop: 48, color: '#94A3B8' },
});
Wire index.tsx to the router
// index.tsx — replaces App.tsx as the registered component
import { AppRegistry } from 'react-native';
import Root from './app/_layout';
AppRegistry.registerComponent('notes-app', () => Root);
You can now delete the original App.tsx.
Routing conventions reference
| Filesystem | URL |
|---|---|
app/index.tsx | / |
app/about.tsx | /about |
app/notes/index.tsx | /notes |
app/notes/[id].tsx | /notes/:id |
app/notes/[id]/edit.tsx | /notes/:id/edit |
app/(auth)/sign-in.tsx | /sign-in (group hidden from URL) |
Same as Expo Router / Next.js App Router. See @keel-ai/router/README.md.
5. Persist state with AsyncStorage
@react-native-async-storage/async-storage is a community native
module — autolink-installed; works in Keel Go if Keel Go’s binary
links it.
keel install @react-native-async-storage/async-storage
On iOS this runs pod install. On Android, autolink picks it up at the
next gradle sync — no extra step.
What you just got
A persistent JSON-blob k/v store at the OS level (NSUserDefaults on iOS, SharedPreferences on Android). Survives cold starts, deletes with the app.
For our notes app, that’s enough. Real apps with more structured data
should look at react-native-mmkv (faster, encrypted) or — if you want
cross-device sync — a BaaS like Mortar / Supabase / Firebase.
No Mortar dependency. Keel and Mortar are completely decoupled. This app uses zero backend — purely local storage. See
architecture.md§ Mortar 关系 for why.
6. Run locally
Start the dev server
keel start
This boots metro + opens a tunnel that Keel Go can reach. Sample output:
metro waiting on http://localhost:8081
QR code:
████████████████████
████ ▄▄▄▄▄ █▀ █ ████
...
Or paste this URL into Keel Go:
keel://exp.host/abc123
Install Keel Go on a real device
App Store (iOS), or any of 华为 / 小米 / OPPO / vivo / 应用宝 (Android): search “Keel Go”. Free, no account required.
Open Keel Go → scan the QR code → your notes app loads in ~5 seconds.
Why Keel Go and not an Expo Go clone? Same UX, different module set: Keel Go’s binary links China-friendly modules (
@keel-ai/wechat,@keel-ai/alipay, etc.). Expo Go’s module set is FCM-centric, which doesn’t work in China.
Iterate
Edit any file under app/. Hot Module Replacement re-renders without
losing state. This is the inner loop — you should never need to
re-build the host or re-install Keel Go during normal development.
7. Build for App Store + Google Play
When you’re ready to ship, you trigger a cloud build that produces
signed .ipa and .apk (or .aab) artifacts.
keel build ios
keel build android
Each invocation produces one platform’s artifact; run them in sequence (or in parallel in CI). What KAS Build does in the cloud (alibaba 云 ECI / FC pod):
- Pulls your project tarball
- Runs
metro bundle→ produces JS bundle - Runs
hermesc→ compiles JS to Hermes bytecode (~3x faster cold-start) - Cross-compiles native shells (iOS
.ipa, Android.aab) - Signs with your uploaded provisioning profile + keystore
- Uploads the artifact to your project’s OSS bucket
- Returns a download URL
$ keel build ios
→ uploading tarball (4.3 MB) ... done
→ build queued (id=bld_a8f2)
iOS: building [██████░░░░] 60%
→ build complete (id=bld_a8f2, 4 min 12 sec)
iOS: https://oss.keel.appunvs.com/builds/bld_a8f2/notes-app.ipa
The artifact lands at the path you pass via --output (default
./build/<platform>.bin); inspect / archive from there.
Why cloud build and not local
- No need to install Xcode + Android SDK + matching JDK across your team
- Reproducible: same image, same versions, same output bytes
- Signed: certificates live in Keel’s encrypted vault, not on devs’ laptops
- Free for first 30 builds/month on Free tier (
pricing.md)
8. Set up Apple ASC + Play Console credentials
Before keel submit can upload, each market needs its credentials
filed under ~/.keel/credentials.yml. Full schema per market is in
submit.md § 11; summary here:
8.1 Apple App Store Connect
- App Store Connect → Users and Access → Keys → ”+”
- Name it
keel-submit, roleApp Manager - Download the
AuthKey_<KEY_ID>.p8(you only get one chance) - Note the Issuer ID + Key ID
# ~/.keel/credentials.yml
ios_appstore:
issuer_id: "1234abcd-..."
key_id: "ABC123"
p8_path: "/Users/you/.keel/AuthKey_ABC123.p8"
bundle_id: "com.yourcorp.notesapp"
chmod 600 ~/.keel/credentials.yml — Keel will warn on next run if
permissions are too open.
8.2 Google Play Console
- GCP Console → IAM → Service Accounts → Create
- Grant role: Service Account User + grant access in Play Console: Users and Permissions → Invite → role Release Manager
- Download the service-account JSON
android_googleplay:
service_account_json: "/Users/you/.keel/play-svc.json"
package_name: "com.yourcorp.notesapp"
8.3 Huawei AppGallery
- AppGallery Connect → My projects → pick your project → API key
- Generate a client_id + client_secret pair (OAuth 2.0)
- From the App detail card, copy
app_idandproject_id
huawei:
app_id: "100000123"
client_id: "abcdef123456"
client_secret: "<long-string>"
project_id: "987654321"
Per-market field tables (including 小米 / OPPO / vivo / 应用宝) are in
submit.md § 11.
8.4 What Keel can’t do for you
Before keel submit works at all, you need:
- Enterprise developer account at each market (vivo / OPPO / 应用宝 require enterprise; Huawei accepts individual)
- Qualification review at each — 1-3 days each
- 软件著作权 (软著) for the Chinese markets — 中国版权保护中心, ~30 day review, ¥300-600
- App entry created in each market’s portal (name, logo, screenshots, privacy policy) — Keel doesn’t auto-create these because they involve trademark + legal content
Start these in parallel with development. Total wall-clock: ~4 weeks to first submit-ready state for all China markets, ~1 week for iOS + Google Play.
9. First submit
Single-target:
keel submit ios --to=ios_appstore --build=./builds/notes-app.ipa
Multi-target (fan-out):
keel submit android \
--to=android_googleplay,huawei \
--build=./builds/notes-app.aab \
--release-notes="v1.0.0 — initial release"
Output:
→ ios_appstore uploaded (submission_id=asc_a8f2)
→ android_googleplay uploaded (submission_id=gp_5e1b)
→ huawei uploaded (submission_id=hw_7f9c)
Watch status:
keel submit watch bld_a8f2
Each market goes into its review queue; per-market review times:
| Market | Typical | Worst case |
|---|---|---|
| Apple App Store | 24-48h | 7 days |
| Google Play | 4-24h | 3 days |
| Huawei AppGallery | 2-3 days | 7 days |
| 小米 | 1-3 days | 7 days |
| OPPO / vivo | 2-5 days | 14 days |
| 应用宝 | 3-7 days | 14 days |
Poll status:
keel submit status --build=bld_a8f2
Or block (CI-friendly):
keel submit watch bld_a8f2 # blocks until all markets terminal
When a market rejects, the reason is in keel submit show <submission-id>.
Fix, rebuild, resubmit — the same submission_id chain tracks across
rounds.
10. OTA: ship a bug fix without re-submitting
Now the killer feature. Your app is live in stores; you spot a typo in a button label. Re-submitting takes 1-7 days per market. Or:
// app/index.tsx — change one line
<Text style={styles.fabText}>+</Text>
// becomes
<Text style={styles.fabText}>+</Text> // fullwidth plus, more legible
Then:
# Build the iOS bundle artifact (cloud build, same path as native build)
keel build ios
# Push it to the Update channel
keel publish ./build/ios.bin --platform=ios --bundle-version=v1.0.1
Output:
→ uploading bundle (1.2 MB) ... done
→ sha256: a8f2e9c3...
→ rotated production/ios pointer → a8f2e9c3
→ live for clients with runtime_version=1.0
Open your app on the device — pull to refresh, or just cold-start. The new bundle downloads (~50 KB after bsdiff4 patching against the previous one) and runs on the next launch. No App Store review, no 3-day wait.
How it works under the hood
- Client SDK calls
GET /v1/notes-app/update/manifest?current_hash=<old> - Server checks: is there a newer hash for
production/iosmatchingruntime_version=1.0? - If yes, returns
{ next_hash, patch_url }wherepatch_urlis a bsdiff4 patch fromcurrent_hash→next_hash - Client downloads patch (~50 KB instead of full 1.2 MB), applies it, verifies sha256 of the result, swaps the bundle on next cold start
Full protocol: update.md.
Constraints — what OTA can and can’t change
| Change | OTA-able? | Why |
|---|---|---|
| JS code / styles / images | ✓ | All inside the JS bundle |
| Adding a new screen | ✓ | Just more JS |
| Bumping a native module | ✗ | New native code → store re-submit |
| Changing app icon / launch screen | ✗ | These live in the native shell |
Bumping runtime_version in keel.json | ✗ | Different runtime = different client SDK |
Rule of thumb: if you didn’t add a native dependency, OTA works.
11. Roll out gradually
Don’t blast a new bundle to 100% of users immediately — even if your own tests pass, edge devices (old Android API levels, weird locales) will surprise you.
Stage at 10%
keel publish ./build/ios.bin \
--platform=ios \
--bundle-version=v1.0.1 \
--rollout=10
10% of clients (deterministic hash on device-id, not random per-fetch) get the new bundle. Monitor for an hour.
Bump to 50%, then 100%
Re-keel publish the same bundle with a higher --rollout:
keel publish ./build/ios.bin \
--platform=ios \
--bundle-version=v1.0.1 \
--rollout=50
# wait, monitor
keel publish ./build/ios.bin \
--platform=ios \
--bundle-version=v1.0.1 \
--rollout=100
The bundle hash is identical (deterministic from the bytes), so each re-publish skips upload and only rotates the manifest’s rollout percent.
Rollback in one command
Saw an error spike? Roll back:
keel rollback <previous-hash> --platform=ios
The pointer rotates back; clients fetching the manifest within the next ~30 sec download the previous bundle as a patch. Median user is on the old bundle within 5 minutes.
The previous hash is always whatever keel manifest --platform=ios
shows as the previous current.
Rollback doesn’t delete the broken bundle — it stays in storage so you can re-promote after fixing. Old-bundle GC is server-side (operator policy, not a CLI command currently).
12. What’s next
You’ve shipped. Now what?
Adding more modules
Common modules used by typical apps:
react-native-mmkv— faster + encrypted alternative to AsyncStoragereact-native-svg— SVG renderingreact-native-reanimated— high-perf animations (uses worklets)react-native-screens— native nav stack performance@keel-ai/wechat/@keel-ai/alipay— China payment SDKs@keel-ai/amap— 高德地图 native SDK@keel-ai/umeng— analytics
Add any with keel install <pkg>.
Custom domain for OTA
If you don’t want clients pinging api.keel.appunvs.com, self-host the Update
server behind your own domain. Caddy + Let’s Encrypt + Aliyun OSS:
update.mycorp.com {
reverse_proxy localhost:8081
}
Full setup including Aliyun DNS-01 challenge (for 备案-blocked port
80): custom-domain.md.
VS Code extension
keel-vscode adds:
- syntax highlighting for
module.yml/keel.json - inline build status (which hash is live on which channel)
- Keel module suggestions in
importautocomplete keel doctorintegrated as a status bar item
Install from VS Code Marketplace; source at packages/vscode-keel/.
CI/CD with GitHub Actions
Templates for build + publish on push:
# .github/workflows/keel-build.yml
name: keel build + publish
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm install -g @keel-ai/cli
- run: keel build ios
env: { KEEL_API_KEY: ${{ secrets.KEEL_API_KEY }} }
- run: keel build android
env: { KEEL_API_KEY: ${{ secrets.KEEL_API_KEY }} }
- run: keel publish ./build/ios.bin --platform=ios --bundle-version=v1.0.${{ github.run_number }} --rollout=10
env: { KEEL_API_KEY: ${{ secrets.KEEL_API_KEY }} }
Full reference workflows in ../templates/github-actions/.
AI-assisted development
@keel-ai/skill is a Claude Code skill bundle
that teaches the agent about Keel-specific APIs (router conventions,
official modules). Drop it into .claude/skills/ and Claude can write
Keel-shaped code for you.
keel install @keel-ai/skill
See packages/skill/README.md.
More examples
../examples/ ships 5 reference apps:
| Folder | What it shows |
|---|---|
01-todo-list | This tutorial’s app, extended with categories |
02-chat-im | Real-time IM via Mortar realtime primitive |
03-ecommerce | Product catalog + cart + 微信支付 checkout |
04-wechat-pay-flow | Standalone 微信支付 SDK integration |
05-amap-nearby | 高德地图 + geofence + push notification flow |
Clone any with keel create -t example:02-chat-im my-clone.
13. Troubleshooting
keel doctor fails
| Error | Fix |
|---|---|
Node 18.x detected, need ≥ 20.10 | nvm install 20 && nvm use 20 |
Java 11 detected, need 17 | sdk install java 17.0.10-tem (or brew) |
Xcode-select not pointing at Xcode 15 | sudo xcode-select -s /Applications/Xcode.app |
ANDROID_HOME not set | export ANDROID_HOME=$HOME/Library/Android/sdk |
pod not found | sudo gem install cocoapods |
keel install fails
| Error | Fix |
|---|---|
pod install failed | Try cd ios && pod install --repo-update |
Version conflict with @keel-ai/router | keel install <pkg>@<compatible-version> |
Cloud build fails
CLI streams the build logs inline during keel build <platform> and
prints the build_id on failure for offline lookup via the dashboard
(no separate logs subcommand currently).
Common causes:
- metro symbolicate errors: usually a stale
node_modules; rebuild the tarball withrm -rf node_modules && npm ci && keel build - Hermes byte-compile fails: an incompatible module slipped in;
keel doctorafterkeel installwill catch this earlier - Code signing fails: missing or expired provisioning profile; see the cloud build log for the exact missing entitlement
keel submit credential errors
| Error | Driver | Fix |
|---|---|---|
401 invalid issuer_id | ios_appstore | Re-copy from ASC → Users → Keys |
403 service account lacks Release Manager | android_googleplay | Re-invite in Play Console |
client_secret expired | huawei | Re-issue in AGC Connect |
RSA key not registered | xiaomi | Submit public key to 小米后台 → wait 24h |
signature mismatch | oppo / vivo | Check HMAC alg: OPPO is SHA1, vivo is SHA256 |
Full per-driver credential schema + rotation difficulty: submit.md § 11.
OTA: clients aren’t picking up the new bundle
keel manifest --platform=ios --project=notes-app
Check the response:
current_hashis what you just published? Good — server side is fine.- Now on device: shake to open dev menu → “Force OTA check” — this bypasses the rate-limit and re-queries.
- If still nothing: check the client’s
runtime_versionmatches what you published with. Mismatch = silently skipped.
App Store rejection: “Guideline 4.3 — Spam (low-quality clone)”
Apple’s bot sometimes flags JS-only apps as low-effort. Mitigation:
- Add at least one native binding (we used AsyncStorage; bump to MMKV
or
expo-camera-equivalent if needed) - Fill out the App Store metadata fully (screenshots, description, privacy policy URL)
- Reply to the rejection with a build summary explaining the native integrations — usually approved on appeal within 48h
Huawei AppGallery rejection: “应用使用了未授权 SDK”
Huawei scans the apk for SDK fingerprints; sometimes flags
react-native-mmkv or older Hermes prefixes. Mitigation:
- Make sure your Hermes is the version pinned in
keel.jsonruntime_version(Huawei has an allowlist) - If they still flag: file an exemption request in AGC Connect with the module’s source URL — Hermes is open source and the Huawei review team accepts this in ~24h
Where to go from here
architecture.md— the 5-layer design (SDK / CLI / Build / Update / Submit) + how they composesubmit.md§ 11 — every driver’s credential schemacustom-domain.md— self-host the Update server behind your own Caddy + Aliyun OSS + CDNpricing.md— Free / Pro / Team / Enterprise tiersmodules.md— the 5 China-first native modules (微信 / 支付宝 / 高德 / 推送 / 友盟)governance.md— RFC process, Apache 2.0 governance, how to contribute
Questions / issues: GitHub Discussions (keel-rfc / keel-help channels), 飞书 group via the docs site footer.