Models (custom data)
Define your own record types — Models with typed Fields — and store, read and update their ModelItems.
- Use it for: Server integration · Custom app
- Reads: Bearer (
apiKey) — server-side only - Writes: Bearer (
apiKey) — server-side only - In a custom app: with the signed-in member's session, no token to configure — see Custom app
- Auth: what each token can do and where it may run — Authentication
A Model is a record type you define yourself: a name, a list of typed Fields, and the ModelItems stored in it. It is uidu's data engine — the same one behind the new CMS — and the place a custom app keeps the data that is its own: the bookings of a room, a checklist, a log.
Mental model
Read what the workspace already has
A uidu workspace is not empty: it already holds goals, contacts, deals, people, events, courses… Create a Model only for data uidu has no place for. An app asking for "the OKRs behind schedule" or "our open deals" should read Goals or Contacts, not copy them into a Model — the copy drifts from the real records. When the data exists but a custom app can't reach it yet, say so rather than rebuilding it here.
WorkspaceApp (the installed app — custom apps get one each)
└─→ Model (name, namePlural) "Booking"
├─→ Field (shortname, kind, required) room: string, when: datetime
│ └─→ FieldOption for singleSelect / multipleSelect
└─→ ModelItem one booking
└─→ FieldValue (field + content: { value })- Every read goes through
node(id:), starting from a WorkspaceApp id — inside a custom app that iscontext.workspaceApp.idfrom the app bridge. This is the only query root a custom app's session token may call. A read whose id isn't visible (or isn't the right type) returns[]ornull, not an error. - Auth: a custom app's session token (the member, framed in uidu) or an account Bearer
token (
apiKey, server-side only). ApublicTokenalone doesn't reach a WorkspaceApp's models. Writes are server-side with theapiKey— except a custom app writing its own Models with the member's session, in the browser. - Shapes. A
Modelcomes with its schema:id,name,namePlural,fieldList[](id,name,shortname,kind,required,position,optionList[]of{ id, name, color, position }). AModelItemhasid,name,createdAt,updatedAtandfieldValuesByShortname—{ room: 'Aula Magna', when: '…' }, the easiest shape to read values from.getModelItemaddsmodel.id, the model the item belongs to.
Field kinds
Field kinds you will normally use: string (one line), text (several lines), number,
currency, percent, date, datetime, checkbox, email, phone, url, rating,
member (a person of the workspace). singleSelect / multipleSelect also need their options
(createFieldOption, see Provision a Model from a script) —
prefer string unless the choices matter. uidu knows more kinds (attachments, linkedRecord,
formula, progress, richText…); leave them out unless you need one.
A new field goes on a model with fieldableId (the model) and fieldableType: 'Model' — a
custom app may only name a type that matches the record it loads. A new model goes in a
WorkspaceApp with modelableId.
ensureModel only adds
ensureModel finds the app's model by name, or creates it — together with any of fields
it is missing, matched by shortname — and returns it with all its fields. Fields the model
already has are left alone, extra fields are never removed, and a field whose kind changed is
not altered. It is what a custom app runs on its first load, until model schemas are created
from the app's manifest at install time. When a write is refused it throws a UiduError with
code MUTATION_ERROR (unlike the other writes, which return errors).
Call it once per app instance
Two concurrent ensureModel calls each see "no model yet" and each create one. Memoize the
promise (load models once, at the top of the app) instead of calling it from every component.
Values are content: { value }
A write takes fieldValuesAttributes: one { fieldId, content: { value } } per field.
toFieldValuesAttributes(model, { room: 'Aula Magna' }) builds it from a { shortname: value }
object: it resolves each shortname to its field id, wraps the value, skips undefined values,
and throws a UiduError with code UNKNOWN_FIELD for a shortname the model doesn't have — so
pass a model loaded with its fieldList (what ensureModel, listModels and getModel
return).
content is always { value: … }
The API stores each field value in a JSON content column and reads it back as
content.value, for every kind: { value: 42 }, { value: true },
{ value: '2026-07-27' }, { value: ['gid://…'] }. A bare value is accepted by the server but
silently lost. toFieldValuesAttributes builds the right shape, and every @uidu/client
mutation also wraps a bare content for you (with a dev-time warning) — but write it wrapped
anyway, other clients of the API won't fix it up. See Forms.
Writes return the payload
Writes return the mutation payload as-is rather than throwing — always check errors. The
record's key follows the entity, with no node wrapper:
| Write | Payload |
|---|---|
createModel, updateModel | { errors, model } — with its fieldList |
createField, updateField, deleteField | { errors, field } |
createFieldOption, updateFieldOption, deleteFieldOption | { errors, fieldOption } |
createModelItem, updateModelItem | { errors, modelItem } — same shape as a listed item |
deleteModelItem | { errors, modelItem: { id } } |
updateFieldValue / deleteFieldValue | { errors, fieldValue } |
Each one resolves to null when the server returns no payload.
Two behaviours that bite
listModelItemslags behind writes. It reads uidu's search index, which catches up a moment after a write. AftercreateModelItem/deleteModelItem, update your list from the mutation payload instead of re-listing straight away, or the new item will be missing. Its optionalparamsare search parameters, the same shape assearch.updateModelItemappends, it doesn't edit in place. Entries infieldValuesAttributeswithout anidare written as new field values;fieldValuesByShortnamereports the most recently written one per field. See Change a record.
Use cases
A custom app's own records
Goal: a custom app framed in uidu keeps its bookings in a Model of its own WorkspaceApp, with the member's session — no token to configure.
'use client';
import { useEffect, useState } from 'react';
import { useUiduApp } from '@uidu/react';
import {
createModelItem,
deleteModelItem,
ensureModel,
listModelItems,
toFieldValuesAttributes,
type Model,
type ModelItem,
type UiduClient,
} from '@uidu/client';
// One promise per app instance: concurrent ensureModel calls would each create the model.
let bookingModel: Promise<Model> | null = null;
function loadBookingModel(client: UiduClient, workspaceAppId: string) {
bookingModel ??= ensureModel(client, {
workspaceAppId,
name: 'Booking', // matched against existing models by name
namePlural: 'Bookings',
fields: [
// matched by shortname; missing ones are created
{ shortname: 'room', name: 'Room', kind: 'string', required: true },
{ shortname: 'when', name: 'When', kind: 'datetime' },
],
});
return bookingModel;
}
export function useBookings() {
const app = useUiduApp();
const client = app.status === 'ready' ? app.client : null;
const workspaceAppId = app.status === 'ready' ? app.context.workspaceApp.id : null;
const [model, setModel] = useState<Model | null>(null);
const [items, setItems] = useState<ModelItem[]>([]);
useEffect(() => {
if (!client || !workspaceAppId) return;
loadBookingModel(client, workspaceAppId).then(async (m) => {
setModel(m);
setItems(await listModelItems(client, { modelId: m.id }));
});
}, [client, workspaceAppId]);
async function add(room: string, when: Date) {
if (!client || !model) return;
const result = await createModelItem(client, {
input: {
attributes: {
modelId: model.id,
fieldValuesAttributes: toFieldValuesAttributes(model, {
room,
when: when.toISOString(),
}),
},
},
});
if (result?.errors?.length) throw new Error(result.errors[0].name);
setItems((prev) => [...prev, result!.modelItem!]); // don't re-list: the index lags
}
async function remove(id: string) {
if (!client) return;
const result = await deleteModelItem(client, { input: { id } });
if (!result?.errors?.length) setItems((prev) => prev.filter((i) => i.id !== id));
}
return { items, add, remove };
}- Read a value with
(item.fieldValuesByShortname ?? {}) as Record<string, unknown>—values.roomis'Aula Magna',values.whenan ISO timestamp. - The session exists only in the browser, after the bridge connects; the whole flow, and what to show when the app is opened outside uidu: Custom app. A working version is the demo's custom app.
Provision a Model from a script
Goal: set up a Model's schema — including a select field and its options — and seed it from a server-side script, ahead of the app's first load.
// scripts/provision-bookings.ts — runs on your machine or server, never in a browser
import {
createClient,
createFieldOption,
createModelItem,
ensureModel,
listModels,
toFieldValuesAttributes,
} from '@uidu/client';
const uiduAdmin = createClient({
workspace: process.env.UIDU_WORKSPACE!,
apiKey: process.env.UIDU_API_KEY,
});
const workspaceAppId = process.argv[2]; // the installed app's WorkspaceApp id
const existing = await listModels(uiduAdmin, { workspaceAppId });
console.log(existing.map((m) => `${m.name}: ${m.fieldList?.map((f) => f.shortname).join(', ')}`));
const model = await ensureModel(uiduAdmin, {
workspaceAppId,
name: 'Booking',
namePlural: 'Bookings',
fields: [
{ shortname: 'room', name: 'Room', kind: 'string', required: true },
{ shortname: 'when', name: 'When', kind: 'datetime' },
{ shortname: 'status', name: 'Status', kind: 'singleSelect' },
],
});
const status = model.fieldList?.find((f) => f.shortname === 'status');
if (status && !status.optionList?.length) {
for (const [name, color] of [['Requested', 'yellow'], ['Confirmed', 'green']]) {
const result = await createFieldOption(uiduAdmin, {
input: { attributes: { fieldId: status.id, name, color } },
});
if (result?.errors?.length) throw new Error(result.errors[0].name);
}
}
const seeded = await createModelItem(uiduAdmin, {
input: {
attributes: {
modelId: model.id,
fieldValuesAttributes: toFieldValuesAttributes(model, {
room: 'Aula Magna',
when: new Date('2026-10-12T09:00').toISOString(),
}),
},
},
});
if (seeded?.errors?.length) throw new Error(seeded.errors[0].name);ensureModelmakes the script safe to re-run: it only adds what's missing.- Below it are the lower-level writes it uses:
createModel(client, { input: { attributes: { modelableId, name, namePlural } } })andcreateField(client, { input: { attributes: { fieldableId, fieldableType: 'Model', name, shortname, kind } } }).updateModel,updateFieldanddeleteFieldtake{ input: { id, attributes } }/{ input: { id } }, and so doupdateFieldOption/deleteFieldOption. listModelsreturns[]when the WorkspaceApp isn't visible to the token;getModel(client, { id })reads one model by id.
Change a record
Goal: move a booking to another room, and know what the item reports afterwards.
import { updateModelItem, toFieldValuesAttributes } from '@uidu/client';
const result = await updateModelItem(client, {
input: {
id: item.id,
attributes: {
fieldValuesAttributes: toFieldValuesAttributes(model, { room: 'Sala riunioni 1' }),
},
},
});
if (!result?.errors?.length) {
result?.modelItem?.fieldValuesByShortname; // { room: 'Sala riunioni 1', when: … }
}- The
roomentry has noid, so it is written as a new field value next to the old one;fieldValuesByShortnamereports the most recent. That's what you read back, but the old value is still stored. - To change an existing field value in place, use
updateFieldValue(client, { input: { id, attributes: { content: { value } } } })with that value's id;deleteFieldValue(client, { input: { id } })removes one, anddeleteLinkedRecord: truealso deletes the record it links to. TheModelItemthe SDK reads carries values by shortname, not their ids. getModelItem(client, { id })re-reads one item, with the id of its model.
API at a glance
Every @uidu/client function in Models (custom data), generated from the source — the full reference has the rest of the SDK.
| Function | Kind | Auth | CLI | Description |
|---|---|---|---|---|
createField | write | Bearer | — | Pass fieldableId (the model) with fieldableType: 'Model': a custom app may only name a type that matches the record it loads. |
createFieldOption | write | Bearer | — | Add an option to a select-kind field (attributes.fieldId, name, color). Returns the payload (with errors) or null. |
createModel | write | Bearer | — | Create a model in a WorkspaceApp (attributes.modelableId, name, namePlural). Returns the payload (with errors) or null. |
createModelItem | write | Bearer | — | Pass attributes.modelId and attributes.fieldValuesAttributes — build the latter by shortname with toFieldValuesAttributes. |
deleteField | write | Bearer | — | Delete a field from its model by { input: { id } }. Returns the payload (with errors) or null. |
deleteFieldOption | write | Bearer | — | Delete a select option by { input: { id } }. Returns the payload (with errors) or null. |
deleteFieldValue | write | Bearer | — | Clear a single value by its FieldValue id; deleteLinkedRecord: true also deletes the record it links to. Returns the payload (with errors) or null. |
deleteModelItem | write | Bearer | — | Delete an item by { input: { id } }. Returns the payload (with errors) or null. |
ensureModel | write | Bearer | — | Find the app's model by name, or create it — and any of fields it lacks. Returns the model with all its fields. What a custom app runs on its first load until schemas come from an install-time manifest (custom apps spec, step 3). Throws a UiduError (MUTATION_ERROR) when a write is refused. |
getModel | read | Bearer | — | One model with its fields, or null when the id is not a Model. |
getModelItem | read | Bearer | — | One model item with its values and its model.id, or null when the id is not a ModelItem. |
listModelItems | read | Bearer | — | A model's items. Each carries fieldValuesByShortname — { room: 'Aula Magna', when: '…' } — the easiest shape to read values from. |
listModels | read | Bearer | — | The models of a WorkspaceApp, with their fields. |
toFieldValuesAttributes | helper | — | — | { room: 'Aula Magna' } → [{ fieldId: '<id of room>', content: { value: 'Aula Magna' } }], for createModelItem / updateModelItem. Throws on a shortname the model doesn't have; skips undefined values. |
updateField | write | Bearer | — | Update a field (name, required, preferences…): { input: { id, attributes } }. Returns the payload (with errors) or null. |
updateFieldOption | write | Bearer | — | Update a select option: { input: { id, attributes } }. Returns the payload (with errors) or null. |
updateFieldValue | write | Bearer | — | Set a single value by its FieldValue id: { input: { id, attributes: { content: { value } } } }. Returns the payload (with errors) or null. |
updateModel | write | Bearer | — | Rename or reconfigure a model: { input: { id, attributes } }. Returns the payload (with errors) or null. |
updateModelItem | write | Bearer | — | Update an item: { input: { id, attributes: { fieldValuesAttributes } } }, built with toFieldValuesAttributes. Returns the payload (with errors) or null. |
Types: EnsureModelOptions, Model, ModelField, ModelFieldOption, ModelItem