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:


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:

ToolVersionWhy
Node≥ 20.10Native fetch + corepack stable
Java17 LTSRequired by Android Gradle Plugin 8.x
Xcode≥ 15Required for iOS 17 SDK
macOS13.5+Xcode 15 minimum
Android SDKAPI 34Required 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.com hosted Update server is free up to 10k MAU; see pricing.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

FilesystemURL
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):

  1. Pulls your project tarball
  2. Runs metro bundle → produces JS bundle
  3. Runs hermesc → compiles JS to Hermes bytecode (~3x faster cold-start)
  4. Cross-compiles native shells (iOS .ipa, Android .aab)
  5. Signs with your uploaded provisioning profile + keystore
  6. Uploads the artifact to your project’s OSS bucket
  7. 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

  1. App Store Connect → Users and Access → Keys → ”+”
  2. Name it keel-submit, role App Manager
  3. Download the AuthKey_<KEY_ID>.p8 (you only get one chance)
  4. 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

  1. GCP Console → IAM → Service Accounts → Create
  2. Grant role: Service Account User + grant access in Play Console: Users and Permissions → Invite → role Release Manager
  3. 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

  1. AppGallery Connect → My projects → pick your project → API key
  2. Generate a client_id + client_secret pair (OAuth 2.0)
  3. From the App detail card, copy app_id and project_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:

  1. Enterprise developer account at each market (vivo / OPPO / 应用宝 require enterprise; Huawei accepts individual)
  2. Qualification review at each — 1-3 days each
  3. 软件著作权 (软著) for the Chinese markets — 中国版权保护中心, ~30 day review, ¥300-600
  4. 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:

MarketTypicalWorst case
Apple App Store24-48h7 days
Google Play4-24h3 days
Huawei AppGallery2-3 days7 days
小米1-3 days7 days
OPPO / vivo2-5 days14 days
应用宝3-7 days14 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

  1. Client SDK calls GET /v1/notes-app/update/manifest?current_hash=<old>
  2. Server checks: is there a newer hash for production/ios matching runtime_version=1.0?
  3. If yes, returns { next_hash, patch_url } where patch_url is a bsdiff4 patch from current_hashnext_hash
  4. 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

ChangeOTA-able?Why
JS code / styles / imagesAll inside the JS bundle
Adding a new screenJust more JS
Bumping a native moduleNew native code → store re-submit
Changing app icon / launch screenThese live in the native shell
Bumping runtime_version in keel.jsonDifferent 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 AsyncStorage
  • react-native-svg — SVG rendering
  • react-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 import autocomplete
  • keel doctor integrated 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:

FolderWhat it shows
01-todo-listThis tutorial’s app, extended with categories
02-chat-imReal-time IM via Mortar realtime primitive
03-ecommerceProduct catalog + cart + 微信支付 checkout
04-wechat-pay-flowStandalone 微信支付 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

ErrorFix
Node 18.x detected, need ≥ 20.10nvm install 20 && nvm use 20
Java 11 detected, need 17sdk install java 17.0.10-tem (or brew)
Xcode-select not pointing at Xcode 15sudo xcode-select -s /Applications/Xcode.app
ANDROID_HOME not setexport ANDROID_HOME=$HOME/Library/Android/sdk
pod not foundsudo gem install cocoapods

keel install fails

ErrorFix
pod install failedTry cd ios && pod install --repo-update
Version conflict with @keel-ai/routerkeel 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 with rm -rf node_modules && npm ci && keel build
  • Hermes byte-compile fails: an incompatible module slipped in; keel doctor after keel install will 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

ErrorDriverFix
401 invalid issuer_idios_appstoreRe-copy from ASC → Users → Keys
403 service account lacks Release Managerandroid_googleplayRe-invite in Play Console
client_secret expiredhuaweiRe-issue in AGC Connect
RSA key not registeredxiaomiSubmit public key to 小米后台 → wait 24h
signature mismatchoppo / vivoCheck 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_hash is 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_version matches 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:

  1. Make sure your Hermes is the version pinned in keel.json runtime_version (Huawei has an allowlist)
  2. 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 compose
  • submit.md § 11 — every driver’s credential schema
  • custom-domain.md — self-host the Update server behind your own Caddy + Aliyun OSS + CDN
  • pricing.md — Free / Pro / Team / Enterprise tiers
  • modules.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.