decentrl/docs
SDK

Client API

The client instance provides methods for publishing events, managing contracts, querying state, and syncing.

The client is the primary interface for interacting with the Decentrl network. It's created from an app definition and handles all cryptographic operations internally. It is typed by that definition: event names, payloads, envelopes and state all come from your schemas and slices.

const client = app.createClient({
  mediatorDid: 'did:web:mediator.decentrl.io',
})

// Name the client's type when you need it
type MyAppClient = ReturnType<typeof app.createClient>

Identity

The client manages your cryptographic identity through client.identity:

// Create a new identity (generates keys, DID, registers with mediator)
await client.identity.create({ alias: 'alice', mediatorDid: 'did:web:mediator.decentrl.io' })

// Access current identity
const identity = client.identity.getIdentity()
// { did, alias, mediatorDid, mediatorEndpoint, keys, mediatorContract }

// Serialize for persistence
const serialized = client.identity.serialize()

// Load a previously serialized identity
client.identity.load(serialized)

// Listen for identity changes
const unsub = client.identity.onChange((identity) => { ... })

Publishing Events

const { eventId } = await client.publish('message', {
  text: 'Hello Bob',
  threadId: crypto.randomUUID(),
})

publish is typed by the app: the event type must be one of your events, and data is that schema's input (z.input), so fields with defaults may be left out.

Under the hood, publish:

  1. Validates the payload against the Zod schema, once; the parsed output is what is sent and reduced (an invalid payload throws SCHEMA_VALIDATION_FAILED before anything is sent)
  2. Evaluates tag templates from the event definition
  3. Signs the event envelope, including the event ID, with Ed25519
  4. Encrypts with the contract's key for today's epoch and sends to recipient's mediator
  5. Re-encrypts with storage key and saves on own mediator with encrypted tags
  6. Updates local state through the matching reducer
  7. Returns { eventId }

Options:

await client.publish('message', data, {
  recipient: bobDid,    // send to specific recipient
  ephemeral: true,      // don't store locally (typing indicators, presence)
  eventId,              // reuse to retry; a random UUID when omitted
})

Retrying

A publish that throws may still have reached the recipient or your mediator. The error (DecentrlSDKError, code PUBLISH_FAILED) carries the event ID in details.eventId. Publish again with it: the recipient stores and applies the event once, and your mediator keeps one copy. A retry without it creates a second event.

try {
  await client.publish('message', data, { recipient: bobDid })
} catch (error) {
  const { eventId } = (error as DecentrlSDKError).details as { eventId: string }
  await client.publish('message', data, { recipient: bobDid, eventId })
}

Event IDs are 1–128 characters from A-Z a-z 0-9 . _ : -, unique per sender. Received events carry the authenticated sender and the signed ID in envelope.meta.senderDid and envelope.meta.eventId, and the client applies each (sender, event ID) once — across reloads too, with persist.

What meta Guarantees

Only meta.senderDid, meta.eventId and (on group events) meta.groupDid are authenticated. meta.timestamp is the sender's claim, in unix ms. Everything in data is what the sender wrote, so never trust identity fields in a payload (sender, createdBy, authorDid): reducers take who sent an event from meta. See Who Sent It.

Validation

Every private event that reaches the state is validated first, wherever it comes from (live delivery, WebSocket push, stored events, query). An event of an unknown type or with data that fails its schema is dropped and logged. A reducer that throws is logged and skipped. Since the parsed output is what is sent, a schema must accept its own output: defaults and stripped keys are fine, transforms are not.

Public Events

Publish signed, unencrypted public events. Like publish, publishPublic is typed by your publicEvents:

// Publish a public event (validated against publicEvents schema)
const { publicEventId, eventId } = await client.publishPublic('blog.post', {
  title: 'Hello World',
  body: 'My first public post',
})

// Retry safely: the same eventId returns the existing post instead of a second one
await client.publishPublic('blog.post', post, { eventId })

// Delete a public event (takes the mediator's publicEventId, not the eventId)
await client.deletePublicEvent(publicEventId, 'blog.post')

