# Integrate Pear OTA into an existing mobile app





<Callout type="info">
  This guide is for a React Native or Expo app you **already have**, adding [Pear Mobile OTA](/pear/reference/pear/mobile) to it. For the desktop/Electron equivalent, see [Integrate Pear OTA into an existing Electron app](/pear/how-to/operate-an-app/integrate-pear-ota/electron). Starting from scratch instead? [Start from the hello-pear-react-native template](/p2p/getting-started/from-a-template/start-from-hello-pear-react-native) clones a finished boilerplate with all of this already wired.
</Callout>

<Callout type="warn">
  `pear-mobile` and `pear-runtime-react-native` are MVP and experimental—expect the API to keep moving.
</Callout>

Integrating for updates means five things land in your app: the [`pear-mobile`](/pear/reference/pear/mobile) dependency, a bundled Bare worklet that runs your peer-to-peer/update logic off the JS thread, the updater wiring on the React Native side (`updating` / `updated` / `applyUpdate()`), the mobile-only `minver` gate that stops an OTA from reaching a native build too old for it, and a boot-control patch so a release build knows whether to boot the bundle shipped in the binary or a newer one downloaded over the air. None of this touches your existing screens or navigation—it's additive.

## What this guide does NOT cover [#what-this-guide-does-not-cover]

<Callout type="warn">
  This page wires the runtime and the updater UI only. It does **not** cover the full mobile release ceremony—staging a payload, provisioning it, and multisig-gating production. That reuses [Deploy your application](/pear/how-to/operate-an-app/manual-deployment/deployment)'s stage/provision/multisig model with a different build step, documented for mobile in the upstream [`hello-pear-react-native` README](https://github.com/holepunchto/hello-pear-react-native#deployments). Read [Version management](https://github.com/holepunchto/hello-pear-react-native#version-management) in that same README before you ship a real release—it carries the mobile-specific sequencing rules for how native and OTA version numbers must interleave.

  It also does not cover the raw [`bare-kit`](/bare/reference/bare/bare-kit) primitive on its own—a bare `Worklet` and an `IPC` channel with no updater, storage, or boot control attached. See [Embed Bare in a React Native app](/bare/how-to/run-on-native/embed-bare-in-react-native) for that lower layer.
</Callout>

## Before you begin [#before-you-begin]

