SDK 0.28 on servers and desktops: Python on PyPI and a local API
TL;DR
SDK 0.28.0 publishes Python wheels to PyPI as offline-protocol-sdk, with sealed file stores for hosts with no keychain. A local JSON-RPC 2.0 API lets several programs share one node, and custody lets a device hold a neighbour's frames until the recipient appears.
Until now the SDK ran inside one application at a time. A React Native app embedded the engine, or a Python program built it from source and embedded it. Version 0.28.0, released on 6 October 2026, adds the pieces a long-running host needs: wheels on PyPI, storage that works without a keychain, a way for several programs on one machine to share one node, and a way for that node to hold document changes for neighbours that are out of reach.
Each of them is for a desktop or a server that keeps a node running, rather than for one application embedding the engine.
Installing the Offline Protocol Python SDK from PyPI
pip install offline-protocol-sdk
The import name is offline_protocol_sdk. Version 0.28.0 has wheels for Python 3.10 to 3.13 on these platforms, and only these:
| Platform | Wheel | Requirement |
|---|---|---|
| macOS, Apple silicon | macosx_14_0_arm64 | macOS 14 or later |
| Linux x86_64 | manylinux_2_34_x86_64 | glibc 2.34 or newer (Debian 12, Ubuntu 22.04, RHEL 9 and later) |
| Linux aarch64 | manylinux_2_34_aarch64 | glibc 2.34 or newer |
| Windows x86_64 | win_amd64 | 64-bit Python |
There is no Intel macOS wheel, no musl wheel for Alpine, and no source distribution. Each wheel carries the native library for its platform. Earlier builds were tagged py3-none-any, four files with one name and a different library inside, and the Linux ones claimed an older glibc than the library needs. Both are fixed.
Before anything is published, the release workflow installs each wheel with pip and runs the whole Python suite against it on macOS, Linux (x86_64 and aarch64) and Windows. A failure stops the npm and crates.io uploads as well. The first Windows run found that import offline_protocol_sdk had never worked on Windows, because the package imported a Bluetooth peripheral library that is not installed there. It works now.
Running an Offline Protocol node without a keychain
With a store key, ProtocolManager uses the SDK’s built-in file stores, which 0.28 gives these hosts as their own storage. A server or a container usually has no macOS Keychain, Secret Service or Windows Credential Locker, and on such a host the keyring library falls back to a backend that fails every call, or one that writes plaintext. MLS material is sealed with ChaCha20-Poly1305 under keys derived from that store key, and protocol state is written in the same format the iOS, Android and Python providers write, byte for byte.
Here is a node for a desktop or a server on the same network as its peers. It runs against the PyPI wheel as written:
import asyncio
from offline_protocol_sdk import (
CustodyConfig, OverflowPolicy, ProtocolConfig, ProtocolManager,
)
config = ProtocolConfig(
app_id="field-notes",
profile="depot-1",
ble_enabled=False,
wifi_direct_enabled=True, # peer streams: TCP on the LAN
internet_enabled=False,
reticulum_enabled=False,
nostr_enabled=False,
prefer_online=False,
initial_ttl=3,
encryption_enabled=True,
auto_key_exchange=True,
store_pending=True,
require_encryption=True,
max_pending_per_peer=100,
max_pending_global=1000,
pending_ttl_ms=86_400_000,
overflow_policy=OverflowPolicy.DROP_OLDEST,
custody=CustodyConfig(enabled=True), # see "Custody" below
)
async def main():
async with ProtocolManager(
config,
event_handler=print,
# 32 bytes as 64 hex digits or base64, generated once
store_key_env="OFFLINE_PROTOCOL_STORE_KEY",
mls_root="/var/lib/field-notes/keys",
state_root="/var/lib/field-notes/state",
) as pm:
pm.peer_stream.configure(
listen_port=7878, peers=["10.0.0.2:7878"])
await pm.peer_stream.start()
print(pm.local_address) # off1...
await asyncio.sleep(3600)
asyncio.run(main())
Generate the store key once with openssl rand -hex 32 and hand it to the process the way your service manager hands it any other secret. A few rules follow from what the stores protect:
mls_rootis the device’s identity. Lose it and the device comes back with a new address.state_rootbelongs to the installation. The two must be different directories, and one inside the other is refused.- A wrong key is refused before anything is read.
start()raisesInvalidConfigurationand no record changes. On this pathstart()raises rather than starting without an identity. - One process per directory. Each store locks its directory while open, so a second engine over the same directories is refused instead of quietly diverging its MLS state.
await pm.close(), or leaving theasync withblock, releases both locks. - Moving an existing deployment onto the file stores starts a new identity. Nothing is carried over from the keyring. Point
state_rootat a fresh directory: the SDK refuses a state directory that belongs to another identity, because the first restore would otherwise delete the messages it could not open.
A store key on a host with no hardware keystore is only as safe as the host. The threat model records that exposure as residual risk R18. Hosts that stay on the keyring path now get a warning when keyring has fallen back to its failing or null backend, naming the backend and pointing at store_key.
Local API for sharing one node across programs
The local API lets one process own the engine and serve any number of local applications over JSON-RPC 2.0 on a WebSocket. A machine often runs more than one program that wants the mesh, and embedding the engine in each gives that machine several identities, several sets of peer sessions, and several copies of every replicated document. The Python package ships the reference server as the offline-protocol-service command:
# Generate the key once and keep it.
export OFFLINE_PROTOCOL_STORE_KEY="$(openssl rand -hex 32)"
offline-protocol-service --config config.json \
--mls-root /var/lib/example/keys \
--state-root /var/lib/example/state \
--socket /run/example/api.sock \
--listen 0.0.0.0:7878 --peer 10.0.0.2:7878
config.json holds the ProtocolConfig fields by name. Enum values use the generated record’s spelling, so the overflow policy is "overflow_policy": "DropOldest". By default the service listens on a Unix domain socket, created mode 0600 in a 0700 directory. The opt-in alternative is loopback TCP with --tcp PORT --token-file PATH, where a new 32-byte token is written at every launch and the client presents it in hello. The server never binds a non-loopback address. If you need the API across a network, put a reverse proxy with its own authentication in front of it.
A client sends hello with its application id first, then calls the engine’s own methods by name:
import asyncio, json
from websockets.asyncio.client import unix_connect
async def main():
async with unix_connect("/run/example/api.sock",
uri="ws://localhost/") as ws:
hello = {"jsonrpc": "2.0", "id": 1, "method": "hello",
"params": {"app_id": "notes"}}
await ws.send(json.dumps(hello))
reply = json.loads(await ws.recv())
print(reply["result"]["local_address"])
asyncio.run(main())
The reply names the server and the node’s address, which every client of one server shares:
{"jsonrpc": "2.0", "id": 1, "result": {
"api_version": 1, "state": "Running", "local_address": "off1…",
"server": {"name": "offline-protocol-service", "version": "0.28.0"}}}
After that, a send and a document edit are ordinary calls:
{"jsonrpc": "2.0", "id": 2, "method": "send_message", "params": {
"recipient": "off1…", "content": "hi", "priority": "Medium"}}
{"jsonrpc": "2.0", "id": 3, "method": "data.create_doc", "params": {
"space_id": "notes-1", "doc_id": "todo"}}
A handful of rules make this safe to share:
- The server owns the run loop and the drain. The engine delivers a message only when something drains its inbound queue, and two drainers would split one stream between them. No client can call
process(),receive_message()orstop(); they are not on the wire. - A connection speaks for one application. Every message it sends is stamped with the
app_idfrom itshello, and an inbound message stamped with that id reaches only connections that declared it. When no such client is connected, the server holds up to 256 inbound messages per application and delivers them after the nexthello. Delivery is at least once, so clients deduplicate onmessage_id. - Events are the engine’s own JSON, unchanged. A handler written for
message_receivedin the embedded SDK handles the same object here. - Errors keep the engine’s names. A failed call is a JSON-RPC error whose
data.variantis the engine’sProtocolErrorvariant, such asNoKeyPackage. Switch on that, not on the number. - The socket is the authorization boundary. Whoever can open the socket, or holds the token, is one of the operator’s applications. The application id is cleartext routing metadata on the wire and is never an access control.
An optional policy file adds rules per application: glob allow-lists over document space ids, so two programs cannot merge each other’s documents by picking the same space name, and denied method groups such as sign_data, so not every program holds a signing oracle over the shared identity key. Once any rule names an application, an id that no rule names is refused at hello.
The interface definition splits into 127 methods a client may call and 93 platform operations it never can, such as attaching storage or driving a radio. A Rust test holds the server’s dispatch table, the specification and the interface definition to each other, so a method added to the engine fails CI until someone decides which side of that line it belongs on. The local API guide walks through two example clients, one in Python and one for Node 22 with no dependencies, and the Python test suite runs both against an in-process server. The full contract, including all seventy-five event tags and how each is routed, is the local API chapter of the specification.
The stamping is not specific to the service. SendMessageOptions and MediaSendOptions take an optional per-send app_id in every binding, and message_received and file_received now carry the application id a message was for.
Custody for a neighbour’s document changes
With custody enabled, a device that could not forward a neighbour’s replicated-document frame holds it for hours and hands it on when the recipient appears. Without it, when a device forwards a frame for someone else and no neighbour can take it, the frame is dropped after five seconds. A device’s own messages get the opposite treatment: sealed on disk and retried for days. Custody v1 closes part of that gap.
It is deliberately narrow:
- Off by default. Turn it on with
CustodyConfig(enabled=True)in Python, or thecustodysection in any binding. - Document frames only. Version 1 carries the replication frames
delta,snap,vvandblob_gone. A duplicate of any of them is harmless, because the recipient recognises a change it has already applied. Direct messages are refused: a custodian cannot see a session re-key, and a late copy would arrive in a dead epoch. - One hop, from the sender itself. The sending engine marks the frame when it hands it to a neighbour, and every forwarder strips that mark from a third-party frame, so a deposit never fans out across a zone.
- Only what could not be forwarded. A custodian takes a frame at the point where an ordinary forward would have given up, and never holds a copy that went out.
- Bounded. The default hold is 6 hours, and it is validated strictly shorter than the outbox lifetime. Each peer with an established session may have up to 64 frames and 2 MiB held at once, within 512 frames and 16 MiB in total. Peers without a session get nothing unless you raise both of their quotas.
- A receipt settles nothing. The custodian answers with a signed receipt, but only the recipient’s acknowledgement settles a message. The sender keeps its outbox entry and its retries either way.
pm.protocol.get_custody_stats() reports what is held, delivered, expired and refused, with a counter for every refusal reason, and pm.protocol.erase_custody() drops every record. The data-layer wipe calls it too. There is a privacy cost, and the custody chapter states it plainly: the payload stays sealed, but a custodian keeps the outer metadata of other people’s frames (sender, recipient, size and time) on disk for hours instead of in memory for seconds. A mains-powered machine at a site is the natural custodian. Any custodian below its soft battery floor stops accepting new deposits, though it keeps what it already holds.
Python gateway daemon client
With reticulum_enabled=True, ProtocolManager now builds pm.gateway, a client for the gateway-daemon contract over TCP to a daemon on the local network. It attaches with a signed address declaration, settles each send on the gateway’s verdict rather than on the socket write, and reports when the gateway stored or pushed a message. Configure it and start it after pm.start():
pm.gateway.configure(daemon_address="localhost:4242")
await pm.gateway.start()
The local API service starts it for you, with --gateway HOST:PORT naming the daemon. This package ships the device half only, and no daemon has been run against it yet.
Upgrading Python code to SDK 0.28
Two things to check in Python code:
InternetManagernow requiresapp_id=as a keyword argument.ProtocolManageralready passes it, so only code that builds anInternetManagerdirectly changes.- Deduplication now remembers 5000 message ids for seven days by default, up from 2000 for one day. A config that sets the old values keeps them.
Everything that behaves differently without a compile error is listed in UPGRADING §26, and the full notes are in the 0.28.0 changelog.
The rest of 0.28 has its own posts. Native iOS and Android covers the Swift package, the Android library and the transport fixes, including the iOS peer stream that now finds a Python host on the same LAN. The leaf node covers the firmware crate’s new ESP32 RISC-V, Cortex-M0 and WASI builds.
Get the package from PyPI, read the source on GitHub, or talk to us about running a node per site instead of a node per program.
Frequently asked questions
Is there an offline-protocol-sdk wheel for Intel Macs or Alpine Linux?
No. Version 0.28.0 has no Intel macOS wheel, no musl wheel for Alpine, and no source distribution. Linux wheels need glibc 2.34 or newer.
What happens if a node loses its mls_root directory?
The mls_root directory is the device's identity, so a node that loses it comes back with a new address. The state_root directory belongs to the installation and must be a different directory.
Can I reach the Offline Protocol local API over the network?
Not directly. The server listens on a Unix domain socket by default, or on loopback TCP with a token file, and never binds a non-loopback address. To use it across a network, put a reverse proxy with its own authentication in front of it.
Is the application id in the local API an access control?
No. The socket is the authorization boundary, and the application id is cleartext routing metadata. An optional policy file can add per-application allow-lists over document space ids and deny method groups such as sign_data.
Does custody hold direct messages as well as document changes?
No. Custody v1 carries only the replication frames delta, snap, vv and blob_gone. Direct messages are refused, because a custodian cannot see a session re-key and a late copy would arrive in a dead epoch.
What does a custodian device learn about the frames it holds?
The payload stays sealed, but a custodian keeps the outer metadata of other people's frames (sender, recipient, size and time) on disk for hours instead of in memory for seconds.