---
name: ricochet-dart-client
description: Use when writing Dart or Flutter code against package:ricochet (ricochet-dart-client) to talk to a Ricochet server: SFClient setup on an existing dart_libp2p host, sendMessage/retrieveMessages, flags and deletes, push notifications, NaCl box encryption with PayloadEncryptor, mailboxes and ACLs, documents, feeds, collections, claim mailboxes.
triggers: package:ricochet, ricochet-dart-client, SFClient, SFClientConfig, SFServerPreference, PayloadEncryptor, MailboxManager, MailboxNotification, RetrieveRefusedException, claimMessage, retrieveMessages
---

# ricochet-dart-client

## What it is

`package:ricochet` is the Dart client for Ricochet store-and-forward servers
(go-ricochet). It opens libp2p streams on the Ricochet protocol IDs over a
`dart_libp2p` host that the application has already built and connected; it
never creates or manages networking itself.

Host construction (transports, Noise, yamux, relay, hole punching) is in the
`dart-libp2p-host` skill; UDX is in `udx-dart`. The server side is
`ricochet-server`; the end-to-end offline-messaging pattern is
`ricochet-mailbox`.

## Install

```yaml
dependencies:
  ricochet: ^0.3.1        # the README still says ^0.2.1; 0.3.1 is current
```

It accepts `dart_libp2p >=1.0.0 <5.0.0`, `dart_libp2p_pubsub >=1.1.0 <5.0.0`
and `dart_libp2p_kad_dht >=1.2.0 <2.0.0`; use dart_libp2p 4.1+ for working
hole punching behind NAT.

```dart
import 'dart:convert';      // utf8
import 'dart:typed_data';   // Uint8List
import 'package:ricochet/ricochet.dart';
import 'package:dart_libp2p/core/peer/peer_id.dart';
import 'package:dart_libp2p/core/peer/addr_info.dart';
import 'package:dart_libp2p/core/multiaddr.dart';
import 'package:dart_libp2p/core/network/context.dart' as core_context;
```

## Core workflow

### Minimal client

```dart
// host: a started dart_libp2p Host (UDX + Noise + yamux), see dart-libp2p-host.
final serverId = PeerId.fromString('12D3KooW...');
final serverAddr = MultiAddr('/ip4/203.0.113.10/udp/55223/udx');

await host.peerStore.addrBook.addAddr(serverId, serverAddr, const Duration(hours: 24));
await host.connect(AddrInfo(serverId, [serverAddr]), context: core_context.Context());

final client = SFClient(
  host: host,
  config: SFClientConfig(
    preferredServers: [SFServerPreference(serverId: serverId, priority: 10)],
    connectionTimeout: const Duration(seconds: 20),
    messageTimeout: const Duration(seconds: 20),
  ),
  pubsub: pubsub,                                     // optional: presence, shared/public pushes
  encryptor: PayloadEncryptor.fromEd25519Seed(seed),  // optional: E2E payloads
);
client.registerServerAddress(serverId, serverAddr);   // lets it re-add the address after TTL expiry
await client.start();                                 // registers the push handler
```

### Send and retrieve

```dart
final res = await client.sendMessage(
  recipient: bobId,
  payload: Uint8List.fromList(utf8.encode('hello')),
  folderPath: 'inbox',          // default 'inbox'
  priority: MessagePriority.normal,
  expiry: const Duration(days: 7),
  persistent: false,            // removed when the recipient acknowledges it
  encrypt: true,                // needs an encryptor
);
if (!res.success) print('not stored: ${res.errorMessage}');

final msgs = await client.retrieveMessages(throwOnFailure: true);
for (final m in msgs) {
  if (m.flags.isEncrypted) continue;   // did not open; see Gotchas
  handle(m.senderPeerId, m.payload);
}
await client.markMessagesDelivered(msgs.map((m) => m.messageId).toList());
```

Paging: `retrieveMessages(maxMessages: 100, fromSequence: last.sequenceNumber! + 1)`.
`hasMore` is not exposed on `SFClient`; keep paging until a page comes back
short.

### Flags and deletion (IMAP style)

