# beacio — navigator.bluetooth on iOS Safari (full corpus) > A Safari Web Extension that ships the W3C Web Bluetooth API on iPhone. > Free polyfill; premium capabilities on `window.webbleIOS`. > Canonical: https://ioswebble.com/llms-full.txt > Lightweight index: https://ioswebble.com/llms.txt --- # Quickstart Get `navigator.bluetooth` working inside iOS Safari in under ten minutes. This page covers the two steps every beacio integration shares — install the Safari Web Extension, then load the polyfill in your page — and links out to framework-specific walkthroughs. ## 1. Install the beacio Safari Web Extension beacio ships as a regular iOS app that registers a Safari Web Extension. Distributed through the App Store for user install; no sideloading, no developer mode. 1. Open the App Store on iPhone and install **beacio**. 2. Launch the app once to finish first-run setup. 3. Open **Settings → Apps → Safari → Extensions → beacio** and enable the extension. 4. Tap **beacio** again, choose **Always Allow**, then **Always Allow on Every Website**. The extension is now live on every tab. Safari exposes `navigator.bluetooth` on any HTTPS page that loads the beacio polyfill script. ## 2. Load the polyfill in your page Pick one of two install modes: **CDN (fastest to try):** ```html ``` **npm (recommended for real projects):** ```bash npm install @beacio/core ``` ```js import '@beacio/core/auto'; ``` The core package is a zero-config polyfill. It detects the extension, mounts the W3C surface at `navigator.bluetooth`, and mounts the iOS-only vendor-prefixed surface at `window.webbleIOS`. ## 3. Verify it works ```js if (await navigator.bluetooth.getAvailability()) { const device = await navigator.bluetooth.requestDevice({ filters: [{ services: ['heart_rate'] }] }); console.log('Paired with', device.name); } ``` If `getAvailability()` returns `false`, the user has not enabled the extension on this site; see [Extension not detected](troubleshooting/extension-not-detected.md). ## Framework-specific walkthroughs - [HTML / plain JS](quickstart-html.md) - [React](quickstart-react.md) - [Vue](quickstart-vue.md) - [Svelte / SvelteKit](quickstart-svelte.md) - [Angular](quickstart-angular.md) - [Next.js](quickstart-next.md) ## Next steps - [API reference](api-reference.md) — every documented method - [Recipes](recipes.md) — heart-rate, battery, CGM, lock, beacon, peripheral - [Premium APIs](premium.md) — `window.webbleIOS` surface - [Troubleshooting](troubleshooting/extension-not-detected.md) --- # Quickstart — HTML / plain JavaScript Minimal integration: one ` ``` Open the page over HTTPS on iPhone, tap the button, pick the monitor in the chooser sheet, and values stream into the `
` block.

## 2. npm install alternative

Prefer a real module graph? Install the core package:

```bash
npm install @beacio/core
```

```js
import '@beacio/core/auto';

const device = await navigator.bluetooth.requestDevice({
  filters: [{ services: ['heart_rate'] }]
});
```

Importing the package side-effect mounts `navigator.bluetooth` and `window.webbleIOS`.

## What just happened

- `@beacio/core` detected the beacio Safari Web Extension and installed the W3C polyfill at `navigator.bluetooth`.
- `requestDevice` opened the system chooser sheet — the extension is the only code allowed to drive it.
- GATT read/write/notify traffic flows through the extension to CoreBluetooth and back, fully off the main thread.

## Next

- [API reference](api-reference.md)
- [Recipes](recipes.md)
- [Troubleshooting: extension not detected](troubleshooting/extension-not-detected.md)

---

# Quickstart — React

Wire `@beacio/core` into a React app with a typed hook from `@beacio/react`.

## Prerequisites

- The beacio Safari Web Extension enabled on your origin. See the [base quickstart](quickstart.md).
- React 18+ (hooks). Works with Vite, CRA, Next, Remix, Astro islands.

## 1. Install

```bash
npm install @beacio/core @beacio/react
```

## 2. Mount the polyfill

Import the core package once at the entry point so `navigator.bluetooth` is installed before any component renders.

```tsx
// main.tsx
import '@beacio/core/auto';
import { createRoot } from 'react-dom/client';
import App from './App';

createRoot(document.getElementById('root')!).render();
```

## 3. Read a heart-rate monitor

```tsx
// HeartRate.tsx
import { useEffect, useState } from 'react';
import { useConnection } from '@beacio/react';

