Build a Group Chat
A group chat usually needs a server that stores the messages and sends each new
one to everyone in the room. With puter.kv.share(), the room can
live in the Puter account of the user who creates it instead. Each member gets
write access to the room, reads and sends messages through it, and gets new
messages as events.
Write access lets any member change or delete any message. To let members change only their own, see Let Members Change Only Their Own Messages. For rules that a server checks, use a worker as in Store Server-Side Data. Share a List With Someone explains the basics of sharing in more detail.
Create a Room
The user who creates the room keeps it under room:<id>: in their account. Your
app asks them once for permission to share rooms with
puter.perms.request(), naming the room: prefix so the
approval covers every room they create:
await puter.perms.request('kvShare', { prefix: 'room:', access: 'write' });
const roomId = crypto.randomUUID();
const prefix = `room:${roomId}:`;
await puter.kv.set(`${prefix}meta`, { roomId, title: 'Weekend trip' });
A share doesn't say who made it or what its prefix is, so members' apps show the
room's title from meta.
Invite Members
Share the room with every member in one call by passing recipients. With
notify: true, each member is told about the room right away, and the creator
and each member can notify each other through the share, as
Notify Chat Members of New Messages
shows:
const invites = await puter.kv.share({
prefix,
recipients: ['alice', 'bob'],
access: 'write',
label: 'Weekend trip',
notify: true,
});
for (const invite of invites) {
if (invite.error) showError(`Couldn't invite ${invite.recipient}`);
}
The call resolves to one item per recipient. A recipient who couldn't be
invited, such as a username that doesn't exist, has an error on their item,
and the others are still invited.
Each member finds their rooms with puter.kv.listShared(). A
member who has your app open also gets a
kv.share.received notification for each new
room, which puter.events.onLocal() receives:
let cursor;
do {
const page = await puter.kv.listShared({ cursor });
for (const { handle } of page.items) addRoom(handle);
cursor = page.cursor;
} while (cursor);
await puter.events.onLocal('notif:app-user', ({ event }) => {
if (event.type === 'kv.share.received') addRoom(event.notification.handle);
});
Here addRoom() is your app's function that reads the room's meta through
the handle and lists the room.
Open the Room
The creator uses the room's full keys, and members use relative keys and the
share's handle. This helper hides the difference, so the rest of the chat
works the same for everyone:
const openRoom = ({ prefix = '', handle }) => {
const optConfig = handle ? { handle } : {};
const relative = (key) => key?.slice(prefix.length);
return {
get: (key) => puter.kv.get(prefix + key, optConfig),
set: (key, value) => puter.kv.set(prefix + key, value, optConfig),
latest: async (pattern, { limit = 50, cursor } = {}) => {
const page = await puter.kv.list({
pattern: prefix + pattern,
reverse: true,
limit,
cursor,
returnValues: true,
optConfig,
});
const items = page.items.map(({ key, value }) => ({ key: relative(key), value }));
return { items, cursor: page.cursor };
},
watch: (pattern, onChange) => puter.events.onLocal(
`kv:${handle ?? puter.appID}:${prefix}${pattern}`,
({ event }) => onChange({ ...event, key: relative(event.key) }),
{ includeValue: true },
),
};
};
// The creator opens the room by its prefix, and a member by their share's handle.
const room = handle ? openRoom({ handle }) : openRoom({ prefix });
Send a Message
Give each message its own key, made from the time and a random part:
const user = await puter.auth.getUser();
const sendMessage = (text) => room.set(
`msg:${Date.now()}-${crypto.randomUUID().slice(0, 8)}`,
{ text, from: user.username },
);
await sendMessage('Who is bringing the tent?');
Keys sort by the time, so the newest messages come last. The random part keeps
two messages sent in the same millisecond on different keys. Two members who
send at about the same time can get the same number from a counter kept with
puter.kv.incr(), so a counter can't replace the random part.
Load the Latest Messages
List the room's messages newest first, 50 at a time:
const page = await room.latest('msg:*');
showMessages(page.items.reverse()); // oldest first, for display
The page includes a cursor if there are older messages to load. Pass it back
to get the next 50:
const older = await room.latest('msg:*', { cursor: page.cursor });
Get New Messages Live
Subscribe to the room's messages with the helper's watch(), which uses
puter.events.onLocal(). New messages only need to reach
people with the chat open, so a subscription that ends with the page is enough:
await room.watch('msg:*', (event) => {
if (event.op === 'gap') return reloadMessages();
if (event.op === 'set') showMessage(event.key, event.value);
if (event.op === 'del') removeMessage(event.key);
});
Every message arrives this way, including the sender's own, so sending doesn't
need to draw anything. A gap event means some messages weren't delivered, for
example during a reconnect, so load the latest page again.
Remove a Member or Leave the Room
The creator removes a member by passing their share's handle to
puter.kv.unshare(). Find it by paging through
puter.kv.listSharedByMe():
const findShare = async (prefix, holder) => {
let cursor;
do {
const page = await puter.kv.listSharedByMe({ state: 'live', limit: 200, cursor });
const share = page.items.find((s) => s.prefix === prefix && s.holder === holder);
if (share) return share;
cursor = page.cursor;
} while (cursor);
return null;
};
const bobs = await findShare(prefix, 'bob');
if (bobs) await puter.kv.unshare(bobs.handle);
Bob loses access right away, and his subscription to the room ends. A member
leaves the room the same way, by calling puter.kv.unshare()
with their own handle.
Manage Who Has Access to Shared Data
shows the full lists.
What Members Can Do
The room lives in the creator's account, so if the creator deletes the room's keys or their account, the room is gone for everyone. Each member pays for their own reads and writes.
Every member can change or delete any message, and meta. The from field is
whatever the sender's app wrote, so a member can put any name there. The next
section fixes both.
Let Members Change Only Their Own Messages
Give each member two shares. A read share of the whole room lets them read
everything, and a write share of their own part, room:<id>:u:<memberId>:,
lets them write there and nowhere else:
const memberId = crypto.randomUUID();
const part = `${prefix}u:${memberId}:`;
await puter.kv.set(`${prefix}member:${memberId}`, { username: 'alice' });
await puter.kv.set(`${part}meta`, { roomId });
await puter.kv.share({ prefix, recipient: 'alice', access: 'read', label: 'Weekend trip', notify: true });
await puter.kv.share({ prefix: part, recipient: 'alice', access: 'write', label: 'Weekend trip' });
Only the creator can write the member: keys, so they're a reliable record of
who each memberId belongs to. The creator gives themselves a part and a
member: key the same way, and writes to their part with full keys.
Alice gets both shares from puter.kv.listShared(). Her app
pairs them by the roomId in each meta, and tells them apart by access:
const { items } = await puter.kv.listShared();
const rooms = new Map();
for (const share of items) {
const { roomId } = await puter.kv.get('meta', { handle: share.handle });
rooms.set(roomId, { ...rooms.get(roomId), [share.access]: share.handle });
}
const { read, write } = rooms.get(roomId);
She sends through her write share, and her messages land in her part:
await puter.kv.set(`msg:${Date.now()}-${crypto.randomUUID().slice(0, 8)}`, { text: 'I am!' }, { handle: write });
She reads the whole room through her read share. Each message key starts with
u:<memberId>:, which says who wrote it:
const members = new Map((await puter.kv.list('member:*', true, { handle: read }))
.map(({ key, value }) => [key.slice('member:'.length), value.username]));
const messages = (await puter.kv.list('u:*', true, { handle: read }))
.filter(({ key }) => key.includes(':msg:'))
.map(({ key, value }) => ({
from: members.get(key.split(':')[1]),
sentAt: key.split(':msg:')[1],
...value,
}))
.sort((a, b) => a.sentAt.localeCompare(b.sentAt));
This loads every message in the room, which suits rooms with a short history.
To get new messages live, subscribe to kv:<read handle>:u:*, which gets every
member's writes.
A write through the read share is rejected with kv_handle_access_denied, even
for a key inside Alice's own part, so her app always writes through the write
share. When the creator removes Alice's read share, or Alice leaves it, her
write share stops with it.
Without a creator who holds the room, each member can keep their own messages in their own account and share read access with every other member. That takes a share for every pair of members, so it suits small groups.
Full Code
The creator, when they create a room:
await puter.perms.request('kvShare', { prefix: 'room:', access: 'write' });
const roomId = crypto.randomUUID();
const prefix = `room:${roomId}:`;
await puter.kv.set(`${prefix}meta`, { roomId, title: 'Weekend trip' });
const invites = await puter.kv.share({
prefix,
recipients: ['alice', 'bob'],
access: 'write',
label: 'Weekend trip',
notify: true,
});
for (const invite of invites) {
if (invite.error) showError(`Couldn't invite ${invite.recipient}`);
}
A member, to find their rooms:
let cursor;
do {
const page = await puter.kv.listShared({ cursor });
for (const { handle } of page.items) addRoom(handle);
cursor = page.cursor;
} while (cursor);
await puter.events.onLocal('notif:app-user', ({ event }) => {
if (event.type === 'kv.share.received') addRoom(event.notification.handle);
});
The room, for the creator and members alike:
const openRoom = ({ prefix = '', handle }) => {
const optConfig = handle ? { handle } : {};
const relative = (key) => key?.slice(prefix.length);
return {
get: (key) => puter.kv.get(prefix + key, optConfig),
set: (key, value) => puter.kv.set(prefix + key, value, optConfig),
latest: async (pattern, { limit = 50, cursor } = {}) => {
const page = await puter.kv.list({
pattern: prefix + pattern,
reverse: true,
limit,
cursor,
returnValues: true,
optConfig,
});
const items = page.items.map(({ key, value }) => ({ key: relative(key), value }));
return { items, cursor: page.cursor };
},
watch: (pattern, onChange) => puter.events.onLocal(
`kv:${handle ?? puter.appID}:${prefix}${pattern}`,
({ event }) => onChange({ ...event, key: relative(event.key) }),
{ includeValue: true },
),
};
};
// The creator opens the room by its prefix, and a member by their share's handle.
const room = handle ? openRoom({ handle }) : openRoom({ prefix });
const user = await puter.auth.getUser();
const sendMessage = (text) => room.set(
`msg:${Date.now()}-${crypto.randomUUID().slice(0, 8)}`,
{ text, from: user.username },
);
const page = await room.latest('msg:*');
showMessages(page.items.reverse());
await room.watch('msg:*', (event) => {
if (event.op === 'gap') return reloadMessages();
if (event.op === 'set') showMessage(event.key, event.value);
if (event.op === 'del') removeMessage(event.key);
});