WendyOS Docs

Services between devices with Offline Protocol

Run the Offline Protocol service as a Wendy app, then call services on other devices over plain HTTP, connect over Bluetooth LE, and use encrypted groups, files, and replicated documents.

Let an app on one WendyOS device call an app on another with an ordinary HTTP request. The Offline Protocol service runs as one Wendy app per device and carries each request end-to-end encrypted over the local network, Bluetooth LE, or a device in between. Your apps keep their own code and language and need no SDK.

To embed the SDK in a Python app instead, follow the three-board tutorial.

1. Create the service app

Create a directory with two files. Save this as Dockerfile:

FROM ghcr.io/offline-protocol/offline-protocol-service:0.32.0

The image is published for linux/arm64 and linux/amd64. Save this as wendy.json:

{
  "appId": "dev.offlineprotocol.service",
  "version": "0.32.0",
  "platform": "linux",
  "entitlements": [
    { "type": "network", "mode": "host" },
    { "type": "bluetooth", "mode": "bluez" },
    { "type": "persist", "name": "offline-protocol-data", "path": "/var/lib/offline-protocol" }
  ]
}
EntitlementWhy the service needs it
network mode hostMulticast DNS finds other devices only on the host network. It also puts the HTTP front on the device's loopback, and lets other devices connect to the peer stream port, 7878.
bluetooth mode bluezAdvertising, scanning, and connecting over Bluetooth LE through BlueZ. Leave it out on a device without an adapter or for wired links only.
persist at /var/lib/offline-protocolThe device's identity, encrypted sessions, held messages, and service registrations, sealed under the store key.

See entitlements for each type.

2. Deploy with a store key

Generate a store key for each device and keep it in your secret store:

(umask 077; openssl rand -hex 32 > device-a.store-key)

The key seals everything the service keeps on disk. Together, the key and the volume are the device's identity. Pass the key as an environment variable and keep it off the volume.

Deploy from the service directory:

wendy run --device <device-a> --detach --restart-unless-stopped \
  --env "OFFLINE_PROTOCOL_STORE_KEY=$(cat device-a.store-key)" \
  --env OP_CONFIG=/etc/offline-protocol/config-ble.json \
  --env OP_LISTEN=<device-a-lan-address>:7878

Set OP_LISTEN to the device's LAN address and reserve that address in your DHCP server. Repeat for each device with its own key and address. Devices on the same network find each other and form encrypted sessions on their own.

VariableDefaultPurpose
OFFLINE_PROTOCOL_STORE_KEYrequired64 hex digits. The service exits with code 64 without it.
OP_CONFIG/etc/offline-protocol/config.jsonTransport configuration (below).
OP_LISTEN0.0.0.0:7878Where other devices open peer streams.
OP_HTTP127.0.0.1:8080Where the HTTP front listens. Empty for no front.
OP_LAN1Advertise and discover over multicast DNS. 0 turns it off.
OP_PEERSnoneSpace-separated host:port entries for devices multicast cannot reach.
OP_HTTP_TOKEN_FILEnoneRequire a per-launch token on every request to the front.
OP_HTTP_ALIASESnoneA JSON file of friendly device names.
OP_SOCKET/run/offline-protocol/api.sockThe local API socket.

The image ships three configurations in /etc/offline-protocol/. All require encryption and form sessions automatically:

FileLocal networkBluetooth LEInternet relay
config.jsononoffoff
config-ble.jsonononoff
config-relay.jsononoffon, with OP_RELAY

Check the service:

wendy device apps list --device <device-a>
wendy device logs --app dev.offlineprotocol.service --device <device-a>

From an app on the device, GET http://127.0.0.1:8080/health returns the front's name, version, connected, and local_address. The local_address (off1…) is the name other devices use to call this one.

3. Register a service on device B

Any HTTP server on the device's loopback can be a provider. Give its app the network entitlement in host mode so it shares the front's loopback, then register 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", "version": "1"}'
  • The name is a DNS label: lowercase letters, digits, and hyphens.
  • The request's path and query are appended to the callback, so a call to /now?tz=utc reaches http://127.0.0.1:9000/now?tz=utc.
  • Registration is idempotent; register at every startup. A 409 means another local app owns the name.
  • GET /services lists this device's registrations and DELETE /services/<name> removes one. Registrations persist on the volume across restarts.

Each delivered request carries these headers:

HeaderValue
X-Offline-Protocol-SenderThe calling device's address, authenticated by the encrypted session.
X-Offline-Protocol-Request-IdUnique per request. A retry gets a new one.
X-Offline-Protocol-ServiceThe service name the caller used.

The status, headers, and body your callback returns go back to the caller.

4. Call it from device A

Send a normal request to the local front. The Host header names the service and the target device:

