Open source, MIT v0.3 Beta Flutter host + any static web build

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@latest
Read the docs
Try the release flow

A 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.

Reference app · app.valence.reference channels/production.json → seq 42
10:24

On your laptop
On a user’s phone
The sync button inside the app calls sync.now over the bridge.

Next

valence/state.json

                  
log
    Inside the app

    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.

    How the Flutter host works

    The Valence hermit crab stepping out of an old phone-shaped shell into a new one
    The host stays. The shell gets swapped.
    Flutter host store-reviewed, rarely changes
    WebViewYour web build, served by the app’s own server on 127.0.0.1
    Typed bridge per-launch nonce · strict decoding
    SQLitekv, bucket, outbox and prefs, starting from seed.db
    Updaterpointer → signed manifest → changed files
    Your sync APIor static chunks on a CDN
    Static OTA hostR2, S3 or any CDN
    Only new native features wait for a store release.
    What changedHow it reaches usersStore review
    UI, copy, styles, web logicA signed over-the-air bundle with a staged rollout: 10%, then 50%, then 100%Not needed
    Data, or a fix to bundled dataSync. The fix is a new row with a higher sequence number.Not needed
    A new native capability or feature areaA normal app releaseRequired
    Release path

    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.

    Read the release guide

    The Valence hermit crab holding an envelope closed with a wax seal
    Every bundle arrives signed.
    1. 01

      Build and sign

      valence bundle hashes every file, writes the manifest with the page’s CSP, and signs it. It refuses any native code.

    2. 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.

    3. 03

      Verify in a fixed order

      Signature, then appId, then seq against the pointer, then the floor, then requires, then every path, hash and size. Any failure stops the update.

    4. 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.

    5. 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.

    manifests/43.jsonsigned · keyId k1
    {
      "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.
    The Valence hermit crab tucked back inside its old shell

    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.

    Offline first

    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.

    Read the data and sync guide

    usn 1 – 1240 · shipped in the app
    1241 – 1242 · sync
    GET /schedule?since=1240&schema=2&limit=500 → 200 · 2 rows · max 1242 · more: false
    1. usn 1239schedule/day-2Day 2 sessionsseed
    2. usn 1240schedule/day-1Day 1, offline talk in Hall Bseed
    3. usn 1241schedule/day-2Day 2, adds a workshop at 14:00new
    4. usn 1242schedule/day-1Day 1, offline talk moved to Hall C, lightning talks at 16:30fix
    sqlite · the whole data layer
    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.put writes 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.
    Typed bridge

    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.

    Read the bridge guide

    {
      "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" }
      }
    }

    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.

    Store policy

    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.

    Apple Developer Program License Agreement §3.3.1(B)
    “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.”
    Google Play, Device and Network Abuse policy
    “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.dll

    Purpose 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.

    Get started

    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.

    Follow the quick start

    ~/my-app
    $ 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
    1. 01Detects your web app (SvelteKit, Next.js static export, Astro or Vite), its package manager, an existing Flutter project and your app id.
    2. 02Writes valence.config.json, the bridge schema and the web bridge client.
    3. 03Generates your Ed25519 signing key outside the repo, and the typed bridge code.
    4. 04Installs the /valence skill and its agents into .claude/.
    5. 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.
    Questions

    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.Valence is 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 /valence workflows 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 valence pub package and an @valence/bridge npm package.

    Your website is already your app.

    $npm create valence@latest
    View on GitHub
    The Valence hermit crab waving