decentrl/docs
SDK

defineDecentrlApp

The declarative entry point that ties event schemas, state reducers, and transport into a single typed configuration.

defineDecentrlApp is the main entry point for creating a Decentrl application. You declare your event schemas with Zod and your state reducers — the SDK handles identity, encryption, routing, and storage. Every type in the client and the React hooks derives from this one call.

Basic Usage

import { defineDecentrlApp } from '@decentrl/sdk'
import { z } from 'zod'

interface Message {
  threadId: string
  text: string
  senderDid: string
  eventId: string
}

export const app = defineDecentrlApp({
  events: {
    message: {
      schema: z.object({
        text: z.string(),
        threadId: z.string().uuid(),
      }),
      tags: ['thread.${threadId}'],
    },
    reaction: {
      schema: z.object({
        emoji: z.string(),
        messageId: z.string(),
      }),
      tags: ['reaction.${messageId}'],
    },
  },
  state: {
    messages: {
      initial: [] as Message[],
      reduce: {
        message: (messages, data, meta) => [
          ...messages,
          { ...data, senderDid: meta.senderDid, eventId: meta.eventId },
        ],
      },
    },
  },
})

Each event definition has a schema (Zod) and tags (template strings interpolated from event data, used for encrypted querying). State slices declare an initial value and a reduce map that specifies which event types update that slice.

Typed Reducers

Reducers need no annotations. For every key in reduce, the SDK infers:

  • state — the slice's type, which comes from its initial value. Type initial once (initial: [] as Message[], initial: null as Profile | null) and every reducer of that slice, client.getState() and the React hooks follow.
  • data — the event schema's output (z.output): defaults filled in, unknown keys stripped.
  • meta — EventMeta for private events, PublicEventMeta for public: and channel: keys, GroupDeletionMeta for group:deletion.

A reducer key that isn't a declared event type (or public:/channel: of a declared one, or group:deletion) is a compile error, and so is a reducer that returns something other than its slice's type.

defineDecentrlApp({
  events: { a: { schema: z.object({ x: z.string() }), tags: [] } },
  state: {
    count: {
      initial: 0,
      reduce: {
        a: (count) => count + 1,          // ok
        b: (count) => count,              // error: 'b' is not a declared event
      },
    },
  },
})

App Configuration

ParameterTypeRequiredDescription
eventsRecord<string, { schema, tags }>YesPrivate event types
stateRecord<string, { initial, reduce }>YesState slices and their reducers
publicEventsRecord<string, { schema, tags }>NoPublic event types you publish
channelsRecord<string, { schema }>NoExternal public event types you read
interface EventDefinition {
  schema: z.ZodType
  tags: string[]           // template strings like "chat.${chatId}"
  groupTags?: string[]     // for posts to a group: templates like "thread.${threadId}"
}

interface ChannelDefinition {
  schema: z.ZodType
}

// A slice: its type is its `initial`'s. `reduce` is keyed by
// type | `public:${type}` | `channel:${type}` | 'group:deletion', each reducer typed for its key
interface SliceDefinition<TSlice> {
  initial: TSlice
  reduce: SliceReducers<TSlice, App>
}

Schemas

A schema checks every event on its way in and out:

  • Publishing. publish takes the schema's input (z.input), so a field with a .default() may be left out. The data is validated once, and the parsed output is what is sent and what your own reducers get.
  • Receiving. Every private event that reaches your reducers — live, pushed, read from storage or queried — is validated against its schema first. An event of an unknown type or with invalid data is dropped and logged. A reducer that throws is logged and skipped; the other slices still get the event.

Because the recipient validates the output you sent, a schema must accept its own output. Defaults and stripped keys are fine; .transform() isn't.

Who Sent It: meta, Not data

Only some of meta is authenticated. For private events:

FieldAuthenticatedMeaning
meta.senderDidYesThe sender, from the contract the event arrived on
meta.eventIdYesThe event ID signed inside the envelope
meta.groupDidYesThe group a group event was read from
meta.timestampNoThe sender's claim, in unix ms

Everything in data is whatever the sender wrote. Never put identity in payloads — no sender, createdBy or authorDid field that reducers trust. Take it from meta instead:

state: {
  chats: {
    initial: [] as Chat[],
    reduce: {
      // Only a participant may open a chat, and an id has one chat: the first
      'chat.create': (chats, data, meta) =>
        data.participants.includes(meta.senderDid) && !chats.some((c) => c.id === data.id)
          ? [...chats, { ...data, createdBy: meta.senderDid }]
          : chats,
    },
  },
  messages: {
    initial: {} as Record<string, Message[]>,
    reduce: {
      'chat.message': (messages, data, meta) => ({
        ...messages,
        [data.chatId]: [
          ...(messages[data.chatId] ?? []),
          {
            ...data,
            senderDid: meta.senderDid,
            eventId: meta.eventId,
            timestamp: meta.timestamp,
          },
        ],
      }),
    },
  },
}

