First Class Modules
Conversations
Provides native channel threads with participants, messages, status, priority, internal notes, outbound replies, related context, activity, dynamic fields, and favorites.
module.conversationsUse Conversations for durable communication history across internal, email, SMS, form-driven, and other supported channels while Universal Inbox points people to the threads that need attention.
The bundled renderer key is module.conversations. 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 native channel threads with participants, messages, status, priority, internal notes, outbound replies, related context, activity, dynamic fields, and favorites.
Key capabilities
- Search and filter conversation threads by status.
- Create internal conversations and manage subject, priority, and lifecycle.
- Read escaped message history and post internal notes or permitted outbound replies.
- Expose unread/favorite state, dynamic fields, related records, and activity.
Common uses
- Customer support and success communication.
- Project, sales, case, and operations correspondence.
- The native thread behind Universal Inbox triage and governed AI suggestions.
How it connects
Inbound providers normalize into native Conversations and messages, then create or update Inbox attention. Threads can relate to Contacts, orders, projects, Work Orders, Files, and any authorized record without making Inbox the source of truth.
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
Conversation, message, channel, DataRole, and location access is enforced server-side. Outbound capability and edit rights are explicit; provider credentials and direct channel endpoints never reach the browser.
- 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.conversationsmodule 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 Conversations page
The native block provides a secured thread queue, search/status filters, internal-thread creation, detail, edit/snooze state, messages, internal notes, outbound replies when permitted, attachments metadata, custom fields, and favorites:
{
"blocks": [
{ "_id": "customer-conversations", "_type": "module.conversations", "props": {}, "children": [] }
]
}
Custom unstyled queue and thread
The server applies status, channel, location, assignee, awaiting-reply, and search filters before returning a page. Message history is separately bounded to 1–500 messages; pass the oldest loaded timestamp as beforeUtc to request an older slice.
import { FormEvent, useEffect, useState } from "react";
import type { ConversationDetail, ConversationList, ConversationStatus } from "@buildwithhq/module-sdk";
import { getConversation, listConversations } from "./api";
const empty: ConversationList = { contractVersion: 1, pageNumber: 1, pageSize: 100, totalRecords: 0, totalPages: 0, totalIsExact: true, hasMore: false, items: [] };
export function UnstyledConversationQueue({ locationId = "", assignedToUserId = "" }) {
const [draft, setDraft] = useState("");
const [search, setSearch] = useState("");
const [status, setStatus] = useState<ConversationStatus | "">("");
const [awaitingReplyOnly, setAwaitingReplyOnly] = useState(false);
const [page, setPage] = useState(1);
const [result, setResult] = useState<ConversationList>(empty);
const [selected, setSelected] = useState<ConversationDetail | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
listConversations(search, status, controller.signal, undefined, locationId, assignedToUserId, awaitingReplyOnly, page, 100)
.then(setResult)
.catch((caught: unknown) => { if (!controller.signal.aborted) setError(caught instanceof Error ? caught.message : "Conversations could not be loaded."); });
return () => controller.abort();
}, [assignedToUserId, awaitingReplyOnly, locationId, page, search, status]);
function submit(event: FormEvent) { event.preventDefault(); setPage(1); setSearch(draft.trim()); }
async function open(recordId: string) { setSelected(await getConversation(recordId)); }
async function loadOlder(current: ConversationDetail) {
const oldest = current.messages[0]?.createdUtc;
if (oldest) setSelected(await getConversation(current.recordId, undefined, 200, oldest));
}
return <section className="custom-conversations">
<h1>Conversations</h1>
<form role="search" onSubmit={submit}><label>Search<input value={draft} onChange={e => setDraft(e.target.value)} /></label><label>Status<select value={status} onChange={e => { setPage(1); setStatus(e.target.value as ConversationStatus | ""); }}><option value="">All</option><option>Open</option><option>Pending</option><option>Snoozed</option><option>Closed</option></select></label><label><input type="checkbox" checked={awaitingReplyOnly} onChange={e => { setPage(1); setAwaitingReplyOnly(e.target.checked); }} /> Awaiting reply</label><button>Search</button></form>
{error && <p role="alert">{error}</p>}
<ul>{result.items.map(thread => <li key={thread.recordId}><button onClick={() => void open(thread.recordId)}><strong>{thread.subject}</strong><span>{thread.channelName} - {thread.status} - Priority {thread.priority}</span><small>{thread.lastMessagePreview || "No messages"}</small></button></li>)}</ul>
<nav><button disabled={page <= 1} onClick={() => setPage(value => value - 1)}>Previous</button><span>Page {result.pageNumber}</span><button disabled={!result.hasMore && page >= result.totalPages} onClick={() => setPage(value => value + 1)}>Next</button></nav>
{selected && <article><h2>{selected.subject}</h2><p>{selected.channelName} - {selected.status}</p>{selected.messages.map(message => <section key={message.conversationMessageId}><strong>{message.isPrivateNote ? "Internal note" : message.direction}</strong><p>{message.bodyText || "HTML message"}</p><small>{message.deliveryStatus}</small></section>)}{selected.messages.length === 200 && <button onClick={() => void loadOlder(selected)}>Load older messages</button>}</article>}
</section>;
}
Create, replace, reply, and send directly
Conversation replacement requires a fresh updatedUtc. Only offer Outbound when detail returns isOutboundCapable=true; otherwise post a private Note. Provider delivery is asynchronous and its returned delivery state is authoritative. Direct tenant-user messages use a client message ID for idempotency.
import type { ConversationDetail, ConversationDynamicField } from "@buildwithhq/module-sdk";
import { createConversation, getConversation, postConversationMessage, sendDirectConversation, setConversationFavorite, updateConversation } from "./api";
const fieldInput = (field: ConversationDynamicField) => ({ fieldKey: field.fieldKey, valueText: field.valueText ?? undefined, valueInt: field.valueInt ?? undefined, valueDecimal: field.valueDecimal ?? undefined, valueDateTime: field.valueDateTime ?? undefined, valueBool: field.valueBool ?? undefined, valueGuid: field.valueGuid ?? undefined, valueJson: field.valueJson });
export function createInternalThread(subject: string, contactRecordId?: string) {
return createConversation({ subject, contactRecordId, priority: 2 });
}
export async function closeThread(current: ConversationDetail) {
await updateConversation(current.recordId, {
expectedUpdatedUtc: current.updatedUtc, subject: current.subject, status: "Closed", priority: current.priority,
assignedToUserId: current.assignedToUserId || undefined, locationId: current.locationId || undefined,
contactRecordId: current.contactRecordId || undefined, dynamicFields: current.dynamicFields.map(fieldInput),
});
return getConversation(current.recordId);
}
export async function addInternalNote(current: ConversationDetail, text: string) {
await postConversationMessage(current.recordId, { direction: "Note", bodyText: text });
return getConversation(current.recordId);
}
export async function replyToCustomer(current: ConversationDetail, text: string) {
if (!current.isOutboundCapable) throw new Error("This channel does not permit outbound replies.");
await postConversationMessage(current.recordId, { direction: "Outbound", bodyText: text });
return getConversation(current.recordId);
}
export function messageTenantUser(recipientUserId: string, bodyText: string) {
return sendDirectConversation({ recipientUserId, clientMessageId: crypto.randomUUID(), bodyText });
}
export async function toggleConversationFavorite(current: ConversationDetail) {
await setConversationFavorite(current.recordId, !current.isFavorite);
return getConversation(current.recordId);
}
Optional starter styling
.custom-conversations { width: 100%; max-width: none; }
.custom-conversations form, .custom-conversations nav { align-items: end; display: flex; gap: .75rem; }
.custom-conversations > ul { list-style: none; margin: 1rem 0; padding: 0; }
.custom-conversations > ul button { background: transparent; border: 0; display: grid; gap: .2rem; padding: .75rem 0; text-align: left; width: 100%; }
.custom-conversations article { border: 1px solid var(--line, #d8dee8); margin-top: 1rem; padding: 1rem; }
.custom-conversations article section { border-top: 1px solid var(--line, #d8dee8); padding: .75rem 0; }
Exact data path
| Purpose | Route | Procedure |
|---|---|---|
| Queue/detail | GET /api/modules/conversations, GET /api/modules/conversations/{recordId} | sp_Conversations_ListSecured, sp_Conversations_GetSecured |
| Create/replace | POST /api/modules/conversations, PUT /api/modules/conversations/{recordId} | sp_Conversations_CreateSecured, sp_Conversations_UpdateSecured |
| Note/outbound reply | POST /api/modules/conversations/{recordId}/messages | sp_Conversations_ReplySecured |
| Direct tenant message | POST /api/modules/conversations/direct | sp_Conversations_SendDirectSecured |
| Favorite | PUT /api/modules/conversations/{recordId}/favorite | sp_Conversations_SetFavoriteSecured |
Universal Inbox is the attention layer; Conversation remains the message source of truth. Never duplicate messages into page state or treat an Inbox item as the thread. Outbound capability, channel connection, contact, assignee, location, and every attachment are reauthorized server-side.
Build a professional Conversations dashboard
These six registry-backed presentation blocks let a designer turn the secured Conversations 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/conversations; 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/conversations-sample.json, its executable authenticated page at pages/first-class/conversations-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 Conversations projection |
|---|---|---|
metric-set | core.metric-strip | Open, unread, waiting, and message-volume counts |
entity-list | core.entity-list | Recent permitted threads with channel and participant |
progress-list | core.progress-list | Distribution by channel, state, queue, or owner |
series-chart | core.series-chart | Inbound and outbound messages over time |
data-grid | core.presentation-grid | One bounded thread page without hidden message content |
timeline | core.timeline | Thread creation, messages, assignment, reply, reopen, and resolution |
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": "conversations-metrics",
"_type": "core.metric-strip",
"props": {
"title": "Conversation center",
"asOfUtc": "2026-09-04T18:00:00Z",
"items": [
{
"key": "open",
"label": "Open threads",
"value": 73,
"format": "number",
"tone": "primary"
},
{
"key": "unread",
"label": "Unread",
"value": 19,
"format": "number",
"tone": "warning"
},
{
"key": "waiting",
"label": "Waiting for us",
"value": 11,
"format": "number",
"tone": "danger"
},
{
"key": "today",
"label": "Messages today",
"value": 146,
"format": "number",
"tone": "success"
}
]
},
"children": []
},
{
"_id": "conversations-recent",
"_type": "core.entity-list",
"props": {
"title": "Recent conversations",
"hasMore": true,
"items": [
{
"id": "conversations-sample-1",
"recordId": "conversations-record-1",
"primary": "Installation timing",
"secondary": "Maya Chen - Email",
"status": {
"key": "waiting-for-us",
"label": "Waiting for us",
"tone": "warning"
},
"trailing": "4 messages"
},
{
"id": "conversations-sample-2",
"recordId": "conversations-record-2",
"primary": "Installation timing - Follow-up",
"secondary": "Maya Chen - Email - Updated two hours ago by the assigned owner",
"status": {
"key": "in-review",
"label": "In review",
"tone": "primary"
},
"trailing": "Today"
},
{
"id": "conversations-sample-3",
"recordId": "conversations-record-3",
"primary": "Installation timing - West region",
"secondary": "Maya Chen - Email - Related to three visible records at the Reno location",
"status": {
"key": "on-track",
"label": "On track",
"tone": "success"
},
"trailing": "3 related"
},
{
"id": "conversations-sample-4",
"recordId": "conversations-record-4",
"primary": "Installation timing - Customer response",
"secondary": "Maya Chen - Email - Waiting for an external response before work can continue",
"status": {
"key": "scheduled",
"label": "Scheduled",
"tone": "warning"
},
"trailing": "Tomorrow"
},
{
"id": "conversations-sample-5",
"recordId": "conversations-record-5",
"primary": "Installation timing - Regional operations review with a deliberately long title",
"secondary": "Maya Chen - Email - 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": "conversations-sample-6",
"recordId": "conversations-record-6",
"primary": "Installation timing - Completed preview",
"secondary": "Maya Chen - Email - Closed after review with its related evidence retained",
"status": {
"key": "complete",
"label": "Complete",
"tone": "success"
},
"trailing": "Closed"
}
]
},
"children": []
},
{
"_id": "conversations-bystatus",
"_type": "core.progress-list",
"props": {
"title": "Threads by channel",
"items": [
{
"key": "email",
"label": "Email",
"value": 45,
"maximum": 73,
"displayValue": "45",
"tone": "primary",
"status": {
"key": "email",
"label": "Email",
"tone": "primary"
}
},
{
"key": "sms",
"label": "SMS",
"value": 18,
"maximum": 73,
"displayValue": "18",
"tone": "success",
"status": {
"key": "sms",
"label": "SMS",
"tone": "success"
}
},
{
"key": "internal",
"label": "Internal",
"value": 10,
"maximum": 73,
"displayValue": "10",
"tone": "neutral",
"status": {
"key": "internal",
"label": "Internal",
"tone": "neutral"
}
}
]
},
"children": []
},
{
"_id": "conversations-trend",
"_type": "core.series-chart",
"props": {
"title": "Message volume",
"variant": "bar",
"defaultPeriodKey": "d7",
"periods": [
{
"key": "d7",
"label": "7 days",
"labels": [
"Fri",
"Sat",
"Sun",
"Mon",
"Tue",
"Wed",
"Thu"
],
"series": [
{
"key": "primary",
"label": "Inbound",
"tone": "primary",
"values": [
8,
5,
4,
12,
15,
11,
17
]
},
{
"key": "secondary",
"label": "Outbound",
"tone": "success",
"values": [
6,
4,
3,
9,
12,
10,
14
]
}
]
}
]
},
"children": []
},
{
"_id": "conversations-table",
"_type": "core.presentation-grid",
"props": {
"title": "Visible conversations",
"columns": [
{
"key": "subject",
"label": "Subject",
"type": "text",
"align": "left"
},
{
"key": "channel",
"label": "Channel",
"type": "text",
"align": "left"
},
{
"key": "participant",
"label": "Participant",
"type": "text",
"align": "left"
},
{
"key": "status",
"label": "Status",
"type": "status",
"align": "left"
},
{
"key": "updated",
"label": "Updated",
"type": "date",
"align": "left"
}
],
"rows": [
{
"id": "conversations-row-1",
"recordId": "conversations-record-1",
"cells": {
"subject": "Installation timing",
"channel": "Email",
"participant": "Maya Chen",
"status": {
"key": "waiting",
"label": "Waiting for us",
"tone": "warning"
},
"updated": "2026-09-04T17:28:00Z"
}
},
{
"id": "conversations-row-2",
"recordId": "conversations-record-2",
"cells": {
"subject": "Installation timing - Follow-up",
"channel": "Email",
"participant": "Maya Chen",
"status": {
"key": "in-review",
"label": "In review",
"tone": "primary"
},
"updated": "2026-09-04T15:42:00Z"
}
},
{
"id": "conversations-row-3",
"recordId": "conversations-record-3",
"cells": {
"subject": "Installation timing - West region",
"channel": "Email",
"participant": "Maya Chen",
"status": {
"key": "on-track",
"label": "On track",
"tone": "success"
},
"updated": "2026-09-04T12:18:00Z"
}
},
{
"id": "conversations-row-4",
"recordId": "conversations-record-4",
"cells": {
"subject": "Installation timing - Customer response",
"channel": "Email",
"participant": "Maya Chen",
"status": {
"key": "scheduled",
"label": "Scheduled",
"tone": "warning"
},
"updated": "2026-09-03T21:07:00Z"
}
},
{
"id": "conversations-row-5",
"recordId": "conversations-record-5",
"cells": {
"subject": "Installation timing - Regional operations review with a deliberately long title",
"channel": "Email - This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"participant": "Maya Chen",
"status": {
"key": "needs-attention",
"label": "Needs attention",
"tone": "danger"
},
"updated": "2026-09-03T16:31:00Z"
}
},
{
"id": "conversations-row-6",
"recordId": "conversations-record-6",
"cells": {
"subject": "Installation timing - Completed preview",
"channel": "Email",
"participant": "Maya Chen",
"status": {
"key": "complete",
"label": "Complete",
"tone": "success"
},
"updated": "2026-09-02T19:14:00Z"
}
}
],
"page": {
"pageNumber": 1,
"pageSize": 6,
"totalRecords": 73,
"totalIsExact": true,
"hasMore": true
}
},
"children": []
},
{
"_id": "conversations-timeline",
"_type": "core.timeline",
"props": {
"title": "Conversation activity",
"hasMore": true,
"items": [
{
"id": "conversations-event-1",
"recordId": "conversations-record-1",
"occurredUtc": "2026-09-04T17:58:00Z",
"title": "Inbound reply received",
"description": "The customer replied to Installation timing.",
"actor": "Maya Chen",
"tone": "primary"
},
{
"id": "conversations-event-2",
"recordId": "conversations-record-2",
"occurredUtc": "2026-09-04T15:42:00Z",
"title": "Inbound reply received - Follow-up",
"description": "The customer replied to Installation timing. Updated two hours ago by the assigned owner.",
"actor": "Avery Patel",
"tone": "primary"
},
{
"id": "conversations-event-3",
"recordId": "conversations-record-3",
"occurredUtc": "2026-09-04T12:18:00Z",
"title": "Inbound reply received - West region",
"description": "The customer replied to Installation timing. Related to three visible records at the Reno location.",
"actor": "Sam Rivera",
"tone": "success"
},
{
"id": "conversations-event-4",
"recordId": "conversations-record-4",
"occurredUtc": "2026-09-03T21:07:00Z",
"title": "Inbound reply received - Customer response",
"description": "The customer replied to Installation timing. Waiting for an external response before work can continue.",
"actor": "Maya Chen",
"tone": "warning"
},
{
"id": "conversations-event-5",
"recordId": "conversations-record-5",
"occurredUtc": "2026-09-03T16:31:00Z",
"title": "Inbound reply received - Regional operations review with a deliberately long title",
"description": "The customer replied to Installation timing. This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"actor": "Automation",
"tone": "danger"
},
{
"id": "conversations-event-6",
"recordId": "conversations-record-6",
"occurredUtc": "2026-09-02T19:14:00Z",
"title": "Inbound reply received - Completed preview",
"description": "The customer replied to Installation timing. 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 ConversationsPresentationData {
readonly metrics: unknown;
readonly recent: unknown;
readonly byStatus: unknown;
readonly trend: unknown;
readonly table: unknown;
readonly timeline: unknown;
}
export interface ConversationsPresentationProps {
/** Pass only the already-authorized presentation document returned by the API. */
readonly data?: ConversationsPresentationData | 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 ConversationsPresentation({
data,
loading = false,
error = false,
styled = false,
onOpenRecord,
}: ConversationsPresentationProps) {
if (error) return <p role="alert">The Conversations presentation could not be loaded.</p>;
if (loading || !data) return <p role="status">Loading Conversations presentation...</p>;
return (
<main className={styled ? "bwhq-api-example" : undefined}>
<header>
<p>Conversations</p>
<h1>Conversation center</h1>
<p>Open threads, unread activity, channels, response state, and recent messages.</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/conversations, 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": "conversations-live",
"_type": "module.conversations",
"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 one canonical thread per real conversation and relate it to business context. Avoid copying message bodies into custom records or building a second inbox table family.
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.