> ## Documentation Index
> Fetch the complete documentation index at: https://www.offlineprotocol.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# API Methods

> Complete reference for all Mesh SDK methods: lifecycle, messaging, identity, encryption, groups, file transfer, service discovery, telemetry, and more.

# API Methods

## Module exports

```typescript theme={null}
import {
  OfflineProtocol,        // main class (also the default export)
  MeshServices,           // service discovery & RPC
  registerMeshWakeTask,   // Android process-restart hook
} from '@offline-protocol/mesh-sdk';
```

All types, enums, and constants are re-exported from the package root.

## Constructor

```typescript theme={null}
new OfflineProtocol(config: ProtocolConfig)
```

There is no singleton and no factory. See [Configuration](/docs/mesh-sdk/configuration) for all
options. Only `appId` and `profile` are required.

The native protocol is created lazily on the first `start()`, not in the constructor. The
constructor *does* subscribe to native events immediately, which is why listeners must be
registered synchronously right after it returns.

<Note>
  The underlying native module is process-global, so multiple `OfflineProtocol` instances share
  one native protocol.
</Note>

## Lifecycle

| Method                               | Returns                  | Description                                                                              |
| ------------------------------------ | ------------------------ | ---------------------------------------------------------------------------------------- |
| `start()`                            | `Promise<void>`          | Create the native protocol, initialize MLS, start transports. Throws if already started. |
| `stop()`                             | `Promise<void>`          | Stop the protocol and disconnect all peers. Throws if not started.                       |
| `pause()`                            | `Promise<void>`          | Pause for background mode. Throws if not running.                                        |
| `resume()`                           | `Promise<void>`          | Resume from paused state. Throws if not paused.                                          |
| `getState()`                         | `Promise<ProtocolState>` | Current state: `"Stopped"`, `"Running"`, or `"Paused"`.                                  |
| `destroy()`                          | `Promise<void>`          | Remove all listeners, destroy the native instance, clear cached state.                   |
| `wipePersistedState(appId, profile)` | `Promise<void>`          | Erase all persisted state for one account.                                               |
| `emitTestEvent()`                    | `Promise<void>`          | Debug helper that emits a zeroed `network_metrics` event to verify the event path.       |

`start()` initializes MLS *before* starting transports, so a peer can never be discovered
before key exchange is possible. It also auto-enables any internet, Nostr, or Reticulum
transport marked `enabled` in config.

<Warning>
  `wipePersistedState()` is irreversible. Call it **after** `destroy()`, on logout or account
  switch. It rotates the MLS and Nostr identities, so peers see a desync and must re-establish.
  The native side rejects it if the named account is the one currently running. It is safe to
  call twice. Apps with custom storage providers must erase their own containers separately.
</Warning>

## Events

| Method                           | Returns | Description                                    |
| -------------------------------- | ------- | ---------------------------------------------- |
| `on(eventType, listener)`        | `this`  | Register an event listener.                    |
| `off(eventType, listener)`       | `this`  | Remove an event listener.                      |
| `once(eventType, listener)`      | `this`  | Register a one-time listener.                  |
| `removeAllListeners(eventType?)` | `this`  | Remove listeners for one type, or all of them. |

`eventType` is any [event name](/docs/mesh-sdk/events) or the string `'all'`, which receives every
event.

```typescript theme={null}
protocol.on('message_received', (event) => { /* ... */ });
protocol.on('all', (event) => console.log(event.type));
```

<Warning>
  Register listeners synchronously after construction. The SDK subscribes to native events in
  its own constructor, so events can arrive before your handlers attach, and apart from two
  one-shot types, unlistened events are dropped by design.

  Delivery is **at-least-once**: handlers must be idempotent.
</Warning>

**One-shot replay.** `internet_session_superseded` and `mesh_stopped_by_user` are held when
they arrive with no listener and delivered to the first listener that registers. Replay is
asynchronous, so a handler never fires before the `on(...)` call returns. Held events are
cleared by `start()`, by a successful `enableTransport('internet', …)` (for
`internet_session_superseded`), and by `destroy()`.

## Messaging

