First Class Modules
Contacts
Provides a secured contact directory with configurable contact types, search, create/detail experiences, custom fields, relationships, activity, and favorites.
module.contactsUse Contacts as the shared people-and-organization foundation for CRM, customer service, projects, field work, portals, sales, and other relationship-driven applications.
The bundled renderer key is module.contacts. 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 a secured contact directory with configurable contact types, search, create/detail experiences, custom fields, relationships, activity, and favorites.
Key capabilities
- Search and browse only the contacts visible to the current user.
- Create contacts with name, company, email, phone, and configurable type fields.
- Show secured contact detail, custom fields, related records, activity, and favorite state.
- Reuse the same contact identity across other modules instead of creating duplicate people records.
Common uses
- Customer and prospect directories.
- Members, vendors, partners, or project stakeholders.
- The person/company context behind conversations, events, work orders, files, and cases.
How it connects
Contacts commonly anchor Conversations, Calendar attendees, Files, Work Orders, CRM records, and portal membership. Typed relationships preserve the role a contact plays without flattening every relationship into the contact row.
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
Contact lists and details apply SaaS, tenant, DataRole, location, record, and field rules. Create and edit controls appear only when secured responses grant the corresponding capability; hidden or encrypted dynamic fields are not returned.
- 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.contactsmodule 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 Contacts page
Pagination: In the standard Contacts directory, users can choose 10, 25, 50, 100, or 200 rows per page. Set props.pageSize to an integer from 1 through 200 in the page block JSON to choose its initial size (default 25); a custom initial size is also added to the selector. A size change restarts at page one and keeps the search text. The choice is local to the mounted page, not a saved user preference. Multi-type composite directories retain their existing combined-list behavior and do not show this selector.
API callers control pageNumber and pageSize (1–200), browsing up to 5,000 authorized matches for the current filters. Refine the search to find other records. Fast rows are the default (includeTotal=false); follow hasMore for navigation and do not present an inexact list total as the full directory size. The separate secured count endpoint returns the full authorized total, even above 5,000. See progressive loading and exact counts for the executable SDK example, matching filters and cache boundaries. This browsing policy supersedes the earlier full-directory traversal behavior.
The native block is the fastest complete starting point. It supplies the secured directory, search, create, detail, custom-field display, relationship/activity counts, favorites, and normal server pagination. Paste this page document into Raw JSON mode:
{
"blocks": [
{
"_id": "contacts-directory",
"_type": "module.contacts",
"props": {
"title": "Customers and partners",
"singular": "contact",
"typeLabel": "Relationship",
"contactTypes": ["Customer", "Prospect", "Partner"]
},
"children": []
}
]
}
title, singular, and typeLabel change presentation only. contactTypes accepts at most eight values and is a bounded convenience for small segmented directories: the native renderer requests the first 100 authorized rows for each configured type, merges duplicates, and sorts the result. For a large directory, omit contactTypes and use the native server-paginated list.
Custom unstyled React directory
For custom presentation inside apps/tenant-runtime/src, keep the official authenticated API helpers and SDK parsers and replace only the markup. This example searches and pages on the server; it never downloads an entire directory and then filters it in the browser:
import { FormEvent, useEffect, useState } from "react";
import { contactDynamicValue, type ContactDetail, type ContactList } from "@buildwithhq/module-sdk";
import { getContact, listContacts } from "./api";
const emptyContacts: ContactList = {
contractVersion: 1,
pageNumber: 1,
pageSize: 100,
totalRecords: 0,
totalPages: 0,
totalIsExact: false,
hasMore: false,
items: [],
};
type UnstyledContactsDirectoryProps = {
contactType?: string;
};
export function UnstyledContactsDirectory({
contactType = "",
}: UnstyledContactsDirectoryProps) {
const [draftSearch, setDraftSearch] = useState("");
const [search, setSearch] = useState("");
const [pageNumber, setPageNumber] = useState(1);
const [result, setResult] = useState<ContactList>(emptyContacts);
const [selected, setSelected] = useState<ContactDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
listContacts(search, controller.signal, contactType, pageNumber, 100)
.then(setResult)
.catch((caught: unknown) => {
if (!controller.signal.aborted) {
setError(caught instanceof Error ? caught.message : "Contacts could not be loaded.");
}
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [contactType, pageNumber, search]);
function submitSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPageNumber(1);
setSearch(draftSearch.trim());
}
async function openContact(recordId: string) {
setError(null);
try {
setSelected(await getContact(recordId));
} catch (caught) {
setError(caught instanceof Error ? caught.message : "The contact could not be loaded.");
}
}
return (
<section className="custom-contacts" aria-labelledby="contacts-title" aria-busy={loading}>
<header>
<h1 id="contacts-title">Contacts</h1>
<p>Showing {result.items.length} on this page; {result.totalIsExact ? result.totalRecords : `at least ${result.totalRecords}`} authorized contacts found.</p>
</header>
<form role="search" onSubmit={submitSearch}>
<label>
Search name, company, email, or phone
<input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} />
</label>
<button type="submit" disabled={loading}>Search</button>
</form>
{error && <p role="alert">{error}</p>}
{!loading && result.items.length === 0 && <p>No visible contacts match this search.</p>}
<ul aria-label="Contact results">
{result.items.map((contact) => (
<li key={contact.recordId}>
<button type="button" onClick={() => void openContact(contact.recordId)}>
<strong>{contact.title}</strong>
<span>{contact.email || contact.phone || "No contact channel"}</span>
<span>{contact.locationName || "All locations"}</span>
{contact.isFavorite && <span aria-label="Favorite">★</span>}
</button>
</li>
))}
</ul>
<nav aria-label="Contact pages">
<button
type="button"
disabled={loading || result.pageNumber <= 1}
onClick={() => setPageNumber((page) => page - 1)}
>Previous</button>
<span>Page {result.pageNumber}{result.totalIsExact ? ` of ${Math.max(1, result.totalPages)}` : ""}</span>
<button
type="button"
disabled={loading || !result.hasMore}
onClick={() => setPageNumber((page) => page + 1)}
>Next</button>
</nav>
{selected && (
<article aria-label="Selected contact">
<h2>{selected.title}</h2>
<p>{selected.email || "No email"} · {selected.phone || selected.mobilePhone || "No phone"}</p>
<p>{selected.relationCount} related records · {selected.activityCount} activity items</p>
<dl>
{selected.dynamicFields.map((field) => (
<div key={field.fieldKey}>
<dt>{field.fieldLabel}</dt>
<dd>{contactDynamicValue(field)}</dd>
</div>
))}
</dl>
</article>
)}
</section>
);
}
Replacement-safe create and update
Create accepts native fields, an optional authorized location, and at most 200 typed dynamic-field values. Update is intentionally replacement-shaped: omitted native fields become empty. Always rebuild the request from the current secured detail, apply the user's changes, and send the current updatedUtc. A stale edit returns 409; reload before offering a deliberate retry.
import type {
ContactDetail,
ContactDynamicField,
ContactDynamicFieldInput,
ContactWrite,
} from "@buildwithhq/module-sdk";
import { createContact, setContactFavorite, updateContact } from "./api";
const dynamicInput = (field: ContactDynamicField): ContactDynamicFieldInput => ({
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 ?? undefined,
});
export const contactReplacement = (current: ContactDetail): ContactWrite => ({
expectedUpdatedUtc: current.updatedUtc,
locationId: current.locationId ?? undefined,
contactType: current.contactType ?? "",
firstName: current.firstName ?? "",
lastName: current.lastName ?? "",
companyName: current.companyName ?? "",
email: current.email ?? "",
phone: current.phone ?? "",
mobilePhone: current.mobilePhone ?? "",
address1: current.address1 ?? "",
address2: current.address2 ?? "",
city: current.city ?? "",
stateProvince: current.stateProvince ?? "",
postalCode: current.postalCode ?? "",
country: current.country ?? "",
notes: current.notes ?? "",
dynamicFields: current.dynamicFields.map(dynamicInput),
});
export async function addCustomer(locationId?: string) {
return createContact({
locationId,
contactType: "Customer",
firstName: "Ada",
lastName: "Lovelace",
companyName: "Analytical Engines",
email: "[email protected]",
dynamicFields: [{ fieldKey: "customerTier", valueText: "Gold" }],
});
}
export async function saveContact(
current: ContactDetail,
changes: Omit<ContactWrite, "expectedUpdatedUtc">,
) {
return updateContact(current.recordId, {
...contactReplacement(current),
...changes,
expectedUpdatedUtc: current.updatedUtc,
});
}
export async function toggleContactFavorite(current: ContactDetail) {
return setContactFavorite(current.recordId, !current.isFavorite);
}
Optional styling
The unstyled component uses semantic HTML and can inherit a product's own design system. This small layer makes a two-column directory without changing its data behavior:
.custom-contacts { width: 100%; max-width: none; }
.custom-contacts form { display: flex; gap: .75rem; align-items: end; }
.custom-contacts form label { flex: 1; }
.custom-contacts input { box-sizing: border-box; width: 100%; }
.custom-contacts ul { list-style: none; margin: 1rem 0; padding: 0; }
.custom-contacts li + li { border-top: 1px solid var(--line, #d8dee8); }
.custom-contacts li > button {
align-items: start;
background: transparent;
border: 0;
display: grid;
gap: .25rem;
padding: .85rem 0;
text-align: left;
width: 100%;
}
.custom-contacts nav { display: flex; gap: 1rem; align-items: center; }
@media (min-width: 800px) {
.custom-contacts { display: grid; grid-template-columns: minmax(18rem, 1fr) minmax(22rem, 2fr); gap: 1.5rem; }
}
Exact lifecycle and data path
| Purpose | Method and route | Contract behavior |
|---|---|---|
| Secured list/search | GET /api/modules/contacts?search=&contactType=&locationId=&pageNumber=1&pageSize=100 | Search first, then browse up to 5,000 matches; 1–200 rows per page; use hasMore for Next |
| Full authorized match count | GET /api/modules/contacts/count?search=&contactType=&locationId= | Same filters and security; no 5,000 cap on the total |
| Secured detail | GET /api/modules/contacts/{recordId} | Native and visible dynamic fields, relation/activity counts, and personal favorite state |
| Create | POST /api/modules/contacts | Requires create permission, an authorized location, and at least one name/company/email/phone value |
| Replace/update | PUT /api/modules/contacts/{recordId} | Requires edit permission and the exact expectedUpdatedUtc; stale state is 409 |
| Personal favorite | PUT /api/modules/contacts/{recordId}/favorite | Current signed-in user's favorite state only |
These routes derive SaaS, account, user, DataRole, and effective location scope from the authenticated server identity. The browser supplies search, filter, page, location, and record targets only. The service executes sp_Contacts_ListSecured, sp_Contacts_GetSecured, sp_Contacts_CreateSecured, sp_Contacts_UpdateSecured, and sp_Contacts_SetFavoriteSecured; React does not trim a broader SQL result after retrieval.
Validation failures return 400, permission failures return 403 where disclosure is safe, stale replacements return 409, and an absent or inaccessible detail target returns the same 404 contacts_not_found envelope so it cannot be used as a cross-tenant existence oracle.
See the complete React component catalog for all generated page blocks and operations, or the headless application guide when the React application lives outside the BuildWithHQ tenant shell.
Build a professional Contacts dashboard
These six registry-backed presentation blocks let a designer turn the secured Contacts 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/contacts; 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/contacts-sample.json, its executable authenticated page at pages/first-class/contacts-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 Contacts projection |
|---|---|---|
metric-set | core.metric-strip | Visible contacts, new records, companies, and favorites |
entity-list | core.entity-list | Recently touched people and organizations |
progress-list | core.progress-list | Distribution by contact type, location, or lifecycle |
series-chart | core.series-chart | Contact creation and updates over time |
data-grid | core.presentation-grid | One server-paged directory with only display fields |
timeline | core.timeline | Creation, edits, relationships, favorites, and related work |
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": "contacts-metrics",
"_type": "core.metric-strip",
"props": {
"title": "Contact overview",
"asOfUtc": "2026-09-04T18:00:00Z",
"items": [
{
"key": "visible",
"label": "Visible contacts",
"value": 1842,
"format": "number",
"tone": "neutral"
},
{
"key": "new",
"label": "New this month",
"value": 86,
"format": "number",
"tone": "success"
},
{
"key": "companies",
"label": "Companies",
"value": 412,
"format": "number",
"tone": "primary"
},
{
"key": "favorites",
"label": "Your favorites",
"value": 23,
"format": "number",
"tone": "warning"
}
]
},
"children": []
},
{
"_id": "contacts-recent",
"_type": "core.entity-list",
"props": {
"title": "Recently updated contacts",
"hasMore": true,
"items": [
{
"id": "contacts-sample-1",
"recordId": "contacts-record-1",
"primary": "Maya Chen",
"secondary": "Acme Field Services - Customer",
"status": {
"key": "active",
"label": "Active",
"tone": "success"
},
"trailing": "Reno"
},
{
"id": "contacts-sample-2",
"recordId": "contacts-record-2",
"primary": "Maya Chen - Follow-up",
"secondary": "Acme Field Services - Customer - Updated two hours ago by the assigned owner",
"status": {
"key": "in-review",
"label": "In review",
"tone": "primary"
},
"trailing": "Today"
},
{
"id": "contacts-sample-3",
"recordId": "contacts-record-3",
"primary": "Maya Chen - West region",
"secondary": "Acme Field Services - Customer - Related to three visible records at the Reno location",
"status": {
"key": "on-track",
"label": "On track",
"tone": "success"
},
"trailing": "3 related"
},
{
"id": "contacts-sample-4",
"recordId": "contacts-record-4",
"primary": "Maya Chen - Customer response",
"secondary": "Acme Field Services - Customer - Waiting for an external response before work can continue",
"status": {
"key": "scheduled",
"label": "Scheduled",
"tone": "warning"
},
"trailing": "Tomorrow"
},
{
"id": "contacts-sample-5",
"recordId": "contacts-record-5",
"primary": "Maya Chen - Regional operations review with a deliberately long title",
"secondary": "Acme Field Services - Customer - 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": "contacts-sample-6",
"recordId": "contacts-record-6",
"primary": "Maya Chen - Completed preview",
"secondary": "Acme Field Services - Customer - Closed after review with its related evidence retained",
"status": {
"key": "complete",
"label": "Complete",
"tone": "success"
},
"trailing": "Closed"
}
]
},
"children": []
},
{
"_id": "contacts-bystatus",
"_type": "core.progress-list",
"props": {
"title": "Contacts by type",
"items": [
{
"key": "customer",
"label": "Customers",
"value": 1104,
"maximum": 1842,
"displayValue": "1104",
"tone": "primary",
"status": {
"key": "customer",
"label": "Customers",
"tone": "primary"
}
},
{
"key": "prospect",
"label": "Prospects",
"value": 521,
"maximum": 1842,
"displayValue": "521",
"tone": "warning",
"status": {
"key": "prospect",
"label": "Prospects",
"tone": "warning"
}
},
{
"key": "partner",
"label": "Partners",
"value": 217,
"maximum": 1842,
"displayValue": "217",
"tone": "success",
"status": {
"key": "partner",
"label": "Partners",
"tone": "success"
}
}
]
},
"children": []
},
{
"_id": "contacts-trend",
"_type": "core.series-chart",
"props": {
"title": "Contact growth",
"variant": "bar",
"defaultPeriodKey": "d7",
"periods": [
{
"key": "d7",
"label": "7 days",
"labels": [
"Fri",
"Sat",
"Sun",
"Mon",
"Tue",
"Wed",
"Thu"
],
"series": [
{
"key": "primary",
"label": "Created",
"tone": "primary",
"values": [
8,
5,
4,
12,
15,
11,
17
]
},
{
"key": "secondary",
"label": "Updated",
"tone": "success",
"values": [
6,
4,
3,
9,
12,
10,
14
]
}
]
}
]
},
"children": []
},
{
"_id": "contacts-table",
"_type": "core.presentation-grid",
"props": {
"title": "Contact directory",
"columns": [
{
"key": "name",
"label": "Name",
"type": "text",
"align": "left"
},
{
"key": "company",
"label": "Company",
"type": "text",
"align": "left"
},
{
"key": "type",
"label": "Type",
"type": "status",
"align": "left"
},
{
"key": "location",
"label": "Location",
"type": "text",
"align": "left"
},
{
"key": "updated",
"label": "Updated",
"type": "date",
"align": "left"
}
],
"rows": [
{
"id": "contacts-row-1",
"recordId": "contacts-record-1",
"cells": {
"name": "Maya Chen",
"company": "Acme Field Services",
"type": {
"key": "customer",
"label": "Customer",
"tone": "primary"
},
"location": "Reno",
"updated": "2026-09-04T15:12:00Z"
}
},
{
"id": "contacts-row-2",
"recordId": "contacts-record-2",
"cells": {
"name": "Maya Chen - Follow-up",
"company": "Acme Field Services",
"type": {
"key": "in-review",
"label": "In review",
"tone": "primary"
},
"location": "Reno",
"updated": "2026-09-04T15:42:00Z"
}
},
{
"id": "contacts-row-3",
"recordId": "contacts-record-3",
"cells": {
"name": "Maya Chen - West region",
"company": "Acme Field Services",
"type": {
"key": "on-track",
"label": "On track",
"tone": "success"
},
"location": "Reno",
"updated": "2026-09-04T12:18:00Z"
}
},
{
"id": "contacts-row-4",
"recordId": "contacts-record-4",
"cells": {
"name": "Maya Chen - Customer response",
"company": "Acme Field Services",
"type": {
"key": "scheduled",
"label": "Scheduled",
"tone": "warning"
},
"location": "Reno",
"updated": "2026-09-03T21:07:00Z"
}
},
{
"id": "contacts-row-5",
"recordId": "contacts-record-5",
"cells": {
"name": "Maya Chen - Regional operations review with a deliberately long title",
"company": "Acme Field Services - This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"type": {
"key": "needs-attention",
"label": "Needs attention",
"tone": "danger"
},
"location": "Reno",
"updated": "2026-09-03T16:31:00Z"
}
},
{
"id": "contacts-row-6",
"recordId": "contacts-record-6",
"cells": {
"name": "Maya Chen - Completed preview",
"company": "Acme Field Services",
"type": {
"key": "complete",
"label": "Complete",
"tone": "success"
},
"location": "Reno",
"updated": "2026-09-02T19:14:00Z"
}
}
],
"page": {
"pageNumber": 1,
"pageSize": 6,
"totalRecords": 1842,
"totalIsExact": true,
"hasMore": true
}
},
"children": []
},
{
"_id": "contacts-timeline",
"_type": "core.timeline",
"props": {
"title": "Contact activity",
"hasMore": true,
"items": [
{
"id": "contacts-event-1",
"recordId": "contacts-record-1",
"occurredUtc": "2026-09-04T17:58:00Z",
"title": "Contact updated",
"description": "Phone and company details were refreshed.",
"actor": "Sam Rivera",
"tone": "neutral"
},
{
"id": "contacts-event-2",
"recordId": "contacts-record-2",
"occurredUtc": "2026-09-04T15:42:00Z",
"title": "Contact updated - Follow-up",
"description": "Phone and company details were refreshed. Updated two hours ago by the assigned owner.",
"actor": "Avery Patel",
"tone": "primary"
},
{
"id": "contacts-event-3",
"recordId": "contacts-record-3",
"occurredUtc": "2026-09-04T12:18:00Z",
"title": "Contact updated - West region",
"description": "Phone and company details were refreshed. Related to three visible records at the Reno location.",
"actor": "Sam Rivera",
"tone": "success"
},
{
"id": "contacts-event-4",
"recordId": "contacts-record-4",
"occurredUtc": "2026-09-03T21:07:00Z",
"title": "Contact updated - Customer response",
"description": "Phone and company details were refreshed. Waiting for an external response before work can continue.",
"actor": "Maya Chen",
"tone": "warning"
},
{
"id": "contacts-event-5",
"recordId": "contacts-record-5",
"occurredUtc": "2026-09-03T16:31:00Z",
"title": "Contact updated - Regional operations review with a deliberately long title",
"description": "Phone and company details were refreshed. This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"actor": "Automation",
"tone": "danger"
},
{
"id": "contacts-event-6",
"recordId": "contacts-record-6",
"occurredUtc": "2026-09-02T19:14:00Z",
"title": "Contact updated - Completed preview",
"description": "Phone and company details were refreshed. 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 ContactsPresentationData {
readonly metrics: unknown;
readonly recent: unknown;
readonly byStatus: unknown;
readonly trend: unknown;
readonly table: unknown;
readonly timeline: unknown;
}
export interface ContactsPresentationProps {
/** Pass only the already-authorized presentation document returned by the API. */
readonly data?: ContactsPresentationData | 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 ContactsPresentation({
data,
loading = false,
error = false,
styled = false,
onOpenRecord,
}: ContactsPresentationProps) {
if (error) return <p role="alert">The Contacts presentation could not be loaded.</p>;
if (loading || !data) return <p role="status">Loading Contacts presentation...</p>;
return (
<main className={styled ? "bwhq-api-example" : undefined}>
<header>
<p>Contacts</p>
<h1>Contact overview</h1>
<p>People and companies, type distribution, locations, engagement, and recent changes.</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/contacts, 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": "contacts-live",
"_type": "module.contacts",
"props": {
"experience": "directory",
"title": "Contact directory",
"pageSize": 25
},
"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.
Define a small contact-type vocabulary and decide whether organizations are contacts, related company records, or both before importing data.
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.