SDK 0.29: call a service on another device with one HTTP request
TL;DR
SDK 0.29.0 adds an HTTP front to the Python service, so programs without the SDK call services on another box with plain HTTP, MLS-encrypted over whichever carrier reaches it. The front does not authorize callers, and SDK service calls are now sealed end to end.
Most software on a site already speaks HTTP. A scanner app posts readings, a dashboard polls a status endpoint, a controller exposes a small REST API. When those programs sit on different machines that share no network route, or the site’s uplink is down, that HTTP traffic usually goes nowhere, or takes a trip through a cloud service to cross a few metres. Version 0.29.0, released on 13 October 2026, lets those programs keep calling each other locally without importing the SDK: the Offline Protocol service on each box carries the request.
Each box runs one service with an HTTP front, and a program on one box calls a service on another through its own front.
Setting up the Offline Protocol HTTP front
Install the Python package with two extras, then start one service per box with --http. lan finds other boxes on the local network over DNS-SD, and http adds the front:
pip install "offline-protocol-sdk[lan,http]"
Wheels are published for Python 3.10 or later on macOS arm64, Linux x86_64 and aarch64 (glibc 2.34 or newer) and Windows x86_64. The package installs three commands: offline-protocol-service, offline-protocol-http-front and offline-protocol-verify.
Start one service per box. config.json holds the engine’s configuration; this one enables the peer stream with encryption required, the same as the SDK’s container image:
{
"app_id": "offline-protocol-service",
"profile": "device",
"ble_enabled": false,
"wifi_direct_enabled": true,
"internet_enabled": false,
"reticulum_enabled": false,
"nostr_enabled": false,
"prefer_online": false,
"initial_ttl": 5,
"encryption_enabled": true,
"require_encryption": true,
"auto_key_exchange": true,
"store_pending": true,
"max_pending_per_peer": 100,
"max_pending_global": 1000,
"pending_ttl_ms": 604800000,
"overflow_policy": "DropOldest"
}
export OFFLINE_PROTOCOL_STORE_KEY="$(openssl rand -hex 32)" # once per box; keep it
offline-protocol-service --config config.json \
--mls-root /var/lib/op/keys --state-root /var/lib/op/state \
--listen 0.0.0.0:7878 --lan --http 127.0.0.1:8080
The service owns the box’s identity, an off1... address derived from its key, and keeps its queued messages on disk, sealed under the store key. --lan advertises the box and finds the others on the network with no peer list, and --http starts the front on loopback.
Registering an HTTP service and calling it from another box
Register the provider with one PUT to the front on its own box, then call it from any other box through that box’s front, by a host name that says which service on which device. A provider is any HTTP server. This one uses only the Python standard library and answers with the address of whoever called it:
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
class TimeOfDay(BaseHTTPRequestHandler):
def do_GET(self):
caller = self.headers.get("X-Offline-Protocol-Sender")
body = json.dumps({"path": self.path, "caller": caller}).encode()
self.send_response(200)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(body)))
self.end_headers()
self.wfile.write(body)
HTTPServer(("127.0.0.1", 9000), TimeOfDay).serve_forever()
One PUT to the front on the same box registers it. The registration is kept across restarts and also goes to the engine’s service registry, so other devices can discover it:
curl -X PUT http://127.0.0.1:8080/services/timeofday \
-H 'content-type: application/json' \
-d '{"callback": "http://127.0.0.1:9000"}'
On any other box, a program calls it through its own front. The host name says which service on which device, <service>.<device>.offline.protocol.internal, where the device is its address or an alias from a file the operator writes:
curl -H "Host: timeofday.off1qy83m8cutflgjwe2n4msnec0nt0a5hgfuglal4au.offline.protocol.internal" \
http://127.0.0.1:8080/now
# {"path": "/now", "caller": "off1q9uw28256gfxx6aw3mq0rt8r5604x4zad5l6xjud"}
The front routes on the host name, so a resolver entry or a wildcard DNS rule that points *.offline.protocol.internal at the front lets a program use the URL as it is, with no Host header. GET /browse/<service> streams the devices that offer a service, and GET /health gives the box’s own address.
The request and its response travel as the content of an ordinary direct message, which the engine encrypts end to end with MLS, over whichever carrier reaches the other box: a peer stream on the LAN, Bluetooth LE, the internet relay, or through another box in between. The front acts only on a message the engine reports as encrypted, and it hands the provider the sender MLS authenticated in X-Offline-Protocol-Sender. The provider never sees the caller’s IP address, only its device identity.
Offline Protocol HTTP front limits
The front does not authorize callers, its sender header is only as good as the route to the callback, a 504 does not mean the request did not run, bodies are small, and browsers are kept out. The HTTP front chapter is precise about these limits, and they are worth knowing before you put anything real behind it:
- It does not authorize. Any device whose message the engine decrypts can call every registered service. With
auto_key_exchangeon, that is any device running the SDK that reaches the box. The provider decides who it serves, for example by comparing the sender header with an allowlist of addresses it was given out of band. - The sender header is only as good as the route to the callback. A request through the front cannot forge it, but the header is unsigned on the callback request. Trust it only when nothing but the front can reach the callback, such as a callback on loopback in a namespace the front alone shares.
- A
504does not mean the request did not run. The front stops waiting at the deadline, 30 seconds by default and at most 300. The message is not withdrawn, and a provider that receives it within 60 seconds past its deadline still serves it. Give anything that changes state an idempotency key of your own in anx-header. - Bodies are small. 128 KiB each way. A sealed envelope over about 71 KB does not cross Bluetooth LE at the 185-byte MTU floor, and a request whose only route is such a carrier ends as
deadline_exceeded. - Browsers are kept out. Off loopback the front requires a per-launch token, and on any binding it refuses a request that carries an
Originheader, so a web page cannot send a body or register a callback through it by DNS rebinding. A page can still make a blindGETby loading a resource, so a host where a browser runs should use the token too.
The threat model records the trust boundary as R23, “The HTTP front trusts every process that can reach it”.
End-to-end encryption for service calls
From 0.29, a service call body is sealed inside the 1:1 MLS session with the other device once that device advertises support and the session is confirmed. The provider’s sender is then the requester MLS authenticated. The SDK’s own service API, sendServiceRequest and respondToServiceRequest, used to send bodies as signed plaintext: attributable and tamper-evident, and readable by every device that carried them.
The first request to a provider this device has never met still leaves as signed plaintext, because there is no session yet. Sending it starts one, the way a direct message does, and the next request is sealed. Discovery is never sealed, since every hop reads it to forward it. Both service events gain an encrypted field, so an application can check which form arrived:
import type { ServiceRequestReceivedEvent } from '@offline-protocol/mesh-sdk';
function handleRequest(event: ServiceRequestReceivedEvent) {
if (!event.encrypted) {
// Signed plaintext: an older peer, or no confirmed session yet.
// Answer without anything the devices in between should not read.
}
}
A sealed body is about a third larger on the wire, so bodies above about 52 KB no longer cross Bluetooth LE at the 185-byte MTU floor. encryption: { encryptServiceMessages: false } turns sealing off without a release. This is the one build break in 0.29: Rust code that names every field of either service event needs encrypted or .., and so does a TypeScript object literal typed as either event.
Offline Protocol service in Docker and a two-box demo
bindings/python/docker in the SDK repository builds the service image: one service per host on python:3.12-slim, with both extras installed from PyPI and configured from environment variables. No image is published to a registry; you build it. Its README lists what a host must grant and what breaks without each grant: host networking for multicast DNS, inbound TCP 7878 for peer streams, a persistent volume for the identity and queued messages, the store key from a secret store, and the D-Bus socket for Bluetooth LE.
To see the front work before deploying anything, examples/http-front runs two boxes on one machine with Docker Compose, a provider on box B and a client on box A, each sharing its box’s network namespace:
cd examples/http-front
printf 'BOX_A_STORE_KEY=%s\nBOX_B_STORE_KEY=%s\n' \
"$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > .env # once; keep it
docker compose up -d --build
docker compose logs provider # prints box B's address
docker compose run --rm client off1...
We ran it against the 0.29.0 package from PyPI: the call came back in 0.4 seconds with box A’s address as the caller. That is two containers on one laptop, not radios.
HTTP front hardware test on Raspberry Pi
The front has been tested on hardware in one setup. Before the release, the SDK team ran the front between two Raspberry Pi 4s, each plugged into a laptop over USB networking and with no route to each other, so the laptop was the only path between them. In that test an HTTP call from one Pi to the other, through the laptop, answered in 5.2 seconds, and a call between the two Pis over Bluetooth LE alone passed. These are reported results from one setup, not guarantees. The companion post on the service covers that run in more detail.
Upgrading service calls to SDK 0.29
- Rust code that destructures
ServiceRequestReceivedorServiceResponseReceivednaming every field needsencryptedor... - A service send can now fail where it used to succeed: when sealing fails on a confirmed session, the call returns the error instead of sending plaintext.
- Everything else that behaves differently without a compile error is in UPGRADING §27, and the full notes are in the 0.29.0 changelog.
The rest of 0.29 has its own posts. Every carrier on a box covers the service’s transports, sessions through a middle box and offline-protocol-verify. Phones covers Android and iPhone on one Wi-Fi network and phones reaching a box over Bluetooth LE.
Get the package from PyPI, read the source on GitHub, or talk to us about the programs on your site that should keep talking when the uplink does not.
Frequently asked questions
Do my programs need to import the Offline Protocol SDK to use the HTTP front?
No. A provider is any HTTP server registered with one PUT to the front on its box, and a program on another box calls it with plain HTTP through its own front.
Does the HTTP front decide who can call my service?
No. Any device whose message the engine decrypts can call every registered service, which with auto_key_exchange on is any device running the SDK that reaches the box. The provider decides who it serves, for example by checking the sender header against an allowlist it was given out of band.
Does a 504 from the HTTP front mean the request did not run?
No. The front stops waiting at the deadline, 30 seconds by default and at most 300, but the message is not withdrawn, and a provider that receives it within 60 seconds past its deadline still serves it. Give anything that changes state an idempotency key of your own in an x- header.
How large can a request through the HTTP front be?
128 KiB each way. A sealed envelope over about 71 KB does not cross Bluetooth LE at the 185-byte MTU floor, so a request whose only route is such a carrier ends as deadline_exceeded.
Is the first service request to a new provider encrypted?
No. With no session yet, it leaves as signed plaintext, and sending it starts a session, so the next request is sealed. Both service events carry an encrypted field so an application can check which form arrived.
Is there a published Docker image for the Offline Protocol service?
No. You build it from bindings/python/docker in the SDK repository, which makes one service per host on python:3.12-slim with both extras installed from PyPI.