atsign-dart-sdk Skill
Audience: developers adding
at_client/at_client_flutterto their own Dart or Flutter app from pub.dev — not developers editing the SDK repo itself.
1. CRITICAL: Use the Modern Collection API
Always use AtCollection<T>. Never use AtCollectionModel.
// ✅ CORRECT — modern API
import 'package:at_client/at_client.dart';
final todos = await atClient.collection<Todo>(
'todos.my_app',
const Duration(days: 7),
fromJson: Todo.fromJson,
typeTag: 'Todo',
);
// ❌ WRONG — deprecated, do not use
class MyModel extends AtCollectionModel { ... } // @Deprecated("Use AtClient.collection...")
The entire AtCollectionModel hierarchy is annotated
@Deprecated("Use AtClient.collection for collection-style operations").
If asked how to use
AtCollectionModel: do NOT show anyAtCollectionModelcode, even in a "before/deprecated" comparison. Show ONLY theAtCollection<T>pattern above, state it is deprecated, and quote the@Deprecatedmessage.
Read references/01-deprecation-guide.md for the full migration table from old to new API.
2. Package Map
Install with dart pub add — it resolves the latest compatible version:
| Use case | packages to add |
|---|---|
| Dart CLI / server / IoT | at_client |
| Flutter app | at_client_flutter (re-exports at_client — one dep) |
| CLI / headless Dart | at_client, at_cli_commons |
| Onboarding, login, APKAM | at_client (the Atsign verbs) or at_client_flutter (dialogs) |
| Raw cryptographic operations | at_client or at_client_flutter, plus at_chops |
An app never imports at_auth: at_client owns onboarding, login and
enrollment (see §11), and at_client_flutter re-exports the one at_auth type
an app may want, RegistrarService.
Never add: at_common_flutter, at_onboarding_flutter (discontinued — use
the at_client_flutter auth dialogs), at_backupkey_flutter,
at_invitation_flutter, at_sync_ui_flutter, at_theme_flutter
Never hardcode version constraints for
at_client/at_client_flutter(e.g.at_client: ^3.11.0) inpubspec.yamlor generated templates — rundart pub addand let pub choose. Pinned constraints go stale between SDK releases and cause resolution conflicts; stability comes from a fixedtypeTagstring literal (§3), not a fixed SDK version.
Read references/06-package-map.md for per-use-case checklists and the full list of in-migration packages to avoid.
3. Getting an AtCollection<T>
Obtain via AtClient.collection(...) — never construct AtCollection directly.
final todos = await atClient.collection<Todo>(
'todos.my_app', // namespace: MUST contain '.' (fully qualified)
const Duration(days: 7), // defaultExpiration for new items
fromJson: Todo.fromJson, // auto-registers factory; typeTag required with this
typeTag: 'Todo', // pin as a string literal — NOT T.toString()
cleanupOrphansOnCreation: true, // recommended when using sub-collections
eventSource: EventSource.both, // default (see below)
);
- Cached per
(namespace, eventSource)pair — same namespace + different eventSource = separate instances namespacemust contain.(e.g.'todos.my_app') — throwsArgumentErrorotherwisetypeTagis mandatory whenfromJsonis supplied — always a string literal, never derived fromT.toString()(minifier renames types in release builds)
EventSource:
| Value | Events seen |
|---|---|
EventSource.data |
All local keystore mutations (requires SyncService running) |
EventSource.notifs |
Cross-atsign writes via notification pipeline only |
EventSource.both |
Both sources; same change may fire twice (no dedup); default |
Read references/02-atcollection-api.md for
the complete API surface including getDescendant, cleanupOrphans, and
registerFactory.
4. CRUD Cheatsheet
// create — strict: throws StateError if id already exists
final item = await todos.create(
obj: Todo('buy milk'),
sharedWith: {'@bob'.toAtsign()},
);
// upsert — idempotent: use for re-runnable publishers
await todos.upsert(id: 'my-known-id', obj: Todo('buy milk'));
// update — mutate, then persist
item.obj.done = true;
await todos.update(item);
// updateSharedWith — change recipients without rewriting the item
await todos.updateSharedWith(item, {'@alice'.toAtsign(), '@carol'.toAtsign()});
// delete
await todos.delete(item); // throws StateError if has sub-items
await todos.delete(item, cascade: true); // removes self-owned descendants first
Ownership model:
AtCollectionis owner-writes-only — an atsign can only mutate items it owns.update/updateSharedWith/deletethrowArgumentErroron an item whoseownerisn't you. Collaboration is additive: sharing grants the recipient a readable copy, not write access. For a peer to contribute, they create their own item and share it back — you never edit theirs in place. (Reading a received item works; mutating it doesn't.)
5. Reading Data
final all = await todos.getItems();
final mine = await todos.getItems(owner: atClient.atSign);
final todos.getOrNull('abc', atClient.atSign); // null if not found
final todos.get('abc', atClient.atSign); // throws if not found
final has = await todos.exists('abc', atClient.atSign);
// Streaming — decode errors surface as stream errors (not silently swallowed)
todos.getItemsAsStream()
.handleError((e) => _log.warning('decode error: $e'))
.listen((item) => handle(item));
6. Query Builder
Queries are immutable — each modifier returns a new Query<T>. Execution
is always on-device (E2E encryption means the atServer cannot filter
plaintext).
final q = todos.query()
.where((t) => !t.obj.done)
.orderBy((t) => t.obj.due)
.thenBy((t) => t.obj.title)
.limit(20);
final list = await q.get(); // Future<List<CItem<Todo>>>
final live = q.watch(); // Stream<List<CItem<Todo>>>
final count = await q.count();
final any = await q.any();
final first = await q.firstOrNull();
Typed predicates (wherePath): prefer over .where() when you want future
push-down optimisation on indexed fields.
abstract class $Todo {
static final done = PathField<bool>(path: ['obj','done'], extract: (i) => (i.obj as Todo).done);
static final due = PathField<DateTime>(path: ['obj','due'], extract: (i) => (i.obj as Todo).due);
}
todos.query()
.wherePath($Todo.done.eq(false).and($Todo.due.lt(DateTime.now())))
.watch();
Flutter rule: create a
watch()stream once and hold it inState(alate finalfield orinitState); never callwatch()insidebuild(), or each rebuild mints a new stream and drops live updates. Memoise theQueryand recreate the stream only when its inputs change.
Read references/03-query-api.md for all terminals
(distinct, groupBy, watchWithSub, watchWithTree) and the full
PathField operator list.
7. Sub-collections
// Create a sub-collection — NEVER call atClient.collection(composedNamespace)
final notes = todos.subCollection<TodoNote>(
parent: todo,
subName: 'notes', // must NOT contain '.'
defaultExpiration: const Duration(days: 30),
fromJson: TodoNote.fromJson,
typeTag: 'TodoNote',
);
// Walk ancestry from a CSubItemUpdated event
final leaf = await todos.getDescendant<Reply>(
ancestry: event.ancestry, // root-to-direct-parent; ancestry.last is direct parent
id: event.id,
owner: event.owner,
leafExpiration: const Duration(days: 7),
); // returns null if any ancestor expired
// ⚠️ THROWS ArgumentError if any CAncestor.owner in ancestry is null.
// CAncestor.owner IS null on CSubItemDeleted events — never call getDescendant from a delete handler.
// Cache the ancestry from the preceding CSubItemUpdated if you need it on delete.
For 3+ levels use watchWithTree with SubSpec<U>. Each TreeNode<T> has a
parent (CItem<T>) and branches (Map<String, List<TreeNode<dynamic>>>).
When handling sub-collection events, ancestry is always root-first:
ancestry[0] is the root ancestor, ancestry.last is the direct parent.
Read references/03-query-api.md when working with
deep hierarchies.
8. Events & Streams
collection.updates // Stream<CItemUpdated>
collection.deletes // Stream<CItemDeleted> (item.wasExpired flag)
collection.readReceipts // Stream<CReadReceipt> (r.from, r.readAt)
collection.subUpdates // Stream<CSubItemUpdated> (ancestry chain; ancestry.last = direct parent)
collection.subDeletes // Stream<CSubItemDeleted> (ancestry[n].owner is null — cache from subUpdates)
collection.availableEvents // Stream<CItemAvailable> (e.availableAt fired)
collection.expiringSoonEvents(leadTime: const Duration(hours: 1))
Flutter subscribe/dispose:
late StreamSubscription<CItemUpdated> _sub;
@override void initState() { super.initState(); _sub = collection.updates.listen((_) => setState(() {})); }
@override void dispose() { _sub.cancel(); super.dispose(); }
Read references/04-events-api.md for all event
class fields, the EventSource decision guide, and the ancestry ordering
(root-first: ancestry[0] = root, ancestry.last = direct parent of the leaf).
9. Read Receipts
await item.markReadByMe(); // mark as read (idempotent)
final readers = await item.readBy; // Future<Set<Atsign>>
item.readBySnapshot; // sync snapshot
final didRead = await item.wasMarkedReadByMe();
collection.readReceipts.listen((r) => print('${r.from} read ${r.id} at ${r.readAt}'));
10. Flutter Auth (at_client_flutter)
Four flows, four dialogs. Every dialog hands back the AtClient it opened
(null means the user cancelled or the dialog failed) and the app owns it: it
uses it, makes it current if its screens read the manager, and stop()s it
when done. Nothing here needs at_auth.
Flow 2 (existing .atKeys file) — most common for returning developers:
final atKeysIo = await AtKeysFileDialog.show(context); // FileAtKeysIo?
if (atKeysIo == null) return;
final atSign = atKeysIo.getAtsign(); // extensions.dart
final client = await PkamDialog.show(context,
atSign: atSign,
keys: atKeysIo,
preference: AtClientPreference()..namespace = 'my_namespace',
storage: await _storage(atSign),
backupKeys: [KeychainAtKeysIo()], // copies the keys into the keychain for next time
);
if (client == null) return;
_adopt(client);
Flow 3 (device keychain — returning user on same device):
final atSigns = await KeychainStorage().getAllAtsigns();
final selection = await AtSignSelectionDialog.show(context, existingAtSigns: atSigns);
if (selection == null) return; // AtsignSelection: atSign, rootDomain
final client = await PkamDialog.show(context,
atSign: selection.atSign,
rootDomain: selection.rootDomain,
keys: KeychainAtKeysIo(),
preference: AtClientPreference()..namespace = 'my_namespace',
storage: await _storage(selection.atSign),
);
if (client == null) return;
_adopt(client);
Storage and adoption (all flows):
// Where this app keeps the atSign's local store. closedByClient: the client
// closes it when it stops, so there is nothing to tear down.
Future<HiveAtClientStorage> _storage(String atSign) async {
final dir = await getApplicationSupportDirectory();
return HiveAtClientStorage(atSign: atSign, storagePath: dir.path, closedByClient: true);
}
// An app whose screens read AtClientManager.getInstance().atClient makes the
// client current; an app that passes the client around needs no manager.
void _adopt(AtClient client) => AtClientManager.getInstance().use(client);
// Logout: stop the client. Opening the same atSign again while it is live is
// refused, so every sign-in stops the previous client first.
await client.stop();
Read references/05-flutter-auth.md for all 4
flows (including Flow 1: CRAM new-atsign and Flow 4: APKAM enrollment) with
complete code, and what a PkamDialog that comes back offline means.
Sync setup: set
AtClientPreference.syncRegex = '<your namespace>'— without it, sync covers the atsign's whole keystore and can wedge, so shares and updates never propagate. Reads are local; writes sync in the background. See references/11-sync.md.
11. AtClient Lifecycle: Open, Own, Stop
at_client owns the atSign lifecycle. An app holds an AtKeysIo (a .atKeys
file, the keychain, or memory) and asks the atSign for a client; every verb
hands back an AtClient the app owns and stops. The Flutter dialogs (§10)
and CLIBase (§16) are these verbs behind UI and argument parsing.
final client = await Atsign('@alice').open(
keys: FileAtKeysIo(filePath: (_) => '/keys/@alice_key.atKeys'),
preference: AtClientPreference()..namespace = 'my_namespace'..syncRegex = 'my_namespace',
storage: HiveAtClientStorage(atSign: '@alice', storagePath: dir, closedByClient: true),
lookUps: secureSocketLookUps(), // optional: how every connection is built
);
// Also: Atsign(a).activate(cramSecret:, keys:, preference:) — onboard a new atSign
// Atsign(a).enroll(otp:, app:, device:, namespaces:, keys:, preference:) → PendingEnrollment
// Atsign(a).resumeEnrollment(app:, device:, keys:, preference:) — after a restart
The platform bundle is the three things every verb takes the same way:
keys: (where the keys live), storage: (the local store) and lookUps:
(an AtLookUpFactory that builds every connection the client opens: its
own, its sync's, its monitor's). Omit lookUps for TLS on TCP with the
defaults; pass secureSocketLookUps(config: SecureSocketConfig()..pathToCerts = ...) for TLS settings, secureSocketLookUps(onConnect: ...) for a proxy
that needs from: first, or a factory of your own for another transport. The
Flutter dialogs and CLIBase take lookUps: too. The preference's
decryptPackets, pathToCerts and tlsKeysSavePath are deprecated in its
favour.
Connection state — the client comes back whatever the network did:
client.connection.current; // AtConnectionState: online | offline | refused, plus cause
client.connection.changes.listen((s) => ...); // every change, in order
await client.connection.awaitOnline(budget: const Duration(seconds: 30));
await client.connection.attempt(); // try again now
- offline (
unreachable,noAtServer): the client serves its local store; writes queue and sync when the atServer is reached. - refused (
revoked,unauthenticated,invalidEnrollment,enrollmentNotApproved): the atServer rejected the keys — the app decides. The one refusal that throws (AtOpenRefusedException) is the first open of these keys on a device: nothing is held locally, so there is nothing to serve. Keys holding only a pending enrollment throwAtEnrollmentPendingException— callresumeEnrollment. stopped: whatcurrentreads afterstop();changesis done.
Services — both belong to the client, start with it and stop with it:
client.syncServiceruns on its own: on every stats notification from the atServer and everysyncIntervalMins.sync()expedites a round,isInSync()asks the atServer fresh,addProgressListenerobserves. TheAtClientManagergetters for these services are deprecated.client.notificationService.subscribe(regex:, shouldDecrypt:)— the monitor starts on the firstsubscribe(or 30 s after creation) whenmonitorAutoStartis true.subscribe()returns before the monitor is connected: wait forlistening(orcurrentListenerStateStream) before a notify you expect to receive, or the send reportsdeliveredand nothing arrives.
Own it, then stop it:
- One live client per (atSign, enrollment) and per storage location in a
process: opening again while one is live throws
StateError—stop()the previous client first. Another enrollment of the same atSign opens beside it on its own store. await client.stop()stops sync, notifications and the connection, and closes storage built withclosedByClient: true. It does not drain: a sync round in flight is abandoned and its work stays queued for the next client on that store. Wait forisInSync()first if the writes must land.- A stopped client is not restarted — open the atSign again.
AtClientManager.getInstance().use(client)makes a client current for code that readsAtClientManager.getInstance().atClient;usedoes not stop the previous current client.setCurrentAtSign/fromAuthSessionare deprecated.reset()is a test hook, not a logout.
Read references/15-client-lifecycle.md
for the full verb list, the storage object, every AtConnectionCause, the
service lifecycles and the shutdown checklist.
12. Domain-Object Checklist
class Todo {
String title; bool done; DateTime due;
Todo(this.title, {this.done = false, required this.due});
Map<String, dynamic> toJson() => {'title': title, 'done': done, 'due': due.toIso8601String()};
factory Todo.fromJson(Map<String, dynamic> j) => Todo(j['title'] as String,
done: j['done'] as bool, due: DateTime.parse(j['due'] as String));
}
// Call once at startup — before any atClient.collection() call
AtCollection.registerFactory<Todo>(Todo.fromJson, typeTag: 'Todo');
typeTagmust be a string literal — neverT.toString()(breaks in release builds)- Primitives (
String,Map<String,dynamic>,List,Uint8List) need no registration - Use
typeTag: 'binary'forUint8List
Read references/08-domain-object-patterns.md for polymorphic types, schema evolution, and the full re-registration rules.
13. Architecture Decision: AtCollection vs Notifications+SQLite
AtCollection<T> |
Notifications + SQLite | |
|---|---|---|
| Data shape | Typed records, discrete items | High-frequency events / telemetry |
| Persistence | Synced via atServer | Local-only, from notifications |
| Volume | Low-medium (hundreds-thousands) | High (per-second metrics, logs) |
| Example | Todos, notes, contacts | Live dashboard, analytics |
These patterns are complementary and can coexist in the same app.
Sending / receiving notifications (the fire-and-forget side):
// Send — returns the notification id; body is usually JSON
await atClient.notificationService.send(
to: '@bob'.toAtsign(),
namespace: 'sample.my_app',
body: jsonEncode(payload),
expiration: const Duration(minutes: 5), // short TTL for telemetry
);
// Receive — subscribe to a namespace regex; decrypt encrypted payloads
atClient.notificationService
.subscribe(regex: r'sample\..*\.my_app', shouldDecrypt: true)
.listen((n) => handle(n.value));
Read references/10-architecture-guide.md
for the full decision guide and the dockerstats Notifications+SQLite example.
See packages/at_client/example/bin/notifications.dart for a minimal
send/subscribe walkthrough.
14. Testing Without a Live atServer
import 'package:at_client/at_client.dart'; // test hooks are re-exported here
final ctrl = StreamController<AtNotification>.broadcast();
final coll = collectionWithInjectedNotifications<Todo>(
fakeAtClient, 'todos.my_app', const Duration(days: 7),
notifications: ctrl.stream, fromJson: Todo.fromJson, typeTag: 'Todo',
);
clearFactoriesForTest(); // call in setUp() to prevent cross-test pollution
Available helpers: collectionWithInjectedNotifications,
collectionWithInjectedDataEvents,
collectionWithInjectedBoth, handleNotificationForTest,
handleDataEventForTest,clearFactoriesForTest,
clearMissingFactoryWarningsForTest
Read references/09-testing-patterns.md for
the complete test template including the correct AtNotification constructor
and MockAtClient stubs.
15. RPC — Request/Response Between Atsigns
For "call another atsign and get an answer back" (actions and queries — not
data), use AtRpc/AtRpcClient from at_client:
// Requester — call() sends, awaits the success response, returns its payload
final client = AtRpcClient(serverAtsign: '@server', atClient: atClient,
baseNameSpace: 'my_app', domainNameSpace: 'route_planning');
final answer = await client.call({'from': 'A', 'to': 'B'});
// Responder — handler's return value is sent back; thrown errors become nacks
final rpc = AtRpc.server(atClient: atClient, baseNameSpace: 'my_app',
domainNameSpace: 'route_planning', requestHandler: handleRequest,
allowList: {'@requester'.toAtsign()}, allowAll: false,
enableRequestMutex: false);
rpc.start();
Requests from atsigns not on allowList are discarded before your handler
runs. Persist durable results via AtCollection<T>, not RPC payloads.
Read references/13-rpc.md when implementing request/response between atsigns — response types, retries, expiry, and the multi-instance mutex.
16. Headless Agents & Multi-Instance Coordination
Authenticate a UI-less process (agent, daemon, CLI) in one line with
CLIBase from at_cli_commons:
final AtClient atClient =
(await CLIBase.fromCommandLineArgs(args, namespace: 'my_app')).atClient;
- Every process needs its own storage (
HiveAtClientStorage(atSign:, storagePath:), or the deprecatedhiveStoragePath) — one store is held by one live client and a second open on it is refused. UseDirectory.systemTemp.createTempSync('agent_')per instance, andclient.stop()when the process is done. - Multiple instances of one agent coordinate via an immutable-mutex race
(
Metadata()..immutable = true+ remote put; the losingput()throws — there is no typed exception, inspect the message for'immutable'), or run stateless withAtsign(a).open(..., serviceFactory: ServiceFactoryWithNoOpSyncService())(fromat_cli_commons, NOTat_client) + remote operations.
Read references/14-multi-agent.md when building headless agents, daemons, or anything that runs more than one instance.
17. Remote vs Local atServer Operations
By default (AtClientPreference.remoteLocalPref = RemoteLocalPref.localOnly)
put/get/delete hit the local secondary and sync in the background —
the right default for app data. (Reading another atsign's non-cached: key is
always a remote lookup.) For coordination keys and read-your-write
consistency, target the cloud secondary per operation:
await atClient.put(key, value,
putRequestOptions: PutRequestOptions()..useRemoteAtServer = true);
await atClient.get(key,
getRequestOptions: GetRequestOptions()..useRemoteAtServer = true);
await atClient.get(key, // force-refresh another atsign's key past caches
getRequestOptions: GetRequestOptions()..bypassCache = true);
AtCollection<T> takes no per-operation options — collection ops follow the
client-wide remoteLocalPref (set RemoteLocalPref.remoteOnly to route the
whole client, collections included, to the remote atServer). Remote ops cost a
round-trip and fail offline; scope per-operation overrides to infrastructure
keys (give those a distinct key-name prefix within your app's namespace, e.g.
lock.).
Read references/12-remote-atserver.md when an operation must see or produce server-side truth immediately.
18. Deprecated — Do Not Use
| Avoid | Use instead |
|---|---|
AtCollectionModel / AtJsonCollectionModel |
AtCollection<T> via atClient.collection(...) |
at_common_flutter |
at_client_flutter |
at_backupkey_flutter |
Copy at_client_flutter backup-key snippet |
at_invitation_flutter |
Copy at_client_flutter invitation snippet |
at_sync_ui_flutter, at_theme_flutter |
Deprecated — do not use |
at_chat_flutter, at_contacts_flutter, at_contacts_group_flutter, at_events_flutter, at_follows_flutter, at_location_flutter, at_notify_flutter |
In migration — copy example code instead |
AtClientManager.setCurrentAtSign(...), fromAuthSession(...) |
Atsign(a).open(...) then AtClientManager.getInstance().use(client) |
AtAuthRequest, AuthResponse, AuthService, FlutterEnrollmentService (at_client_flutter 1.x); importing at_auth in an app |
The dialogs hand back the AtClient (§10); the Atsign verbs (§11) |
AtClientPreference.hiveStoragePath, .commitLogPath |
HiveAtClientStorage(atSign:, storagePath:) passed as storage |
Read references/01-deprecation-guide.md
for the full migration table from old AtCollectionModel patterns to
AtCollection<T>.
19. Canonical Examples & Future Scope
packages/at_client/example/bin/collections_domain_objects.dartpackages/at_client/example/bin/collections_subcollections.dartpackages/at_client/example/bin/collections_todos.dart— terminal-UI (TUI) todos app usingAtCollection+query().watch()(Dart/CLI reference)packages/at_client/example/bin/notifications.dart— minimalNotificationServicesend/subscribepackages/at_client_flutter/examples/todos/— canonical Flutter reference apppackages/at_client_flutter/examples/dockerstats/— notifications + SQLite
If asked about migrating from atClient.put() / atClient.get() to
AtCollection<T>: Both APIs share the same underlying atServer keystore but
use different key-naming conventions — AtCollection data will not appear in
raw get() queries and vice versa. Migration is non-trivial: read existing
data with the raw API and re-write it through AtCollection<T>. Always test
in a staging environment before touching production data. A formal migration
guide is coming in skill v2.0.