export function HeartRate() {
  const { device, status, isConnected, connect, disconnect, error } = useConnection({
    filters: [{ services: ['heart_rate'] }]
  });
  const [bpm, setBpm] = useState(null);

  useEffect(() => {
    if (!isConnected || !device?.gatt) return;
    let char: BluetoothRemoteGATTCharacteristic | undefined;
    const onChange = (ev: Event) => {
      const v = (ev.target as BluetoothRemoteGATTCharacteristic).value!;
      setBpm(v.getUint8(0) & 0x01 ? v.getUint16(1, true) : v.getUint8(1));
    };
    (async () => {
      const server = await device.gatt!.connect();
      const service = await server.getPrimaryService('heart_rate');
      char = await service.getCharacteristic('heart_rate_measurement');
      await char.startNotifications();
      char.addEventListener('characteristicvaluechanged', onChange);
    })();
    return () => char?.removeEventListener('characteristicvaluechanged', onChange);
  }, [isConnected, device]);

  if (error) return 

Error: {error.message}

; if (!isConnected) { return ( ); } return ( <>

{bpm ?? '—'} bpm

); } ``` `useConnection` is the all-in-one hook for a single-device session: it drives the click-triggered `requestDevice()` picker and GATT connect from one `connect()` call and exposes `{ device, status, isConnected, connect, disconnect, error }`. `status` moves through `idle → requesting → connecting → connected` (and `disconnected`), so disable the trigger while it is not `idle`. ## 4. Feature-detect iOS premium surface ```tsx import { useEffect, useState } from 'react'; export function usePremium() { const [premium, setPremium] = useState(false); useEffect(() => { setPremium(typeof window !== 'undefined' && 'webbleIOS' in window); }, []); return premium; } ``` If `webbleIOS` is present the page can use [premium APIs](premium.md); if not, fall back to the standard surface only. ## Next - [API reference](api-reference.md) - [Recipes](recipes.md) - [Premium APIs](premium.md) --- # Quickstart — Vue Integrate `@beacio/core` with a Vue 3 Composition API component. ## Prerequisites - The beacio Safari Web Extension enabled on your origin. See the [base quickstart](quickstart.md). - Vue 3 (Composition API). Works with Vite, Nuxt, Quasar. ## 1. Install ```bash npm install @beacio/core ``` ## 2. Mount the polyfill at app entry ```ts // main.ts import '@beacio/core/auto'; import { createApp } from 'vue'; import App from './App.vue'; createApp(App).mount('#app'); ``` Importing once installs `navigator.bluetooth` and `window.webbleIOS` before any component code runs. ## 3. Read a heart-rate monitor ```vue ``` ## 4. Nuxt note In Nuxt, mount the polyfill from a client-only plugin: ```ts // plugins/webble.client.ts import '@beacio/core/auto'; export default defineNuxtPlugin(() => {}); ``` The `.client.ts` suffix prevents SSR from evaluating the polyfill, which touches `window` at import time. ## Next - [API reference](api-reference.md) - [Recipes](recipes.md) - [Premium APIs](premium.md) --- # Quickstart — Svelte / SvelteKit Integrate `@beacio/core` with a Svelte 4/5 component. ## Prerequisites - The beacio Safari Web Extension enabled on your origin. See the [base quickstart](quickstart.md). - Svelte 4+ or SvelteKit. Works with Vite-based tooling. ## 1. Install ```bash npm install @beacio/core ``` ## 2. Mount the polyfill For a plain Svelte app, import once at the entry point: ```ts // main.ts import '@beacio/core/auto'; import App from './App.svelte'; const app = new App({ target: document.getElementById('app')! }); export default app; ``` For **SvelteKit**, load it only in the browser — SSR must not touch `window`: ```ts // src/routes/+layout.ts export const ssr = false; // or per-page // src/routes/+layout.svelte ``` ## 3. Read a heart-rate monitor ```svelte {#if err}

Error: {err}

{:else}

{bpm ?? '—'} bpm

{/if} ``` ## Next - [API reference](api-reference.md) - [Recipes](recipes.md) - [Premium APIs](premium.md) --- # Quickstart — Angular Integrate `@beacio/core` with Angular 17+ standalone components. ## Prerequisites - The beacio Safari Web Extension enabled on your origin. See the [base quickstart](quickstart.md). - Angular 17+ (standalone components, signals). ## 1. Install ```bash npm install @beacio/core ``` ## 2. Mount the polyfill at bootstrap ```ts // main.ts import '@beacio/core/auto'; import { bootstrapApplication } from '@angular/platform-browser'; import { AppComponent } from './app/app.component'; bootstrapApplication(AppComponent).catch((err) => console.error(err)); ``` Importing at `main.ts` guarantees `navigator.bluetooth` exists before any component renders. ## 3. Read a heart-rate monitor ```ts // heart-rate.component.ts import { Component, signal } from '@angular/core'; @Component({ selector: 'app-heart-rate', standalone: true, template: `

Error: {{ err() }}

{{ bpm() ?? '—' }} bpm