* An existing React Native or Expo app, with its own screens and navigation already in place.
* [`bare-pack`](https://github.com/holepunchto/bare-pack) and [`react-native-bare-kit`](/bare/reference/bare/bare-kit) available—`pear-mobile` runs your worklet through the latter, and you'll bundle for it with the former.
* The [Pear CLI](/pear/reference/pear/cli) installed, to mint an `upgrade` link with `pear touch`.

<Callout type="info">
  **Need the `pear` CLI?** Install it from **[install.pears.com](https://install.pears.com)**, or prefix any command below with `npx`. See [Install & upgrade](/pear/reference/pear/cli#install) for details.
</Callout>

## What you're adding [#what-youre-adding]

| Piece                     | Added via                                                                                                     |
| ------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Dependencies              | [`pear-mobile`](/pear/reference/pear/mobile), plus the modules the JS thread and the bundled worker require   |
| Worker bundling step      | [`bare-pack`](https://github.com/holepunchto/bare-pack), producing a `.bundle` file your JS thread imports    |
| Updater worklet code      | `new PearRuntime(opts)` inside the worklet, wrapping `pear.updater`                                           |
| JS-thread launcher wiring | `PearRuntime.run(filename, bundle, argv)` in your view component                                              |
| `minver-required` handler | `pear.on('minver-required', ...)` on the worklet side, surfaced to the UI as a store-update prompt            |
| Boot control patch        | [`pear-runtime-react-native`](/pear/reference/pear/mobile-boot-control) (Expo) or its hand-applied equivalent |

<Steps>
  <Step>
    ### Install the dependencies [#install-the-dependencies]

    ```sh skip="install-instruction"
    npm install pear-mobile react-native-bare-kit framed-stream b4a
    ```

    `pear-mobile` is the runtime, `react-native-bare-kit` the worklet host it runs on, and `framed-stream`/`b4a` are what the JS thread uses to frame and decode the worklet's IPC duplex.

    The worker added two steps from now brings its own requires, which have to resolve from this same `package.json`—[`bare-pack`](https://github.com/holepunchto/bare-pack) resolves them at pack time, from `node_modules`, not at runtime on the device:

    ```sh skip="install-instruction"
    npm install hyperswarm corestore graceful-goodbye bare-path bare-storage which-runtime
    ```

    [`bare-pack`](https://github.com/holepunchto/bare-pack), which packs that worker into a bundle three steps from now, isn't one of the worker's own dependencies—install it separately as a dev dependency:

    ```sh skip="install-instruction"
    npm install --save-dev bare-pack
    ```

    `pear-mobile` resolves to a different export depending on which side of the worklet boundary requires it: a static `PearRuntime.run(filename, bundle, argv)` launcher from the React Native/Expo JS thread, and the same `PearRuntime` class shape as desktop's `pear-runtime` from inside the worklet. See [Two entry points, one package](/pear/reference/pear/mobile#two-entry-points-one-package) for the mechanics.
  </Step>

  <Step>
    ### First-time `package.json` and `pear.json` setup [#first-time-packagejson-and-pearjson-setup]

    Mint a real `upgrade` link and set the app's starting version:

    ```sh skip="release-flow"
    pear touch
    ```

    ```json skip="release-flow"
    {
      "version": "1.0.0",
      "upgrade": "pear://qxenz5wmspmryjc13m9yzsqj1conqotn8fb4ocbufwtz9mtbqq5o"
    }
    ```

    Mobile has one more field, in a separate top-level `pear.json` next to `package.json`: `updates.minver`, the compatibility floor (see [The `minver` gate](/pear/reference/pear/mobile#the-minver-gate)). Give it a real value now—your current native version—rather than leaving it unset, so the gate has something meaningful to compare against from the first release:

    ```json skip="release-flow"
    {
      "updates": {
        "minver": "1.0.0"
      }
    }
    ```

    The gate is not read locally: `pear-mobile` reads `/pear.json` out of the **incoming** update drive and compares that payload's `updates.minver` against the `version` argv the JS thread passed when it started the worklet—see [Start the worklet from the JS thread](#start-the-worklet-from-the-js-thread) three steps from now. So this file is the floor you are declaring for the payload you are about to publish, not a check on the build you are running—and it travels inside the payload, which means it can't be retrofitted onto one that's already staged.

    Bump `updates.minver` whenever a *later* release changes the native/OTA contract—an OTA payload that assumes native code, ABI, or config a given binary doesn't have must be gated off from that binary; set it to that release's own version. Leaving it at its previous value is correct for any release that doesn't change that contract.

    <Callout type="warn">
      **"Native version" only holds until the first OTA activates.** `version` is read from `../package.json` inside the JS bundle that's *currently executing*—on a fresh native install that's the native build's own version, but once an OTA has activated, the running bundle is the OTA's bundle, and its baked-in `version` is the OTA's version, not the original binary's. The `minver` floor is really compared against whichever JS version last booted, not literally "the native binary" once your app has taken at least one OTA. Keep this in mind when reasoning about what a given `minver` bump actually gates.
    </Callout>
  </Step>

  <Step>
    ### Add the updater worker [#add-the-updater-worker]

    This is the same `workers/main.js` every `hello-pear-*` template that has a worker ships, and the same one the [Electron guide](/pear/how-to/operate-an-app/integrate-pear-ota/electron) uses—one file that owns the `PearRuntime` instance and nothing else. Keep it dedicated to the updater: if your app has (or grows) its own peer-to-peer logic, put that in a second worker so update traffic never blocks it.

    Unlike on desktop, **how** you bring this file in matters here, because the specifier that picks `pear-mobile` over `pear-runtime` lives in a package's `imports` map, not in Node's normal resolution:

    ```sh skip="install-instruction"
    npm install hello-pear-worker
    ```

    `workers/main.js` becomes a one-line `require('hello-pear-worker')`. That package's own `imports` map is what redirects its internal `require('pear-runtime')` to `pear-mobile` on iOS/Android/simulator—see [Two entry points, one package](/pear/reference/pear/mobile#two-entry-points-one-package). This is the path to reach for first on mobile: skip it and `bare-pack` fails at pack time with `MODULE_NOT_FOUND: Cannot find module 'pear-runtime'`, because nothing in a bare app resolves `pear-runtime` to anything on iOS or Android.

    Prefer to see (and edit) every line instead? Vendor the file—but you take over the `imports` map yourself, in **your own** `package.json`, before it will pack:

    ```json skip="release-flow"
    {
      "imports": {
        "pear-runtime": {
          "ios": "pear-mobile",
          "android": "pear-mobile",
          "simulator": "pear-mobile",
          "default": "pear-runtime"
        }
      }
    }
    ```

    Either way—package or vendored copy—here's what's actually running:

    Everything peer-to-peer lives in `workers/main.js`, which runs in Bare (not Node). The host passes the runtime configuration as positional arguments; the worker reads them (with an `argv` helper for cross-platform compatibility) and constructs the [`pear-runtime`](/pear/reference/pear/runtime) instance:

    ```js file=<rootDir>/examples/getting-started/hello-pear-electron/workers/main.js#L15-L27 title="workers/main.js"
    const updaterConfig = {
      updates: argv(0) !== 'false',
      version: argv(1),
      upgrade: argv(2),
      name: argv(3),
      dir: argv(4) || dir.persistent(), // argv[4] is undefined in mobile
      app: argv(5) // argv[5] is undefined in mobile
    }

    const pipe = new FramedStream(Bare.IPC)
    const store = new Corestore(path.join(updaterConfig.dir, 'pear-runtime', 'corestore'))
    const swarm = new Hyperswarm()
    const pear = new PearRuntime({ ...updaterConfig, swarm, store })
    ```

    This is where you add your [Corestore](/p2p/reference/helpers/corestore) cores, join [Hyperswarm](/p2p/reference/building-blocks/hyperswarm) topics, and run your protocols. Use `pear.storage` as the storage root so your data lands in the same per-app directory Pear manages—see [Storage and distribution](/pear/explanation/storage-and-distribution). For why the logic belongs in a worker rather than the UI, see [Workers](/pear/explanation/workers).

        <Callout type="info">
          Upstream now ships this worker as the [`hello-pear-worker`](https://github.com/holepunchto/hello-pear-worker) package—the template's `workers/main.js` is just `require('hello-pear-worker')`. The code above is that worker inlined so you can see what it does; write your own peer-to-peer logic in `workers/main.js` the same way.
        </Callout>

    For this guide, don't add your own peer-to-peer logic to this file the way the callout above suggests for a from-scratch app—keep it dedicated to updates, as the intro to this step said, and start a second worker for anything else.

    Nothing in the worker's own code needs to change for mobile beyond the `imports` map above. One more place does the rest of the platform-specific work for you: lines 8–13 in [the canonical copy](https://github.com/holepunchto/pear-docs/blob/published/examples/getting-started/hello-pear-electron/workers/main.js#L8) branch on `isBareKit` from [`which-runtime`](https://www.npmjs.com/package/which-runtime) to pick the right offset into `Bare.argv`—mobile's worklet argv has no executable-path or entry-path slot the way a spawned desktop process does, so the same file produces a correct config object on both sides without edits.
  </Step>

  <Step>
    ### Bundle the worker [#bundle-the-worker]

    There's no filesystem for a mobile worklet to load a file from at runtime, so the worker has to be packed into a bundle and imported as a JS module. [`bare-pack`](https://github.com/holepunchto/bare-pack) does the packing; target every platform you ship to in one combined bundle:

    ```console skip="native-build"
    npx bare-pack --linked --host ios --host android --out src/worker.bundle.js workers/main.js
    ```

    `--linked` is required here—iOS and Android link native addons ahead of time rather than loading them from disk, so a bundle built without it fails on device. See [Bundle a Bare app](/bare/how-to/run-on-native/bundle-a-bare-app#embeddable-bundle-with-bare-pack) for the rest of the flags. Wire this behind an npm script (the `hello-pear-react-native` template calls its version `bundle:bare`) so it's easy to remember.

    <Callout type="warn">
      **Nothing rebuilds this automatically, and a stale bundle fails silently.** The app still boots and looks normal—it's just running whatever worker code was packed last time. Re-run the bundle command after every change to `workers/main.js` or anything it imports, every time, before you reload the app.
    </Callout>
  </Step>

  <Step>
    ### Start the worklet from the JS thread [#start-the-worklet-from-the-js-thread]

    In the component that owns your app's lifetime, start the worklet with the bundle's content (not a path—there's no disk to load from) and wrap the `IPC` duplex it returns in a framing layer so messages arrive as discrete chunks instead of a raw byte stream:

    ```js file=<rootDir>/examples/getting-started/hello-pear-react-native/src/App.tsx#L28-L35 title="src/App.tsx" skip="example-import"
        const IPC = PearRuntime.run('/worker.bundle', bundle, [
          (!__DEV__).toString(),
          version,
          upgrade,
          appName
        ])
        const pipe = new FramedStream(IPC)
        pipeRef.current = pipe
    ```

    `updatesEnabled`, `version`, `upgrade`, and the product name are passed as `argv`—the same four positional values `workers/main.js`'s `argv()` helper reads back out on the other side. There is no instance on this side of the boundary; `PearRuntime.run` is static. See [Starting the worklet (React Native side)](/pear/reference/pear/mobile#starting-the-worklet-react-native-side) for the full signature.

    This repo's copy of the example keeps the framed pipe in a ref so the rest of the component can write to it—that ref is the only way to trigger an apply:

    ```js file=<rootDir>/examples/getting-started/hello-pear-react-native/src/App.tsx#L23-L25 title="src/App.tsx" skip="example-import"
      const applyUpdate = useCallback(() => {
        pipeRef.current?.write('pear:applyUpdate')
      }, [])
    ```

    Call that from whatever affordance you show when an update is ready. The worker turns `'pear:applyUpdate'` into `pear.updater.applyUpdate()` and answers on the same pipe.

    <Callout type="info">
      Upstream's [`hello-pear-react-native`](https://github.com/holepunchto/hello-pear-react-native/blob/main/src/App.tsx) holds that same write in `useState` rather than a ref, so a fresh clone of the template looks slightly different here. This repo's copy moved it to a ref to clear a `react-hooks` set-state-in-effect lint error; the behaviour is identical either way.
    </Callout>
  </Step>

  <Step>
    ### Wire the update and minver events [#wire-the-update-and-minver-events]

    Everything from the worklet arrives on the same pipe, as plain `Uint8Array` chunks—decode with `b4a.toString(data)` rather than `data.toString()`, which yields a comma-joined byte list. Route each message to UI state:

    ```js file=<rootDir>/examples/getting-started/hello-pear-react-native/src/App.tsx#L37-L74 title="src/App.tsx" skip="example-import"
        pipe.on('data', (data) => {
          const parsed = b4a.toString(data)

          if (parsed === 'updating') {
            setStatus('updating')
            return
          }

          if (parsed === 'updated') {
            setStatus('updated')
            return
          }

          if (parsed === 'minver-required') {
            setStatus('incompatible')
            return
          }

          if (parsed.startsWith('pear:updateFailed')) {
            shouldReload.current = false
            setError(parsed.slice('pear:updateFailed '.length) || 'Update failed')
            setStatus('failed')
            return
          }

          if (parsed === 'pear:updateApplied') {
            if (shouldReload.current) {
              reloadAppAsync('Pear update applied').catch((err) => {
                setError(`Reload failed: ${err instanceof Error ? err.message : String(err)}`)
                setStatus('failed')
              })
              return
            }

            setStatus('')
            return
          }
        })
    ```

    `updating` and `updated` mirror desktop's [`pear.updater` events](/pear/reference/pear/mobile#updates) one-to-one—show progress, then an "apply" affordance once `updated` arrives. `pear:updateApplied` and `pear:updateFailed` are the two replies to the `'pear:applyUpdate'` write from the previous step: the first is the cue to restart (see the last step), the second carries an error message to surface.

    `minver-required` is mobile-only: it means an update is sitting on the swarm, but the running **native** build is older than the OTA's declared floor, so nothing was downloaded. There's nothing to retry here—the fix is a new binary. Treat it as a terminal state and prompt the user to update from the App Store or Play Store, the same way this template does with its `` `Update available on the ${Platform.OS === 'ios' ? 'App Store' : 'Play Store'}` `` message. See [The `minver` gate](/pear/reference/pear/mobile#the-minver-gate) for why this exists.
  </Step>

  <Step>
    ### Add boot control [#add-boot-control]

    A release build has to decide, on every launch, whether to boot the JS bundle shipped inside the binary or a newer one your app already downloaded and staged via `pear-mobile`. That decision is what [`pear-runtime-react-native`](/pear/reference/pear/mobile-boot-control) patches in: `bundleURL()` on iOS, and the `jsBundleFilePath` passed into `getDefaultReactHost()` on Android. Install it and register it as an Expo config plugin, then run `expo prebuild` to apply the patch—see [Mobile OTA Boot Control](/pear/reference/pear/mobile-boot-control#expo-setup) for the exact install command and `app.json` entry.

    If your app isn't on Expo, there's no config-plugin path—the same `bundleURL()` / `getJSBundleFile()` override has to be applied by hand once, in your native project. [Plain React Native](/pear/reference/pear/mobile-boot-control#plain-react-native) has the condition both platforms check (a newer `version` in the downloaded OTA's `package.json` than the installed app version) and links the exact Swift and Kotlin the plugin generates, for reference.

    Skip this step and updates still download and stage correctly—they just never boot, because nothing on the native side ever looks for them.
  </Step>

  <Step>
    ### Handle the restart [#handle-the-restart]

    `applyUpdate()` stages the new bundle on disk, but activation isn't immediate—[Runtime activation](/pear/reference/pear/mobile-boot-control#runtime-activation) documents that the two platforms differ on when the staged bundle actually takes effect. iOS re-reads `bundleURL()` on reload, so a plain JS-level reload picks up a freshly staged OTA. Android caches `jsBundleFilePath` when the React host is created, so the same JS-only reload generally reuses the old path—there, the update takes effect on the next full process launch.

    The template does the JS-level reload, with no `Platform.OS` branch, on `pear:updateApplied`—an Expo [`reloadAppAsync()`](https://docs.expo.dev/versions/latest/sdk/expo/#reloadappasyncreason) call (the `reloadAppAsync` branch in the excerpt in *Wire the update and minver events*). One call, both platforms, no branching—but [Runtime activation](/pear/reference/pear/mobile-boot-control#runtime-activation) says to **treat a full restart as the requirement on both platforms**, not just Android. Be honest with yourself about what a JS-level reload actually buys on each side:

    * **iOS** re-reads `bundleURL()` on reload, so this JS reload picks up the freshly applied OTA in practice—but a JS reload isn't a guaranteed cold start, so don't design around it as a contract.
    * **Android** caches `jsBundleFilePath` when the React host is created, so the same JS-only reload generally reuses the old path—there, the new bundle boots on the next cold start, not this reload. Either accept next-launch activation and tell the user the update applies on next open, or add a native restart module to force a cold start on both platforms.

    Either way, don't branch the *call* per platform—branch only the message you show the user, if you show one.
  </Step>
</Steps>

## Verify the wiring [#verify-the-wiring]

* The worklet starts with no `error` event—`pear.updater.on('error', console.error)` on the worker side stays silent. Neither the worker nor the template's `App.tsx` logs the worker's `'Hello from worker'` liveness message, so temporarily add a `console.log` in the pipe's `'data'` handler if you want to confirm it arrives. `console.log` inside the worklet goes to the system log, not the Metro console: use `xcrun simctl spawn booted log stream` on iOS or `adb logcat` on Android.
* The bundle is freshly rebuilt after **any** change to the worker or something it imports—rerun `bare-pack` and confirm the timestamp on `worker.bundle.js` moved, since a stale bundle gives no error and just runs old code.
* `updating` → `updated` fire in a release build (not a dev/Metro build—those always load from Metro, so the update path isn't exercised) when a newer version is staged.
* Raising a payload's `pear.json` `updates.minver` above the currently-running JS bundle's own `version` (see the callout in *First-time `package.json` and `pear.json` setup*) actually produces the `minver-required` path—a store-update prompt in the UI—rather than a silent download.
* The boot-control patch is present after `expo prebuild` (or your hand-applied equivalent): look for the `pear-runtime-react-native OTA v3` marker comment in `AppDelegate.swift` and `MainApplication.kt`.
* A full restart after `applyUpdate()` actually boots the new bundle on both a release iOS build and a release Android build—not just one of them.

## See also [#see-also]

* [Pear Mobile OTA](/pear/reference/pear/mobile)—the full `pear-mobile` API this guide wires: options, the updater, and the `minver` gate.
* [Mobile OTA Boot Control](/pear/reference/pear/mobile-boot-control)—the `pear-runtime-react-native` Expo plugin, its plain-React-Native equivalent, and version synchronization rules.
* [Start from the hello-pear-react-native template](/p2p/getting-started/from-a-template/start-from-hello-pear-react-native)—all of this, already wired, in a clone-first tour.
* [Bundle a Bare app](/bare/how-to/run-on-native/bundle-a-bare-app)—more on `bare-pack`, `--linked`, and per-platform `--host` targets.
* [Embed Bare in a React Native app](/bare/how-to/run-on-native/embed-bare-in-react-native)—the raw `bare-kit` `Worklet`/`IPC` primitive underneath `pear-mobile`.
* [Integrate Pear OTA into an existing Electron app](/pear/how-to/operate-an-app/integrate-pear-ota/electron)—the desktop/Electron counterpart to this guide.
