# Models (custom data)

URL: https://developers.uidu.org/docs/domains/models

> Define your own record types — Models with typed Fields — and store, read and update their ModelItems.

* **Use it for:** [Server integration](https://developers.uidu.org/docs/paths/server-integration.md) · [Custom app](https://developers.uidu.org/docs/paths/custom-app.md)
* **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](https://developers.uidu.org/docs/paths/custom-app.md)
* **Auth:** what each token can do and where it may run — [Authentication](https://developers.uidu.org/docs/foundations/authentication.md)

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](https://developers.uidu.org/docs/paths/custom-app.md) keeps the data that is its own: the bookings of a
room, a checklist, a log.

## Mental model

<Callout type="warn" title="Read what the workspace already has">
  A uidu workspace is not empty: it already holds goals, contacts, deals, people, events,
  courses… &#x2A;*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](https://developers.uidu.org/docs/domains/goals.md) or [Contacts](https://developers.uidu.org/docs/domains/contacts.md),
  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.
</Callout>

```
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](https://developers.uidu.org/docs/paths/custom-app.md). 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 `kind`s 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](#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`).

<Callout type="warn" title="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.
</Callout>

### 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).

<Callout type="info" title="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](https://developers.uidu.org/docs/domains/forms.md).
</Callout>

### 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

* **`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`](https://developers.uidu.org/docs/domains/search.md).
* **`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](#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.

```tsx
'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](https://developers.uidu.org/docs/paths/custom-app.md). A working
  version is the [demo's custom app](https://github.com/uidu-org/api.js/tree/main/apps/demo/src/app/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.

```ts
// 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.

```ts
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 &#x2A;*Models (custom data)**, generated from the source — the [full reference](https://developers.uidu.org/docs/reference/client.md#models) has the rest of the SDK.

| Function                                                                                                | Kind   | Auth   | CLI | Description                                                                                                                                                                                                                                                                                              |
| ------------------------------------------------------------------------------------------------------- | ------ | ------ | --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`createField`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/models.ts)             | write  | Bearer | —   | Pass `fieldableId` (the model) with `fieldableType: 'Model'`: a custom app may only name a type that matches the record it loads.                                                                                                                                                                        |
| [`createFieldOption`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/models.ts)       | write  | Bearer | —   | Add an option to a select-kind field (`attributes.fieldId`, `name`, `color`). Returns the payload (with `errors`) or null.                                                                                                                                                                               |
| [`createModel`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/models.ts)             | write  | Bearer | —   | Create a model in a WorkspaceApp (`attributes.modelableId`, `name`, `namePlural`). Returns the payload (with `errors`) or null.                                                                                                                                                                          |
| [`createModelItem`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/models.ts)         | write  | Bearer | —   | Pass `attributes.modelId` and `attributes.fieldValuesAttributes` — build the latter by shortname with `toFieldValuesAttributes`.                                                                                                                                                                         |
| [`deleteField`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/models.ts)             | write  | Bearer | —   | Delete a field from its model by `{ input: { id } }`. Returns the payload (with `errors`) or null.                                                                                                                                                                                                       |
| [`deleteFieldOption`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/models.ts)       | write  | Bearer | —   | Delete a select option by `{ input: { id } }`. Returns the payload (with `errors`) or null.                                                                                                                                                                                                              |
| [`deleteFieldValue`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/models.ts)        | 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`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/models.ts)         | write  | Bearer | —   | Delete an item by `{ input: { id } }`. Returns the payload (with `errors`) or null.                                                                                                                                                                                                                      |
| [`ensureModel`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/models.ts)             | 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`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/models.ts)                | read   | Bearer | —   | One model with its fields, or null when the id is not a Model.                                                                                                                                                                                                                                           |
| [`getModelItem`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/models.ts)            | read   | Bearer | —   | One model item with its values and its `model.id`, or null when the id is not a ModelItem.                                                                                                                                                                                                               |
| [`listModelItems`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/models.ts)          | read   | Bearer | —   | A model's items. Each carries `fieldValuesByShortname` — `{ room: 'Aula Magna', when: '…' }` — the easiest shape to read values from.                                                                                                                                                                    |
| [`listModels`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/models.ts)              | read   | Bearer | —   | The models of a WorkspaceApp, with their fields.                                                                                                                                                                                                                                                         |
| [`toFieldValuesAttributes`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/models.ts) | 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`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/models.ts)             | write  | Bearer | —   | Update a field (name, `required`, preferences…): `{ input: { id, attributes } }`. Returns the payload (with `errors`) or null.                                                                                                                                                                           |
| [`updateFieldOption`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/models.ts)       | write  | Bearer | —   | Update a select option: `{ input: { id, attributes } }`. Returns the payload (with `errors`) or null.                                                                                                                                                                                                    |
| [`updateFieldValue`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/models.ts)        | write  | Bearer | —   | Set a single value by its FieldValue id: `{ input: { id, attributes: { content: { value } } } }`. Returns the payload (with `errors`) or null.                                                                                                                                                           |
| [`updateModel`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/models.ts)             | write  | Bearer | —   | Rename or reconfigure a model: `{ input: { id, attributes } }`. Returns the payload (with `errors`) or null.                                                                                                                                                                                             |
| [`updateModelItem`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/models.ts)         | 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`
