Documentation Index
Fetch the complete documentation index at: https://docs.deep.space/llms.txt
Use this file to discover all available pages before exploring further.
DeepSpace ships a complete messaging layer as drop-in schemas plus a small set of React hooks. Wire the schemas into your app’s RecordRoom, mount the providers (already done in the scaffold), and compose a chat surface from useChannels, useMessages, and useReactions.
This guide walks the end-to-end flow. For the exhaustive hook signatures, options, and return shapes, see the messaging reference.
Add the schemas
Pick the schemas your UI actually uses. CHANNELS_SCHEMA + MESSAGES_SCHEMA is the minimum; the rest are additive.
// src/schemas.ts
import type { CollectionSchema } from 'deepspace/worker'
import {
CHANNELS_SCHEMA,
MESSAGES_SCHEMA,
REACTIONS_SCHEMA,
CHANNEL_MEMBERS_SCHEMA,
READ_RECEIPTS_SCHEMA,
} from 'deepspace/worker'
import { usersSchema } from './schemas/users-schema'
import { settingsSchema } from './schemas/admin-schema'
export const schemas: CollectionSchema[] = [
usersSchema,
settingsSchema,
CHANNELS_SCHEMA,
MESSAGES_SCHEMA,
REACTIONS_SCHEMA,
CHANNEL_MEMBERS_SCHEMA,
READ_RECEIPTS_SCHEMA,
]
The drop-in schemas include sensible RBAC defaults: signed-in users can read channels and post messages, and only the author can edit or delete a message. Private-channel gating is an app-level concern - enforce it with useChannelMembers (see Channel membership).
Build the chat surface
A minimal chat surface. useChannels lists the sidebar, useMessages drives the transcript, and useReactions gives every message a toggleable emoji. Identity (author) is derived from the verified JWT - you never pass an authorId.
import { useState } from 'react'
import {
useChannels,
useMessages,
useReactions,
formatMessageTime,
} from 'deepspace'
export default function ChatApp() {
const { channels, status } = useChannels()
const [activeId, setActiveId] = useState<string | null>(null)
if (status === 'loading') return <SkeletonList />
return (
<div className="grid grid-cols-[240px_1fr] h-screen">
<aside>
{channels.map((ch) => (
<button key={ch.recordId} onClick={() => setActiveId(ch.recordId)}>
#{ch.data.name}
</button>
))}
</aside>
<main>{activeId && <ChannelView channelId={activeId} />}</main>
</div>
)
}
function ChannelView({ channelId }: { channelId: string }) {
const { messages, send } = useMessages(channelId)
const { toggle, getReactionsForMessage } = useReactions(channelId)
const [draft, setDraft] = useState('')
return (
<>
<ul>
{messages.map((m) => (
<li key={m.recordId}>
<span>{m.data.authorId}</span>
<span>{formatMessageTime(m.createdAt)}</span>
<p>{m.data.content}</p>
{getReactionsForMessage(m.recordId).map((r) => (
<button key={r.emoji} onClick={() => toggle(m.recordId, r.emoji)}>
{r.emoji} {r.count}
</button>
))}
<button onClick={() => toggle(m.recordId, '❤️')}>+❤️</button>
</li>
))}
</ul>
<form
onSubmit={(e) => {
e.preventDefault()
if (!draft.trim()) return
send(draft)
setDraft('')
}}
>
<input value={draft} onChange={(e) => setDraft(e.target.value)} />
<button>Send</button>
</form>
</>
)
}
Sends, edits, reactions, and deletes all dispatch optimistically and reconcile through the room. Prefer softDelete(messageId) over remove(messageId) for user-initiated deletes - it keeps thread replies and read receipts coherent. See useChannels, useMessages options, and useReactions for the full API surface.
Create a channel
Channel type is one of 'public', 'private', or 'dm'.
const { create } = useChannels()
const channelId = await create({
name: 'general',
type: 'public',
description: 'Company-wide announcements',
})
type is required. Passing only { name } produces a channel with type: undefined, which silently breaks downstream queries.
Direct messages
DMs are just channels with type: 'dm'. By convention, encode the participant pair in the channel name so you can dedupe locally before calling create.
const { create } = useChannels()
async function startDM(otherUserId: string, currentUserId: string) {
const channelId = await create({
name: `dm:${[currentUserId, otherUserId].sort().join(':')}`,
type: 'dm',
})
navigate(`/messages/${channelId}`)
}
The same useMessages(channelId) and useReactions(channelId) hooks drive the DM thread - no separate API. For cross-app DMs that live on a dedicated Durable Object scope, use useConversation instead.
Channel membership
For private channels, gate posting on membership. isMember is a derived boolean for the current signed-in user.
import { useChannelMembers } from 'deepspace'
function JoinGate({ channelId }: { channelId: string }) {
const { isMember, join } = useChannelMembers(channelId)
if (!isMember) return <button onClick={() => join()}>Join channel</button>
return <MessageComposer channelId={channelId} />
}
See useChannelMembers for the full membership surface.
Read receipts
Stamp the channel as read whenever the user has it open, then derive unread counts from the message list.
import { useEffect } from 'react'
import { useMessages, useReadReceipts } from 'deepspace'
function ChannelView({ channelId }: { channelId: string }) {
const { messages } = useMessages(channelId)
const { markAsRead, getUnreadCount } = useReadReceipts()
useEffect(() => {
markAsRead(channelId)
}, [channelId, messages.length, markAsRead])
const unread = getUnreadCount(channelId, messages)
// ...
}
markAsRead writes the current timestamp; getUnreadCount counts messages that landed after it. The receipt is per-user, scoped by the verified JWT. Full signatures live in useReadReceipts.
Next steps