SDKs and platforms

How do I use Offline Protocol from Ruby?

Offline Protocol has a typed Ruby binding for scripts, tooling and servers on Ruby 3.3 or later, generated from the same interface definition as the other bindings and calling the native library through the ffi gem. You install the offline_protocol gem from RubyGems and put the native library from the GitHub release on the loader's search path.

TL;DR

The Ruby binding gives Ruby 3.3 or later the full engine API in one generated file over the ffi gem, on desktop and server hosts. You install the offline_protocol gem from RubyGems with the native library from the same release, mask returned byte arrays and keep one engine for the process.

Learning objectives

After reading this article you will be able to:

  • Load the Ruby binding and the matching native library on Linux, macOS or Windows
  • Create an engine with the built-in file stores and receive events in Ruby
  • Follow the integration notes on returned bytes, engine lifetime and slow calls

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_event instead 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 start and initialize_mls outside 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.

Frequently asked questions

How do I install the Offline Protocol Ruby binding?

Install the offline_protocol gem from RubyGems, which brings the ffi gem with it, and put the native library from the same GitHub release on the loader's search path. The gem holds no native code.

Which Ruby versions does the binding support?

Ruby 3.3 or later, with the ffi gem at version 1.17 or a later 1.x. CI runs the binding's consumer check on Ruby 4.0 on Linux, macOS and Windows, and on Ruby 3.3 on Linux.

Why does Ruby raise RangeError when I pass a key package back to the engine?

The generated code reads bytes as signed values from -128 to 127 but accepts only 0 to 255 on the way in. Mask the array with map { |b| b & 0xFF } before handing it back.

Does the Ruby binding use the C API?

No. It calls the native library directly through the ffi gem using UniFFI's own interface, with no hand-written native code and no C API in between.

Which Ruby should I use on macOS?

A Ruby you installed. The protected system Ruby at /usr/bin/ruby drops DYLD_LIBRARY_PATH at launch, so with it, put the library in a directory the loader already searches.

Sources

Build it with Offline Protocol

The platforms page of the docs lists every binding with its install route and the hosts it runs on.

Read supported platforms