Kable: a coroutines-based Bluetooth Low Energy API for Kotlin
Kotlin Asynchronous Bluetooth Low-Energy
At a glance
- What is it?
- Kable is a Kotlin Multiplatform library that talks to Bluetooth Low Energy peripherals through coroutines and Flow, with the same scanner, UUID and connection code running on Android, iOS, macOS, the JVM and the web through Web Bluetooth. The README is unusually precise about which platform supports which scan filter natively, which matters because native filtering lets the system do the work.
- Who is it for?
- Adopt Kable if you are writing a Bluetooth Low Energy application in Kotlin and you want one codebase across Android, iOS and the web, with an asynchronous API that fits the rest of your coroutine-based code. Do not adopt it if you need features it does not implement, such as classic Bluetooth, or if you are on a platform outside its matrix, since the JVM target reaches BLE only through a native bridge.
- Can I use it commercially?
- Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 1 day ago.
- What is it written in?
- Mainly Kotlin, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
One BLE API across five Kotlin targets
Kable, for Kotlin Asynchronous Bluetooth Low Energy, provides a simple coroutines-powered API for interacting with Bluetooth Low Energy devices. That sentence is the whole pitch, and the badges at the top of the README spell out the targets: Android, iOS, JavaScript, macOS and the JVM.
The value of a multiplatform BLE library is not abstract. Writing a BLE client twice, once against the Android stack and once against CoreBluetooth, means two sets of state machines, two sets of error handling and two sets of bugs, and the two implementations drift apart precisely in the areas that are hardest to test, which is the error paths. A shared library puts the protocol logic in one place and leaves only the platform bindings different.
The asynchronous design is the other half. BLE is an event protocol with a lot of waiting, and a callback-based API forces every caller into a callback chain or its own concurrency wrapper. Kable expresses the same thing as Flow and suspend functions, so a scan is a stream you can filter and collect from, and a read is a suspend call that can be composed with the rest of a coroutine-based application.
Usage is demonstrated with the SensorTag sample app, in the samples/ directory of the repository, a reasonable target for showing a characteristic subscription working end to end.
The base UUID trick, and named lookups for the web
The README opens its documentation with UUIDs, because in BLE everything is addressed by UUID and the address format is the first thing that trips people up.
The Bluetooth Base UUID is 00000000-0000-1000-8000-00805F9B34FB, and it exists so that the 16-bit and 32-bit short forms reserved for standard components can be expanded into the full 128-bit form. Kable provides Bluetooth.BaseUuid to make that arithmetic readable:
val uuid16bit = 0x180D
val heartRateServiceUuid = Bluetooth.BaseUuid + uuid16bit
println(heartRateServiceUuid) // Output: 0000180d-0000-1000-8000-00805f9b34fbThe standard examples in the README are the heart rate service at 0x180D and the heart rate measurement characteristic at 0x2A37, and 128-bit UUIDs outside the base UUID are the ones used for custom applications.
For the web target there is a second route, because Web Bluetooth defines named UUIDs for standard services, characteristics and descriptors. Kable provides three extension functions for that, Uuid.service, Uuid.characteristic and Uuid.descriptor, each taking a name:
val heartRateServiceUuid = Uuid.service("heart_rate")
println(heartRateServiceUuid) // Output: 0000180d-0000-1000-8000-00805f9b34fbThe list of known names lives in Uuid.kt in the common source set, and the README gives a shorthand table showing the canonical form for each notation, including the blood pressure service, the altitude characteristic and the valid range descriptor.
Scanning is a Flow of advertisements
To scan for nearby peripherals, the Scanner provides an advertisements Flow, a stream of Advertisement objects representing advertisements seen from nearby peripherals. Those objects carry information such as the peripheral's name and its RSSI, the signal strength.
The scanner is configured with a DSL, and the README shows the defaults when a value is not specified:
val scanner = Scanner {
filters {
match {
name = Filter.Name.Exact("My device")
}
}
logging {
engine = SystemLogEngine
level = Warnings
format = Multiline
}
}The logging block is the interesting part of that snippet, because it shows the library treating logging as a first-class concern rather than an afterthought. The engine is pluggable, the level and the format are configuration, and there is a separate module in the repository for a Khronicle-based log engine, which suggests the logging backend is replaceable.
The DSL shape is what makes the library pleasant to use. A scanner with a filter is a small configuration object, and the same code expresses a name match, a prefix match or a list of service matches, which matters for the multiplatform case where what the platform can filter natively differs.
The filter matrix, and the shortcut that makes scanning faster
The README publishes a support matrix for the scan filters across the four platform families, and it is the most useful page in the document.
Service is supported on Android natively, on Apple natively but only when it is the only filter type used, on JavaScript, and on the JVM with a note. Name and NamePrefix are supported on all four. Address is supported on Android only. ManufacturerData is supported on Android natively, on Apple through a Kable flow filter, and on JavaScript and the JVM with notes.
The three legend entries explain the difference. A plain tick means supported natively, a superscript one means the support is provided by Kable via a flow filter, and the superscript two means the support is native only if it is the sole filter type used, otherwise it falls back to a flow filter.
The tip that follows is the practical conclusion, and it is the sort of note that saves real battery and real time. When a filter is supported natively, the system can often perform scan optimizations, so if it is feasible, provide only Filter.Service filters, at least one, since that is natively supported on all platforms.
That distinction between native filtering and filtering in the library is the difference between the operating system discarding advertisements for you and your process receiving every advertisement in the area and throwing most of them away. On a busy venue that is the difference between a scan that lasts and a scan that drains a battery.
The module layout shows where the platform boundaries are
The repository is a multiplatform Gradle build with several modules, and reading their names tells you how the platform differences are handled.
kable-core/ is the shared code, and it is where the UUID helpers, the scanner, the filters and the logging interfaces live. kable-btleplug-ffi/ is the native bridge, and the name points at the technology: an FFI binding to a native Bluetooth stack, which is how the JVM target reaches BLE at all. kable-default-permissions/ is the permission handling, which is a genuinely awkward problem on Android and Apple and therefore a module of its own.
The logging is split out too, with kable-log-engine-khronicle/ providing an alternative engine, and the module name suggests an ecosystem standard rather than a bespoke format. The uniffi-plugin/ directory is the build tooling for the FFI layer, and kotlin-js-store/ is the Kotlin/JS dependency lockfile, which is the standard mechanism for pinning JavaScript dependencies in a Kotlin Multiplatform build.
The rest is ordinary project furniture: samples/ with the SensorTag application, artwork/ for the images, renovate.json5 for dependency update automation, a .sdkmanrc for the JDK version, gradlew and gradlew.bat for the wrapper, settings.gradle.kts and build.gradle.kts, and a .editorconfig. The result is a repository where a reader can tell which parts are shared logic and which parts are the inevitable platform adapters.
Where Kable is the wrong tool
The first wrong-tool case is classic Bluetooth. Everything in the library is about Bluetooth Low Energy, and a device that needs an RFCOMM serial link, a headset profile or a keyboard pairing is out of scope. The repository has no classic Bluetooth support to fall back on.
The second is a platform outside the matrix. The JVM target reaches BLE through a native binding, which means the machine needs a native BLE stack available, and the JavaScript target needs a browser that implements Web Bluetooth with the named-UUID restriction. If either of those assumptions fails at runtime, the failure appears at the point of use rather than at build time.
The third is an application that needs the platform's own ecosystem. Kable gives you a cross-platform BLE API, and in exchange you give up the vendor specific features that each platform adds on top of the baseline, and you give up direct access to the native stack when you need something the library does not model. For a team that only ships on one platform, the native SDK is usually the better answer, because it is the thing the platform vendor actually tests.
There is also the usual BLE reality to keep in mind. The library removes the concurrency and platform friction, and it does not remove the protocol's fragility, so the pairing, the connection interval and the peripheral's own flakiness remain the application's problem.
Apache-2.0, version 0.45.0 and a project that ships often
The project is licensed under Apache-2.0, with LICENSE.txt at the repository root, and the documentation site is at juullabs.github.io/kable.
The release line shows a library that ships regularly and is not afraid to use a zero major version. The versions are 0.45.0 from 2026-09-15, 0.44.3 from 2026-07-22 and 0.44.2 from 2026-07-21. Two patch releases a day apart in July 2026 and a minor release in September is the cadence of an active project responding to platform changes, since BLE is a moving target on both Android and Apple and the library has to follow.
The last push was on 2026-10-14, days before this review, and the community is on Slack in the juul-libraries channel on the Kotlin workspace.
The 0.x version deserves the attention it usually gets in a review. It signals that the API is not yet frozen, which for a multiplatform library whose surface spans five targets is understandable, since the lowest common denominator across platforms is what determines the API. The advice that follows from it is to pin the version and read the changelog before upgrading, and to keep the BLE-specific code behind a thin interface of your own so that a version bump does not ripple through the application.
Editorial conclusion
Adopt Kable if you are writing a Bluetooth Low Energy application in Kotlin and you want one codebase across Android, iOS and the web, with an asynchronous API that fits the rest of your coroutine-based code. Do not adopt it if you need features it does not implement, such as classic Bluetooth, or if you are on a platform outside its matrix, since the JVM target reaches BLE only through a native bridge. Before you commit, read the filter support table and prefer service filters, which the README says are natively supported everywhere, and note that the JVM path is an FFI binding to a native stack rather than a pure Kotlin implementation.
Frequently asked questions
What platforms does Kable support?
The badges at the top of the README list Android, iOS, JavaScript, macOS and the JVM. The library is a Kotlin Multiplatform project, with a shared core, an FFI module for the native bridge, a permissions module and a separate logging engine module.
How do I build a full Bluetooth UUID in Kable?
Add a 16-bit or 32-bit value to Bluetooth.BaseUuid, for example Bluetooth.BaseUuid + 0x180D, which produces 0000180d-0000-1000-8000-00805f9b34fb. On the web target you can instead use the named lookups Uuid.service, Uuid.characteristic and Uuid.descriptor.
Which scan filters are supported natively in Kable?
The README publishes a matrix. Service is native on Android, native on Apple when it is the only filter type, and supported on JavaScript and the JVM. Name and NamePrefix work on all platforms, Address is Android only, and ManufacturerData is native on Android and JavaScript, and provided by a Kable flow filter on Apple and the JVM.
How does Kable expose scanning results?
The Scanner provides an advertisements Flow of Advertisement objects carrying the peripheral's name and RSSI. The scanner is configured through a DSL with filters and logging blocks, and only advertisements matching at least one filter are emitted.
What licence is Kable released under?
Apache-2.0, with LICENSE.txt at the repository root. The most recent listed release is 0.45.0, dated 2026-09-15, and the last push was on 2026-10-14.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/juullabs-kable)Was this useful?