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:
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;dataandmetaare inferred per event. - Who sent it comes from
meta.meta.senderDidandmeta.eventIdare authenticated; the payload carries noidorsender, because anything indatais whatever the sender wrote. A message is identified by(senderDid, eventId), andchat.createonly counts if its sender is one of the participants. When showing a chat, show only messages whosesenderDidis 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.
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.
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:
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.
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,
)
}
}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
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" appearsWith 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
| Extension | Direct | |
|---|---|---|
| Identity | Portable across apps | Lives in this app only |
| Key storage | Extension service worker | localStorage |
| Setup | User installs extension | Nothing to install |
| Multi-app | Same DID everywhere | Different DID per app |
| Security | Keys never exposed to app JS | Keys accessible in JS context |
| Best for | Production apps, multi-app ecosystems | Prototypes, 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".