Keep the core. Swap the shell.
Your web app becomes your mobile app’s interface. A small Flutter host ships through the stores once. Every UI release after that is signed, tried on each phone and rolled back on its own if it fails to start. Data lives in native SQLite, so the app works offline from the first launch.
npm create valence@latestA reference app running on a simulated phone. The console follows the update rules in the spec, with time sped up: release a bundle, restart the phone, ship a broken build and watch it revert.
sync.now over the bridge.
Next
A small native host. Everything users see is your web build.
The Flutter host goes through store review and rarely changes. It serves your web build from the phone itself, keeps data in SQLite, and checks for new signed bundles in the background.

| What changed | How it reaches users | Store review |
|---|---|---|
| UI, copy, styles, web logic | A signed over-the-air bundle with a staged rollout: 10%, then 50%, then 100% | Not needed |
| Data, or a fix to bundled data | Sync. The fix is a new row with a higher sequence number. | Not needed |
| A new native capability or feature area | A normal app release | Required |
Signed on your laptop, verified on every phone.
A release is a set of content-addressed files, a manifest signed with Ed25519, and a small pointer that says which manifest is live. Phones download only the files whose hash changed.

- 01
Build and sign
valence bundlehashes every file, writes the manifest with the page’s CSP, and signs it. It refuses any native code. - 02
Upload, pointer last
Files go up first, then the manifest and its
.sig, then the pointer. A phone never sees a manifest whose files are missing. - 03
Verify in a fixed order
Signature, then
appId, thenseqagainst the pointer, then the floor, thenrequires, then every path, hash and size. Any failure stops the update. - 04
Try it on the next launch
The new bundle runs as a trial. Your web app calls
ready()after its first paint, and that call commits the update. - 05
Commit, or go back
No
ready()within 15 seconds of foreground time means the phone returns to the last good version. A second failure marks the bundle bad. The bundle built into the app is the final fallback.
{
"format": 1,
"appId": "app.valence.reference",
"seq": 43,
"version": "2026.10.16+43",
"floor": 40,
"requires": { "bridge": 1, "data": { "schedule": 2 } },
"entry": "index.html",
"csp": "default-src 'self'; script-src 'self' 'sha256-…'; frame-src 'none'",
"urgent": false,
"routes": ["/", "/schedule", "/settings"],
"files": {
"index.html": { "h": "9f2c41…e41a", "n": 1834 },
"_app/immutable/entry/start.js": { "h": "03bd7e…a9c0", "n": 48210 }
},
"keyId": "k1"
}
seq only goes up. It is the bundle’s identity; version is for display.floor is the lowest seq a phone may run after this one. Raise it after a security fix.csp is computed at build time and sent as the entry page’s header.routes are compared with the last store-reviewed build to catch purpose drift.
Rolling back means rolling forward. Release the last good commit again. It gets a new, higher seq, so every phone moves forward to it. Phones still trying the bad bundle revert on their own.
The first launch works with no network at all.
The app ships with seed.db, so every screen has data before the phone has ever been online. After that, the phone asks for everything after the last row it has.
- usn 1239schedule/day-2Day 2 sessionsseed
- usn 1240schedule/day-1Day 1, offline talk in Hall Bseed
- usn 1241schedule/day-2Day 2, adds a workshop at 14:00new
- usn 1242schedule/day-1Day 1, offline talk moved to Hall C, lightning talks at 16:30fix
CREATE TABLE kv (bucket, schema, key, value, usn, deleted) -- JSON values CREATE TABLE bucket (name, schema, max_usn, synced_at) -- where each pull resumes CREATE TABLE outbox (id, bucket, key, value, deleted, created_at) CREATE TABLE prefs (key, value) PRAGMA journal_mode = WAL;
- Pull by sequence number. Rows arrive in order and each page is applied in one transaction, so a dropped connection never leaves half a page.
- Corrections are just newer rows. A wrong room in the seed is fixed by a row with a higher
usn. There is no separate patch system. - Local writes win until acknowledged.
data.putwrites the row and an outbox entry together. Push is idempotent by op id, and the server keeps the last write per key. - The UI and its data move together. Read-only data is stored per schema version, so a trial that reverts still has the data its old UI expects.
- No backend needed for public data. Publish static chunks on a CDN and phones fetch only the chunks above what they already have.
One schema, so Dart and TypeScript can’t drift apart.
You describe the bridge once in bridge.schema.json. Codegen writes the Dart handler interface for the host and the TypeScript types for your web app. CI runs codegen --check and fails when they are stale.
{
"version": 1,
"methods": {
"app.ready": { "since": 1, "params": {}, "returns": "void" },
"data.get": { "since": 1, "params": { "bucket": "string", "key": "string" }, "returns": "json?" },
"data.list": { "since": 1, "params": { "bucket": "string", "prefix": "string?", "limit": "int?" }, "returns": "Row[]" },
"data.put": { "since": 1, "params": { "bucket": "string", "key": "string", "value": "json?" }, "returns": "void" },
"sync.now": { "since": 1, "params": { "buckets": "string[]?" }, "returns": "SyncResult" }
},
"events": {
"sync.changed": { "since": 1, "payload": { "buckets": "string[]" } },
"bundle.updateReady": { "since": 1, "payload": { "seq": "int" } }
},
"types": {
"Row": { "key": "string", "value": "json", "usn": "int" },
"SyncResult": { "buckets": "string[]", "changed": "int", "offline": "bool" }
}
}
// GENERATED by valence codegen from valence/bridge.schema.json. Do not edit. const hostBridgeVersion = 1; abstract interface class BridgeHandlers { Future<void> appReady(); Future<Object?> dataGet(DataGetParams p); Future<List<Row>> dataList(DataListParams p); Future<void> dataPut(DataPutParams p); Future<SyncResult> syncNow(SyncNowParams p); } /// Decodes one message from the JS channel, checks the per-launch nonce, /// calls the handler and returns the reply JSON. Never throws. Future<String> bridgeDispatch(BridgeHandlers h, String raw, {required String nonce})
<script lang="ts"> import { onMount } from 'svelte'; import { on, call, ready } from '$lib/valence/client'; let day = $state<unknown>(null); const load = async () => (day = await call('data.get', { bucket: 'schedule', key: 'day-1' })); onMount(async () => { await load(); await ready(); // first paint is done: this commits a trial bundle return on('sync.changed', ({ buckets }) => buckets.includes('schedule') && load()); }); // call('data.get') with no params does not compile. In a plain browser, // calls go to your web shim, so the same build is also your website. </script>
Only your own pages can call it
The app’s loopback server writes a per-launch nonce into the entry page. Messages without it are refused, so a page served by anything else gets no bridge.
Strict decoding on the host
Unknown fields, wrong types and non-JSON input are rejected before a handler runs. Replies are built with jsonEncode, never by joining strings into a script.
A locked frame
The WebView stays on 127.0.0.1, other URLs open in the browser, and there are no off-origin subframes. The page’s CSP comes from the signed manifest.
Allowed by both stores, within limits.
Downloading JavaScript into a WebView is permitted on iOS and Android if it keeps the app’s purpose. Apple also bars downloaded code that adds features (Guideline 2.5.2), so new features ship with a store build. Valence checks the parts a machine can check. App review has the final say.
“Interpreted code may be downloaded to an Application but only so long as such code: (a) does not change the primary purpose of the Application … (b) does not bypass signing, sandbox, or other security features of the OS; and (c) … does not create a store or storefront for other Applications.”
“An app distributed via Google Play may not modify, replace, or update itself using any method other than Google Play’s update mechanism. … This restriction does not apply to code that runs in a virtual machine or an interpreter where either provides indirect access to Android APIs (such as JavaScript in a webview or browser).”
Native code can never be downloaded
The bundler rejects these anywhere in a path:
.so.dex.jar.apk.aab.ipa.dylib.framework.a.o.class.exe.dllPurpose drift is flagged
Each release’s top-level routes are compared with the bundle you last submitted to the store. A new route raises a warning, because a new feature area should go out with a store update.
Real native value
To avoid the “repackaged website” rejection, the host ships at least two of: offline cold start, notifications, a home-screen widget, share and haptics, biometric lock.
First launch never waits
The built-in bundle and the seed make the app fully usable offline, so a first launch never blocks on a download.
No untrusted pages
Play flags a JavaScript interface that loads untrusted content. The bridge only answers pages from the app’s own local server.
A CDN can’t push code
Every manifest is signed and every file is checked against its hash. Someone who controls the CDN can freeze phones on their current bundle, but can’t serve new code.
Policy text retrieved 2026-10-14 and 2026-10-16. Not legal advice. The exact quotes and the rules built on them are in the store policy guide.
One command from your project root.
The wizard does everything that doesn’t need AI in a few seconds. It is safe to run again: it never overwrites your config, your schema or files you’ve edited.
$ npm create valence@latest # then, in Claude Code $ claude "/valence init" # later $ npx create-valence@latest update # CLI, skill and agents $ npx create-valence doctor # quick health check # no Claude Code? the CLI is vendored into your repo $ node valence/valence.mjs bundle $ node valence/valence.mjs codegen --check
- 01Detects your web app (SvelteKit, Next.js static export, Astro or Vite), its package manager, an existing Flutter project and your app id.
- 02Writes
valence.config.json, the bridge schema and the web bridge client. - 03Generates your Ed25519 signing key outside the repo, and the typed bridge code.
- 04Installs the
/valenceskill and its agents into.claude/. - 05Offers to launch
/valence init, which builds the Flutter host, wires your web app, runs the tests and has a separate reviewer agent check the result.
/valence initScaffold the host, keys, bridge, offline store and tests./valence releaseBuild, sign and stage a bundle. Uploads only after you confirm./valence doctorCheck versions, store policy, signatures and tests.Before you ship it.
- Will Apple or Google reject an app built this way?
- Both stores allow JavaScript running in a WebView to be downloaded, as long as it doesn’t change the app’s advertised purpose, bypass OS security or create a storefront, and Apple expects new features to go through review. Valence guards against the common rejections: the host must add real native value, new top-level routes are flagged for a store release and the bridge is locked to the app’s own pages. Reviewers judge each app, so no tool can promise approval.
- What happens to users when a release is broken?
- Each phone tries a new bundle as a trial on the next launch. If the page doesn’t call
ready()within 15 seconds of foreground time, or the app dies first, the phone goes back to the last good version. After a second failure the bundle is marked bad on that phone. Start with a 10% rollout and most users never see it. - Can a compromised CDN push code to my users?
- No, as long as your signing key stays private. Every manifest is signed, and every file is checked against its hash and size. The pointer is unsigned so you can change the rollout without re-signing; someone who controls it can freeze phones on their current bundle or change who is in the rollout, and nothing more.
- Which web frameworks work?
- Any SPA that builds to static files. The wizard detects SvelteKit, Next.js static export, Astro and Vite. When
window.Valenceis absent, bridge calls go to a web shim, so the same build is also your website. - Do I need Claude Code?
- No. The CLI is zero-dependency Node and the wizard vendors it into your repo, so CI needs no install. Claude Code adds the
/valenceworkflows that generate the Flutter host against the spec and review it. - What is out of scope?
- Third-party mini-app stores (Apple guideline 4.7), CRDTs and real-time collaboration, and downloading native code.
- How mature is it?
- It is in beta: version 0.3, skill-first, and the spec and commands may still change before 1.0. The CLI, the spec, codegen and the TypeScript client are tested. The Flutter runtime is generated per app against the spec and its test matrix. Once a reference app has proven it, the runtime moves into a
valencepub package and an@valence/bridgenpm package.
Your website is already your app.
npm create valence@latest