```dart
await client.updateMessageFlags(id, addFlags: MessageFlags.flagged);
await client.updateMessageFlags(id, addFlags: MessageFlags.deleted);
await client.expungeMessages();                 // or folderPath: 'orders'
await client.deleteMessages([id]);              // immediate
```

### Push notifications

```dart
client.notifications.listen((n) async {
  // n.mailboxPath is the folder; n.ownerPeerId whose mailbox; n.serverPeerId who sent it
  final from = n.serverPeerId != null ? PeerId.fromString(n.serverPeerId!) : null;
  await client.retrieveMessages(fromServer: from, folderPath: n.mailboxPath);
});
await client.subscribeToMailbox(ownerId.toBase58(), 'announcements'); // shared/public, needs pubsub
```

Private mailboxes are pushed over `/ricochet/mailbox-notify/1.0.0` on the
connection the client opened; shared and public ones over GossipSub topic
`getMailboxTopic(owner, folder)` = `/ricochet/mailbox/<owner>/<folder>`.

### Mailboxes and ACLs

```dart
final mb = client.mailboxes;
await mb.createMailbox(folderPath: 'team', type: MailboxType.shared, maxMessages: 500, retentionDays: 7);
await mb.grantAccess(folderPath: 'team', type: MailboxType.shared,
    targetPeerId: aliceId, accessMode: AccessMode.readWrite);
final info = await mb.getMailboxInfo(folderPath: 'team', type: MailboxType.shared);
final all = await mb.listMailboxes();
await mb.deleteMailbox(folderPath: 'team', type: MailboxType.shared);
```

### Claim mailboxes (server v1.6.0+)

```dart
await owner.mailboxes.createMailbox(folderPath: 'prekeys', type: MailboxType.claim);
// owner then sendMessage()s items to itself with folderPath: 'prekeys'
final item = await other.claimMessage(ownerPeerId: ownerId, folderPath: 'prekeys'); // null when none left
```

### Documents

```dart
final put = await client.putDocument(
  path: 'profile',
  content: Uint8List.fromList(utf8.encode('{"name":"Ann"}')),
  visibility: DocumentVisibility.public,   // new documents are private by default
  history: 5,                              // keep 5 earlier versions
);
if (put == null || !put.isSuccess) throw StateError('put failed: ${put?.status}');

final doc = await client.getDocument(ownerPeerId: annId, path: 'profile', ifNoneMatch: put.etag);
if (doc != null && doc.isNotModified) { /* 304 */ }

await client.patchDocument(path: 'profile', patch: {'bio': 'hi'}, ifMatch: put.etag);
final meta = await client.headDocument(ownerPeerId: annId, path: 'profile');
final list = await client.listDocuments(ownerPeerId: annId, pathPrefix: 'settings/');
final versions = await client.getDocumentHistory(ownerPeerId: annId, path: 'profile');
await client.restoreDocumentVersion(path: 'profile', versionNumber: versions!.first.versionNumber);
await client.setDocumentVisibility(path: 'profile', visibility: DocumentVisibility.shared);
final deleted = await client.deleteDocument(path: 'profile');
```

### Feeds and collections

```dart
await client.createFeed(path: 'posts', title: 'Posts', collaborative: false);
final a = await client.appendFeedEntry(path: 'posts', content: bytes, entryType: 'post');
final page = await client.getFeedEntries(ownerPeerId: annId, path: 'posts', fromSequence: 1, limit: 50);

await client.createCollection(path: 'contacts', name: 'Contacts', visibility: CollectionVisibility.private);
await client.putCollectionItem(path: 'contacts', key: 'bob', content: Uint8List.fromList(utf8.encode('{"city":"Paris"}')));
final q = await client.queryCollection(ownerPeerId: annId, path: 'contacts',
    filter: {'city': 'Paris'}, sortField: 'city', sortAsc: true, limit: 20);
```

Directory: `joinDirectory(displayName:, bio:, extras:)`,
`browseDirectory(search:, limit:, cursor:)`, `leaveDirectory()`.

## Key concepts / API map

- `SFClient` - entry point; every call takes an optional `fromServer` /
  `toServer` / `serverPeerId` to pin one server.
