standards
Set up the clientData model

Set up the client

Install @standards/client, create one server-side client, and know what it covers before you write your first object.


@standards/client is one package. It carries the schema builders, defineSchemaSource, createStandards and the error classes. You declare your objects in TypeScript, sync them to a workspace with schema.sync, read and write typed records with db.<object>, and upload files with files and documents. Nothing else is required: no runtime, no framework adapter.

pnpm add @standards/client

The package runs on Node 22 or newer, and on edge runtimes: defineSchemaSource does nothing at import, and hashing uses Web Crypto.

Create the client

import { createStandards } from "@standards/client";

export const standards = createStandards();

Called without an argument, createStandards reads STANDARDS_API_URL and STANDARDS_API_KEY from process.env, the same two variables the CLI reads. Pass any option explicitly and it wins over its variable; the rest still come from the environment:

export const standards = createStandards({
  baseUrl: "https://api.standards.new/v1", // wins over STANDARDS_API_URL
  // apiKey still comes from STANDARDS_API_KEY
});

Pass your source as schema to get one typed query per object. The key is the camelCase of the object name:

import { createStandards } from "@standards/client";
import { statusSource } from "./schema";

const db = createStandards({
  baseUrl: "https://api.standards.new/v1",
  apiKey: process.env.STANDARDS_API_KEY!,
  schema: statusSource,
});

await db.checks.fetch();

createStandards takes five options and nothing else.

OptionRequiredDefaultEffect
baseUrlyes, unless STANDARDS_API_URL is setSTANDARDS_API_URLThe API root, /v1 included. A dedicated instance uses its own hostname.
apiKeyyes, unless STANDARDS_API_KEY is setSTANDARDS_API_KEYSent as Authorization: Bearer. schema.sync needs a key whose role grants architect:update.
schemanononeA source from defineSchemaSource. Exposes db.<camelCaseName> for each of its objects, typed from the builders.
tenantIdnononeSent as X-Tenant-ID on every request.
timeoutMsno30_000Bounds each request until its response headers arrive; reading the body is not timed.

An empty variable counts as unset. When a value is in neither place, createStandards throws StandardsConfigurationError before sending anything, and the message names the variable: STANDARDS_API_URL first, then STANDARDS_API_KEY. The variables are read when you call createStandards, never at import, and only when a process global exists, so importing the package in a runtime without one does not throw.

There is no fetch override, no custom headers option and no retry. Retries, if you need them, live in your code.

The client is server-only. When a window global exists and an apiKey is set, createStandards throws StandardsConfigurationError before sending anything. In that context the environment is never read either: a call without an explicit baseUrl throws, even if STANDARDS_API_URL and STANDARDS_API_KEY are set. Keep the key in Route Handlers, Server Components, scripts and cron jobs.

The surface

ExportWhat it does
createStandards(config?)Returns { schema, from, files, documents }. Missing options come from STANDARDS_API_URL / STANDARDS_API_KEY. With a schema source, it also exposes one typed query per object (see below).
standards.schema.sync(source, { resolutions? })GET the source's summary, PUT it when the hash or the presentation differs, with the renames and retypes it resolves. Never throws for "nothing to do".
standards.<camelCaseName>With createStandards({ …, schema: source }): a typed RecordsQuery per declared object (db.dailyStats for daily-stats): filters, sorts, fetch, single, get, create, update, delete.
standards.from("name")The same query, untyped, for an object your code does not declare.
standards.files.upload({ file, public?, folderPath? })Uploads one file, private by default. Resolves { id, name, mimeType, size, isPublic }, plus publicUrl when public: true.
standards.files.getDownloadUrl(fileId)A fresh signed URL to read a file.
standards.documents.attachToRecord({ objectName, recordId, attributeName?, documentId?, title?, file })Uploads one private file and attaches it to a record's document attribute in one request. With documentId, appends it to that document.
standards.documents.listFiles(documentId)The document's files, in pack order.
defineSchemaSource({ id?, objects, views?, label?, icon? })Declares the source and keeps the builders for inference. Does no work at import: resolveSchemaSource builds, validates and hashes it on first sync.
object, text, number, date, select, status, relation, document, …The builders. listView, detailView, group and content declare views.
RecordInput, RecordUpdate, ExtractRecord, RecordMetadata, …The inference helpers.
StandardsRequestError, StandardsAuthError, SchemaSourceConflictError, SchemaSourceValidationError, StandardsConfigurationErrorThe error classes. Builders throw ValidationError and DuplicateError.
createTransport, createRecordsQuery, createFilesApi, createDocumentsApi, reviveRecordThe lower layer, for raw calls and tests.

Files and documents

files and documents take a Node File: the bytes, a filename and a MIME type. The server compares the MIME type, the extension and the file signature, so all three are sent. A Buffer or a file path is not accepted; wrap the bytes in a File first. The client builds the multipart request and sends the same auth headers as every other call.

Publish a public file

import { readFile } from "node:fs/promises";
import { standards } from "./client";

const bytes = await readFile("./logo.png");
const logo = await standards.files.upload({
  file: new File([bytes], "logo.png", { type: "image/png" }),
  public: true,
});

logo.publicUrl; // string: `{publicFilesUrl}/{id}`, readable without signing in

