First Class Modules
Record activity
Shows a paged, filterable, chronological activity timeline for an authorized record with actor, event type/state, date range, relations, and favorite state.
module.record-activityUse Record Activity to give users an understandable operational history of what happened around a record without exposing raw database auditing or internal event payloads.
The bundled renderer key is module.record-activity. 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
Shows a paged, filterable, chronological activity timeline for an authorized record with actor, event type/state, date range, relations, and favorite state.
Key capabilities
- Search visible records across modules and select a record timeline.
- Filter activity by event type and date range.
- Group events chronologically with summary, actor, state, and occurrence time.
- Page bounded history and show secured relation/favorite context.
Common uses
- Customer, project, work-order, case, and asset timelines.
- Operational handoffs and investigation context.
- A user-facing history adjacent to formal compliance/audit evidence.
How it connects
Activity aggregates the reviewed events emitted by native and dynamic records. Formal security/compliance evidence remains in its audit contracts; the activity module provides the bounded business-facing view.
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
The selected record must pass normal scope checks, and only safe activity summaries/metadata are rendered. Private event detail payloads stay outside the browser surface.
- 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.record-activitymodule 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 complete Record Activity page
The native block supplies secured record selection, event/date filtering, bounded timeline pages, actor/state summaries, dynamic fields, relation counts, and favorites.
{
"blocks": [
{ "_id": "record-history", "_type": "module.record-activity", "props": {}, "children": [] }
]
}
Custom unstyled React record picker and timeline
The record picker uses the stable title/RecordId cursor. After selection, activity uses independent pages of at most 100 safe event summaries. This component deliberately does not render the internal detail object.
import { FormEvent, useEffect, useState } from "react";
import type { RecordActivityResponse, RecordActivitySearchResponse } from "@buildwithhq/module-sdk";
import { getRecordActivity, searchRecordActivity } from "./api";
const empty: RecordActivitySearchResponse = { contractVersion: 1, pageNumber: 1, pageSize: 50, totalRecords: 0, totalPages: 0, totalIsExact: false, hasMore: false, modules: [], items: [] };
type Cursor = { afterTitle?: string; afterRecordId?: string };
const startOfDayUtc = (date: string) => date ? `${date}T00:00:00.000Z` : "";
const endOfDayUtc = (date: string) => date ? `${date}T23:59:59.999Z` : "";
export function UnstyledRecordActivity({ locationId = "" }) {
const [draft, setDraft] = useState("");
const [search, setSearch] = useState("");
const [moduleKey, setModuleKey] = useState("");
const [cursors, setCursors] = useState<Cursor[]>([{}]);
const [records, setRecords] = useState<RecordActivitySearchResponse>(empty);
const [activity, setActivity] = useState<RecordActivityResponse | null>(null);
const [activityType, setActivityType] = useState("");
const [fromDate, setFromDate] = useState("");
const [toDate, setToDate] = useState("");
const [error, setError] = useState<string | null>(null);
const cursor = cursors[cursors.length - 1];
useEffect(() => {
const controller = new AbortController();
searchRecordActivity(moduleKey, search, locationId, controller.signal, cursor.afterTitle, cursor.afterRecordId, 50)
.then(setRecords)
.catch((caught: unknown) => { if (!controller.signal.aborted) setError(caught instanceof Error ? caught.message : "Records could not be loaded."); });
return () => controller.abort();
}, [cursor.afterRecordId, cursor.afterTitle, locationId, moduleKey, search]);
async function loadTimeline(recordId: string, page = 1) { setActivity(await getRecordActivity(recordId, activityType, startOfDayUtc(fromDate), endOfDayUtc(toDate), page, undefined, 50)); }
function submit(event: FormEvent) { event.preventDefault(); setCursors([{}]); setActivity(null); setSearch(draft.trim()); }
function nextRecords() { if (records.hasMore && records.nextRecordId) setCursors(current => [...current, { afterTitle: records.nextTitle || undefined, afterRecordId: records.nextRecordId || undefined }]); }
return <section className="custom-record-activity">
<h1>Record activity</h1>
<form role="search" onSubmit={submit}><label>Find a record<input value={draft} maxLength={200} onChange={event => setDraft(event.target.value)} /></label><label>Module<select value={moduleKey} onChange={event => { setModuleKey(event.target.value); setCursors([{}]); }}><option value="">All modules</option>{records.modules.map(module => <option key={module.moduleKey} value={module.moduleKey}>{module.moduleName}</option>)}</select></label><button>Search</button></form>
{error && <p role="alert">{error}</p>}
<div className="activity-columns"><aside><ol>{records.items.map(record => <li key={record.recordId}><button onClick={() => void loadTimeline(record.recordId)}><strong>{record.title}</strong><span>{record.moduleName} - {record.locationName || "All locations"}</span></button></li>)}</ol><nav aria-label="Record batches"><button disabled={cursors.length === 1} onClick={() => setCursors(current => current.slice(0, -1))}>Previous</button><span>Batch {cursors.length}</span><button disabled={!records.hasMore || !records.nextRecordId} onClick={nextRecords}>Next</button></nav></aside><main>{activity ? <><header><h2>{activity.title}</h2><p>{activity.moduleName} - {activity.recordStatus || "No status"} - {activity.relationCount} relations</p></header><form onSubmit={event => { event.preventDefault(); void loadTimeline(activity.recordId, 1); }}><label>Event type<select value={activityType} onChange={event => setActivityType(event.target.value)}><option value="">All</option>{activity.activityTypes.map(type => <option key={type.activityType}>{type.activityType}</option>)}</select></label><label>From<input type="date" value={fromDate} onChange={event => setFromDate(event.target.value)} /></label><label>To<input type="date" min={fromDate || undefined} value={toDate} onChange={event => setToDate(event.target.value)} /></label><button>Apply</button></form><ol className="timeline">{activity.items.map(item => <li key={item.activityKey}><strong>{item.summary}</strong><span>{item.activityType}{item.activityState ? ` - ${item.activityState}` : ""}</span><small>{item.actorDisplayName || "System"} - <time dateTime={item.occurredUtc}>{new Date(item.occurredUtc).toLocaleString()}</time></small></li>)}</ol><nav aria-label="Timeline pages"><button disabled={activity.pageNumber <= 1} onClick={() => void loadTimeline(activity.recordId, activity.pageNumber - 1)}>Previous</button><span>Page {activity.pageNumber} of {Math.max(1, activity.totalPages)}</span><button disabled={activity.pageNumber >= activity.totalPages} onClick={() => void loadTimeline(activity.recordId, activity.pageNumber + 1)}>Next</button></nav></> : <p>Select a record.</p>}</main></div>
</section>;
}
Favorite the source record
Activity is read-only by design. There is no generic “create activity” route: Contacts, Work Orders, workflows, Conversations, and other owning modules emit reviewed business events as their real operations occur.
import type { RecordActivityResponse } from "@buildwithhq/module-sdk";
import { getRecordActivity, setRecordActivityFavorite } from "./api";
export async function toggleActivityRecordFavorite(current: RecordActivityResponse) {
await setRecordActivityFavorite(current.recordId, !current.isFavorite);
return getRecordActivity(current.recordId, "", "", "", 1, undefined, 50);
}
Optional starter styling
.custom-record-activity { width: 100%; max-width: none; }
.custom-record-activity > form, .activity-columns form, .activity-columns nav { align-items: end; display: flex; flex-wrap: wrap; gap: .75rem; }
.activity-columns { display: grid; gap: 1rem; grid-template-columns: 280px minmax(0, 1fr); margin-top: 1rem; }
.activity-columns ol { list-style: none; margin: 0; padding: 0; }
.activity-columns aside li button { background: transparent; border: 0; display: grid; padding: .65rem; text-align: left; width: 100%; }
.activity-columns main { border: 1px solid var(--line, #d8dee8); padding: 1rem; }
.timeline li { border-left: 3px solid var(--accent, #4967d5); display: grid; gap: .2rem; margin: .75rem 0; padding: .25rem .75rem; }
@media (max-width: 760px) { .activity-columns { grid-template-columns: 1fr; } }
Exact data path
| Purpose | Route | Procedure |
|---|---|---|
| Cursor record search | GET /api/modules/record-activity/records | sp_RecordActivity_SearchSecured |
| Paged safe timeline | GET /api/modules/record-activity/{recordId} | sp_RecordActivity_GetSecured |
| Favorite source | PUT /api/modules/record-activity/{recordId}/favorite | sp_Favorites_SetSecured |
Record search accepts up to 200 rows but the example uses 50; timeline detail accepts up to 100. This user-facing history is not the immutable security/audit log, and safe summaries should not expose raw event payloads.
Build a professional Record activity dashboard
These six registry-backed presentation blocks let a designer turn the secured Record activity 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/record-activity; 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/record-activity-sample.json, its executable authenticated page at pages/first-class/record-activity-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 Record activity projection |
|---|---|---|
metric-set | core.metric-strip | Event volume, creates, updates, workflow, and attention counts |
entity-list | core.entity-list | Records with the newest visible activity |
progress-list | core.progress-list | Distribution by module, event type, actor, or state |
series-chart | core.series-chart | Created, updated, related, approved, and completed events |
data-grid | core.presentation-grid | One bounded safe-summary event page |
timeline | core.timeline | Chronological safe summaries without private event payloads |
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": "record-activity-metrics",
"_type": "core.metric-strip",
"props": {
"title": "Record activity overview",
"asOfUtc": "2026-09-04T18:00:00Z",
"items": [
{
"key": "events",
"label": "Events today",
"value": 486,
"format": "number",
"tone": "primary"
},
{
"key": "creates",
"label": "Records created",
"value": 74,
"format": "number",
"tone": "success"
},
{
"key": "updates",
"label": "Records updated",
"value": 361,
"format": "number",
"tone": "neutral"
},
{
"key": "attention",
"label": "Attention events",
"value": 12,
"format": "number",
"tone": "warning"
}
]
},
"children": []
},
{
"_id": "record-activity-recent",
"_type": "core.entity-list",
"props": {
"title": "Recently active records",
"hasMore": true,
"items": [
{
"id": "record-activity-sample-1",
"recordId": "record-activity-record-1",
"primary": "WO-1048 - North Wing closeout",
"secondary": "Work Orders - 8 events",
"status": {
"key": "updated",
"label": "Updated",
"tone": "primary"
},
"trailing": "2 min ago"
},
{
"id": "record-activity-sample-2",
"recordId": "record-activity-record-2",
"primary": "WO-1048 - North Wing closeout - Follow-up",
"secondary": "Work Orders - 8 events - Updated two hours ago by the assigned owner",
"status": {
"key": "in-review",
"label": "In review",
"tone": "primary"
},
"trailing": "Today"
},
{
"id": "record-activity-sample-3",
"recordId": "record-activity-record-3",
"primary": "WO-1048 - North Wing closeout - West region",
"secondary": "Work Orders - 8 events - Related to three visible records at the Reno location",
"status": {
"key": "on-track",
"label": "On track",
"tone": "success"
},
"trailing": "3 related"
},
{
"id": "record-activity-sample-4",
"recordId": "record-activity-record-4",
"primary": "WO-1048 - North Wing closeout - Customer response",
"secondary": "Work Orders - 8 events - Waiting for an external response before work can continue",
"status": {
"key": "scheduled",
"label": "Scheduled",
"tone": "warning"
},
"trailing": "Tomorrow"
},
{
"id": "record-activity-sample-5",
"recordId": "record-activity-record-5",
"primary": "WO-1048 - North Wing closeout - Regional operations review with a deliberately long title",
"secondary": "Work Orders - 8 events - 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": "record-activity-sample-6",
"recordId": "record-activity-record-6",
"primary": "WO-1048 - North Wing closeout - Completed preview",
"secondary": "Work Orders - 8 events - Closed after review with its related evidence retained",
"status": {
"key": "complete",
"label": "Complete",
"tone": "success"
},
"trailing": "Closed"
}
]
},
"children": []
},
{
"_id": "record-activity-bystatus",
"_type": "core.progress-list",
"props": {
"title": "Activity by module",
"items": [
{
"key": "work",
"label": "Work Orders",
"value": 181,
"maximum": 486,
"displayValue": "181",
"tone": "primary",
"status": {
"key": "work",
"label": "Work Orders",
"tone": "primary"
}
},
{
"key": "contacts",
"label": "Contacts",
"value": 163,
"maximum": 486,
"displayValue": "163",
"tone": "success",
"status": {
"key": "contacts",
"label": "Contacts",
"tone": "success"
}
},
{
"key": "files",
"label": "Files",
"value": 142,
"maximum": 486,
"displayValue": "142",
"tone": "neutral",
"status": {
"key": "files",
"label": "Files",
"tone": "neutral"
}
}
]
},
"children": []
},
{
"_id": "record-activity-trend",
"_type": "core.series-chart",
"props": {
"title": "Activity volume",
"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": "record-activity-table",
"_type": "core.presentation-grid",
"props": {
"title": "Authorized activity events",
"columns": [
{
"key": "record",
"label": "Record",
"type": "text",
"align": "left"
},
{
"key": "module",
"label": "Module",
"type": "text",
"align": "left"
},
{
"key": "event",
"label": "Event",
"type": "text",
"align": "left"
},
{
"key": "actor",
"label": "Actor",
"type": "text",
"align": "left"
},
{
"key": "occurred",
"label": "Occurred",
"type": "date",
"align": "left"
}
],
"rows": [
{
"id": "record-activity-row-1",
"recordId": "record-activity-record-1",
"cells": {
"record": "WO-1048",
"module": "Work Orders",
"event": "Status changed",
"actor": "Maya Chen",
"occurred": "2026-09-04T17:57:00Z"
}
},
{
"id": "record-activity-row-2",
"recordId": "record-activity-record-2",
"cells": {
"record": "WO-1048 - Follow-up",
"module": "Work Orders",
"event": "Status changed",
"actor": "Maya Chen",
"occurred": "2026-09-04T15:42:00Z"
}
},
{
"id": "record-activity-row-3",
"recordId": "record-activity-record-3",
"cells": {
"record": "WO-1048 - West region",
"module": "Work Orders",
"event": "Status changed",
"actor": "Maya Chen",
"occurred": "2026-09-04T12:18:00Z"
}
},
{
"id": "record-activity-row-4",
"recordId": "record-activity-record-4",
"cells": {
"record": "WO-1048 - Customer response",
"module": "Work Orders",
"event": "Status changed",
"actor": "Maya Chen",
"occurred": "2026-09-03T21:07:00Z"
}
},
{
"id": "record-activity-row-5",
"recordId": "record-activity-record-5",
"cells": {
"record": "WO-1048 - Regional operations review with a deliberately long title",
"module": "Work Orders - This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"event": "Status changed",
"actor": "Maya Chen",
"occurred": "2026-09-03T16:31:00Z"
}
},
{
"id": "record-activity-row-6",
"recordId": "record-activity-record-6",
"cells": {
"record": "WO-1048 - Completed preview",
"module": "Work Orders",
"event": "Status changed",
"actor": "Maya Chen",
"occurred": "2026-09-02T19:14:00Z"
}
}
],
"page": {
"pageNumber": 1,
"pageSize": 6,
"totalRecords": 486,
"totalIsExact": true,
"hasMore": true
}
},
"children": []
},
{
"_id": "record-activity-timeline",
"_type": "core.timeline",
"props": {
"title": "Recent authorized activity",
"hasMore": true,
"items": [
{
"id": "record-activity-event-1",
"recordId": "record-activity-record-1",
"occurredUtc": "2026-09-04T17:58:00Z",
"title": "Status changed",
"description": "WO-1048 moved from In progress to Ready for signoff.",
"actor": "Maya Chen",
"tone": "primary"
},
{
"id": "record-activity-event-2",
"recordId": "record-activity-record-2",
"occurredUtc": "2026-09-04T15:42:00Z",
"title": "Status changed - Follow-up",
"description": "WO-1048 moved from In progress to Ready for signoff. Updated two hours ago by the assigned owner.",
"actor": "Avery Patel",
"tone": "primary"
},
{
"id": "record-activity-event-3",
"recordId": "record-activity-record-3",
"occurredUtc": "2026-09-04T12:18:00Z",
"title": "Status changed - West region",
"description": "WO-1048 moved from In progress to Ready for signoff. Related to three visible records at the Reno location.",
"actor": "Sam Rivera",
"tone": "success"
},
{
"id": "record-activity-event-4",
"recordId": "record-activity-record-4",
"occurredUtc": "2026-09-03T21:07:00Z",
"title": "Status changed - Customer response",
"description": "WO-1048 moved from In progress to Ready for signoff. Waiting for an external response before work can continue.",
"actor": "Maya Chen",
"tone": "warning"
},
{
"id": "record-activity-event-5",
"recordId": "record-activity-record-5",
"occurredUtc": "2026-09-03T16:31:00Z",
"title": "Status changed - Regional operations review with a deliberately long title",
"description": "WO-1048 moved from In progress to Ready for signoff. This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"actor": "Automation",
"tone": "danger"
},
{
"id": "record-activity-event-6",
"recordId": "record-activity-record-6",
"occurredUtc": "2026-09-02T19:14:00Z",
"title": "Status changed - Completed preview",
"description": "WO-1048 moved from In progress to Ready for signoff. 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 RecordActivityPresentationData {
readonly metrics: unknown;
readonly recent: unknown;
readonly byStatus: unknown;
readonly trend: unknown;
readonly table: unknown;
readonly timeline: unknown;
}
export interface RecordActivityPresentationProps {
/** Pass only the already-authorized presentation document returned by the API. */
readonly data?: RecordActivityPresentationData | 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 RecordActivityPresentation({
data,
loading = false,
error = false,
styled = false,
onOpenRecord,
}: RecordActivityPresentationProps) {
if (error) return <p role="alert">The Record activity presentation could not be loaded.</p>;
if (loading || !data) return <p role="status">Loading Record activity presentation...</p>;
return (
<main className={styled ? "bwhq-api-example" : undefined}>
<header>
<p>Record activity</p>
<h1>Record activity overview</h1>
<p>Safe event summaries, actors, source modules, status changes, and recent authorized history.</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/record-activity, 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": "record-activity-live",
"_type": "module.record-activity",
"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.
Write concise activity summaries when workflows change important state. Users should understand the event without needing internal JSON or database knowledge.
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.