First Class Modules

Files

Provides private file upload, download, metadata, tags, record attachments, comments, locks, custom fields, relationships, activity, and favorites without exposing storage coordinates.

Open raw .md
Runtime key
module.files
Experience
Documents & evidence
Security
Server-enforced

Use Files as the governed document and attachment layer for contracts, photos, manuals, evidence, exports, project artifacts, and other content that belongs beside business records.

Note

The bundled renderer key is module.files. 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 private file upload, download, metadata, tags, record attachments, comments, locks, custom fields, relationships, activity, and favorites without exposing storage coordinates.

Key capabilities

  • Search and filter secured file metadata by name and content type.
  • Upload, describe, tag, edit, and privately download authorized files.
  • Attach one file to multiple permitted records without duplicating the binary.
  • Show comments, locks, custom fields, relations, activity, and favorite state.

Common uses

  • Customer and project documents.
  • Inspection photos, work-order evidence, manuals, and invoices.
  • Case evidence, knowledge source files, and signed approvals.

How it connects

Files attach to canonical Records across Contacts, Work Orders, Conversations, cases, and dynamic modules. AI ingestion may reference approved files through the secured source pipeline while storage keys and provider credentials stay server-only.

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

Download and upload flow through authenticated backend endpoints. The browser never receives provider credentials, tenant database identity, storage paths, or unrestricted object-store locations; attachment targets are reauthorized independently.

  • 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.files 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 Files page

The native block includes secured list/search, upload, metadata replacement, detail, tags, locks, attachment links, download, dynamic fields, and favorites:

{
  "blocks": [
    { "_id": "secured-files", "_type": "module.files", "props": {}, "children": [] }
  ]
}

Custom unstyled file library

Search, MIME-prefix filtering, location filtering, and paging execute on the server. Detail intentionally contains no storage provider, path, or object key.

import { FormEvent, useEffect, useState } from "react";
import type { FileDetail, FileList } from "@buildwithhq/module-sdk";
import { downloadFileContent, getFile, listFiles } from "./api";

const empty: FileList = { contractVersion: 1, pageNumber: 1, pageSize: 100, totalRecords: 0, totalPages: 0, items: [] };

export function UnstyledFileLibrary({ locationId = "" }) {
  const [draft, setDraft] = useState("");
  const [search, setSearch] = useState("");
  const [contentType, setContentType] = useState("");
  const [page, setPage] = useState(1);
  const [result, setResult] = useState<FileList>(empty);
  const [selected, setSelected] = useState<FileDetail | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    listFiles(search, contentType, controller.signal, locationId, page, 100)
      .then(setResult)
      .catch((caught: unknown) => { if (!controller.signal.aborted) setError(caught instanceof Error ? caught.message : "Files could not be loaded."); });
    return () => controller.abort();
  }, [contentType, locationId, page, search]);

  function submit(event: FormEvent) { event.preventDefault(); setPage(1); setSearch(draft.trim()); }
  async function open(recordId: string) { setSelected(await getFile(recordId)); }
  async function download(file: FileDetail) {
    const blob = await downloadFileContent(file.recordId);
    const url = URL.createObjectURL(blob);
    const link = document.createElement("a");
    link.href = url; link.download = file.fileName; link.click();
    URL.revokeObjectURL(url);
  }

  return <section className="custom-files">
    <h1>Files</h1>
    <form role="search" onSubmit={submit}><label>Search<input value={draft} onChange={e => setDraft(e.target.value)} /></label><label>Type<select value={contentType} onChange={e => { setPage(1); setContentType(e.target.value); }}><option value="">All</option><option value="image/">Images</option><option value="application/pdf">PDF</option></select></label><button>Search</button></form>
    {error && <p role="alert">{error}</p>}
    <ul>{result.items.map(file => <li key={file.recordId}><button onClick={() => void open(file.recordId)}><strong>{file.fileName}</strong><span>{file.contentType} - {file.sizeBytes} bytes - {file.attachmentCount} attachments</span></button></li>)}</ul>
    <nav aria-label="File pages"><button disabled={page <= 1} onClick={() => setPage(value => value - 1)}>Previous</button><span>Page {result.pageNumber} of {Math.max(1, result.totalPages)}</span><button disabled={page >= result.totalPages} onClick={() => setPage(value => value + 1)}>Next</button></nav>
    {selected && <article><h2>{selected.fileName}</h2><p>{selected.description}</p><p>{selected.tags.join(", ")}</p><button onClick={() => void download(selected)}>Download</button><h3>Attached to</h3><ul>{selected.attachments.map(link => <li key={link.attachmentId}>{link.sourceTitle} ({link.sourceModuleKey || "record"})</li>)}</ul></article>}
  </section>;
}