curl http://127.0.0.1:8080/now \
  -H 'Host: timeofday.<device-b-address>.offline.protocol.internal' \
  -H 'X-Offline-Protocol-Timeout: 90'

The request travels end-to-end encrypted over whichever link the devices share and returns as an ordinary HTTP response. X-Offline-Protocol-Timeout sets the wait from 1 to 300 seconds; the default is 30.

Calls are durable. If device B is away, the request keeps travelling and runs when B is reachable. A caller that stops waiting gets 504 with X-Offline-Protocol-Error: deadline_exceeded, and the provider still serves the request if it arrives within 60 seconds of the deadline. For anything with a side effect, send your own idempotency key in an x- header and have the provider ignore keys it has handled.

To find providers, GET /browse/<name> streams server-sent events for every device offering the service, with its address, version, capabilities, and hop count. For friendly names, point OP_HTTP_ALIASES at a file such as {"aliases": {"kitchen": "off1…"}}, then call timeofday.kitchen.offline.protocol.internal.

StatusX-Offline-Protocol-ErrorMeaning
400bad_hostThe Host header is not <service>.<device>.offline.protocol.internal.
404unknown_deviceThe device is neither an address nor an alias.
404unknown_serviceNothing is registered under that name on the target.
413body_too_largeThe request body is over 128 KiB.
502recipient_unreachableThe target device could not be reached.
502callback_failedThe provider's callback failed or timed out.
504deadline_exceededNo answer before the timeout. The request may still run.

The HTTP front specification lists every endpoint, limit, and error, and the http-front example has a provider and caller in standard-library Python.

5. Connect over Bluetooth LE

With config-ble.json and the bluetooth entitlement in bluez mode, the service advertises its own GATT service and scans for and connects to peers through BlueZ. Two WendyOS devices talk with no network between them, and a phone running an app built on the SDK reaches a device directly. There is no pairing: every link is authenticated and encrypted by MLS. The demo app is the reference phone app.

If another app on the device holds the adapter's only advertising slot, the service logs the refusal and keeps running on its other transports.

6. Use groups, files, and replicated documents

The local API exposes the rest of the SDK: JSON-RPC 2.0 over a WebSocket on the service's Unix socket. One service serves any number of apps, and each app sees only the messages addressed to it. To share the socket, deploy the service with --env OP_SOCKET=/var/lib/offline-protocol/api.sock and mount the same volume in your app:

{
  "appId": "dev.example.groupbot",
  "version": "1.0.0",
  "platform": "linux",
  "entitlements": [
    { "type": "persist", "name": "offline-protocol-data", "path": "/var/lib/offline-protocol" }
  ]
}

Each connection starts with hello, then calls the engine's methods by name:

FeatureMethods
Encrypted groupscreate_group, invite_to_group, send_group_message
File transfersend_file, get_file_progress
Replicated documentsdata.create_doc, data.map_set, data.doc_json
{"jsonrpc":"2.0","id":1,"method":"hello","params":{"app_id":"notes","client":"notes-desktop/2.4"}}
{"jsonrpc":"2.0","id":2,"method":"data.create_doc","params":{"space_id":"notes-1","doc_id":"todo"}}

The local API guide documents every method and event, and the Python client example is a complete client.

Secure the front

  • The front listens on loopback and refuses requests with a browser Origin header. On a device that runs a browser, also set OP_HTTP_TOKEN_FILE: the front writes a fresh token there at every start and requires it in X-Offline-Protocol-Token. Mount the file's directory, not the file.
  • Any device running the SDK that reaches port 7878 can form a session. To serve only known devices, compare X-Offline-Protocol-Sender with an allow list and answer 403 to the rest. Keep callbacks on loopback so only the front can reach them.
  • Keep device clocks in sync with wendy device sync-time; request deadlines and encryption use them.

Troubleshoot

SymptomWhat to check
The service exits at startOFFLINE_PROTOCOL_STORE_KEY is missing (exit code 64), OP_LISTEN is not an address the device has, or OP_RELAY is set without config-relay.json.
Devices do not find each otherBoth need network in host mode and OP_LAN=1 on the same network segment. Across subnets, set OP_PEERS=<other-ip>:7878.
An app cannot reach 127.0.0.1:8080The app needs network in host mode. Check the service with wendy device apps list.
404 unknown_serviceRegister the provider on the target device at startup.
502 callback_failedNothing answered at the callback URL, it was too slow, or 32 requests were already in flight.
A new address after a redeployMount the volume at /var/lib/offline-protocol and keep the same store key.
A phone cannot see the deviceUse config-ble.json and the bluetooth entitlement; check that no other app holds the advertising slot.

See the service image guide for every variable and grant.

On this page