First Class Modules
Chat rooms
Provides secured team rooms with messages, unread state, membership, room administration, mute settings, reactions, archives, custom fields, relations, activity, and favorites.
module.chat-roomsUse Chat Rooms for ongoing team conversation where membership, unread state, lightweight reactions, and room administration matter more than a formal topic hierarchy.
The bundled renderer key is module.chat-rooms. First-class means BuildWithHQ supplies a native, typed, secured runtime experience inside normal app provisioning and the signed-in user's existing permissions.
What it does
Provides secured team rooms with messages, unread state, membership, room administration, mute settings, reactions, archives, custom fields, relations, activity, and favorites.
Key capabilities
- Search active or archived rooms and see unread counts.
- Create/edit rooms, post messages, and react with bounded reaction types.
- Manage members, room administrators, mute state, and archive state when authorized.
- Show location, dynamic fields, related records, activity, and favorites.
Common uses
- Project or operations team chat.
- Location, department, incident, or customer team rooms.
- Informal collaboration adjacent to records that retain the formal work state.
How it connects
Rooms can relate to projects, customers, incidents, Files, and other Records. Use Conversations for durable customer/channel threads and Discussion Boards for structured topics; Chat Rooms remain the fast team discussion surface.
Where applicable, its records use the universal RecordId conventions so they can participate in secured relationships, activity history, favorites, dynamic fields, notifications, Inbox attention, and global search without copying the source record.
Security and data boundary
Room visibility and posting require both normal tenant/DataRole/location access and current membership/room capability. Member administration, editing, and archiving are separately authorized.
- The authenticated service derives the SaaS app, tenant account, user, DataRole, and location scope; browser identifiers are never authorization proof.
- The page editor composes React components with validated data bindings. Those bindings call typed runtime APIs, whose application services execute reviewed stored procedures.
- List, search, detail, relation, activity, favorite, and write operations reapply their required server-side permissions.
Add it to an app
- Open the SaaS app in the Developer Console and identify the user journey and page where this module belongs.
- Add the validated
module.chat-roomsmodule block through the supported page/template authoring flow. - Configure the module with the page editor's React components and validated data bindings; the bindings call authenticated platform APIs backed by reviewed stored procedures.
- Place the page in the correct user-type menus and assign existing DataRole, record, field, and location permissions.
- Test list, detail, search, empty, denied, stale-update, and cross-location behavior before publishing an exact version.
Copy/paste the Chat Rooms page
The native block includes secured rooms, unread state, create/edit/archive, membership and mute administration, messages, replies, file references, mentions, reactions, custom fields, relations, activity, and favorites:
{
"blocks": [
{ "_id": "team-chat", "_type": "module.chat-rooms", "props": {}, "children": [] }
]
}
Custom unstyled room and message view
Opening a room deliberately marks its latest visible message read for the current user. The server still applies record and membership scope before returning content.
import { FormEvent, useEffect, useState } from "react";
import type { ChatRoomDetail, ChatRoomsList } from "@buildwithhq/module-sdk";
import { getChatRoom, listChatRooms, markChatRoomRead, postChatMessage } from "./api";
const empty: ChatRoomsList = { contractVersion: 1, pageNumber: 1, pageSize: 50, totalRecords: 0, totalPages: 0, canCreate: false, items: [] };
export function UnstyledChatRooms({ locationId = "" }) {
const [draft, setDraft] = useState(""); const [search, setSearch] = useState(""); const [page, setPage] = useState(1);
const [rooms, setRooms] = useState<ChatRoomsList>(empty); const [selected, setSelected] = useState<ChatRoomDetail | null>(null);
useEffect(() => { const controller = new AbortController(); listChatRooms(search, false, controller.signal, locationId, page, 50).then(setRooms); return () => controller.abort(); }, [locationId, page, search]);
async function open(recordId: string) { const detail = await getChatRoom(recordId); setSelected(detail); await markChatRoomRead(recordId); }
async function send(event: FormEvent<HTMLFormElement>) { event.preventDefault(); if (!selected?.canPost) return; const form = event.currentTarget; const text = String(new FormData(form).get("message") || ""); await postChatMessage(selected.recordId, { messageText: text, mentionedUserIds: [] }); form.reset(); setSelected(await getChatRoom(selected.recordId)); }
return <section className="custom-chat"><h1>Team chat</h1><form role="search" onSubmit={e => { e.preventDefault(); setPage(1); setSearch(draft.trim()); }}><input aria-label="Search rooms" value={draft} onChange={e => setDraft(e.target.value)} /><button>Search</button></form>
<ul>{rooms.items.map(room => <li key={room.recordId}><button onClick={() => void open(room.recordId)}><strong>{room.roomName}</strong><span>{room.memberCount} members - {room.unreadCount} unread</span><small>{room.lastMessageText || "No messages"}</small></button></li>)}</ul>
<nav><button disabled={page <= 1} onClick={() => setPage(value => value - 1)}>Previous</button><span>Page {rooms.pageNumber} of {Math.max(1, rooms.totalPages)}</span><button disabled={page >= rooms.totalPages} onClick={() => setPage(value => value + 1)}>Next</button></nav>
{selected && <article><h2>{selected.roomName}</h2>{selected.messages.map(message => <p key={message.chatMessageId}><strong>{message.postedByDisplayName || "Member"}</strong>: {message.messageText}</p>)}{selected.canPost && <form onSubmit={send}><label>Message<textarea name="message" required maxLength={20000} /></label><button>Send</button></form>}</article>}
</section>;
}
Create, administer, react, and attach proof
Room replacement uses expectedUpdatedUtc. Member changes require room administration; a tenant-user ID is a target and is revalidated. Messages allow up to 50 unique mentions and one already-authorized File ID. Reactions are exactly like, love, celebrate, insightful, or ack.
import type { ChatRoomDetail } from "@buildwithhq/module-sdk";
import { createChatRoom, getChatRoom, postChatMessage, setChatReaction, setChatRoomFavorite, setChatRoomMember, updateChatRoom } from "./api";
const roomFields = (room: ChatRoomDetail) => room.dynamicFields.map(field => ({ fieldKey: field.fieldKey, valueText: field.valueText, valueInt: field.valueInt, valueDecimal: field.valueDecimal, valueDateTime: field.valueDateTime, valueBool: field.valueBool, valueGuid: field.valueGuid, valueJson: field.valueJson }));
export const createProjectRoom = (locationId?: string) => createChatRoom({ roomName: "Project team", description: "Daily coordination", roomType: 0, locationId, dynamicFields: [] });
export async function archiveRoom(room: ChatRoomDetail) { await updateChatRoom(room.recordId, { expectedUpdatedUtc: room.updatedUtc, roomName: room.roomName, description: room.description || "", roomType: room.roomType, isArchived: true, locationId: room.locationId || undefined, dynamicFields: roomFields(room) }); return getChatRoom(room.recordId); }
export async function addMember(room: ChatRoomDetail, memberUserId: string, isAdmin = false) { await setChatRoomMember(room.recordId, memberUserId, { isMember: true, isAdmin, isMuted: false }); return getChatRoom(room.recordId); }
export async function attachMessage(room: ChatRoomDetail, messageText: string, attachedFileId: string) { await postChatMessage(room.recordId, { messageText, attachedFileId, mentionedUserIds: [] }); return getChatRoom(room.recordId); }
export async function acknowledge(room: ChatRoomDetail, chatMessageId: string, isActive = true) { await setChatReaction(room.recordId, chatMessageId, "ack", isActive); return getChatRoom(room.recordId); }
export async function toggleRoomFavorite(room: ChatRoomDetail) { await setChatRoomFavorite(room.recordId, !room.isFavorite); return getChatRoom(room.recordId); }
Optional styling
.custom-chat { width: 100%; max-width: none; }
.custom-chat form, .custom-chat nav { display: flex; gap: .75rem; }
.custom-chat > ul { list-style: none; margin: 1rem 0; padding: 0; }
.custom-chat > ul button { background: transparent; border: 0; display: grid; padding: .75rem 0; text-align: left; width: 100%; }
.custom-chat article { border: 1px solid var(--line, #d8dee8); margin-top: 1rem; padding: 1rem; }
Exact data path
GET/POST /api/modules/chat-rooms, GET/PUT /{recordId}, POST /{recordId}/messages, and the /reaction, /members, /read, and /favorite actions map to the corresponding sp_ChatRooms_*Secured procedures. Lists are at most 200 rooms per page. Room detail returns a bounded message/member view; use a formal record or Conversation when durable workflow/channel semantics are required.
Build a professional Chat rooms dashboard
These six registry-backed presentation blocks let a designer turn the secured Chat rooms API into a complete admin page without writing a chart, grid, status badge, empty state, or timeline from scratch. The normal operational API begins at /api/modules/chat-rooms; a dashboard-wide count or trend should come from a separate purpose-built presentation binding so the browser never downloads a broad record population to calculate one number.
The ZIP contains this feature's fictional design composition at pages/first-class/chat-rooms-sample.json, its executable authenticated page at pages/first-class/chat-rooms-live.json, and the catalog-driven FirstClassPresentationGallery.tsx. Use the sample only for visual design. The live page calls the real native API and reviewed stored procedures under the signed-in user's scope.
| Payload kind | Runtime block | Useful Chat rooms projection |
|---|---|---|
metric-set | core.metric-strip | Accessible rooms, unread rooms, messages, and mentions |
entity-list | core.entity-list | Rooms the current member can open |
progress-list | core.progress-list | Distribution by activity, membership, or state |
series-chart | core.series-chart | Message and active-member volume |
data-grid | core.presentation-grid | One bounded member-authorized room page |
timeline | core.timeline | Room creation, membership, message, mention, pin, and archive |
Copy/paste design preview: all six blocks
This complete static page document renders immediately in Puck/Monaco and is useful while styling a template. Its names, counts, dates, and IDs are fictional design fixtures; static preview values are not live tenant facts.
Copy the complete six-block page JSON
{
"blocks": [
{
"_id": "chat-rooms-metrics",
"_type": "core.metric-strip",
"props": {
"title": "Chat room overview",
"asOfUtc": "2026-09-04T18:00:00Z",
"items": [
{
"key": "rooms",
"label": "Accessible rooms",
"value": 28,
"format": "number",
"tone": "neutral"
},
{
"key": "unread",
"label": "Unread rooms",
"value": 6,
"format": "number",
"tone": "warning"
},
{
"key": "messages",
"label": "Messages today",
"value": 214,
"format": "number",
"tone": "primary"
},
{
"key": "mentions",
"label": "Mentions",
"value": 9,
"format": "number",
"tone": "danger"
}
]
},
"children": []
},
{
"_id": "chat-rooms-recent",
"_type": "core.entity-list",
"props": {
"title": "Active rooms",
"hasMore": true,
"items": [
{
"id": "chat-rooms-sample-1",
"recordId": "chat-rooms-record-1",
"primary": "Field Operations",
"secondary": "18 members - 42 messages today",
"status": {
"key": "active",
"label": "Active",
"tone": "success"
},
"trailing": "3 unread"
},
{
"id": "chat-rooms-sample-2",
"recordId": "chat-rooms-record-2",
"primary": "Field Operations - Follow-up",
"secondary": "18 members - 42 messages today - Updated two hours ago by the assigned owner",
"status": {
"key": "in-review",
"label": "In review",
"tone": "primary"
},
"trailing": "Today"
},
{
"id": "chat-rooms-sample-3",
"recordId": "chat-rooms-record-3",
"primary": "Field Operations - West region",
"secondary": "18 members - 42 messages today - Related to three visible records at the Reno location",
"status": {
"key": "on-track",
"label": "On track",
"tone": "success"
},
"trailing": "3 related"
},
{
"id": "chat-rooms-sample-4",
"recordId": "chat-rooms-record-4",
"primary": "Field Operations - Customer response",
"secondary": "18 members - 42 messages today - Waiting for an external response before work can continue",
"status": {
"key": "scheduled",
"label": "Scheduled",
"tone": "warning"
},
"trailing": "Tomorrow"
},
{
"id": "chat-rooms-sample-5",
"recordId": "chat-rooms-record-5",
"primary": "Field Operations - Regional operations review with a deliberately long title",
"secondary": "18 members - 42 messages today - This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"status": {
"key": "needs-attention",
"label": "Needs attention",
"tone": "danger"
},
"trailing": "Review",
"tertiary": "Long-content fixture: verify keyboard focus, wrapping, narrow columns, and mobile overflow before publishing."
},
{
"id": "chat-rooms-sample-6",
"recordId": "chat-rooms-record-6",
"primary": "Field Operations - Completed preview",
"secondary": "18 members - 42 messages today - Closed after review with its related evidence retained",
"status": {
"key": "complete",
"label": "Complete",
"tone": "success"
},
"trailing": "Closed"
}
]
},
"children": []
},
{
"_id": "chat-rooms-bystatus",
"_type": "core.progress-list",
"props": {
"title": "Rooms by activity",
"items": [
{
"key": "high",
"label": "High activity",
"value": 7,
"maximum": 28,
"displayValue": "7",
"tone": "primary",
"status": {
"key": "high",
"label": "High activity",
"tone": "primary"
}
},
{
"key": "normal",
"label": "Normal",
"value": 15,
"maximum": 28,
"displayValue": "15",
"tone": "success",
"status": {
"key": "normal",
"label": "Normal",
"tone": "success"
}
},
{
"key": "quiet",
"label": "Quiet",
"value": 6,
"maximum": 28,
"displayValue": "6",
"tone": "neutral",
"status": {
"key": "quiet",
"label": "Quiet",
"tone": "neutral"
}
}
]
},
"children": []
},
{
"_id": "chat-rooms-trend",
"_type": "core.series-chart",
"props": {
"title": "Chat activity",
"variant": "bar",
"defaultPeriodKey": "d7",
"periods": [
{
"key": "d7",
"label": "7 days",
"labels": [
"Fri",
"Sat",
"Sun",
"Mon",
"Tue",
"Wed",
"Thu"
],
"series": [
{
"key": "primary",
"label": "Messages",
"tone": "primary",
"values": [
8,
5,
4,
12,
15,
11,
17
]
},
{
"key": "secondary",
"label": "Active members",
"tone": "success",
"values": [
6,
4,
3,
9,
12,
10,
14
]
}
]
}
]
},
"children": []
},
{
"_id": "chat-rooms-table",
"_type": "core.presentation-grid",
"props": {
"title": "Accessible chat rooms",
"columns": [
{
"key": "room",
"label": "Room",
"type": "text",
"align": "left"
},
{
"key": "members",
"label": "Members",
"type": "number",
"align": "right"
},
{
"key": "unread",
"label": "Unread",
"type": "number",
"align": "right"
},
{
"key": "status",
"label": "Status",
"type": "status",
"align": "left"
},
{
"key": "updated",
"label": "Last activity",
"type": "date",
"align": "left"
}
],
"rows": [
{
"id": "chat-rooms-row-1",
"recordId": "chat-rooms-record-1",
"cells": {
"room": "Field Operations",
"members": 18,
"unread": 3,
"status": {
"key": "active",
"label": "Active",
"tone": "success"
},
"updated": "2026-09-04T17:51:00Z"
}
},
{
"id": "chat-rooms-row-2",
"recordId": "chat-rooms-record-2",
"cells": {
"room": "Field Operations - Follow-up",
"members": 19,
"unread": 4,
"status": {
"key": "in-review",
"label": "In review",
"tone": "primary"
},
"updated": "2026-09-04T15:42:00Z"
}
},
{
"id": "chat-rooms-row-3",
"recordId": "chat-rooms-record-3",
"cells": {
"room": "Field Operations - West region",
"members": 20,
"unread": 5,
"status": {
"key": "on-track",
"label": "On track",
"tone": "success"
},
"updated": "2026-09-04T12:18:00Z"
}
},
{
"id": "chat-rooms-row-4",
"recordId": "chat-rooms-record-4",
"cells": {
"room": "Field Operations - Customer response",
"members": 21,
"unread": 6,
"status": {
"key": "scheduled",
"label": "Scheduled",
"tone": "warning"
},
"updated": "2026-09-03T21:07:00Z"
}
},
{
"id": "chat-rooms-row-5",
"recordId": "chat-rooms-record-5",
"cells": {
"room": "Field Operations - Regional operations review with a deliberately long title",
"members": 22,
"unread": 7,
"status": {
"key": "needs-attention",
"label": "Needs attention",
"tone": "danger"
},
"updated": "2026-09-03T16:31:00Z"
}
},
{
"id": "chat-rooms-row-6",
"recordId": "chat-rooms-record-6",
"cells": {
"room": "Field Operations - Completed preview",
"members": 23,
"unread": 8,
"status": {
"key": "complete",
"label": "Complete",
"tone": "success"
},
"updated": "2026-09-02T19:14:00Z"
}
}
],
"page": {
"pageNumber": 1,
"pageSize": 6,
"totalRecords": 28,
"totalIsExact": true,
"hasMore": true
}
},
"children": []
},
{
"_id": "chat-rooms-timeline",
"_type": "core.timeline",
"props": {
"title": "Room activity",
"hasMore": true,
"items": [
{
"id": "chat-rooms-event-1",
"recordId": "chat-rooms-record-1",
"occurredUtc": "2026-09-04T17:58:00Z",
"title": "Message posted",
"description": "A schedule update was posted in Field Operations.",
"actor": "Maya Chen",
"tone": "primary"
},
{
"id": "chat-rooms-event-2",
"recordId": "chat-rooms-record-2",
"occurredUtc": "2026-09-04T15:42:00Z",
"title": "Message posted - Follow-up",
"description": "A schedule update was posted in Field Operations. Updated two hours ago by the assigned owner.",
"actor": "Avery Patel",
"tone": "primary"
},
{
"id": "chat-rooms-event-3",
"recordId": "chat-rooms-record-3",
"occurredUtc": "2026-09-04T12:18:00Z",
"title": "Message posted - West region",
"description": "A schedule update was posted in Field Operations. Related to three visible records at the Reno location.",
"actor": "Sam Rivera",
"tone": "success"
},
{
"id": "chat-rooms-event-4",
"recordId": "chat-rooms-record-4",
"occurredUtc": "2026-09-03T21:07:00Z",
"title": "Message posted - Customer response",
"description": "A schedule update was posted in Field Operations. Waiting for an external response before work can continue.",
"actor": "Maya Chen",
"tone": "warning"
},
{
"id": "chat-rooms-event-5",
"recordId": "chat-rooms-record-5",
"occurredUtc": "2026-09-03T16:31:00Z",
"title": "Message posted - Regional operations review with a deliberately long title",
"description": "A schedule update was posted in Field Operations. This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"actor": "Automation",
"tone": "danger"
},
{
"id": "chat-rooms-event-6",
"recordId": "chat-rooms-record-6",
"occurredUtc": "2026-09-02T19:14:00Z",
"title": "Message posted - Completed preview",
"description": "A schedule update was posted in Field Operations. Closed after review with its related evidence retained.",
"actor": "Jordan Lee",
"tone": "success"
}
]
},
"children": []
}
]
}Copy/paste React composition
Copy InteractivePresentationComponents.tsx and its optional CSS from the Professional Foundation Developer Kit, then add this module-specific composition. It is semantic and unstyled by default; pass styled after importing interactive-presentation-components.css for the supplied polished foundation. Either version accepts only a bounded already-authorized document and fails closed through the shared strict parsers.
import {
EntityList,
MetricStrip,
PresentationGrid,
ProgressList,
SeriesChart,
Timeline,
} from "./InteractivePresentationComponents";
export interface ChatRoomsPresentationData {
readonly metrics: unknown;
readonly recent: unknown;
readonly byStatus: unknown;
readonly trend: unknown;
readonly table: unknown;
readonly timeline: unknown;
}
export interface ChatRoomsPresentationProps {
/** Pass only the already-authorized presentation document returned by the API. */
readonly data?: ChatRoomsPresentationData | null;
readonly loading?: boolean;
readonly error?: boolean;
readonly styled?: boolean;
/** Record identity is navigation context; the detail API must authorize it again. */
readonly onOpenRecord?: (recordId: string) => void;
}
export function ChatRoomsPresentation({
data,
loading = false,
error = false,
styled = false,
onOpenRecord,
}: ChatRoomsPresentationProps) {
if (error) return <p role="alert">The Chat rooms presentation could not be loaded.</p>;
if (loading || !data) return <p role="status">Loading Chat rooms presentation...</p>;
return (
<main className={styled ? "bwhq-api-example" : undefined}>
<header>
<p>Chat rooms</p>
<h1>Chat room overview</h1>
<p>Accessible rooms, unread work, membership, message volume, and recent collaboration.</p>
</header>
<MetricStrip data={data.metrics} styled={styled} />
<div className={styled ? "bwhq-api-example__split" : undefined}>
<ProgressList data={data.byStatus} styled={styled} />
<EntityList data={data.recent} styled={styled} onOpenRecord={onOpenRecord} />
</div>
<SeriesChart data={data.trend} styled={styled} />
<PresentationGrid data={data.table} styled={styled} onOpenRecord={onOpenRecord} />
<Timeline data={data.timeline} styled={styled} onOpenRecord={onOpenRecord} />
</main>
);
}
Copy the live, authenticated module page
This document has no placeholder key and needs no invented endpoint. Save it to a page and add that page to a User Type menu. The registered native component calls /api/modules/chat-rooms, uses the current tenant session, and preserves the module's real list, detail, create/update, pagination, empty, loading, and error behavior. Dynamic Records discovers the organization's real tenant-owned modules when no module key is configured.
Copy the executable live page
{
"blocks": [
{
"_id": "chat-rooms-live",
"_type": "module.chat-rooms",
"props": {},
"children": []
}
]
}The server derives SaaS app, organization, user, DataRoles, locations, module-specific membership/privacy, and any AI-read gate from verified identity. A returned identifier can select a detail target, but the detail or write endpoint authorizes it again. Page layout, status, tone, totals, action names, and identifiers never grant authority.
Use rooms for conversation, not as the only place a decision or task exists. Promote important outcomes into the appropriate record, workflow, or activity trail.
A renderer being bundled in the tenant application does not make its data visible in every app. The server returns only components and records authorized for the current app and signed-in user; unavailable or unauthorized blocks fail closed.
Capability review: 2026-09-14. For exact current technical availability, use the generated API Map and first-class module inventory.