#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, policies, and shares. Authored under/definition/specsand shareable to other tenants. - 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. How a spec and its bundle provision a
consumer tenant is covered in Share Workflows & Apps.
#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 of its config scopes (configSchemas), register the
JSON schema in the activating tenant first. Skip this when every scope is
null.
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 and shares
blocks are optional. The policy story is in
Shares & Grants; the share/bundle story is in
Share Workflows & Apps. 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); the override takes precedence and later spec
edits no longer touch this activation.
#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.
POST/apps/{specId}/integrationsCreate a provider integrationAPI docs ↗Try it
#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>) exactly once — it is shown
once and not retrievable later, so capture it now.
#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. For company the principalId is
the provider organisation's id; for user it is the user's id.
PUT/apps/{specId}/principals/{type}/{principalId}Set per-principal configAPI docs ↗Try it
#8. Render the app
The render call uses the app token (Bearer), not your PAT, and includes the
integration id in the path — so it is not part of the Authorize bar above. 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 dashboard), state, and
referenceData. A data_grid's rows come back under referenceData keyed by
the grid's element id — not inline in the surface.
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. See The company gate.
#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, and shares the app binds to. 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 layout nodes
│ │ ├── dataSources.<name> the page's named sources, resolved on demand
│ │ └── 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
├── 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
├── shares what travels with the spec to consumer tenants
└── 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, and which upload types it speaks. See Providers & identity and File uploads. | |
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. |
|
shares |
Explicit cross-tenant shares — the owner manifest is derived solely from this block. | |
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. | |
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. 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 three 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": {…} } |
Every dashboard declares the placements it renders into in its surfaces record
— at least one — and names the entry page for each:
"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.
#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.
#Page layout
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"] ] }
] }
}
]
The container components (section, row, tabs, header, footer) 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; 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.
#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. 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/table's rows via
"dataSource": "<name>", a select's options via { "dataSource": "<name>" }. A
source resolves when something reads it (a widget binding, a
{{ page.data.<name> }} template, another source) — never just for existing.
Pick one of four producers:
| Producer | Shape | Use |
|---|---|---|
$query |
{ "$query": { "typeName": "Task", "filter"?: {…}, "sort"?: [{…}], "include"?: [...], "limit"?: n, "single"?: true, "distinctOn"?: "…", "search"?: {…} } } |
Read entities of a type (filter/sort mirror the entity query). |
$http |
{ "$http": { "url": "…", "method"?: "GET", "headers"?: {…}, "rowsPath"?: "data.items" } } |
Fetch rows from a provider HTTP endpoint (SSRF-safe; JSON only). |
$rows |
{ "$rows": [ … ] } |
An inline / computed array (often { "$jsonata": "page.data.x" }). |
$typeSchema |
{ "$typeSchema": { "entityType": "Vessel", "attribute": "segment" } } (or { "schema", "path" }) |
An option feed built from a schema's enum values. |
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 source a widget FEED binds (grid rows, options) may carry row-localmapsteps only — set-level steps would shape one page, not the result set.single— on$query, return the record (ornull) instead of a row array.
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": { "title": "{{ attributes.title }}", "due": "{{ attributes.due_date }}" } } ]
}
}
The widget then binds "dataSource": "openTasks". 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 and raises an author warning, never match-all.
#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
render-time gate), 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 |
Show a toast (INFO / WARN / ERROR). |
actions |
actions[], input?, output?, permissionSet?, additionalAllowed?, denied? |
Run orchestration actions inline — the inline-job path for apps. permissionSet plus allow/deny lists scope what the actions may do. |
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 whole-document 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? |
Echo a value while authoring. |
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 is merged into
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" }
]
#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:
click "Upload" → the app mints a capability; the form re-renders with the
host's upload element in the field's place
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": {
"props": { "uploadId": "{{ upload.id }}", "size": "{{ upload.size }}" },
"errorProps": { "errorMessage": "{{ 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 after resolution, and 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
currently 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
}
Empty, it renders an Upload button. Clicking it mints a capability for the
field's own slot and re-renders with the host's upload element in place; on
success the host writes the temp file id under the field id. A Preview button
appears only when previewPage names a modal. Replacing and clearing the file
are the upload element's own affordances.
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 re-POSTs the same capability and replaces the bytes under
the same id. Clearing the file clears only the client's value; the temp file
stays until its TTL reaps it. A required field with nothing uploaded fails
preflight with "Please upload a file."
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 at publish
time. 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, and
uploadArtifact promotes the bytes into a durable artifact on an entity. Two
patterns cover almost 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. - Keep it —
downloadTempFile, thenuploadArtifactonto the entity the file belongs to.
Of the three, only uploadTempFile needs the dataMutation permission set;
both temp-file reads are in readonly.
#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
auth.type selects how a render request proves who it is:
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), 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. The publish lint warns when an app-defined key is one typo away from a canonical one.
- 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 (POST /access/external-users, which needs the same
provider + external_id pair and optionally an email).
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
409 EMAIL_LINK_REFUSED unless the call passes link_existing_email: true, and
re-posting a deactivated mapping returns 409 EXTERNAL_USER_INACTIVE
(reactivate it with PUT { "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; deactivating an integration
(is_enabled: false) does the same for that binding.
#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 |
The provider organisation's config. | A tenant admin, or a user with the grant, via PUT …/principals/company/{id} or a setPrincipalConfig step. |
config.team |
The caller's team's config. | As above, with team. |
config.user |
The caller's own config. | The user themself (…/principals/user/self or a setPrincipalConfig step). |
Each scope is kept under its own key — nothing is flattened across scopes, and
there is no principal.* binding. The three principal scopes are matched per
request: company on the request's provider organisation, team on the
caller's team memberships, user on the caller.
The principalId in PUT /apps/{specId}/principals/{type}/{principalId} is the
provider organisation's id for company, the team id for team, and the user
id for user. The sentinel self names the caller's own principal (the calling
user, or the calling tenant's own company entry).
#Definition-level values
A spec supplies its own values for any scope under config.<scope>:
{
"config": {
"app": { "process": { "states": ["draft", "review", "done"] } },
"user": { "default_view": "fleet", "expiring_soon_window_days": 30 }
},
"configSchemas": { "user": "my_app_user_config_v1" }
}
These are merged under the tenant's stored config, so an activation that sets nothing still reads the author's values, and any key it does set wins. They are never copied into the activation: a definition value stays live, so editing the spec reaches every tenant that has not overridden that key. That is why an author-owned value — a process model, a default window — belongs here rather than in a bootstrap write.
The five scope names are a closed set. A typo fails the publish rather than publishing a key nothing will ever read.
#Writing config
Every config write merges: the keys you send are set, a key sent as null
is unset, and every key you do not mention keeps its stored value. So a PUT that
carries one key changes one key.
What gets validated is the effective config — the spec's config[scope]
merged under the stored row plus your delta — against the schema
configSchemas[scope] names. A write that fails it is rejected with 422, and
the message names the scope and the offending path. Annotate a property with
"x-immutable": true to stop a lower scope from overriding it.
What gets stored is only what an operator actually supplied, plus any defaults the schema filled in. A key that came from the definition and was not overridden is not written into the row — that is what keeps the definition's value live rather than frozen at activation time.
Config writes are optimistically locked. If another writer changed the row
between your read and your write, the server re-applies your delta over theirs
and answers 200; if it cannot converge, you get a 409 and should re-read and
retry. A write refused because you lack permission is a 403 — never a silent
no-op.
#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; 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": { "value": "{{ page.params.id }}" } } ] }
config merges a document over the existing config; ops performs list
operations on an array-valued key by dot-path — appendUnique, remove,
moveItem (by: 1 | -1) — with values resolved at step time. 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.
#Share an app with another tenant
Publishing a spec for another tenant to install — spec shares, data shares, and the policy fan-out — is its own topic. See Share Workflows & Apps; the consumer's side is Activate Shared Workflows & Apps.
#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.