ricochet-dart-client
Skill
ricochet-dart-client. 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.SKILL.md (Claude Code, Codex, Cursor) · ricochet-dart-client.md (Boubou) · install:
curl -fsSL https://cryptopeer.net/skills/install.sh | sh -s -- ricochet-dart-client
What it is
Section titled “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
Section titled “Install”dependencies: ricochet: ^0.3.1 # the README still says ^0.2.1; 0.3.1 is currentIt 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.
import 'dart:convert'; // utf8import 'dart:typed_data'; // Uint8Listimport '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
Section titled “Core workflow”Minimal client
Section titled “Minimal client”// 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 expiryawait client.start(); // registers the push handlerSend and retrieve
Section titled “Send and retrieve”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)
Section titled “Flags and deletion (IMAP style)”await client.updateMessageFlags(id, addFlags: MessageFlags.flagged);await client.updateMessageFlags(id, addFlags: MessageFlags.deleted);await client.expungeMessages(); // or folderPath: 'orders'await client.deleteMessages([id]); // immediatePush notifications
Section titled “Push notifications”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 pubsubPrivate 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
Section titled “Mailboxes and ACLs”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+)
Section titled “Claim mailboxes (server v1.6.0+)”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 leftDocuments
Section titled “Documents”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
Section titled “Feeds and collections”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
Section titled “Key concepts / API map”SFClient- entry point; every call takes an optionalfromServer/toServer/serverPeerIdto pin one server.SFClientConfig-preferredServers,enableAutoRetrieval(polls everyretrievalInterval, 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; throwsMailboxManagementError.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’sclient.presenceTrackergetter does not exist. RicochetUri/RicochetUriResolver-ricochet://<owner>/<type>/<path>?servers=....- Exceptions:
RetrieveFailedException,RetrieveRefusedException(status403/404/400),PayloadDecryptException,BoundToAnotherMessageException,MailboxManagementError.
Gotchas
Section titled “Gotchas”- The README’s flag API is out of date. The methods are
markMessagesDelivered,updateMessageFlags,expungeMessages(positional ids), andgetDocument/listDocumentsrequireownerPeerId. Trust the signatures inlib/client/sf_client.dart. - Most calls swallow errors and return
nullorfalse. Timeouts, no server and transport faults come back asnull(getDocument,putDocument, flag acks, deletes) orfalse(deleteDocument,deleteFeed).retrieveMessagesreturns[]unless you passthrowOnFailure: 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,setDocumentVisibilityreturn the server’sstatus: check for 403 (not yours / not readable), 404, 409 (ETag mismatch), 507 (quota). deleteDocumentreturnsfalsefor “absent” and for “refused” alike. Follow it withheadDocumentif you must know which.SendResulthas no status code. A full mailbox or a missing write grant arrives only aserrorMessagetext (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
maxRetrieswith 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\Seenon 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.isEncryptedstays true andpayloadis 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.
fromLibp2pKeyonly works for keys that still expose their seed (built withEd25519PrivateKey.fromRawBytes); otherwise it throwsStateError. - Without an encryptor,
sendMessage(encrypt: true)throwsStateError. - 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.
MailboxManageruses the first server inpreferredServerslist order, not the priority orderServerSelectoruses, 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”. Probesharedthenprivateif unsure.listMailboxes()entries have no message count;getMailboxInfodoes. - 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 newSFClientinstead 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.
claimMessagereturnsnullboth 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 aretentionCount.createFeedonSFClienthas no visibility parameter (feeds default public); useFeedHandleron a raw stream for visibility.queryCollectionwantTotalandcursorare onCollectionHandleronly.setPreferredServersdoes 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.yamlconcurrency: 1.
Testing
Section titled “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).
(cd ../go-ricochet && go build -o ricochet ./cmd/ricochet)export RICOCHET_TEST_PG=postgresql://user:pass@localhost:5433/postgresdart testdart run bin/store_interop_test.dart # store interop checks against go-ricochetTests 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
Section titled “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; usethrowOnFailureor checkstatus. - 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
Section titled “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