- `SFClientConfig` - `preferredServers`, `enableAutoRetrieval` (polls every
  `retrievalInterval`, default 1 min), `maxRetries` (3),
  `connectionTimeout` (10 s), `messageTimeout` (30 s), retry delays.
- `SFServerPreference(serverId:, priority:, weight: 50)` - lower priority wins.
- `ServerSelector` - priority order, marks an unreachable server unavailable
  for 30 s.
- `SendResult` - `success`, `messageId`, `storedAtServer`, `errorMessage`.
- `SFMessage` - `messageId`, `senderPeerId`, `recipientPeerId`, `payload`,
  `flags` (`SFMessageFlags`), `priority`, `folderPath`, `sequenceNumber`,
  `createdTimestamp`, `expiryTimestamp`.
- `MessageFlags` - `seen`, `flagged`, `deleted`, `draft` (int bit masks).
- `MailboxType` - `private`, `shared`, `public`, `claim`;
  `AccessMode` - `readOnly`, `writeOnly`, `readWrite`.
- `MailboxManager` (`client.mailboxes`) - create, update, delete, grant,
  revoke, listACL, getMailboxInfo, listMailboxes; throws
  `MailboxManagementError`.
- `PayloadEncryptor` - `fromEd25519Seed`, `fromLibp2pKey`; wire format
  `"RCE2" || nonce(24) || box(binding || payload)`, identical to Go.
- `DocumentVisibility`, `CollectionVisibility` - `private`, `shared`, `public`.
- Presence (needs pubsub): `client.trackContactPresence(contact, serverId)`,
  `isContactOnline`, `getContactPresence`, `contactPresenceChanges` (null
  without pubsub). The README's `client.presenceTracker` getter does not exist.
- `RicochetUri` / `RicochetUriResolver` - `ricochet://<owner>/<type>/<path>?servers=...`.
- Exceptions: `RetrieveFailedException`, `RetrieveRefusedException` (`status`
  403/404/400), `PayloadDecryptException`, `BoundToAnotherMessageException`,
  `MailboxManagementError`.

## Gotchas

- **The README's flag API is out of date.** The methods are
  `markMessagesDelivered`, `updateMessageFlags`, `expungeMessages` (positional
  ids), and `getDocument` / `listDocuments` require `ownerPeerId`. Trust the
  signatures in `lib/client/sf_client.dart`.
- **Most calls swallow errors and return `null` or `false`.** Timeouts, no
  server and transport faults come back as `null` (`getDocument`,
  `putDocument`, flag acks, deletes) or `false` (`deleteDocument`,
  `deleteFeed`). `retrieveMessages` returns `[]` unless you pass
  `throwOnFailure: true`. Always pass it where an empty inbox and an
  unreachable server must be told apart.
- **A non-null store response can still be a failure.** `putDocument`,
  `getDocument`, `setDocumentVisibility` return the server's `status`: check
  for 403 (not yours / not readable), 404, 409 (ETag mismatch), 507 (quota).
- **`deleteDocument` returns `false` for "absent" and for "refused" alike.**
  Follow it with `headDocument` if you must know which.
- **`SendResult` has no status code.** A full mailbox or a missing write
  grant arrives only as `errorMessage` text (`mailbox full`, `no write access
  to shared mailbox`). Do not retry a full mailbox on a timer; only the owner
  can drain it.
- **A send that times out is retried** up to `maxRetries` with backoff, so a
  submission the server stored but could not acknowledge can arrive twice.
  De-duplicate on the receiving side.
- **Retrieval is not consumption.** Messages stay on the server until
  `markMessagesDelivered` (deletes non-persistent, sets `\Seen` on
  persistent), `deleteMessages`, `\Deleted` + expunge, retention or expiry. A
  client that only keeps a local cursor fills the mailbox to its cap (1000)
  and every sender then fails.
- **A message that will not open is returned sealed, not dropped.** Its
  `flags.isEncrypted` stays true and `payload` is ciphertext. Delete it or
  hand it to your own E2E layer; never treat it as plaintext.
- **The encryptor must be built from the host's own identity seed.** The
  recipient derives the sender's X25519 key from the sender's peer ID; an
  encryptor from another key produces messages nobody can open.
  `fromLibp2pKey` only works for keys that still expose their seed (built with
  `Ed25519PrivateKey.fromRawBytes`); otherwise it throws `StateError`.
