Blog
Engineering

SDK 0.32: service subscriptions, a C# binding and Bluetooth LE that works in a pocket

SDK 0.32: service subscriptions, a C# binding and Bluetooth LE that works in a pocket

TL;DR

SDK 0.32.0 lets a device subscribe to another device's live state and receive each update as it is published, sealed end to end, in every binding. It adds a C# binding and keeps Bluetooth LE links working in the background and with the screen off.

Devices that coordinate need more than a request and an answer. A dashboard wants each change in a sensor’s reading as it happens, a crew lead wants every handheld’s battery and position, and the phone doing the watching is usually in a pocket. Version 0.32.0, released on 5 November 2026, adds service subscriptions: a device subscribes to a topic of another device’s service and receives every update as it is published, sealed in the session between the two.

The same release adds a C# binding for .NET 8 hosts on Windows, macOS and Linux, and keeps Bluetooth LE working with the app in the background or the screen off on both iPhone and Android. It also fixes four React Native calls on the shared engine bridge, gives a second SDK app on one Android phone its own LAN streams, and lets the browser lobby example load with no network.

Service subscriptions over the mesh

A service request gets one answer. A subscription gets every change: the consumer holds a lease on one topic of a provider’s service, and the provider sends it updates until the lease ends. Each topic has a mode, fixed by its first publish.

ModeForA consumer that was away gets
latestState: a battery level, a position, who is leading. Each publish has a key, and only the newest value per key mattersThe current value of every key
logEvents: a door opened, an alarm fired. Every update matters and carries its seqWhat it missed from the last 256 updates, or the topic’s checkpoint and what follows it

From the React Native guide, a provider that admits every peer and a consumer that renders each update:

import { MeshServices } from '@offline-protocol/mesh-sdk';

const services = new MeshServices();

// Provider
await services.setServiceSubscriptionPolicy('fleet.v1', { kind: 'accept_all' });
await services.publishServiceUpdate('fleet.v1', 'battery', '81', 'pct');

// Consumer
const subId = await services.subscribeService(providerAddress, 'fleet.v1', 'battery', 'latest');
protocol.on('service_update_received', (event) => {
  if (event.sub_id === subId) {
    render(event.key, event.body);
  }
});

publishServiceUpdate reports how each subscriber was reached: once over a live direct link, or queued on the reliable ladder across hops, where a newer latest value replaces the one still queued for its key. A latest topic sends each subscriber at most one update every 50 ms and coalesces faster publishes to the newest value. The calls are on MeshServices in every UniFFI binding (Swift, Kotlin, Python, Ruby, Java and C), in React Native and Flutter on both platforms, in the Python services wrapper and local API, and in the browser worker. examples/web-coordination is a page of devices that watch each other’s state.

Topic patterns and key filters