Under the hood, publishPublic:

  1. Validates the payload against the public event's Zod schema
  2. Evaluates tag templates from the definition
  3. Signs { event, tags, timestamp, event_id } with Ed25519
  4. Sends a ONE_WAY_PUBLIC command to the mediator; if that event_id was already published, the mediator returns the existing post
  5. Updates local state through the matching public: reducer
  6. Returns { publicEventId, eventId }

In reducers, meta.eventId is the signed event ID, meta.publicEventId is the mediator's row ID, and meta.timestamp is the publisher's signed time in unix ms.

Querying Public Events

Read public events from any identity — no contract or identity required. Only verified events come back:

// One page of a publisher's public events, newest first
const result = await client.queryPublicEvents({
  publisherDid: 'did:decentrl:alice:...',
  tags: ['featured'],
  afterTimestamp: Date.now() - 7 * 86_400_000,   // unix ms
  pagination: { page: 0, pageSize: 20 },
})
// result.data — PublicEventOf<App>[]: typed envelopes { type, data, meta, tags }
// result.pagination — the mediator's { page, pageSize, total }, dropped events included
// result.dropped — { forged, unverifiable, unknownType, invalid, malformed }

for (const post of result.data) {
  if (post.type === 'blog.post') {
    console.log(post.data.title, post.meta.publisherDid, new Date(post.meta.timestamp))
  }
}

