Complete guide to configuring the Offline Protocol SDK for different use cases.
The TypeScript examples below target the React Native package. Desktop apps on
macOS, Linux, and Windows can use the Python binding's snake_case
ProtocolConfig; see the Python binding guide.
{
// Required fields
appId: string,
profile: string, // Local namespace selector — NOT your identity.
// Your address is derived; read it with localAddress().
// Optional configurations
transports?: TransportsConfig,
binaryWireEnabled?: boolean, // Binary wire-codec kill switch (default: true)
encryption?: EncryptionConfig, // NEW: Auto-encryption settings
dors?: DorsConfig,
relay?: RelayConfig,
reliability?: ReliabilityConfig,
network?: NetworkConfig,
}Requirements: Maximum coverage, offline-only, high reliability.
{
appId: 'emergency-responder',
profile: profile,
transports: {
ble: { enabled: true },
wifiDirect: { enabled: true },
internet: { enabled: false }, // Offline only
},
dors: {
preferOnline: false,
switchHysteresis: 10, // More aggressive switching
bleToWifiRetryThreshold: 1, // Switch faster
congestionDurationSecs: 5, // Require 5s sustained congestion before escalating
ttlEscalationHoldSecs: 30, // Keep TTL alarm active for 30s
},
relay: {
allowRelay: true,
relayPriority: 'always', // Always try to be relay
minBatteryForRelay: 15, // Lower threshold for emergencies
},
network: {
initialTtl: 10, // Higher TTL for wider coverage
},
reliability: {
ack: {
defaultTimeoutMs: 10000, // Longer timeout
},
retry: {
maxRetries: 5, // More retries
outboxMaxLifetimeMs: 2592000000, // 30 days
}
}
}Requirements: Online-first, automatic offline fallback, end-to-end encryption.
{
appId: 'chat-app',
profile: profile,
transports: {
ble: { enabled: true },
wifiDirect: { enabled: true },
internet: { enabled: true },
},
// Auto-encryption enabled by default
encryption: {
enabled: true, // Messages automatically encrypted
autoKeyExchange: true, // Key packages exchanged on discovery
storePending: true, // Queue messages until session established
requireEncryption: true // Fail-closed by default; set false for best-effort/plaintext
},
dors: {
preferOnline: true, // Online-first
switchHysteresis: 15,
switchCooldownSecs: 20,
historyWindowSize: 12,
},
relay: {
allowRelay: true,
relayPriority: 'auto',
minBatteryForRelay: 30,
},
network: {
initialTtl: 8, // Standard TTL
}
}Requirements: High bandwidth, efficient chunking.
{
appId: 'file-share',
profile: profile,
transports: {
ble: { enabled: false }, // BLE too slow for large files
wifiDirect: { enabled: true }, // Prefer high bandwidth
internet: { enabled: true },
},
dors: {
preferOnline: true,
bleToWifiRetryThreshold: 1, // Quick escalation to WiFi
queueRecoveryRatio: 0.4, // De-escalate when queues recover to 40%
},
relay: {
allowRelay: true,
minBatteryForRelay: 40, // Higher for heavy traffic
},
fileTransfer: {
chunkSize: 64 * 1024, // 64KB chunks for faster transfer
maxFileSize: 500 * 1024 * 1024, // 500MB max
}
}Requirements: Maximum resilience, no infrastructure assumed, long-range.
{
appId: 'disaster-response',
profile: profile,
transports: {
ble: { enabled: true },
wifiDirect: { enabled: true },
internet: { enabled: false }, // No infrastructure
reticulum: { enabled: true }, // LoRa long-range fallback
},
dors: {
preferOnline: false,
switchHysteresis: 10,
bleToWifiRetryThreshold: 1,
},
relay: {
allowRelay: true,
relayPriority: 'always',
minBatteryForRelay: 15,
},
network: {
initialTtl: 12, // Higher TTL for sparse networks
}
}Requirements: Minimize power consumption.
{
appId: 'battery-saver-app',
profile: profile,
transports: {
ble: { enabled: true }, // Low power
wifiDirect: { enabled: false }, // Avoid high power WiFi
internet: { enabled: true },
},
dors: {
preferOnline: true, // Internet when available
},
relay: {
allowRelay: false, // Don't relay to save battery
relayPriority: 'never',
},
// Or if relay needed:
relay: {
allowRelay: true,
relayPriority: 'auto',
minBatteryForRelay: 50, // Only relay with good battery
}
}Requirements: High congestion, many devices.
{
appId: 'event-app',
profile: profile,
transports: {
ble: { enabled: true },
wifiDirect: { enabled: true },
internet: { enabled: true },
},
dors: {
congestionQueueThreshold: 30, // Lower threshold
rssiSwitchThreshold: -80, // Switch earlier on poor signal
},
path: {
forwardToTopK: 2, // Fewer relays to reduce congestion
maxCongestionLevel: 0.6, // Stricter congestion filtering
},
relay: {
minBatteryForRelay: 50, // Carry for others only well above half charge
},
reliability: {
retry: {
maxRetries: 2, // Fewer retries to reduce traffic
},
dedup: {
maxTrackedMessages: 20000, // Track more in dense network
}
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
transports.ble.enabled |
boolean | true | Enable BLE mesh |
transports.wifiDirect.enabled |
boolean | true | Enable Wi-Fi Direct (Android only) |
transports.internet.enabled |
boolean | true | Enable Internet |
transports.reticulum.enabled |
boolean | false | Enable Reticulum mesh (requires a gateway daemon speaking contract v1) |
transports.reticulum.daemonAddress |
string | localhost:4242 |
Host and port of the gateway daemon |
transports.reticulum.autoReconnect |
boolean | true | Reconnect after the connection drops |
transports.reticulum.maxReconnectAttempts |
number | 0 | Attempts before giving up (0 = infinite) |
transports.nostr.enabled |
boolean | false | Enable Nostr relay transport (requires relayUrls) |
transports.nostr.relayUrls |
string[] | [] |
Nostr relay WebSocket URLs (e.g. ["wss://relay.damus.io"]) |
transports.nostr.connectionTimeout |
number | 30 | Connection timeout in seconds |
transports.nostr.autoReconnect |
boolean | true | Auto-reconnect on disconnect |
transports.nostr.reconnectDelay |
number | 1000 | Reconnect delay in ms |
transports.nostr.maxReconnectAttempts |
number | 0 | Max reconnect attempts per relay (0 = infinite) |
Controls automatic MLS end-to-end encryption. See MLS Integration Guide for details.
| Parameter | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | true | Enable automatic encryption/decryption |
autoKeyExchange |
boolean | true | Auto-exchange key packages on peer discovery |
storePending |
boolean | true | Queue messages when no session exists |
requireEncryption |
boolean | true | Fail send unless encryption is applied (fail-closed) |
compactEnvelopeEnabled |
boolean | true | Emit the compact MLS envelope to recipients that advertise support (kill switch — see Wire Format Kill Switches) |
richPayloadEnabled |
boolean | true | Seal rich extras (reply context, media metadata, forward attribution) inside the MLS ciphertext for capable recipients (kill switch — see Wire Format Kill Switches) |
cryptoRecoveryEnabled |
boolean | true | Recover an undecryptable 1:1 message instead of dropping it and ACKing anyway (kill switch — see Crypto-Failure Recovery) |
pendingQueue.maxPendingPerPeer |
number | 64 | Max inbound encrypted messages held per peer awaiting session readiness |
pendingQueue.maxPendingGlobal |
number | 4096 | Max inbound encrypted messages held across all peers |
pendingQueue.pendingTtlMs |
number | 1800000 | TTL for held encrypted messages (30 minutes) |
pendingQueue.overflowPolicy |
string | drop_oldest |
Overflow action: drop_oldest or drop_newest |
pendingQueue bounds the inbound pending-decryption queue — messages that
arrived before the MLS session or group state was ready. Under the deferred-ACK
model such a message is not delivery-ACKed on receipt, so this queue is the
primary recovery window before the session confirms; that is why the TTL default
is 30 minutes rather than the 2 minutes earlier releases used. The Rust
PendingQueueConfig additionally carries max_pending_bytes_per_peer (4 MiB) and
max_pending_bytes_global (32 MiB) — memory bounds that the count limits alone
cannot provide, since a queued media chunk is far larger than a text message.
Those two are not carried on the FFI dictionary; binding callers get the
defaults.
The outbound queue — messages you sent that are waiting for a session to be
established — is a different queue with its own bounds and its own configurable
lifetime (pendingMessageMaxLifetimeMs); see
Reliability Configuration below.
Encryption is required by default (fail-closed): sends fail with a typed error
instead of ever silently degrading to plaintext — including when MLS was never
initialized. To deliberately operate in plaintext, set requireEncryption: false
explicitly; every plaintext send then emits a PLAINTEXT_SEND security warning
event (once per peer). Internal control messages (key exchange, connection
requests, service discovery) are exempt and unaffected.
Pending encrypted-message queue behavior (before MLS session readiness):
- Queueing is bounded by both per-peer and global limits.
- TTL eviction uses monotonic clock semantics.
- Overflow behavior is explicit and deterministic (
drop_oldest/drop_newest). - Every overflow/TTL drop emits structured warning logs with reason and triggered limit.
Under the default strict mode:
sendMessage/sendMessageViaTransportfail fast with typed errors (SessionNotReady,EncryptFailed) and do not send transport payloads on failure. WithstorePending: true(default), messages for peers whose session is not yet confirmed are queued and sent encrypted once it is.SessionNotReadycarries establishment progress (NoKeyPackage,HaveKeyPackage,SessionPending,SessionConfirmed) for retry/UI decisions.- Internal control messages (
sendConnectionRequest,acceptConnectionRequest,rejectConnectionRequest, key packages, service discovery) are exempt — they are plaintext bootstrap messages and continue to work. - Inbound plaintext content is rejected — text messages and legacy media chunks alike. Rejected plaintext is never surfaced as
message_received(plaintext carries no sender authentication, so anyone could inject it under a contact's name); aSecurityWarningwith reason codePLAINTEXT_RECEIVE_REJECTEDis emitted once per peer. Even withrequireEncryption: false, inbound plaintext from a peer known to run MLS is rejected as a downgrade/forgery attempt. "Known to run MLS" means an MLS session with them exists, or they have signed a control message this install verified — not merely that a session is confirmed. An honest peer never sends cleartext in that state: while a session is pending its sender queues rather than downgrading, so plaintext from such a peer is always either an injection or a genuine downgrade. Peers that have shown no MLS signal at all are still readable, which is what makesenabled: true+requireEncryption: falseusable for legacy interop. - The
message_receivedevent carriesencrypted: truewhen the content arrived MLS-encrypted and was auto-decrypted, andencrypted: falsefor plaintext accepted under the opt-out.
Rust migration note:
EncryptionConfig::default()setsrequire_encryption: true(fail-closed). Nodes that never callinitialize_mlsnow fail sends withEncryptFailedinstead of silently sending plaintext.- Disabling encryption (
enabled: false) requires also settingrequire_encryption: false— config validation rejects the combination otherwise. - If you construct
EncryptionConfigwith a struct literal, includerequire_encryptionexplicitly or use..Default::default().
Example: Disable auto-encryption (use manual MLS APIs):
{
encryption: {
enabled: false,
// Explicit opt-out required: plaintext operation is never implicit.
requireEncryption: false,
}
}Example: Auto-encrypt but require explicit key exchange:
{
encryption: {
enabled: true,
autoKeyExchange: false, // Must manually exchange key packages
storePending: true,
// requireEncryption defaults to true (strict, fail-closed)
}
}The negotiated wire formats are advertised per peer via the signed key package and can be disabled at runtime — without an SDK release — if a field interop issue ever surfaces:
| Parameter | Type | Default | Description |
|---|---|---|---|
binaryWireEnabled (top level) |
boolean | true | Emit the compact binary wire codec on mesh hops to peers that advertise support (wire_versions) |
encryption.compactEnvelopeEnabled |
boolean | true | Emit the compact MLS envelope on encrypted messages to recipients that advertise support (env_versions) |
encryption.richPayloadEnabled |
boolean | true | Seal the rich payload (quoted-reply context, rich media metadata, forward attribution) inside the MLS ciphertext toward recipients that advertise support (rich_versions) |
The three switches are independent — each format degrades separately.
Disabling a switch stops advertising and emitting that format, so both
directions fall back as key packages refresh: the wire and envelope switches
fall back to the permanent JSON floor, and disabling richPayloadEnabled drops
rich extras from outbound sends (messages degrade to plain text — rich fields
are never sent cleartext). Parsing of inbound compact/sealed formats stays on
regardless — the switches can never make a device unable to read a peer, and a
disabled fleet interoperates with an enabled one automatically.
{
binaryWireEnabled: false, // Hop-local: mesh framing back to JSON
encryption: {
compactEnvelopeEnabled: false, // End-to-end: MLS envelope back to JSON
richPayloadEnabled: false, // End-to-end: stop sealing rich extras
}
}encryption.cryptoRecoveryEnabled (default true) is a fourth runtime kill
switch. It is not a wire format — nothing is negotiated and no peer has to
support it — so it degrades independently of the three above.
An inbound encrypted message on an established 1:1 session can fail to decrypt — most importantly when the session has fallen out of epoch sync with the peer (the two sides disagree on the MLS epoch, e.g. after a fork). Such a failure used to be delivery-ACKed and dropped: silent loss behind an ACK that claimed delivery. With the switch on:
- the failure withholds the delivery ACK, so the sender keeps retrying instead of marking the message delivered;
- the sender re-seals each resend against the peer's current session, so the message is delivered rather than merely retried;
- if the failure is a proven epoch mismatch, a rate-limited session re-key
(one per peer per 30 s, via a
session_resetkey package) additionally rebuilds the channel.
Failures that are not an epoch mismatch — AEAD/authentication failures, discarded ratchet generations, malformed frames — get the un-ACK and the resend re-seal, but never the re-key: turning every malformed frame into a session teardown would be an unbounded churn vector.
Because these failures no longer settle the message, message_decryption_failed
is advisory and fires once per failed attempt (bounded by the sender's ACK
retry budget), not once per message. The terminal signals remain
message_failed and, for media, file_receive_failed.
The re-key trigger is unauthenticated by construction: an MLS epoch is
checked during framing validation, before the sender is verified, so any party
able to inject a frame can drive a re-key without key material or captured
ciphertext. It is safe because it is bounded — confined to that peer's own
session slot, one per peer per 30 s, destroying no queued message, and reported
as a SESSION_REKEY_TRIGGERED security warning. See
MLS Integration for the full
threat model and the residual.
| Parameter | Type | Default | Description |
|---|---|---|---|
encryption.cryptoRecoveryEnabled |
boolean | true | Recover an undecryptable 1:1 message (un-ACK + resend re-seal; rate-limited re-key on epoch desync only) |
Setting it to false reverts to the legacy drop-and-ACK behaviour. Media chunks
have no resend re-seal (chunks are re-encoded, not replayed) and recover through
the media_resend_required path instead. See
MLS Integration for the full
mechanism.
| Parameter | Type | Default | Description |
|---|---|---|---|
preferOnline |
boolean | false | Prefer Internet when available |
switchHysteresis |
number | 15.0 | Min score improvement to switch |
switchCooldownSecs |
number | 20 | Cooldown after switching (seconds) |
bleToWifiRetryThreshold |
number | 2 | Retries before escalating |
rssiSwitchThreshold |
number | -85 | RSSI threshold (dBm) |
congestionQueueThreshold |
number | 50 | Queue depth for congestion |
stabilityWindowSecs |
number | 8 | Stability check window |
poorSignalDurationSecs |
number | 10 | Seconds RSSI must remain poor before escalating |
ttlEscalationThreshold |
number | 2 | TTL value considered near exhaustion |
| Parameter | Type | Default | Description |
|---|---|---|---|
allowRelay |
boolean | true | Allow device to act as relay |
minBatteryForRelay |
number | 30 | Min battery % for relay |
relayPriority |
string | 'auto' | 'auto', 'always', or 'never' |
The shape of carrying other people's traffic, once relay.relayPriority and the
battery floors have already decided that this device does. Whether to forward at
all is relay.allowRelay's job; this section is only how.
Applied at construction. There is no runtime update, because the governor takes its snapshot when it is built and re-pointing it mid-flight would have to rebuild the token buckets and suppression cache underneath in-flight forwards.
| Parameter | Type | Default | Description |
|---|---|---|---|
maxTtl |
number | 8 | Hop budget a forwarded frame is clamped to |
denseMaxTtl |
number | 5 | Hop budget once the neighborhood is dense |
denseDegree |
number | 6 | Neighbor count at which the dense budget applies |
fanout |
number | 3 | Neighbors a frame is forwarded to (must be ≥ 1) |
jitterMinMs |
number | 20 | Shortest pre-transmit delay |
jitterMaxMs |
number | 200 | Longest pre-transmit delay at low density |
ratePerSec |
number | 10 | Sustained forwarding rate, frames per second |
burst |
number | 30 | Burst allowance above the sustained rate |
peerRatePerSec |
number | 5 | Sustained per-neighbor acceptance rate |
peerBurst |
number | 15 | Per-neighbor burst allowance |
queueCapacity |
number | 256 | Maximum forwards awaiting transmission |
biasMinScale |
number | 0.25 | Smallest share of full forwarding effort capability bias scales down to; 1.0 disables bias |
biasMaxHandicapMs |
number | 400 | Longest extra pre-transmit delay bias adds to a weaker device |
activityWindowMs |
number | 60000 | How long a stretch of forwarding activity is measured over |
activityMinForwards |
number | 3 | Frames carried in one window at or above which this device reads as an active relay |
activityIdleWindows |
number | 2 | Consecutive quiet windows before an active relay reads as inactive |
Every field is optional, and an omitted one keeps the default above rather than
being restated by a binding. That is deliberate: the defaults live in the Rust
core and nowhere else, so a section naming one dial moves only that dial. The
suppression-cache sizing (seen) is not exposed, being internal memory sizing
rather than a policy dial.
To read what is actually in force, including every default the app never set,
use getMeshRelayTunables(). Unlike the config, its result has every field
populated, so no caller needs a fallback literal. Counters are
getMeshRelayStats(); see mesh.md for how to
read them.
| Parameter | Type | Default | Description |
|---|---|---|---|
forwardToTopK |
number | 3 | Number of relays to forward to |
maxCongestionLevel |
number | 0.7 | Max congestion threshold (0-1) |
ACK Config:
| Parameter | Type | Default | Description |
|---|---|---|---|
defaultTimeoutMs |
number | 10000 | ACK timeout (milliseconds) |
maxPendingAcks |
number | 1000 | Max pending ACKs |
Retry Config:
| Parameter | Type | Default | Description |
|---|---|---|---|
maxRetries |
number | 10 | Max ACK retry attempts (transport retries are unlimited) |
initialDelayMs |
number | 1000 | Initial retry delay |
maxDelayMs |
number | 300000 | Max retry delay (5 min) |
backoffMultiplier |
number | 2.0 | Backoff multiplier |
outboxMaxLifetimeMs |
number | 604800000 | Max message lifetime (7 days) |
pendingMessageMaxLifetimeMs |
number | 604800000 | Max lifetime while waiting for MLS session establishment (7 days) |
edgeDrivenUnreachableDm |
boolean | false | Opt-in flood control for durably-unreachable DMs (see below) |
edgeDrivenUnreachableDm (default false) changes how a direct message to
a peer that stays unreachable is retried. With the default, the SDK honors the
"a parked message never goes fully quiet"
contract: it keeps a timed reachability probe running on every carrier forever
(escalating to one per 600s). Set it to true and, after the probe has run a
bounded number of times, the message stops being timed-probed and rests in the
outbox — re-driven only when the peer next proves reachable (an inbound frame or
a presence-online edge flushes it), and a restart skips re-driving a
durably-failing backlog. Enabling the flag also bounds the core resend rate to
gone peers, which is what prevents a large unreachable backlog from tripping a
relay's rate limiter into a disconnect loop.
This trades the SDK's "self-recovers even for a silent returning peer that never advertises presence" guarantee for zero steady-state relay traffic to gone peers. Enable it only for deployments whose peers always interact or advertise presence when they return (for example a machine-to-machine capability exchange). Do not enable it for consumers that rely on the timed probe as their only recovery path — a pure-relay peer that comes back silently and never polls presence would not be re-driven. The flag is off by default so every existing native and third-party integration is unaffected.
Fixed (not configurable) message-plane limits, listed here because they can
surface as errors or as message_failed events:
| Limit | Value | Effect when exceeded |
|---|---|---|
sendMessage content size |
256 KiB | call fails with InvalidArgument — use sendMedia, which chunks |
| Pending queue, per peer | 64 messages / 2 MiB | oldest evicted with message_failed |
| Pending queue, global | 4096 messages / 16 MiB | globally oldest evicted with message_failed |
| Single protocol-state record | 4 MiB | refused on write, dropped on read |
Group sends have no durable pre-session queue and are exempt from the queue bounds and the content cap. They are not otherwise unbounded: by default a group send over the Internet transport is one ordinary message frame per member, so each member's copy carries its own outbox entry, ACK, and retry ladder. See Message Delivery for the reasoning behind each bound, and Group sends for the per-member delivery path.
Dedup Config:
| Parameter | Type | Default | Description |
|---|---|---|---|
maxTrackedMessages |
number | 1000 | Max message IDs to track (must be > 0) |
retentionTimeSecs |
number | 3600 | Retention time (1 hour; must be > 0) |
Both fields are now rejected at 0. Neither failed safe: at
maxTrackedMessages: 0 the exact-match tracker evicts on every insert, so it
holds a single id and duplicate suppression — a replay defence — was effectively
off for a configuration the SDK used to accept in silence;
retentionTimeSecs: 0 expires every entry immediately for the same result. This
refuses only the degenerate value, not an unwise one: sizing the window for your
deployment is still your call.
| Parameter | Type | Default | Description |
|---|---|---|---|
maxGroupMembers |
number | 256 | Maximum members in a single group (must be > 0) |
relayEnabled |
boolean | true |
Register groups with the relay server |
relayBroadcastEnabled |
boolean | true |
Allow a relay-synced group to send via one O(1) relay broadcast instead of per-member fan-out — taken only against a relay that advertised the group_delivery_v3 capability |
enforceAdminCommits |
boolean | false |
Refuse an incoming MLS membership commit whose committer the local admin overlay does not authorize, instead of applying and reporting it |
These two relay flags are not the same switch.
relayEnabled gates registration. The relay's group registry is what invite
links resolve against, so turning it off breaks invite links. Leave it on.
relayBroadcastEnabled gates the send path, and the flag alone never selects
it: the broadcast is additionally gated on the connected relay having advertised
the group_delivery_v3 capability in its Authenticated answer — the settled
delivery-report contract plus an address-aware group path (members named by
the registered off1… identifiers, so the report is comparable against the
MLS roster; a group_delivery_v2 relay's username-keyed path deliberately
fails this gate). Such a relay
answers every broadcast with a settled per-recipient delivery report, and the
SDK re-sends a per-member copy — through the ordinary outbox/ACK/park ladder —
to every MLS roster member the report does not account for, surfacing the
result as the group_message_delivery_report event. That report-plus-backstop
is what gives the broadcast a delivery contract and is why the default is now
on. Against an older relay the capability gate fails closed and every send
takes per-member fan-out; the v1 relay's fire-and-forget broadcast (no
presence check, no push fallback, no persistence, "sent" answered before
delivery was known — a miss was undetectable, since MLS application messages
do not advance the group epoch) is never taken regardless of this flag.
Set it to false to force per-member fan-out even against a capable relay: one
ordinary message frame per member, each inheriting the full direct-message
ladder — outbox, ACK, retry, relay write-ack, offline push carrying the
ciphertext, and park-on-unreachable with presence-driven flush. The cost is
O(N) frames, which does not risk the relay's rate limiter at any group size
(the bridge's own token bucket is tighter and defers rather than drops), but
does mean drain latency and, past roughly 118 members, duplicate sends from the
ACK timer starting at local enqueue — the strongest reason large groups should
leave the broadcast on. See
Group sends for the full analysis.
Leaving this off does not mean unauthorized membership changes go
unnoticed. They are applied and reported: group_unauthorized_membership_change
fires and the affected group_member_added / group_member_removed events
carry authorized: false. The flag only decides whether the commit is also
refused.
Turning it on is a decision about partition risk, not a hardening tweak. Refusing a commit means declining the MLS merge, so the refusing device's epoch stays behind every member that accepted it — and MLS cannot heal that. The device stops being able to decrypt the group and has to be re-invited by the app. Enforcement is fork-free only if every member reaches the same verdict, and the admin overlay is replicated best-effort: role changes ride unreconciled mesh notifications, and a joiner receives a point-in-time snapshot.
The check therefore fails open on every absent input — no group metadata, no admin role stored yet, an unreadable roster — so the common "my role map is behind" case merges normally. What it cannot detect is divergent knowledge: two members who each hold a non-empty but disagreeing admin set will refuse each other's commits. That residual risk is why this is opt-in. Enable it only for a closed deployment that controls role distribution, and never on part of a fleet — members with it off will apply a commit that members with it on rejected. Note also that rejection is receiver-local: the sender's frame is still acknowledged, so a committer gets no signal that anyone refused.
Pure key-update commits (which carry no membership change) and 1:1 sessions are never gated.
React Native: the group section is plumbed through both bridges. Its
documented home is the nested object (top-level flat keys are also accepted,
camelCase or snake_case, nested winning over flat — the same shape rules as
encryption):
const config = {
group: {
maxGroupMembers: 256, // default
relayEnabled: true, // default — invite links depend on it
relayBroadcastEnabled: true, // default — capability-gated; false forces per-member fan-out
enforceAdminCommits: false, // default — see the partition warning above before enabling
},
};Replicated documents: offline-first state any member of a space can edit while disconnected, merging deterministically when replicas meet again. Messaging is synced events; this is synced state.
| Parameter | Type | Default | Description |
|---|---|---|---|
data.enabled |
boolean | true |
Whether the data layer accepts work |
const config: ProtocolConfig = {
appId: 'my-app',
profile: 'default',
data: { enabled: true },
};enabled is on by default. It was off for one release, while the layer could
store documents but not replicate them: a capability advertised with no sync
behind it invites peers to expect a sync that never comes. Replication landed,
so the switch is on.
Leaving it on costs an application that never opens a store nothing at rest and
nothing on the wire. Set it to false to be certain the layer is inert; a
DataStore call then fails with a DataDisabled error naming the reason,
rather than working somewhere unexpected.
Storage. Documents persist through the same seam as the rest of protocol state, so there is nothing to configure: every binding ships a default provider and the layer works out of the box. To put documents somewhere else, construct the store with a backend instead of assigning a config field:
// Default: documents live wherever protocol state already does.
const store = new DataStore();// Rust consumers assign the backend directly.
let config = ProtocolConfig::builder("my-app", "default")
.data_enabled(true)
.data_storage(Arc::new(MyBackend::open("documents.db")?))
.build()?;Swapping the backend is a runtime choice: no rebuild, no build flag, no change to any data API. Protocol secrets stay where they are; only documents move. Sealing sits above the seam, so a custom backend is handed sealed bytes and never sees document content.
Two obligations come with a custom backend:
- Verify it.
runStorageConformance(provider)returns a JSON report; emptyfailuresis the definition of supported. See storage adapter references. - Wipe it on logout.
wipePersistedState()clears the default provider's account directory, which a custom backend is not inside, so callDataStore.wipeAll()as well or documents outlive the account. Stop the engine first: there are no deletion tombstones, so a wipe on a running engine with live sessions is undone by the peer's next version offer, which recreates and refills every document with no error and no event. Stopping covers it only while it stays stopped, because the peer still holds the documents: the call clears this device, it does not delete content.
Limits. A document is capped at 1 MiB compacted, with a
data_doc_size_warning event at 768 KiB. Passing the cap raises DocTooLarge;
the breaching change is still durable, and deletions keep working while growth
is refused, so a document can be brought back under the cap.
| Parameter | Type | Default | Description |
|---|---|---|---|
initialTtl |
number | 8 | Initial TTL for messages |
ProtocolConfig::validate() runs on construction — OfflineProtocol::new fails
with InvalidConfiguration rather than starting on a configuration that cannot
work. Nothing is partially applied.
Identity and routing
appIdmust not be emptyprofilemust not be emptyinitialTtlmust be > 0- At least one transport must be enabled
ACK and retry
ack.defaultTimeoutMsmust be > 0ack.maxPendingAcksmust be > 0retry.initialDelayMsandretry.maxDelayMsmust be > 0, andinitialDelayMsmust be ≤maxDelayMsretry.backoffMultipliermust be finite and ≥ 1.0retry.outboxMaxLifetimeMsandretry.pendingMessageMaxLifetimeMsmust each be in1..=i64::MAX
Deduplication
dedup.maxTrackedMessagesmust be > 0dedup.retentionTimeSecsmust be > 0- When
useBloomFilteris set:bloomFilterBits,bloomHashCount,bloomFilterCount, andbloomRotationSecsmust each be > 0 (Rust-only — the FFIDedupConfigcarries no Bloom fields)
Encryption and groups
requireEncryption: truerequiresenabled: truependingQueue.maxPendingPerPeerandmaxPendingGlobalmust be > 0, andmaxPendingGlobalmust be ≥maxPendingPerPeerpendingQueue.pendingTtlMsmust be > 0maxGroupMembersmust be > 0
Mesh forwarding
meshRelay.fanoutmust be > 0. Zero is not a cheaper forward but a silent drop: the frame is already admitted and recorded as seen, so no copy goes out and this node's suppression entry stops it arriving by another pathmeshRelay.maxTtlandmeshRelay.denseMaxTtlmust each be > 0. A hop ceiling of zero clamps every arriving budget to nothing, so the frame is refused before it is ever queued. The dense ceiling only applies in a crowded room, so a zero there fails exactly where the mesh is most neededmeshRelay.queueCapacitymust be > 0. A queue that holds nothing refuses every admission as queue-fullmeshRelay.ratePerSec,burst,peerRatePerSecandpeerBurstmust each be finite and > 0. The token buckets clamp their inputs to zero, so a negative, zero orNaNvalue yields a bucket that never releases a token and forwarding stops for good with no error and no countermeshRelay.biasMinScalemust be finite and in(0.0, 1.0]meshRelay.jitterMinMsmust not exceedmeshRelay.jitterMaxMs. An inverted window collapses the delay spread to a single millisecond, so neighbors stop separating in time, and it would slip past rule 23 belowmeshRelay.jitterMaxMs + meshRelay.biasMaxHandicapMsmust stay under the 5s overdue cut-off, past which a forward is abandoned rather than latemeshRelay.activityWindowMs,activityMinForwardsandactivityIdleWindowsmust each be > 0
Rules 17 through 20 all guard one failure: a dial that reads like a conservative setting but is in fact an off switch, leaving the device running, reporting no error, and carrying nothing. Refusing them at construction is what keeps that distinguishable from a quiet neighborhood.
Note what is not validated: minBatteryForRelay is a u8 and is clamped by
its type rather than range-checked. Earlier versions of this guide claimed it
was validated; it never was.
updateAckConfig, updateRetryConfig, and updateDedupConfig are fallible
(Result in Rust, [Throws=ProtocolError] over UniFFI — Swift callers need
try). Each builds the candidate configuration and runs the same
ProtocolConfig::validate above, rather than re-checking a hand-rolled subset
that would drift. On rejection the previous configuration is kept.
Two consequences worth knowing:
- A rejection can name a field you did not pass. The updaters validate the whole candidate configuration, so an already-installed bad value surfaces on the next unrelated update.
- On React Native, a
reliabilityblock passed to theOfflineProtocolconstructor is applied duringstart(), where a rejection is logged and swallowed — the SDK keeps its defaults. A silently-defaulted reliability block looks like it worked; grep your logs forFailed to apply … configuration. A directupdateDedupConfig(...)call rejects the promise instead.
Available Transports: Internet, BLE, Wi-Fi Direct, Reticulum, Nostr
Permissions Required:
BLUETOOTH,BLUETOOTH_SCAN,BLUETOOTH_CONNECTACCESS_FINE_LOCATION(for BLE scanning)ACCESS_WIFI_STATE,NEARBY_WIFI_DEVICES
Available Transports: Internet, BLE, Reticulum, Nostr (no Wi-Fi Direct)
Permissions Required:
NSBluetoothAlwaysUsageDescription
Recommended Config:
{
transports: {
ble: { enabled: true },
wifiDirect: { enabled: false }, // Not available on iOS
internet: { enabled: true },
}
}The Python binding supports macOS, Linux, and Windows. Internet/WebSocket, BLE, Reticulum, and Nostr are available; Wi-Fi Direct is not implemented on desktop. Configuration uses the generated snake_case fields:
from offline_protocol_sdk.offline_protocol import ProtocolConfig, OverflowPolicy
config = ProtocolConfig(
app_id="desktop-app",
profile="alice",
ble_enabled=True,
wifi_direct_enabled=False,
internet_enabled=True,
reticulum_enabled=False,
nostr_enabled=False,
prefer_online=True,
initial_ttl=8,
encryption_enabled=True,
auto_key_exchange=True,
store_pending=True,
require_encryption=True,
max_pending_per_peer=64,
max_pending_global=4096,
pending_ttl_ms=1_800_000, # 30 min (the SDK default)
overflow_policy=OverflowPolicy.DROP_OLDEST,
)ProtocolManager also requires a state_root (or OFFLINE_PROTOCOL_STATE_ROOT
in the environment): Python has no portable uninstall-scoped container, so the
SDK refuses to guess one. Your installer must remove that directory on
uninstall.
Build and package details are in
bindings/python/README.md.
Available Transports: Internet only
Recommended Config:
{
transports: {
ble: { enabled: false }, // Not available in browsers
wifiDirect: { enabled: false }, // Not available in browsers
internet: { enabled: true },
}
}{
relay: {
minBatteryForRelay: 50, // Only relay with good battery
},
dors: {
// Prefer low-power transports
}
}{
reliability: {
retry: {
maxRetries: 5,
outboxMaxLifetimeMs: 2592000000, // 30 days
}
},
path: {
forwardToTopK: 5, // More redundancy
}
}{
dors: {
switchHysteresis: 5, // Switch faster
bleToWifiRetryThreshold: 1, // Escalate immediately
},
reliability: {
ack: {
defaultTimeoutMs: 2000, // Shorter timeout
}
}
}- More relays, lower TTL
- Stricter congestion management
- BLE preferred (short range sufficient)
- Fewer relays, higher TTL
- Reticulum with LoRa preferred (multi-km range)
- Wi-Fi Direct for nearby high-bandwidth transfers
- Higher battery thresholds
- Medium TTL
- BLE mesh optimal
- Many relays (walls attenuate signal)
- Quick transport switching
- Lower hysteresis
- Shorter stability window