decentrl/docs
SDK

Example: Building a Chat App

A complete encrypted chat application showing both portable identity (browser extension) and app-managed identity (direct transport).

This example builds an encrypted chat app that supports two identity modes:

  • Extension transport — the Decentrl browser extension manages your identity. Keys are portable across apps. The extension handles encryption, contracts, and sync in the background.
  • Direct transport — the app manages identity itself via localStorage. Simpler to set up, but the identity lives only in this app.

The app schema, UI, and business logic are identical in both modes — only the client setup differs.

1. Define the App Schema

This is shared between both modes. Events + state reducers = your entire backend:

src/app.ts
import { defineDecentrlApp } from '@decentrl/sdk'
import { z } from 'zod'

export interface Chat {
  id: string
  name: string
  participants: string[]
  createdBy: string
}

export interface Message {
  chatId: string
  text: string
  senderDid: string
  eventId: string
  timestamp: number   // unix ms, as claimed by the sender
}

export const chatApp = defineDecentrlApp({
  events: {
    'chat.message': {
      schema: z.object({
        chatId: z.string().uuid(),
        text: z.string(),
      }),
      tags: ['chat.${chatId}'],
    },
    'chat.create': {
      schema: z.object({
        id: z.string().uuid(),
        name: z.string(),
        participants: z.array(z.string()),
      }),
      tags: ['chat', 'chat.${id}'],
    },
  },
  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[]>,   // by chat id
      reduce: {
        'chat.message': (messages, data, meta) => ({
          ...messages,
          [data.chatId]: [
            ...(messages[data.chatId] ?? []),
            {
              ...data,
              senderDid: meta.senderDid,
              eventId: meta.eventId,
              timestamp: meta.timestamp,
            },
          ],
        }),
      },
    },
  },
})

// Types every hook from this app
declare module '@decentrl/sdk-react' {
  interface Register {
    app: typeof chatApp
  }
}

export type ChatClient = ReturnType<typeof chatApp.createClient>

A few things the SDK takes care of, so the reducers stay short:

  • No annotations. Each slice's type comes from its initial; data and meta are inferred per event.
  • Who sent it comes from meta. meta.senderDid and meta.eventId are authenticated; the payload carries no id or sender, because anything in data is whatever the sender wrote. A message is identified by (senderDid, eventId), and chat.create only counts if its sender is one of the participants. When showing a chat, show only messages whose senderDid is a participant.
  • No dedupe. Every event is validated against its schema and applied exactly once, across reloads too, so appending is safe.

2. Create the Client

This is the only part that differs between the two modes.

Option A: Extension Transport (Portable Identity)

The browser extension owns the keys. Your app connects to it via Chrome's messaging API. The identity is portable — the user can use the same DID across multiple apps.

src/client-extension.ts
import { ExtensionTransport, detectDecentrlExtension } from '@decentrl/extension-client'
import { chatApp } from './app'

const EXTENSION_ID = 'your-extension-id'

export async function createExtensionClient() {
  // Check if the extension is installed
  const hasExtension = await detectDecentrlExtension(EXTENSION_ID)
  if (!hasExtension) return null

  // Create transport — the extension handles keys, encryption, and sync
  const transport = new ExtensionTransport({
    extensionId: EXTENSION_ID,
    appId: 'chat-app',
    prefix: 'chat',
    permissions: ['chat.message', 'chat.create'],
  })

  await transport.connect()

  return chatApp.createClient({
    mediatorDid: 'did:web:mediator.decentrl.io',
    transport,
  })
}

The extension:

  • Holds the private keys in its service worker (never exposed to the app)
  • Handles contract negotiation, encryption, and mediator communication
  • Pushes decrypted events to the app via onEvents
  • Persists identity across browser sessions and multiple apps

Option B: Direct Transport (App-Managed Identity)

The app generates and stores keys itself using localStorage. No extension needed — DirectTransport is the default when you don't pass a transport option. The identity lives only in this app.

src/client-direct.ts
import { chatApp } from './app'

export async function createDirectClient() {
  // No transport option → defaults to DirectTransport (HTTP + WebSocket to mediator)
  const client = chatApp.createClient({
    mediatorDid: 'did:web:mediator.decentrl.io',
    persist: { key: 'chat-app' },  // persist identity, contract keys and a state snapshot to localStorage
  })

  // Check if we already have a persisted identity
  const existing = client.identity.getIdentity()

  if (!existing) {
    // First launch — generate keys and register with mediator
    await client.identity.create({
      alias: 'alice',
      mediatorDid: 'did:web:mediator.decentrl.io',
    })
  }

  return client
}

Choosing at Runtime

Detect the extension and fall back to direct transport:

src/client.ts
import type { ChatClient } from './app'
import { createExtensionClient } from './client-extension'
import { createDirectClient } from './client-direct'

export async function createClient(): Promise<ChatClient> {
  // Try extension first (portable identity)
  const extensionClient = await createExtensionClient()
  if (extensionClient) return extensionClient

  // Fall back to direct transport (app-managed identity)
  return createDirectClient()
}

3. Contacts and Messaging

From here on, the code is identical regardless of transport mode. The client interface is the same.

src/contacts.ts
import { client } from './client'

export async function addContact(recipientDid: string) {
  await client.contracts.request(recipientDid)
}

export async function acceptPendingContacts() {
  const pending = await client.contracts.getPending()

  for (const request of pending) {
    await client.contracts.accept(
      request.id,
      request.encryptedPayload,
      request.requestorEphemeralPublicKey,
    )
  }
}
src/chat.ts
export async function sendMessage(chatId: string, text: string) {
  // Resolves to { eventId }: generated, signed into the event, and what meta.eventId will be
  return client.publish('chat.message', { chatId, text })
}

