Authorize

#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) and registered_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.
  • metadata is 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 and metadata. 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 replaces metadata).
  • 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 general PUT /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_id the request resolves to must equal the integration's registered organisation — otherwise the render is rejected with 403. Make the extractor read whatever stable key you registered the organisation under (here, company.identifier → "acme"). Deactivating an organisation (is_active: false via PUT /access/external-companies/{id}) cuts off its app sessions shortly after: its renders answer 401. 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, referenceData and any notifications.
  • A data refresh answers with only the requested element's referenceData — no surfaces and no state.
  • No action and no referenceData is 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 roles field. App access is policies + defaultPolicy (provisioned per consumer tenant) plus per-user assignment: GET /apps/{specId}/policies lists the spec's policies in your tenant, and PUT /apps/{specId}/users/{authUserId}/policy with { "policy": "<key>" } assigns one to a user. Apps that don't need policy-mode authorisation omit policies and fall back to standard mode (the union of the user's role bindings); configSchemas governs 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 surfaces record does. Any page is reachable from any placement through navigate.

  • The dashboard is chosen by defaultDashboard. With several dashboards, make it a smart value over the host's request context so each host placement opens the dashboard built for it:

    "defaultDashboard": "{{ context.display = 'GLOBAL' ? 'workspace' : 'reader' }}"

    The keys under context are 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.
    maxHeight A CSS length ("320px") capping the card's height.

    title is required — a string-shorthand or props-less scanCard entry is rejected at publish. Resolved props are validated strictly; a prop that fails (a numeric maxHeight, a link without href) 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. items must be a non-empty array, or publish fails with action_surface_missing_items. A dashboard may declare an actionSurface as its only placement.
  • Render. The response carries { "type": "actionSurface", "id": "<dashboardKey>", "blocks": [ …items ] }, with each custom item's id rewritten to :a/<itemId>:. If the items fail to resolve, that placement is left out with an ERROR notification; 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 no thisWidget or itemId), then the view re-renders. An item with no actionHandlers entry answers with an ERROR notification (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 state in 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, a setPageData, 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 navigate to the page whose params do 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 navigate neither reads nor writes the memory, and remember on 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 $query or $http source that feeds a grid may carry only the row-local steps, map and leftJoin, 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 $rows source 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 example VALIDATION_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 referenceData entry carries a WARNING notification whose id is process-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:

  • rows reads a page of the module's anchors. process describes that page's ids (one describeProcessActions per 200 rows) and folds the results onto the rows, so a row holds steps.<process>.<emit>.{status, actions} ("rows": "step" gives each step row its own {status, actions}; a step the viewer cannot read is left out). can folds can: { update: boolean, remove: boolean } onto every row, answered by the page read itself — display only; every write is judged again. Both are row data.
  • grid is a "load more" grid: rows only, 25 at a time, up to 1,000. Its columns are generated from configuration; a generated column list declares no needs, so include the relationships the columns read on the source. The status column shows the label and badge words at its path. fromRow names 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 authored actions control opens the detail page for its row.
  • steps is one anchor's steps as a list (rowsPath drills into the describeProcessActions result). checklist draws them: one heading per category (a groupBy is { field, labelField }), a status column 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's intent.
  • bar is the call-to-action controls that show on the bar. A descriptor's surfaces is null for "everywhere", so the filter tests surfaces = null as well as membership (the surface words, action_bar here, are whatever the presentation's surfaces lists). $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) gives undefined, not []. A trailing [] fixes only the first case. The actions config refuses both an object and undefined.
  • edit is an authored control gated by a permission read that is not about a row. enabled is a smart value resolved before the widget runs, and reason is the viewer's words. A disabled control renders as disabled text, and its command never runs.
  • form is the page a transition that takes inputs opens. It receives page.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.

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. describeProcessForm takes values (the form's current values by field); a $job source 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 carrying suppliedBy, the select carrying suppliesFields: 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_bar outside the form, feed its state into the source's values, map the returned fields to the form's own field config, and set the form's resetKey to 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, the resetKey and 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 — downloadTempFile into a stream, then an http call 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 createEntitiesFromTempFiles for 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 — nameAttr receives the file's name and artifactAttr the 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 links outputs.<step>.<action>.data[k].id. An expired temp file fails with the action error TEMP_FILE_NOT_FOUND. A temp file is promoted once: a submit sent again (its move refused after its files landed) links the same rows, marked reused, and copies nothing.

  • Keep it on an existing row — downloadTempFile, then uploadArtifact onto 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 for bearer: 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_name feed provisioning. Each is optional; a user whose user_email resolves to nothing is created without an email (the JWT omits the email claim, 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 a derived_key_near_canonical warning. Warnings never block a publish; they are listed, with severity: "warning", in details.issues when a publish is rejected for another reason.

#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. For oidc and exchange the link is additionally bounded to users who already hold an active external identity under one of this app's integrations — an IdP's email / preferred_username claim 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. A bearer provider 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.

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}" }
    }
  }
}
  • baseUrl is the shape of that provider's host, with the tenant's own instance left as a placeholder over company — 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 an http(s)://host origin: 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 $link target 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 $link whose literal target names no declared route (link_target_unknown);
  • refuses an href whose scheme is not http, https, mailto or tel (href_scheme_not_allowed) — javascript:, data:, vbscript:, file: and every other scheme. A relative href, a {{ … }}-led template, a #fragment and "" 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 null is unset, and keys you leave out keep their stored value. A write that fails the schema is rejected with 422; details carries schema_name, scope and schema_errors. Keys the schema does not declare are dropped.
  • company is the activating tenant's own config (one per activation, shared by every integration), team is matched on the caller's team memberships, and user on the caller. The principalId is your tenant id (or self) for company, the team id for team, and the user id (or self) for user.

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.