public: true returns the public branch, whose publicUrl is a string. A private upload (the default) has no publicUrl in its type. A public link is permanent and anyone who has it can open the file. It carries no credential. See Public files.

Attach a private file to a record

const { document, file } = await standards.documents.attachToRecord({
  objectName: "brand-assets",
  recordId: asset.id,
  attributeName: "original",
  file: new File([bytes], "original.png", { type: "image/png" }),
});

// Later, add a file to the same document:
await standards.documents.attachToRecord({
  objectName: "brand-assets",
  recordId: asset.id,
  attributeName: "original",
  documentId: document.id,
  file: new File([variant], "[email protected]", { type: "image/png" }),
});

One call is one POST /records/:object/:id/documents/attach. The server uploads the file, checks it against the attribute's accepts and maxFileSize, and creates the document or appends to documentId. If the attach fails, the server deletes the uploaded file. file is the same input files.upload takes, and the route accepts one file per request: to add more, call again with documentId. The file is always private. attachToRecord takes bytes, never a file id, so a public upload can never be attached to a document. attributeName defaults to attachments. The result is { document: { id, title }, file, action }, where action is "created" or "appended".

Both files.upload and attachToRecord take an optional timeoutMs for that one request. The client's timeoutMs (30 s by default) covers the whole upload, so raise it for large files: attachToRecord({ …, file, timeoutMs: 300_000 }). A timed-out upload surfaces as StandardsRequestError with status 0, and the server may still have stored it. The attach is not idempotent, so before retrying a timed-out attachToRecord, check with listFiles whether the file already landed.

Read a file back

const [original] = await standards.documents.listFiles(document.id);
const url = await standards.files.getDownloadUrl(original.id);
const response = await fetch(url);

getDownloadUrl returns a new signed URL on every call. The URL expires, so request it right before you read the file and do not store it.

A file without a name, a file without a MIME type, or an input that is not a Blob rejects with ValidationError before any request is sent. The server's refusals keep the classes the client already uses. A MIME type or size refusal is a StandardsRequestError with the server's status and code (for example 400 DOCUMENT_MIME_NOT_ACCEPTED). A key without the permission is a StandardsAuthError. A missing or invisible file or document is a StandardsRequestError with status: 404.

What the client does not cover

The typed client covers schema sync, records, files and documents. It does not cover global search, agents or forms. Which tool uploads is settled on Files and documents.

When you need one of them from Node, createTransport gives you the same authenticated fetch the client uses, without the typing:

import { createTransport } from "@standards/client";

const transport = createTransport({
  baseUrl: "https://api.standards.new/v1",
  apiKey: process.env.STANDARDS_API_KEY!,
});

const hits = await transport.get<{ data: unknown[] }>("/search", { q: "latency" });

The transport exposes get, post, patch, put, delete, getText and postMultipart. postMultipart(path, form, options?) takes an optional MultipartRequestOptions ({ timeoutMs }) that replaces the transport's timeout for that one request. It throws the same error classes as the client. Nothing on it revives dates or flattens values.

Keep three files under src/standards/, as the status page cookbook does.

src/standards/
  schema.ts        # the builders and the schema source
  client.ts        # createStandards, one instance per process
  sync-schema.ts   # a script: standards.schema.sync(source)
// src/standards/schema.ts
import { date, defineSchemaSource, number, object, relation, text } from "@standards/client";

export const service = object("services", "Service")
  .attribute(text("name", "Name").required())
  .attribute(text("url", "URL").url().required())
  .labelExpression("{{ name }}");

export const check = object("checks", "Check")
  .attribute(relation("service", "Service").to("services").required())
  .attribute(number("latencyMs", "Latency (ms)"))
  .attribute(date("checkedAt", "Checked at").includeTime().required())
  .labelExpression("{{ checkedAt }}");

export const statusSource = defineSchemaSource({
  id: "status-site",
  objects: [service, check],
  label: "Status page",
});
// src/standards/client.ts
import { createStandards } from "@standards/client";
import { statusSource } from "./schema";

let instance: ReturnType<typeof create> | null = null;

function create() {
  return createStandards({ schema: statusSource }); // STANDARDS_API_URL, STANDARDS_API_KEY
}

export function getStandards() {
  instance ??= create();
  return instance;
}
// src/standards/sync-schema.ts
import { getStandards } from "./client";
import { statusSource } from "./schema";

const result = await getStandards().schema.sync(statusSource);
console.log(result.applied ? `applied ${result.hash}` : "already in sync");

Run the sync script from CI or by hand before the app starts. Sync is idempotent: an unchanged source costs one GET.

Testing without the network

db.<object> is createRecordsQuery(transport, builder) under the hood, and the transport argument only needs get, post, put and delete. export() also needs postRaw(path, body) returning a Response; without it, export() rejects with a StandardsConfigurationError. Hand it an in-memory object and every query stays typed:

import { createRecordsQuery } from "@standards/client";
import { service } from "./schema";

export const standards = {
  services: createRecordsQuery(memoryTransport, service),
};

Type your application against Pick<ReturnType<typeof getStandards>, "services"> and both the real client and the double satisfy it. The status page cookbook ships a working double that emulates filters, sorts and paging.

Next steps