An event is accepted only when it is signed by the publisher, is one of your declared publicEvents types, and its data passes the schema (data is the schema's output). Everything else is counted in dropped by reason. A publisher whose DID yields no signing key gets nothing accepted (unverifiable), and posts from before event IDs can't be verified either. pagination is the mediator's own paging, so total still tells you where the end is when some rows were dropped. Timestamps in the options and in meta are unix ms.

Reading a Publisher's State

readPublicState folds a publisher's newest 500 verified public events, oldest first, through your app's own public: reducers — the same reducers that build your own profile and posts. No hand-written parsing:

const { profile, posts } = await client.readPublicState('did:decentrl:bob:...')

Options: tags reads only events with these tags (e.g. { tags: ['post'] } for a feed), and mediatorEndpoint skips resolving the publisher's mediator. It returns the app's state shape; slices without public: reducers stay at initial. In React, use usePublicState.

Subscribing to Public Events

For continuous polling of an external identity's public events:

const { unsubscribe } = client.subscribeToChannel({
  did: 'did:decentrl:alice:...',
  pollIntervalMs: 10000,
})

// New events are signature-verified, schema-validated,
// and dispatched to channel: reducers; the rest are dropped

// Stop polling
unsubscribe()

Public Event Listeners

// Listen for public events (both own and channel); envelopes are typed and narrow by `type`
const unsub = client.onPublicEvent((envelope) => {
  console.log(envelope.type, envelope.data, envelope.meta.publisherDid)
})

Contracts

Manage communication contracts through client.contracts:

// Send a contract request to another identity
await client.contracts.request(bobDid)

// Query pending inbound contract requests (group invitations are not among them)
const pending = await client.contracts.getPending()

// Accept a pending contract request
await client.contracts.accept(
  pending[0].id,
  pending[0].encryptedPayload,
  pending[0].requestorEphemeralPublicKey,
)

// Decline a pending request (it leaves your inbox)
await client.contracts.decline(pending[0].id)

// Refresh contracts from mediator
await client.contracts.refresh()

// Contracts this device can use (it holds their keys)
const contracts = client.contracts.getActiveContracts()

// Contracts this device has no keys for: request again to reconnect
const unkeyed = client.contracts.getUnkeyedContracts()

// Find a specific contract by DID
const contract = client.contracts.getContractByDid(bobDid)

// Revoke silently: Bob isn't told, and his messages are refused from now on
await client.contracts.revoke(bobDid)

// Listen for contract changes
const unsub = client.contracts.onChange(() => { ... })

getPending leaves group invitations out: accepting one as a plain contract would never join the group. List them with client.groups.invitations().

Contracts don't expire. There's at most one per pair: requesting again replaces the old contract with a new one. Each contract's keys (its key state) live on the device that made or accepted it; with persist they survive reloads. A device without them sees the contract as unkeyed.

State

Access reactive state derived from your reducers. It's typed from your slices' initial values:

// Get the current state
const state = client.getState()
// { messages: Message[], reactions: ..., ... }

// Subscribe to state changes
const unsub = client.subscribe((state) => {
  console.log('Messages:', state.messages.length)
})

Querying Events

// Query stored events with filters
const result = await client.query({
  tags: ['thread.abc'],
  afterTimestamp: Date.now() - 86_400_000,   // unix ms: when your mediator stored the event
  pagination: { page: 0, pageSize: 50 },
})
// result.data — EventOf<App>[]: validated envelopes, discriminated by `type`
// result.pagination — { page, pageSize, total }

// Query and process events through reducers (each applied once)
const { processed, total } = await client.queryAndProcess({
  tags: ['thread.abc'],
})

// Listen for individual events as they are applied (validated, once)
const unsub = client.onEvent((envelope) => {
  if (envelope.type === 'message') {
    console.log(envelope.data.text, envelope.meta.senderDid)
  }
})

query leaves out rows that don't fit your schemas. afterTimestamp and beforeTimestamp are unix ms and filter on when your own mediator stored the event, not on the sender's meta.timestamp.

Groups

Groups (DCTRL-0006) are available through client.groups when the transport provides them. The client builds them for its own direct transport; the extension transport provides them when the app asks for the groups permission. Every call throws GROUPS_NOT_SUPPORTED otherwise; check client.groups.isSupported first.

A group carries the app's own event types. publish is typed and validated like client.publish, and group events are folded into the state through the same reducers, with meta.groupDid set. Deletions go through the group:deletion reducer.

// Alice creates a group and invites Bob
const { groupDid } = await alice.groups.create({ alias: 'book-club', history: 'full' })
await alice.groups.invite(groupDid, bobDid)

// Bob accepts. Alice's next upkeep adds him and delivers his key
const [invitation] = await bob.groups.invitations()
await bob.groups.acceptInvitation(invitation.pendingId)

// Once the group is keyed for Bob (see getGroups), he can post

// Post one of the app's events: resolves once it is folded into the state
const { eventId, seq } = await bob.groups.publish(groupDid, 'forum.post', {
  threadId: 'intro',
  text: 'Hello everyone',
})

// A page of one thread, by group tag: typed and validated, not folded
const page = await alice.groups.read(groupDid, { tags: ['thread.intro'], limit: 50 })

if (page.status === 'ok') {
  for (const post of page.events) {
    console.log(post.meta.senderDid, post.data.text)
  }
}

// Delete it: pass the event as read, or where it sits with its id
await bob.groups.delete(groupDid, { seq, senderDid: bobDid, eventId })

Listing Groups

// The groups as last read: synchronous and stable until they change
const groups = client.groups.getGroups()
// [{ groupDid, alias, role: 'admin' | 'member', status: 'joined' | 'requested', keyed, handoverOffered }]

const unsub = client.groups.onChange(() => render(client.groups.getGroups()))

// Read them again
await client.groups.refresh()

A group is requested until it lets you in, and keyed once a key arrived. Only joined, keyed groups are followed: read on every sync and watched for hints. handoverOffered means the admin offered to hand the group over to you (acceptHandover).

Joining and Leaving

// Invitations to groups not yet joined
const invitations = await client.groups.invitations()   // [{ pendingId, groupDid, adminDid }]
await client.groups.acceptInvitation(invitations[0].pendingId)
await client.groups.declineInvitation(invitations[0].pendingId)

// Ask to join through the group's mediator; the note is sealed so only members can read it,
// and the admin or any approver may let you in
await client.groups.requestToJoin(groupDid, { note: 'Alice sent me' })
await client.groups.cancelJoinRequest(groupDid)

await client.groups.leave(groupDid)

// The group as its mediator knows it: admin chain, members, key version, history policy
const view = await client.groups.view(groupDid)

Posting and Deleting

client.groups.publish(groupDid, type, data, { eventId? }): Promise<{ eventId: string; seq: number }>
client.groups.delete(groupDid, eventOrRef): Promise<void>

publish validates data against the event's schema, evaluates its groupTags templates, posts it, then reads the group, so the post is in the state when it resolves. It is not applied optimistically. Pass eventId to retry a failed post without a second one. When the post hits STALE_GROUP_KEY (a newer key is waiting among the pending 1:1 events), the client takes the pending events and retries once with the same eventId.

delete takes an event read from the group (its meta.groupSeq, senderDid, eventId and groupTags are used), or a GroupEventRef: { seq, senderDid, eventId, groupTags? }. Authors can delete their own events while they are members; the admin can delete any, and a grantee with delete any non-staff member's within its reach (see Staff). Other members fold the deletion through group:deletion. client.groups.onDeletion(listener) hears each deletion as it is folded, for views the state doesn't cover (paged reads).

Reading Pages

client.groups.read(groupDid, {
  tags?: string[],                         // events carrying any of these group tags
  direction?: 'forward' | 'backward',      // with tags; forward (oldest first) by default
  cursor?: GroupReadCursor,                // where the previous page stopped
  limit?: number,
}): Promise<GroupEventsPage>
// { status: 'ok', events, deletions, cursor, hasMore } | { status: 'removed' | 'deleted' }

read returns one page of the app's events, validated and typed (unknown types and invalid data are left out), and does not fold them into the state. Without tags it reads newest first. Pass the returned cursor to read the next page. removed and deleted mean you can no longer read the group.

Fold or page. Fold what belongs in app state: small groups, chats, anything the app needs whole. Read pages with read (or useGroupEvents) for large streams, like a forum's threads, and keep such events out of your reducers.

Administering

These work on groups this device administers, except where staff may too:

await client.groups.invite(groupDid, did)
const requests = await client.groups.pendingJoinRequests(groupDid)   // [{ pendingId, requesterDid, note? }]
await client.groups.approve(groupDid, requests[0].pendingId)
await client.groups.decline(groupDid, requests[0].pendingId)
await client.groups.remove(groupDid, did)                            // and rotates the key
await client.groups.rotate(groupDid)
await client.groups.handOver(groupDid, did)                          // the successor calls acceptHandover
await client.groups.acceptHandover(groupDid)
await client.groups.deleteGroup(groupDid)

An invitee who accepted is added to the group by the admin's next upkeep, which also delivers keys members still need and finishes rotations. approve adds the requester right away; its keys wait on the group's mediator, and the requester's app takes them on its next sync.

Counts and Presence

A group's mediator can count events without anyone reading them (DCTRL-0006 §7.24), and say who is connected (§7.25):

const counts = await client.groups.counts(groupDid, [
  { id: 'news', tag: 'forum.news' },                  // every event at or under forum.news
  { id: 'bobs', tag: 'forum', senderDid: bobDid },    // bob's, anywhere under forum
  { id: 'new', afterSeq: lastSeen },                  // everything since a seq
])
// { news: 57, bobs: 12, new: 3 }

const online = await client.groups.online(groupDid)   // DIDs subscribed right now

Counts cover live events this member may read, under every key version it holds. The mediator's tags carry no meaning to it, so a count of forum.news counts posts, edits and anything else tagged there; an app that wants to count one kind of event gives it a tag of its own. Presence is the mediator's WebSocket state: a member is online while its app follows the group's hints.

Staff

The admin can give members capabilities (DCTRL-0006 §4.7): delete other members' events (everything, or only those tagged under given prefixes), remove members, and approve join requests. The mediator checks every staff action; nobody but the admin can grant, rotate, hand over or delete the group, and staff can never act on the admin or on each other.

// The admin: a moderator for the news section who also lets people in and removes them
await client.groups.setGrant(groupDid, modDid, {
  delete: ['forum.news'],   // or 'all'
  remove: true,
  approve: true,
})
await client.groups.revokeGrant(groupDid, modDid)   // takes everything away

// The admin: a member its device cannot account for (no trusted admission, not its own add)
// gets no new keys (v0.6); staff.unaccounted lists them. Check who it is, then:
await client.groups.vouch(groupDid, memberDid)   // it gets the keys it missed; or remove it

// Anyone: who runs the group, from the grant log this member verified
const staff = await client.groups.staff(groupDid)
// { adminDid, incomingAdminDid?, members: [{ did, capabilities: { delete?, remove?, approve? }, deletePrefixes? }], log, unaccounted? }

// Whether to show a delete button on an event, as the mediator will judge it
import { canDeleteGroupEvent } from '@decentrl/sdk'
canDeleteGroupEvent({ staff, viewerDid: myDid, authorDid: event.meta.senderDid, groupTags: event.meta.groupTags ?? [] })
  • Scoped deletes cover events carrying a group tag at or under a prefix: forum.news reaches forum.news.t1, not forum.newsletter. deletePrefixes shows only where this device can read them: the admin sees every grantee's, a grantee its own.
  • Moderators use the same calls as the admin: delete, pendingJoinRequests, approve, decline and remove. A moderator's remove takes the member out at once; the key is rotated at the admin's next upkeep. Removal is not a ban: the member may ask to join again.
  • log holds recent approvals, declines and removals, as the mediator recorded them (unsigned), for the admin and staff; other members get an empty list.
  • In extension mode, staff actions need the groups.admin permission, like the admin's; staff needs only groups.

Sync and Upkeep

While sync runs (startSync), each tick reads every followed group from where the state stops. Hints from the group mediators' WebSockets (or the extension's pushes) trigger a read in between. The admin's upkeep (adding members who accepted, delivering keys, rotating) runs on its own interval while sync runs, at most once a minute per group, and never holds up a tick. With a direct transport, an admin whose app isn't syncing adds nobody and delivers no keys; the extension runs the upkeep itself.

