Offline Protocol Ruby binding
The Offline Protocol Ruby binding is a typed Ruby API for scripts, tooling and servers, added in 0.31.0. It is generated by UniFFI’s own Ruby generator from the same interface definition as the Swift, Kotlin, Python and Java bindings, and committed as one file, bindings/ruby/lib/offline_protocol.rb.
It calls the native library directly through the ffi gem, with no native glue of its own, and it does not go through the C API. The module is OfflineProtocol, and so is the engine class inside it, so the engine is OfflineProtocol::OfflineProtocol. MeshServices and DataStore hang off the engine, every record and enum of the definition is a class, and an engine refusal is an exception named for its variant, such as OfflineProtocol::ProtocolError::EncryptFailed.
Ruby versions, ffi gem and RubyGems status
The binding needs Ruby 3.3 or later and the ffi gem, ~> 1.17, and nothing else. CI runs its consumer check on Ruby 4.0 on Linux, macOS and Windows, and on Ruby 3.3 on Linux, which is the gemspec’s minimum.
To add it, install the offline_protocol gem from RubyGems, 0.31.0 as of October 2026, or put lib/offline_protocol.rb from a checkout of the SDK at the same release tag on your load path. Like the Java jar, the gem holds the binding and takes the native library from the release. Each release tests the committed binding against its release library, builds the gem at the release’s version and checks that it carries the binding and the licence.
# Gemfile
gem "offline_protocol", "~> 0.31"
Native library and hosts
The native library comes from the GitHub release’s desktop archives, from the same release as the binding: liboffline_protocol_uniffi.so for Linux x86_64 and aarch64, liboffline_protocol_uniffi.dylib for macOS arm64, and offline_protocol_uniffi.dll for Windows x86_64. You can also build it with cargo build --release -p offline-protocol-uniffi.
The loader finds it on the library search path, and reads that path only when the process starts, so set it before Ruby launches:
# Linux
LD_LIBRARY_PATH=/path/to/dir ruby -Ilib your_script.rb
# macOS
DYLD_LIBRARY_PATH=/path/to/dir ruby -Ilib your_script.rb
# Windows (RubyInstaller reads RUBY_DLL_PATH, not PATH)
$env:RUBY_DLL_PATH = "C:\path\to\dir"; ruby -Ilib your_script.rb
Take the library and the binding from the same release. Each release’s consumer check runs the generated Ruby against the library built from the same definition, and the Ruby binding relies on that pairing rather than on load-time checksums like the Swift, Kotlin, Python and Java bindings.
Ruby quick start
The Ruby records keep the definition’s defaults as keyword arguments, so you name only the fields that have none. This is the binding’s own quick start:
require "offline_protocol"
Sdk = OfflineProtocol
# An instance-less function needs no engine.
puts Sdk.derive_address([0] * 32)
# Name the fields the definition leaves without a default; the rest take it.
config = Sdk::ProtocolConfig.new(
app_id: "com.example.ruby", profile: "ruby-example",
ble_enabled: false, wifi_direct_enabled: false, internet_enabled: true,
reticulum_enabled: false, nostr_enabled: false, prefer_online: false,
initial_ttl: 8, encryption_enabled: true, auto_key_exchange: true,
# The pending queue: per peer, global, the SDK's 24 h lifetime, overflow.
store_pending: true, max_pending_per_peer: 64, max_pending_global: 4096,
pending_ttl_ms: 86_400_000, overflow_policy: Sdk::OverflowPolicy::DROP_OLDEST
)
engine = Sdk::OfflineProtocol.new(config)
# Any object that responds to on_event. It runs inside the engine call.
listener = Object.new
def listener.on_event(json) = puts(json)
engine.set_event_callback(listener)
engine.emit_test_event
For storage, hand the engine the built-in file stores with engine.initialize_mls_with_file_stores(mls_dir, state_dir, store_key_32_bytes), or write two providers, pass them to initialize_mls and prove them with OfflineProtocol.run_storage_conformance. A runnable program in examples/ruby-app puts two devices in one Ruby process and takes them from pairing through an encrypted message, a file and an MLS group, with a carrier of a dozen lines between them.
Ruby integration notes
Three habits keep a Ruby host in step with the generated code, and the example program follows all of them:
- Mask returned bytes. A byte array from the engine arrives as values from -128 to 127, and the engine takes 0 to 255. Before passing one back, such as a key package, mask it with
map { |b| b & 0xFF }. - Keep one engine for the process. That is how the engine is meant to be used, and an engine that is never collected never leaves a callback registered. Where engines are short lived, use
poll_eventinstead of a callback. - Run slow calls off request threads. Engine calls hold Ruby’s global VM lock while they run, so a server makes calls such as
startandinitialize_mlsoutside its request threads.
Callbacks are duck-typed: any object that responds to the interface’s methods. A callback runs inside the engine call, must not call back into the engine and must not raise anything but the interface’s own error. The two storage interfaces also carry bytes differently: key material as arrays of integers, protocol state as binary strings.
Host responsibilities in Ruby
In Ruby your process is the platform: the engine handles routing, encryption and delivery, and the host connects it to the world. That means the transports (draining each queue, doing the I/O and reporting the outcome), two storage providers or the built-in file stores, the lifecycle (start, stop, and process on a timer), and the telemetry platform if you enable telemetry: TelemetryOs::LINUX, MACOS or WINDOWS and the major version, read from the host.
Ruby fits tooling, scripts and services that already run Ruby and want full control of their carriers. For Bluetooth LE, LAN peer streams and relays ready-made on desktop and server hosts, the Python package on PyPI ships those bridges. The SDK is AGPL-3.0-only, with a commercial licence for proprietary use.