` }) export class HeartRateComponent { bpm = signal(null); err = signal(null); async connect() { try { const device = await navigator.bluetooth.requestDevice({ filters: [{ services: ['heart_rate'] }] }); const server = await device.gatt!.connect(); const service = await server.getPrimaryService('heart_rate'); const char = await service.getCharacteristic('heart_rate_measurement'); await char.startNotifications(); char.addEventListener('characteristicvaluechanged', (ev) => { const v = (ev.target as BluetoothRemoteGATTCharacteristic).value!; this.bpm.set(v.getUint8(0) & 0x01 ? v.getUint16(1, true) : v.getUint8(1)); }); } catch (e) { this.err.set((e as Error).message); } } } ``` ## 4. Type support The W3C Web Bluetooth types ship with most modern TypeScript configurations. If `navigator.bluetooth` is flagged as `any`, install the DOM types add-on: ```bash npm install --save-dev @types/web-bluetooth ``` ## Next - [API reference](api-reference.md) - [Recipes](recipes.md) - [Premium APIs](premium.md) --- # Quickstart — Next.js Integrate `@beacio/core` with Next.js (App Router, 13+). Web Bluetooth is a client-side-only API, so every integration path is client-component. ## Prerequisites - The beacio Safari Web Extension enabled on your origin. See the [base quickstart](quickstart.md). - Next.js 13+ (App Router) or Next.js 12+ (Pages Router). ## 1. Install ```bash npm install @beacio/core @beacio/react ``` ## 2. Mount the polyfill in a client-only boundary Do **not** import `@beacio/core` in a server component — it touches `window` at import time. ```tsx // app/providers.tsx 'use client'; import { useEffect } from 'react'; export function BluetoothProvider({ children }: { children: React.ReactNode }) { useEffect(() => { import('@beacio/core/auto'); }, []); return <>{children}; } ``` ```tsx // app/layout.tsx import { BluetoothProvider } from './providers'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` `dynamic import inside useEffect` ensures the polyfill only runs in the browser. ## 3. Read a heart-rate monitor (client component) ```tsx // app/heart-rate/page.tsx 'use client'; import { useState } from 'react'; export default function HeartRate() { const [bpm, setBpm] = useState(null); async function connect() { const device = await navigator.bluetooth.requestDevice({ filters: [{ services: ['heart_rate'] }] }); const server = await device.gatt!.connect(); const service = await server.getPrimaryService('heart_rate'); const char = await service.getCharacteristic('heart_rate_measurement'); await char.startNotifications(); char.addEventListener('characteristicvaluechanged', (ev) => { const v = (ev.target as BluetoothRemoteGATTCharacteristic).value!; setBpm(v.getUint8(0) & 0x01 ? v.getUint16(1, true) : v.getUint8(1)); }); } return ( <>