| Method                                                     | Returns                                 | Description                                         |
| ---------------------------------------------------------- | --------------------------------------- | --------------------------------------------------- |
| `sendMessage(params)`                                      | `Promise<string>`                       | Send a message; resolves to the message ID.         |
| `forwardMessage(params)`                                   | `Promise<string>`                       | Forward a message with original-sender attribution. |
| `receiveMessage()`                                         | `Promise<MessageReceivedEvent \| null>` | Poll for the next received message.                 |
| `sendPresenceUpdate(recipient, status)`                    | `Promise<string>`                       | Send presence (`'online'`, `'away'`, `'offline'`).  |
| `sendTypingIndicator(recipient, conversationId, isTyping)` | `Promise<string>`                       | Send a typing indicator.                            |
| `sendReadReceipt(recipient, messageIds)`                   | `Promise<string>`                       | Send a read receipt.                                |

```typescript theme={null}
interface SendMessageParams {
  recipient: string;              // a peer's off1… address
  content: string;
  priority?: MessagePriority;     // default: Medium
  replyToMsg?: string;
  contentType?: ContentType;      // must not be `file_chunk`
  replyContext?: ReplyContext;    // sealed-only
  mediaMetadata?: MediaMetadata;  // sealed-only
  forwardInfo?: ForwardInfo;      // sealed-only
}

interface ForwardMessageParams {
  originalMessageJson: string;
  newRecipient: string;
  priority?: MessagePriority;
}

enum MessagePriority {
  Low = 0,
  Medium = 1,
  High = 2,
  Critical = 3,
}
```

<Note>
  `replyContext`, `mediaMetadata`, and `forwardInfo` travel only inside the MLS-sealed rich
  payload, and only to recipients whose SDK advertised support. Toward anyone else they are
  silently dropped; they are **never** sent cleartext.
</Note>

`sendTypingIndicator`'s `conversationId` is opaque and echoed back on the receiving event. It
must be stable, so do not derive it from a mutable display name.

## Connection Requests

| Method                            | Returns           | Description                          |
| --------------------------------- | ----------------- | ------------------------------------ |
| `sendConnectionRequest(params)`   | `Promise<string>` | Send a connection request to a peer. |
| `acceptConnectionRequest(params)` | `Promise<string>` | Accept a connection request.         |
| `rejectConnectionRequest(params)` | `Promise<string>` | Reject a connection request.         |
| `cancelConnectionRequest(params)` | `Promise<string>` | Cancel a previously sent request.    |

```typescript theme={null}
interface SendConnectionRequestParams {
  recipient: string;
  senderName: string;
  keyPackage?: number[];
  initialMessage?: string;
}

interface AcceptConnectionRequestParams {
  recipient: string;
  accepterName: string;
  keyPackage?: number[];
}

interface RejectConnectionRequestParams { recipient: string }
interface CancelConnectionRequestParams { recipient: string }
```

<Warning>
  `senderName` and `initialMessage` travel **in plaintext**, because connection requests
  necessarily precede the MLS session.
</Warning>

The returned message ID correlates `connection_request_undeliverable`, `message_delivered`,
and `message_failed`. The peer's answer arrives as `connection_accepted` or
`connection_rejected`, correlated by peer address, not by message ID.

## Identity

| Method                                        | Returns                   | Description                                                     |
| --------------------------------------------- | ------------------------- | --------------------------------------------------------------- |
| `localAddress()`                              | `Promise<string \| null>` | This device's `off1…` address; `null` before startup completes. |
| `deriveAddress(publicKey)`                    | `Promise<string>`         | Derive the canonical address from a 32-byte Ed25519 key.        |
| `getIdentityPublicKey()`                      | `Promise<number[]>`       | This device's Ed25519 public key (32 bytes).                    |
| `signData(data)`                              | `Promise<number[]>`       | Sign bytes with the identity key (64-byte signature).           |
| `verifySignature(publicKey, data, signature)` | `Promise<boolean>`        | Verify an Ed25519 signature.                                    |
| ~~`deriveUserIdFromPublicKey(publicKey)`~~    | `Promise<string>`         | **Deprecated.** Use `deriveAddress`.                            |

`deriveAddress()` needs no protocol instance, so it is safe to call before `start()`, which
is what makes it usable for verifying an invite or QR code. It throws if the key is not
exactly 32 bytes.

See [Identity & Addressing](/docs/mesh-sdk/identity) for the full model.

## Media & File Transfer