Upload, replace metadata, attach, and detach

Upload is multipart and capped at 25 MiB. The backend normalizes the filename and content type, streams through the configured security scanner, validates the measured size, and removes failed storage writes. Browser MIME metadata is never trusted as a security decision.

import type { FileDetail, FileDynamicField } from "@buildwithhq/module-sdk";
import { attachFile, detachFile, getFile, setFileFavorite, updateFile, uploadFile } from "./api";

const fieldInput = (field: FileDynamicField) => ({
  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 uploadEvidence(file: File, sourceRecordId: string) {
  return uploadFile(file, { description: "Completion evidence", tags: ["evidence"], sourceRecordId, sourceModuleKey: "checklists-signoffs" });
}

export async function renameFile(current: FileDetail, fileName: string) {
  await updateFile(current.recordId, {
    expectedUpdatedUtc: current.updatedUtc, fileName, locationId: current.locationId || undefined,
    description: current.description || "", tags: current.tags, dynamicFields: current.dynamicFields.map(fieldInput),
  });
  return getFile(current.recordId);
}

export async function linkFile(current: FileDetail, targetRecordId: string, targetModuleKey: string) {
  await attachFile(current.recordId, targetRecordId, targetModuleKey);
  return getFile(current.recordId);
}

export async function unlinkFile(current: FileDetail, attachmentId: string) {
  await detachFile(current.recordId, attachmentId);
  return getFile(current.recordId);
}

export async function toggleFileFavorite(current: FileDetail) {
  await setFileFavorite(current.recordId, !current.isFavorite);
  return getFile(current.recordId);
}

Optional starter styling

.custom-files { width: 100%; max-width: none; }
.custom-files form, .custom-files nav { align-items: end; display: flex; gap: .75rem; }
.custom-files > ul { list-style: none; margin: 1rem 0; padding: 0; }
.custom-files > ul button { background: transparent; border: 0; display: grid; gap: .25rem; padding: .75rem 0; text-align: left; width: 100%; }
.custom-files article { border: 1px solid var(--line, #d8dee8); margin-top: 1rem; padding: 1rem; }

Exact data path

PurposeRouteProcedure/storage behavior
List/detailGET /api/modules/files, GET /api/modules/files/{recordId}sp_Files_ListSecured, sp_Files_GetSecured
UploadPOST /api/modules/files/uploadScan + private storage + sp_Files_CreateSecured
Metadata replacePUT /api/modules/files/{recordId}sp_Files_UpdateSecured, optimistic concurrency
Attach/detachPOST/DELETE /api/modules/files/{recordId}/attachmentssp_Files_AttachSecured, sp_Files_DetachSecured
DownloadGET /api/modules/files/{recordId}/contentsp_Files_GetDownloadSecured authorizes before private streaming
FavoritePUT /api/modules/files/{recordId}/favoritesp_Files_SetFavoriteSecured

Attachment targets are independently reauthorized. A file visible to a user does not make every linked record visible, and a record identifier does not authorize attachment or download.

Build a professional Files dashboard

These six registry-backed presentation blocks let a designer turn the secured Files 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/files; 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/files-sample.json, its executable authenticated page at pages/first-class/files-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 kindRuntime blockUseful Files projection
metric-setcore.metric-stripVisible, newly uploaded, pending-scan, and blocked counts
entity-listcore.entity-listRecent authorized documents and photos
progress-listcore.progress-listDistribution by type, scan state, or attachment use
series-chartcore.series-chartUpload, scan, download, and link activity
data-gridcore.presentation-gridOne bounded page without storage routes or secrets
timelinecore.timelineUpload, scan, metadata edit, relation, download, and deletion

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": "files-metrics",
      "_type": "core.metric-strip",
      "props": {
        "title": "File operations",
        "asOfUtc": "2026-09-04T18:00:00Z",
        "items": [
          {
            "key": "visible",
            "label": "Visible files",
            "value": 932,
            "format": "number",
            "tone": "neutral"
          },
          {
            "key": "uploaded",
            "label": "Uploaded this month",
            "value": 114,
            "format": "number",
            "tone": "success"
          },
          {
            "key": "pending",
            "label": "Scan pending",
            "value": 3,
            "format": "number",
            "tone": "warning"
          },
          {
            "key": "blocked",
            "label": "Blocked",
            "value": 1,
            "format": "number",
            "tone": "danger"
          }
        ]
      },
      "children": []
    },
    {
      "_id": "files-recent",
      "_type": "core.entity-list",
      "props": {
        "title": "Recent files",
        "hasMore": true,
        "items": [
          {
            "id": "files-sample-1",
            "recordId": "files-record-1",
            "primary": "north-wing-closeout.pdf",
            "secondary": "PDF - 2.4 MB - WO-1048",
            "status": {
              "key": "available",
              "label": "Available",
              "tone": "success"
            },
            "trailing": "2 links"
          },
          {
            "id": "files-sample-2",
            "recordId": "files-record-2",
            "primary": "north-wing-closeout.pdf - Follow-up",
            "secondary": "PDF - 2.4 MB - WO-1048 - Updated two hours ago by the assigned owner",
            "status": {
              "key": "in-review",
              "label": "In review",
              "tone": "primary"
            },
            "trailing": "Today"
          },
          {
            "id": "files-sample-3",
            "recordId": "files-record-3",
            "primary": "north-wing-closeout.pdf - West region",
            "secondary": "PDF - 2.4 MB - WO-1048 - Related to three visible records at the Reno location",
            "status": {
              "key": "on-track",
              "label": "On track",
              "tone": "success"
            },
            "trailing": "3 related"
          },
          {
            "id": "files-sample-4",
            "recordId": "files-record-4",
            "primary": "north-wing-closeout.pdf - Customer response",
            "secondary": "PDF - 2.4 MB - WO-1048 - Waiting for an external response before work can continue",
            "status": {
              "key": "scheduled",
              "label": "Scheduled",
              "tone": "warning"
            },
            "trailing": "Tomorrow"
          },
          {
            "id": "files-sample-5",
            "recordId": "files-record-5",
            "primary": "north-wing-closeout.pdf - Regional operations review with a deliberately long title",
            "secondary": "PDF - 2.4 MB - WO-1048 - 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": "files-sample-6",
            "recordId": "files-record-6",
            "primary": "north-wing-closeout.pdf - Completed preview",
            "secondary": "PDF - 2.4 MB - WO-1048 - Closed after review with its related evidence retained",
            "status": {
              "key": "complete",
              "label": "Complete",
              "tone": "success"
            },
            "trailing": "Closed"
          }
        ]
      },
      "children": []
    },
    {
      "_id": "files-bystatus",
      "_type": "core.progress-list",
      "props": {
        "title": "Files by type",
        "items": [
          {
            "key": "documents",
            "label": "Documents",
            "value": 531,
            "maximum": 932,
            "displayValue": "531",
            "tone": "primary",
            "status": {
              "key": "documents",
              "label": "Documents",
              "tone": "primary"
            }
          },
          {
            "key": "images",
            "label": "Images",
            "value": 327,
            "maximum": 932,
            "displayValue": "327",
            "tone": "success",
            "status": {
              "key": "images",
              "label": "Images",
              "tone": "success"
            }
          },
          {
            "key": "other",
            "label": "Other",
            "value": 74,
            "maximum": 932,
            "displayValue": "74",
            "tone": "neutral",
            "status": {
              "key": "other",
              "label": "Other",
              "tone": "neutral"
            }
          }
        ]
      },
      "children": []
    },
    {
      "_id": "files-trend",
      "_type": "core.series-chart",
      "props": {
        "title": "File activity",
        "variant": "bar",
        "defaultPeriodKey": "d7",
        "periods": [
          {
            "key": "d7",
            "label": "7 days",
            "labels": [
              "Fri",
              "Sat",
              "Sun",
              "Mon",
              "Tue",
              "Wed",
              "Thu"
            ],
            "series": [
              {
                "key": "primary",
                "label": "Uploaded",
                "tone": "primary",
                "values": [
                  8,
                  5,
                  4,
                  12,
                  15,
                  11,
                  17
                ]
              },
              {
                "key": "secondary",
                "label": "Downloaded",
                "tone": "success",
                "values": [
                  6,
                  4,
                  3,
                  9,
                  12,
                  10,
                  14
                ]
              }
            ]
          }
        ]
      },
      "children": []
    },
    {
      "_id": "files-table",
      "_type": "core.presentation-grid",
      "props": {
        "title": "Authorized files",
        "columns": [
          {
            "key": "file",
            "label": "File",
            "type": "text",
            "align": "left"
          },
          {
            "key": "type",
            "label": "Type",
            "type": "text",
            "align": "left"
          },
          {
            "key": "size",
            "label": "Size (KB)",
            "type": "number",
            "align": "right"
          },
          {
            "key": "scan",
            "label": "Scan",
            "type": "status",
            "align": "left"
          },
          {
            "key": "updated",
            "label": "Updated",
            "type": "date",
            "align": "left"
          }
        ],
        "rows": [
          {
            "id": "files-row-1",
            "recordId": "files-record-1",
            "cells": {
              "file": "north-wing-closeout.pdf",
              "type": "PDF",
              "size": 2458,
              "scan": {
                "key": "clean",
                "label": "Available",
                "tone": "success"
              },
              "updated": "2026-09-04T14:08:00Z"
            }
          },
          {
            "id": "files-row-2",
            "recordId": "files-record-2",
            "cells": {
              "file": "north-wing-closeout.pdf - Follow-up",
              "type": "PDF",
              "size": 2459,
              "scan": {
                "key": "in-review",
                "label": "In review",
                "tone": "primary"
              },
              "updated": "2026-09-04T15:42:00Z"
            }
          },
          {
            "id": "files-row-3",
            "recordId": "files-record-3",
            "cells": {
              "file": "north-wing-closeout.pdf - West region",
              "type": "PDF",
              "size": 2460,
              "scan": {
                "key": "on-track",
                "label": "On track",
                "tone": "success"
              },
              "updated": "2026-09-04T12:18:00Z"
            }
          },
          {
            "id": "files-row-4",
            "recordId": "files-record-4",
            "cells": {
              "file": "north-wing-closeout.pdf - Customer response",
              "type": "PDF",
              "size": 2461,
              "scan": {
                "key": "scheduled",
                "label": "Scheduled",
                "tone": "warning"
              },
              "updated": "2026-09-03T21:07:00Z"
            }
          },
          {
            "id": "files-row-5",
            "recordId": "files-record-5",
            "cells": {
              "file": "north-wing-closeout.pdf - Regional operations review with a deliberately long title",
              "type": "PDF - This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
              "size": 2462,
              "scan": {
                "key": "needs-attention",
                "label": "Needs attention",
                "tone": "danger"
              },
              "updated": "2026-09-03T16:31:00Z"
            }
          },
          {
            "id": "files-row-6",
            "recordId": "files-record-6",
            "cells": {
              "file": "north-wing-closeout.pdf - Completed preview",
              "type": "PDF",
              "size": 2463,
              "scan": {
                "key": "complete",
                "label": "Complete",
                "tone": "success"
              },
              "updated": "2026-09-02T19:14:00Z"
            }
          }
        ],
        "page": {
          "pageNumber": 1,
          "pageSize": 6,
          "totalRecords": 932,
          "totalIsExact": true,
          "hasMore": true
        }
      },
      "children": []
    },
    {
      "_id": "files-timeline",
      "_type": "core.timeline",
      "props": {
        "title": "File activity",
        "hasMore": true,
        "items": [
          {
            "id": "files-event-1",
            "recordId": "files-record-1",
            "occurredUtc": "2026-09-04T17:58:00Z",
            "title": "File scan completed",
            "description": "The uploaded PDF passed content scanning.",
            "actor": "File security worker",
            "tone": "success"
          },
          {
            "id": "files-event-2",
            "recordId": "files-record-2",
            "occurredUtc": "2026-09-04T15:42:00Z",
            "title": "File scan completed - Follow-up",
            "description": "The uploaded PDF passed content scanning. Updated two hours ago by the assigned owner.",
            "actor": "Avery Patel",
            "tone": "primary"
          },
          {
            "id": "files-event-3",
            "recordId": "files-record-3",
            "occurredUtc": "2026-09-04T12:18:00Z",
            "title": "File scan completed - West region",
            "description": "The uploaded PDF passed content scanning. Related to three visible records at the Reno location.",
            "actor": "Sam Rivera",
            "tone": "success"
          },
          {
            "id": "files-event-4",
            "recordId": "files-record-4",
            "occurredUtc": "2026-09-03T21:07:00Z",
            "title": "File scan completed - Customer response",
            "description": "The uploaded PDF passed content scanning. Waiting for an external response before work can continue.",
            "actor": "Maya Chen",
            "tone": "warning"
          },
          {
            "id": "files-event-5",
            "recordId": "files-record-5",
            "occurredUtc": "2026-09-03T16:31:00Z",
            "title": "File scan completed - Regional operations review with a deliberately long title",
            "description": "The uploaded PDF passed content scanning. This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
            "actor": "Automation",
            "tone": "danger"
          },
          {
            "id": "files-event-6",
            "recordId": "files-record-6",
            "occurredUtc": "2026-09-02T19:14:00Z",
            "title": "File scan completed - Completed preview",
            "description": "The uploaded PDF passed content scanning. 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 FilesPresentationData {
  readonly metrics: unknown;
  readonly recent: unknown;
  readonly byStatus: unknown;
  readonly trend: unknown;
  readonly table: unknown;
  readonly timeline: unknown;
}

export interface FilesPresentationProps {
  /** Pass only the already-authorized presentation document returned by the API. */
  readonly data?: FilesPresentationData | 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 FilesPresentation({
  data,
  loading = false,
  error = false,
  styled = false,
  onOpenRecord,
}: FilesPresentationProps) {
  if (error) return <p role="alert">The Files presentation could not be loaded.</p>;
  if (loading || !data) return <p role="status">Loading Files presentation...</p>;

  return (
    <main className={styled ? "bwhq-api-example" : undefined}>
      <header>
        <p>Files</p>
        <h1>File operations</h1>
        <p>Visible documents, scan state, attachment coverage, file types, and controlled activity.</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/files, 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": "files-live",
      "_type": "module.files",
      "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.

Tip

Model the business record first, then attach the file to it. Do not use folder names as a substitute for tenant, record, or location authorization.

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. For exact current technical availability, use the generated API Map and first-class module inventory.