A message is identified by (senderDid, eventId). When you show a chat, show only messages whose senderDid is one of its participants.

Applied Exactly Once

The SDK applies each private event to your reducers once per (meta.senderDid, meta.eventId) — across retries, re-deliveries, WebSocket pushes racing a sync, and reloads (with persist). Reducers don't need to dedupe: appending is fine.

Public events reach reducers once per (meta.publisherDid, meta.eventId).

Group events reach reducers once by their place in the group; see Groups.

Public Events

To publish public (unencrypted, signed) events, add publicEvents to your app config:

const app = defineDecentrlApp({
  events: { /* private events */ },
  publicEvents: {
    'blog.post': {
      schema: z.object({
        title: z.string(),
        body: z.string(),
      }),
      tags: ['blog', 'post:${title}'],
    },
  },
  state: {
    posts: {
      initial: [] as Array<{ title: string; id: string; timestamp: number }>,
      reduce: {
        // public: prefix — your own public events (and anyone's, via readPublicState)
        'public:blog.post': (posts, data, meta) => [
          ...posts,
          { title: data.title, id: meta.eventId, timestamp: meta.timestamp },
        ],
      },
    },
  },
})

Public event reducers use the public: prefix. They run on your own public events, and client.readPublicState(did) folds any publisher's verified public events through the same reducers — so a profile page can show someone else's posts without a hand-written parser. See Public Channels for the full concept.

Reading External Public Channels

To read public events from other identities, define channels — these describe the schema of events you want to consume:

const app = defineDecentrlApp({
  events: {},
  channels: {
    'blog.post': {
      schema: z.object({
        title: z.string(),
        body: z.string(),
      }),
    },
  },
  state: {
    feed: {
      initial: [] as Array<{ title: string; from: string }>,
      reduce: {
        // channel: prefix — triggered when external public events are processed
        'channel:blog.post': (feed, data, meta) => [
          ...feed,
          { title: data.title, from: meta.publisherDid },
        ],
      },
    },
  },
})

Channel events are verified before processing: the signature must check out against the publisher's DID, and the data must pass the schema. Anything else is dropped — including posts from before event IDs, and every post of a publisher whose DID yields no signing key. Each post reaches your reducers once per (meta.publisherDid, meta.eventId); meta.publicEventId is the mediator's ID for it, and meta.timestamp is the publisher's signed time in unix ms.

Groups

A group (DCTRL-0006) carries your app's ordinary events. There are no separate group definitions: an event posted with client.groups.publish is validated against the same schema and folded through the same reducers as a 1:1 event. Group events differ only in their meta:

FieldMeaning
meta.groupDidThe group the event was read from (authenticated). Never set on a 1:1 event.
meta.groupSeqThe event's place in the group's order: what deleting it names
meta.groupTagsThe sender's signed group tags, when it set any

A reducer that should tell them apart checks meta.groupDid.

Group Tags

groupTags are templates, like tags, evaluated from the event's data when it is posted to a group. Members read the group by them (client.groups.read(groupDid, { tags })); the group's mediator sees only a keyed hash of each, so it learns which events share a tag, never the tag. An event carries at most 8 group tags (INVALID_GROUP_TAGS otherwise).

events: {
  'forum.post': {
    schema: z.object({ threadId: z.string(), text: z.string() }),
    tags: [],
    groupTags: ['thread.${threadId}'],
  },
},

tags still apply to your own stored copy on your mediator; groupTags apply in the group.

Deletions: group:deletion

An event's author, or the group's admin, can delete a group event. Other members fold the deletion through the reserved group:deletion reducer key:

state: {
  posts: {
    initial: [] as Post[],
    reduce: {
      'forum.post': (posts, data, meta) => [
        ...posts,
        {
          groupDid: meta.groupDid,
          senderDid: meta.senderDid,
          eventId: meta.eventId,
          threadId: data.threadId,
          text: data.text,
        },
      ],
      'group:deletion': (posts, deletion) =>
        posts.filter(
          (p) =>
            !(
              p.groupDid === deletion.groupDid &&
              p.senderDid === deletion.senderDid &&
              p.eventId === deletion.eventId
            ),
        ),
    },
  },
}
interface GroupDeletionData {
  groupDid: string
  seq: number          // the deleted event's seq in the group
  senderDid: string    // the deleted event's sender
  eventId: string      // the deleted event's id
  deletedBy: string    // its author, or the admin at the time
  noticeSeq: number    // the deletion notice's own seq
}