| Method                                                    | Returns                         | Description                                 |
| --------------------------------------------------------- | ------------------------------- | ------------------------------------------- |
| `sendMedia(params)`                                       | `Promise<string>`               | Send media of any type. Returns a file ID.  |
| `sendFile(params)`                                        | `Promise<string>`               | Convenience wrapper for `ContentType.File`. |
| `sendImage(recipient, fileData, fileName, metadata?)`     | `Promise<string>`               | Send an image.                              |
| `sendVoiceNote(recipient, fileData, fileName, metadata?)` | `Promise<string>`               | Send a voice note.                          |
| `sendVideoNote(recipient, fileData, fileName, metadata?)` | `Promise<string>`               | Send a video note.                          |
| `sendVideo(recipient, fileData, fileName, metadata?)`     | `Promise<string>`               | Send a video.                               |
| `getFileProgress(fileId)`                                 | `Promise<FileProgress \| null>` | Get transfer progress.                      |
| `cancelFileTransfer(fileId)`                              | `Promise<boolean>`              | Cancel an active transfer.                  |
| `processFileChunk(...)`                                   | `Promise<void>`                 | Low-level: process one inbound chunk.       |
| `finalizeFile(fileId)`                                    | `Promise<void>`                 | Low-level: finalize after all chunks.       |

All media methods accept base64-encoded file data. The SDK handles chunking and reassembly.

```typescript theme={null}
interface SendMediaParams {
  recipient: string;
  fileData: string;              // base64
  fileName: string;
  contentType: ContentType;
  mediaMetadata?: MediaMetadata;
  caption?: string;              // sealed-only
  replyToMsg?: string;           // sealed-only
  replyContext?: ReplyContext;   // sealed-only
  forwardInfo?: ForwardInfo;     // sealed-only
  fileId?: string;               // caller-supplied; max 4096 bytes
}

enum ContentType {
  Text = 'text',
  Image = 'image',
  Video = 'video',
  Audio = 'audio',
  VoiceNote = 'voice_note',
  VideoNote = 'video_note',
  File = 'file',
  FileChunk = 'file_chunk',   // internal: rejected in SendMessageParams
  Poll = 'poll',
}
```

<Note>
  `sendFile()` discards everything except recipient, data, and name. Use `sendMedia()` when you
  need metadata, a caption, or reply context.
</Note>

