Ease docs/Concepts/Chat
ease.chat: DMs and groups
DMs and groups under one namespace and one mental model: open a room from ease.chat, subscribe to its events, send typed envelopes. Encrypted end to end by default, with the server holding ciphertext and never the keys.
The shape
Every room is opened from ease.chat by ID. A DM is opened directly; a group is created first, then opened by the ID the create call returns. Each handle carries the same contract: send to publish a message, on('message', ...) to receive. The entry point is the connected client:
const dm = ease.chat.dm(bobUserId);
// Subscribe before sending so inbound messages surface as they
// arrive. The handler receives a typed envelope.
dm.on('message', (event) => {
if (event.kind !== 'message') return;
const { envelope } = event.event;
if (envelope.type === 'ease.text') {
render(envelope.text);
}
});
// E2EE by default — the server only ever sees ciphertext.
const ack = await dm.send({ type: 'ease.text', text: 'hello bob' });
console.log('sent', ack.messageId);The send call takes a typed envelope, an object with a type discriminator. Plain text is { type: 'ease.text', text: '…' }. The receive handler is handed the same envelope back, already decrypted, so you branch on envelope.type and read the fields that type carries.
The room types
Two room types are live today. Both gate on the contact graph and both run end-to-end encrypted from the first message.
- DM: 1:1 chat. Two members, contact-gated. Opened with
ease.chat.dm(userId). - Group: small group chat. Owner / admin / member roles, with an approval-gated add path. Created with
ease.chat.createGroup(name, memberUserIds), then opened withease.chat.group(groupId).
The wider room-type vocabulary (team, community, event, announcement, broadcast, live) is the canonical surface the API is shaped around, but those handles are not yet implemented and throw not_yet_implemented when opened. Build against DM and group today.
Creating a group
createGroup mints the group and returns its ID; you open the room by that ID to send and receive. The split is deliberate. The create call is a cross-room verb on ease.chat, and the returned handle is the room you then talk to.
// createGroup mints the group and returns its GRUP_ id; open
// the room by that id to send and receive.
const created = await ease.chat.createGroup(
'Friday lunch',
[bobUserId, carolUserId],
);
const room = ease.chat.group(created.groupId);
room.on('message', (event) => {
if (event.kind !== 'message') return;
const { envelope } = event.event;
if (envelope.type === 'ease.text') render(envelope.text);
});
await room.send({ type: 'ease.text', text: 'pizza?' });The group roster
A group's membership is readable from the room handle. Each member carries an identity (with the userId) and a role (owner, admin, or member). Group metadata like this is plaintext on the wire; the message content is what's encrypted.
const room = ease.chat.group(created.groupId);
const members = await room.members();
for (const member of members) {
// member.identity.userId — who they are
// member.role — 'owner' | 'admin' | 'member'
console.log(member.identity.userId, member.role);
}Moderating a group
Owner and admin members can moderate a group from the room handle. Every action is role-gated server-side: a call the caller is not allowed to make throws a typed EaseError (cannot_ban_owner, forbidden) rather than failing silently.
Mute suspends a member's ability to send until a timestamp; a null until unmutes. A muted member keeps receiving every message — the mute only rejects their sends, with member_muted. The owner is never mutable. Ban removes the member and writes a tombstone that every re-add path checks, so a banned user cannot rejoin until unbanned; it bumps the group epoch and can carry an optional reason. Unban lifts the tombstone but does not re-add them — they rejoin through a normal path.
bans() returns the active bans; moderationEvents() is the append-only audit log, newest first, of every mute, ban, unban, kick, and role change. Filter it by target and action and page to the end to answer how many times a member has been acted on.
const room = ease.chat.group(groupId);
// Mute silences a member's sends until a time; null unmutes. They
// keep receiving. The owner is never mutable.
await room.mute(troubleUserId, new Date(Date.now() + 60 * 60 * 1000));
await room.mute(troubleUserId, null); // unmute
// Ban removes the member and blocks re-add (an optional reason is
// recorded); unban lifts it, but they must rejoin.
await room.ban(troubleUserId, 'spam');
await room.unban(troubleUserId);
// Read the current bans and the append-only moderation audit log.
const bans = await room.bans();
const { events } = await room.moderationEvents({ limit: 50 });
for (const e of events) {
console.log(e.action, e.actorUserId, '→', e.targetUserId, e.createdAt);
}History and search
A room reads back its own message log without a round-trip to the server. history() pages through the logged messages oldest-first; pass the oldest message id you already hold as the cursor to fetch the page before it. search() filters that same log by substring, with optional sender, since, until, and limit.
The log holds what the SDK has decrypted. Where the app opened a persisting snapshot the log survives a restart; an app that never persisted sees only the current session. The SDK owns the log so the app does not rebuild one.
const dm = ease.chat.dm(bobUserId);
// history() pages back oldest-first. Pass the oldest id you hold
// as the cursor to fetch the page before it.
const recent = await dm.history(50);
const older = await dm.history(50, recent[0]?.messageId);
// search() filters the same log by substring, with optional
// sender / since / until / limit.
const hits = await dm.search('invoice', {
since: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
limit: 20,
});
for (const hit of hits) {
console.log(hit.entry.senderUserId, hit.snippet);
}Listening across rooms
Per-room handlers (dm.on('message', …)) fire only for that specific room. For an app-level inbox, subscribe to onIncomingMessage on ease.chat: it delivers every incoming DM as a flat, already-decrypted object. Group plaintext arrives through per-room handlers; the cross-group aggregate, onIncomingGroupMessage, carries routing metadata (groupId, sender, sentAt) rather than decrypted text.
// Every incoming DM in one stream, already decrypted. Use this
// for an app-level inbox view; pair it with per-room handlers for
// the open-chat-screen live view.
const unsubscribe = ease.chat.onIncomingMessage((msg) => {
inbox.push({ from: msg.senderUserId, text: msg.plaintext, at: msg.sentAt });
});
// Group messages arrive per room. Subscribe where a group chat is
// open for decrypted envelopes:
ease.chat.group(groupId).on('message', (event) => {
if (event.kind !== 'message') return;
const { envelope } = event.event;
if (envelope.type === 'ease.text') inbox.push({ text: envelope.text });
});Per-room handles and the aggregate handlers compose; subscribing to both is the standard pattern. The aggregates drive the app-level inbox feed and unread badges, and per-room handles give you the open-chat-screen live view.
Encrypted by default
DMs and groups use libsignal. 1:1 chats run the Signal Protocol; group chats use libsignal Sender Keys. The server sees ciphertext, not plaintext. The SDK handles key distribution; your code never sees the keys.
Group metadata (name, member list, member roles) is plaintext on the wire. Group content, the thing people actually say, is encrypted from the first message.
Message types
The send calls above carry plain text. Images, voice, video, files, locations, and reactions ride the same room API as typed envelopes. Each is a type value you set on the envelope you send and branch on when you receive.
ease.text: plain text, the default.ease.image: image with an optional caption.ease.voice: voice note with duration and optional waveform peaks.ease.video: video with duration, an optional poster, and a caption.ease.file: arbitrary file with a filename.ease.location: latitude and longitude with an optional label.ease.reaction: emoji against a target message ID.
New variants land additively; the shape of an existing case does not change within a major version. A peer running an older SDK that doesn't recognise a type falls back to rendering the envelope as text.