A subscription can be narrowed, and the provider applies the narrowing, so an unmatched topic or a filtered key never leaves its device.

  • Topic patterns. rooms/* follows every topic under rooms/, and * follows every topic of the service, up to 64, including topics first published after the subscription. Each update names its topic, and each topic is caught up on its own.
  • Key filters. A latest subscription can list the keys it wants, each exact or a prefix ending in *.
// Every topic under rooms/, and only two keys of each.
await services.subscribeService(providerAddress, 'home.v1', 'rooms/*', 'latest', '', ['temp', 'humidity']);

Subscription policy and encryption

Every subscription frame is sealed in the 1:1 MLS session between the two devices, with no plaintext fallback. A subscription made before a session exists waits for the session and starts it. No subscription frame is written to the outbox’s store, so nothing of a subscription survives a restart, and a frame from before one is dropped.

Who may subscribe is the service’s policy:

  • restricted, the default. The application is asked with a service_subscribe_received event and calls acceptServiceSubscription or rejectServiceSubscription. A subscriber left unanswered for 60 seconds is rejected.
  • accept_all. Every peer with a session is admitted.
  • rules. Peers in allowPeers and members of the groups in allowGroups are admitted, and everyone else is asked or refused, by fallback.

A provider holds at most 256 subscriptions and 16 from one peer, and refuses rather than evicts at either cap, so a flood of new identities cannot push out the subscribers it already serves. Blocking a peer ends every subscription with it at once, on both sides.

Recovering missed updates

A consumer gets the state back without asking. Each renewal, every minute by default, carries what the consumer holds, and the provider sends what is missing. A provider that restarted or cleared the topic is noticed, and the new state follows. resyncServiceSubscription asks for the whole state at once, and a log topic’s checkpoint, set with setServiceTopicCheckpoint, is what a consumer that fell behind the 256-update buffer receives instead of a gap. The full behaviour, limits and event fields are in the service discovery guide.

Offline Protocol C# binding for .NET

bindings/csharp is a .NET 8 SDK that drives the native library through the C API, the same library the other bindings load. All engine calls for one engine run on a dedicated thread, and every method has a typed async wrapper on ProtocolEngine, engine.Services or engine.Data, generated from the interface definition so a new SDK method reaches C# when the generator runs. From the binding’s consumer check:

using OfflineProtocol.Sdk;
using OfflineProtocol.Sdk.Config;
using OfflineProtocol.Sdk.Storage;

var config = new ProtocolConfig
{
    AppId = "com.example.csharp",
    Profile = "csharp-example",
    BleEnabled = false, WifiDirectEnabled = false, InternetEnabled = true,
    ReticulumEnabled = false, NostrEnabled = false, PreferOnline = false,
    InitialTtl = 8, EncryptionEnabled = true, AutoKeyExchange = true,
    StorePending = true, MaxPendingPerPeer = 64, MaxPendingGlobal = 4096,
    PendingTtlMs = 60_000, OverflowPolicy = OverflowPolicy.DropOldest,
};

await using var engine = await ProtocolEngine.CreateAsync(config);
await engine.InitializeMlsAsync(
    new NativeStorageProvider(new MemoryProtocolStorage()),
    new NativeStorageProvider(new MemoryProtocolStorage()));
var address = await engine.LocalAddressAsync();
  • Storage. Custom storage plugs in through IProtocolStorage, and RunStorageConformanceAsync verifies a backend.
  • Events. Events arrive on a dedicated delivery thread, where a handler that throws is counted instead of ending the process, or through PollEventAsync.
  • Transports and telemetry. Bluetooth LE, Wi-Fi Direct, Reticulum and Nostr transport callbacks install per carrier, and EnableTelemetryAsync turns on telemetry with the binding filling in the OS version.

The binding ships as source in the SDK repository, as a preview, and is not on NuGet. Reference it as a project and load the native library from the release’s desktop archives. The C# bridge contract covers threading, cancellation and disposal.

Bluetooth LE in the background and with the screen off

A phone in a pocket runs its app in the background with the screen off, and both operating systems restrict Bluetooth scanning there. In 0.32.0 each mobile bridge scans for the mesh service whenever scanning is restricted and no longer reads a quiet scan as a broken stack.

  • A backgrounded iPhone keeps its links. It scans for the mesh service, the only scan iOS runs in the background, so it also discovers new peers there, and a scan restart never replaces the Bluetooth manager.
  • An Android phone with its screen off keeps discovering peers. Its scan filters on the mesh service while the screen is off, and it no longer changes its advertising address every 90 seconds, so linked peers keep connecting.
  • Android keeps talking to an iPhone whose app restarted. Android sends over any ready link to the peer and redials a link that takes writes and never answers. On two phones, 36 of 36 restarts and connects carried messages.
  • An iPhone with Bluetooth off reports it down. The engine no longer routes sends to a radio that is off.
  • Frames for a Bluetooth neighbour stay on its link. Retries, acknowledgements and session control frames for a peer with a live direct link now take that link, instead of a relay that cannot reach it.

The rules are in C14 of the bridge contracts.

React Native, Android and browser fixes

  • React Native calls. sendReadReceipt works again on Android, iPhones accept and send connection requests without a key package, createInvite(name, true) signs the invite on iOS, and checkInternetPresence(id, { force: true }) forces the check on both platforms. A generator gate now fails on any argument key an adapter sends that its bridge never reads.
  • Two apps on one Android phone. A second SDK app gets its own LAN streams on a free port and advertises that port, instead of handing its peers to the first app.
  • Wi-Fi Direct on Android. A start that fails partway leaves nothing running, and enabling the transport during a start no longer starts it twice.
  • Groups through a third device. Two devices that meet only through a third can now invite each other to a group.
  • Browser. OfflineNode.start({ signal }) aborts a start that has not finished, and the web-lobby example, opened once online, loads with no network and installs as an app.

Upgrading to SDK 0.32

Nothing is removed from any binding. These are the changes to check:

  • Rust: Event gains ServiceSubscribeReceived, ServiceSubscriptionChanged and ServiceUpdateReceived, and ProtocolConfig gains service_subscriptions. A match with no _ arm, or a struct literal that names every field, needs them. A hand-written MeshServices conformer needs the twelve new methods.
  • TypeScript: ProtocolEvent gains the three service events, so an exhaustive switch that ends in a never check needs the cases.
  • Subscriptions under the default policy: an application that never answers service_subscribe_received serves nobody, so set a policy or answer the event.
  • Reserved prefix: __MEMBER__ joins __SVC_ as a reserved prefix, and a 1:1 or group send that starts with it returns InvalidArgument.

The full list is in UPGRADING §30.

npm install @offline-protocol/mesh-sdk@0.32.0
pip install offline-protocol-sdk==0.32.0
gem install offline_protocol -v 0.32.0

Get the packages from npm, PyPI, RubyGems, crates.io or Maven Central, read the source on GitHub, or talk to us about the devices your team needs to keep in step.

Frequently asked questions

How do I subscribe to another device's state with the Offline Protocol SDK?

Call subscribeService on MeshServices with the provider's address, the service id, a topic or a pattern such as rooms/*, and the mode, latest or log. Updates arrive as service_update_received events, and the provider publishes with publishServiceUpdate.

Are service subscription updates encrypted?

Yes. Every subscription frame is sealed in the 1:1 MLS session between the two devices, with no plaintext fallback. A subscription made before a session exists waits for the session and starts it.

Is the Offline Protocol C# binding on NuGet?

The C# binding builds from source. Reference bindings/csharp from the SDK repository in a .NET 8 project and load the native library from the release's desktop archives. CI builds it, runs its tests and runs a consumer check on Linux, macOS and Windows. It is a preview and is not on NuGet.

Does Offline Protocol keep Bluetooth LE working when the phone is in a pocket?

Yes, from 0.32.0. A backgrounded iPhone keeps its links and discovers new peers, and an Android phone with its screen off keeps discovering peers and keeps a stable advertising address.

What breaks when upgrading to SDK 0.32?

Nothing is removed from any binding. Rust code that matches every Event case or builds ProtocolConfig in full needs the three subscription events and the service_subscriptions field, and an exhaustive TypeScript switch over events needs the three service cases.

More posts
Newsletter Nov 6, 2026

A penny for every exploit I read about

Perspective Nov 2, 2026

Weaker in numbers