The `MediaMetadata` input type is camelCase; the `media_metadata` field on received events is
snake\_case. See [Types & Internals](/docs/mesh-sdk/types-and-internals#media-types).

## Transport Management

| Method                           | Returns                             | Description                                 |
| -------------------------------- | ----------------------------------- | ------------------------------------------- |
| `getActiveTransports()`          | `Promise<TransportType[]>`          | List active transports.                     |
| `enableTransport(type, config?)` | `Promise<void>`                     | Enable a transport.                         |
| `disableTransport(type)`         | `Promise<void>`                     | Disable a transport.                        |
| `forceTransport(type)`           | `Promise<void>`                     | Pin a transport, overriding DORS.           |
| `releaseTransportLock()`         | `Promise<void>`                     | Release the pin, returning control to DORS. |
| `getTransportMetrics(type)`      | `Promise<TransportMetrics \| null>` | Per-transport metrics.                      |

```typescript theme={null}
type TransportType = 'ble' | 'internet' | 'wifiDirect' | 'reticulum' | 'nostr';
```

## Bluetooth

| Method                     | Returns                   | Description                                                                                         |
| -------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------- |
| `isBluetoothEnabled()`     | `Promise<boolean>`        | Whether Bluetooth is enabled.                                                                       |
| `requestEnableBluetooth()` | `Promise<boolean>`        | Prompt to enable Bluetooth. **Always returns `false` on iOS**, which forbids programmatic enabling. |
| `getBLePeerCount()`        | `Promise<number>`         | Number of BLE peers currently tracked.                                                              |
| `getBleDiagnostics()`      | `Promise<BleDiagnostics>` | Three degraded-path counters.                                                                       |

```typescript theme={null}
interface BleDiagnostics {
  fragmentFallbacks: number;        // frames broadcast because the peer was not matched
  recipientNotAmongPeers: number;   // sends naming a peer connected under a different id
  undersizedMtuReports: number;     // peers with an MTU too small for a fragment header
}
```

<Note>
  These are **degraded-path** counters, not error counters. Every frame they count was still
  sent, so they never surface as delivery failures. They are monotonic for the lifetime of the
  protocol instance, so sample the delta and watch the trend. All three read zero when BLE is
  disabled or not yet started.
</Note>

## Internet & Relay

| Method                                    | Returns                            | Description                                                 |
| ----------------------------------------- | ---------------------------------- | ----------------------------------------------------------- |
| `isInternetReady()`                       | `Promise<boolean>`                 | Socket connected **and** relay-authenticated. Never throws. |
| `isInternetSuperseded()`                  | `Promise<boolean>`                 | Whether the relay displaced this session. Never throws.     |
| `forceInternetReconnect()`                | `Promise<boolean>`                 | Force teardown, reconnect, and re-authenticate.             |
| `checkInternetPresence(userId, options?)` | `Promise<boolean>`                 | One-shot relay presence query.                              |
| `sendRawServerCommand(json)`              | `Promise<boolean>`                 | Send a caller-built relay frame verbatim.                   |
| `internetStatusChanged(isConnected)`      | `Promise<void>`                    | Low-level: report a connection state change.                |
| `internetMessageReceived(senderId, data)` | `Promise<void>`                    | Low-level: feed an inbound frame.                           |
| `internetGetNextMessage()`                | `Promise<InternetMessage \| null>` | Low-level: dequeue the next outbound message.               |
| `internetConfirmSent(messageId)`          | `Promise<void>`                    | Low-level: confirm a wire send succeeded.                   |
| `internetSendFailed(messageId)`           | `Promise<void>`                    | Low-level: report a wire send failure.                      |

`checkInternetPresence` is fire-and-event: the answer arrives as a
[`presence_updated`](/docs/mesh-sdk/events#presence_updated) event with `source: 'internet'`, so
subscribe before calling. It is never throttled or deduped. `force: true` parks through the
reconnect window for up to \~8 seconds, which suits opening or focusing a chat. A `true` return
means the query reached the socket, not that an answer will come.

<Warning>
  A superseded session **never reconnects on its own**. Recovery is a deliberate
  `enableTransport('internet', { serverAddress })`.

  You normally do **not** need `forceInternetReconnect()` on foreground: both native bridges
  auto-heal after a background stay. Calling it on every foreground double-reconnects and forces
  a wasted group re-registration. Keep it for a user-initiated "reconnect now".
</Warning>

`sendRawServerCommand` is for app-owned relay features: the invite-link lifecycle
(`CreateGroupInviteLink`, `JoinGroupViaInvite`, `AckGroupInviteJoin`). Gate it on
`isInternetReady()`. Unconsumed responses arrive as `internet_server_message` events.

<Warning>
  Do **not** send SDK-managed frame types through `sendRawServerCommand`: `SendMessage`,
  `CreateGroup`, member deltas, `LeaveGroup`, `CheckPresence`. A raw `CreateGroup` or
  `LeaveGroup` desyncs the SDK's registration tracking. It returns `false` when not
  connected and authenticated, when the JSON is invalid, or when the client-side limiter
  deferred (28 burst / 9 per second).
</Warning>

After calling `internetGetNextMessage()` and sending on the wire, you **must** call either
`internetConfirmSent(messageId)` or `internetSendFailed(messageId)`.

## Wi-Fi Direct (low-level)

| Method                                          | Returns                              | Description                        |
| ----------------------------------------------- | ------------------------------------ | ---------------------------------- |
| `wifiDirectStatusChanged(isConnected)`          | `Promise<void>`                      | Report a connection state change.  |
| `wifiDirectGetNextMessage()`                    | `Promise<WifiDirectMessage \| null>` | Dequeue the next outbound message. |
| ~~`wifiDirectMessageReceived(senderId, data)`~~ | `Promise<void>`                      | **Deprecated.**                    |
| ~~`wifiDirectPeerConnected(peerId)`~~           | `Promise<void>`                      | **Deprecated.**                    |
| ~~`wifiDirectPeerDisconnected(peerId)`~~        | `Promise<void>`                      | **Deprecated.**                    |

<Warning>
  The three deprecated methods must not be called by application code. The bundled Wi-Fi Direct
  managers no longer call them either.

  `senderId` is treated by the core as the peer's *proven* identity: it becomes the frame's
  transport peer identity and is matched against the message sender. Wi-Fi Direct has no
  handshake that yields such a value, so an unproven one is either rejected or accepted into
  routing state under a name anyone could claim. `wifiDirectPeerConnected` additionally admits
  an unproven peer into the capacity-bounded neighbor table, evicting genuine neighbors.
  `WifiDirectTransport` is not registered, so frames passed here are dropped anyway.
</Warning>

## Network & Metrics

| Method                     | Returns                           | Description                      |
| -------------------------- | --------------------------------- | -------------------------------- |
| `getTopology()`            | `Promise<NetworkTopology>`        | Network topology snapshot.       |
| `getMessageStats()`        | `Promise<MessageDeliveryStats[]>` | Per-message delivery statistics. |
| `getDeliverySuccessRate()` | `Promise<number>`                 | Delivery success rate (0–1).     |
| `getMedianLatency()`       | `Promise<number \| null>`         | Median latency in ms.            |
| `getMedianHops()`          | `Promise<number \| null>`         | Median hop count.                |
| `getDedupStats()`          | `Promise<DedupStats>`             | Deduplication statistics.        |
| `getPendingAckCount()`     | `Promise<number>`                 | ACKs awaiting confirmation.      |
| `getRetryQueueSize()`      | `Promise<number>`                 | Messages in the retry queue.     |

## Battery & Relay Role

| Method                       | Returns                                | Description                                        |
| ---------------------------- | -------------------------------------- | -------------------------------------------------- |
| `setBatteryLevel(level)`     | `Promise<void>`                        | Set battery level (0–100) for mesh decisions.      |
| `getBatteryLevel()`          | `Promise<number \| null>`              | Current battery level.                             |
| `setRelayPriority(priority)` | `Promise<void>`                        | Set relay priority: `'low'`, `'medium'`, `'high'`. |
| `getRelayPriority()`         | `Promise<'low' \| 'medium' \| 'high'>` | Current relay priority.                            |
| `isRelay()`                  | `Promise<boolean>`                     | Whether this device is acting as a relay.          |

<Note>
  These runtime methods use `'low' | 'medium' | 'high'`, while `ProtocolConfig.relay.relayPriority`
  uses `'never' | 'auto' | 'always'`. The SDK normalizes config values when applying them:
  `never → low`, `auto → medium`, `always → high`.
</Note>

## DORS

| Method                     | Returns               | Description                                          |
| -------------------------- | --------------------- | ---------------------------------------------------- |
| `updateDorsConfig(config)` | `Promise<void>`       | Update DORS settings at runtime. Values are clamped. |
| `getDorsConfig()`          | `Promise<DorsConfig>` | Current DORS configuration.                          |
| `shouldEscalateToWifi()`   | `Promise<boolean>`    | Whether DORS recommends Wi-Fi escalation.            |

## Reliability

| Method                      | Returns         | Description                                             |
| --------------------------- | --------------- | ------------------------------------------------------- |
| `updateAckConfig(config)`   | `Promise<void>` | Update ACK settings. Throws on invalid input.           |
| `updateRetryConfig(config)` | `Promise<void>` | Update retry settings. Throws on invalid input.         |
| `updateDedupConfig(config)` | `Promise<void>` | Update deduplication settings. Throws on invalid input. |

<Warning>
  These three updaters became **fallible** in v0.17.0. Zero is rejected for
  `maxTrackedMessages` and `retentionTimeSecs`.
</Warning>

## Gradient Routing

| Method                                                                 | Returns                       | Description                                     |
| ---------------------------------------------------------------------- | ----------------------------- | ----------------------------------------------- |
| `learnRoute(destination, nextHop, hopCount, quality, sequenceNumber?)` | `Promise<void>`               | Record that a neighbor can reach a destination. |
| `getBestRoute(destination)`                                            | `Promise<RouteEntry \| null>` | Highest-quality route.                          |
| `getAllRoutes(destination)`                                            | `Promise<RouteEntry[]>`       | All non-expired routes.                         |
| `hasRoute(destination)`                                                | `Promise<boolean>`            | Whether a route exists.                         |
| `removeNeighborRoutes(neighborId)`                                     | `Promise<void>`               | Remove routes through a neighbor.               |
| `cleanupExpiredRoutes()`                                               | `Promise<void>`               | Prune expired routes. Call every \~30s.         |
| `getRoutingStats()`                                                    | `Promise<RoutingStats>`       | Routing table statistics.                       |
| `updateRoutingConfig(config)`                                          | `Promise<void>`               | Update gradient routing configuration.          |

`quality` is a 0.0–1.0 score. `sequenceNumber` is DSDV-style and defaults to 0; it is clamped
to non-negative values.

## End-to-End Encryption (MLS)

MLS is initialized automatically by `start()` when `encryption.enabled` is true (the default).
These methods provide manual control.

### Session Management

| Method                             | Returns                       | Description                                                                   |
| ---------------------------------- | ----------------------------- | ----------------------------------------------------------------------------- |
| `initializeMlsWithSecureStorage()` | `Promise<void>`               | Initialize MLS against platform secure storage.                               |
| `isMlsInitialized()`               | `Promise<boolean>`            | Whether MLS is ready.                                                         |
| `establishSecureSession(peerId)`   | `Promise<MlsWelcome \| null>` | **Recommended.** Full establishment flow; `null` if a session already exists. |
| `mlsHasSession(otherUserId)`       | `Promise<boolean>`            | Whether a session exists.                                                     |
| `hasPendingKeyPackage(peerId)`     | `Promise<boolean>`            | Whether a peer's key package has arrived.                                     |
| `getEstablishmentState(peerId)`    | `Promise<EstablishmentState>` | Per-peer establishment state.                                                 |
| `mlsListSessions()`                | `Promise<string[]>`           | Addresses with active sessions.                                               |
| `mlsDeleteSession(otherUserId)`    | `Promise<void>`               | Delete a session.                                                             |

```typescript theme={null}
type EstablishmentState =
  | 'NoKeyPackage'      // no key package received from the peer
  | 'HaveKeyPackage'    // key package available, session can be created
  | 'SessionPending'    // Welcome sent, awaiting confirmation
  | 'SessionConfirmed'; // session fully established
```

`establishSecureSession()` imports a pending key package, creates the session, and sends the
Welcome. It throws if no key package is available. Prefer it over `mlsCreateSession()`, which
requires a prior `mlsImportKeyPackage()`.

### Key Packages

| Method                               | Returns                    | Description                            |
| ------------------------------------ | -------------------------- | -------------------------------------- |
| `mlsGenerateKeyPackage()`            | `Promise<MlsKeyPackage>`   | Generate a new key package.            |
| `mlsGetOrCreateKeyPackage()`         | `Promise<MlsKeyPackage>`   | Get an existing package or create one. |
| `mlsGetPendingKeyPackages()`         | `Promise<MlsKeyPackage[]>` | Packages not yet synced.               |
| `mlsMarkKeyPackageSynced(packageId)` | `Promise<void>`            | Mark a package as synced.              |
| `mlsImportKeyPackage(userId, data)`  | `Promise<void>`            | Import a peer's key package.           |

### Low-Level Session Operations

| Method                                      | Returns                                   | Description                                          |
| ------------------------------------------- | ----------------------------------------- | ---------------------------------------------------- |
| `mlsCreateSession(otherUserId)`             | `Promise<MlsWelcome>`                     | Create a session (requires an imported key package). |
| `mlsJoinSession(welcome)`                   | `Promise<MlsSessionInfo>`                 | Join from a Welcome.                                 |
| `mlsEncryptForUser(otherUserId, plaintext)` | `Promise<MlsEncryptedMessage>`            | Encrypt for a peer.                                  |
| `mlsDecryptFromUser(encrypted)`             | `Promise<number[] \| null>`               | Decrypt a 1:1 message.                               |
| `mlsDecrypt(encrypted)`                     | `Promise<number[] \| null>`               | Decrypt any MLS message.                             |
| `mlsProcessWelcome(welcome)`                | `Promise<MlsSessionInfo \| MlsGroupInfo>` | Process a Welcome, auto-detecting the type.          |

## Group Messaging

Groups use MLS for end-to-end encryption with mesh transport for delivery.

| Method                                                           | Returns                           | Description                                  |
| ---------------------------------------------------------------- | --------------------------------- | -------------------------------------------- |
| `meshCreateGroup(groupName)`                                     | `Promise<MlsGroupInfo>`           | Create an encrypted group.                   |
| `meshInviteToGroup(groupId, inviteeUserId)`                      | `Promise<void>`                   | Invite a user.                               |
| `meshSendGroupMessage(groupId, content, priority?, replyToMsg?)` | `Promise<string[]>`               | Send to all members; returns per-member IDs. |
| `meshForwardMessageToGroup(params)`                              | `Promise<string[]>`               | Forward to a group with attribution.         |
| `meshRemoveFromGroup(groupId, memberId)`                         | `Promise<void>`                   | Remove a member.                             |
| `meshLeaveGroup(groupId)`                                        | `Promise<void>`                   | Leave a group.                               |
| `meshListGroups()`                                               | `Promise<string[]>`               | List group IDs.                              |
| `meshGetGroupInfo(groupId)`                                      | `Promise<MlsGroupInfo \| null>`   | Get group info.                              |
| `meshSetMemberRole(groupId, userId, role)`                       | `Promise<void>`                   | Set a member's role (admin only).            |
| `meshGetMemberRole(groupId, userId)`                             | `Promise<string>`                 | Get a member's role.                         |
| `meshGetGroupRoles(groupId)`                                     | `Promise<Record<string, string>>` | All member roles.                            |
| `meshRenameGroup(groupId, newName)`                              | `Promise<void>`                   | Rename a group (admin only).                 |
| `meshGroupRichReadiness(groupId)`                                | `Promise<GroupRichReadiness>`     | Whether a rich send would seal its extras.   |

<Note>
  `meshSendGroupMessage` takes `priority` as a **string** (`'low'`, `'medium'`, `'high'`,
  `'critical'`), unlike the 1:1 path's numeric `MessagePriority` enum.
</Note>

### Relay registration

Groups register with the relay so invite links can resolve against them.

| Method                                     | Returns                   | Description                                         |
| ------------------------------------------ | ------------------------- | --------------------------------------------------- |
| `groupRelaySyncState(groupId)`             | `Promise<RelaySyncState>` | `'synced'`, `'pending'`, or `'unsynced'`.           |
| `requestGroupRelayRegistration(groupId)`   | `Promise<boolean>`        | Register or re-register on demand.                  |
| `ensureGroupRegistered(groupId, options?)` | `Promise<void>`           | Resolve once the relay holds an acked registration. |

`ensureGroupRegistered` defaults to a 100-second timeout. The SDK re-sends an unanswered
registration every 30 seconds up to 3 attempts (\~90 seconds worst case) before reporting
`ack_timeout`, which is what the default covers. A shorter timeout is fine for UI, since
retries continue in the background and a later `group_relay_sync_changed` event still fires.

**Example:**

```typescript theme={null}
const group = await protocol.meshCreateGroup('Team Chat');

await protocol.meshInviteToGroup(group.groupId, 'off1q...');

const messageIds = await protocol.meshSendGroupMessage(
  group.groupId,
  'Hello team!',
  'high',
);

protocol.on('group_message_received', (event) => {
  console.log(`${event.sender}: ${event.content}`);
});

await protocol.meshSetMemberRole(group.groupId, 'off1q...', 'admin');
```

## User Blocking

| Method                  | Returns             | Description                                        |
| ----------------------- | ------------------- | -------------------------------------------------- |
| `blockUser(userId)`     | `Promise<void>`     | Block a user; their messages are silently dropped. |
| `unblockUser(userId)`   | `Promise<void>`     | Unblock a user.                                    |
| `getBlockedUsers()`     | `Promise<string[]>` | List blocked addresses.                            |
| `isUserBlocked(userId)` | `Promise<boolean>`  | Whether a user is blocked.                         |

## Telemetry

| Method                                     | Returns                            | Description                                                          |
| ------------------------------------------ | ---------------------------------- | -------------------------------------------------------------------- |
| `installTelemetrySink(config?, listener?)` | `Promise<() => void>`              | Install the telemetry sink; returns an unsubscribe for the listener. |
| `uninstallTelemetrySink()`                 | `Promise<void>`                    | Detach the sink and drain the pull queue.                            |
| `onTelemetry(listener)`                    | `() => void`                       | Register a telemetry listener (synchronous).                         |
| `pollTelemetry()`                          | `Promise<TelemetryRecord \| null>` | Pop the next buffered record.                                        |
| `telemetryInstallId()`                     | `Promise<string \| null>`          | Stable per-install telemetry ID (32 hex chars).                      |

```typescript theme={null}
const unsubscribe = await protocol.installTelemetrySink(
  { mlsVerbosity: 'lifecycle', metricsCadenceMs: 5000 },
  (record) => console.log(record.category, record),
);
```

<Warning>
  Pass the listener **to `installTelemetrySink`** rather than registering it afterwards with
  `onTelemetry`. Registering after the install resolves leaves a window where push records are
  dropped.
</Warning>

The poll queue is bounded at 1024 records and drops the oldest on overflow. `pollTelemetry()`
throws on a malformed native envelope, so callers can distinguish "queue empty" (`null`) from
bridge corruption. Re-installing a sink does *not* drain the queue; use
`uninstallTelemetrySink()` for an atomic detach and drain.

<Note>
  `telemetryInstallId()` is a persistent per-install identifier and may need declaring under
  Apple's privacy manifest or Google Play's data safety form as a "device or other ID". It
  resolves `null` until the persistent scrub secret is available.
</Note>

## Service Discovery & RPC (MeshServices)

Service Discovery turns the mesh into a **decentralized service network** where any device can
be both consumer and provider. Devices register capabilities, others discover them through
multi-hop routing, and invoke them with a request/response pattern.

### Setup

```typescript theme={null}
import { OfflineProtocol, MeshServices } from '@offline-protocol/mesh-sdk';

const protocol = new OfflineProtocol(config);
await protocol.start();

const services = new MeshServices();   // no arguments; requires a started protocol
```

### API

| Method                                                                   | Returns            | Description                                                                         |
| ------------------------------------------------------------------------ | ------------------ | ----------------------------------------------------------------------------------- |
| `registerService(serviceId, version, capabilities?)`                     | `Promise<void>`    | Register a local service.                                                           |
| `unregisterService(serviceId)`                                           | `Promise<boolean>` | Unregister; `true` if found.                                                        |
| `discoverServices(serviceId?)`                                           | `Promise<string>`  | Broadcast a discovery query; returns a query ID. Omit the argument to discover all. |
| `sendServiceRequest(provider, serviceId, method, body)`                  | `Promise<string>`  | Send a request; returns a request ID.                                               |
| `respondToServiceRequest(requestId, requester, serviceId, status, body)` | `Promise<string>`  | Respond to an incoming request.                                                     |

### Provider Example

```typescript theme={null}
await services.registerService('translate.v1', '1.0', {
  languages: 'en,es,fr,de',
  format: 'json',
});

protocol.on('service_request_received', async (event) => {
  if (event.service_id !== 'translate.v1') return;

  const { text, targetLang } = JSON.parse(event.body);
  const translated = await translateLocally(text, targetLang);

  await services.respondToServiceRequest(
    event.request_id,
    event.sender,
    event.service_id,
    'ok',
    JSON.stringify({ translated }),
  );
});
```

### Consumer Example

```typescript theme={null}
const queryId = await services.discoverServices('translate.v1');

protocol.on('service_discovered', (event) => {
  console.log(`Found ${event.service_id} v${event.version} at ${event.provider_peer_id}`);
  console.log(`${event.hop_count} hops away`, event.capabilities);

  services.sendServiceRequest(
    event.provider_peer_id,
    event.service_id,
    'translate',
    JSON.stringify({ text: 'Hello world', targetLang: 'es' }),
  );
});

protocol.on('service_response_received', (event) => {
  if (event.status === 'ok') {
    console.log(JSON.parse(event.body).translated);
  }
});
```

### Discovery across the mesh

Discovery queries are **not** limited to directly connected peers; they propagate through the
mesh using the same multi-hop routing as regular messages:

* A service on a device 5 hops away can still be discovered
* The `hop_count` on `service_discovered` tells you how far the provider is
* Multiple providers for the same service ID may respond; your app decides which to use

## Android Mesh Wake

```typescript theme={null}
registerMeshWakeTask(task: (data: MeshWakeTaskData) => Promise<void>): void
```

Registers the Headless JS task that restores the mesh after Android kills the process.
**Android only; a no-op on iOS.** Requires both the manifest `meta-data` flag and
module-scope registration; see [Installation](/docs/mesh-sdk/installation-rn#optional-android-mesh-wake).

Four caller obligations:

1. **Durably store received messages before `start()`.** The core never persists inbound
   content, and the receive path ACKs before emitting.
2. **Be idempotent and cheap** when there is nothing to do; the task may run in the foreground
   and find a live protocol.
3. **Re-issue what `start()` does not restore**: Wi-Fi Direct always, and the relay via
   `enableTransport('internet', …)`.
4. **Resolve promptly.** The budget is 60 seconds by default, after which React Native
   terminates the task.

If the task never registers, throws, or declines, the keep-alive stops itself on a watchdog.

<Card title="Next: Events Reference" icon="arrow-right" href="/docs/mesh-sdk/events">
  All 72 event types and their payloads.
</Card>
