Appearance
Add an entity
What you're doing
An entity is a database table you declare instead of writing SQL. You list its fields and their data types; the platform's Entity Engine generates the CREATE TABLE, the CRUD REST endpoints (/api/v1/entities/<name>/records), the grid query endpoint, and single-record reads. Zero persistence code.
One entity = one file under spk-assembly/metadata/entities/.
The complete example
spk-assembly/metadata/entities/oeq_equipment.json from the tutorial module — the real file:
json
{
"entity": {
"name": "oeq_equipment",
"tableName": "oeq_equipment",
"label": "{\"en\":\"Equipment\"}",
"category": "office-equipment",
"icon": "devices",
"color": "#2563EB",
"pkStrategy": "identity"
},
"fields": [
{ "fieldName": "asset_tag", "label": "\"Asset Tag\"", "dataType": "text", "nullable": false, "required": true, "isUnique": true, "isIndexed": true, "isDisplayField": true, "displayOrder": 1 },
{ "fieldName": "name", "label": "\"Name\"", "dataType": "text", "nullable": false, "required": true, "displayOrder": 2 },
{ "fieldName": "category_id", "label": "\"Category Id\"", "dataType": "long", "nullable": true, "required": false, "isIndexed": true, "displayOrder": 3 },
{ "fieldName": "category_name", "label": "\"Category\"", "dataType": "text", "nullable": true, "required": false, "displayOrder": 4 },
{ "fieldName": "serial_number", "label": "\"Serial Number\"", "dataType": "text", "nullable": true, "required": false, "displayOrder": 5 },
{ "fieldName": "status", "label": "\"Status\"", "dataType": "enum", "nullable": false, "required": true, "isIndexed": true, "defaultValue": "AVAILABLE", "typeParams": "{\"enumValues\":[\"AVAILABLE\",\"ASSIGNED\",\"MAINTENANCE\",\"RETIRED\"]}", "displayOrder": 6 },
{ "fieldName": "purchase_date", "label": "\"Purchase Date\"", "dataType": "date", "nullable": true, "required": false, "displayOrder": 7 },
{ "fieldName": "warranty_expiry_date", "label": "\"Warranty Expiry Date\"", "dataType": "date", "nullable": true, "required": false, "displayOrder": 8 },
{ "fieldName": "notes", "label": "\"Notes\"", "dataType": "text", "nullable": true, "required": false, "displayOrder": 9 }
]
}Line by line
The entity block
name— lowercase,snake_case, starts with a letter. This is the table name and the id in every REST path. Prefix it (oeq_) so it never collides with another plugin's table.label— this column is stored as JSON in the database, so the string must itself be valid JSON:"{\"en\":\"Equipment\"}"(an i18n object) — not"Equipment". A bare string fails the install withinvalid input syntax for type json. This is the single most common entity mistake.pkStrategy—identitygives an auto-incrementing integerid.snowflakegives a distributed 64-bit id. Useidentityunless you have a reason not to.icon/color/category— cosmetic, used by Studio's explorer tree.
Each field
fieldName—snake_case, starts with a letter.label— also pre-stringified JSON."\"Asset Tag\""is the JSON string literal"Asset Tag". A bare"Asset Tag"fails the install.dataType— accepted case-insensitively; every shipped file uses lowercase. The ones you'll actually use:dataTypePostgres notes texttextany string, short or long. Not long— that's the 64-bit integer type.integerint32-bit longbigint64-bit; use for foreign-key id columns decimal/currencynumericmoney booleanbooleandatedatedatetimetimestamptzenumtext+ checkneeds typeParams(below)typeParams— also a pre-stringified JSON string. For an enum:"{\"enumValues\":[\"AVAILABLE\",\"ASSIGNED\"]}".nullable/required— arequired: truefield must also benullable: false. Arequired: falsefield must benullable: true. A mismatched pair produces a brokenALTER TABLEon a table that already has rows.isUnique,isIndexed— add a unique constraint / index (scoped to the tenant).isDisplayField: true— marks the human-readable "name" column. Pick exactly one.defaultValue— always a string, even for numbers/booleans:"10","true","AVAILABLE".
Foreign keys and automatic labels
category_id is a long holding another table's id. Two ways to show a name instead of a raw number:
- Denormalize — also store
category_nameand write both when you create a row (what the tutorial does — simplest). referenceflag — add"flags": "{\"reference\":{\"entityType\":\"Employee\"}}"to the id field. Every read then gains a sibling<field>_labelkey (employee_id→employee_id_label) resolved automatically, batched once per page load.entityTypemust be a registered provider —"Employee"is the only one shipped today. See resolved reference column.
The tutorial's oeq_checkout entity uses the reference flag on employee_id and it was verified live to return "employee_id_label": "Aria Chen".
Ground yourself first
bash
erp schema pull entity-definition # the full contractOr over MCP: erp_get_schema {"name":"entity-definition"}.
Two file shapes both validate
{ "entity": { ... }, "fields": [ ... ] }— the packaged artifact shape, used inspk-assembly/metadata/entities/. Use this.- flat (
{ "name": ..., "label": ..., "fields": [ ... ] }) — the livePOST /api/v1/entitiesrequest body. Only matters if you're calling the API directly.
How to verify it worked
bash
erp schema validate spk-assembly/metadata/entities/oeq_equipment.json --schema entity-definitionOK — spk-assembly/metadata/entities/oeq_equipment.json matches schema "entity-definition"After publishing, the table is live:
bash
erp api get "/api/v1/entities/oeq_equipment/records/query?size=5"json
{ "rows": [], "total": 0 }And a record you create comes back with audit columns the engine added for free:
json
{ "id": 1, "created_by": "you@example.com", "created_at": "2026-09-10T08:08:33.282+00:00",
"asset_tag": "LAP-001", "name": "Dell Latitude 7440", "status": "AVAILABLE" }Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
install fails invalid input syntax for type json | label is a bare string | make it "{\"en\":\"...\"}" (entity) or "\"...\"" (field) |
| a field silently missing after install | required:false without nullable:true on a table with rows | pair them correctly, bump version, republish |
| big text truncated / rejected | dataType: "long" | that's the integer type; use "text" |
| enum column rejects every value | typeParams is a nested object, not a string | pre-stringify it |
| grid shows a raw number for a person | bound to employee_id not employee_id_label | add the reference flag and bind the _label |
What to read next
- Add a data provider — so a grid can read the table
- Build a page
- Seed reference data on install