The .intent file
One YAML document, at the project root, one per project. It is the whole application's intent in one place so the AI always diffs against full context and you always see the whole system at once. Content type application/yaml+intent (the intent extension) routes a double-click to the Intent Editor.
YAML, not JSON: comments, multi-line strings, no quote noise, friendlier diffs. It is loaded with SnakeYAML's SafeConstructor, so !!type tags are blocked - the intent often arrives from LLM output or paste and must never be a code-execution surface.
A complete example
This is the canonical, integration-test-verified showcase - one Orders intent exercising every block:
name: orders
description: Order management with approval workflow
version: 1
entities:
- name: Country
kind: setting
description: ISO 3166-1 country reference data
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true, length: 100 }
- { name: code2, type: string, length: 2 }
- name: Customer
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true, length: 200 }
- { name: active, type: boolean, defaultValue: "true" }
- { name: creditLimit, type: decimal }
- { name: orderCount, type: integer }
relations:
- { name: country, kind: manyToOne, to: Country }
- { name: orders, kind: oneToMany, to: Order }
- name: Order
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: orderDate, type: date, required: true }
- { name: total, type: decimal }
relations:
- { name: customer, kind: manyToOne, to: Customer }
- { name: items, kind: oneToMany, to: OrderItem }
- name: OrderItem
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: quantity, type: integer, required: true }
relations:
- { name: order, kind: manyToOne, to: Order, composition: true }
processes:
- name: OrderApproval
trigger: { onCreate: Order }
steps:
- { name: managerReview, kind: userTask, args: { assignee: manager, form: ApproveOrder } }
- { name: bigOrder, kind: decision, args: { if: "customer.creditLimit > 10000", then: cfoReview, else: notifyCustomer } }
- { name: cfoReview, kind: userTask, args: { assignee: cfo, form: ApproveOrder } }
- { name: notifyCustomer, kind: serviceTask }
- { name: done, kind: end }
forms:
- name: ApproveOrder
forEntity: Order
fields: [orderDate, total]
actions: [approve, reject]
reports:
- name: OrdersByCustomer
source: Order
dimensions: [customer]
measures: ["count(*)", "sum(total)"]
permissions:
- { role: Sales, can: [Customer:read, Order:create] }
- { role: Manager, can: [Order:approve] }
seeds:
- name: countries
entity: Country
rows:
- { id: 1, name: Afghanistan, code2: AF }
- { id: 2, name: Albania, code2: AL }Plus the declarative-glue blocks (notifications, schedules, integrations, inbound, rollups).
Every collection defaults to empty, so a partial intent (entities only) parses cleanly. Field names are camelCase.
entities
entities:
- name: Customer # PascalCase entity name
description: Buyer account
kind: setting # optional - see "Setting entities"
fields: [ ... ]
relations: [ ... ]fields
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true, length: 200 }
- { name: total, type: decimal }
- { name: active, type: boolean, defaultValue: "true" }| Key | Meaning |
|---|---|
name | field name, camelCase (PascalCased in the generated model) |
type | logical type (see below) |
primaryKey | marks the PK; must be an integer type |
generated | auto-increment (integer PKs only) |
required | NOT NULL; the generated REST controller's required-value validation keys on this |
length | column length for string types |
defaultValue | column default |
unique | a UNIQUE constraint (e.g. a code or a business key) |
precision / scale | override the DECIMAL default (16, 2): { name: rate, type: decimal, precision: 18, scale: 6 } |
calculatedOnCreate / calculatedOnUpdate | an expression the generated repository assigns to the property on insert / update |
calculatedActionOnCreate / calculatedActionOnUpdate | a server-side action call-out - see "Calculated fields" |
Logical types: string, text, integer, int, long, decimal, double, boolean, date, timestamp, uuid. Generators map them to JDBC + EDM types. text becomes a CLOB; uuid becomes VARCHAR(36).
Primary keys must be an integer type (integer / int / long). The Dirigible convention is an integer auto-increment id, and a non-integer auto-increment column is invalid SQL - the parser rejects a uuid or string PK. uuid is fine for non-PK fields.
audit: true on an entity adds the four standard audit columns (CreatedAt, CreatedBy, UpdatedAt, UpdatedBy), populated by the platform's audit annotations.
multilingual: true on an entity makes its string-typed properties translatable - see multilingual.
Control order
By default the generated UI controls (form inputs, list columns, detail rows) follow the declaration order - all fields first, then the to-one relations, so relations land last. Give an entity an order: list of property names to sequence them explicitly, interleaving fields and relations for a better form layout:
- name: SalesInvoiceItem
order: [Id, SalesInvoice, Product, Name, Quantity, UoM, Price, Total]
fields: [ ... ]
relations: [ ... ]Names match the field / relation names (case-insensitive). A partial order is fine - any property not listed keeps its default position and is appended after the listed ones.
Calculated fields
A field value can be derived on insert / update instead of being entered:
calculatedOnCreate/calculatedOnUpdate- an expression the generated repository assigns to the property. Prefer a neutral arithmetic expression for numeric totals ("Quantity * Price","round(Net * 0.2, 2)"): the SDKCalcevaluator runs it on the server and the UI previews it live with the same evaluator. A non-numeric field's expression is emitted verbatim, so it must be valid Java for the Java DAO (e.g."java.util.UUID.randomUUID().toString()").calculatedActionOnCreate/calculatedActionOnUpdate- a server-side call-out for logic too custom to model (conditional / sequential number generation, lookups). The value names a Java class - a@Componentimplementingorg.eclipse.dirigible.sdk.db.CalculatedField<E, T>(T calculate(E entity)) - that the repository invokes viaBeans.get(<class>.class).calculate(entity). It runs server-side only (no live preview) and takes precedence over the expression on the same slot. The implementation is hand-written by you undercustom/(nevergen/); the intent emits no Java.
To reference an action class by simple name, the entity declares imports: - a multi-line string of Java import ...; lines injected verbatim into the generated repository:
entities:
- name: SalesInvoice
imports: |
import custom.sales_invoices.SalesInvoiceNumberAction;
fields:
- { name: number, type: string, length: 100, calculatedActionOnCreate: SalesInvoiceNumberAction }You then add custom/sales_invoices/SalesInvoiceNumberAction.java. Alternatively give the fully-qualified class name and omit the import.
The action class must declare a named package (e.g. package custom.sales_invoices;). A class in the default package cannot be imported or referenced from the generated repository, so the call-out will not resolve. See Java compilation gotchas.
relations
relations:
- { name: customer, kind: manyToOne, to: Customer }
- { name: orders, kind: oneToMany, to: Order }
- { name: order, kind: manyToOne, to: Order, composition: true }Relation kinds: oneToMany, manyToOne, oneToOne, manyToMany. The FK lives on the to-one side; the EDM generator ignores oneToMany / manyToMany (navigation-only) since the column is on the child.
required: trueon a to-one makes the FK NOT NULL but keeps the entity a top-level entity with its own perspective (a plain dropdown).composition: trueon a to-one makes it a master-detail composition: the owning entity becomes DEPENDENT (managed as details under its parent's perspective), and the FK is NOT NULL (sorequiredneed not also be set). Only amanyToOne/oneToOnecan be a composition; an entity's firstcompositionto-one is its composition parent. Declare the inverseoneToManyon the master so the child is managed as a detail of it.
Composition is opt-in - this matches the Dirigible convention where most required FKs are plain associations and composition is explicit.
Setting entities
- name: Country
kind: settingkind: setting marks an entity as nomenclature / configuration. It is generated with type="SETTING", which the template engine routes under the dashboard's global Settings perspective instead of giving it its own perspective. Any relation targeting a setting entity resolves its dropdown to the Settings perspective. Settings are still real entities (own table, seeds, FK columns) - only their UI placement differs. Default kind (omitted) is a regular managed entity.
Cross-model references (uses)
A relation can target an entity owned by a different project's intent model - master / reference data (Customer, Country, Currency, UoM) you do not want to redefine. The owner model owns the single table; this model stores an integer FK and renders a dropdown sourced from the owner's REST service. It does not generate the owner's table or API.
Declare the dependencies in a top-level uses: block, then point a manyToOne / oneToOne relation at the alias with model::
name: customers
uses:
- { model: countries } # project defaults to the model alias
- { model: currencies, project: currencies } # set project when it differs from the alias
entities:
- name: Customer
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true }
relations:
- { name: Country, kind: manyToOne, to: Country, model: countries }
- { name: Currency, kind: manyToOne, to: Currency, model: currencies }A cross-model relation must be manyToOne / oneToOne, its model: must be listed in uses:, and it cannot be composition: true (a detail cannot be owned across models). Generate the owner (leaf) models before their consumers so the dropdown resolves; each project is its own .intent and all must be published to the same runtime. Under the hood this reuses the platform's PROJECTION mechanism.
Many-to-many
There is no manyToMany materialisation. Model n:m as an explicit intermediate entity holding a composition to one side, a manyToOne to the other (which may be cross-model via model:), plus any bridge fields:
- name: SalesInvoiceCustomerPayment
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: amount, type: decimal, precision: 18, scale: 2, required: true } # partial allocation
relations:
- { name: SalesInvoice, kind: manyToOne, to: SalesInvoice, composition: true, required: true }
- { name: CustomerPayment, kind: manyToOne, to: CustomerPayment, model: customer-payments, required: true }processes
processes:
- name: OrderApproval
trigger: { onCreate: Order, when: "total > 0" }
steps:
- { name: managerReview, kind: userTask, args: { assignee: manager, form: ApproveOrder } }
- { name: bigOrder, kind: decision, args: { if: "customer.creditLimit > 10000", then: cfoReview, else: notifyCustomer } }
- { name: cfoReview, kind: userTask, args: { assignee: cfo, form: ApproveOrder } }
- { name: notifyCustomer, kind: serviceTask }
- { name: done, kind: end }Generates one <process>.bpmn (Flowable-flavoured BPMN 2.0) plus the diagram interchange so the BPMN modeler renders it.
Step kinds: userTask, serviceTask, decision, script, end.
Decision steps: if + then are mandatory, else optional. then / else must name a declared step or the literal end; the parser validates this, so a typo fails at parse time rather than producing a Flowable reject. Without else, the gateway default falls through to the next step.
A decision condition can walk one hop off the trigger entity (customer.creditLimit > 10000): the generator inserts a resolver service task before the gateway that loads the related entity and rewrites the condition to the resolved variable.
trigger
trigger: { onCreate | onUpdate | onDelete: <Entity>, when: "<expr>" } starts the process on that entity's lifecycle event. Fully wired:
- the parser validates at most one event kind, and that the target is a declared entity;
- the EDM generator adds a
ProcessIdback-reference column to the entity (so the process starts at most once); template-application-events-javaemits agen/events/<Process>Trigger.java@Listenerthat loads the entity, applies thewhenguard, callsProcess.start(...), and writes the instance id back.
when supports a single field ==|!= literal guard. The business key defaults to the entity PK but is configurable:
trigger: { onCreate: Order, businessKey: orderNo, businessKeyStrategy: timestamp }businessKey names which field becomes the started instance's business key; businessKeyStrategy: timestamp mints a yyyyMMddHHmmss value into that field when it is blank (the field must be string / text). The strategy is the extension point for richer pluggable number generators later.
forms
forms:
- name: ApproveOrder
forEntity: Order
fields: [orderDate, total]
actions: [approve, reject]Generates one <form>.form per form. Controls are typed by looking each field up against the bound entity (string/uuid to text input, text to textarea, integer/decimal to number, boolean to checkbox, date to date picker, timestamp to datetime). Actions become buttons, coloured by name (approve to positive; reject/decline/delete/cancel to negative; save/submit to emphasized). A stub controller declares on<Action>Clicked handlers as TODOs - real wiring is a downstream template or a custom/ override.
reports
reports:
- name: OrdersByCustomer
source: Order
dimensions: [customer] # bare to-one shows the target's label, not the FK id
measures: ["count(*)", "sum(total)"]
- name: BigOrderItems
source: OrderItem
dimensions: [order.orderDate, quantity] # relation.field path -> INNER JOIN
filter: "quantity > 1" # -> WHEREGenerates one <report>.report per report, in the Dirigible .report shape with a fully materialised SQL query. Rooted at source:
- a plain field resolves to a source column;
- a
relation.fieldpath (order.orderDate) adds anINNER JOINto the related entity plus a column on it; - a bare to-one relation (
customer) joins and shows the target's label (name-like) field, not the raw FK id - usecustomer.idfor the id. A cross-model relation joins the owning model's table, so a report can group by an entity another module owns; - a time bucket
month(field)(a sortableYYYYMMinteger, e.g.202607) oryear(field)- standard-SQLEXTRACT, so H2 / PostgreSQL (not SQL Server); - a measure
count(*)/sum(...)/avg/min/maxbecomes an aggregate, and the dimensions become theGROUP BY.
filter becomes the WHERE with field names rewritten to qualified physical columns. All physical identifiers in the query are double-quoted for Postgres compatibility. Keep entity names non-reserved (avoid Order as a bare alias source on reserved-word databases).
The report name, description and column labels are emitted into the generated translation catalog, so they localize per language alongside the rest of the UI (see multilingual).
Chart
chart: renders the report page as a chart instead of a table (the page keeps a Table/Chart toggle, so filters, CSV export and print still work): one of bar, line, pie, doughnut, polarArea, radar. The grouping dimension labels the axis and each measure becomes a series, so a chart report wants exactly one dimension and one or more measures.
reports:
- name: MonthlyRevenue
source: Order
dimensions: ["month(orderDate)"]
measures: ["sum(net)", "sum(vat)", "sum(total)"]
chart: bar # bar | line | pie | doughnut | polarArea | radarA report may declare both a chart and a widget - they are independent surfaces.
Dashboard KPI widgets (widget)
A report may declare a widget block that turns it into a KPI tile on the generated Harmonia home dashboard - a meaningful business number ("Overdue Invoices", "Revenue this month") instead of the default per-entity record-count tiles:
reports:
- name: OverdueInvoices
source: Invoice
dimensions: [number, customer.name, due, total]
filter: "due <= CURRENT_DATE AND balance > 0"
widget: { kind: count, label: Overdue Invoices, icon: alert-triangle }
- name: RevenueByMonth
source: Invoice
dimensions: ["month(date)"]
measures: ["sum(total)"]
widget:
value: "sum(total)" # names a declared measure => kind: value
at: { "month(date)": now } # pin dimensions: the `now` token or a literal
label: Revenue (this month)
icon: banknote
- name: SalesByProduct
source: SalesInvoiceItem
dimensions: [Product]
measures: ["sum(quantity)", "sum(total)"]
widget: { kind: list, limit: 5, label: Sales by Product }kind: count(default) - the number of records the report yields.kind: value- one aggregate cell:valuenames a declared measure;atpins dimension columns with equals conditions. Thenowtoken resolves at view time, type-aware: currentYYYYMMon amonth(x)dimension, current year onyear(x), today on a date column. Anything else is a literal.kind: list- the report's firstlimitrows (default 5) as a compact table tile.
A widget-bearing report shows the KPI tile instead of its dashboard preview tile (clicking still opens the full report), and declaring any widget replaces the auto per-entity count tiles. dashboard: false hides both tiles. label/icon (Lucide name) are optional. The same widget block can also be authored by hand for any standalone .report file via the Web IDE Report Editor's Dashboard Widget panel.
widgets
Custom dashboard widgets - the dashboard's escape hatch when the report machinery cannot express the content:
widgets:
- name: SystemHealth
kind: kpi # default: a number tile fed by a REST endpoint
url: /services/js/sales/custom/health.js # GET returns { value, description? }
label: System Health
icon: activity
- name: SalesFunnel
kind: page # a large tile embedding an HTML page
url: /services/web/sales/custom/funnel/index.htmlkind: kpi (default) renders a number tile whose value (a number or a display string like "99.9%") comes from the developer's REST endpoint - typically hand-written code under the project's custom/ folder. kind: page embeds the page in an iframe tile, like a report preview. The kind implies how the url is consumed, so there is no separate source-type field; the url must be a same-origin path (no scheme/host).
permissions
permissions:
- { role: Sales, can: [Customer:read, Order:create] }
- { role: Manager, can: [Order:approve] }Generates <intent>.roles (deduped by role name). It deliberately does not emit .access URL constraints - URL-shaped rules belong to whichever downstream template materialises the UI, because only that template knows the paths it publishes. The can: [Resource:action] tokens are an authoring hint to those downstream generators about which actions each role may invoke.
seeds
seeds:
- name: order-statuses
entity: OrderStatus
rows: # inline rows: small nomenclatures
- { id: 1, name: DRAFT }
- { id: 2, name: ISSUED }
- name: countries
entity: Country
file: data/countries.csv # or point at an authored CSV: bulk data
- name: uoms-bg
entity: UoM
language: bg # a translation seed for a multilingual entity
rows:
- { id: 8, name: "Килограм" }Generates <seed>.csvim + <seed>.csv per seed. The CSV header carries <ENTITY>_<FIELD> upper-snake column names; row order matches the entity's declared field order. The target table only exists after the downstream "Generate from EDM" output is published, so the CSVIM import retries via its own synchronizer until then.
Two seed shapes:
rows:- inline seed data, right for small nomenclatures whose values are part of the flow (statuses, methods).file: data/<name>.csv- an authored CSV under the project (adata/subfolder is required), right for bulk nomenclatures and prepopulated demo data. The header uses the entity-derived physical column names (<ENTITY>_<FIELD>); a foreign key can be set by the relation name (Country: 34). The intent stays lean and the CSV is versioned by the developer.
A seed with language: <code> is a translation seed: it fills the per-language values of a multilingual: true entity (see multilingual) and lands in the entity's <TABLE>_LANG table; rows carry the base row's id plus the translatable (string/text) fields only.
Multilingual data and UI
Two independent things get translated: the data in multilingual entities, and the generated UI labels.
Data. Mark an entity multilingual: true and its translatable (string-typed) properties gain per-language values in a sibling <TABLE>_LANG table (generated by the schema layer). Every read of the generated Java repository overlays the translated values for the caller's Accept-Language - the shell's Region & Language setting sends the user's choice on each call, and untranslated content falls back to the default language. Author the translations as seeds with a language: code.
languages: [en, bg] # top level: the languages THIS module provides translations for
entities:
- name: UoM
kind: setting
multilingual: true
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true, length: 100 }The languages the whole stack supports are a platform concern (DIRIGIBLE_APPLICATION_LANGUAGES, default en,bg) - never defined per module. The top-level languages: only declares which languages this module provides; the shell warns about a module missing a platform language.
UI labels. Generation also emits a per-project i18n catalog (translations/<locale>/*.json) for every generated label: entity names (a humanized singular plus a _plural form), field labels, form and report names, report descriptions and column headers. The default locale (en-US) is generated for you; a translator adds a sibling locale folder (e.g. bg-BG) with the same keys. The Harmonia UI - sidebar, dashboard tiles, list headers, forms and report pages - renders through these keys, falling back to the baked English label for any key a locale has not translated.
Naming and tables
- The top-level
name:is the intent's identity. Single-file outputs are<name>.edm/.model/.roles/.glue; the table prefix is its upper-snake. - Physical table names are intent-prefixed:
<INTENT>_<ENTITY>upper-snake (ORDERS_ORDER), applied consistently across.edm,.reportand.csvim. This dodges SQL reserved words and cross-project collisions in a shared schema. When the downstream "Generate from EDM" wizard asks for a table prefix, leave it empty - the prefix is already in the model. - Property names are PascalCase in the generated model (
loanedOntoLoanedOn); physical columns stay UPPER_SNAKE. You author in lower camelCase.
Authoring rules
- Comments are encouraged. No tool ever rewrites the intent, so developer comments stay put; the AI patch path is prompted to preserve them.
- No anchors / aliases (
&foo/*foo) at v1 - they make diffs harder for the AI. Prefer adefaults:block if duplication hurts. - No multi-document YAML (
---). One file, one document. - No tags (already enforced by
SafeConstructor). - Quote unquoted braces in scalars.
to: {member.email}is parsed by YAML as an object, not a string - writeto: member.email(braces are only for{...}interpolation insidesubject/body). - An event-binding key is
event:, neveron:- YAML 1.1 resolves a bareonto booleantrue. An action key isdo:.
See also
- The Intent Editor
- Generators and generation
- Declarative glue
- Entity Data modeler - the model the entities generate into