4. React UI

src/ChatApp.tsx
import {
  DecentrlProvider,
  usePublish,
  useDecentrlState,
  useDecentrlIdentity,
  useAutoSync,
} from '@decentrl/sdk-react'
import { useState } from 'react'
import type { ChatClient, Message } from './app'

const NO_MESSAGES: Message[] = []

function ChatRoom({ chatId }: { chatId: string }) {
  const { publish, isPending, error } = usePublish()
  // Select, don't build: the selector returns a stored reference
  const messages = useDecentrlState(s => s.messages[chatId]) ?? NO_MESSAGES
  const [text, setText] = useState('')

  const send = async () => {
    if (!text.trim()) return
    await publish('chat.message', { chatId, text })
    setText('')
  }

  return (
    <div>
      {messages.map(msg => (
        <div key={`${msg.senderDid}|${msg.eventId}`}>{msg.text}</div>
      ))}
      {error && <p>Not sent: {error.message}</p>}
      <form onSubmit={e => { e.preventDefault(); send() }}>
        <input
          value={text}
          onChange={e => setText(e.target.value)}
          placeholder="Type a message..."
        />
        <button type="submit" disabled={isPending}>Send</button>
      </form>
    </div>
  )
}

function IdentityGate({ children }: { children: React.ReactNode }) {
  const { identity, isInitialized } = useDecentrlIdentity()

  if (!isInitialized) {
    return <p>Setting up identity...</p>
  }

  return (
    <div>
      <p>DID: {identity?.did}</p>
      {children}
    </div>
  )
}

function App() {
  useAutoSync({ websocket: true })

  return (
    <IdentityGate>
      <ChatRoom chatId="abc-123" />
    </IdentityGate>
  )
}

// Wrap with provider — same regardless of transport mode
export function Root({ client }: { client: ChatClient }) {
  return (
    <DecentrlProvider client={client}>
      <App />
    </DecentrlProvider>
  )
}

What's Happening Under the Hood

When Alice types "Hey Bob" and hits send:

Alice's device:
  1. Validate { chatId, text: "Hey Bob" } against Zod schema
  2. Evaluate tags: ["chat.abc-123"]
  3. Sign event envelope with Alice's Ed25519 key
  4. Encrypt with today's epoch key → send to Bob's mediator (TWO_WAY_PRIVATE)
  5. Encrypt with Alice's storage_key → save on Alice's mediator
  6. Update local state: messages = [...messages, newMessage]
  7. React re-renders via useSyncExternalStore

Bob's device (WebSocket push or next poll):
  1. Receive pending event from mediator
  2. Look up the contract and epoch named in the payload → pick that epoch's key
  3. Decrypt payload, verify Alice's Ed25519 signature
  4. Re-encrypt with Bob's storage_key → save on Bob's mediator
  5. Validate against the schema, then apply through the reducer once per (senderDid, eventId)
  6. React re-renders — "Hey Bob" appears

With extension transport, steps 3–5 happen in the extension's service worker — the app never sees private keys. With direct transport, they happen in the app's JavaScript context.

Extension vs Direct: When to Use Which

ExtensionDirect
IdentityPortable across appsLives in this app only
Key storageExtension service workerlocalStorage
SetupUser installs extensionNothing to install
Multi-appSame DID everywhereDifferent DID per app
SecurityKeys never exposed to app JSKeys accessible in JS context
Best forProduction apps, multi-app ecosystemsPrototypes, single-purpose apps, demos

Extending the Example

The same pattern scales to any feature — just add event definitions and reducers:

// Reactions (who reacted comes from meta.senderDid)
'chat.reaction': {
  schema: z.object({
    chatId: z.string().uuid(),
    messageKey: z.string(),   // `${senderDid}|${eventId}` of the message
    emoji: z.string(),
  }),
  tags: ['chat.${chatId}'],
},

// Read receipts
'chat.read': {
  schema: z.object({
    chatId: z.string().uuid(),
    timestamp: z.number(),
  }),
  tags: ['readmarker.${chatId}'],
},

// Typing indicators (ephemeral — not stored)
'chat.typing': {
  schema: z.object({ chatId: z.string().uuid() }),
  tags: [],  // empty tags → not persisted
},

Each new feature is just a new event definition and a reducer. No backend changes. No database migrations. No API endpoints. Works identically with both transport modes. If a reducer changes what it computes from events you already have, bump persist.appVersion so saved state is rebuilt.

Replies to Public Posts

The social app in this repository (apps/social) combines both kinds of events for replies. A reply to someone else's post is a private event sent to the post's author (publish('post.reply', data, { recipient: authorDid })). The author's client republishes it on their public channel, naming the reply's sender (meta.senderDid) and event ID, under a deterministic event ID:

import { sha256, stringToBytes } from '@decentrl/crypto'

// `reply`: a received post.reply, with senderDid, eventId and timestamp taken from its meta
// 'fwd.' + the first 32 hex characters of sha256(senderDid|eventId)
const forwardId = 'fwd.' + Array.from(sha256(stringToBytes(`${reply.senderDid}|${reply.eventId}`)))
  .map((byte) => byte.toString(16).padStart(2, '0'))
  .join('')
  .slice(0, 32)

await client.publishPublic('post.reply', {
  postId: reply.postId,
  authorDid: reply.senderDid,
  replyEventId: reply.eventId,
  content: reply.content,
  timestamp: reply.timestamp,
}, { eventId: forwardId })

Because the ID is derived from the reply, forwarding it again — a retry, another tab or device — is the same public event, and the mediator keeps one. The forwarded reply is the post author's statement about what someone wrote, not a signature by that someone, so the UI labels it "via post author".