uidudevelopers
Domains

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 is context.workspaceApp.id from 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 [] or null, not an error.
  • Auth: a custom app's session token (the member, framed in uidu) or an account Bearer token (apiKey, server-side only). A publicToken alone doesn't reach a WorkspaceApp's models. Writes are server-side with the apiKey — except a custom app writing its own Models with the member's session, in the browser.
  • Shapes. A Model comes with its schema: id, name, namePlural, fieldList[] (id, name, shortname, kind, required, position, optionList[] of { id, name, color, position }). A ModelItem has id, name, createdAt, updatedAt and fieldValuesByShortname — { room: 'Aula Magna', when: '…' }, the easiest shape to read values from. getModelItem adds model.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:

WritePayload
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

  • listModelItems lags behind writes. It reads uidu's search index, which catches up a moment after a write. After createModelItem / deleteModelItem, update your list from the mutation payload instead of re-listing straight away, or the new item will be missing. Its optional params are search parameters, the same shape as search.
  • updateModelItem appends, it doesn't edit in place. Entries in fieldValuesAttributes without an id are written as new field values; fieldValuesByShortname reports 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.room is 'Aula Magna', values.when an 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);
  • ensureModel makes 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 } } }) and createField(client, { input: { attributes: { fieldableId, fieldableType: 'Model', name, shortname, kind } } }). updateModel, updateField and deleteField take { input: { id, attributes } } / { input: { id } }, and so do updateFieldOption / deleteFieldOption.
  • listModels returns [] 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 room entry has no id, so it is written as a new field value next to the old one; fieldValuesByShortname reports 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, and deleteLinkedRecord: true also deletes the record it links to. The ModelItem the 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.

FunctionKindAuthCLIDescription
createFieldwriteBearer—Pass fieldableId (the model) with fieldableType: 'Model': a custom app may only name a type that matches the record it loads.
createFieldOptionwriteBearer—Add an option to a select-kind field (attributes.fieldId, name, color). Returns the payload (with errors) or null.
createModelwriteBearer—Create a model in a WorkspaceApp (attributes.modelableId, name, namePlural). Returns the payload (with errors) or null.
createModelItemwriteBearer—Pass attributes.modelId and attributes.fieldValuesAttributes — build the latter by shortname with toFieldValuesAttributes.
deleteFieldwriteBearer—Delete a field from its model by { input: { id } }. Returns the payload (with errors) or null.
deleteFieldOptionwriteBearer—Delete a select option by { input: { id } }. Returns the payload (with errors) or null.
deleteFieldValuewriteBearer—Clear a single value by its FieldValue id; deleteLinkedRecord: true also deletes the record it links to. Returns the payload (with errors) or null.
deleteModelItemwriteBearer—Delete an item by { input: { id } }. Returns the payload (with errors) or null.
ensureModelwriteBearer—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.
getModelreadBearer—One model with its fields, or null when the id is not a Model.
getModelItemreadBearer—One model item with its values and its model.id, or null when the id is not a ModelItem.
listModelItemsreadBearer—A model's items. Each carries fieldValuesByShortname — { room: 'Aula Magna', when: '…' } — the easiest shape to read values from.
listModelsreadBearer—The models of a WorkspaceApp, with their fields.
toFieldValuesAttributeshelper——{ 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.
updateFieldwriteBearer—Update a field (name, required, preferences…): { input: { id, attributes } }. Returns the payload (with errors) or null.
updateFieldOptionwriteBearer—Update a select option: { input: { id, attributes } }. Returns the payload (with errors) or null.
updateFieldValuewriteBearer—Set a single value by its FieldValue id: { input: { id, attributes: { content: { value } } } }. Returns the payload (with errors) or null.
updateModelwriteBearer—Rename or reconfigure a model: { input: { id, attributes } }. Returns the payload (with errors) or null.
updateModelItemwriteBearer—Update an item: { input: { id, attributes: { fieldValuesAttributes } } }, built with toFieldValuesAttributes. Returns the payload (with errors) or null.

Types: EnsureModelOptions, Model, ModelField, ModelFieldOption, ModelItem

On this page