- **Without an encryptor, `sendMessage(encrypt: true)` throws `StateError`.**
- **Encryption is not forward secret** and the envelope (sender, recipient,
  folder, time, size) is visible to the server. Folder bindings are UTF-8
  since 0.3.0: messages sealed by 0.2.x to a non-ASCII folder no longer open.
- **`MailboxManager` uses the first server in `preferredServers` list order**,
  not the priority order `ServerSelector` uses, and its requests have no
  timeout. List servers in priority order.
- **Admin calls are keyed by type.** `getMailboxInfo(folderPath, type)` with
  the wrong type is "not found". Probe `shared` then `private` if unsure.
  `listMailboxes()` entries have no message count; `getMailboxInfo` does.
- **The first writer decides a folder's type for life.** A delivery to a
  folder that does not exist creates it private (open to any sender). Create
  shared or claim mailboxes, and grant writers, before anyone sends.
- **`start()` registers the push handler; `stop()` cannot be undone.** Stop
  closes the incoming-message stream, after which a non-empty retrieval fails
  internally and returns `[]`. Build a new `SFClient` instead of restarting.
- **Notifications only arrive while connected.** The server pushes over the
  client's own connection, so behind a NAT that drops idle UDP mappings you
  must reconnect and retrieve on reconnect. The client protects the server
  connection in the connection manager after a successful call.
- **`claimMessage` returns `null` both when empty and once the hourly share
  (30 per mailbox) is used.** Status 400 means a server older than 1.6.0. Do
  not give a claim mailbox a `retentionCount`.
- **`createFeed` on `SFClient` has no visibility parameter** (feeds default
  public); use `FeedHandler` on a raw stream for visibility. `queryCollection`
  `wantTotal` and `cursor` are on `CollectionHandler` only.
- **`setPreferredServers` does nothing** but log; recreate the client to
  change servers.
- **Two test files that each start a server time out side by side**; keep
  `dart_test.yaml` `concurrency: 1`.

## Testing

Integration tests start a real go-ricochet binary and a throwaway database
(`test/support/ricochet_server.dart`, `RicochetTestServer.start()`), and build
hosts with `test/support/test_host.dart` (`testHost(keyPair)`: UDX, Noise,
yamux, `maxStreams: 256`).

```bash
(cd ../go-ricochet && go build -o ricochet ./cmd/ricochet)
export RICOCHET_TEST_PG=postgresql://user:pass@localhost:5433/postgres
dart test
dart run bin/store_interop_test.dart     # store interop checks against go-ricochet
```

Tests skip with a reason when the binary or PostgreSQL is missing
(`RicochetTestServer.available()`). Give `connectionTimeout` and
`messageTimeout` 20 s in tests; UDX dials on CI are slow. Unit-test frame code
without a server: `AccessFrame`, `CollectionFrame` and the encryption vectors
in `test/payload_encryption_test.dart`, which match Go's
`TestCrossLanguageVectors` byte for byte.

## Red lines

- Never change the payload encryption format or binding encoding without
  updating the Go vectors too; messages stop opening across languages.
- Never log or persist the Ed25519 seed passed to `PayloadEncryptor`.
- Never treat a `null` / `[]` result as "nothing there" in sync logic; use
  `throwOnFailure` or check `status`.
- Never leave a stream open on a custom handler call; yamux allows 256 per
  connection and the client relies on closing every one.
- Never delete and recreate a mailbox to change its type.

## Reference

- Dart client docs: https://cryptopeer.net/ricochet/dart-client/
- Server and protocol: https://cryptopeer.net/ricochet/server/
- Mailbox pattern: https://cryptopeer.net/ricochet/mailbox/
- Repository: https://github.com/stephanfeb/ricochet-dart-client (`CHANGELOG.md`, `lib/client/sf_client.dart`)
- Mailbox semantics: go-ricochet `doc/MAILBOX_LIFECYCLE_AND_ACLS.md`
- Related skills: `dart-libp2p-host`, `udx-dart`, `ricochet-server`, `ricochet-mailbox`
