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, policies, and shares. Authored under /definition/specs and 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_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. 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 roles field. App access is policies + defaultPolicy (provisioned per consumer tenant) plus per-user assignment. 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 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 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.

#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.

#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-local map steps only — set-level steps would shape one page, not the result set.
  • single — on $query, return the record (or null) 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 copydownloadTempFile into a stream, then an http call that PUTs that stream to the provider's own upload URL.
  • Keep itdownloadTempFile, then uploadArtifact onto 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 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. The publish lint warns when an app-defined key is one typo away from a canonical one.

#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. 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 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.