Persistence

With persist, the client keeps its state across reloads in localStorage:

const client = app.createClient({
  mediatorDid: 'did:web:mediator.decentrl.io',
  persist: { key: 'myapp', appVersion: '3' },
})
KeyHolds
${key}:identityThe identity (direct transport)
${key}:key-states, ${key}:ephemeral-keysContract keys
${key}:snapshotThe state, the keys of the events it contains, a cursor on your mediator's storage time, and a cursor per group (groupCursors)
${key}:groups:${did}Group keys and join status, per identity (direct transport)
${key}:group-admins:${did}The state of each group this device administers, including the group identity's private keys

On load the snapshot is used only if it belongs to the same identity, the same set of slice keys and the same appVersion; otherwise the state is rebuilt from initial by replaying your stored events (and your own public events). Startup then reads stored events from 5 minutes below the cursor; events already in the snapshot are recognised and skipped, so each is applied exactly once.

Tabs sharing the storage merge group member state instead of overwriting each other's, and admin work on a group takes a Web Lock where the browser has them, so two tabs never administer the same group at once.

Bump appVersion when your reducers change meaning (a slice computed differently, a new field derived from old events), so old snapshots are discarded and the state is rebuilt. Adding or removing a slice discards it on its own. The old ${key}:state entry from earlier SDK versions is removed.

Sync

Start automatic background synchronization:

client.startSync({
  intervalMs: 3000,           // poll interval
  websocket: true,            // enable WebSocket push
  onError: (err) => console.error('Sync error:', err),
})

// Manual sync
await client.sync()

// Stop sync
client.stopSync()

// Connection status
const status = client.getConnectionStatus()
// 'disconnected' | 'connecting' | 'authenticating' | 'connected'

const unsub = client.onConnectionStatusChange((status) => { ... })

Lifecycle

// Reset client state (identity, contracts, groups, state), in memory and in storage
client.reset()

Subscribers (onEvent, onPublicEvent, subscribe, and the React hooks built on them) stay attached across a reset and hear the next identity's events. Loading another identity into a client (client.identity.load(...)) gives it its own state, and restarts sync for it when sync is running. Results still in flight for the previous identity — sync, a publish, queryAndProcess — are dropped instead of being applied to the new identity's state.