{bpm ?? '—'} bpm

); } ``` ## 4. Pages Router equivalent ```tsx // pages/heart-rate.tsx import dynamic from 'next/dynamic'; const HeartRate = dynamic(() => import('../components/HeartRate'), { ssr: false }); export default function Page() { return ; } ``` `ssr: false` ensures the component — and the polyfill import inside it — only loads in the browser. ## Next - [API reference](api-reference.md) - [Recipes](recipes.md) - [Premium APIs](premium.md) --- # API Reference beacio exposes two distinct API surfaces: - **`navigator.bluetooth`** — the standard W3C Web Bluetooth API. Pure polyfill; works identically to Chrome/Edge implementations. Portable across browsers. - **`window.webbleIOS`** — an iOS-only vendor-prefixed surface for capabilities that are deliberately not part of the W3C spec (peripheral / GATT-server mode, background sync, beacon scanning, iOS notifications). Feature-detect with `'webbleIOS' in window`. Every method documented below is present at runtime; every premium signature is derived from `src/webble/api/` and `src/types/background-sync.ts` in the repo. --- ## Standard surface — `navigator.bluetooth` ### `navigator.bluetooth.requestDevice(options)` Prompts the user to select a nearby Bluetooth Low Energy peripheral matching the given filters. Must be invoked from a user activation (click/tap). **Signature** ```ts navigator.bluetooth.requestDevice(options: RequestDeviceOptions): Promise ``` **Parameters** - `options.filters` — array of `BluetoothLEScanFilter`. At least one required unless `acceptAllDevices: true`. - `options.optionalServices` — additional service UUIDs the page may access after pairing. - `options.acceptAllDevices` — if `true`, shows every advertising peripheral. Cannot combine with `filters`. **Returns** A `BluetoothDevice` handle. The user may cancel the chooser, in which case the promise rejects with `NotFoundError`. **Example** ```js const device = await navigator.bluetooth.requestDevice({ filters: [{ services: ['heart_rate'] }], optionalServices: ['battery_service'] }); ``` **Spec:** [W3C §requestDevice](https://webbluetoothcg.github.io/web-bluetooth/#dom-bluetooth-requestdevice) --- ### `navigator.bluetooth.getAvailability()` Reports whether the browser has a usable Bluetooth radio and the beacio extension is reachable on this origin. **Signature** ```ts navigator.bluetooth.getAvailability(): Promise ``` **Returns** — `true` if the polyfill is mounted and the extension is enabled for this site; `false` otherwise. **Example** ```js if (!(await navigator.bluetooth.getAvailability())) { alert('Enable the beacio extension on this site'); } ``` **Spec:** [W3C §getAvailability](https://webbluetoothcg.github.io/web-bluetooth/#dom-bluetooth-getavailability) --- ### `BluetoothDevice.gatt.connect()` Opens a GATT connection to the peripheral represented by the `BluetoothDevice`. **Signature** ```ts device.gatt.connect(): Promise ``` **Returns** — a connected `BluetoothRemoteGATTServer`. Rejects with `NetworkError` if the peripheral is out of range or refuses the connection. **Example** ```js const server = await device.gatt.connect(); ``` **Spec:** [W3C §gatt.connect](https://webbluetoothcg.github.io/web-bluetooth/#dom-bluetoothremotegattserver-connect) --- ### `BluetoothDevice.gatt.disconnect()` Tears down the GATT connection. Fires a `gattserverdisconnected` event on the device. **Signature** ```ts device.gatt.disconnect(): void ``` **Example** ```js device.addEventListener('gattserverdisconnected', () => console.log('dropped')); device.gatt.disconnect(); ``` **Spec:** [W3C §gatt.disconnect](https://webbluetoothcg.github.io/web-bluetooth/#dom-bluetoothremotegattserver-disconnect) --- ### `BluetoothRemoteGATTServer.getPrimaryService(uuid)` Returns a primary GATT service advertised by the peripheral. **Signature** ```ts server.getPrimaryService(service: BluetoothServiceUUID): Promise ``` **Parameters** — `service`: a 16-bit alias (`'heart_rate'`), 128-bit UUID string, or numeric service ID. **Returns** — a `BluetoothRemoteGATTService`. Rejects with `NotFoundError` if the service is not present on the peripheral or was not listed in `filters`/`optionalServices`. **Example** ```js const service = await server.getPrimaryService('heart_rate'); ``` **Spec:** [W3C §getPrimaryService](https://webbluetoothcg.github.io/web-bluetooth/#dom-bluetoothremotegattserver-getprimaryservice) --- ### `BluetoothRemoteGATTService.getCharacteristic(uuid)` Returns a single GATT characteristic of this service. **Signature** ```ts service.getCharacteristic( characteristic: BluetoothCharacteristicUUID ): Promise ``` **Example** ```js const char = await service.getCharacteristic('heart_rate_measurement'); ``` **Spec:** [W3C §getCharacteristic](https://webbluetoothcg.github.io/web-bluetooth/#dom-bluetoothremotegattservice-getcharacteristic) --- ### `BluetoothRemoteGATTCharacteristic.readValue()` Performs a GATT read of the characteristic. **Signature** ```ts char.readValue(): Promise ``` **Returns** — a `DataView` over the raw characteristic bytes. Also updates `char.value`. **Example** ```js const level = (await batteryChar.readValue()).getUint8(0); // 0–100 ``` **Spec:** [W3C §readValue](https://webbluetoothcg.github.io/web-bluetooth/#dom-bluetoothremotegattcharacteristic-readvalue) --- ### `BluetoothRemoteGATTCharacteristic.writeValue(value)` Writes the given bytes to the characteristic. Prefer `writeValueWithResponse` / `writeValueWithoutResponse` when the peripheral supports both and you need to pick explicitly. **Signature** ```ts char.writeValue(value: BufferSource): Promise ``` **Example** ```js await char.writeValue(new Uint8Array([0x01, 0x02])); ``` **Spec:** [W3C §writeValue](https://webbluetoothcg.github.io/web-bluetooth/#dom-bluetoothremotegattcharacteristic-writevalue) --- ### `BluetoothRemoteGATTCharacteristic.startNotifications()` Subscribes to value-change notifications from the characteristic. Each notification fires a `characteristicvaluechanged` event with the new bytes in `event.target.value`. **Signature** ```ts char.startNotifications(): Promise ``` **Example** ```js await char.startNotifications(); char.addEventListener('characteristicvaluechanged', (ev) => { const v = ev.target.value; // DataView console.log('new value', new Uint8Array(v.buffer)); }); ``` Use `stopNotifications()` to unsubscribe. **Spec:** [W3C §startNotifications](https://webbluetoothcg.github.io/web-bluetooth/#dom-bluetoothremotegattcharacteristic-startnotifications) --- ## Premium surface — `window.webbleIOS` Present only when the beacio Safari Web Extension is installed. The surface is frozen; managers are instantiated lazily on first access. Feature-detect with `'webbleIOS' in window`. ```ts interface WebBLEIOS { readonly peripheral: WebBLEPeripheralManager; readonly backgroundSync: WebBLEBackgroundSync; getCapabilities(): Promise; } ``` ### `window.webbleIOS.getCapabilities()` Returns a bag of capability flags negotiated with the companion app. Useful for runtime dispatch between standalone and IPC-relay modes. ```ts window.webbleIOS.getCapabilities(): Promise ``` --- ### `window.webbleIOS.peripheral` — GATT-server mode Advertise a custom GATT server from the web page. Not present on any other browser. See [recipes: peripheral chat](recipes.md#peripheral-chat) for a full example. Core methods: - `peripheral.addService(definition)` — register a GATT service + its characteristics. - `peripheral.startAdvertising(options?)` — begin advertising to nearby centrals. - `peripheral.stopAdvertising()` — stop. - Events: `onwriterequest`, `onsubscriptionchange`, `onconnectionstatechange`, `onadvertisingstatechange`, `onnotificationready`. Service and characteristic definitions follow the types in [`src/webble/api/webble-peripheral.ts`](https://github.com/wklm/ioswebble-sdk/blob/main/src/webble/api/webble-peripheral.ts). --- ### `window.webbleIOS.backgroundSync` — background operations Keep connections alive, fire OS notifications on characteristic value changes, and scan for beacons while Safari is backgrounded. Requires the companion app to be running. ```ts interface WebBLEBackgroundSync { requestPermission(): Promise<'granted' | 'denied' | 'prompt'>; requestBackgroundConnection(options): Promise; registerCharacteristicNotifications(options): Promise; // "notifications" registerBeaconScanning(options): Promise; // "beacons" getRegistrations(): Promise; unregister(id: string): Promise; update(id: string, template: Partial): Promise; destroy(): void; } ``` The conceptual premium categories map to this surface as follows: | Category | Actual API | |----------|-----------| | `backgroundSync` | `window.webbleIOS.backgroundSync.requestBackgroundConnection` | | `notifications` | `window.webbleIOS.backgroundSync.registerCharacteristicNotifications` | | `beacons` | `window.webbleIOS.backgroundSync.registerBeaconScanning` | | `peripheral` | `window.webbleIOS.peripheral` | | `liveActivity` | Implicit — the companion app manages ActivityKit Live Activities automatically for active background-sync registrations when running in IPC-relay mode. No direct JS API today. | Full type surface: [`src/types/background-sync.ts`](https://github.com/wklm/ioswebble-sdk/blob/main/src/types/background-sync.ts). See the [Premium APIs](premium.md) page for narrative usage. --- # Premium APIs — `window.webbleIOS` The standard polyfill at `navigator.bluetooth` is free: anyone with the beacio extension gets the full W3C surface, portable, cross-browser-compatible. Beyond that, beacio exposes iOS-only capabilities under a vendor-prefixed global: **`window.webbleIOS`**. These capabilities are deliberately not part of the W3C spec — they extend what the web can do on iPhone beyond what any other browser offers. Feature-detect before use: ```js if (!('webbleIOS' in window)) { // standard surface only; fall back or upsell the companion app return; } ``` ## The surface ```ts interface WebBLEIOS { readonly peripheral: WebBLEPeripheralManager; readonly backgroundSync: WebBLEBackgroundSync; getCapabilities(): Promise; } ``` ## Capabilities ### Peripheral mode — `window.webbleIOS.peripheral` Turn the iPhone into a GATT peripheral advertising custom services. Scanning centrals (other phones, laptops, sensor tags) discover and connect to it like any dedicated device. No other browser exposes this. Core methods: - `addService(definition)` — register a GATT service with characteristics and their properties (`read`, `write`, `notify`, …). - `startAdvertising(options?)` — begin advertising; options include `localName` and `serviceUUIDs`. - `stopAdvertising()` — stop. Events: `onwriterequest`, `onsubscriptionchange`, `onconnectionstatechange`, `onadvertisingstatechange`, `onnotificationready`. See [recipes: peripheral chat](recipes.md#peripheral-chat) for a runnable example. --- ### Background sync — `window.webbleIOS.backgroundSync` Continue BLE work while Safari is backgrounded or the phone is locked. Requires the beacio companion app to be installed and running. Three first-class registration types: #### `requestBackgroundConnection({ deviceId })` Keeps a previously-paired device connected while Safari is backgrounded. No OS notification is shown — the connection is just maintained so that when the user returns, the device is still live. ```js const reg = await window.webbleIOS.backgroundSync.requestBackgroundConnection({ deviceId: device.id }); ``` #### `registerCharacteristicNotifications(options)` — *the "notifications" premium API* Deliver an iOS notification when a GATT characteristic value changes in the background, with a template-interpolated title/body and an optional reply action. ```js await window.webbleIOS.backgroundSync.registerCharacteristicNotifications({ deviceId: device.id, serviceUUID: 'heart_rate', characteristicUUID: 'heart_rate_measurement', condition: { decode: 'uint8', operator: 'gt', threshold: 150 }, template: { title: 'High heart rate', body: '{{deviceName}} is at {{value.utf8}} bpm', url: 'https://example.com/alert' }, cooldownSeconds: 30, replyAction: { actionTitle: 'Ack', placeholder: 'Reply…' } }); ``` Supported decoders: `uint8`, `int16be`, `int16le`, `int32be`, `float32le`, `float32be`. Supported operators: `gt`, `lt`, `gte`, `lte`, `eq`, `neq`, `changed`, `always`. Template placeholders: `{{device.name}}`, `{{device.id}}`, `{{value.hex}}`, `{{value.utf8}}`, `{{value.int16be}}`, `{{value.int32be}}`, `{{timestamp}}`. #### `registerBeaconScanning(options)` — *the "beacons" premium API* Fire an iOS notification when a matching BLE advertisement is seen while backgrounded. iOS hardware-filters on service UUIDs; `namePrefix` is applied in software. ```js await window.webbleIOS.backgroundSync.registerBeaconScanning({ filters: [{ services: ['battery_service'], namePrefix: 'Acme' }], cooldownSeconds: 30, template: { title: 'Acme beacon in range', body: 'at {{timestamp}}' } }); ``` **iOS platform note:** beacon delivery is optimized for quick catch-up rather than guaranteed fixed real-time discovery while the app is backgrounded. #### Lifecycle - `requestPermission()` — explicitly request notification permission ahead of registering. - `getRegistrations()` — list active registrations for this origin. - `unregister(id)` / `update(id, partialTemplate)` — teardown / modify. Every registration is persisted in the companion app; it survives Safari closing, the phone rebooting, and the extension process restarting. --- ### Live Activities Live Activities (Lock Screen / Dynamic Island) are **not** a direct JS API. The companion app manages them automatically while running in IPC-relay mode: when the page has one or more active background-sync registrations, the companion app drives an ActivityKit session reflecting keep-alive connections, characteristic notification alerts, and beacon scans. No opt-in is required from the page; no opt-out surface is exposed today. If you need programmatic control over the Live Activity, open an issue on the GitHub repository. --- ### `getCapabilities()` Returns a bag of runtime capability flags negotiated with the companion app — use it to branch between standalone mode (Safari owns BLE) and IPC-relay mode (companion app owns BLE, background work is allowed). ```js const caps = await window.webbleIOS.getCapabilities(); ``` The exact shape is forward-compatible by design; treat unknown keys as additive. ## Source of truth - [`src/webble-ios/index.ts`](https://github.com/wklm/ioswebble-sdk/blob/main/src/webble-ios/index.ts) — surface construction - [`src/webble/api/webble-peripheral.ts`](https://github.com/wklm/ioswebble-sdk/blob/main/src/webble/api/webble-peripheral.ts) — peripheral manager - [`src/types/background-sync.ts`](https://github.com/wklm/ioswebble-sdk/blob/main/src/types/background-sync.ts) — background-sync types --- # Recipes Copy-paste, runnable snippets for the six profiles the beacio MCP server also emits. Every recipe assumes the extension is installed and `@beacio/core` is loaded. Each uses only documented W3C surface except where explicitly marked premium. --- ## Heart rate Standard Heart Rate profile (`0x180D`). The first byte of `heart_rate_measurement` is a flags byte; if bit 0 is set, the BPM value is uint16 little-endian starting at offset 1; otherwise it's a single uint8. ```js const device = await navigator.bluetooth.requestDevice({ filters: [{ services: ['heart_rate'] }] }); const server = await device.gatt.connect(); const service = await server.getPrimaryService('heart_rate'); const char = await service.getCharacteristic('heart_rate_measurement'); await char.startNotifications(); char.addEventListener('characteristicvaluechanged', (ev) => { const v = ev.target.value; const bpm = v.getUint8(0) & 0x01 ? v.getUint16(1, true) : v.getUint8(1); console.log(`${bpm} bpm`); }); ``` **Spec:** Bluetooth SIG Heart Rate Service 1.0. --- ## Battery level Standard Battery Service (`0x180F`). Single uint8 characteristic value 0–100. ```js const device = await navigator.bluetooth.requestDevice({ filters: [{ services: ['battery_service'] }] }); const server = await device.gatt.connect(); const service = await server.getPrimaryService('battery_service'); const char = await service.getCharacteristic('battery_level'); const level = (await char.readValue()).getUint8(0); console.log(`${level}% battery`); // Optional: subscribe if the peripheral supports notify if (char.properties.notify) { await char.startNotifications(); char.addEventListener('characteristicvaluechanged', (ev) => { console.log('battery now', ev.target.value.getUint8(0)); }); } ``` --- ## CGM (continuous glucose monitor) CGM Service (`0x181F`). Glucose measurement characteristic reports values in mg/dL as a 16-bit SFLOAT; most vendors also provide a simpler vendor characteristic — check your device's profile. ```js const CGM_SERVICE = 0x181f; const CGM_MEASUREMENT = 0x2aa7; const device = await navigator.bluetooth.requestDevice({ filters: [{ services: [CGM_SERVICE] }] }); const server = await device.gatt.connect(); const service = await server.getPrimaryService(CGM_SERVICE); const char = await service.getCharacteristic(CGM_MEASUREMENT); await char.startNotifications(); char.addEventListener('characteristicvaluechanged', (ev) => { const v = ev.target.value; // Byte 0: size. Byte 1: flags. Bytes 2-3: glucose concentration (SFLOAT) const raw = v.getUint16(2, true); console.log(`glucose raw=${raw}`); }); ``` CGM frames carry a CRC and sequence number per the spec — discard frames that fail CRC when deploying to production. --- ## Lock Many consumer smart locks expose a vendor-specific service with a write-characteristic that accepts authenticated command packets. The shape below is generic; supply your vendor's UUIDs and command frame. ```js const LOCK_SERVICE = '0000fee0-0000-1000-8000-00805f9b34fb'; const LOCK_COMMAND = '0000fee1-0000-1000-8000-00805f9b34fb'; const device = await navigator.bluetooth.requestDevice({ filters: [{ services: [LOCK_SERVICE] }] }); const server = await device.gatt.connect(); const service = await server.getPrimaryService(LOCK_SERVICE); const char = await service.getCharacteristic(LOCK_COMMAND); // Example: 0x02 = unlock, followed by an auth token negotiated at pairing await char.writeValue(new Uint8Array([0x02, ...authToken])); ``` Real locks require vendor authentication handshakes — consult your lock's integration spec. --- ## Beacon (premium) Receive an iOS OS notification when a beacon with a specific service UUID is in range, even while Safari is backgrounded. Requires the beacio companion app. ```js if (!('webbleIOS' in window)) { throw new Error('Beacon scanning requires the beacio companion app'); } const registration = await window.webbleIOS.backgroundSync.registerBeaconScanning({ filters: [{ services: ['heart_rate'] }], cooldownSeconds: 30, template: { title: 'Beacon in range', body: 'Detected {{deviceName}} at {{timestamp}}', url: 'https://example.com/beacon-hit' } }); console.log('registered', registration.id); // later: registration.unregister(); ``` Template placeholders: `{{deviceName}}`, `{{device.id}}`, `{{value.hex}}`, `{{value.utf8}}`, `{{value.int16be}}`, `{{value.int32be}}`, `{{timestamp}}`. --- ## Peripheral chat Advertise a custom GATT server from the web page. A central (another phone, a laptop, a sensor tag, …) scans, connects, and exchanges bytes. This is premium — no other browser exposes it. ```js if (!('webbleIOS' in window)) { throw new Error('Peripheral mode requires the beacio companion app'); } const CHAT_SERVICE = '12345678-1234-5678-1234-56789abcdef0'; const CHAT_CHAR = '12345678-1234-5678-1234-56789abcdef1'; const record = await window.webbleIOS.peripheral.addService({ uuid: CHAT_SERVICE, characteristics: [{ uuid: CHAT_CHAR, properties: ['read', 'write', 'notify'], value: new TextEncoder().encode('hello') }] }); window.webbleIOS.peripheral.onwriterequest = (ev) => { const msg = new TextDecoder().decode(ev.value); console.log('central wrote:', msg); }; await window.webbleIOS.peripheral.startAdvertising({ localName: 'beacio Chat', serviceUUIDs: [CHAT_SERVICE] }); ``` When a subscribed central is present, push bytes back with the peripheral manager's `notifyCharacteristic`-style events — see the API reference for the full event list. --- # Extension not detected Your page loaded `@beacio/core` (or the CDN script) but `navigator.bluetooth.getAvailability()` returns `false` and `requestDevice()` rejects. This page enumerates every reason the extension fails to connect to Safari and how to fix each one. ## Symptom ```js await navigator.bluetooth.getAvailability(); // → false await navigator.bluetooth.requestDevice(...); // → rejects with NotSupportedError ``` Or, at import time, `navigator.bluetooth` is `undefined` entirely. ## 1. Extension not enabled in Safari Most common cause. iOS installs Safari Web Extensions disabled by default. **Fix:** 1. Open **Settings → Apps → Safari → Extensions**. 2. Tap **beacio**. 3. Toggle **Allow Extension** on. 4. Set **Permission → Other Websites** to **Allow**. Reload the page. `navigator.bluetooth` should now be defined. ## 2. "Always Allow on Every Website" not granted iOS Safari extensions default to per-origin opt-in: the first time a tab uses the extension, Safari shows a banner asking for permission. If the banner was dismissed with "Deny" or ignored, the extension is inert on that site. **Fix:** 1. Navigate to the site. 2. Tap the **`A`A** button in the address bar. 3. Tap **Manage Extensions**, choose **beacio**, and grant **Always Allow on Every Website** (or at least on this origin). Reload the page. ## 3. Private Browsing tab iOS Safari disables most extensions in Private Browsing. There is no per-extension opt-in; this is by design. **Fix:** open the site in a standard Safari tab (close Private Browsing or switch tab groups). ## 4. iOS version too old beacio requires **iOS 26.0 or later** — the minimum OS version of the App Store listing. (Safari Web Extensions as a platform date back to iOS 15, but the beacio app itself targets iOS 26.0.) **Fix:** - Open **Settings → General → About** and check the iOS version. - Update iOS from **Settings → General → Software Update**. - If the device cannot update (older hardware), beacio is not supported on that device. ## 5. beacio app never launched The extension only registers with Safari after the host app has been launched at least once. **Fix:** open the beacio app from the home screen, let it finish first-run setup, then reload the site. ## 6. Non-HTTPS origin Web Bluetooth (and the beacio polyfill) requires a secure context. `http://` pages and custom schemes don't get `navigator.bluetooth`. **Fix:** serve over HTTPS. For local development, `localhost` is treated as secure and works without a certificate. ## 7. The polyfill script didn't load Verify the ` ``` Or: ```bash npm install @beacio/core ``` Then: ```js import '@beacio/core/auto'; const device = await navigator.bluetooth.requestDevice({ filters: [{ services: ['heart_rate'] }] }); ``` ## Why is this allowed on iOS? beacio is a **Safari Web Extension**, not a custom browser engine. Apple's App Store rules permit extensions that add capabilities to Safari, including API polyfills that communicate with native code through the official `SFSafariExtensionHandler` bridge. beacio is distributed through the App Store — no enterprise certificate, no TestFlight hack, no jailbreak. ## What about macOS Safari? beacio's current focus is iPhone, because Chrome and Edge already expose native Web Bluetooth on macOS. If you need a macOS companion, the same extension architecture is viable; it is not the current product. ## What about other iOS browsers? On iOS, every browser (Chrome, Firefox, Brave, Edge, Arc) is required to use the WebKit engine. An iOS Web Bluetooth solution therefore has to live at the WebKit/Safari layer — which is exactly where beacio lives. Installing beacio and enabling its extension in Safari makes Web Bluetooth work on the iPhone's default browser; users do not need a different browser app. ## TL;DR - **Stock Safari:** no `navigator.bluetooth`. - **Safari + beacio extension:** full W3C `navigator.bluetooth` + iOS-only `window.webbleIOS` premium surface (peripheral mode, background sync, beacon notifications). - **Install path:** App Store → enable extension → load `@beacio/core` on your page. Start with the [quickstart](quickstart.md), or jump straight to the [heart-rate recipe](recipes.md#heart-rate). --- # Web Bluetooth on iPhone finally works For years the answer to "can I use Web Bluetooth on iPhone?" was a short, unhappy no. Safari did not — and still does not — implement the W3C Web Bluetooth specification. The standard response was to ship a native iOS app (Cordova, Capacitor, React Native, Flutter) or to give up on the iOS half of your audience. beacio changes that. This post explains what beacio actually is, why it works inside Apple's rules, and what it costs. ## The problem in one paragraph `navigator.bluetooth` is a W3C working draft shipped by Chrome, Edge, and Opera. Safari has declined to implement it citing privacy and security concerns, and iOS enforces WebKit as the rendering engine for every browser on the platform, so no third-party iOS browser can ship the API either. The net effect: if your product is a web page and you want to talk to a Bluetooth Low Energy device on an iPhone, there is no web-native path. You have to leave the web. ## What beacio is beacio is a **Safari Web Extension**, distributed through the iOS App Store, that polyfills the W3C `navigator.bluetooth` API inside the user's own Safari. Once the user installs the extension and grants it permission on a website, that website's JavaScript can call `navigator.bluetooth.requestDevice`, `GATTServer.connect`, `characteristic.readValue`, and the rest of the standard surface, and the calls reach real CoreBluetooth through the extension's native handler. The site code is unchanged. It is the same `navigator.bluetooth` code that already runs on Chrome desktop. There is no SDK to import, no rewrite, no platform fork. ## Why this is allowed Safari Web Extensions are a first-class, documented Apple feature. They are distributed through the App Store, and a user opts in by installing the extension and granting per-site permission. beacio is not a jailbreak, not an enterprise cert workaround, and not a private API hack. The native side uses public CoreBluetooth; the web side is a standard polyfill. ## How it fits into an existing web app Three lines. ```html ``` Then write the same `navigator.bluetooth` code you would write for Chrome: ```js const device = await navigator.bluetooth.requestDevice({ filters: [{ services: ['heart_rate'] }] }); const server = await device.gatt.connect(); const service = await server.getPrimaryService('heart_rate'); const chr = await service.getCharacteristic('heart_rate_measurement'); await chr.startNotifications(); chr.addEventListener('characteristicvaluechanged', (e) => { console.log('BPM:', e.target.value.getUint8(1)); }); ``` On Chrome this hits the browser's native implementation. On iOS Safari with beacio installed it hits the extension. There is no `if (iOS)` branch in your code. ## What it costs The polyfill is free for `navigator.bluetooth`. Premium capabilities are exposed under `window.webbleIOS` and are billed separately: - **Background Sync** — keep a GATT connection alive and fire iOS notifications when a characteristic changes, even with Safari closed. - **Beacons** — scan for advertising packets matching service-UUID filters in the background. - **Peripheral** — advertise the iPhone itself as a BLE peripheral. - **White-label** — ship a branded companion app. See [premium](../premium.md) for the exact API surface. ## What it does not do beacio cannot magically circumvent the W3C permission model. `requestDevice` still triggers the iOS system chooser; you cannot silently enumerate devices. The site still needs HTTPS. User gestures are still required. The rules are the W3C rules. ## When to pick beacio - Your product is a web page (PWA, SaaS console, dashboard, marketing site with a demo). - You already use `navigator.bluetooth` on Chrome and want iOS parity without porting. - You want same-day deploys, no App Review loop of your own. - You need BLE on iPhone without making your product a native app. ## When not to pick beacio - Your product must be a native app for non-BLE reasons (deep HealthKit, CarPlay, widgets). - You cannot ask users to install an extension, even once. ## Next steps - Read the [quickstart](../quickstart.md). - Browse the [recipes](../recipes.md) — heart rate, battery, CGM, lock, beacon. - Ask whether it [supports your framework](../is-web-bluetooth-supported-in-safari.md). --- # Changelog All notable changes to beacio and the `@beacio/*` package family are recorded here. Dates are ISO 8601 UTC; versions follow semver. The canonical source is this file. The repository root `CHANGELOG.md` and the npm package READMEs all render from it. --- ## Unreleased ### Added - Canonical agent-readable documentation mirror under `/docs-md/*` served by the Cloudflare `docs-md` Worker. - `/.well-known/agent.json` capability card for MCP-aware agents. - `/.well-known/openapi.json` describing the public telemetry and forms endpoints. - `/llms.txt` and `/llms-full.txt` for one-fetch agent ingestion. - Per-method `APIReference` JSON-LD blocks on the API reference page. - `@beacio/mcp` server exposing six tools (`webble_install_plan`, `webble_example`, `webble_detect_ios_support`, `webble_premium_guide`, `webble_troubleshoot`, `webble_spec_citation`). ### Changed - Homepage rebuilt for static parseability: stable section IDs, `