#Apps (Bridge)
A bridge app is a config-driven UI — dashboards, pages, and widgets — that renders inside a host provider (e.g. a shipping platform's canvas) and reads/writes your Helm entities on behalf of that provider's users. You author an app spec once; each tenant activates it, wires an integration to a provider organisation, and the provider's users get a rendered, permission-scoped app.
An app lives in two layers:
- Definition — the design-time app spec (
spec_document): dashboards, pages, widgets, providers, config values and their schemas, and policies. Authored under/definition/specs; another tenant reaches it through a manifest it accepts (see Share with Another Tenant). - Activation — a tenant's use of a spec: turning it on, registering
provider integrations, minting app tokens, and per-principal config.
Driven under
/apps/*. Each activation is independent, so the same spec serves many tenants.
Authenticate with a Personal Access Token (HTTP Basic auth) — enter it once in the Authorize bar at the top of this page and every Try it below uses it. Each example body is pre-filled; the console targets staging by default. Full request/response schemas are in the linked API reference.
This guide is for an implementer who authors an app once and activates it for
other tenants. It walks the activation happy path; the
spec_document shape is detailed below. For the widgets
see the Widget Catalogue; for the expression model
see Smart Values; for grids see
Data Grids. How a spec and what it depends on reach a
consumer tenant is covered in
Share with Another Tenant.
#Build an app
The flow is: (optional) register config schemas → create the spec → activate → register the provider organisation → wire an integration → mint a token → render.
#1. (Optional) Register a config schema
If your spec validates any config scope (configSchemas), register the JSON
schema in the tenant that owns the spec. Skip this when no scope names a schema.
Publishing does not look the name up; the first config write for that scope
fails with 422 and details.code UNKNOWN_CONFIG_SCHEMA if no visible schema
has it. Tenants you share the spec with need the schema as a json_schema
member of the manifest — see
Configuration & Config Schemas.
POST/schema/schemasRegister a config schemaAPI docs ↗Try it
#2. Create the app spec
App specs are part of the definition layer (POST /definition/specs). Inside
spec_document, every key is camelCase (defaultDashboard,
widgetComponent, autoCreateUsers, …); the one exception is the identity
keys (user_external_id, …), which are well-known provider-identity field
names. The dashboard declares the placement it renders into (surfaces) and the
provider block says how the caller's identity is read — both are detailed below.
POST/definition/specsCreate an app specAPI docs ↗Try it
The response is 201 with the spec under data. The policies block is
optional; the policy story is in Shares & Grants.
A spec that does not publish answers 422. A body that does not match the spec
shape comes back as VALIDATION_FAILED, one line per problem in
details.validation_errors. A spec that is well-formed but inconsistent (a
layout naming a missing widget, a remembered param the page does not take, …)
comes back as UNPROCESSABLE_ENTITY with details.code
SPEC_STRUCTURE_INVALID and one entry per problem in details.issues (path,
code, message). The codes this guide names, such as remember_on_modal or
pipe_server_widget_feed, are those issues[].code values.
The app renders as the provider's user, so anything its pages read needs a grant
for that user. This spec only greets, so it needs none. Once a page reads
entities, add a policy and make it the default. Here a $query over a Task
type needs read access to the type, its rows and its schema:
"policies": {
"viewer": {
"label": "Viewer",
"grants": [
{ "resourceType": "entity_type", "resourceName": "Task", "actions": ["read"] },
{ "resourceType": "entity", "resourceName": "Task", "actions": ["read"] },
{ "resourceType": "json_schema", "resourceName": "task_attributes", "actions": ["read"] }
]
}
},
"defaultPolicy": "viewer"
Without these grants, a $query source fails with
Entity type "Task" not found. A setPrincipalConfig step likewise needs a
grant on the per-principal config of the activation; without it the write is
refused:
{
"resourceType": "principal_config",
"resourceId": "$app_config",
"actions": ["read", "create", "update"]
}
Sharing the spec with another tenant is done with a manifest, not inside the
spec — see Share with Another Tenant. Every
spec_document part is detailed in App spec structure.
#3. Activate the spec in a tenant
Activation is per tenant: there is exactly one activation per (spec, tenant),
so the call is an upsert — first call creates, later calls update. On creation,
the spec's policy block is provisioned into the activating tenant so its users
get the access the spec intends (see Shares & Grants).
PUT/apps/{specId}/configActivate the spec in your tenantAPI docs ↗Try it
To override the spec-defined policy, pass policy_id (an existing policy) or
policy (an inline definition with its own name, label and grants); the
two are mutually exclusive (422). The override takes precedence, and later
edits to the spec's policies leave this activation's policy alone.
"policy_id": null clears the binding.
#Bind a job slot
A job step whose key has no body in the spec's jobs is a slot, and the
activation fills it. Change single keys with
PATCH /apps/{specId}/config/job-bindings: the body is
{ "job_bindings": { "<key>": <value> } }, where a value is a job id or
name@version (stored as the id), null unbinds the key, and keys you leave
out keep their binding. A reference that names no job your tenant can read is
refused with 422 and details.code BINDING_REF_UNRESOLVED. The response is
the activation, with the stored map in job_bindings and each bound job's
name, version and level in job_bindings_detail. A step whose slot is
bound nowhere fails when it runs, with an ERROR notification whose
details.error_code is JOB_BINDING_UNRESOLVED. Integration-level overrides
and the spec's own defaults are in Job Bindings.
PATCH/apps/{specId}/config/job-bindingsBind a job slot for this activationAPI docs ↗Try it
#4. Register the provider organisation
An app renders for a specific provider organisation — the host platform's
tenant, keyed by (provider, external_id), where provider matches a key in
spec_document.providers. The pair is unique per tenant, so different Helm
tenants can each register the same provider organisation.
A tenant admin registers it directly on POST /access/external-companies
(provider + external_id, plus an optional name/metadata); the tenant is
taken from your token. Read the companies back with
GET /access/external-companies to get the id for the next step. (Platform
operators can also provision one cross-tenant via the operator surface.) See
Provisioning.
POST/access/external-companiesRegister a provider organisationAPI docs ↗Try it
#5. Wire an integration
An integration ties a registered provider organisation to your activation. Its
allowed_origins is both the render-time Origin check and the browser
CORS source for /apps/run — a browser host must list its exact origin here
(https://*.sedna.com admits any subdomain). A render whose Origin is not
listed is refused with 403; a server-to-server render that sends no Origin
is not checked.
POST/apps/{specId}/integrationsCreate a provider integrationAPI docs ↗Try it
#Record the provider's application
On the provider side, an integration is an application whose endpoint is
<gateway>/apps/run/<integrationId> (for Sedna, a Sedna application).
Record which one on the integration, so an installer run from another machine or
from CI finds the existing application instead of creating a second one, and so
anyone can see the registration without local state:
PUT/apps/{specId}/integrations/{integrationId}/registrationRecord the provider's applicationAPI docs ↗Try it
- Setting the id stamps
registered_at,registered_by(the calling user) andregistered_by_api_token_id(the API token the call was made with, if any — so an installer run from CI is attributed to its token, not only to the user the token is bound to). The platform derives all three from the id on every write; a body carrying any of them is refused (422). They are display attribution, not references, so a registration outlives its actor. metadatais any object you want to keep about the registration (no schema). The body is the whole registration: what you send replaces the stored object, and omitting it stores{}.{ "external_app_id": null }clears the id, every stamp andmetadata. Registering a different id overwrites and re-stamps, which is what you want when the provider application was deleted and recreated; sending the same id again keeps the original stamps (and still replacesmetadata).- All five fields come back on every read of the integration (single and list).
They are not in the render-time
config.integration, and the generalPUT /apps/{specId}/integrations/{integrationId}refuses them (422), so this route is the only way to change a registration. - It takes the same permission as any other write to the integration.
- Helm stores the id; it does not check it against the provider (it holds no
provider credentials). Before writing, the installer should confirm that the
provider application's endpoint path ends in
/apps/run/<integrationId>.
#6. Mint an app token
App tokens are scoped to a single integration and authenticate the render call.
The first issue needs no flag; replacing a live token is explicit
(?rotate=true — a plain PUT over an active token returns 409).
PUT/apps/{specId}/integrations/{integrationId}/tokenMint an app tokenAPI docs ↗Try it
The response carries raw_token (helm_at_<64-hex>) next to data. You get it
this one time and cannot retrieve it later, so capture it from this response.
#7. (Optional) Pre-author principal config
Override the spec's own config.company / config.team / config.user values
before first use, addressed by natural key. Company config belongs to your
tenant, so for company the principalId is your own tenant id — write it as
self. For team it is the team id; for user it is the user's id (or self
for yourself). A write is checked against the scope's schema after it is merged
with what is stored, so the first company write must include the schema's
required api_endpoint. See
Configuration & Config Schemas.
PUT/apps/{specId}/principals/{type}/{principalId}Set per-principal configAPI docs ↗Try it
#8. Render the app
The render call uses the app token as a Bearer credential
(Authorization: Bearer helm_at_…), not your PAT, and includes the integration
id in the path — so it is not part of the Authorize bar above. Any other
credential, Basic auth included, is refused with 401. The provider posts the
payload your spec's identity block reads:
curl -X POST https://api.helm.bridge-labs.com/apps/run/<integration_id> \
-H "Authorization: Bearer helm_at_<your_token>" \
-H "Content-Type: application/json" \
-d '{
"user": { "id": "ext-user-1", "emailAddress": "user@example.com", "firstName": "Jane", "lastName": "Doe" },
"company": { "identifier": "acme", "name": "Acme" }
}'
The 200 carries surfaces (the rendered placements), state, and
referenceData, plus notifications when there is something to tell the user.
A data_grid's rows come back under referenceData keyed by the grid's element
id — not inline in the surface (see Data Grids) —
and a notice about those rows (the load-more cap, a process that is not
available) rides that entry's own notifications. A form submit that fails the
form's own checks (a blank required field) answers with validationErrors,
keyed by the field's element id, and state, and runs nothing.
Company must match. The
company_external_idthe request resolves to must equal the integration's registered organisation — otherwise the render is rejected with403. Make the extractor read whatever stable key you registered the organisation under (here,company.identifier→"acme"). Deactivating an organisation (is_active: falseviaPUT /access/external-companies/{id}) cuts off its app sessions shortly after: its renders answer401. See The company gate.
#Interactions
The host drives every interaction through the same endpoint, adding up to three keys to the provider payload:
| Key | Carries |
|---|---|
state |
The state object from the previous response, with the user's edits written into it (form values, chip picks, a grid's page). |
action |
The id of the element the user activated — a button, a row control, an action-surface item. |
referenceData |
For a data refresh with no action — a grid page change, sort or column filter: { "<gridElementId>": { "filter": { … } } }. |
Element ids are scoped: :p/<pageId>/<widgetId>/<elementId>: on a page (:m/…
in a modal) and :a/<itemId>: for an action-surface item. A row control appends
the clicked row's id: :p/root/fleet/actions/0:-<rowId>.
- An action runs the element's command and answers with a full re-render:
surfaces,state,referenceDataand anynotifications. - A data refresh answers with only the requested element's
referenceData— no surfaces and no state. - No
actionand noreferenceDatais a plain render.
// Page 2 of the "fleet" grid, 25 rows a page, sorted by DWT descending.
{
"user": { … }, "company": { … },
"state": { "__bridge__": "…", ":p/root/fleet/data_grid:": { "page": 2, "pageSize": 25, "sorting": [{ "id": "dwt", "desc": true }], "filters": {} } },
"referenceData": { ":p/root/fleet/data_grid:": { "filter": { "page": 2, "pageSize": 25, "sorting": [{ "id": "dwt", "desc": true }], "filters": {} } } }
}
#App spec structure
The spec_document is a declarative tree: dashboards hold surfaces, pages, and
modals; pages hold sources and widgets; the document also carries the providers,
policies, jobs and outbound auth profiles the app uses. Every key inside it is
camelCase, and the body is validated on every create and update.
spec_document
├── dashboards.<key> a dashboard — what a host placement opens
│ ├── surfaces the placements it renders into + their entry pages
│ ├── pages.<key> a page (placement-free — reached via a surface or navigate)
│ │ ├── layout rows of widget ids, $if gates, inline nodes, generated entries
│ │ ├── dataSources.<name> the page's named sources, resolved on demand
│ │ ├── params / remember the nav params it takes, and the ones it keeps
│ │ └── widgets.<id> the page's widgets
│ ├── modals.<key> modal pages, opened only via navigate { modal: true }
│ └── widgets.<id> dashboard-level widgets, merged into each of its pages
├── widgets / pages / modals SPEC-LEVEL shared entries, merged into every dashboard
├── defaultDashboard which dashboard answers a render (literal or smart value)
├── providers.<name> host providers — auth, identity, upload types, links
├── config.<scope> definition-level config values per scope
├── configSchemas.<scope> JSON-schema name each scope's config validates against
├── messages copy overrides
├── policies / defaultPolicy named access policies, provisioned per consumer tenant
├── auth.<name> outbound HTTP auth profiles for http actions and $http sources
├── strictHrefs opt in to refusing literal relative hrefs
└── jobs / functions inline job bodies by key; spec-defined JSONata functions
Merge semantics. Spec-level widgets, pages, and modals are inherited
by every dashboard; a dashboard-level entry with the same key overrides the
shared one. Navigation resolves against the merged set — a page name against the
merged pages, a modal name against the merged modals.
| Field | Req | Purpose |
|---|---|---|
dashboards |
✓ | The dashboards, keyed by name. The only required field. Each declares surfaces and at least one page. |
defaultDashboard |
The dashboard a render opens — a literal key, or a smart value that picks one from the host's request context (see Surfaces). Falls back to the first key. |
|
widgets / pages / modals |
Spec-level entries merged into every dashboard (see merge semantics). | |
providers |
The host providers the app serves — how each authenticates, how the caller's identity is read, which upload types it speaks, and the links it serves. See Providers & identity, File uploads and Links to provider pages. | |
config |
Definition-level config values, keyed by scope (app, integration, company, team, user). Merged under each activation's own config at read time — never copied into it. See Config and its scopes. |
|
configSchemas |
Maps a scope to the JSON-schema name that scope's config validates against (null = unvalidated). Same five scopes as config. |
|
policies |
Named access policies; each is provisioned into every consumer tenant on activation. A grant names its resource by resourceName (a catalogue name, portable across environments) or resourceId; "$app_config" stands for the activation itself. |
|
defaultPolicy |
Default policy key (must be in policies); applied to the activation after provisioning. |
|
auth |
Named outbound HTTP auth profiles, referenced by name from an http action or an $http source's auth. See HTTP Auth & External Data. (A provider's own auth is the inbound check on render requests — see Authentication.) |
|
jobs |
Inline job bodies, keyed by the name a job step calls. A key with no body here is a slot filled by a binding — see Job Bindings. |
|
functions |
Spec-defined JSONata functions available in every expression in this app. A name may not take a platform function's (link, a JSONata built-in, or a standard helper): publish refuses it (function_name_reserved), because it would replace that function in every expression. |
|
strictHrefs |
true makes publish refuse every literal relative href — see Links to provider pages. |
|
messages |
Error-copy overrides. |
Access is policy-based, not role-based — there is no top-level
rolesfield. App access ispolicies+defaultPolicy(provisioned per consumer tenant) plus per-user assignment:GET /apps/{specId}/policieslists the spec's policies in your tenant, andPUT /apps/{specId}/users/{authUserId}/policywith{ "policy": "<key>" }assigns one to a user. Apps that don't need policy-mode authorisation omitpoliciesand fall back to standard mode (the union of the user's role bindings);configSchemasgoverns config shape, not access.
#Surfaces
A host renders an app into one of four placements:
| Placement | Where the host shows it | Entry |
|---|---|---|
dashboard |
A standalone full page. | "<page>" or { "page": "<page>" } |
panel |
A side panel alongside the host's own content. | "<page>" or { "page": "<page>" } |
scanCard |
A compact card inside the host's content (a message, a record). | { "page": "<page>", "if"?: "<predicate>", "props": {…} } |
actionSurface |
Buttons in the host's own toolbar or menu. | { "items": [ … ], "actionHandlers"?: { … } } — no page |
Every dashboard declares the placements it renders into in its surfaces record
— at least one — and names the entry page for each (an actionSurface
carries its items instead; see Action surface):
"dashboards": {
"workspace": {
"name": "Workspace",
"surfaces": { "dashboard": "home" },
"pages": { "home": { … }, "detail": { … } }
},
"reader": {
"name": "Reader",
"surfaces": {
"panel": "root",
"scanCard": { "page": "card", "props": { "title": "Documents" } }
},
"pages": { "root": { … }, "card": { … } },
"modals": { "confirm": { … } }
}
}
Three rules govern how surfaces render:
Placements are peers over one current page. A render answers every placement the dashboard declares, each around the page that is current for that placement; the host shows the one that matches its own placement. On the first render each placement opens at its entry page; after that, navigation moves the current page and every placement follows it.
Pages are placement-free. A page never says where it renders — the dashboard's
surfacesrecord does. Any page is reachable from any placement throughnavigate.The dashboard is chosen by
defaultDashboard. With several dashboards, make it a smart value over the host's requestcontextso each host placement opens the dashboard built for it:"defaultDashboard": "{{ context.display = 'GLOBAL' ? 'workspace' : 'reader' }}"The keys under
contextare whatever the host posts with its render request — check your provider's request contract for the field that identifies the placement.
#Modals
Modals live in their own namespace — modals on a dashboard, or spec-level
modals shared by every dashboard — and open only through navigate with
modal: true:
{
"kind": "navigate",
"page": "confirm",
"modal": true,
"params": { "id": "{{ item.id }}" }
}
A modal is a page (layout, dataSources, widgets) plus wide: true for the
wide presentation. Modals stack: a navigate { modal: true } from inside a
modal opens a child; closeModal pops the top one and returns to its opener
with that opener's state restored. A modal is rendered as an overlay on
whichever placement asked for it: the response carries it as its own entry in
surfaces, { "type": "modal", "id": "<modalKey>", "blocks" }, beside the
placements. A header at the top of a modal also draws a Close button.
#Scan cards
A scan card is the one placement that is an optional adornment of the host's content, so its surface entry carries two extra fields the others never take:
if— a bare JSONata predicate (never{{ }}, which would stringify to the truthy string"false") evaluated against the card page's render context. When it is false the card is left out of the response; the sibling placements are unaffected. A predicate that throws also leaves the card out, with an error notification for the author — never a failed render.props— the card's chrome, authored as ordinary smart values and resolved against the card page's render context:Prop Req Value title✓ The card heading. link{ "title", "href" }— a link rendered on the card.app{ "name", "image"? }— the app's name and icon.maxHeightA CSS length ( "320px") capping the card's height.titleis required — a string-shorthand or props-lessscanCardentry is rejected at publish. Resolved props are validated strictly; a prop that fails (a numericmaxHeight, alinkwithouthref) skips the card with an error notification naming the field, and the panel still answers.
The card page is an ordinary page: it has its own dataSources and widgets,
reads the host's request context (typically the record the host is showing),
and its widgets run commands like any other. An action taken on the card
re-renders every placement around the current page.
"dashboards": {
"reader": {
"name": "Document reader",
"surfaces": {
"panel": "root",
"scanCard": {
"page": "card",
"if": "$count(page.data.matches) > 0",
"props": {
"title": "{{ $count(page.data.matches) }} matching documents",
"app": { "name": "Documents" },
"maxHeight": "320px"
}
}
},
"pages": {
"root": { "layout": [["heading"], ["docs"]], "dataSources": { … }, "widgets": { … } },
"card": {
"dataSources": {
"matches": {
"$query": {
"typeName": "Document",
"filter": { "operator": "eq", "path": ["attributes", "external_ref"], "value": "{{ context.entity.id }}" },
"limit": 5
}
}
},
"layout": [["summary"], ["open"]],
"widgets": {
"summary": {
"widgetComponent": "table",
"config": { "columns": [ { "field": "name", "header": "Document" } ], "rows": { "$jsonata": "page.data.matches.{ 'id': id, 'name': attributes.name }" } }
},
"open": {
"widgetComponent": "actions",
"config": { "actions": [ { "label": "Open in panel", "command": [ { "kind": "navigate", "page": "root" } ] } ] }
}
}
}
}
}
}
The response to a render carries the answered placements side by side —
surfaces: [{ "type": "panel", "id", "blocks" }, { "type": "scanCard", "id", "props", "blocks" }]
— plus state and referenceData. When the if predicate is false, only the
panel is present.
#Action surface
An actionSurface puts buttons into the host's own chrome — a toolbar or menu —
rather than rendering a page. It has no entry page: it is a flat list of
items, and each custom item runs the command named after its id in
actionHandlers:
"dashboards": {
"reader": {
"name": "Document reader",
"surfaces": {
"panel": "root",
"actionSurface": {
"items": [
{ "type": "actionSurfaceItem", "label": "Open detail", "icon": "openLink", "action": "custom", "id": "open_detail" }
],
"actionHandlers": {
"open_detail": [ { "kind": "navigate", "page": "detail", "params": {}, "modal": true } ]
}
}
},
"pages": { "root": { … } },
"modals": { "detail": { … } }
}
}
- Items. Each item is
{ "type": "actionSurfaceItem", "label", "icon", "disabled"?, … }plus a button action —"action": "custom", "id": "<itemId>"to run a handler, or one of the host's other button actions (such as a link). Items are smart values, resolved against the dashboard's current page. - Publish.
itemsmust be a non-empty array, or publish fails withaction_surface_missing_items. A dashboard may declare anactionSurfaceas its only placement. - Render. The response carries
{ "type": "actionSurface", "id": "<dashboardKey>", "blocks": [ …items ] }, with eachcustomitem's id rewritten to:a/<itemId>:. If the items fail to resolve, that placement is left out with anERRORnotification; the other placements still render. - Click. The host sends
"action": ":a/<itemId>:". The handler's steps run against the dashboard's current page (there is nothisWidgetoritemId), then the view re-renders. An item with noactionHandlersentry answers with anERRORnotification (actionSurface item "<id>" has no actionHandlers entry.) and no surfaces. - No page mounted. A click works even when none of the dashboard's page
placements is open: with no
statein the request the engine starts from the dashboard's entry page, so a handler can open a modal on its own.
#Page layout
The full layout reference — sections, rows, tabs, headers and footers, generated
layouts, nav params and modals — is App Layouts. A page's
layout is a sequence of entries. The classic entry is a row — a list of
widget ids (columns), each optionally gated by $if:
"layout": [
["header"],
[{ "$if": "permissions.policy = 'admin'", "$then": "tools" }],
["tasks", "activity"]
]
An entry can instead be an inline node — it instantiates any registered
widget component anonymously at that position, with its own config and (for
container components) nested children, forming a layout tree:
"layout": [
["header"],
{
"component": "section",
"config": { "title": "Overview", "columns": 2, "collapsible": true },
"children": [ ["kpis"], ["chart"] ]
},
{ "component": "divider" },
{
"component": "tabs",
"id": "detail_tabs",
"config": { "items": [
{ "id": "tasks", "label": "Tasks", "children": [ ["tasks"] ] },
{ "id": "notes", "label": "Notes", "children": [ ["notes"] ] }
] }
}
]
Nest authored children in a section, a row, a tabs item or a footer; a
header is a title bar whose only children are filter controls. These and the
standalone layout leaves (divider, iframe, file_viewer) are catalogued
with their configs in
Widget Catalogue → Layout widgets. Rules
enforced at publish: every id anywhere in the tree — inside gates and containers
included — must name a widget; tabs requires an id (it is stateful); tabs,
header, and footer are top-level-only; iframe and file_viewer may sit at
the top level, in a tab or in a section only
("file_viewer" may live top-level, in tabs, or in a section only); an inline
node may not carry interactive config (actionHandlers / stateKey /
dataSource) — interactive widgets stay in the page's widgets record and are
referenced by id.
A row of several widget ids renders as a section with one column per id, so a
file_viewer widget beside a review form ("layout": [["viewer", "review"]])
shows the document and the form side by side.
#Generated layout entries
A layout may also hold { "$jsonata": "<expr>" }, as a top-level entry or as
the whole layout. The expression yields entries (rows of widget ids, or inline
nodes) from configuration, so a page can derive which panels it shows, and in
what order, from the app's config instead of enumerating every variant:
"layout": [
["bar"],
["checklist"],
{ "$jsonata": "$map($module().panels, function($p) { [$p] })" }
]
Here panels is a list of widget ids in the app's config, each a widget the
page defines over its own sources. Only widgets the page defines and registered
components may appear: a generated node cannot add a widget, take a widget's id,
or reuse another node's id. A row holds at most six cells, and a page's
generated entries place at most 200 entries in all. Output is final — to gate an
entry, gate inside the expression. An entry that fails is dropped on its own,
with one generated_layout_error notification naming it; the rest of the page
renders. A generated entry is not admitted inside a container's children.
#Page params
A page names the nav params it takes (page.params.<name>) in params. A
navigate passes exactly the params it states; nothing carries over from the
page it left. A page that should keep a choice across records — the tab a detail
page was showing when the viewer opens the next record — names the params it
keeps in remember:
"pages": {
"entity_detail": {
"params": ["type", "id", "section"],
"remember": { "params": ["section"] },
"layout": [["detail_switcher"], ["detail_section"]],
"widgets": { … }
}
}
- Written whenever the page renders as a base page with the param present (a
tab click, a
refresh, asetPageData, a navigate that states it), and kept in the round-tripped__bridge__state beside the page's widget state: the memory is the viewer's own, per page. - Read by a non-modal
navigateto the page whoseparamsdo not state the param: it renders with the value the page last rendered with, whatever record that was. - A stated param always wins, whatever it resolves to.
- Modals remember nothing. A modal
navigateneither reads nor writes the memory, andrememberon a modal is refused at publish (remember_on_modal). - Publish refuses a remembered param the page does not list in
params(remember_param_undeclared). Names are non-empty and listed once. A value the new record cannot show (a section it lacks) is the page's own fallback to handle, as with any unknown param.
#Widget behaviour & smart values
Widgets are config-driven and can carry interactive commands (entity ops,
HTTP, setPrincipalConfig, …), render-time $if visibility, and {{ }} smart
values. The full authoring model lives in Smart Values;
each widget's config is catalogued in the
Widget Catalogue, and grids have their own guide,
Data Grids. Two spec-wide authoring vocabularies — data
sources (how a widget gets its rows) and commands (what an action does) —
are documented next.
#Data sources
Every page declares ONE named-source map, dataSources, and a widget that
shows data binds an entry by name — a data_grid's rows via
"dataSource": "<name>", a select's options via { "dataSource": "<name>" }. A
table takes its rows as a value instead:
"rows": { "$jsonata": "page.data.<name>" } (a {{ }} template would turn the
array into text). A source resolves when something reads it (a widget binding, a
{{ page.data.<name> }} template, another source) — never just for existing.
Pick one of five producers:
| Producer | Use |
|---|---|
$query |
Read entities of a type. filter / sort mirror the entity query. |
$http |
Fetch rows from an external HTTP endpoint (SSRF-safe; JSON responses only). |
$job |
The output of one read-only action, run inline as the viewer (a process read, a permission check, …). |
$rows |
An inline or computed array (often { "$jsonata": "page.data.x" }). |
$typeSchema |
An option feed built from a schema's enum values: { "entityType", "attribute" } or { "schema", "path" }. |
$query keys:
| Key | Meaning |
|---|---|
typeName |
Required. The entity type, by name. |
filter |
A filter tree; particle values may be smart values (see below). |
sort |
[{ "path": ["attributes", "x"], "direction": "asc" | "desc" }]. |
include |
Related types to hydrate onto each row. |
limit |
Row cap for a value read (default 100); a read that matches more adds a WARNING notification. A grid pages with its own pageSize instead. |
page |
Page number for a value read. A grid sends its own page. |
single |
true returns the first row (or null) instead of an array. Cannot feed a grid or picker. |
distinctOn |
A dotted attribute path ("attributes.port"): one row per distinct value. Pages by page number only — a cursor beside it fails the widget at render. |
countStrategy |
How the row total a grid shows is counted. |
search |
How a picker's type-ahead term matches: { "mode": "contains" | "fuzzy", "threshold"?, "paths"? }. |
match |
How a grid's column filters match, per dotted path: { "<path>": { "mode": "contains" | "fuzzy", "threshold"? } }. Default: substring contains. |
process |
{ "processes", "presentation"?, "rows": "anchor" | "step" } — fold each row's process steps onto it. See Process pages. |
can |
[{ "action", "as"? }] — fold whether the viewer may write each row onto it, as can.<as or action>. See Process pages. |
$http keys:
| Key | Meaning |
|---|---|
url |
Required. The endpoint; may carry smart values. |
method |
GET (default) or POST. |
query |
Query-string parameters, { "<name>": "<string>" }. |
headers |
Request headers, { "<name>": "<string>" }. |
body |
The request body (with POST). |
auth |
The name of a root auth profile, or an inline auth block — see HTTP Auth & External Data. |
rowsPath |
Dotted path to the row array in the response ("data.items"). |
paged |
{ "totalPath"?, "hasNextPath"? } — the upstream API does the paging for a grid. See Data Grids → Server-paged $http. |
Without paged, an $http source is fetched whole on every read and capped at
5,000 rows; a longer response is cut, and a WARNING notification says so.
$job keys:
| Key | Meaning |
|---|---|
action |
Required. One action from the read-only set (describeProcessActions, checkPermissions, getEntityTimeline, …). |
config |
Required. The action's config, as a record; each value may be a smart value, resolved at render before the run. |
rowsPath |
Dotted path to the rows in the action's output ("anchors.0.steps"), like $http's rowsPath. |
cursor |
{ "in", "out" } — page by keyset: the request's cursor is written to config.<in>, and the output at <out> is the next one. |
Publish refuses a $job whose action is not read-only, whose config is
computed as a whole rather than key by key, or whose config names a key the
action does not take. Without cursor, the rows are the whole set and the grid
pages them in memory.
The full $job reference — how it runs, its limits, and the
timeline and
permission-check
actions — is in
Data Grids → Rows from an action. A
grid's $job source refuses set-level $pipe steps, as a paged source does.
An entry may also be a plain literal (a record, an array, a scalar — with nested
{{ }} values resolved at render) or a keyed {{ … }} template string. Combine
a producer with these node-level siblings:
$pipe— shape the resolved rows (map/filter/orderBy/orderByList/leftJoin/distinct/guardedProject). The full vocabulary is in Smart Values → Shaping a row set. A$queryor$httpsource that feeds a grid may carry only the row-local steps,mapandleftJoin, applied to the page's rows in the order you author them. Both keep one output row per input row, so the page stays the page. Every other step (filter,orderBy,orderByList,distinct,guardedProject) would shape one page rather than the result set, and is refused — at publish (pipe_server_widget_feed) and at read time. Push those upstream in the query, or pipe a source that is not paged. A$rowssource applies its whole pipe.
How each producer feeds a grid — paging, column filters, sorting — is in
Data Grids → Choosing a source. A grid pages
in one of three ways: a page-number pager (the default, page / pageSize plus
a total), paginate: false (every row, capped, with no pager — for small
bounded lists), or paginate: "more". A "more" grid shows rows only — no
total, no pager — with a Load more button below them and a Back to top
once more than one page is loaded. Each press adds pageSize rows, and every
render re-reads the loaded rows from the first one, up to 1,000, so a render
costs the whole loaded extent, not one page. At 1,000 rows no Load more is
drawn, and when more rows follow, the grid's referenceData entry carries a
WARNING notification saying so. It refuses set-level $pipe steps on its
source, as any paged read does. See
Data Grids → Paging.
A grid column's display is a whole JSONata expression, evaluated per cell with
value (the cell) and row (the source row) over the render context. A source
it reads — page.data.teams, or $$.page.data.teams from inside a nested path
or predicate, where a bare name would resolve against the current item — is
resolved like any other reference, and publish compiles the expression (syntax
and unknown $functions) at …columns[i].display:
"display": "($v := value; $$.page.data.teams[id = $v].name)"
#Joining names onto a paged source
A leftJoin lets a paged list show names from another source. Here each
quarantined message carries only the ids of its teams, and a teams source
fetches every team's name in one call:
"dataSources": {
"teams": {
"$http": { "url": "{{ config.app.sedna_base_url }}/platform/2019-01-01/team",
"auth": "sedna", "rowsPath": "data" },
"$pipe": [{ "map": { "id": { "$jsonata": "$string(id)" },
"name": { "$jsonata": "attributes.name" } } }]
},
"quarantined": {
"$http": { /* url, auth, query and rowsPath as above */ "paged": {} },
"$pipe": [
{ "map": { "id": "{{ id }}", "subject": "{{ attributes.subject }}",
"teamIds": { "$jsonata": "$append([], relationships.team.data).$string(id)" } } },
{ "leftJoin": { "rows": { "$jsonata": "page.data.teams" },
"left": "teamIds", "right": "id", "as": "teams", "many": true } },
{ "map": { "id": "{{ id }}", "subject": "{{ subject }}",
"mailboxes": { "$jsonata": "$join($append([], teams.name), ', ')" } } }
]
}
}
With no match, teams is [] and $join($append([], teams.name), ', ') yields
""; the bare $join(teams.name, …) would leave the field absent.
Reading page.data.teams in leftJoin.rows makes teams a dependency of
quarantined, so it resolves first. That happens on every request that reads
the paged source — each render, page change and row action — because $http
results are shared only within one request. Keep the joined source a single
cheap call.
#Reacting to another widget
Cross-widget reactivity is not declared — a source reads the other widget's
state (page.widgets.<id>.state.<field>) in its filter, and every interaction
re-renders in full, so the read is always current. Mark such filter particles
"optional": true so an empty input means "no filter" rather than match-none:
"dataSources": {
"openTasks": {
"$query": {
"typeName": "Task",
"filter": {
"operator": "and",
"filters": [
{ "operator": "eq", "path": ["attributes", "status"], "value": "open" },
{ "operator": "eq", "path": ["attributes", "assignee"],
"value": "{{ page.widgets.assignee_filter.state.value }}", "optional": true }
]
},
"sort": [ { "path": ["attributes", "due_date"], "direction": "asc" } ]
},
"$pipe": [ { "map": { "id": "{{ id }}", "attributes": { "title": "{{ attributes.title }}", "due": "{{ attributes.due_date }}" } } } ]
}
}
The widget then binds "dataSource": "openTasks". A grid over a $query source
reads each column's field from the row's attributes, so a map there keeps
id and writes its fields under attributes. A flat projection leaves the
grid's cells empty. An optional particle elides on undefined, on an empty
in list, and on the empty string — a cleared filter_bar input is "no
filter". A non-optional particle whose value does not resolve matches nothing —
never everything.
Dependent widgets reset first. Before an action's steps run, every other
widget whose data reads the acting widget's state — directly, or through a
source that reads another source — gets to reset its own state. A data_grid
returns to page 1 (a paginate: "more" grid back to one page of rows) and drops
any keyset cursor, keeping page size, sorting, column filters and selection, so
a chip click with the grid on page 3 does not ask for a page the new result
lacks. A widget never resets on its own actions, a setWidgetState step in the
same command runs after the reset and wins, and a read that names no single
widget (page.widgets[$k], page.widgets.*, $lookup(page.widgets, …)) counts
as reading every widget.
#Commands
An interactive widget (a button, a form submit, a row action) runs a command
— an ordered list of typed steps. Each step has a kind plus its own
fields, and may carry stepId (binds its result to outputs.<stepId>), if (a
bare JSONata predicate evaluated when the step is reached; a falsy result skips
the step), and onSuccess / onError recovery sub-sequences (the same model as
a workflow action). The vocabulary:
kind |
Fields | What it does |
|---|---|---|
navigate |
page, params?, modal? |
Go to a page; with modal: true, open the named modal on top of the current view. |
refresh |
includeModal? |
Re-render the current page (and the open modal when asked). |
setPageData |
data, inModal? |
Write keys into page data (read elsewhere as page.data.*). An override shadows a source of the same name. |
setWidgetState |
widgetId, state |
Set one widget's state (selection, paging, a filter value, …). |
notify |
level, message, button? |
Show a toast (INFO / WARN / ERROR; WARN arrives as WARNING); button: { label, href } adds a link call-to-action beside it. |
actions |
actions[], input?, output?, permissionSet?, additionalAllowed?, denied? |
Run orchestration actions inline — the inline-job path for apps. By default the step may run every synchronous action; permissionSet (readonly / dataMutation) narrows that, and additionalAllowed / denied adjust it. |
job |
job, input?, additionalAllowed?, denied? |
Run the job a key names — an inline body in the spec's jobs, or a bound job when the spec supplies none. |
setPrincipalConfig |
principalType, principalId?, config? / ops? |
Write per-principal config — a merge (config) or list operations on array keys (ops). |
setSecret |
target, value |
Store a scoped secret from inside the app (see Secrets). |
createTempFileUploadUrl |
slots, type?, ttl? |
Mint one signed upload capability per slot, for the provider's upload type. See File uploads. |
closeModal |
result? |
Close the current modal, optionally handing a value back to whoever opened it. |
debug |
value? |
Resolve a value and make it the step's output (outputs.<stepId>) — for checking an expression while authoring; it shows nothing to the user. |
closeModal pops the top modal and reveals its opener (a parent modal with its
state restored, or the base page). To open a specific named modal instead, use
navigate with modal: true.
With result, it also hands a value back. The resolved object must be an
object, and it is merged into the data (page.data) of whatever page becomes
current as it re-renders — a patch: nested plain objects merge key by key
all the way down, arrays and scalars replace what was there, and a key result
never mentions is left alone. That makes any picker or editor modal a reusable
component: it closes itself and returns its answer to its opener.
{
"kind": "closeModal",
"result": { "$jsonata": "{ 'vessel': outputs.pick.selected }" }
}
The actions step runs the same action catalog a workflow job does, inline
in the render request — prefer it over a named job step for app-local logic
(see Workflows → Jobs & actions). A
step's input is where render values enter an action: an action's config
resolves against { input, outputs } only, so thread a page value through
input and read {{ input.x }} in the config.
"command": [
{ "kind": "actions", "stepId": "save",
"input": { "title": "{{ page.widgets.new_task.state.title }}" },
"actions": [ { "stepId": "upsert", "name": "upsertEntity", "config": { "typeName": "Task", "payload": { "attributes": { "title": "{{ input.title }}" } } } } ] },
{ "kind": "notify", "level": "INFO", "message": "Saved" },
{ "kind": "refresh" }
]
A notify button.href is resolved when the step runs, so it can link to
something a previous step just made — for example a file an actions step
exported and stored (see
Data Grids → Exporting what a grid shows).
#When a step fails
A step that fails stops the command; the steps after it do not run. With
onError, those steps run in place of the default notification and read the
failure at outputs.__error__: message, name, status (the failure's HTTP
status) and code (its platform error code) — 0 and "" when the failure is
not a platform error — so a handler branches on the kind of failure
(outputs.__error__.status = 409) rather than on its wording. Without
onError, the response gets an ERROR notification. Its message is
user-facing copy (overridable through the spec's messages); its details are
for code:
"notifications": [
{
"id": "step-error-…",
"level": "ERROR",
"message": "Some of the information entered isn't valid. Please review your entries and try again.\n(root): Instance does not have required property \"name\".",
"details": {
"error_code": "VALIDATION_FAILED",
"validation_errors": ["(root): Instance does not have required property \"name\"."]
}
}
]
details.error_code— the platform error code (for exampleVALIDATION_FAILED). Branch on this, not on the message text. It is absent when the failure did not come from a platform error.details.validation_errors— one entry per violation, on validation failures.
#Process pages
A process page shows each item's steps (their status, and the controls a viewer
may press) and lets the viewer act. There is no process widget: the page reads
the steps with a $job or a $query process source and draws them with
table, actions and data_grid. The spec names nothing of your domain (no
entity type, process key, attribute or column); the consumer's configuration
does.
What a process is — its steps and transitions, its presentation, activating it in a tenant, and the refusals a move can answer — is in Process Models. This section is the page side.
Before you start
- The process is defined, and the tenant has activated it by name (see Process Models).
- The app's policy lets the viewer read the anchor and step types, and write the step type for the moves it may make (see step 2).
- A process a page names that the tenant has not activated shows no steps, and
the grid's
referenceDataentry carries aWARNINGnotification whoseidisprocess-unresolved:<names>.
#1. The module configuration
The names live in the consumer's activation config (config.app, written with
PUT /apps/{specId}/config as { "config": { "modules": [ … ] } }), validated
by the app's own config schema when configSchemas.app names one. The page
reads it as config.app.modules:
{
"modules": [
{
"key": "tickets",
"anchor": "Ticket",
"processes": ["ticket_flow"],
"presentation": {
"ticket_flow": {
"priority": 100,
"category": "Support",
"emits": {
"triage": { "transitions": { "initiate": { "form": "form" } } }
}
}
},
"columns": [
{ "type": "string", "field": "title", "header": "Title" },
{
"type": "status",
"field": "steps.ticket_flow.ticket.status",
"header": "Ticket"
}
],
"rowActionsPath": "steps.ticket_flow.ticket.actions"
}
]
}
presentation is the process presentation, keyed by process: its priority and
category, and per step and transition the words, order and form page the
controls use (see Process Models). Put words and order
there, never in a widget.
#2. The spec's functions entry
There is no built-in $module binding. Define the read once, in the spec's
functions block, and call it as $module():
{
"functions": {
"module": "function() { ($m := $$.config.app.modules; $p := $$.page.params.module; $p ? $m[key = $p] : $m[0]) }"
}
}
It answers the module a module page param names, and the first module when no
page states one — so the entry page, which a render opens with no params, still
reads a module. The $$ matters: inside the [ … ] predicate the context is
each module, so a bare page.params.module would read nothing.
#3. The pages
A list page (a grid of items, each row carrying its steps) and a detail page
(one item's checklist), plus the form page the presentation names. Widgets in
the widget map are keyed widgetComponent; the inline nodes of a layout are
keyed component. Add the policies from step 2 for
the anchor and step types.
{
"dashboards": {
"main": {
"name": "Items",
"surfaces": { "dashboard": "list" },
"pages": {
"list": {
"title": "Items",
"dataSources": {
"rows": {
"$query": {
"typeName": { "$jsonata": "$module().anchor" },
"process": {
"processes": { "$jsonata": "$module().processes" },
"presentation": { "$jsonata": "$module().presentation" },
"rows": "anchor"
},
"can": [
{ "action": "update" },
{ "action": "delete", "as": "remove" }
]
}
}
},
"widgets": {
"grid": {
"widgetComponent": "data_grid",
"config": {
"dataSource": "rows",
"paginate": "more",
"pageSize": 25,
"columns": { "$jsonata": "$module().columns" },
"fromRow": { "$jsonata": "$module().rowActionsPath" },
"actions": [{
"icon": "arrowRight",
"command": [
{
"kind": "navigate",
"page": "detail",
"params": { "id": "{{ itemId }}" }
}
]
}]
}
}
},
"layout": [["grid"]]
},
"detail": {
"title": "Item",
"dataSources": {
"steps": {
"$job": {
"action": "describeProcessActions",
"config": {
"processes": { "$jsonata": "$module().processes" },
"presentation": { "$jsonata": "$module().presentation" },
"anchorIds": ["{{ page.params.id }}"]
},
"rowsPath": "anchors.0.steps"
}
},
"access": {
"$job": {
"action": "checkPermissions",
"config": {
"checks": [{
"resource": "entity",
"resourceName": { "$jsonata": "$module().anchor" },
"action": "update"
}]
},
"rowsPath": "allowed"
}
}
},
"widgets": {
"checklist": {
"widgetComponent": "table",
"config": {
"rows": { "$jsonata": "page.data.steps" },
"groupBy": { "field": "category", "labelField": "category" },
"columns": [
{ "field": "label", "header": "Step" },
{ "field": "status", "header": "Status", "type": "status" }
],
"fromRow": "actions"
}
},
"bar": {
"widgetComponent": "actions",
"config": {
"actions": {
"$jsonata": "$append([], page.data.steps.actions[cta and (surfaces = null or 'action_bar' in surfaces)])"
}
}
},
"edit": {
"widgetComponent": "actions",
"config": {
"actions": [{
"label": "Edit",
"command": [{ "kind": "navigate", "page": "list" }],
"enabled": { "$jsonata": "page.data.access[0]" },
"reason": "You cannot edit this item"
}]
}
}
},
"layout": [["bar", "edit"], ["checklist"]]
},
"form": {
"title": "Move",
"widgets": {
"heading": {
"widgetComponent": "headline",
"config": { "text": "{{ page.params.transition }}" }
}
},
"layout": [["heading"]]
}
}
}
}
}
What each part does:
rowsreads a page of the module's anchors.processdescribes that page's ids (onedescribeProcessActionsper 200 rows) and folds the results onto the rows, so a row holdssteps.<process>.<emit>.{status, actions}("rows": "step"gives each step row its own{status, actions}; a step the viewer cannot read is left out).canfoldscan: { update: boolean, remove: boolean }onto every row, answered by the page read itself — display only; every write is judged again. Both are row data.gridis a "load more" grid: rows only, 25 at a time, up to 1,000. Itscolumnsare generated from configuration; a generated column list declares no needs, so include the relationships the columns read on the source. Thestatuscolumn shows the label and badge words at its path.fromRownames the path to the row's control descriptors, and the grid renders one row menu of them: one item per distinct transition key and label, each item enabled only on the rows whose descriptor of that key carries that label and is enabled. The authoredactionscontrol opens the detail page for its row.stepsis one anchor's steps as a list (rowsPathdrills into thedescribeProcessActionsresult).checklistdraws them: one heading percategory(agroupByis{ field, labelField }), astatuscolumn and a row menu of each step's controls. A press is answered from a fresh evaluation of the page and returns the clicked descriptor'sintent.baris the call-to-action controls that show on the bar. A descriptor'ssurfacesisnullfor "everywhere", so the filter testssurfaces = nullas well as membership (the surface words,action_barhere, are whatever the presentation'ssurfaceslists).$append([], …)keeps the result an array in every case: JSONata collapses a single match to the object, and a filter that matches nothing (for example once the step is done) givesundefined, not[]. A trailing[]fixes only the first case. Theactionsconfig refuses both an object andundefined.editis an authored control gated by a permission read that is not about a row.enabledis a smart value resolved before the widget runs, andreasonis the viewer's words. A disabled control renders as disabled text, and its command never runs.formis the page a transition that takes inputs opens. It receivespage.params{ process, emit, anchorId, transition }and is where the transition's form goes (see the next sections).
#What a press does
A descriptor's press returns an intent; you author no steps. The engine maps
it by kind to one step, every value taken literally: process.perform →
performTransition, process.undo → undoTransition, process.release →
releaseHold, navigate → a navigate step. The read picks the intent: a
transition that declares inputs opens the presentation's form page (so give it
one, or its press performs with no inputs); a held move is released; an undo
carries the step's expectedUpdatedAt. Every press is the caller's own and is
judged again when it runs; a control that was disabled when drawn cannot be
forced. A refusal shows the catalogue's word for its code; a refusal because the
row changed under the viewer shows a Reload toast. Each press answers with a
full re-render, so the row's status and controls are the new ones.
How a step's status reads — rung words, tones and badges, a failed move included — is set by the presentation; see Process Models.
#Related collections
A detail page can show the module's related collections as panels its configuration chooses, with a generated layout entry:
{
"layout": [
["bar"],
["checklist"],
{ "$jsonata": "$map($module().panels, function($p) { [$p] })" }
]
}
Each id in panels names a data_grid (or entity_info) the page defines over
its own sources.
#Forms whose chosen row supplies fields
A transition's input may be a select whose chosen row carries more fields of
its own (a template with its fields); the process definition declares it, and
Process Models has the field grammar. The page side:
- The read.
describeProcessFormtakesvalues(the form's current values by field); a$jobsource can pass the switcher's state there. With no value for the select, the choice is its default, and a required select that declares no default starts on its first option. The result is the combined list, each supplied field carryingsuppliedBy, the select carryingsuppliesFields: true, and, when the chosen row cannot be read or its fields break the rules,reason: { code: "FIELDS_UNAVAILABLE" }with the declared list unchanged. - The page. Put the switcher in a
filter_baroutside theform, feed its state into the source'svalues, map the returnedfieldsto the form's own field config, and set the form'sresetKeyto the selection so a new selection drops the previous one's typed values while defaults recompute. Before the viewer picks, the choice is the one the read started on (the first option, for a required select with no default): the switcher's value, theresetKeyand the submitted select must follow that value, not an empty one. Which options apply to which anchor stays in the spec, per option; the platform decides none. - Perform. The press re-reads the chosen row as the caller and validates the
submitted inputs against the combined list; a choice that is not an option, or
a row whose fields cannot be read, refuses the move with
INPUT_INVALID.
#Common mistakes
| Symptom | Cause |
|---|---|
$module is undefined, or reads nothing inside a predicate |
There is no built-in binding: define it in functions and read the page through $$. |
The entry page's grid fails its config parse (columns) |
$module() reads only a module page param, which the entry page does not have: fall back to a module ($m[0]). |
A grid of anchors shows Entity type "…" not found |
The app's policy grants the viewer nothing on the anchor type. |
| The bar renders nothing, or the widget fails its config parse | A filter that drops surfaces: null, or a result that is not an array: wrap the path in $append([], …). |
A table column or groupBy is refused |
A table column is { field, header?, type? } and groupBy is { field, labelField, command? }. |
| A grid column derives no include, filter or sort | Generated columns and status columns declare no needs: name the includes on the source. |
Publish refuses a $job |
Its action is not read-only, its config is computed as a whole, or a key is not one of the action's keys. |
Load more ends, or a set-level $pipe is refused |
paginate: "more" reads at most 1,000 rows and refuses set-level $pipe steps on a source other than $rows. |
| A press with inputs performs with none | The transition has no presentation form page. |
#File uploads
A visitor picks a file in the host's own upload widget; the bytes go straight to a signed URL the app minted, land as a temp file, and the field's value becomes that file's id. Nothing is stored until the browser actually POSTs, and nothing survives longer than a temp file's 24-hour life unless a job promotes it into a durable artifact.
End to end, one upload:
render → each file field's capability is minted (a signature only, no
write) and the host's upload element renders with it
upload element → POSTs the bytes directly to that URL
the platform → verifies the capability, stores the bytes, answers { uploadId }
the host → stores the id under the field id
"Preview" → mints a short-lived download link and opens previewPage
submit → outputs.form.<field> is the temp file id
#Declare the provider's upload type
A provider that accepts uploads names the type(s) it speaks under
providers.<key>.upload. The shorthand names one type and takes the default
response shaping:
"providers": {
"sedna": {
"auth": { "type": "bearer" },
"upload": "canvasFileUpload"
}
}
The record form exists to override the success and error bodies the host reads, as smart values resolved against the upload context:
"providers": {
"sedna": {
"upload": {
"canvasFileUpload": {
"errorProps": { "errorMessage": "Upload refused: {{ error.message }}" }
}
}
}
}
props resolves against
{ upload: { id, filename, contentType, size, expiresAt }, provider, config }
and defaults to { "uploadId": "{{ upload.id }}" }. errorProps resolves
against { error: { code, message, status }, provider, config } and defaults to
{ "errorMessage": "{{ error.message }}" }.
Both are parsed strictly against the host's body after resolution —
canvasFileUpload takes exactly { uploadId } on success and
{ errorMessage? } on error, and an extra key fails the parse. An override that
fails to parse is not a failed upload: the ingest falls back to the type's
default body, so a bad override can never break uploading for your users.
canvasFileUpload is the only declared upload type.
#The file field
In a form, a type: "file" field renders the whole upload and preview lifecycle
with no other plumbing:
{
"type": "file",
"id": "instructions_pdf",
"label": "Instructions (PDF)",
"previewPage": "preview_file",
"accept": [".pdf"],
"maxFileSize": 10485760,
"required": true
}
Every render mints the field's capability — signed from the render's own claims
and the field's accept / maxFileSize / uploadType, at the maximum lifetime
(7200 seconds), with no read and no write — and renders one row of two: the
host's upload element with that link under the field id (where the host
writes the response's uploadId, the temp file id, on success), labelled with
the field's label, at the start; and an X icon button (<fieldId>/replace, icon
closeLine) at the end. A Preview button (<fieldId>/preview) follows the row
only when previewPage names a modal. In an inline form, which is itself one
row, the two sit flat in it. There is no Upload press and nothing about the link
round-trips: the next render mints its own, and a newer link applies to the next
pick.
While any file is in flight, nothing in the form dispatches: every file element disables every button of the form that sends its state — each file field's X and Preview, the authored buttons and the submit — because a press answers with the values as they stood, and one landing after another field's upload finished would clear it. A render that cannot mint (no integration, or a provider with no upload type) shows the field as a warning alert instead and logs why; the alert names the reason only in development, and the rest of the form works.
The X (Replace) clears the field's value and nothing else: the response carries the form's other values as they stand and drops the field's, and the host, seeing the value go from set to empty, resets the element to its picker. It is how a visitor picks another file without the element's own Remove (which asks for a browser confirmation), and — because the response carries a fresh link — how they recover a link that expired with the form left open for over two hours (the element's own Retry re-sends to the expired link): press the X, remove the failed file, pick again. The X is always rendered: the host keys elements by position, so a button that came and went would remount every control below it and drop what they hold.
The uploaded id is ordinary form state:
- on submit it is
outputs.form.<fieldId>; - anywhere else on the same surface it is
page.widgets.<formWidget>.state.<fieldId>.
A file field is never bound — its value is a temp file id, which no
attribute schema derives — so in is refused where it is authored rather than
quietly ignored.
Picking another file with no round trip in between re-POSTs the same capability
and replaces the stored bytes under the same id. Clearing the file (the X, or
the element's Remove) only clears the form's value: the temp file stays until
its 24-hour life ends, and nothing references it. A required field with
nothing uploaded fails preflight with "Please upload a file.", the channel a
blank required text field uses; the upload element itself never carries
required, because the host flags a required element the moment it mounts,
before anything is picked.
A form re-rendered in place — a Replace, a refresh, a filter change — keeps
what the visitor has already typed: the form seeds its state from the live
client values first, then the authored or bound initial values.
Publish lints: file_field_max_size (≤ 25 MiB), file_field_accept
(well-formed extension or MIME entries), and file_field_preview_page
(previewPage must name a modal).
#Minting capabilities yourself
For anything the file field does not cover — several slots at once, slots
derived from the host's own request context — mint them from a step:
{
"kind": "createTempFileUploadUrl",
"stepId": "mint",
"slots": [
{ "name": "instructions_pdf", "maxFileSize": 10485760, "accept": [".pdf"] }
],
"type": "canvasFileUpload",
"ttl": 1800
}
slots may also be a $jsonata directive resolving to that array; an
expression that resolves to nothing is an empty slot list, and anything that is
not slot-shaped fails the step. Duplicate slot names are refused: in a literal
list, the widget fails when the page renders; in a $jsonata list, the step
fails. type defaults to the provider's only declared upload type — name it
when a provider declares more than one.
The output is outputs.mint = { <slot>: { uploadUrl, uploadId, expiresAt } }.
The capability lives 1800 seconds by default (7200 at most), and maxFileSize
is clamped to the platform's 25 MiB ceiling however large a slot asks for.
Tenant, integration, user and provider scope all come from the render's own
claims — there is no way to name them on the step — so this step needs a real
integration context and fails loudly without one.
#Previewing an upload
Preview mints a fresh short-lived link and opens previewPage as a modal with
page.params = { fieldId, url, name, contentType }. A preview page is a
file_viewer over those params:
"modals": {
"preview_file": {
"wide": true,
"layout": [
{ "component": "header", "config": { "title": "{{ page.params.name }}" } },
{ "component": "file_viewer", "config": {
"fileUrl": "{{ page.params.url }}",
"mimeType": "{{ page.params.contentType }}",
"fileName": "{{ page.params.name }}",
"height": 700 } }
]
}
}
With nothing uploaded, Preview notifies "Upload a file first."; an expired upload says so and asks for it again.
#What a job does with the file afterwards
A temp file id is all a job needs. downloadTempFile reads the bytes into the
run's stream context, createTempFileDownloadURL mints a link, uploadArtifact
promotes the bytes into a durable artifact on an entity, and
createEntitiesFromTempFiles turns files into entity rows that carry them.
Three patterns cover everything:
Hand it to a provider without keeping a copy —
downloadTempFileinto a stream, then anhttpcall that PUTs that stream to the provider's own upload URL.Rows from files — each uploaded file becomes an entity row carrying it as an artifact (a form's Documents), with one
createEntitiesFromTempFilesfor all the form's files:createEntitiesFromTempFiles { items: [{ typeName, tempFileId, attributes?, nameAttr?, artifactAttr?, artifactType? ('upload'), relationships?: [{ ruleName, targetEntityId, direction? ('from' | 'to'), attributes? }] (at most 10) }, …] (at most 20, each temp file once) onConflict? ('merge' | 'replace' | 'raise'; default 'merge') suppressEvents? (default false) } → { data: [row…] } in item order; each row carries its `attributes`, `operation_type`, `reused` and `artifact` ({ id, version, artifact_type, original_filename, content_type, size })A submit command threads the form's file into the action through
input:{ "kind": "actions", "stepId": "rows", "input": { "file": "{{ outputs.form.instructions_pdf }}" }, "actions": [ { "stepId": "make", "name": "createEntitiesFromTempFiles", "config": { "items": [ { "typeName": "Document", "tempFileId": "{{ input.file }}", "nameAttr": "title", "attributes": { "status": "open" } } ] } } ] }The new row's id is then
outputs.rows.make.data[0].id.The platform copies each temp file to its artifact in storage (no bytes pass through the app) and writes every row, artifact, link and event together: the batch lands whole or not at all, so there is nothing to compensate. Each row is validated as it will be written —
nameAttrreceives the file's name andartifactAttrthe artifact id before the type's schema judges it — so a type that requires either is satisfied by the operation itself. A process move that waits on the files linksoutputs.<step>.<action>.data[k].id. An expired temp file fails with the action errorTEMP_FILE_NOT_FOUND. A temp file is promoted once: a submit sent again (its move refused after its files landed) links the same rows, markedreused, and copies nothing.Keep it on an existing row —
downloadTempFile, thenuploadArtifactonto the entity the file belongs to.
An actions step runs all four by default. If you narrow it with
permissionSet, the two temp-file reads are in readonly, while
uploadArtifact and createEntitiesFromTempFiles need dataMutation.
#Providers & identity
The providers block declares each host provider the app serves: how its render
requests are authenticated, and how the calling user's identity is read
from them. A provider's name is the key you register its organisations under
(POST /access/external-companies, provider: "<name>").
"providers": {
"sedna": {
"auth": { "type": "bearer" },
"autoCreateUsers": true,
"allowEmailLink": false,
"identity": {
"user_external_id": { "source": "body", "path": "user.id" },
"company_external_id": { "source": "body", "path": "company.identifier" },
"user_email": { "$jsonata": "($u := body.user ? body.user : user; $u.ssoEmail ? $u.ssoEmail : $u.emailAddress)" },
"first_name": { "$jsonata": "($u := body.user ? body.user : user; $u.firstName)" },
"last_name": { "$jsonata": "($u := body.user ? body.user : user; $u.lastName)" },
"surface": { "source": "body", "path": "context.display" }
}
}
}
#Authentication
providers.<name>.auth is inbound: auth.type selects how a render request
proves who it is. (Credentials for the app's own outbound calls live in the
spec's root auth profiles instead.)
auth.type |
The credential | Identity is read from |
|---|---|---|
bearer |
An app token you mint per integration (Authorization: Bearer helm_at_…; header overrides the header name). |
The request body (also header / query). |
oidc |
A JWT issued by the provider's identity provider, verified against its JWKS. | Verified JWT claims (jwt_claim). |
exchange |
An Exchange Server identity token, verified against the metadata hosts you trust (trustedAmurlHosts). |
Verified JWT claims (jwt_claim). |
An oidc provider declares the verification contract: issuerPattern (a regex
the token's iss must match, anchored with ^ and $), audience (a string
or array matched against aud), jwksUriTemplate (the key-set URL, with
{tid} allowed only in the path or query so the origin is fixed),
allowedTidClaim (default tid), and requiredClaims. Publish rejects an
oidc/exchange provider with an extractor that is not jwt_claim, or without
a user_external_id extractor; and a bearer provider with a jwt_claim
extractor.
#The identity block
identity is ONE map from identity key to how it is read. Two keys are strict;
the rest are open:
- Discriminator keys — fixed-path extractors only. An extractor is
{ "source": "body" | "header" | "query" | "jwt_claim", "path": "dot.path" }.user_external_id— required: the provider's stable user id. Half of the identity key an external user is known by, and of the per-user render cache key.company_external_id— required forbearer: the provider organisation's stable id, matched against the integration's registered organisation (mismatch ⇒403). Make it read the same key you registered the organisation under.
- Every other key — an extractor or a smart value (a literal, a
{{ }}template, or{ "$jsonata": … }).- The canonical login keys
user_email,first_name,last_namefeed provisioning. Each is optional; a user whoseuser_emailresolves to nothing is created without an email (the JWT omits theemailclaim, and the profile's email stays empty). - App-defined keys (
surface,message_id,message_attachments, …) reshape a provider's request into names your pages read uniformly. A key one typo away from a canonical one (user_emial) is flagged as aderived_key_near_canonicalwarning. Warnings never block a publish; they are listed, withseverity: "warning", indetails.issueswhen a publish is rejected for another reason.
- The canonical login keys
#Where identity is evaluated
Identity keys are evaluated at two moments, against two roots — write expressions that resolve under both:
| Moment | Who evaluates | Root of a smart value | What it feeds |
|---|---|---|---|
| Login | The auth service, on the cold create/link path | The login surface itself: the request body (bearer), the verified claims (oidc / exchange) |
The canonical login keys → provisioning. |
| Render | The app engine, on every render | { body, headers, query } |
Every key → identity.<key> in the render context. |
A smart-valued key resolves alone — there is no fixed-path fallback — so a
user_email expression that finds no address yields no address. Because the
roots differ, an expression that must work at both moments picks its root first,
as the example above does:
($u := body.user ? body.user : user; $u.ssoEmail ? $u.ssoEmail : $u.emailAddress)
— at login body is absent and user is the body's own key; at render body
is present. Extractors need no such care: their source names the surface.
At render every key lands in identity.<key> (the long form
provider.identity.<key> names the same object), so a headline can read
{{ identity.first_name }} and a source can filter on {{ identity.surface }}
regardless of which provider the request came through. provider.provider names
the provider the request came through, and context carries the host's request
context exactly as posted.
#Auto-provisioning
With autoCreateUsers: true, the first render for an unknown
(company_external_id, user_external_id) pair provisions the user: a tenant
user profile and an external-user mapping keyed by that pair, then a JWT for the
render. With it false, unknown users are refused (403) until an admin
pre-provisions them with POST /access/external-users. It takes the
organisation's external_company_id (the id from step 4) and the user's
external_id, plus an optional email, first_name and last_name.
Linking by email. When the resolved user_email already belongs to a user
in the tenant, the provider's allowEmailLink decides what happens:
false(the default): the login still succeeds — a distinct person is created rather than joining the request to someone else's account.true: the external identity is linked to the existing user. Foroidcandexchangethe link is additionally bounded to users who already hold an active external identity under one of this app's integrations — an IdP'semail/preferred_usernameclaim is asserted by the customer's IdP administrator, not verified by Helm, so it can never converge onto a Helm-internal user or onto another app's users. Abearerprovider carries no such bound: its app token is a Helm-issued credential, so an operator who opts in may link to any existing user in the tenant.
Admin pre-provisioning is explicit about the same choice:
POST /access/external-users refuses an email collision with a 409 whose
details.code is EMAIL_LINK_REFUSED unless the call passes
link_existing_email: true. Re-posting a deactivated mapping returns a 409
with details.code EXTERNAL_USER_INACTIVE; reactivate it with
PUT /access/external-users/{id} and { "is_active": true }.
#The company gate
An integration binds one registered provider organisation to your activation. A
bearer render must resolve company_external_id to that organisation's
external_id; any other value is rejected with 403. Deactivating an
organisation (PUT /access/external-companies/{id} with is_active: false)
cuts off its app sessions shortly after — its renders answer 401; deactivating
an integration (is_enabled: false) does the same for that binding.
#Links to provider pages
Whether an address may be written bare is not a property of the path — it is a property of who is serving the page. A relative address resolves against whatever origin served it. Inside Sedna, a Sedna route is the whole address. Inside Outlook — a second provider beside Sedna on the same app — that same route is a link into Outlook's own origin: it resolves, it 404s, and nothing says a different system was meant.
$link lets a document name a target and leave the form of the address to the
render. There is no tenant link setting. It is a tool, not a mandate: by default
an author may write any href, literal or template, and a spec that wants every
address to go through $link opts in to the strict rule (below).
A provider declares its links on its provider block:
"providers": {
"sedna": {
"auth": { … }, "identity": { … },
"links": {
"baseUrl": "https://{company.external_id}.sednanetwork.com",
"routes": {
"message": "/list/message/{id}",
"messageSearch": "/list?q={query}&box={box}",
"compose": "/compose?to={to}"
}
}
},
"outlook": {
"auth": { … }, "identity": { … },
"links": {
"baseUrl": "https://outlook.office.com",
"routes": { "mail": "/mail/{id}" }
}
}
}
baseUrlis the shape of that provider's host, with the tenant's own instance left as a placeholder overcompany— the provider organisation registered on this activation's integration with that provider. It may read{company.external_id},{company.provider}and{company.metadata.<key>}; publish refuses any other placeholder, since the render could not fill it. It must render to anhttp(s)://hostorigin: publish refuses userinfo, a path, a query, a fragment or a trailing slash, because the route supplies the path.routes.<key>is a path rooted at/, optionally with a query string. Placeholders are{name}. Publish refuses a route that is not rooted, one starting//or containing\, one carrying a scheme, a malformed placeholder, and a placeholder used twice.- A provider's key follows the same pattern as a provider organisation's
provider(lowercase letters, digits,-and_, starting with a letter, no dot), since a$linktarget is split at its first dot.
Author a provider address with $link("<provider>.<route>", params):
{
"label": "Open message",
"href": "{{ $link('sedna.message', { 'id': row.message_id }) }}"
}
| Situation | Result |
|---|---|
| The target provider is the one serving this render | The rendered route alone (/list/message/42) |
| Any other provider, or a render with no serving provider | That provider's own baseUrl for this tenant + the route (https://ldcfreight.sednanetwork.com/list/message/42) |
No integration with the target provider, an undeclared provider or route, an absent path param, an unrenderable baseUrl |
"" |
| Two enabled integrations with the target provider | "", plus one structured warning per render naming the provider and the integration ids |
So the Outlook-beside-Sedna case falls out: rendering inside Sedna, a Sedna link
is /list/message/42 and an Outlook link is absolute; rendering inside Outlook,
the same two expressions swap which one is bare. Nothing in the document
changes.
Params. Values are percent-encoded, so a / or an & inside one cannot
restructure the URL. An absent path param yields "" — half a path is a
different page, not a worse version of the right one. An absent query param
drops its pair, because an absent filter is a coherent narrowing of the same
request. Empty strings, nulls and objects all count as absent.
Ambiguity refuses the link, not the render. Two enabled integrations with
one provider are two possible hosts, and the rule never picks. That one href
goes empty while the rest of the page draws, and the engine logs
$link could not address an ambiguous target provider once per render, with
provider and integration_ids, so an operator can find it. Disable the
integration the app does not use.
The strict rule is opt-in. A spec served by more than one provider, where a
relative address resolves against whichever origin is serving the render, sets
"strictHrefs": true at the document root. Publish then refuses a literal
relative href wherever it stands (relative_href_not_via_link), naming
$link — no allowlist could make one safe, because only a render knows the
origin. An href string passes only when it locates itself: an absolute URL with
a scheme, a template led by a {{ … }} span, a bare #fragment, or "". So
/detail/42, detail/42, ./x, ?tab=x and detail/{{ row.id }} are all
refused. Without strictHrefs (the default) none of these is refused.
Whatever the opt-in, publish always:
- refuses a
$linkwhose literal target names no declared route (link_target_unknown); - refuses an
hrefwhose scheme is nothttp,https,mailtoortel(href_scheme_not_allowed) —javascript:,data:,vbscript:,file:and every other scheme. A relative href, a{{ … }}-led template, a#fragmentand""name no scheme and pass this check.
A { "$jsonata": … } href and a { "$literal": … } escape hatch are never
flagged, and a target or scheme computed at render is left to runtime: the
client refuses the same schemes at the navigation boundary, rendering no link
and opening nothing. hrefAccessor names a row field rather than an address —
build the value that field holds with $link in the source producing the row.
Files served by the platform gateway are always absolute and never pass through
$link.
#Config and its scopes
An app carries config at five scopes, and the render context exposes them as
one config object:
| Binding | Holds | Written by |
|---|---|---|
config.app |
The activation's own config. | A tenant admin, via PUT /apps/{specId}/config. |
config.integration |
One integration's config. | A tenant admin, via POST/PUT /apps/{specId}/integrations. |
config.company |
Your tenant's company-wide config. | PUT /apps/{specId}/principals/company/self, or a setPrincipalConfig step. |
config.team |
The caller's team's config. | PUT /apps/{specId}/principals/team/{teamId}, or a setPrincipalConfig step. |
config.user |
The caller's own config. | The user themself (…/principals/user/self, or a setPrincipalConfig step). |
In short:
- A spec supplies its own values under
config.<scope>. They are merged under the tenant's stored config at read time and never copied into it, so editing the spec reaches every tenant that has not overridden that key. configSchemas.<scope>names the JSON schema a scope validates against. The schema lives in the spec owner's tenant; share it with the app's manifest.- Every write merges: keys you send are set, a key sent as
nullis unset, and keys you leave out keep their stored value. A write that fails the schema is rejected with422;detailscarriesschema_name,scopeandschema_errors. Keys the schema does not declare are dropped. companyis the activating tenant's own config (one per activation, shared by every integration),teamis matched on the caller's team memberships, anduseron the caller. TheprincipalIdis your tenant id (orself) forcompany, the team id forteam, and the user id (orself) foruser.
The full model — scopes, schemas, merge and validation rules, reading config and the error codes — is in Configuration & Config Schemas.
#Writing from inside the app
A form with a config binding ("config": { "principalType": "user" })
derives its fields from that scope's schema and submits through
setPrincipalConfig. Its fields name keys with
{ "in": "config", "key": "<key>" }. The viewer reads that schema to draw the
form, so the app's policy grants read on it
({ "resourceType": "json_schema", "resourceName": "<schema>" }) beside the
per-principal config grant from step 2; without it the
form is replaced by an error notice. A command can also write directly:
{ "kind": "setPrincipalConfig", "principalType": "user",
"config": { "default_view": "{{ page.widgets.prefs.state.default_view }}" } }
{ "kind": "setPrincipalConfig", "principalType": "company",
"ops": [ { "path": "followed_vessels", "appendUnique": "{{ page.params.id }}" } ] }
config merges a document over the existing config (a key set to null is
unset); ops edits the value at a dot-path, with values resolved at step time:
| Op | Effect |
|---|---|
appendUnique: <value> |
Append the value to the array unless it is already there. |
remove: <value> |
Remove the value from the array. |
moveItem: { value, by: 1 | -1, within? } |
Move the value one place down or up; fails if the value is not in the list. |
upsertBy: { key, value?, set } |
Merge set into the record whose key equals value, or append set. |
removeBy: { key, value } |
Remove every record whose key equals value. |
setKey: { key, value } |
Set one entry of an object; an empty value deletes it. |
Without principalId, the step writes the caller's own principal.
#Secrets in an app
Two rules cover secrets in apps; the full model is in Secrets:
- A spec never contains a secret reference.
{{ secrets.* }}belongs in config — the app config, an integration config, or a principal config — and the spec reads the config slot (config.app.provider_token). A spec carrying a reference is rejected at publish (SECRET_REF_IN_DEFINITION). - Wiring a reference into config is the read authorisation. The engine resolves only the references present in this activation's config, per caller.
Users set their own secrets from inside the app with a secretInput field and a
setSecret step; the render context's secretsSet lists the names (never the
values) of secrets already set per scope, so a $if can hide a "connect" widget
once 'API_KEY' in secretsSet.user is true.
To authenticate outbound calls — an http action or an $http source — with a
secret, declare a root auth profile that reads the config slot and name it
from the call; see
HTTP Auth & External Data.
#Share an app with another tenant
Another tenant installs your app through a manifest: you list the spec and
everything it needs — its config schemas, the entity types it reads, and an
entity member for each type whose rows consumers must see — offer the manifest
to the tenant, and that tenant accepts it before activating. The spec itself
carries no sharing block. See
Share with Another Tenant; the consumer's side is
Accept & Activate.
#API reference
Every app spec, config, integration, token, external-user, and principal-config operation — with full schemas and an interactive console — is in the API reference.