at_client
The non-platform-specific client SDK for building apps on the
Atsign Protocol. An atSign owns a personal server (the
atServer); at_client talks to that server on its owner's behalf,
transparently handling key management, end-to-end encryption, sync, and
notifications.
at_client runs on both Dart (CLI / server) and Flutter
(mobile / desktop / IoT). It is intentionally platform-neutral:
the actual onboarding dialogs, secure key storage, and CLI scaffolding
live in sibling packages that depend on at_client. Flutter web
is not supported — atSign onboarding and key storage rely on
platform plugins that don't have web implementations today.
Which package do I actually want?
| If you're building… | Start with |
|---|---|
| A Dart CLI or server app | at_cli_commons for boilerplate + at_onboarding_cli to provision atSigns |
| A Flutter app (mobile/desktop/IoT) | at_client_flutter — ships pre-built onboarding / APKAM / keychain widgets (web not supported) |
| Understanding the atSign lifecycle | at_auth — platform-neutral onboarding / authentication core with a detailed lifecycle writeup |
Core surface
The AtClient interface (lib/src/client/at_client_spec.dart)
is the main entry point once authentication is complete.
collection<T>(namespace, defaultExpiration, {fromJson, typeTag, eventSource, cleanupOrphansOnCreation})— returns aFuture<AtCollection<T>>.fromJsonandtypeTagtravel together; pass either both or neither. AtCollections hide the low-level keystore plumbing and let you work directly with your own domain objects and types (see Collections below).notificationService— fire-and-forget and pub/sub messaging (lib/src/service/notification_service.dart)syncService— background sync between the local store and the atServerput(AtKey, value)/get(AtKey)/delete(AtKey)— low level CRUD against the keystore. You should almost never need to do this if you are using AtCollections.
Examples
The API in at_client has evolved substantially over several years.
The authoritative examples of current usage are the worked programs
under these two directories — start there rather than with older
tutorials or blog posts:
-
Dart / CLI examples:
example/README.mdwalks through every program inexample/bin/— primitives, domain objects, polymorphic / binary collections, a full interactive TUI todos app, raw notifications, RPCs, and the dockerstats CLI publisher / subscriber. -
Flutter examples:
../at_client_flutter/example/— onboarding + auth UI walkthroughs (CRAM, .atKeys-file, keychain, APKAM).../at_client_flutter/example/todos/README.md— full shared-todos Flutter app. The idiomatic Flutter consumer ofAtCollection<T>— every common collection pattern (typedAtCollection, sub-collections, queries, watches, read receipts, sharing, schedule-via-availableAt) wired up the way a real app would. Wire-compatible with the CLI sibling so the two apps can share data live.../at_client_flutter/example/dockerstats/README.md— live telemetry dashboard. The deliberate counterpart totodos,dockerstatsis the canonical worked example of a pattern the SDK supports but doesn't impose: deliver via short-lived notifications, store in a relational database. It demonstrates the trade-off explicitly because mis-applyingAtCollection<T>to this shape of workload — a high-frequency stream of observations whose query / aggregation / windowing is the dominant design concern — would be wrong.
atSign lifecycle (short version)
Before an app can read or write, an atSign must be registered,
onboarded, and the app must authenticate. The detailed writeup
of all three phases — including why APKAM matters for "evil app"
protection — is in at_auth's README. The
summary is:
- Register an atSign (free at my.noports.com/no-ports-plans or paid/custom at my.atsign.com). Once you have a registered atSign, the application code needs to get the CRAM key for step 2 below. Typically this is done by the app, once it knows the atSign to be onboarded, requesting that an OTP for the atSign be sent to the registered owner's email address. (There are other processes possible for all of this, but this is typical.)
- Onboard the atSign exactly once: CRAM-authenticate, generate the
master keypairs, and write them to disk (
.atKeysfile for CLI) or the device keychain (Flutter). These master AtKeys are the root of trust — end users must back them up, losing them means losing the atSign. - Authenticate subsequent apps via APKAM enrollment: the app
requests only the namespaces it needs (e.g.
{'todos': 'rw'}), the master-keys holder approves, and the atServer issues a new scoped AtKeys set. Scoped keys can be revoked at any time; a compromised scoped key can only damage data in its granted namespaces.
In code: one import, four verbs
at_client owns the whole of that lifecycle. An app holds an AtKeysIo
(a .atKeys file, the platform keychain, or memory) and asks the atSign
for a client; every verb hands back an AtClient the app owns and stops.
import 'package:at_client/at_client.dart';
// Log in: a client on keys the app already holds. It comes back online,
// offline or refused, and says which; offline it serves its local store.
final client = await Atsign('@alice').open(
keys: FileAtKeysIo(filePath: (_) => '/keys/@alice_key.atKeys'),
preference: AtClientPreference()..namespace = 'todos');
client.connection.current; // online | offline | refused
client.connection.changes.listen((state) => print(state));
await client.connection.awaitOnline(); // wait, with a budget
// Onboard: activate a newly registered atSign with its CRAM secret. The
// keys it mints land in `keys`, and the client opens on them.
final owner = await Atsign('@alice').activate(
cramSecret: secret, keys: keys, preference: preference);
// Enroll: ask the atSign's owner to approve this app. The request is filed
// in `keys` as pending, so a restart resumes it rather than repeating it.
final pending = await Atsign('@alice').enroll(otp: otp, app: 'todos',
device: 'phone', namespaces: {'todos': 'rw'}, keys: keys,
preference: preference);
final enrolled = await pending.client(preference); // waits for approval
final resumed = await Atsign('@alice').resumeEnrollment(app: 'todos',
device: 'phone', keys: keys, preference: preference);
// Approve, from an enrolled client: the atSign's roster and passcodes.
final requests = await owner.enrollments.pending();
await owner.enrollments.approve(requests.first.enrollmentId!);
owner.enrollments.requests.listen((request) => print(request.appName));
final passcode = await owner.enrollments.otp();
// An app that keeps one current client for its screens makes it so.
AtClientManager.getInstance().use(client);
Three things are the platform's to supply, and every verb takes them the
same way: keys:, where the atSign's keys live (FileAtKeysIo, the
Flutter keychain, memory); storage:, the client's local store
(HiveAtClientStorage, or a bundle of your own); and lookUps:, how
the client reaches its atServer. The last is an AtLookUpFactory, a function
that builds every connection the client opens - its own, its sync's, its
monitor's - so a transport or a proxy convention is chosen once:
final client = await Atsign('@alice').open(
keys: FileAtKeysIo(filePath: (_) => '/keys/@alice_key.atKeys'),
storage: HiveAtClientStorage(atSign: '@alice', storagePath: dir, closedByClient: true),
preference: AtClientPreference()..namespace = 'todos',
lookUps: secureSocketLookUps(
config: SecureSocketConfig()..pathToCerts = '/certs',
onConnect: (connection) => connection.sendSync('from:@alice\n')));
With no lookUps, connections are TLS on TCP with the defaults;
secureSocketLookUps is that default, taking a SecureSocketConfig and an
onConnect run on each new connection before anything else (a proxy that
routes on from: is what that is for). A factory of your own can hand back
any AtLookUp. The preference's decryptPackets, pathToCerts and
tlsKeysSavePath are deprecated in favour of the factory's config.
Atsign.authenticatesAs(keys: ..., rootDomain: ...) answers which
enrollment a keys store authenticates as without building a client.
Flutter apps get the same verbs behind dialogs in
at_client_flutter; CLI apps get them behind
at_onboarding_cli's commands and
at_cli_commons' CLIBase.
AtClientManager.setCurrentAtSign and fromAuthSession, which built the
current client for the caller, are deprecated and go in 4.0: build the
client with a verb above and make it current with use.
Collections
For the common "CRUD on typed, shareable records" use case,
AtCollection<T> (lib/src/collections/collections.dart)
hides almost all of the AtKey / Metadata / notification-regex ceremony
behind a small set of verbs.
Sketch:
final todos = await atClient.collection<Todo>(
'todos.my_app', // fully-qualified namespace
const Duration(days: 7),
fromJson: Todo.fromJson,
typeTag: 'Todo', // wire-format identifier — required
);
final item = await todos.create(
obj: Todo('write readme'),
sharedWith: {'@bob'.toAtsign()},
);
item.obj.done = true;
await todos.update(item);
// Add @carol without rewriting the self copy:
await todos.updateSharedWith(item, {'@bob'.toAtsign(), '@carol'.toAtsign()});
await for (final e in todos.updates) {
print('updated: ${e.id} by ${e.owner}');
}
update / delete / create fire CItemUpdated / CItemDeleted
on the writing collection's event streams as soon as the local write
lands — no waiting for the network round-trip — so a UI using
Query.watch() redraws immediately. The same event re-fires on the
round-trip ~50–200 ms later (and ~10–30 ms excluding network transit
once fsync ships); Query.watch's delta path is idempotent so the
second occurrence is invisible.
AtCollection<T> executes reads on-device by default, against a
local copy that the at_client SDK keeps current via real-time
sync with the atServer. Value-level filtering is always on-device
— records are end-to-end encrypted between atSigns, so the server
never sees plaintext to filter on.
A composable Query<T> builder covers the common filter / sort /
paginate / aggregate patterns:
final overdue = todos.query()
.where((t) => !t.obj.done)
.where((t) => t.obj.due.isBefore(DateTime.now()))
.orderBy((t) => t.obj.due)
.thenBy((t) => t.obj.title) // tiebreak within same due date
.limit(20);
final list = await overdue.get(); // one-shot List
final live = overdue.watch(); // Stream<List<CItem<Todo>>>
final openCount = await todos.query().where((t) => !t.obj.done).count();
final byOwner = await todos.query().groupBy<Atsign>((t) => t.owner);
Queries are immutable values — build once, store, pass, fetch or
watch. watch() does incremental delta maintenance for
non-paginated queries (single-item read on each event, not a full
re-scan). For ad-hoc pipelines outside the builder's vocabulary,
getItemsAsStream().where(...) remains supported as an escape
hatch.
For typed, introspectable predicates that a future indexed
executor can push down to a secondary index, declare PathFields
on your domain type and use wherePath:
abstract class $Todo {
static final done = PathField<bool>(
path: ['obj', 'done'],
extract: (item) => (item.obj as Todo).done,
);
static final due = PathField<DateTime>(
path: ['obj', 'due'],
extract: (item) => (item.obj as Todo).due,
);
}
final overdue = await todos.query()
.wherePath($Todo.done.eq(false))
.wherePath($Todo.due.lt(DateTime.now()))
.get();
Multi-level parent → children → grandchildren joins use
watchWithTree:
final stream = posts.query().watchWithTree([
SubSpec<Comment>(
subName: 'comments',
subDefaultExpiration: const Duration(days: 30),
subFromJson: Comment.fromJson,
subTypeTag: 'Comment',
children: [
SubSpec<Reply>(
subName: 'replies',
subDefaultExpiration: const Duration(days: 30),
subFromJson: Reply.fromJson,
subTypeTag: 'Reply',
),
],
),
]);
// → Stream<List<TreeNode<Post>>> — branches['comments'] holds
// per-comment TreeNodes whose own branches['replies'] hold
// per-reply TreeNodes.
Timer-driven events for items written with availableAt
(scheduled visibility) and expiresAt (TTL):
// Fires when each scheduled item becomes visible.
todos.availableEvents.listen((e) {
print('Item ${e.id} just became available');
});
// Fires `leadTime` before each item expires — useful for
// reminder UIs that need to nudge the user before the atServer
// expires the record.
todos.expiringSoonEvents(leadTime: const Duration(minutes: 30))
.listen((e) {
print('Item ${e.id} expires at ${e.expiresAt}');
});
Read receipts ship built-in — one call on each side, no app-level bookkeeping:
// Reader side: idempotent, no-op on self-owned items.
await incomingItem.markReadByMe();
// Owner side: who has read this? Maintained live via events.
final readers = await myItem.readBy; // Future<Set<Atsign>>
todos.readReceipts.listen((e) => print('${e.from} read ${e.id}'));
Sub-collections are AtCollection<U> instances scoped to a parent
CItem — comments on a blog post, line-items on an invoice — with
opt-in cascade-delete:
final comments = posts.subCollection<Comment>(
parent: post,
subName: 'comments',
defaultExpiration: const Duration(days: 30),
fromJson: Comment.fromJson,
typeTag: 'Comment',
);
await comments.create(obj: Comment('nice one'), sharedWith: {/* … */});
await posts.delete(post, cascade: true); // removes comments too
Worked examples (Dart / CLI):
example/bin/collections_primitives.dartexample/bin/collections_domain_objects.dartexample/bin/collections_generic.dart(polymorphic types)example/bin/collections_binary.dart(Uint8List)example/bin/collections_subcollections.dart(parent + sub with cascade)example/bin/collections_todos.dart(full interactive TUI)
For Flutter, the canonical reference app is
../at_client_flutter/example/todos
— same feature set as collections_todos.dart above, rendered
through the mobile / desktop widget stack the way a shipping app
would use it.
Key-length note. atServer keys are capped at 255 chars and
atSigns at 55. The absolute worst-case wire shape is the
cached-copy form
cached:<other>:<itemId>.<composedNs>@<self>, which fixes the
wrapper overhead at 118 chars (cached: + 55 + : + 55) and
leaves 137 chars for everything inside (item id + every
level of namespace). With 8-char auto-generated ids and 1 char
for the separator, composedNs is capped at 128 chars — a
budget enforced by subCollection(...) at construction time
with a hard ArgumentError, so oversized keys never reach the
wire. Plenty of room: with 1-char collection / sub-collection
names and a 15-char application namespace, the theoretical
ceiling is 11 levels (root + 10 nested sub-collections).
Crypto providers
By default, encrypted writes use the built-in legacy Atsign encryption
provider. Apps that need their own encryption configure
AtClientPreference.crypto with a CryptoConfig listing one or more
CryptoProvider instances. The SDK records the provider's id in the record's
appMetadata.providerId and uses it to route future decrypts back to the same
provider. appMetadata is the wire field for this: the SDK owns providerId
(routing) and isEncrypted; any entries a provider adds to
appMetadata.additional are provider-owned, opaque to the SDK, and visible to
the atServer as plaintext metadata.
A CryptoProvider is stateless and implements three members — everything it
needs arrives per call via CryptoContext (the client) and the AtKey:
class DemoCryptoProvider extends CryptoProvider {
static const providerId = 'demo-v1';
@override
String get id => providerId;
@override
Future<String> encrypt(
CryptoContext context, AtKey atKey, String plaintext) async {
// The SDK stamps providerId + isEncrypted for you. Carry anything decrypt
// needs (an IV, a key id, a format version) in appMetadata.additional.
atKey.metadata.appMetadata =
AppMetadata(providerId: id, additional: {'format': 'demo'});
return 'demo:$plaintext';
}
@override
Future<String> decrypt(
CryptoContext context, AtKey atKey, String ciphertext) async {
// Read back what encrypt stored, if you need it.
final _ = atKey.metadata.appMetadata?.additional?['format'];
return ciphertext.replaceFirst('demo:', '');
}
}
final preference = AtClientPreference()
..crypto = CryptoConfig(
defaultProviderId: DemoCryptoProvider.providerId,
providers: [DemoCryptoProvider()],
);
plaintext/ciphertext are opaque strings — for binary records (putBinary)
a Base2e15-encoded string, so treat them as bytes, not text. Throw an
AtException subclass (AtEncryptionException / AtDecryptionException) on
failure. Supply a fresh provider instance per atSign only if your provider
holds per-atSign state.
Per-write provider overrides use PutRequestOptions.cryptoProviderId;
notification overrides use NotificationService.send(..., cryptoProviderId:)
or NotificationParams.cryptoProviderId. For a runnable end-to-end example,
see example/bin/custom_crypto_provider.dart.
For compact examples of provider registration and per-write overrides, see
example/bin/custom_crypto_provider.dart,
test/at_client_impl_test.dart and
test/put_request_test.dart. For notification
provider selection, see
test/notification_service_test.dart.
For lazy-provider recovery behavior, see
test/crypto_runtime_test.dart.
Post-quantum cryptography
at_client can run every path an adversary could record today — data
shared between atSigns, an atSign's own data, and the secrets an enrollment
approval hands a new device — under post-quantum key establishment, and
authenticate with a post-quantum signature. It is opt-in per client, through
the preference's posture, which is fixed at construction:
final preference = AtClientPreference(posture: PqPosture.pqReady)
..namespace = 'todos';
The goals
- Close the harvest-now, decrypt-later hole. Anything recorded off the wire or off an atServer today — shared records, an atSign's own records, and the secrets handed to a newly approved device — is established under post-quantum key exchange, so a quantum computer later cannot open it. Authentication moves to a post-quantum signature for the same reason.
- Lose nothing. Every capability the SDK has — encrypt for another atSign, encrypt for another client of your own atSign, enroll a device, revoke one — works the same way under post-quantum keys.
- Every app upgrades on its own schedule. No flag day. Each record carries the id of the scheme that wrote it, a client keeps every older scheme it ever read, and a writer only ever uses a scheme every reader of that record supports. Upgrading adds read capability and takes nothing away.
- Crypto-agility. Algorithms are named, not assumed: the X-Wing hybrid today, pure ML-KEM-1024 beside it, and whatever comes next drops in as another provider without a migration of stored data.
The rollout ladder, and why it is a ladder
The default posture moves one stage per major version of at_client. An
app that names no posture rides the default; an app can name a later stage
at any time, or name legacy to stay put.
at_client |
Default posture | A client that names no posture |
|---|---|---|
| 3.x | PqPosture.legacy |
Byte-for-byte the pre-posture SDK: RSA authentication, legacy encryption, no post-quantum startup, reads no post-quantum data |
| 4.x | PqPosture.pqReady |
ML-DSA-65 authentication, publishes its key package and namespace keys, reads post-quantum data, still writes legacy so every peer can read it |
| 5.x | PqPosture.pqActive |
Writes post-quantum by default and refuses a legacy write |
The order follows from the one invariant above: a record may only be
written in a scheme every reader of it supports. So the whole population of
clients has to be able to read post-quantum data (pqReady) before any
client writes it by default (pqActive), and the stage that adds the
reading has to be rolled out before the stage that changes the writing. Two
majors give every app one release to become a reader and another to become
a writer, with the SDK's default carrying apps that never think about it.
Legacy key material is still minted at every stage, because when an atSign
can stop holding it is a question about every client of that atSign, not
about one build.
You do not have to wait for 4.x or 5.x. The stages are postures, and a posture is a construction-time choice, so a 3.x app names the stage it wants:
// Reads post-quantum data, authenticates with ML-DSA-65, writes legacy
// until every reader of your namespaces has done the same.
final ready = AtClientPreference(posture: PqPosture.pqReady)..namespace = 'todos';
// Writes post-quantum by default and refuses legacy writes: for a
// deployment whose every client already runs pqReady or later.
final active = AtClientPreference(posture: PqPosture.pqActive)..namespace = 'todos';
That is the two-release model for an app that wants to lead: ship pqReady
to all of its clients first, then ship pqActive. A posture is a floor for
what a client drives, never a downgrade of what its atSign already holds.
| Posture | Authentication | Data written | Reads post-quantum data |
|---|---|---|---|
PqPosture.legacy (default) |
RSA-2048 APKAM | legacy encryption | no: a record sealed to a namespace key is refused |
PqPosture.pqReady |
ML-DSA-65 APKAM; publishes a key package and the atSign's namespace keys | legacy encryption, so pre-quantum peers read it | yes |
PqPosture.pqActive |
ML-DSA-65 APKAM and an ML-DSA-65 data signing key | post-quantum by default; legacy writes refused | yes |
What each posture switches on:
- The
nskeydata path. Each namespace an atSign owns gets a key-establishment keypair, published as an APKAM-signed advertisement (public:__nskey.<namespace>@<atSign>). A writer establishes a content key to the recipient's namespace key and encrypts the record with AES-256-GCM; a reader that holds the namespace key's private half opens it. A sender follows whatever the recipient advertised, ordered byAtClientPreference.sealsToKeyAlgorithms. The X-Wing hybrid (ML-KEM-768 + X25519) is the default; pure ML-KEM-1024 is selected withkeyEstablishmentAlgorithms, and every build opens both. - Post-quantum enrollment. Under
pqReadyorpqActive,Atsign.enrollsubmits a request that advertises a key package, and the approving client (client.enrollments.approve) seals the atSign's secrets to it instead of wrapping them with RSA;enroll(keyExchangeMode: ...)overrides that per request. Alegacyclient cannot approve such a request and refuses before anything reaches the atServer. - ML-DSA-65 authentication. The enrollment's APKAM keypair is ML-DSA-65,
filed as typed material in the keys store while the flat legacy fields are
left as they were. A client whose posture asks for a stronger
authentication key than its enrollment holds re-enrolls itself at its
first start and comes up on the new enrollment. This needs an atServer
that verifies ML-DSA signatures; a
legacyclient makes no such demand. - Signed advertisements and key packages. Everything a peer relies on is carried in a signed envelope and verified before use; the signing keys chain to a per-atSign signing root that only fully privileged enrollments hold.
pqActive is for a deployment that controls every client of its
namespaces and has seeded them: a destination with no published namespace
key is refused rather than written with the legacy provider. The design,
the acceptance catalogue and the decision log are under
docs/projects/pq/.
Further reading
Libraries
- at_client
- at_client_mixins
- sqlite
- The SQLite-backed AtClientStorage implementations, including the
in-memory one. A separate import so
package:at_client/at_client.dartcarries no SQLite dependency.