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 const chatApp = defineDecentrlApp({
events: {
'chat.message': {
schema: z.object({
id: z.string().uuid(),
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 Array<{ id: string; name: string; participants: string[] }>,
reduce: {
'chat.create': (state, data) => {
if (state.some(c => c.id === data.id)) return state
return [...state, data]
},
},
},
messages: {
initial: [] as Array<{ id: string; chatId: string; text: string }>,
reduce: {
'chat.message': (state, data) => {
if (state.some(m => m.id === data.id)) return state
return [...state, data]
},
},
},
},
})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 + state 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 { createExtensionClient } from './client-extension'
import { createDirectClient } from './client-direct'
export async function createClient() {
// 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) {
await client.publish('chat.message', {
id: crypto.randomUUID(),
chatId,
text,
})
}4. React UI
import {
DecentrlProvider,
useDecentrl,
useDecentrlState,
useDecentrlIdentity,
useAutoSync,
} from '@decentrl/sdk-react'
import { useState } from 'react'
function ChatRoom({ chatId }: { chatId: string }) {
const { publish } = useDecentrl()
const messages = useDecentrlState(s =>
s.messages.filter(m => m.chatId === chatId)
)
const [text, setText] = useState('')
const send = () => {
if (!text.trim()) return
publish('chat.message', { id: crypto.randomUUID(), chatId, text })
setText('')
}
return (
<div>
{messages.map(msg => (
<div key={msg.id}>{msg.text}</div>
))}
<form onSubmit={e => { e.preventDefault(); send() }}>
<input
value={text}
onChange={e => setText(e.target.value)}
placeholder="Type a message..."
/>
<button type="submit">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 }) {
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 { id, 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 root_secret → 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 contract with Alice → derive root_secret
3. Decrypt payload, verify Alice's Ed25519 signature
4. Re-encrypt with Bob's storage_key → save on Bob's mediator
5. Apply to local state through reducer
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
'chat.reaction': {
schema: z.object({
id: z.string().uuid(),
messageId: z.string().uuid(),
emoji: z.string(),
}),
tags: ['chat.${messageId}'],
},
// 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.