App UI description
Workspace apps · UI description spec (full).
Source docs/en/site/sdk-appsdk-ui.md
Workspace apps · UI description spec (full).
Doc version: 1.0 (matches app.json / AppSpec v1) Readers: people who hand-write or review an app UI description (including the app-development assistant when it revises a draft) Related: appsdk directory notes · App views · Hand-build an app · Workspace apps mechanism
1. Core principles
- The UI is generated from a description, not a hand-written web page. The platform Web standard renderer reads
app.jsonand draws lists and forms fromviews. - One description does three things: what data looks like (
entities) → which blocks the page has (views) → what buttons call (actions). - User-visible copy uses
label;name/id/actionare program ids (lowercase, underscores, and similar). Do not use them as UI titles. - Currently only three views:
list|form|action_form. There is no standalone layout model; extensions and “UI from a picture” are in App views.
When the app opens, the renderer draws each block top to bottom in views array order.
2. Files and pack location
| File | Role |
|---|---|
app.json | UI and data-model description (this spec’s main subject) |
manifest.json | App manifest: display name, assembly, ui_mode=metadata, and similar (usually written by the platform) |
data/app.db | Business DB generated from entities (separate from the Cadau main DB) |
logic/handlers.py | Custom action implementations with impl=script (called after the UI submits) |
schema/001_init.sql | CREATE TABLE SQL generated from entities (maintained when the spec is created/updated) |
When hand-writing the UI, you usually only change app.json. If you add a custom action, you also implement the matching action id in handlers.py.
3. Root object
{
"version": 1,
"entities": [ /* at least one */ ],
"views": [ /* at least one */ ],
"actions": [ /* actions referenced by action_form must be declared here */ ]
}
| Field | Required | Meaning |
|---|---|---|
version | Recommended | Currently 1; if omitted the platform fills 1 on write |
entities | Yes | Business-object (table) definitions; at least 1 |
views | Yes | UI blocks; at least 1 |
actions | Conditional | If there is an action_form, you must declare the action it references |
Validation failure cannot save/publish (see §10).
4. Entities entities[]
One business table (SQLite) inside the app. The platform automatically adds primary-key column id (autoincrement). You need not declare id in fields.
{
"name": "profile_reports",
"label": "Profile records",
"fields": [
{ "name": "emp_name", "label": "Employee name", "type": "text", "required": true },
{ "name": "status", "label": "Report status", "type": "select", "default": "待生成",
"options": ["待生成", "已生成", "失败"] },
{ "name": "created_at", "label": "Generated at", "type": "datetime", "read_only": true, "auto": "now" }
]
}
4.1 Entity attributes
| Field | Required | Rules |
|---|---|---|
name | Yes | Table name; ^[a-z][a-z0-9_]*$ (starts with a lowercase letter; lowercase, digits, underscore only) |
label | Yes | User-visible name (for example “Profile records”) |
fields | Yes | At least 1 field (excluding system id) |
4.2 Fields fields[]
| Field | Required | Meaning |
|---|---|---|
name | Yes | Column name; same style as entity names; do not use id (system-reserved) |
label | Yes | Column header / form label |
type | Recommended | See the table below; empty string is treated as text |
required | No | true → column NOT NULL; form shows * |
default | No | Default (written into DDL; form initial value depends on the control) |
min | No | number only: HTML min |
read_only | No | true: not shown on editable forms |
auto | No | Currently "now": on create the frontend writes UTC ISO (no input shown) |
hidden | No | true: by default not shown as a list column or on the form |
options | No | String list of options for select |
Field types and UI controls:
type | Control | SQLite column type |
|---|---|---|
text (default) | Single-line input | TEXT |
number | type=number | INTEGER |
textarea | Multi-line text | TEXT |
datetime | datetime-local (wall clock in the account display timezone) | TEXT (stored as UTC ISO) |
select | Dropdown; must have options | TEXT |
Time conventions (aligned with product “time display and timezone preference”):
- On disk:
datetime/auto=noware UTC absolute-time strings; do not store “Beijing time pretending to be local, with no timezone” in the DB. - List display: the platform formats by the current user’s display timezone, date format, and 12/24-hour setting; not the browser system timezone.
- Form input: the user fills wall clock in the display timezone; submit converts to UTC ISO.
- Account preference itself cannot be written into
app.json; point users to “Me → Preferences → Time display”.
4.3 Special list convention
If the entity has a field named upload_id (stores an attachment id), the list Actions column offers Open / Download for rows that have a value (used for employee-profile HTML reports and similar). This is a renderer convention, not a separate view type.
5. Views views[] (how the UI is drawn)
Each element is one UI block. type decides what is drawn.
5.1 List list
Shows a table for an entity; data comes from built-in action crud.list.
{
"type": "list",
"id": "history",
"label": "Past profiles",
"entity": "profile_reports",
"columns": ["emp_name", "report_title", "status", "created_at"],
"limit": 100,
"allow_delete": true
}
| Field | Required | Meaning |
|---|---|---|
type | Yes | "list" |
entity | Yes | Must be a declared entity name |
label | Recommended | Block title; default “List” |
id | No | Frontend key; recommended for stability |
columns | No | Column order (field names); if omitted, all non-hidden, non-id entity fields |
limit | No | Rows to fetch; default 100 |
allow_delete | No | Omitted or true: show delete; false: hide delete. Before delete, a product-style confirm (not the browser native one) |
UI behavior: “Refresh” next to the title; delete calls crud.delete.
5.2 Entity form form
Inserts one row into an entity; submit calls crud.create.
{
"type": "form",
"label": "New note",
"entity": "notes",
"mode": "create",
"fields": ["title", "body"]
}
| Field | Required | Meaning |
|---|---|---|
type | Yes | "form" |
entity | Yes | Target entity |
mode | Recommended | Current implementation treats as create; write "create" |
fields | No | Subset of field names to show; if omitted, all editable entity fields |
label | Recommended | Block title; default “New” |
Fields that do not appear on the form: id, hidden=true, read_only=true, auto=now (auto=now is written by the frontend on submit; no input shown).
5.3 Action form action_form
Collects params and calls a custom action (usually impl=script).
{
"type": "action_form",
"id": "generate",
"label": "Generate employee profile",
"action": "profile.generate"
}
| Field | Required | Meaning |
|---|---|---|
type | Yes | "action_form" |
action | Yes | Must match actions[].id |
label | Recommended | Block title |
id | No | Recommended |
fields | Does not drive controls | See “Important” below |
Important (not intuitive): when the standard renderer draws an action form, inputs come from actions[].params, not views[].fields. views[].fields may be documentation/assistant hints, but the current frontend does not generate controls from it. When hand-writing, put form fields on the matching action’s params.
After submit: calls invoke(action, params). If the result includes upload_id (and optional filename), the UI can offer open/download report (same as employee profile).
6. Actions actions[]
{
"id": "profile.generate",
"label": "Generate employee profile",
"impl": "script",
"params": [
{ "name": "emp_name", "label": "Employee name", "type": "text", "required": true },
{ "name": "emp_id", "label": "Employee ID (when names collide)", "type": "text" }
]
}
| Field | Required | Meaning |
|---|---|---|
id | Yes | Action id; prefer domain.verb (for example profile.generate) |
label | Yes | User-visible name |
impl | Yes | "script": goes through logic/handlers.py; "crud.create": built-in create (rare; usually a form view is enough) |
params | Recommended | Field definitions for the action form; decide which inputs appear on action_form |
6.1 Params params[]
| Field | Meaning |
|---|---|
name | Param name submitted to the script |
label | Form label |
type | Same as field types: text / number / …; default text |
required | Whether required |
select on action params: if you need a dropdown, the current action-param structure does not include options. Put dropdown options on entity fields and use form/list. Action forms currently center on text/number/textarea/datetime (aligned with renderer resolveActionFields). For enums, use text and validate in the script, or extend the contract later.
6.2 Built-in actions (need not be in actions)
Provided by the platform; lists/forms call them automatically:
| action | Use |
|---|---|
crud.list | List load |
crud.create | Entity form submit |
crud.update | (API exists; standard UI has no edit view yet) |
crud.delete | List delete |
schema.tables | List business tables |
ping | Health check |
A custom action referenced by action_form but missing from actions → validation fails.
6.3 Mapping to handlers.py
When impl=script, runtime calls the in-pack script entry, roughly:
handle(action, params, ctx) -> dict
actionequalsactions[].id(for exampleprofile.generate)paramsare key-values submitted from the form- Return should include
ok; on failure includeerror; optionalupload_id/warningsand similar for the UI
Script detail and the platform bridge (fetch data, save attachments) are in the mechanism docs under “invoke”. Whether a button appears on the UI is still decided by this file’s actions + action_form.
7. Minimal hand-written example (notebook)
{
"version": 1,
"entities": [
{
"name": "notes",
"label": "Notes",
"fields": [
{ "name": "title", "label": "Title", "type": "text", "required": true },
{ "name": "body", "label": "Body", "type": "textarea" },
{ "name": "created_at", "label": "Created at", "type": "datetime", "read_only": true, "auto": "now" }
]
}
],
"views": [
{
"type": "form",
"label": "New note",
"entity": "notes",
"mode": "create",
"fields": ["title", "body"]
},
{
"type": "list",
"label": "All notes",
"entity": "notes",
"columns": ["title", "created_at"],
"allow_delete": true
}
]
}
No actions needed: only form + list, all built-in CRUD.
8. Hand-written example with a custom action (sketch)
{
"version": 1,
"entities": [
{
"name": "jobs",
"label": "Job records",
"fields": [
{ "name": "keyword", "label": "Keyword", "type": "text", "required": true },
{ "name": "status", "label": "Status", "type": "select", "options": ["待处理", "完成", "失败"] },
{ "name": "upload_id", "label": "Attachment ID", "type": "text", "hidden": true },
{ "name": "created_at", "label": "Time", "type": "datetime", "read_only": true, "auto": "now" }
]
}
],
"views": [
{
"type": "action_form",
"id": "run",
"label": "Run job",
"action": "job.run"
},
{
"type": "list",
"label": "History",
"entity": "jobs",
"columns": ["keyword", "status", "created_at"]
}
],
"actions": [
{
"id": "job.run",
"label": "Run job",
"impl": "script",
"params": [
{ "name": "keyword", "label": "Keyword", "type": "text", "required": true }
]
}
]
}
You also need to implement job.run in logic/handlers.py (produce a result, optionally write the jobs table). Changing only the UI description without a script will fail on submit.
9. Employee-profile walkthrough (live structure)
| What the user sees | What it maps to in the description |
|---|---|
| “Generate employee profile” form | views type=action_form, action=profile.generate; fields from that action’s params |
| “Past profiles” table | views type=list, entity=profile_reports |
| Delete history | allow_delete not set to false |
| Open/download report | Entity has upload_id, and the row has a value |
| Fetch and HTML after submit | Not part of the UI description; in logic/ + references/pipeline.json and similar |
A complete sample is the app.json in the runtime app pack (employee-profile template).
10. Validation checklist (self-check after hand-writing)
- [ ]
entities≥ 1, and each hasname/label/fields - [ ] Entity names and field names match the lowercase-id rules; fields have
label; types are valid - [ ]
views≥ 1;typeis onlylist|form|action_form - [ ]
list/formentityexists - [ ] Each
action_formactionexists inactions - [ ] Each
actions[]hasid/label;implisscriptorcrud.create - [ ] Action-form fields are on
actions[].params, not only onviews[].fields - [ ] Custom actions are implemented in
handlers.py(ifimpl=script) - [ ] User-visible copy is all in
label; English ids are not used as titles
Implementation validator: workspaceapp.ValidateSpec.
11. Explicitly out of this spec
| Need | Go to |
|---|---|
| Free layout, own CSS/routes, full web | Platform plugin SDK · App views |
| Fetch steps, HTML templates, assemble scripts | App data pipeline, App actions |
| “Make an app” from conversation | Workspace apps §create from conversation |
| Product help (end users) | help/product-features/workspace-apps.md |
| Hand-build a pack from scratch | Hand-build an app · App package layout |
12. Revision convention
- Contract changes (new view types, field attributes, action-param options, and similar) must sync: this file,
spec.govalidation,WorkspaceAppRenderer.tsxrender behavior, and bump this section’s doc version. - Mechanism overview Workspace apps may keep a short summary; full hand-write notes are this document.