decentrl.
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.

Basic Usage

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

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().uuid(),
      }),
      tags: ['reaction.${messageId}'],
    },
  },
  state: {
    messages: {
      initial: [] as Array<{ text: string; threadId: string }>,
      reduce: {
        message: (state, data) => [...state, data],
      },
    },
  },
})

Each event definition has a schema (Zod, validated at publish time) 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.

App Configuration

ParameterTypeRequiredDescription
eventsEventDefinitionsYesMap of event names to { schema, tags }
stateStateDefinitionsYesMap of state slices to { initial, reduce }
publicEventsPublicEventDefinitionsNoMap of public event names to { schema, tags, channelId }
channelsChannelDefinitionsNoMap of external public channel event names to { schema }
type EventDefinitions = Record<string, {
  schema: z.ZodType
  tags: string[]           // template strings like "chat.${chatId}"
}>

interface StateSliceDefinition<TSlice, TEvents> {
  initial: TSlice
  reduce: {
    [K in keyof TEvents]?: (state: TSlice, data: InferEventData, meta: EventMeta) => TSlice
  }
}

Public Events

To publish public (unencrypted, signed) events, add publicEvents to your app config. Each public event definition includes a channelId that groups events into named channels:

const app = defineDecentrlApp({
  events: { /* private events */ },
  publicEvents: {
    'blog.post': {
      schema: z.object({
        title: z.string(),
        body: z.string(),
      }),
      tags: ['blog', 'post:${title}'],
      channelId: 'blog',
    },
  },
  state: {
    posts: {
      initial: [] as Array<{ title: string; publisherDid: string }>,
      reduce: {
        // public: prefix — triggered when YOU publish a public event
        'public:blog.post': (state, data, meta) => [
          ...state,
          { title: data.title, publisherDid: meta.publisherDid },
        ],
      },
    },
  },
})

Public event reducers use the public: prefix. 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': (state, data, meta) => [
          ...state,
          { title: data.title, from: meta.publisherDid },
        ],
      },
    },
  },
})

Channel events are validated against the schema before processing, and their signatures are verified against the publisher's DID.

Reducer Prefixes

State reducers use prefixes to distinguish event sources:

PrefixSourceExample
(none)Private events (your own events definitions)'message': (state, data, meta) => ...
public:Your own public events (your publicEvents definitions)'public:blog.post': (state, data, meta) => ...
channel:External public events (your channels definitions)'channel:blog.post': (state, data, meta) => ...

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' },   // optional: persist identity + state to localStorage
})
ParameterTypeRequiredDescription
mediatorDidstringNoDID of the mediator relay service (required for private events)
persist{ key: string }NolocalStorage key prefix for identity + state 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
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', { ... })

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).