---
title: "Contacts"
section: "First Class Modules"
canonical_url: "https://support.buildwithhq.com/first-class-modules/contacts.html"
reviewed_at: "2026-09-14"
authority: Official
---

# Contacts

Provides a secured contact directory with configurable contact types, search, create/detail experiences, custom fields, relationships, activity, and favorites.

## In brief

- **Runtime key:** module.contacts
- **Experience:** People & organizations
- **Security:** Server-enforced

Use Contacts as the shared people-and-organization foundation for CRM, customer service, projects, field work, portals, sales, and other relationship-driven applications.

[Open SaaS Apps](https://dev.buildwithhq.com/apps) [Page editor](../builder-guide/page-editor.html) [Menus & user types](../builder-guide/menus-user-types.html) [Templates](../builder-guide/templates-forking.html)

Note

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

1. Open the SaaS app in the Developer Console and identify the user journey and page where this module belongs.
2. Add the validated `module.contacts` module block through the supported page/template authoring flow.
3. 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.
4. Place the page in the correct user-type menus and assign existing DataRole, record, field, and location permissions.
5. 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](../api/progressive-loading-and-counts.html)  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: "ada@example.test",
    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](../builder-guide/react-component-catalog.html)  for all generated page blocks and operations, or the [headless application guide](../builder-guide/headless-application-kit.html)  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.

[Download Developer Kit 1.20.13](../downloads/BuildWithHQ-API43-Developer-Kit-1.20.13.zip) [Installation and component guide](../builder-guide/professional-template-foundation.html) [Complete headless SaaS guide](../api/headless-saas.html)

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](../builder-guide/professional-template-foundation.html), 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.

Tip

Define a small contact-type vocabulary and decide whether organizations are contacts, related company records, or both before importing data.

Important

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. [View the canonical guide](https://support.buildwithhq.com/first-class-modules/contacts.html).
