Objects
Declare an object with the fluent builder, give it a label expression, and know every method the builder accepts.
An object is a typed table. object(name, label) starts one; each .attribute() call adds a column and refines the inferred record type; .labelExpression() says how a record is displayed. The builder never touches the network. defineSchemaSource builds it, and schema.sync pushes it.
import { object, relation, status, text } from "@standards/client";
export const contact = object("contacts", "Contact")
.pluralLabel("Contacts")
.icon("user")
.description("A person you talk to.")
.attribute(text("firstName", "First name").required())
.attribute(text("lastName", "Last name").required())
.attribute(text("email", "Email").email())
.attribute(
relation("company", "Company")
.to("companies")
.bilateral({ object: "companies", attribute: "contacts" })
)
.attribute(
status("status", "Status").options([
{ value: "lead", label: "Lead", color: "ochre", group: "idle" },
{ value: "active", label: "Active", color: "sage", group: "in_progress" },
{ value: "churned", label: "Churned", color: "brick", group: "finished" },
])
)
.labelExpression("{{ firstName }} {{ lastName }}");Three rules apply at declaration time:
nameandlabelare positional arguments:object("services", "Service").nameis kebab-case, 63 characters at most. Anything else throws aSchemaErrorwith codeSCHEMA_INVALID_OBJECT_NAME.nameis not one the platform ships.skill,memory,drives,artifactandmeetingare native objects.object("meeting", "Meeting")is a type error in the editor and throwsReservedObjectNameError(codeSCHEMA_RESERVED_OBJECT_NAME) at runtime. The class is exported by@standards/schema; from@standards/client, catch it as aSchemaErrorand readerror.code.labelExpressionis mandatory.build()throws aSchemaErrorwith codeSCHEMA_VALIDATION_FAILEDwithout it, andresolveSchemaSourcecallsbuild()on the first sync.defineSchemaSourcedoes no work at import, so a missing label expression fails at sync, not when the module loads.
Migrating from { name, label }
Builders used to take a single config object holding name and label. That form is removed, for object and for every attribute builder. To rewrite a codebase, run the codemod, then your formatter:
npx @standards/cli codemod builder-shorthand srcCalls it cannot rewrite are listed with their file and line. See Schema and sources.
Builder methods
Every method returns the builder, so the order is yours. Only .attribute() changes the inferred type.
| Method | Default | Effect |
|---|---|---|
.labelExpression(template) | none, required | The record's display label. {{ attribute }} placeholders with pipes: UPPER, LOWER, capitalize, trim, prefix:"…", suffix:"…", wrap:"…", default:"…". |
.description(text) | none | Shown in the schema explorer and to agents. |
.pluralLabel(text) | none | Used where a collection is named. |
.icon(name) | none | An icon name from the platform set. |
.color(id) | derived from the name | A colour from the Standards palette: stone, clay, brick, terra, ochre, moss, sage, lagoon, ink, iris, peony. Removing the call clears it on the next deploy. |
.image({ shape, from }) | { shape: "square" } | How the images of the object's records are cropped (shape: circle or square) and which text attributes say where an image can be found (from). See Record images. Removing the call clears both on the next deploy. |
.order(n) | alphabetical | Sidebar position, lowest first. Written to metadata.order. |
.hidden() | visible | Removes the object from the sidebar and the command palette. The API, search, permissions and agent tools still see it. |
.metadata(object) | {} | Merges into the object's metadata. Keys written by .order() and .hidden() survive. icon, color and image do not compile there: each has its own field, set with .icon(), .color() and .image(), and an icon key that reaches metadata anyway is dropped rather than stored. |
.embeddingExpression(template) | none | Opts the object into vector search. Same syntax as the label expression. Without it, records carry no embedding. |
.sealed() | extensible | Declares that no custom attribute belongs on this object. standards diff reports any custom attribute not listed in .tolerate() as unexpected. |
.tolerate(names) | [] | Custom attribute names standards diff accepts on a sealed object. On an extensible object it is documentary. |
.documentLayout(layout) | none | Folder presets for the object's drive. Preset trees are validated at declaration. |
.attribute(builder) | — | Adds one attribute and refines the type. Throws DuplicateError on a repeated name. |
.attributes(list) | — | Adds several attributes. They do not enter the inferred type. Same DuplicateError rule, inside the list and against earlier calls. |
.runtime() | system: true | Marks the object system: false. Meaningless over a schema source: the server forces system: true on every object a source declares. Keep it for test fixtures. |
.build() | — | Returns the ObjectDefinition. resolveSchemaSource calls it on sync; you rarely do. |
.$infer | — | record, create and update types with no import. See Type safety. |
The builder has no openMode, no visibility, no permissions and no softDelete. Visibility is decided per record by the workspace; permissions belong to roles and API keys; deletion goes through the records API. An object's id is assigned by the server at sync time; call .id(value) only for deterministic fixtures.
Record images
.image() takes two optional keys. shape crops the images of the object's records, circle or square; it defaults to square. from is an ordered list of the object's text attributes that say where a record's image can be found, first one first. Each entry's kind follows from the attribute:
| Attribute | Kind |
|---|---|
text().url() | website |
text().email() | |
any other text() | name |
import { object, text } from "@standards/client";
export const companies = object("companies", "Company")
.attribute(text("name", "Name").required())
.attribute(text("website", "Website").url())
.image({ from: ["website", "name"] })
.labelExpression("{{ name }}");
// image: { shape: "square", from: ["website", "name"] }
export const contacts = object("contacts", "Contact")
.attribute(text("email", "Email").email())
.image({ shape: "circle", from: ["email"] })
.labelExpression("{{ email }}");from is checked when the object is built, on sync, on schema push and draft publish, and when an object is updated. Each of these fails with a ValidationError (code SCHEMA_VALIDATION_FAILED):
- an empty list:
from: []. Leavefromout instead; - a name the object does not declare:
from: ["missing"]; - the same name twice:
from: ["name", "name"]; - an attribute that is not
text, such as a user or a relation:from: ["owner"].
Removing an attribute that from cites is refused the same way, unless the same change also drops it from from.
In the architect, an object's settings set from under "Find image from": each ticked text attribute is added at the end of the order, and unticking the last one leaves from out. In a draft, update_object takes image.from: null to clear the list; an omitted from keeps it.
A record's own image is record.appearance.image: { fileId, source }. source is upload when someone set it, a file or a company logo picked with client.api.records.setLogo({ objectName, recordId, domain }). When the deployment has a logo service, Standards also finds an image on its own, from the first from attribute that gives a key: a website or an email's domain (public mail domains such as gmail.com are skipped), else the company name, matched strictly against the provider's results. That image's source is the attribute's kind (website, email or name).
- It is looked up in the background after a record is created or one of its
fromattributes changes, and for every existing record without an icon or image whenfromis added or changed. - A record set to an icon or to an image someone set is never touched.
- When the data changes to another key, the found image follows it; when no logo is known, the found image goes.
- Clearing a record's appearance finds its image again.
- A logo is fetched once per installation, stored once per workspace, re-checked monthly, and a domain with no logo is not asked again for 30 days.
A workspace can stop finding images: the tenant setting findImages (« Retrouver les logos automatiquement »), on by default, is turned off with client.api.tenantSettings.update({ findImages: false }). Off, nothing is looked up for the workspace, every found image goes, and so do the stored logos no record still shows. Images someone set stay, a logo picked with setLogo included, and the logo search keeps answering. Turned on again, every record without an icon or image is looked up again.
What a record shows, first match first: an image someone set, then its icon, then a found image, then its object's tile. See Appearance for the routes.
In the mailbox, a sender shows an image too, chosen for whoever reads it: the image of the record linked to the sender's address that the reader can see (the reverse of the record's Emails tab), else the image of the company that record points to through a relation to an object with from, else the workspace's logo for the sender's domain — not when the workspace turned findImages off, which leaves only the linked records' images. Without one, the sender's initials. A list row carries it as latestFromImage: { fileId }, a message in the reading pane as fromImage: { fileId }. Only a real exchange looks a domain up: a thread holding a message the workspace sent, or a sender linked to a record. A newsletter shows a logo already known but never has one fetched for it. A logo found after the page was read reaches the reader's open mailbox live.
To offer logos for a record, read logosConfigured from the tenant settings: when it is false, the server has no logo provider and client.api.logos.search({ object, query }) answers no candidate. Otherwise it answers at most five { name, domain, previewUrl }, and the domain picked goes to setLogo.
Migrating from .imageShape()
.imageShape(shape) is removed. .image({ shape }) takes its place. To rewrite a codebase:
npx @standards/cli codemod image srcSee Schema and sources.
Duplicate attribute names
Names are compared with ===, so email and Email are two attributes. The same name twice throws at the second call, not at build():
import { object, text } from "@standards/client";
object("contacts", "Contact")
.labelExpression("{{ email }}")
.attribute(text("email", "Email"))
.attribute(text("email", "Work email"));
// DuplicateError: Object "contacts": duplicate attribute name "email"Sealed objects
By default an object is extensible: workspace users add their own attributes on top of yours, and schema.sync leaves those attributes alone. .sealed() states that the code is the whole definition. It does not delete anything; it turns every custom attribute into a standards diff failure unless .tolerate() names it:
import { number, object } from "@standards/client";
export const invoice = object("invoices", "Invoice")
.sealed()
.tolerate(["legacyRef"])
.attribute(number("amount", "Amount").decimal(2).required())
.labelExpression("{{ amount }}");Use it on objects whose shape a downstream integration depends on, and run standards diff in CI.
Renames and retypes
A diff of your code cannot tell a rename from a drop and an add, and a retype would leave every stored value unconverted. The sync refuses both rather than guess, before it writes anything, with a 400 SCHEMA_AMBIGUOUS_CHANGE (SchemaSourceValidationError from schema.sync) that names the object and the attributes:
- Rename-shaped: a system attribute disappears from the code while a new one of the same type appears. For a relation, "same type" also means the same target objects; two relations to different objects are an ordinary removal and addition.
- Retype: a declared attribute's type differs from the stored one.
textandrichtextare the one exception: both store a string, so switching between them is applied in place and keeps every value. For a relation, dropping a target object (or narrowing fromtoAny()to named objects) is a retype; adding a target, or widening totoAny(), keeps every stored link and is applied.
import { object, text } from "@standards/client";
// Stored: `fullName` (text). Pushed: `displayName` (text), no `fullName`.
export const contact = object("contacts", "Contact")
.attribute(text("displayName", "Display name").required())
.labelExpression("{{ displayName }}");
// 400 SCHEMA_AMBIGUOUS_CHANGE — a rename or a drop + add?The refusal names the resolutions to send. When two removed attributes could each have become the one added (or one removed attribute either of two added ones), it lists those renames as alternatives: send at most one rename per from and per to. Resolve it one of three ways:
-
Keep the data with a resolution: pass the resolutions to
schema.sync. They travel with the same push, outside its hash, and are applied in its transaction before the ambiguity check runs:await standards.schema.sync(source, { resolutions: [ // Keep `fullName` and every stored value under the name `displayName`. { kind: "rename", object: "contacts", from: "fullName", to: "displayName" }, // Convert a `text` attribute to `phone`: a number with no calling code belongs to France. { kind: "retype", object: "contacts", attribute: "mobile", to: "phone", defaultCountryCode: "FRA" }, // Convert a `number` attribute to `currency`: every amount is stored in euros. { kind: "retype", object: "deals", attribute: "amount", to: "currency", currencyCode: "EUR" }, ], });A rename keeps the attribute (same id) and moves every record's value to the new key, soft-deleted records included, along with the references a
userordocumentattribute holds. A push converts exactly two retypes:text → phone(a value is parsed as a phone number; an empty text becomesnull) andnumber → currency. One value it cannot convert — a text that is not a phone number, a number with an extension, a negative amount into a currency that does notallowNegative— refuses the whole push with a400that names the records; nothing is written. Each applied resolution leaves aschema_sync_logrow applied bysource:<id>. -
Keep the data with SQL: relations, and every other retype, still take a SQL migration that renames or converts the stored attribute and its record values before the sync.
-
Drop the data: ship the removal and the addition in separate deploys. Removing an attribute on its own is never ambiguous, and neither is adding one.
A resolution names one of the objects the push declares, and describes the state the payload shows: to (or the retyped attribute, with its new type) is declared, from is not. One that finds nothing to do is ignored — a first push, or a later one still carrying a resolution already applied — so a script may keep sending it. A push whose hash is already applied is not sent at all. A resolution naming an object the push does not declare, a workspace attribute, or a relation is refused with a 400. In the same transaction, a rename rewrites the workspace views, and the workspace edits of your own views, that named the old attribute: columns, sorts, filter rules, grouping, form fields and richtext attributes. Other objects' formulas and rollups that named it are not rewritten.
A retype checks the values stored when it runs. A record write in flight during the push, or one from a server still validating against the previous schema until its cache refreshes, can store an old-type value right after it: run a retype while nothing writes that attribute.
A server older than resolutions answers 400 Unknown source payload fields: upgrade the client and the server together.
Attributes users created in the app are never weighed: only system attributes, the ones your code owns, can make a change ambiguous.
Vector search
embeddingExpression is opt-in. Give it the attributes that carry meaning, and semantic search indexes the rendered text:
import { object, richtext, text } from "@standards/client";
export const note = object("notes", "Note")
.attribute(text("title", "Title").required())
.attribute(richtext("body", "Body"))
.labelExpression("{{ title }}")
.embeddingExpression("{{ title }}\n{{ body }}");An object without the expression renders an empty embedding text and is left out of vector results.
The inference ceiling
Each .attribute() call deepens the accumulated record type. Measured with a uniform chain of required text attributes, inference is clean at 98 attributes and fails at 99 with a single TS2589 on the object(...) call. Real objects mix attribute types and cost more per step, so treat 98 as an upper bound. Past it, the earliest-declared attributes degrade to any while every assertion keeps passing. Split objects that large.
System and runtime
Every object a schema source declares is a system object: users can add attributes and views on top of it, but cannot delete or retype what your code declared. Attributes and views created in the workspace are runtime, and they belong to the workspace, not to the source. Schema sources covers who creates, changes and deletes each kind.
Next steps
- Attributes: every attribute builder and its options.
- Views: list and detail views declared next to the object.
- Sync: push the source and read what changed.
- Type safety: what the declaration gives you at compile time.
- Book a demo