interface GroupDeletionMeta {
  groupDid: string
  senderDid: string    // who deleted it
}

A deletion is folded only when its signer was allowed to make it. It names the event by (senderDid, eventId): hide that one. A deletion made without the event's ID reaches no reducer, since members cannot tell which event it was.

The group: prefix is reserved for the SDK: group:deletion is its only key today.

Applied Exactly Once

Each group is folded in the group's own order, page by page, from a cursor per group that the state keeps (and the snapshot persists). A page that doesn't start where the state stops is refused, so each group event and deletion reaches reducers once. Your mediator's stored copies of group events are never folded as 1:1 events.

Your own posts come back the same way: publish resolves after the group was read back and the post folded. Posts are not applied optimistically.

Fold or Page

Folding keeps every event of every group you follow in the state. That suits small groups and chats. For large streams (a forum's threads, a long history) leave the event out of your reducers, or keep only what the app needs, and read pages with client.groups.read or useGroupEvents.

Reducer Prefixes

State reducers use prefixes to distinguish event sources:

PrefixSourcemetaExample
(none)Private events (your events)EventMeta'message': (state, data, meta) => ...
public:Public events (your publicEvents)PublicEventMeta'public:blog.post': (state, data, meta) => ...
channel:External public events (your channels)PublicEventMeta'channel:blog.post': (state, data, meta) => ...
group:Reserved for the SDK: group:deletion onlyGroupDeletionMeta'group:deletion': (state, deletion, meta) => ...

Group events use the unprefixed key of their event type, with meta.groupDid set.

Creating a Client

The app definition is inert — it's just configuration. To start using the protocol, create a client:

const client = app.createClient({
  mediatorDid: 'did:web:mediator.decentrl.io',
  persist: { key: 'myapp', appVersion: '1' },   // optional: identity, contract keys and state in localStorage
})

// The client's type, when you need to name it
export type MyAppClient = ReturnType<typeof app.createClient>
ParameterTypeRequiredDescription
mediatorDidstringNoDID of the mediator relay service (required for private events)
persist{ key: string; appVersion?: string }NoPersist identity, contract keys and a state snapshot under this localStorage key prefix. Bump appVersion when reducers change meaning. See Persistence.
transportDecentrlTransportNoCustom transport (defaults to HTTP + WebSocket)

The client manages identity, contracts, event publishing, and state synchronization.

Type Safety

Because events are defined with Zod schemas, the SDK provides full TypeScript inference:

// Type-safe — matches the 'message' schema's input
await client.publish('message', {
  text: 'Hello',
  threadId: crypto.randomUUID(),
})

// Type error — 'text' is required
await client.publish('message', {
  threadId: crypto.randomUUID(),
})

// Type error — 'unknown' is not a defined event type
await client.publish('unknown', { ... })

// Typed state: { messages: Message[] }
const { messages } = client.getState()

// Envelopes narrow by type
client.onEvent((envelope) => {
  if (envelope.type === 'reaction') {
    envelope.data.emoji   // string
  }
})

To name the app's types elsewhere, derive them from the definition:

import type { DecentrlApp, EventInput, EventName, EventOf, PublicEventOf, StateOf } from '@decentrl/sdk'

type App = typeof app extends DecentrlApp<infer T> ? T : never

type State = StateOf<App>                         // { messages: Message[] }
type MessageEvent = EventOf<App, 'message'>       // a private envelope; `data` is the schema output
type AnyEvent = EventOf<App>                      // union of all private envelopes, discriminated by `type`
type PostEvent = PublicEventOf<App>               // a verified public envelope
type MessageInput = EventInput<App, 'message'>    // what publish('message', …) takes
type Name = EventName<App>                        // 'message' | 'reaction'

App is an AppTypes — { events, publicEvents, channels, state } — which every helper type takes. With React, register the app once and every hook is typed from it.

Why Declarative?

Traditional encrypted communication requires stitching together separate libraries for key generation, DID management, encryption, transport, and state sync. Each layer has its own API, error modes, and configuration.

defineDecentrlApp collapses this into a single declaration. You describe what your app does (event schemas and state reducers). The SDK handles how (cryptography, routing, storage). This is the same philosophy behind tools like Prisma (declare your schema, get a client) or tRPC (declare your API, get type safety).