# Reshape into a production app





This is **part 2 of 4** in the [getting started path](/p2p/getting-started). [Part 1](/p2p/getting-started/build-a-peer-to-peer-chat/build-a-peer-to-peer-chat) built a working chat from scratch with the simplest form of the runtime ([`PearRuntime.run`](/pear/reference/pear/runtime#running-workers)). This part reshapes it into a **production-shaped scaffold** built on the same patterns as Pear's official [`hello-pear-electron`](https://github.com/holepunchto/hello-pear-electron) template—a Bare worker behind a preload bridge, an OTA updater worker, and Electron Forge packaging—the same patterns [Keet](https://keet.io) and [PearPass](https://pass.pears.com) use under the hood.

By the end you have the shared **`pear-chat` scaffold** every chat-family and media how-to in [the How To guides](/pear/how-to) extends. There is no separate intermediate app—part 2 is the finished template shape:

* A separate [Bare worker](/pear/explanation/runtime-and-languages) that owns all the P2P code.
* An [Autobase](/p2p/reference/building-blocks/autobase)-backed shared room with [blind-pairing](https://www.npmjs.com/package/blind-pairing) invite codes, so two peers join from a short string without exposing keys.
* On-disk persistence via [Corestore](/p2p/reference/helpers/corestore) + a [HyperDB](https://www.npmjs.com/package/hyperdb) view—the conversation survives restarts.
* A separate [OTA updater worker](/pear/explanation/deployment-releasing-apps-p2p#ota-update-event-lifecycle), wired but inert, ready for [part 3](/p2p/getting-started/build-a-peer-to-peer-chat/ship) and [part 4](/p2p/getting-started/build-a-peer-to-peer-chat/update) to flip on.
* A polished [Tailwind CSS](https://tailwindcss.com/) UI (compiled by the CLI, not the CDN) with a peer counter and a **copy-invite** button.

<Callout type="info">
  **Full production-ready reference: `hello-pear-electron`.** The complete version of this chat lives at [`holepunchto/hello-pear-electron`](https://github.com/holepunchto/hello-pear-electron)—Holepunch's official Electron template, the same shape [Keet](https://keet.io) and [PearPass](https://pass.pears.com) ship. Clone it any time to see the finished structure or to crib code. For a guided tour of the template, see [Start from the hello-pear-electron template](/p2p/getting-started/from-a-template/start-from-hello-pear-electron).
</Callout>

<Callout type="info">
  **New to the CLI?** Run **`pear --menu`** (Pear 3.2.0+) to browse every command from a filterable list and fill in its flags as a form, instead of memorizing them. See [Browse commands with the interactive menu](/pear/how-to/browse-commands-with-the-interactive-menu).
</Callout>

## What you'll build [#what-youll-build]

A standalone Electron app that:

* Runs the actual peer-to-peer code inside a [Bare worker](/pear/explanation/runtime-and-languages), keeping native modules out of the renderer and Electron's main thread:
  * [Hyperswarm](/p2p/reference/building-blocks/hyperswarm) finds and connects peers over the DHT.
  * [Corestore](/p2p/reference/helpers/corestore) is the on-disk store that holds the app's Hypercores.
  * [Autobase](/p2p/reference/building-blocks/autobase) is the multi-writer log that merges every peer's messages into one shared room view.
  * [blind-pairing](https://www.npmjs.com/package/blind-pairing) lets a new peer join from a short invite code without either side exposing its keys.
* Pairs two peers via an **invite code** generated from the room owner's Autobase key—the UI exposes it behind a copy button.
* Persists every message to disk in an [Autobase-backed HyperDB view](/p2p/reference/building-blocks/autobase), so reopening the app replays the conversation.
* Embeds the [`pear-runtime`](/pear/reference/pear/runtime) OTA updater in its **own** Bare worker, so update traffic never blocks the chat.
* Renders a vanilla HTML + [Tailwind CSS](https://tailwindcss.com/) UI that talks to the worker by exchanging JSON over a framed pipe—no bundler, no framework.

<Mermaid
  chart="flowchart LR
  subgraph Electron
    R[HTML renderer]
    M[Main process<br/>worker bridge]
  end
  W[Bare chat worker<br/>WorkerTask + ChatRoom]
  U[Bare updater worker<br/>PearRuntime OTA]
  P[(Corestore +<br/>Autobase + HyperDB)]
  S{{Hyperswarm}}

  R <-->|preload IPC| M
  M <-->|FramedStream| W
  M <-->|FramedStream| U
  W <--> P
  W <--> S"
/>

<Callout type="info">
  For the conceptual picture behind this split—why a production app separates OTA updates, storage, and workers—read [Pear desktop application architecture](/pear/explanation/pear-desktop-architecture).
</Callout>

## Before you start [#before-you-start]

You need:

* [Node.js](https://nodejs.org/) v22.17 or newer and npm v10.9 or newer.
* A POSIX-style terminal (macOS, Linux, or Windows with WSL).
* An IDE or text editor.
* The working chat from [part 1—build the peer-to-peer chat](/p2p/getting-started/build-a-peer-to-peer-chat/build-a-peer-to-peer-chat), so you are comfortable with [`PearRuntime.run()`](/pear/reference/pear/runtime#running-workers), [Bare workers](/pear/explanation/workers), and [Hyperswarm](/p2p/reference/building-blocks/hyperswarm) topics.

<Callout type="info">
  Part 1 was type-along in five files. The production scaffold is \~14 files and a generated `spec/` directory—too much to retype. **Clone the repo** and walk the working code as this part explains it. Every snippet points to a real file so you can read the surrounding context.
</Callout>

<Steps>
  <Step>
    ## Clone the example [#clone-the-example]

    ```bash skip="desktop-gui"
    git clone https://github.com/holepunchto/pear-docs
    cd pear-docs
    git switch published
    cd examples/getting-started/pear-chat
    npm install
    npm run build
    ```

    `npm run build` does two things you must run before the first `npm start`:

    * `build:db` runs `node schema.js` to generate the `spec/` directory (HyperSchema records, the HyperDB view, and the HyperDispatch encoders).
    * `build:tailwind` compiles `input.css` into `renderer/build/output.css` with the [Tailwind CLI](https://tailwindcss.com/docs/installation)—replacing part 1's in-browser CDN script.

    You should now have a `pear-chat/` directory laid out like this:

    ```text
    pear-chat/
    ├─ build/                # icons + per-OS packaging assets
    ├─ electron/
    │  ├─ main.js            # spawns the chat worker + the updater worker
    │  └─ preload.js         # contextBridge exposing window.bridge
    ├─ renderer/
    │  ├─ app.js             # vanilla DOM + worker bridge
    │  └─ index.html         # chat shell (Tailwind classes)
    ├─ spec/                 # generated HyperDispatch + HyperDB schemas
    ├─ workers/
    │  ├─ chat-room.js       # Autobase + blind-pairing room
    │  ├─ index.js           # Bare chat worker entrypoint
    │  ├─ main.js            # Bare updater worker (PearRuntime OTA)
    │  └─ worker-task.js     # WorkerTask: Corestore + Hyperswarm + ChatRoom
    ├─ forge.config.js       # Electron Forge makers + signing hooks
    ├─ input.css             # Tailwind v4 entrypoint
    ├─ package.json          # app metadata, scripts, deps
    ├─ pear.json             # multisig namespace for OTA releases
    └─ schema.js             # regenerates spec/ from schema definitions
    ```

    The four "moving parts" are `electron/`, `workers/`, `spec/`, and `renderer/`. Everything else is plumbing or build config.
  </Step>

  <Step>
    ## Read the Bare chat worker [#read-the-bare-chat-worker]

    The worker is the **only** place P2P code lives. The renderer never imports [Hyperswarm](/p2p/reference/building-blocks/hyperswarm) or [Corestore](/p2p/reference/helpers/corestore); the main process barely touches them. That isolation keeps native modules out of the browser-style sandbox and off Electron's main thread.

    Open `workers/index.js`. It is the Bare-side entrypoint and is intentionally tiny:

    ```js file=<rootDir>/examples/getting-started/pear-chat/workers/index.js title="workers/index.js" lineNumbers {9-13,15-16,23-24,26-27,29-30,32-34,37-40}
    const FramedStream = require('framed-stream')
    const fs = require('bare-fs')
    const goodbye = require('graceful-goodbye')
    const { command, flag } = require('paparam')
    const path = require('bare-path')

    const WorkerTask = require('./worker-task.js')

    const cmd = command('pear-chat',
      flag('--invite|-i <invite>', 'Room invite'),
      flag('--name|-n <name>', 'Your name'),
      flag('--reset', 'Reset')
    )

    const storage = path.join(Bare.argv[2], 'corestore')
    cmd.parse(Bare.argv.slice(3))

    async function main () {
      if (cmd.flags.reset) {
        await fs.promises.rm(storage, { recursive: true, force: true })
      }

      const pipe = new FramedStream(Bare.IPC)
      pipe.pause()

      const workerTask = new WorkerTask(pipe, storage, cmd.flags)
      goodbye(() => workerTask.close())

      await workerTask.ready()
      pipe.resume()

      console.log(`Storage: ${storage}`)
      console.log(`Name: ${workerTask.name}`)
      console.log(`Invite: ${await workerTask.room.getInvite()}`)
    }

    main().catch((err) => {
      console.error(err)
      Bare.exit(1)
    })
    ```

    Things to notice:

    * **Lines 9–13:** the worker declares its flags with [`paparam`](https://www.npmjs.com/package/paparam): `--invite`/`-i` (the room code a joining peer pastes), `--name`/`-n`, and `--reset`.
    * **Lines 15–16:** `Bare.argv[2]` is the storage directory the main process passes when it spawns the worker; the remaining flags are parsed on line 16.
    * **Lines 23–24:** the worker wraps [`Bare.IPC`](/pear/reference/pear/runtime#running-workers) in a [`framed-stream`](https://www.npmjs.com/package/framed-stream), then pauses it until the task is ready. From here on, worker and renderer talk by exchanging JSON over this framed pipe. This tutorial keeps that layer deliberately simple to stay focused on the worker lifecycle; for a real app, [HRPC](/p2p/reference/helpers/hrpc) is the recommended way to replace hand-written `{ type: '...' }` messages with generated, typed request/response methods—see [Structured RPC over IPC](/bare/explanation/rpc-and-schemas#structured-rpc-over-ipc).
    * **Lines 26–27 and 29–30:** `WorkerTask` extends [`ready-resource`](https://www.npmjs.com/package/ready-resource) so open/close are explicit lifecycle steps. [`graceful-goodbye`](https://www.npmjs.com/package/graceful-goodbye) registers its `close()` for shutdown. Once ready, line 30 resumes the stream so buffered frames flow.
    * **Lines 32–34:** the `console.log` lines print the storage path, the chosen name, and the room invite—handy when running two peers from the terminal.

    <Callout type="info">
      The worker's internals—`WorkerTask`'s Corestore/Hyperswarm wiring, the Autobase-backed `ChatRoom` with blind-pairing invites, and the generated `spec/` schema—are pure peer-to-peer logic with nothing Pear-specific about it. [A production Autobase-backed chat room](/p2p/explanation/autobase-backed-chat-room) traces all three in detail. The rest of this tutorial covers the Electron shell around that worker.
    </Callout>
  </Step>

  <Step>
    ## Read the Electron main process [#read-the-electron-main-process]

    Open `electron/main.js`. The whole app is CommonJS—mirroring [`hello-pear-electron`](https://github.com/holepunchto/hello-pear-electron). The main process keeps Electron's UI thread free of P2P work by spawning two [workers](/pear/explanation/workers)—separate Bare processes—and brokering messages between them and the renderer.

    ### Chat worker [#chat-worker]

    The chat worker (`workers/index.js`) owns the Corestore, swarm, and `ChatRoom`. The main process spawns it and relays its byte streams to every window. Because `Bare.IPC` is a raw byte stream, the main process wraps the worker in a [`FramedStream`](https://www.npmjs.com/package/framed-stream) and forwards deframed frames:

    ```js file=<rootDir>/examples/getting-started/pear-chat/electron/main.js#L101-L138 title="electron/main.js — chat worker"
    function getWorker (specifier) {
      if (workers.has(specifier)) return workers.get(specifier)
      const storage = path.join(getStorageDir(), 'app-storage')
      const worker = PearRuntime.run(require.resolve('..' + specifier), [storage, ...passthroughArgs])
      const pipe = new FramedStream(worker)
      function sendWorkerStdout (data) {
        process.stdout.write(data)
        sendToAll('pear:worker:stdout:' + specifier, data)
      }
      function sendWorkerStderr (data) {
        process.stderr.write(data)
        sendToAll('pear:worker:stderr:' + specifier, data)
      }
      function sendWorkerIPC (data) {
        sendToAll('pear:worker:ipc:' + specifier, data)
      }
      function onBeforeQuit () {
        pipe.destroy()
      }
      ipcMain.handle('pear:worker:writeIPC:' + specifier, (evt, data) => {
        return pipe.write(data)
      })
      workers.set(specifier, pipe)
      pipe.on('data', sendWorkerIPC)
      worker.stdout.on('data', sendWorkerStdout)
      worker.stderr.on('data', sendWorkerStderr)
      worker.once('exit', (code) => {
        app.removeListener('before-quit', onBeforeQuit)
        ipcMain.removeHandler('pear:worker:writeIPC:' + specifier)
        pipe.removeListener('data', sendWorkerIPC)
        worker.stdout.removeListener('data', sendWorkerStdout)
        worker.stderr.removeListener('data', sendWorkerStderr)
        sendToAll('pear:worker:exit:' + specifier, code)
        workers.delete(specifier)
      })
      app.on('before-quit', onBeforeQuit)
      return pipe
    }
    ```

    `getWorker` spawns the worker with the `PearRuntime.run` shortcut and fans four named IPC channels out to every window (`pear:worker:ipc`, `:stdout`, `:stderr`, `:exit`), plus a `pear:worker:writeIPC` handler the renderer calls to send bytes back.

    ### Updater worker [#updater-worker]

    The updater worker (`workers/main.js`) runs the [`pear-runtime`](/pear/reference/pear/runtime) OTA updater with its **own** swarm and Corestore, downloads new application drives from peers, and reports its progress (`update-scheduled`, `updating`, `updated`) as pipe messages. When the main process asks it to apply a downloaded release, it calls `pear.updater.applyUpdate()` and replies `pear:updateApplied` (or `pear:updateFailed <message>` if the apply throws). Upstream ships the same worker as the [`hello-pear-worker`](https://github.com/holepunchto/hello-pear-worker) package—here it's inlined so you can read what it does:

    ```js file=<rootDir>/examples/getting-started/pear-chat/workers/main.js title="workers/main.js — updater worker" lineNumbers {10-13,15-22,24-27,29,31-37,44-47,55-69}
    const PearRuntime = require('pear-runtime') // pear-runtime on desktop; pear-mobile on mobile
    const Hyperswarm = require('hyperswarm')
    const Corestore = require('corestore')
    const goodbye = require('graceful-goodbye')
    const FramedStream = require('framed-stream')
    const path = require('bare-path')
    const dir = require('bare-storage')
    const { isBareKit } = require('which-runtime')

    // mobile doesn't have the executable path (argv[0])
    // and the worker entry path (argv[1]) in the workers argv‘s
    // ... to reuse the same worker in all platforms this logic is needed
    const argv = (index) => Bare.argv[index + (isBareKit ? 0 : 2)]

    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 })

    pear.updater.on('error', (err) => pipe.write('updater-error ' + err.message))

    if (updaterConfig.updates !== false) {
      swarm.on('connection', (connection) => store.replicate(connection))
      swarm.join(pear.updater.drive.core.discoveryKey, {
        client: true,
        server: false
      })
    }

    // An append on the app's drive doesn't check for an update straight away: the
    // updater defers it by a random delay of up to an hour (see PearRuntimeUpdater)
    // so that a released fleet doesn't hit the seeder all at once. Forward the
    // delay so the app can say it is waiting rather than look inert — without it,
    // a staged update is indistinguishable from a broken updater for up to an hour.
    pear.updater.on('update-scheduled', (delay) => pipe.write('update-scheduled ' + delay))
    pear.updater.on('updating', () => pipe.write('updating'))
    pear.updater.on('updated', () => pipe.write('updated'))
    pear.on('minver-required', () => pipe.write('minver-required')) // for mobile store update notification

    goodbye(async () => {
      await swarm.destroy()
      await pear.close()
      await store.close()
    })

    pipe.on('data', async (data) => {
      const message = data.toString()
      if (message !== 'pear:applyUpdate') {
        pipe.write('unknown-message ' + message)
        return
      }

      try {
        await pear.ready()
        await pear.updater.applyUpdate()
        pipe.write('pear:updateApplied')
      } catch (err) {
        pipe.write('pear:updateFailed ' + err.message)
      }
    })

    pipe.write('Hello from worker')
    ```

    Things to notice:

    * **Lines 10–13:** an `argv()` helper offsets `Bare.argv` so the same worker reads its arguments identically on desktop and on mobile (`bare-kit`), where the executable and entry paths aren't present.
    * **Lines 15–22:** the runtime config the host passed positionally—`updates`, `version`, `upgrade`, `name`, storage `dir`, and the running `app` path—reassembled into one object, with `dir` falling back to the persistent per-app directory.
    * **Lines 24–27:** the worker opens its **own** `FramedStream` pipe, [Corestore](/p2p/reference/helpers/corestore), and [Hyperswarm](/p2p/reference/building-blocks/hyperswarm), then constructs the `pear-runtime` updater over them—independent of the chat worker's swarm and store.
    * **Line 29:** background updater errors (a check that couldn't complete, no build for this host) go to the main process as `updater-error <message>`. The worker never writes to stdout or stderr—everything it has to say goes over the pipe.
    * **Lines 31–37:** with updates enabled, it replicates the store on every connection and joins the update drive's discovery key as a client to pull new releases from seeding peers.
    * **Lines 44–47:** the updater's events are forwarded to the main process as pipe messages: `update-scheduled <ms>` when the update drive changes and a check is queued, then `updating` and `updated`, plus `minver-required` for mobile. Once the app has been running for more than 60 seconds, a queued check waits a random delay of up to an hour (see the [`delay`](/pear/reference/pear/runtime#options) option) so a released fleet doesn't hit the seeder all at once. `<ms>` is the delay the updater picked at startup, which lets the UI say an update is on its way instead of looking stuck. In the first 60 seconds after boot the check runs immediately, even though the event still carries that value.
    * **Lines 55–69:** any message other than `pear:applyUpdate` is echoed back as `unknown-message <message>`. On `pear:applyUpdate` from the main process, it awaits `pear.ready()`, applies the release, and replies `pear:updateApplied`—or, if `applyUpdate()` throws, replies `pear:updateFailed <message>` instead of leaving the caller waiting forever.

    The main process does **not** embed `new PearRuntime({ ... })`; like the template, it keeps the updater in its own Bare worker so update traffic never blocks the chat. It only spawns the worker, wraps it in a `FramedStream`, and relays `pear:event:updating` / `pear:event:updated`:

    ```js file=<rootDir>/examples/getting-started/pear-chat/electron/main.js#L142-L179 title="electron/main.js — updater worker"
    function getUpdaterPipe () {
      if (updaterPipe) return updaterPipe
      const dir = getStorageDir()
      const appPath = getAppPath()
      const extension = isLinux ? '.AppImage' : isMac ? '.app' : '.msix'
      const worker = PearRuntime.run(require.resolve('..' + updaterSpecifier), [
        updates,
        version,
        upgrade,
        productName + extension,
        dir,
        appPath
      ])
      const pipe = new FramedStream(worker)

      function onData (data) {
        const message = data.toString()
        if (message === 'updating') sendToAll('pear:event:updating', 'updating')
        else if (message === 'updated') sendToAll('pear:event:updated', 'updated')
      }
      function onStderr (data) {
        process.stderr.write(data)
      }
      function onBeforeQuit () {
        pipe.destroy()
      }
      pipe.on('data', onData)
      worker.stderr.on('data', onStderr)
      worker.once('exit', () => {
        app.removeListener('before-quit', onBeforeQuit)
        pipe.removeListener('data', onData)
        worker.stderr.removeListener('data', onStderr)
        updaterPipe = null
      })
      app.on('before-quit', onBeforeQuit)
      updaterPipe = pipe
      return pipe
    }
    ```

    `applyUpdate` swaps the app on disk; `app:afterUpdate` relaunches the process. The renderer wires both into a button:

    ```js file=<rootDir>/examples/getting-started/pear-chat/electron/main.js#L215-L262 title="electron/main.js — apply + relaunch"
    ipcMain.handle('pear:applyUpdate', () => {
      const pipe = getUpdaterPipe()

      return new Promise((resolve, reject) => {
        function done (err) {
          pipe.removeListener('data', onData)
          pipe.removeListener('close', onClose)
          if (err) reject(err)
          else resolve()
        }

        function onData (data) {
          const message = data.toString()
          if (message === 'pear:updateApplied') done(null)
          else if (message.startsWith('pear:updateFailed')) {
            done(new Error(message.slice('pear:updateFailed '.length) || 'Update failed'))
          }
        }

        // A worker that dies mid-apply never replies; without this the promise
        // would stay pending forever.
        function onClose () {
          done(new Error('updater worker exited before replying'))
        }

        pipe.on('data', onData)
        pipe.once('close', onClose)
        pipe.write('pear:applyUpdate')
      })
    })
    ipcMain.handle('pear:startWorker', (evt, filename) => {
      getWorker(filename)
      return true
    })
    ipcMain.handle('app:afterUpdate', () => {
      if (isLinux && process.env.APPIMAGE) {
        app.relaunch({
          execPath: process.env.APPIMAGE,
          args: [
            '--appimage-extract-and-run',
            ...process.argv.slice(1).filter((arg) => arg !== '--appimage-extract-and-run')
          ]
        })
      } else if (!isWindows) {
        app.relaunch()
      }
      app.quit()
    })
    ```

    ### Single-instance lock + deep links [#single-instance-lock--deep-links]

    `requestSingleInstanceLock` makes a `pear-chat://` deep link from the OS go to the running instance instead of spawning a second one. `setAsDefaultProtocolClient(protocol)` registers the scheme:

    ```js file=<rootDir>/examples/getting-started/pear-chat/electron/main.js#L268-L307 title="electron/main.js — single instance"
    app.setAsDefaultProtocolClient(protocol)

    app.on('open-url', (evt, url) => {
      evt.preventDefault()
      handleDeepLink(url)
    })

    const lock = app.requestSingleInstanceLock()

    if (!lock) {
      app.quit()
    } else {
      app.on('second-instance', (evt, args) => {
        const url = args.find((arg) => arg.startsWith(protocol + '://'))
        if (url) handleDeepLink(url)
      })

      app.whenReady().then(() => {
        createWindow()
          .then(() => getUpdaterPipe())
          .catch((err) => {
            console.error('Failed to create window:', err)
            app.quit()
          })

        app.on('activate', () => {
          if (BrowserWindow.getAllWindows().length === 0) {
            createWindow().catch((err) => {
              console.error('Failed to create window:', err)
            })
          }
        })
      })

      app.on('window-all-closed', () => {
        if (process.platform !== 'darwin') {
          app.quit()
        }
      })
    }
    ```
  </Step>

  <Step>
    ## Expose the bridge in `electron/preload.js` [#expose-the-bridge-in-electronpreloadjs]

    The renderer never sees the worker handle directly. It only sees `window.bridge`:

    ```js file=<rootDir>/examples/getting-started/pear-chat/electron/preload.js title="electron/preload.js" {8,16,27-39}
    const { contextBridge, ipcRenderer, webUtils } = require('electron')

    contextBridge.exposeInMainWorld('bridge', {
      pkg () {
        return ipcRenderer.sendSync('pkg')
      },
      getPathForFile: (file) => webUtils.getPathForFile(file),
      writeClipboard: (text) => ipcRenderer.invoke('clipboard:write', text),
      applyUpdate: () => ipcRenderer.invoke('pear:applyUpdate'),
      appAfterUpdate: () => ipcRenderer.invoke('app:afterUpdate'),
      onPearEvent: (name, listener) => {
        const wrap = (evt, eventName) => listener(eventName)
        ipcRenderer.on('pear:event:' + name, wrap)
        return () => ipcRenderer.removeListener('pear:event:' + name, wrap)
      },
      startWorker: (specifier) => ipcRenderer.invoke('pear:startWorker', specifier),
      onWorkerStdout: (specifier, listener) => {
        const wrap = (evt, data) => listener(new Uint8Array(data))
        ipcRenderer.on('pear:worker:stdout:' + specifier, wrap)
        return () => ipcRenderer.removeListener('pear:worker:stdout:' + specifier, wrap)
      },
      onWorkerStderr: (specifier, listener) => {
        const wrap = (evt, data) => listener(new Uint8Array(data))
        ipcRenderer.on('pear:worker:stderr:' + specifier, wrap)
        return () => ipcRenderer.removeListener('pear:worker:stderr:' + specifier, wrap)
      },
      onWorkerIPC: (specifier, listener) => {
        const wrap = (evt, data) => listener(new Uint8Array(data))
        ipcRenderer.on('pear:worker:ipc:' + specifier, wrap)
        return () => ipcRenderer.removeListener('pear:worker:ipc:' + specifier, wrap)
      },
      onWorkerExit: (specifier, listener) => {
        const wrap = (evt, code) => listener(code)
        ipcRenderer.on('pear:worker:exit:' + specifier, wrap)
        return () => ipcRenderer.removeListener('pear:worker:exit:' + specifier, wrap)
      },
      writeWorkerIPC: (specifier, data) => {
        return ipcRenderer.invoke('pear:worker:writeIPC:' + specifier, data)
      }
    })
    ```

    This is the **single door** between the HTML renderer and the Bare workers. It is intentionally narrow—`startWorker`, `writeWorkerIPC`, `onWorkerIPC`, `onWorkerExit` for the worker, `onPearEvent` / `applyUpdate` / `appAfterUpdate` for OTA, and one extra this part adds:

    * `writeClipboard` (line 8), which the copy-invite button calls.
    * The renderer sends `{ type: 'add-message', text }` and receives `{ type: 'messages' }`, `{ type: 'peers' }`, and `{ type: 'invite' }`—all plain JSON over the bridge.

    <Callout type="info">
      For *why* this split exists, read [the process model](/pear/explanation/pear-desktop-architecture#process-model).
    </Callout>
  </Step>

  <Step>
    ## Read the renderer [#read-the-renderer]

    The renderer is **vanilla HTML + JavaScript**—no framework, no bundler—styled with [Tailwind CSS v4](https://tailwindcss.com/) compiled by the CLI (the `build:tailwind` script), not the part-1 CDN script. Two files matter.

    ### `renderer/index.html` [#rendererindexhtml]

    The static chat shell:

    * a header with the Pear logo, a peer-status dot,
    * a **Copy invite** button,
    * an "Update ready" button (hidden until OTA fires),
    * a scrollable message list,
    * a composer form, and
    * every class is a Tailwind utility scanned out of the markup at build time:

    ```html file=<rootDir>/examples/getting-started/pear-chat/renderer/index.html#L12-L47 title="renderer/index.html — header"
      <header class="px-6 py-4 border-b border-neutral-900 flex items-center justify-between gap-4">
        <div class="flex items-center gap-2.5">
          <svg class="h-5 w-auto" viewBox="0 0 265 358" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
            <path d="M123.036 0H141.964V35.3525H123.036V0Z" fill="#B0D944"/>
            <path d="M113.572 46.5399V53.6999H94.643V62.6499H170.357V53.6999H151.429V39.8274H132.5V46.5399H113.572Z" fill="#B0D944"/>
            <path d="M189.286 67.1249H132.5V73.8374H75.7144V89.9474H189.286V67.1249Z" fill="#B0D944"/>
            <path d="M208.214 94.4224H132.5V101.135H56.7858V117.245H208.214V94.4224Z" fill="#B0D944"/>
            <path d="M208.214 121.72H132.5V128.433H56.7858V144.543H208.214V121.72Z" fill="#B0D944"/>
            <path d="M227.143 149.018H132.5V155.73H37.8573V171.84H227.143V149.018Z" fill="#B0D944"/>
            <path d="M227.143 176.315H132.5V183.028H37.8573V199.138H227.143V176.315Z" fill="#B0D944"/>
            <path d="M246.071 203.613H132.5V210.325H18.9286V226.435H246.071V203.613Z" fill="#B0D944"/>
            <path d="M265 230.91H132.5V237.623H0V253.733H265V230.91Z" fill="#B0D944"/>
            <path d="M265 258.208H132.5V264.92H0V281.03H265V258.208Z" fill="#B0D944"/>
            <path d="M265 285.505H132.5V292.218H0V308.328H265V285.505Z" fill="#B0D944"/>
            <path d="M227.143 312.803H132.5V319.515H37.8573V335.625H227.143V312.803Z" fill="#B0D944"/>
            <path d="M189.286 340.1H132.5V346.812H75.7144V358H189.286V340.1Z" fill="#B0D944"/>
          </svg>
          <h1 class="text-base font-medium tracking-tight">Pear Chat</h1>
          <span id="version" class="text-xs text-neutral-600"></span>
          <button id="update" type="button"
            class="hidden rounded-full bg-lime-400/15 px-3 py-1 text-xs font-medium text-lime-300 hover:bg-lime-400/25 transition">
            Update ready — restart
          </button>
        </div>

        <div class="flex items-center gap-3">
          <span class="flex items-center gap-1.5 text-xs text-neutral-500">
            <span id="dot" class="size-2 rounded-full bg-neutral-600"></span>
            <span id="peers">0 peers</span>
          </span>
          <button id="copy-invite" type="button" disabled
            class="rounded-full border border-neutral-800 bg-neutral-900 px-3.5 py-1.5 text-xs font-medium text-neutral-200 hover:bg-neutral-800 disabled:cursor-not-allowed disabled:opacity-40 transition">
            Copy invite
          </button>
        </div>
      </header>
    ```

    ### `renderer/app.js` [#rendererappjs]

    The entrypoint. It talks to the worker entirely through `window.bridge`. The composer sends `{ type: 'add-message', text }`; the worker pushes back `messages`, `peers`, and `invite` frames:

    ```js file=<rootDir>/examples/getting-started/pear-chat/renderer/app.js#L90-L105 title="renderer/app.js — worker IPC"
    bridge.startWorker(SPECIFIER)

    const offWorkerIPC = bridge.onWorkerIPC(SPECIFIER, (data) => {
      let message
      try {
        message = JSON.parse(decoder.decode(data))
      } catch {
        return
      }
      if (message.type === 'messages') renderMessages(message.messages)
      else if (message.type === 'peers') renderPeers(message.count)
      else if (message.type === 'invite') {
        invite = message.invite
        copyEl.disabled = false
      }
    })
    ```

    The copy button writes the cached invite to the clipboard through the bridge:

    ```js file=<rootDir>/examples/getting-started/pear-chat/renderer/app.js#L73-L78 title="renderer/app.js — copy invite"
    copyEl.addEventListener('click', () => {
      if (!invite) return
      bridge.writeClipboard(invite)
      copyEl.textContent = 'Copied!'
      setTimeout(() => { copyEl.textContent = 'Copy invite' }, 1500)
    })
    ```

    And the OTA banner is driven entirely by `bridge.onPearEvent`—inert in development, live once [part 4](/p2p/getting-started/build-a-peer-to-peer-chat/update) ships a second version:

    ```js file=<rootDir>/examples/getting-started/pear-chat/renderer/app.js#L80-L88 title="renderer/app.js — OTA banner"
    // OTA update banner, driven by the updater worker via pear:event:* (see electron/main.js).
    updateEl.addEventListener('click', async () => {
      updateEl.disabled = true
      updateEl.textContent = 'restarting…'
      await bridge.applyUpdate().catch((err) => { updateEl.disabled = false; updateEl.textContent = 'update failed'; throw err })
      await bridge.appAfterUpdate()
    })
    bridge.onPearEvent('updating', () => { versionEl.textContent = 'updating…' })
    bridge.onPearEvent('updated', () => { updateEl.classList.remove('hidden') })
    ```

    Peer-supplied text is always written with `textContent`, never `innerHTML`, so a message can't inject markup.

    **The split is deliberate.** The renderer treats the worker like a remote API—and that's exactly what it is. Swap the worker specifier and the same plumbing works.
  </Step>

  <Step>
    ## Run two peers locally [#run-two-peers-locally]

    [Corestore](/p2p/reference/helpers/corestore) takes an exclusive lock on its directory, so each peer needs its own `--storage`. From the project root:

    ```bash skip="desktop-gui"
    npm run build

    # user1: create the room + print an invite
    npm start -- --storage /tmp/pear-chat-user1 --name user1
    ```

    Watch the terminal for an `Invite: …` line—or click **Copy invite** in the window. Copy that z32-encoded string.

    In a second terminal, still in `pear-chat/`:

    ```bash skip="desktop-gui"
    npm start -- --storage /tmp/pear-chat-user2 --name user2 --invite <paste-invite>
    ```

    A second window opens. Within a few seconds the two peers pair via [`blind-pairing`](https://www.npmjs.com/package/blind-pairing), the second becomes a writer on the shared [Autobase](/p2p/reference/building-blocks/autobase), and both windows show the message history and tick the peer dot to green. Type in one window, hit Enter, and watch it appear in the other.

    Both peers persist messages under `--storage`. Close the apps, reopen them with the same paths, and the conversation is still there. To wipe a peer's local state:

    ```bash skip="desktop-gui"
    npm start -- --storage /tmp/pear-chat-user1 --name user1 --reset
    ```

    `npm start` already forwards `--no-updates`, so the OTA updater stays inert in development—[part 4](/p2p/getting-started/build-a-peer-to-peer-chat/update) flips it on.
  </Step>
</Steps>

## What you built [#what-you-built]

A complete P2P desktop chat with persistence, pairing, OTA-update wiring, and three-platform packaging config—all on the canonical Pear + Electron template.

| Layer        | File                                         | Concept                                                                                                    |
| ------------ | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| Distribution | `forge.config.js`, `build/`, `pear.json`     | [Build desktop distributables](/pear/how-to/operate-an-app/build-and-package/build-desktop-distributables) |
| Shell        | `electron/main.js`, `electron/preload.js`    | [Pear desktop application architecture](/pear/explanation/pear-desktop-architecture)                       |
| Updater      | `workers/main.js` (PearRuntime OTA in Bare)  | [Pear OTA](/pear/reference/pear/runtime)                                                                   |
| Worker       | `workers/index.js`, `workers/worker-task.js` | [Workers](/pear/explanation/workers)                                                                       |
| Room         | `workers/chat-room.js`                       | [A production Autobase-backed chat room](/p2p/explanation/autobase-backed-chat-room)                       |
| Storage      | `Corestore`, `HyperDB` view                  | [Corestore](/p2p/reference/helpers/corestore), [Hyperbee](/p2p/reference/building-blocks/hyperbee)         |
| Transport    | `electron/preload.js`, framed worker pipe    | JSON messages over `window.bridge` IPC                                                                     |
| UI           | `renderer/index.html`, `renderer/app.js`     | Vanilla DOM + [Tailwind CSS](https://tailwindcss.com/); copy-invite + peer dot                             |

## Where to go next [#where-to-go-next]

* **Continue the path:** [Ship your app](/p2p/getting-started/build-a-peer-to-peer-chat/ship) (part 3 of 4)—mint a `pear://` link, build per-OS distributables, and stage your first release. Then [Deploy over-the-air updates](/p2p/getting-started/build-a-peer-to-peer-chat/update) (part 4 of 4) ships a second version live and the OTA banner you wired here goes hot.

Or pick a capability to add on top of this scaffold—each how-to under [the How To guides](/pear/how-to) documents only the **delta** over this exact code:

* [Add blind peering to a chat app](/p2p/how-to/blind-peering/add-blind-peering-to-a-chat-app)—keep the room online when its writers are offline.
* [Add Keet identity to a chat app](/p2p/how-to/manage-identity/add-keet-identity-to-a-chat-app)—anchor messages to a portable identity key.
* [Host multiple rooms in one chat app](/p2p/how-to/connect-to-peers/host-multiple-rooms-in-one-chat-app)—extend from one room to an account with many.
* [Share files in a peer-to-peer app](/p2p/how-to/stream-and-share-media/share-files-in-a-peer-to-peer-app)—swap the room's HyperDB view for a [Hyperdrive](/p2p/reference/building-blocks/hyperdrive).

Or zoom out to the conceptual picture:

* [A production Autobase-backed chat room](/p2p/explanation/autobase-backed-chat-room)—the worker-side Corestore/Hyperswarm/Autobase/blind-pairing internals this tutorial builds around.
* [Pear desktop application architecture](/pear/explanation/pear-desktop-architecture)—why a production app splits OTA updates, storage, and workers.
* [Workers](/pear/explanation/workers)—what a Bare worker is and why P2P state lives there.
* [Release pipeline](/pear/explanation/deployment-releasing-apps-p2p)—staging links, multisig, and the OTA loop.
