Skip to main content
The SDK is usually embedded: one application, one process, one engine. The local API is the other shape. One process owns the engine and serves any number of local applications over a socket, so a host where several programs need the same identity, peers and documents runs one node instead of several. Each application speaks JSON-RPC 2.0 over a WebSocket, declares which application it is, and calls the engine’s methods and receives its events by name. The Python package ships the reference server as the offline-protocol-service command (module offline_protocol_sdk.local_api), from v0.28.0. Install the package from PyPI first; see Python and Linux gateways.

Guarantees for every client

  • The server owns the engine. It runs the loop, drains inbound messages, starts and stops. No client can call process(), receive_message() or stop().
  • A connection speaks for one application. The first request is hello with an app_id. Every message that connection sends is stamped with it, and an inbound message stamped with it reaches only connections that declared it.
  • The socket is the authorization boundary. Whoever can open the Unix socket, or holds the launch token on TCP, is one of the operator’s applications. The server does not tell one local process from another.
  • Events are the engine’s JSON, unchanged. A client that handles message_received from the embedded SDK handles the same object here.

Start the service

The service runs over the built-in file stores, so a host with no secret service needs only two directories and a key:
config.json holds the ProtocolConfig fields by name, with enum values spelled as in the interface definition:
--listen and --peer drive the peer-stream transport when the configuration enables it (--peer takes host:port or off1...@host:port). --gateway HOST:PORT names the gateway daemon when the configuration enables reticulum (default localhost:4242). --keyring uses the platform keyring instead of the file stores. Run offline-protocol-service --help for the full list. The server never binds a non-loopback address. To reach the API across a network, put a reverse proxy with its own authentication in front of it. GET /health on either carrier returns the server name and version, the API version and the carrier. There is no other HTTP path: a single call is a connection that sends hello, one request, and closes.

Talk to it

Every request is a JSON-RPC 2.0 object with an id. Every event is a notification whose method is event and whose params is the event object.
This Python client declares an application, narrows its events, and edits a replicated document. It needs only the websockets package the SDK already depends on.
  • Sending. Call the engine’s methods by name with the parameters the interface definition gives them, for example send_message with recipient, content and priority. It returns the message ID. The message_sent and message_delivered events for that ID come back to the sending connection only. Watch for message_undeliverable as well.
  • Subscribing. After hello a connection receives every event routed to its application. subscribe with types narrows that; subscribe with "all" resets it.
  • Held messages. An inbound message for an application with no connected client is held, up to 256 per application ID, and delivered after the next hello under that ID. Delivery is at least once, so deduplicate on message_id.
  • Documents and services. The replicated document store is reachable as data.* methods and mesh services as services.*. A write carries a tagged value; a read returns plain JSON. With the data layer off, every data.* call answers DataDisabled.
  • Errors. A failed call is a JSON-RPC error whose data.variant is the engine’s ProtocolError variant. Switch on the variant, not the number. A method before hello is InvalidState; a refused token, space or method is PermissionDenied.
Platform operations (driving a radio, attaching storage, feeding frames, running the loop, owning telemetry) are not on the wire, because the server owns them.

Policy file

With no policy, any well-formed application ID is accepted. --policy policy.json adds rules per application ID:
Once spaces or denied names any application, an ID that no section names is refused at hello.

Application IDs on the wire

The ID a client declares is stamped on each message it sends, in place of the configured app_id, and message_received and file_received report it on the receiving node. It changes only the stamp, never the storage namespace or the MLS session. The ID is cleartext and unsigned on the wire: route on it, never authorize on it. The same per-send ID is available without the service through SendMessageOptions and MediaSendOptions in Python and Rust, and the appId option of sendMessage and sendMedia in React Native.

Reference