---
title: "Custom Tables and Model Extension"
category: "data-center"
canonical: "https://www.ai-go.app/en-US/docs/guide/custom-tables"
locale: "en-US"
last_updated: "2026-08-10"
source: "AI GO 開發者文件（https://www.ai-go.app）"
---

# Custom Tables and Model Extension

The pre-built ERP tables cover general business processes, but every industry has its own data — gyms track body measurements, travel agencies track tour packages, tutoring centers track class attendance. This chapter covers how to extend the model.

---

## Three extension paths

| Path | Suited to | Queryable | Type-constrained | Supports relations |
|---|---|---|---|---|
| `custom_data` JSONB | A few unstructured extras | Limited | No | No |
| **Extension fields** | New columns on existing ERP tables | Yes | Yes | Yes |
| **Custom tables** | Entirely new entities | Yes | Yes | Yes |

The decision is simple: **is this an attribute of an existing entity, or a new entity?** A "membership card number" on a customer is the former (extension field); "class attendance records" is the latter (custom table).

---

## Custom tables are organization-level

This is the most important — and most often misunderstood — property:

**Custom tables belong to the organization, not to an application.**

Every Custom App under the same organization, plus the organization's own Data Center interface, sees **the same tables and the same rows**.

That imposes one discipline: **check for a reusable existing table before creating one**. A single "customers" table should not fragment into two disconnected copies because two applications each created their own.

---

## Dual naming: display name and physical name

Every table and every column has two names:

| | Display name | Physical name |
|---|---|---|
| Chosen by | You | Generated from the display name, or specified explicitly |
| Mutable | Any time | **Never, once created** |
| Character set | Anything | ASCII only |
| Used for | Interface labels | API identity, relation targets |

**All APIs and SDKs address existing tables and columns by physical name.** Display names are just labels — renaming one breaks nothing.

Physical-name generation avoids three namespaces: existing custom tables in the same organization, SQL reserved words, and the platform's built-in ERP table names. A display name of "Customers" in an empty environment yields `customers_2`, not `customers`, because the latter belongs to the ERP.

If you specify a physical name that collides with any of those, the system reports the conflict and names which category it hit — it does not silently rename.

---

## Column types

| Type | Notes | Extra contract |
|---|---|---|
| `text` | Text | |
| `number` | Numeric | |
| `boolean` | Boolean | |
| `date` | Date | |
| `datetime` | Timestamp | |
| `select` | Single choice | Requires an option set; values are constrained at the database level |
| `relation` | Relation | See below |
| `json` | Structured data | |
| `image` | Image | See below |

Every table automatically carries system columns (identifier, created time, updated time). These cannot be deleted or retyped, and do not count toward the column quota.

### `relation` columns

A `relation` can point at **two kinds of target**: another custom table, or a built-in ERP table.

That means an "attendance record" custom table can have a "student" column pointing directly at the ERP `customers` table — no duplicated customer data, no hand-maintained mapping.

### `image` columns

Image columns provide the full pipeline: uploads route through the platform (rather than the browser writing straight to storage), reads return short-lived signed URLs, and cross-organization access control applies. Images count toward the organization's storage quota.

---

## Who can change structure

**Structural operations are administrators only** (users holding `system.admin`): creating, altering, and dropping tables and columns, plus ERP extension field definitions.

**Data operations (record CRUD) and structure reads** remain at ordinary development permission (`builder.access`).

What's governed is the shape of the schema, not its use. This check runs **at the entry layer**, and all three entry points (REST API, SDK, AI tooling) enforce it identically — the AI channel cannot route around the REST gate.

---

## Deletion is two-stage

Dropping a table or column takes two steps, enforced server-side rather than by a front-end confirmation dialog:

1. **Impact preview** — the system reports what the deletion affects: how many rows, which relations point at it
2. **Physical-name confirmation** — you must type the **physical name** to proceed

Deletion is irreversible. The design deliberately makes accidents hard.

---

## Three entry points, one service layer

Every operation on custom tables — structural and data — funnels into a single service layer, shared by three entry points:

| Entry point | Serving | Identity source |
|---|---|---|
| REST API | Organization users (Data Center UI) | The signed-in user's session |
| SDK (`ctx.db`) | Custom App runtime | The application's runtime identity + scope |
| Builder tooling | The AI development assistant | The Builder session's organization identity |

**That service layer is the sole home of the guardrails.** Entry points do not reimplement validation, and they do not bypass it:

- **Tenant isolation** — every operation is anchored to an organization; cross-organization access reports "not found" without revealing whether the resource exists
- **Serialized structural changes** — DDL takes an organization-level lock, so concurrent table creation cannot tear
- **Quotas** — per-organization table limits and per-table column limits, checked inside the lock so concurrency cannot slip past
- **Type and value validation** — column types, option sets, defaults, relation targets
- **Error translation** — database-level failures (unique violations, not-null violations, foreign key violations, dependent objects) are translated into structured codes and actionable messages rather than raw technical errors

**Any new entry point should call this layer rather than assembling its own SQL.**

---

## Reaching custom tables from the SDK

```python
def execute(ctx):
    # List the organization's custom tables
    tables = ctx.db.list_tables()

    # Query (by physical name)
    rows = ctx.db.query_table('attendance', filters=[
        {'column': 'class_date', 'op': 'gte', 'value': '2026-08-01'}
    ])

    # Insert
    ctx.db.insert_row('attendance', {
        'student_id': ctx.params['customer_id'],   # relation to ERP customers
        'class_date': '2026-08-10',
        'status': 'present',
    })

    # Update / delete
    ctx.db.update_row('attendance', row_id, {'status': 'late'})
    ctx.db.delete_row('attendance', row_id)
```

The front end uses the built-in `src/db.ts` SDK with equivalent semantics. See Chapter 8.

---

## ERP extension fields

Extension fields add real columns to **existing ERP tables**. Unlike `custom_data`, they are queryable, filterable, type-constrained, and can be authorized through the reference system.

Use them when the data is an intrinsic attribute of an existing entity and you need to filter or sort by it — for example, adding "membership expiry" to `customers` so you can query "members expiring this month".

Dropping an extension field follows the same two-stage flow with an impact preview.