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/clientThe 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.
| Option | Required | Default | Effect |
|---|---|---|---|
baseUrl | yes, unless STANDARDS_API_URL is set | STANDARDS_API_URL | The API root, /v1 included. A dedicated instance uses its own hostname. |
apiKey | yes, unless STANDARDS_API_KEY is set | STANDARDS_API_KEY | Sent as Authorization: Bearer. schema.sync needs a key whose role grants architect:update. |
schema | no | none | A source from defineSchemaSource. Exposes db.<camelCaseName> for each of its objects, typed from the builders. |
tenantId | no | none | Sent as X-Tenant-ID on every request. |
timeoutMs | no | 30_000 | Bounds 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
| Export | What 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, StandardsConfigurationError | The error classes. Builders throw ValidationError and DuplicateError. |
createTransport, createRecordsQuery, createFilesApi, createDocumentsApi, reviveRecord | The 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 inpublic: 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.
Recommended layout
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
- Objects: declare your first object and its label.
- Sync: push a source and read the result.
- Records: filters, paging, and the write verbs.
- Status page cookbook: the layout above, end to end.
- Book a demo
Vibecoding on Standards
One TypeScript file becomes a back office, a typed API and a tool your agent can query. Follow the model catalogue every Standards deployment reads at boot, from schema to production.
Objects
Declare an object with the fluent builder, give it a label expression, and know every method the builder accepts.