Part 2 — the device
Opening an engine
Section titled “Opening an engine”const engine = await SkmEngine.open({ memberId: me.id, deviceId, transport, store, keyStore, kinds, // optional deviceLabel: "sam's laptop", autoGrant: true, // default: grant other devices' key gaps during sync sign: true, // default: sign every record requireSignatures: true, // default: reject unsigned/unverifiable records pushDebounceMs: 250, // default: how long a write waits for its neighbours retry: { attempts: 4, baseMs: 250, maxMs: 8_000 }, metadata: undefined, // partial-sync metadata — see Part 10 now: () => Date.now(), onEvent: (evt) => console.debug(evt),});open generates this device’s keypairs on first run (stored in the KeyStore),
registers the public halves with the relay, and loads any keyrings it already
holds.
The device id
Section titled “The device id”const deviceId = localStorage.getItem('skm-device') ?? SkmEngine.newDeviceId();localStorage.setItem('skm-device', deviceId);Stable across restarts, unique per installation. A device id already bound to
public keys is never rebound — that is the redirect attack — so registering an
id with different keys is refused with 409 and status.deviceOk goes false.
The correct response to a 409 is a new device id, never a retry.
Choosing a store
Section titled “Choosing a store”import { bestStore, IdbGraphStore, MemoryGraphStore } from '@skm/client';import { NodeSqliteGraphStore } from '@skm/client/node';
await bestStore({ name: me.id }); // browser: OPFS-SQLite, else IndexedDBnew IdbGraphStore('skm-' + me.id); // browser: IndexedDB, explicitly (a db name)new NodeSqliteGraphStore({ path: 'data/sam.db' }); // Node / Electron mainnew MemoryGraphStore(); // tests, ephemeral agentsOPFS is one tab at a time.
bestStorefalls back to IndexedDB in a second tab, so two tabs of one app hold two different local stores. They converge through the relay, but neither sees the other’s unsynced writes. If that matters to your UI, useIdbGraphStoreeverywhere, or run the engine in a SharedWorker.
Choosing a key store
Section titled “Choosing a key store”import { IdbKeyStore, MemoryKeyStore } from '@skm/client';import { FileKeyStore } from '@skm/client/node';
new IdbKeyStore(); // browser (optional db name, default 'skm-keys')new FileKeyStore('data/sam-keys'); // server-side agentsnew MemoryKeyStore(); // tests
FileKeyStoreis plaintext on disk (mode 0700, one file per slot, and adestroy()for tearing a device down). Right for a server-side agent whose disk is already the trust boundary; wrong for a laptop, where the OS keychain is the answer.KeyStoreis three methods —get(slot),set(slot, value),keys(prefix)— so wrapping Keychain, DPAPI, or libsecret is about thirty lines.
Declaring kinds
Section titled “Declaring kinds”import { defineKinds } from '@skm/client';import { z } from 'zod';
const kinds = defineKinds({ note: z.object({ body: z.string().max(10_000) }), task: z.object({ title: z.string(), done: z.boolean(), due: z.number().optional() }),});A decrypted record is untrusted input. The relay cannot validate what it
cannot read, and any member holding the key can craft any record — so the
device validates after opening and skips what fails. Without a KindRegistry,
a member with the key can write anything into every other member’s store.
A validator can be a zod schema or a plain predicate:
defineKinds({ blob: (data: unknown) => typeof (data as any)?.ref === 'string' });The second argument decides what happens to a kind this build has never heard of:
defineKinds({ ... }, 'accept') // default — a newer client's records still syncdefineKinds({ ... }, 'skip') // stricter, for a closed record set'accept' is the default because during a rolling deploy the alternative is an
old tab quietly eating new data.
Transport
Section titled “Transport”new HttpTransport({ baseUrl: '/api/skm', headers: async () => ({ authorization: `Bearer ${await session.token()}` }), credentials: 'same-origin', // only meaningful same-origin timeoutMs: 30_000, fetch: myFetch, // for shells whose fetch is not global});headers is called per request, so a rotating token stays fresh.
Failures surface as TransportError with status and retryable (true for
network failures, 429, and 5xx). Transport is an interface if you want to run
skm over something that is not HTTP.