Data API
Define an entity in your manifest, deploy, and get a full REST API instantly. No controllers, no SQL, no boilerplate.
Every entity also gets auto-generated MCP tools, so AI agents and external AI tools can read and write to the same data with the same RBAC enforced.
Endpoints
Base URL: /api/v1/apps/{appId}/collections/{entity}
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | / |
.read |
List records with simple equality filters |
| POST | / |
.create |
Create one record |
| POST | /query |
.read |
Query with advanced operators |
| POST | /bulk |
.create |
Bulk create (up to 1000 records) |
| GET | /{id} |
.read |
Get one record by ID |
| PATCH | /{id} |
.update |
Partial update |
| DELETE | /{id} |
.delete |
Delete one record |
Every request requires a valid Authorization: Bearer token. The Core resolves the user's permissions and checks app:{appId}:{entity}.{action} before executing any SQL. Unknown entities return 404.
System columns
Every entity gets three auto-managed columns:
| Column | Type | Description |
|---|---|---|
id |
UUID | Primary key. Auto-generated on create. |
created_at |
TIMESTAMPTZ | Set on insert. Cannot be overridden. |
updated_at |
TIMESTAMPTZ | Set on insert and every update. Cannot be overridden. |
List records (GET)
GET /api/v1/apps/{appId}/collections/{entity}?stage=lead&limit=50&sort=created_at&order=desc
Query parameters
| Parameter | Default | Description |
|---|---|---|
| Any field name | Equality filter. Multiple fields combine with AND. Type is auto-cast to match the column type. | |
limit |
(none — returns all) | Max records to return. Range: 1-1000. If omitted, no LIMIT is applied (returns all matching records). |
offset |
0 | Records to skip (pagination). |
sort |
created_at |
Column to sort by. Must be a known field, id, created_at, or updated_at. Unknown fields fallback to created_at. |
order |
desc |
Sort direction: asc or desc. |
linked |
Set to true to enrich with federated identity data from all apps, or a comma-separated list of app IDs. |
Response
Returns a JSON array of record objects:
[
{
"id": "a1b2c3d4-...",
"first_name": "Alice",
"last_name": "Smith",
"stage": "lead",
"created_at": "2024-12-01T10:00:00Z",
"updated_at": "2024-12-01T10:00:00Z"
}
]
Query records (POST /query)
For complex filters beyond simple equality:
POST /api/v1/apps/{appId}/collections/{entity}/query
Request body
{
"where": {
"$or": [
{ "stage": "lead" },
{ "amount": { "$gt": 10000 } }
]
},
"orderBy": "created_at",
"order": "desc",
"limit": 50,
"offset": 0,
"linked": true
}
| Field | Type | Default | Description |
|---|---|---|---|
where |
object | Filter conditions using the operator DSL (see below). | |
orderBy |
string | created_at |
Column to sort by. |
order |
string | desc |
Sort direction: asc or desc. |
limit |
number | 100 | Max records. Range: 1-1000. |
offset |
number | 0 | Records to skip. |
linked |
boolean or string[] | Enrich with federated identity data. true for all apps, or array of specific app IDs. |
Response
{
"data": [{ "id": "...", "first_name": "Alice", ... }],
"total": 42
}
The total field is the count of all matching records (ignoring limit/offset), useful for pagination.
Where operators
| Operator | Description | Example |
|---|---|---|
| Direct value | Equality (shorthand for $eq) |
{ "status": "active" } |
$eq |
Equal. Handles null: { "$eq": null } becomes IS NULL. |
{ "status": { "$eq": "active" } } |
$ne |
Not equal. Handles null: { "$ne": null } becomes IS NOT NULL. |
{ "status": { "$ne": "archived" } } |
$gt |
Greater than | { "amount": { "$gt": 100 } } |
$gte |
Greater than or equal | { "amount": { "$gte": 100 } } |
$lt |
Less than | { "amount": { "$lt": 1000 } } |
$lte |
Less than or equal | { "amount": { "$lte": 1000 } } |
$like |
SQL LIKE (case-sensitive) | { "name": { "$like": "%acme%" } } |
$ilike |
SQL ILIKE (case-insensitive) | { "name": { "$ilike": "%acme%" } } |
$in |
Value in array | { "status": { "$in": ["active", "lead"] } } |
$nin |
Value not in array | { "status": { "$nin": ["archived", "deleted"] } } |
$contains |
PostgreSQL array contains (for [text] and [number] fields) |
{ "tags": { "$contains": ["vip"] } } |
$isNull |
Is null / is not null | { "email": { "$isNull": true } } |
Logical operators
| Operator | Description | Example |
|---|---|---|
$and |
AND multiple conditions (array) | { "$and": [{ "stage": "lead" }, { "amount": { "$gt": 1000 } }] } |
$or |
OR multiple conditions (array) | { "$or": [{ "stage": "lead" }, { "stage": "qualified" }] } |
$not |
Negate a condition (object) | { "$not": { "status": "archived" } } |
Multiple keys at the top level are implicitly ANDed.
Create record (POST)
POST /api/v1/apps/{appId}/collections/{entity}
{ "first_name": "Alice", "last_name": "Smith", "email": "alice@example.com" }
Response (201):
{
"id": "a1b2c3d4-...",
"first_name": "Alice",
"last_name": "Smith",
"email": "alice@example.com",
"created_at": "2024-12-01T10:00:00Z",
"updated_at": "2024-12-01T10:00:00Z"
}
Body must contain at least one writable field. System fields (id, created_at, updated_at) are ignored if sent.
Bulk create (POST /bulk)
POST /api/v1/apps/{appId}/collections/{entity}/bulk
Body: a JSON array of record objects. Maximum 1000 records per request.
[
{ "first_name": "Alice", "email": "alice@example.com" },
{ "first_name": "Bob", "email": "bob@example.com" }
]
Response (201): array of created records with IDs.
Get record (GET /{id})
GET /api/v1/apps/{appId}/collections/{entity}/{id}
Returns the full record object. 404 if not found.
Update record (PATCH /{id})
PATCH /api/v1/apps/{appId}/collections/{entity}/{id}
{ "first_name": "Alice B." }
Only the fields you send are updated. updated_at is set automatically. Returns the full updated record.
Delete record (DELETE /{id})
DELETE /api/v1/apps/{appId}/collections/{entity}/{id}
Returns { "message": "record '{id}' deleted" }. 404 if not found.
Linked entities (identity federation)
When entities declare identityKind and identityKey in the manifest, the linked parameter enriches records with data from other apps that share the same identity kind.
Example: if contacts in your CRM declares identityKind: "contact" and identityKey: "email", and the support app also has contacts with the same identity kind, passing linked=true adds a _linked object to each record with matching data from other apps.
The Core only includes data from apps the current user has .read permission for.
Federated query
Query across all entities that share the same identity kind:
POST /api/v1/federated/{identityKind}/query
Same request body as POST /query. Returns records from all apps that declare the matching identityKind, filtered by the user's read permissions. Each record includes _source: { app, entity } metadata.
Schema Sync Engine
Every deploy triggers an automatic diff between your manifest and the live PostgreSQL schema. The Core generates and runs the minimum DDL within a transaction.
| Change | DDL executed |
|---|---|
| New entity | CREATE TABLE |
| New field | ALTER TABLE ADD COLUMN |
| Field type changed | ALTER COLUMN TYPE with USING cast |
required added |
SET NOT NULL |
required removed |
DROP NOT NULL |
default_value set |
SET DEFAULT |
default_value removed |
DROP DEFAULT |
on_delete changed |
Replace foreign key constraint |
| Field removed | DROP COLUMN CASCADE |
| Entity removed | DROP TABLE CASCADE |
indexes changed |
Drop + recreate (tag-owned, no churn if unchanged) |
checks / enum_values changed |
Drop + recreate (tag-owned, no churn if unchanged) |
Column DDL runs in a single transaction per table. Indexes and checks are reconciled in a separate pass. If any statement fails, the transaction rolls back and the deploy is rejected. Unchanged objects are left untouched (no drop/recreate churn).
id, created_at, and updated_at are protected.Auto-generated MCP
Every entity gets auto-generated MCP tools. AI agents and external AI tools interact with your data through MCP, with the same RBAC permissions enforced. The tools mirror the REST API: query, create, update, delete.