
Remote-mode BLE layer that lets existing Peripheral-based app code control physical devices over IP (WebSocket/CBOR), exposing full GATT, exclusive leases, reconnect reconciliation, and lightweight agents.
A "remote mode" for a Kotlin Multiplatform BLE stack: client app code written against
Kable's Peripheral runs unchanged whether the
peripheral is physically local or driven by a remote agent over an IP link
(WebSocket). Not affiliated with JUUL Labs or the Kable project.
Inspired by ESPHome's Bluetooth Proxy, which pioneered relaying the full BLE/GATT surface over IP behind the host BLE library's own interface (Bleak + Home Assistant there, Kable here) — RemoteBLE applies the same idea to Kotlin Multiplatform and OS-class hosts for development, testing, and CI. Independent and not affiliated with the ESPHome or Home Assistant projects.
The client SDK is published to Maven Central as dev.warsha.remoteble:client-sdk
(Kotlin Multiplatform: JVM, Android, iOS). It pulls :protocol and Kable transitively.
// build.gradle.kts — commonMain for a KMP app, or a JVM/Android source set
dependencies {
implementation("dev.warsha.remoteble:client-sdk:0.10.0")
}The snippet tracks the current release line. The Maven Central badge at the top of this README shows the version actually resolvable right now — if you're reading between a version bump and its Central publish, use that number.
Upgrading from an earlier Central release? Read the concise
0.10.0 migration guide, especially the breaking authToken provider
change.
iOS is covered by the same coordinate: it's a Kotlin Multiplatform publication, so an
iOS app that shares Kotlin code (your Kable app logic lives in commonMain) resolves the
iosArm64/iosSimulatorArm64 klibs from Central automatically. There is no separate
Swift Package / XCFramework — this SDK is consumed as Kotlin, alongside Kable itself.
The agent is run from a prebuilt binary (download from a release)
or from source (agent/run-agent.sh, the agent-rs binary, or the phone apps), not consumed as a
dependency — see Running the agent.
:e2e-runner is built for exactly this.peripheralFor(mode, …)), so Kable code gains it with essentially no rewrite.KableAdapterTest).btleplug agent, both speaking the same versioned, capability-negotiated CBOR protocol (JSON for debugging), interop-tested.In remote mode, RemotePeripheral/RemoteScanner implement Kable's own Peripheral/
Scanner interfaces, but forward every call over an IP link to an agent process near the
physical device, which drives the real radio and streams results/events back:
flowchart LR
Client["Client process · phone / laptop / CI<br/>———————<br/>app code<br/>↓<br/>Kable Peripheral<br/>(= RemotePeripheral / RemoteScanner)<br/>↓<br/>AgentSession<br/>↓<br/>AgentTransport"]
Agent["Agent process · near the BLE device<br/>———————<br/>AgentWebSocketServer<br/>↓<br/>BleAgent<br/>↓<br/>BleBackend<br/>↓<br/>real radio<br/>(CoreBluetooth · Android BLE · btleplug)"]
Dev(["BLE<br/>device"])
Client -->|"Command<br/>(WebSocket / CBOR)"| Agent
Agent -->|"Reply / Event"| Client
Agent --> DevIn local mode it's the box on the left minus the IP link — ordinary Kable, talking to the
radio on the same device. Switching modes is a factory choice (peripheralFor(mode, …)), not an
app-code change. See docs/README.md for the full layered
breakdown inside the client SDK.
📖 Docs: new to this? Start with the getting-started tutorial.
Full implementation reference (APIs, internals, rationale) in docs/ —
architecture, protocol, client SDK,
agent, end-to-end flows + sequence diagrams,
design rationale, build & testing.
Release scope is tracked in docs/proposals/0.10.0-scope.md,
which covers radio-less CI, the Rust-agent container, deferred validation, and the consolidated
Maven Central release; the CHANGELOG is the shipped history and
docs/proposals/0.9.1-hardening-decisions.md records
the accepted security/lifecycle hardening. The future
AgentProxy design is explicitly outside the 0.10.0 release.
| Module | Role | Deps |
|---|---|---|
:protocol |
The wire contract (Frame/Op/OpResult/AgentEvent) + CBOR/JSON codec |
kotlinx-serialization only — no BLE/network. Targets: JVM + Android + iOS |
:client-sdk |
Session, transport, RemotePeripheral/RemoteScanner
|
:protocol, coroutines, Kable. Targets: JVM (tests) + Android + iOS |
:agent |
Remote Bluetooth agent (Kotlin) + live status dashboard + a Compose Multiplatform status UI (Android/iOS). Run via agent/run-agent.sh (JVM) or the android-agent/ios-agent apps |
:protocol, coroutines, Ktor server, Kable, Compose Multiplatform. Targets: JVM + Android + iOS |
agent-rs |
Native cross-platform Bluetooth agent (Rust 2024). Run via the self-bootstrapping run-agent-rs.sh
|
tokio, tokio-tungstenite, btleplug, serde/ciborium. Targets: macOS + Linux |
:e2e-runner |
Live E2E runner (jvmRun) + radio-less scan smoke test (scanRun) |
:client-sdk (JVM). See README
|
:client-ui |
The central demo's UI (RemoteBleApp: ScanScreen/DeviceScreen) + orchestration (RemoteBleController) — Compose Multiplatform, shared by :android-client and ios-client/
|
:client-sdk. Targets: Android (library) + iOS |
:android-client |
Thin Android app shell around :client-ui: scans through the host agent over ws://10.0.2.2:8080/agent (no radio, INTERNET only) |
:client-ui |
ios-client/ |
Thin, logic-free XcodeGen launcher shell for :client-ui's iOS target (standalone Xcode project, not a Gradle module) |
:client-ui's exported RemoteBleClient.xcframework. See README
|
:android-agent |
Thin Android app shell around :agent's androidTarget: runs the real agent (BLE central + WebSocket server + dashboard) on the phone's own radio, in a foreground service |
:agent |
ios-agent/ |
Thin, logic-free XcodeGen launcher shell for :agent's iOS target (standalone Xcode project, not a Gradle module) |
:agent's exported RemoteBleAgent.xcframework. See README
|
You'll need a test peripheral for the full connect/read/write/observe path. Any GATT peripheral works — a Heart Rate / Battery advertiser for the demo UI, or your own app with custom UUIDs for the raw op-set. See
docs/bringup.mdfor a scripted live bring-up.
| Version | |
|---|---|
| Kotlin | 2.4.0 |
| kotlinx-coroutines | 1.11.0 |
| kotlinx-serialization (+cbor) | 1.9.0 |
| Gradle | 9.5.1 (wrapper) |
| Android Gradle Plugin | 9.2.1 (compileSdk 37, minSdk 24) |
| JDK toolchain | 17 |
| Kable |
com.juul.kable:kable-core:0.43.1 (Maven Central) — powers both the client SDK and the JVM agent's radio engine (the JVM btleplug backend ships in this release) |
Wire format: CBOR over the transport; a JSON codec is available for debugging.
./gradlew :protocol:jvmTest # Phase 1 round-trip suite
./gradlew build # all modules + targets (JVM/Android/iOS klibs)build compiles every target but runs the unit suite on the JVM only — iOS test binaries
need a full Xcode toolchain, though the library klibs still compile so all targets are verified.
gradle.properties bumps the daemon heap for the multiplatform/AGP/Native build; Android resolves
the SDK from local.properties (sdk.dir).
# Needs a Mac with Xcode — builds the framework ios-client/ embeds:
sudo xcode-select -s /Applications/Xcode.app
./gradlew :client-ui:assembleRemoteBleClientReleaseXCFramework -PiosFrameworkEvery GitHub release attaches runnable agent binaries, so you don't have to clone and build:
remoteble-agent-<ver>-all.jar — the JVM agent, self-contained (bundles the native BLE
libs for Linux/macOS/Windows). Runs on Linux / Raspberry Pi (and Windows) with a JDK 17+:
java -jar remoteble-agent-<ver>-all.jar 8080 # ws://127.0.0.1:8080/agentremoteble-agent-rs-<platform> — the native Rust agent, a single self-contained binary
(no JVM needed), built for linux-x86_64, linux-aarch64 (Raspberry Pi / ARM SBCs), and
windows-x86_64.exe:
chmod +x remoteble-agent-rs-linux-aarch64 && ./remoteble-agent-rs-linux-aarch64 8080macOS needs a signed .app for Bluetooth (CoreBluetooth/TCC — see below), so there's no prebuilt
macOS download: build + run from source with the scripts below (they assemble and ad-hoc-sign the
.app for you).
agent/run-agent.sh 8080 # ws://0.0.0.0:8080/agent, real CoreBluetooth
# Require a bearer token (clients must return the same value from WebSocketAgentTransport.authToken):
REMOTE_BLE_TOKEN=secret agent/run-agent.sh 8080
Use the script, not ./gradlew :agent:jvmRun. A bare JVM is SIGABRT-ed the instant it
touches CoreBluetooth — macOS TCC only grants Bluetooth to a signed .app bundle that declares
NSBluetoothAlwaysUsageDescription and is launched via LaunchServices. The script wraps a tiny JNI
launcher (agent/macos-launcher/) in such a bundle, opens it, and streams the log (Ctrl-C stops
it). First run prompts once for Bluetooth; a menu-bar item (🟢/🟡) shows status with recent
activity and a dashboard link.
Both desktop agents bind to loopback by default. To expose an agent on a LAN, choose an explicit
REMOTE_BLE_BIND/--bind address and configure credentials; an open LAN listener is refused
unless the explicitly unsafe development override is set. REMOTE_BLE_TOKEN is the legacy
default principal. For separate clients use REMOTE_BLE_TOKENS='lab-a=secret-a,lab-b=secret-b'.
The bearer secret selects the principal; X-RemoteBle-Client is only a bounded reconnect key
within that principal. Deploy LAN use behind a TLS-terminating reverse proxy or VPN; direct
ws:// is for trusted networks/development.
For CI or a deterministic demo, run the JVM agent against the checked-in Heart Rate profile instead of a Bluetooth adapter:
./gradlew :agent:jvmRun --args="--simulate agent/simulation/sim-hrm.json"The normal client URL stays ws://127.0.0.1:8080/agent; only agent configuration changes. See
the simulation profile contract for the released-JAR form, environment
equivalent, supported behaviors, and safety limits.
For lightweight, cross-platform deployments on macOS or Linux.
Use the wrapper script. It's self-bootstrapping: on a bare checkout
with nothing preinstalled, it installs the Rust toolchain via rustup if cargo isn't found,
and on Linux additionally installs the OS build prerequisites (a C toolchain, pkg-config, and
the D-Bus dev headers btleplug's BlueZ backend needs — via apt/dnf/yum/pacman/zypper/
apk, whichever is present), before building and running:
agent-rs/run-agent-rs.sh 8080
REMOTE_BLE_TOKEN=secret agent-rs/run-agent-rs.sh 8080On macOS the script wraps the binary in a signed RemoteBleAgentRs.app and opens it (the
same TCC/SIGABRT reason as the JVM agent above); the first launch prompts once for
Bluetooth — approve it, and re-run if the first scan is empty. On Linux it just builds and runs
the binary directly, talking to BlueZ over D-Bus. Either way it streams the log; Ctrl-C stops it.
The image uses host BlueZ over the system D-Bus socket; it is not a real-radio option for Docker Desktop on macOS or Windows. Build and smoke-test it locally with:
docker build -f agent-rs/Dockerfile -t remoteble-agent-rs:local .
agent-rs/container-smoke.sh remoteble-agent-rs:localSee the container guide for the credentialed D-Bus invocation and the remaining Ubuntu/Pi hardware-validation requirements.
:agent also targets Android and iOS — same radio/protocol/server logic
(EngineBleBackend drives Kable's native Android BLE / CoreBluetooth backends there, no
btleplug), a Compose Multiplatform status UI in place of a terminal, and a persistent
notification (Android) instead of a log stream.
# Android — a real device or emulator with Google APIs; grant the Bluetooth prompt.
./gradlew :android-agent:installDebug
# iOS — needs a Mac with Xcode, and a physical iPhone (the Simulator has no real radio):
sudo xcode-select -s /Applications/Xcode.app
./gradlew :agent:assembleRemoteBleAgentReleaseXCFramework -PiosFramework
cd ios-agent && xcodegen generate && open RemoteBleAgent.xcodeprojTap Start in the app; a laptop on the same network can then point a client (or
:e2e-runner:scanRun) at ws://<phone-ip>:8080/agent, same as the macOS agent.
Android keeps running backgrounded via a foreground service (
AgentService) — the app requestsBLUETOOTH_SCAN/BLUETOOTH_CONNECTon first launch. iOS has no equivalent: it does not support a backgrounded, listening TCP server, so the agent is only reachable while the app is open and the screen is unlocked. The app disables the screen's auto-lock while running and shows an on-screen reminder, since there's no way around this short of the user leaving the phone open — seeios-agent/README.md.
The agent serves a live, mobile-friendly status page at http://<host>:8080/ (same
port as the WebSocket endpoint) on every target, including the phone agents above. It
shows connected RemoteBLE clients, connected hardware, and a rolling activity log,
polling GET /api/state (JSON) once a second. It is read-only; configuration mutation routes are
removed for 0.9.0 pending an authenticated operator plane. See AgentMonitor / Dashboard.kt.
On Android/iOS the same data also drives a
native Compose UI in the app itself — see docs/agent.md.
./gradlew :e2e-runner:scanRun --args "ws://localhost:8080/agent 15"Lists every BLE advertisement the agent's radio sees for 15s, then exits. The client
has no radio of its own — it only sends scan ops over WebSocket — so it doubles as the
proof that app code can scan through a remote host (e.g. an emulator via the host Mac).
The full op-set live runner is :e2e-runner:jvmRun (needs a phone peripheral).
The 0.10.0 release candidate is code-complete and verified locally (full ./gradlew build and the
Rust suite green). App logic written purely against Kable's Peripheral/Scanner API runs
unchanged against a RemotePeripheral talking to an agent over WebSocket — connect, discover,
read, write, observe (notify), scan, and reconnect. (The radio-less simulated agent proves the
complete socket path in automated tests; capabilities are listed under Features above.)
On-hardware validation is complete — four rigs, 25 of 25 cases: real radio, iOS lifecycle, a
TLS reverse proxy, and a Linux container host. Per-case results, including the defects the rigs
found, are in docs/. One boundary is worth stating plainly rather than leaving to the
detail: the container was validated on one amd64 Linux host, so arm64, AppArmor,
SELinux-enforcing and rootless Podman are not covered, and the image is labelled accordingly.
What remains is publication (GitHub release + GHCR image + Maven Central). Until that happens, treat 0.10.0 as a release candidate rather than a shipped version. The exact remaining-work boundary is tracked in the release handoff and the release-candidate inventory.
The reference apps show both sides: an Android client (:android-client) and an iOS launcher
(ios-client/) drive the shared :client-ui over a remote agent with no local radio, while
:android-agent/ios-agent/ run the real agent on a phone's own radio with a native status UI.
Deployment targets: the agent runs on macOS and Linux (incl. Raspberry Pi) via the JVM/
btleplugbackend, and on Android and iOS via Kable's native backends — but not bare-metal firmware (btleplugneeds a real OS Bluetooth stack, so a Pi-class host, not an ESP32). iOS can't run the agent backgrounded, andpairing/conn.priorityare engine-gated —btleplugsupports neither, so the reference agent advertises neither. Seedocs/agent.mdfor the platform caveats.
This project is built with heavy use of AI coding assistants, including Claude Code — for implementation, review, documentation and test design alike. Often several tools and models contribute to a single change, which is why no individual commit attributes authorship to any of them: a per-commit credit would name one participant and imply a precision that does not exist.
That assistance does not stand in for verification. Behaviour claims here are backed by evidence
kept under docs/: a cross-agent conformance suite that runs in CI against two independent
agent implementations, four hardware validation rigs recorded case by case, and tests that are
mutation-checked — deliberately broken to confirm they can fail — before being trusted. Where
something is unverified, or was verified only on one host or one backend, the docs say so rather
than rounding up. Every change is reviewed by a human before it lands.
None of which is a warranty: the software is provided "as is", without warranties or conditions of any kind, under the terms below.
Apache License 2.0 — see LICENSE. This project is independent and not
affiliated with or endorsed by JUUL Labs or the Kable
project; Kable itself is also Apache 2.0 licensed.
A "remote mode" for a Kotlin Multiplatform BLE stack: client app code written against
Kable's Peripheral runs unchanged whether the
peripheral is physically local or driven by a remote agent over an IP link
(WebSocket). Not affiliated with JUUL Labs or the Kable project.
Inspired by ESPHome's Bluetooth Proxy, which pioneered relaying the full BLE/GATT surface over IP behind the host BLE library's own interface (Bleak + Home Assistant there, Kable here) — RemoteBLE applies the same idea to Kotlin Multiplatform and OS-class hosts for development, testing, and CI. Independent and not affiliated with the ESPHome or Home Assistant projects.
The client SDK is published to Maven Central as dev.warsha.remoteble:client-sdk
(Kotlin Multiplatform: JVM, Android, iOS). It pulls :protocol and Kable transitively.
// build.gradle.kts — commonMain for a KMP app, or a JVM/Android source set
dependencies {
implementation("dev.warsha.remoteble:client-sdk:0.10.0")
}The snippet tracks the current release line. The Maven Central badge at the top of this README shows the version actually resolvable right now — if you're reading between a version bump and its Central publish, use that number.
Upgrading from an earlier Central release? Read the concise
0.10.0 migration guide, especially the breaking authToken provider
change.
iOS is covered by the same coordinate: it's a Kotlin Multiplatform publication, so an
iOS app that shares Kotlin code (your Kable app logic lives in commonMain) resolves the
iosArm64/iosSimulatorArm64 klibs from Central automatically. There is no separate
Swift Package / XCFramework — this SDK is consumed as Kotlin, alongside Kable itself.
The agent is run from a prebuilt binary (download from a release)
or from source (agent/run-agent.sh, the agent-rs binary, or the phone apps), not consumed as a
dependency — see Running the agent.
:e2e-runner is built for exactly this.peripheralFor(mode, …)), so Kable code gains it with essentially no rewrite.KableAdapterTest).btleplug agent, both speaking the same versioned, capability-negotiated CBOR protocol (JSON for debugging), interop-tested.In remote mode, RemotePeripheral/RemoteScanner implement Kable's own Peripheral/
Scanner interfaces, but forward every call over an IP link to an agent process near the
physical device, which drives the real radio and streams results/events back:
flowchart LR
Client["Client process · phone / laptop / CI<br/>———————<br/>app code<br/>↓<br/>Kable Peripheral<br/>(= RemotePeripheral / RemoteScanner)<br/>↓<br/>AgentSession<br/>↓<br/>AgentTransport"]
Agent["Agent process · near the BLE device<br/>———————<br/>AgentWebSocketServer<br/>↓<br/>BleAgent<br/>↓<br/>BleBackend<br/>↓<br/>real radio<br/>(CoreBluetooth · Android BLE · btleplug)"]
Dev(["BLE<br/>device"])
Client -->|"Command<br/>(WebSocket / CBOR)"| Agent
Agent -->|"Reply / Event"| Client
Agent --> DevIn local mode it's the box on the left minus the IP link — ordinary Kable, talking to the
radio on the same device. Switching modes is a factory choice (peripheralFor(mode, …)), not an
app-code change. See docs/README.md for the full layered
breakdown inside the client SDK.
📖 Docs: new to this? Start with the getting-started tutorial.
Full implementation reference (APIs, internals, rationale) in docs/ —
architecture, protocol, client SDK,
agent, end-to-end flows + sequence diagrams,
design rationale, build & testing.
Release scope is tracked in docs/proposals/0.10.0-scope.md,
which covers radio-less CI, the Rust-agent container, deferred validation, and the consolidated
Maven Central release; the CHANGELOG is the shipped history and
docs/proposals/0.9.1-hardening-decisions.md records
the accepted security/lifecycle hardening. The future
AgentProxy design is explicitly outside the 0.10.0 release.
| Module | Role | Deps |
|---|---|---|
:protocol |
The wire contract (Frame/Op/OpResult/AgentEvent) + CBOR/JSON codec |
kotlinx-serialization only — no BLE/network. Targets: JVM + Android + iOS |
:client-sdk |
Session, transport, RemotePeripheral/RemoteScanner
|
:protocol, coroutines, Kable. Targets: JVM (tests) + Android + iOS |
:agent |
Remote Bluetooth agent (Kotlin) + live status dashboard + a Compose Multiplatform status UI (Android/iOS). Run via agent/run-agent.sh (JVM) or the android-agent/ios-agent apps |
:protocol, coroutines, Ktor server, Kable, Compose Multiplatform. Targets: JVM + Android + iOS |
agent-rs |
Native cross-platform Bluetooth agent (Rust 2024). Run via the self-bootstrapping run-agent-rs.sh
|
tokio, tokio-tungstenite, btleplug, serde/ciborium. Targets: macOS + Linux |
:e2e-runner |
Live E2E runner (jvmRun) + radio-less scan smoke test (scanRun) |
:client-sdk (JVM). See README
|
:client-ui |
The central demo's UI (RemoteBleApp: ScanScreen/DeviceScreen) + orchestration (RemoteBleController) — Compose Multiplatform, shared by :android-client and ios-client/
|
:client-sdk. Targets: Android (library) + iOS |
:android-client |
Thin Android app shell around :client-ui: scans through the host agent over ws://10.0.2.2:8080/agent (no radio, INTERNET only) |
:client-ui |
ios-client/ |
Thin, logic-free XcodeGen launcher shell for :client-ui's iOS target (standalone Xcode project, not a Gradle module) |
:client-ui's exported RemoteBleClient.xcframework. See README
|
:android-agent |
Thin Android app shell around :agent's androidTarget: runs the real agent (BLE central + WebSocket server + dashboard) on the phone's own radio, in a foreground service |
:agent |
ios-agent/ |
Thin, logic-free XcodeGen launcher shell for :agent's iOS target (standalone Xcode project, not a Gradle module) |
:agent's exported RemoteBleAgent.xcframework. See README
|
You'll need a test peripheral for the full connect/read/write/observe path. Any GATT peripheral works — a Heart Rate / Battery advertiser for the demo UI, or your own app with custom UUIDs for the raw op-set. See
docs/bringup.mdfor a scripted live bring-up.
| Version | |
|---|---|
| Kotlin | 2.4.0 |
| kotlinx-coroutines | 1.11.0 |
| kotlinx-serialization (+cbor) | 1.9.0 |
| Gradle | 9.5.1 (wrapper) |
| Android Gradle Plugin | 9.2.1 (compileSdk 37, minSdk 24) |
| JDK toolchain | 17 |
| Kable |
com.juul.kable:kable-core:0.43.1 (Maven Central) — powers both the client SDK and the JVM agent's radio engine (the JVM btleplug backend ships in this release) |
Wire format: CBOR over the transport; a JSON codec is available for debugging.
./gradlew :protocol:jvmTest # Phase 1 round-trip suite
./gradlew build # all modules + targets (JVM/Android/iOS klibs)build compiles every target but runs the unit suite on the JVM only — iOS test binaries
need a full Xcode toolchain, though the library klibs still compile so all targets are verified.
gradle.properties bumps the daemon heap for the multiplatform/AGP/Native build; Android resolves
the SDK from local.properties (sdk.dir).
# Needs a Mac with Xcode — builds the framework ios-client/ embeds:
sudo xcode-select -s /Applications/Xcode.app
./gradlew :client-ui:assembleRemoteBleClientReleaseXCFramework -PiosFrameworkEvery GitHub release attaches runnable agent binaries, so you don't have to clone and build:
remoteble-agent-<ver>-all.jar — the JVM agent, self-contained (bundles the native BLE
libs for Linux/macOS/Windows). Runs on Linux / Raspberry Pi (and Windows) with a JDK 17+:
java -jar remoteble-agent-<ver>-all.jar 8080 # ws://127.0.0.1:8080/agentremoteble-agent-rs-<platform> — the native Rust agent, a single self-contained binary
(no JVM needed), built for linux-x86_64, linux-aarch64 (Raspberry Pi / ARM SBCs), and
windows-x86_64.exe:
chmod +x remoteble-agent-rs-linux-aarch64 && ./remoteble-agent-rs-linux-aarch64 8080macOS needs a signed .app for Bluetooth (CoreBluetooth/TCC — see below), so there's no prebuilt
macOS download: build + run from source with the scripts below (they assemble and ad-hoc-sign the
.app for you).
agent/run-agent.sh 8080 # ws://0.0.0.0:8080/agent, real CoreBluetooth
# Require a bearer token (clients must return the same value from WebSocketAgentTransport.authToken):
REMOTE_BLE_TOKEN=secret agent/run-agent.sh 8080
Use the script, not ./gradlew :agent:jvmRun. A bare JVM is SIGABRT-ed the instant it
touches CoreBluetooth — macOS TCC only grants Bluetooth to a signed .app bundle that declares
NSBluetoothAlwaysUsageDescription and is launched via LaunchServices. The script wraps a tiny JNI
launcher (agent/macos-launcher/) in such a bundle, opens it, and streams the log (Ctrl-C stops
it). First run prompts once for Bluetooth; a menu-bar item (🟢/🟡) shows status with recent
activity and a dashboard link.
Both desktop agents bind to loopback by default. To expose an agent on a LAN, choose an explicit
REMOTE_BLE_BIND/--bind address and configure credentials; an open LAN listener is refused
unless the explicitly unsafe development override is set. REMOTE_BLE_TOKEN is the legacy
default principal. For separate clients use REMOTE_BLE_TOKENS='lab-a=secret-a,lab-b=secret-b'.
The bearer secret selects the principal; X-RemoteBle-Client is only a bounded reconnect key
within that principal. Deploy LAN use behind a TLS-terminating reverse proxy or VPN; direct
ws:// is for trusted networks/development.
For CI or a deterministic demo, run the JVM agent against the checked-in Heart Rate profile instead of a Bluetooth adapter:
./gradlew :agent:jvmRun --args="--simulate agent/simulation/sim-hrm.json"The normal client URL stays ws://127.0.0.1:8080/agent; only agent configuration changes. See
the simulation profile contract for the released-JAR form, environment
equivalent, supported behaviors, and safety limits.
For lightweight, cross-platform deployments on macOS or Linux.
Use the wrapper script. It's self-bootstrapping: on a bare checkout
with nothing preinstalled, it installs the Rust toolchain via rustup if cargo isn't found,
and on Linux additionally installs the OS build prerequisites (a C toolchain, pkg-config, and
the D-Bus dev headers btleplug's BlueZ backend needs — via apt/dnf/yum/pacman/zypper/
apk, whichever is present), before building and running:
agent-rs/run-agent-rs.sh 8080
REMOTE_BLE_TOKEN=secret agent-rs/run-agent-rs.sh 8080On macOS the script wraps the binary in a signed RemoteBleAgentRs.app and opens it (the
same TCC/SIGABRT reason as the JVM agent above); the first launch prompts once for
Bluetooth — approve it, and re-run if the first scan is empty. On Linux it just builds and runs
the binary directly, talking to BlueZ over D-Bus. Either way it streams the log; Ctrl-C stops it.
The image uses host BlueZ over the system D-Bus socket; it is not a real-radio option for Docker Desktop on macOS or Windows. Build and smoke-test it locally with:
docker build -f agent-rs/Dockerfile -t remoteble-agent-rs:local .
agent-rs/container-smoke.sh remoteble-agent-rs:localSee the container guide for the credentialed D-Bus invocation and the remaining Ubuntu/Pi hardware-validation requirements.
:agent also targets Android and iOS — same radio/protocol/server logic
(EngineBleBackend drives Kable's native Android BLE / CoreBluetooth backends there, no
btleplug), a Compose Multiplatform status UI in place of a terminal, and a persistent
notification (Android) instead of a log stream.
# Android — a real device or emulator with Google APIs; grant the Bluetooth prompt.
./gradlew :android-agent:installDebug
# iOS — needs a Mac with Xcode, and a physical iPhone (the Simulator has no real radio):
sudo xcode-select -s /Applications/Xcode.app
./gradlew :agent:assembleRemoteBleAgentReleaseXCFramework -PiosFramework
cd ios-agent && xcodegen generate && open RemoteBleAgent.xcodeprojTap Start in the app; a laptop on the same network can then point a client (or
:e2e-runner:scanRun) at ws://<phone-ip>:8080/agent, same as the macOS agent.
Android keeps running backgrounded via a foreground service (
AgentService) — the app requestsBLUETOOTH_SCAN/BLUETOOTH_CONNECTon first launch. iOS has no equivalent: it does not support a backgrounded, listening TCP server, so the agent is only reachable while the app is open and the screen is unlocked. The app disables the screen's auto-lock while running and shows an on-screen reminder, since there's no way around this short of the user leaving the phone open — seeios-agent/README.md.
The agent serves a live, mobile-friendly status page at http://<host>:8080/ (same
port as the WebSocket endpoint) on every target, including the phone agents above. It
shows connected RemoteBLE clients, connected hardware, and a rolling activity log,
polling GET /api/state (JSON) once a second. It is read-only; configuration mutation routes are
removed for 0.9.0 pending an authenticated operator plane. See AgentMonitor / Dashboard.kt.
On Android/iOS the same data also drives a
native Compose UI in the app itself — see docs/agent.md.
./gradlew :e2e-runner:scanRun --args "ws://localhost:8080/agent 15"Lists every BLE advertisement the agent's radio sees for 15s, then exits. The client
has no radio of its own — it only sends scan ops over WebSocket — so it doubles as the
proof that app code can scan through a remote host (e.g. an emulator via the host Mac).
The full op-set live runner is :e2e-runner:jvmRun (needs a phone peripheral).
The 0.10.0 release candidate is code-complete and verified locally (full ./gradlew build and the
Rust suite green). App logic written purely against Kable's Peripheral/Scanner API runs
unchanged against a RemotePeripheral talking to an agent over WebSocket — connect, discover,
read, write, observe (notify), scan, and reconnect. (The radio-less simulated agent proves the
complete socket path in automated tests; capabilities are listed under Features above.)
On-hardware validation is complete — four rigs, 25 of 25 cases: real radio, iOS lifecycle, a
TLS reverse proxy, and a Linux container host. Per-case results, including the defects the rigs
found, are in docs/. One boundary is worth stating plainly rather than leaving to the
detail: the container was validated on one amd64 Linux host, so arm64, AppArmor,
SELinux-enforcing and rootless Podman are not covered, and the image is labelled accordingly.
What remains is publication (GitHub release + GHCR image + Maven Central). Until that happens, treat 0.10.0 as a release candidate rather than a shipped version. The exact remaining-work boundary is tracked in the release handoff and the release-candidate inventory.
The reference apps show both sides: an Android client (:android-client) and an iOS launcher
(ios-client/) drive the shared :client-ui over a remote agent with no local radio, while
:android-agent/ios-agent/ run the real agent on a phone's own radio with a native status UI.
Deployment targets: the agent runs on macOS and Linux (incl. Raspberry Pi) via the JVM/
btleplugbackend, and on Android and iOS via Kable's native backends — but not bare-metal firmware (btleplugneeds a real OS Bluetooth stack, so a Pi-class host, not an ESP32). iOS can't run the agent backgrounded, andpairing/conn.priorityare engine-gated —btleplugsupports neither, so the reference agent advertises neither. Seedocs/agent.mdfor the platform caveats.
This project is built with heavy use of AI coding assistants, including Claude Code — for implementation, review, documentation and test design alike. Often several tools and models contribute to a single change, which is why no individual commit attributes authorship to any of them: a per-commit credit would name one participant and imply a precision that does not exist.
That assistance does not stand in for verification. Behaviour claims here are backed by evidence
kept under docs/: a cross-agent conformance suite that runs in CI against two independent
agent implementations, four hardware validation rigs recorded case by case, and tests that are
mutation-checked — deliberately broken to confirm they can fail — before being trusted. Where
something is unverified, or was verified only on one host or one backend, the docs say so rather
than rounding up. Every change is reviewed by a human before it lands.
None of which is a warranty: the software is provided "as is", without warranties or conditions of any kind, under the terms below.
Apache License 2.0 — see LICENSE. This project is independent and not
affiliated with or endorsed by JUUL Labs or the Kable
project; Kable itself is also Apache 2.0 licensed.