Skip to content

Apps: reference ​

The contract between an app and the platform: what a module is, what its manifest declares, the envelopes its exports take and answer, the limits of a call, how workflows and rules call an action, how asynchronous actions complete through webhooks, the life of a version and an install, the routes and the error codes. For a walk through writing one, start with Apps: authoring; for the functions a module imports, see Apps: host interface.

Packages and services:

  • @aletheia-dev/app-sdk: what a TypeScript author imports. defineManifest and defineApp, the aletheia-app build command (and @aletheia-dev/app-sdk/build), and the testing helpers (@aletheia-dev/app-sdk/testing). Published on npm; the generated App SDK reference lists every export.
  • @aletheia-dev/app-host: the host. It inspects a module, validates manifests and JSON Schemas, and runs one export with the host functions bound to one tenant's call. The runner and the SDK's testing helpers both use it. Published on npm.
  • @aletheia-dev/core (app.ts, app-runner.ts, app-install.ts, app-admin.ts, app-callback.ts): the contract itself as zod schemas, which both sides validate against.
  • apps/app-runner: the service that runs calls for the API and the worker.
  • catalog/<name>: the platform apps, one package each (App catalogue).

The module ​

An app version is one WebAssembly module, named by the hex SHA-256 of its bytes and kept in object storage (apps/platform/<sha256>.wasm for a platform app, apps/<tenantId>/<sha256>.wasm for a tenant's own). A module the platform runs:

  • is valid WebAssembly of at most 32 MiB;
  • imports only the five host functions from extism:host/user, Extism's own kernel (extism:host/env) and WASI preview 1 (wasi_snapshot_preview1) without its socket (sock_*) and path (path_*) functions;
  • defines its own memory: a module that imports its memory is refused, and the platform writes the manifest's memoryMiB into the module as its memory maximum at upload (the build already does), so the hash names the capped module;
  • exports manifest and invoke, and handle_webhook and handoff when its manifest says it handles them.
ExportCalled byRuns with
manifestthe API at upload, the build, the runner's inspectionnothing: no tenant, no network, a 5 s deadline
invokethe worker (steps and rules) and the API (POST /apps/:name/test)the tenant's install: config, secrets, hosts, documents
handle_webhookthe API, on POST /webhooks/apps/:app and its tenant formthe install's config, secrets and hosts
handoffthe API, on GET /collection-submissions/:id/handoffthe install's config, secrets and hosts, a 15 s deadline

Every call instantiates the module afresh and discards the instance when the export returns, so memory and Extism variables never outlive a call. manifest runs with every host function refusing.

Manifest fields ​

The manifest is the JSON the manifest export answers (AppManifest in @aletheia-dev/core); with the SDK, defineManifest declares it in zod and the build serialises it.

FieldType and limitsPurpose
interface1The version of this contract (the SDK writes it).
name^[a-z0-9][a-z0-9-]{0,99}$, not approvals, health or uploadsThe app's name: the install's name, the key of call_app steps and app rules, a segment of every route.
version1.2.0 or 1.2.0-rc.1, 50 characters at mostOne immutable module per name and version, per publisher.
description500 characters at most, optionalShown in the console's catalogue.
category60 characters at most, optionalWhere the catalogue files the app, e.g. Sanctions & PEP.
vendor100 characters at most, optionalWho runs the service the app calls; omitted by apps that call no vendor.
docsUrlan https URL, optionalThe vendor's documentation, linked from the app's page.
pricingNote280 characters at most, optionalHow the vendor charges, in one line.
capabilitiesup to 20 tags, default []Free-form tags such as sanctions.screen and document.verify.
configSchemaa JSON SchemaThe tenant's non-secret configuration; an install's config is validated against it.
secretsup to 20 { name, description? }, names ^[a-zA-Z][a-zA-Z0-9_]{0,63}$, each onceThe secrets a tenant stores before the install can be enabled.
actionsat least one, names ^[a-zA-Z][a-zA-Z0-9_]{0,99}$The callable actions (below).
hostsup to 20 hostnames, each once: api.vendor.example, *.vendor.example, or localhost and IPv4 for developmentThe only hosts the app may send requests to; an install approves them.
needs[] or ['documents']documents: calls may read the tenant's clean documents.
memoryMiB16 to 256, default 64The module's memory maximum, written into it at upload.
handles{ webhook, webhookExternalId?, handoff? }Which optional exports the module provides and how its webhooks find their tenant (below). The SDK fills it in.

Per action:

FieldLimitsPurpose
description500 characters at most, optionalShown with the action in the console and the step pickers.
inputa JSON SchemaValidated before a call from a workflow or a rule reaches the module.
outputa JSON SchemaA synchronous call's output is validated against it before the run sees it.
timeoutMs100 to 60 000, default 15 000One attempt, the whole call included.
retry{ maxAttempts: 1-5, backoffMs: 0-60000 }Attempts including the first; the delay is backoffMs times the attempt number. Optional: one attempt.
idempotentdefault falseRepeating the call has no further effect, so the platform may retry without an idempotency key.
async{ callbackTimeoutSeconds: 1-604800 }The action answers pending and completes through a webhook; the run waits up to this long (seven days).

handles:

  • webhook: the module exports handle_webhook (the SDK sets it when defineApp has a handleWebhook).
  • webhookExternalId: where the API reads a webhook's external id when the URL has no ?externalId=: { json: '/data/applicantId' } (a JSON Pointer into a JSON body), { header: 'x-job-id' } (a lower-case header name) or { query: 'job' }.
  • handoff: the browser SDK the applicant continues a pending session in; sumsub is the one the collection terminal knows.

JSON Schema at the boundary ​

The host validates with Ajv in draft 2020-12 with the common formats, strictly: an unknown keyword or format makes the schema invalid, and the upload refuses a manifest whose schemas do not compile. It never fills defaults: a module applies its own when it parses its input and configuration. The SDK serialises configSchema and each action's input with z.toJSONSchema(schema, { io: 'input' }), so a field with a default is optional, and each output with { io: 'output' }; what JSON Schema cannot express (a transform, a refinement) becomes {} and is checked by the module's zod parse only.

Envelopes ​

Every export takes one JSON document and answers one. call names the call: its id, the tenant and, when the deployment has a public webhook address, the URL this app's webhooks go to.

invoke:

json
{
  "call": {
    "id": "0e6d…",
    "tenantId": "3f2a…",
    "callbackUrl": "https://api.example/webhooks/apps/acme-screening"
  },
  "action": "screen",
  "idempotencyKey": "8c41…",
  "config": { "baseUrl": "https://api.acme-screening.example", "threshold": 0.9 },
  "input": { "name": "Evil Corp" }
}

answers { "status": "ok", "output": … }, { "status": "pending", "externalId": "…" } (an asynchronous action; 200 characters at most) or { "status": "error", "message": "…", "retryable": true } (retryable optional: a failure another attempt may fix).

handle_webhook takes { call, config, request }, where request is the vendor's request as the API received it: { method, headers, query, bodyBase64 } with lower-cased header names. It answers { "status": "event", "event": { externalId, eventId, status, output?, error? } } with status completed, failed or pending; { "status": "ignored" }; { "status": "rejected", "message": "…" } (a bad signature or payload); or an error answer.

handoff takes { call, config, externalId } and answers { "status": "ok", "handoff": { "sdk": "sumsub", "token": "…", "expiresAt": "2026-10-07T12:00:00Z" } }, { "status": "ok", "handoff": null } when the applicant has nothing left to do, or an error answer.

An answer must be JSON and at most 1 MiB; anything else fails the call as bad_output. Before it is parsed, the host removes the call's secret values from it.

With the SDK the envelopes stay out of sight: a handler gets the parsed input and a context, and pending(externalId), rejected(message), ignored and a thrown AppError become the answers above.

Limits of a call ​

WhatLimit
Deadlinethe action's timeoutMs for invoke; 15 s for handle_webhook and handoff; 5 s for manifest
Memorythe manifest's memoryMiB
HTTP requests25 per call, 10 s each by default and 30 s at most
HTTP response8 MiB
Document reads10 per call, document bodies included
Log lines200 per call, 2,000 characters each
Answer1 MiB
Module32 MiB
Calls in flightAPP_RUNNER_CONCURRENCY per runner process (8), APP_RUNNER_TENANT_CONCURRENCY per tenant (4)

A call past its deadline is ended by terminating the thread it runs in (timeout). A runner at either concurrency limit refuses the call with busy, which the worker retries after a moment. What a call used (HTTP requests, document reads, log lines) comes back with its answer and is kept on the invocation record.

Input mapping from workflows and rules ​

A call_app step and an app rule name the install and an action, and build the action's input the same way: the literal input object first, then inputMapping (input field -> dot path into the run context, or the rule data), which overrides literals of the same name. Mapped paths that do not resolve leave the literal, or nothing, in place. Field names containing dots set nested paths:

json
{
  "input": { "applicant": { "country": "GB" } },
  "inputMapping": {
    "documentId": "submission.identityDocument.fileId",
    "applicant.fullName": "submission.uboName"
  }
}

produces { documentId, applicant: { country: 'GB', fullName } }. Intermediate objects are created as needed and literal objects are merged into, never changed. An intermediate that already holds a non-object is left alone and the value lands under the literal key "applicant.fullName", where the action's input schema reports it.

The step stores the output under its outputKey; an asynchronous action also writes <outputKey>ExternalId. The app rule compares the output's resultPath with failWhen (Rules) and needs a synchronous action: it cannot wait for a webhook, so an asynchronous one is refused before anything is sent. Dry runs and backtests do not call apps: their app rules come back skipped.

Retries, idempotency, circuits and records ​

  • Idempotency key. Every call from a step carries a key derived from the run and the step (and the round, when a request for information repeats the step); a rule's call adds the rule's key. The key is the same across retries and reaches the module as idempotencyKey, so a vendor that takes one never sees the same logical call twice.
  • The action's retries. The worker retries a failed attempt by the action's retry when the call is safe to repeat: the action is idempotent or the call carries a key, which calls from steps and rules always do. Only failures another attempt may fix are retried: an error answer with retryable: true, a timeout, or a runner that was busy, unreachable or failing on its own. An attempt that could not end before the step's own deadline is not started.
  • The step's retries. Around that, a call_app step runs as a Temporal activity on the app task queue (TEMPORAL_APP_TASK_QUEUE, aletheia-apps), with a two-minute deadline and the step's retry (3 attempts, 2 s apart and doubling, by default). Invalid input, an install that is missing, disabled or not configured, a blocked module, apps being off and an error the app did not call retryable end the step at once.
  • Circuits. Each worker process keeps a circuit per tenant and app: after five failed calls in a row it opens, and calls fail at once with app_circuit_open for 30 seconds before one trial call goes out; the first success closes it. Only calls that ran the module count: a busy or unreachable runner never opens an app's circuit. Opening and closing are audited (app.circuit.opened, app.circuit.closed), opening raises the app.circuit.opened webhook event and the app_degraded notification, and GET /apps/health lists open and half-open circuits.
  • Records. Every attempt that reaches an install is recorded in app_invocations: the version it ran on, the action, the run and subject, the idempotency key, the attempt number, the input and output, the status (success, pending, error, timeout), the error, the duration and what it used at the runner. Secrets are never recorded. Callers without runs:context read the input, the output and the error text as [redacted].

Asynchronous actions and webhooks ​

An action declared async answers pending with the vendor's external id. The worker then:

  1. records the invocation as pending and an app_callbacks row (tenant, app, version, action, external id, run, step), which is what maps the vendor's id back to the tenant;
  2. stores <outputKey>ExternalId in the run context and sets the run to waiting_callback;
  3. waits for the appCallback signal for the external id, up to callbackTimeoutSeconds. A completed event's output becomes the step's output. A failed event fails the run (AppCallbackFailed); no event in time marks the session timed_out and fails the run (CallbackTimeout). The run's error names the app and the action only: the vendor's reply can quote the subject, so it stays on the callback record.

The vendor sends its webhook to the app's callback URL, built on the deployment's public webhook address (WEBHOOK_PUBLIC_URL, the API's own address without it):

PublisherRoute
a platform appPOST /webhooks/apps/:app
a tenant's own appPOST /webhooks/apps/:app/tenants/:tenantId

The two routes never meet: the platform route only finds sessions started by platform versions, and the tenant route only that tenant's own versions' sessions. A tenant's module chooses the external ids it returns, so a shared route would let an app named like a platform app capture another tenant's webhooks.

The routes are public (no token), take bodies up to 256 KiB and hand the module the raw bytes, so a signature is checked over exactly what the vendor sent. The API:

  1. reads the external id from ?externalId=, or else where the newest published version of the publisher declares it (handles.webhookExternalId). No module code runs before the tenant is known; without an id the answer is 400 missing_external_id;
  2. finds the session and so the tenant, the install and the version the session started on;
  3. runs that version's handle_webhook with the install's configuration, secrets and hosts;
  4. records the event and, unless it is a repeat, completes the session and signals the run.
SituationAnswerEffect
The module answered ignored202 { received: false, ignored: true }nothing
The event id was seen before for the tenant and app200 { received: false, duplicate: true }nothing
The event is pending202 { received: true, accepted: true, callbackId, status }event recorded, the session stays pending
The session was already settled or timed out200 { received: false, stale: true, callbackId, status }event recorded, the session unchanged
Otherwise202 { received: true, callbackId, status }session completed or failed, run signalled
The module answered rejected401 webhook_rejectednothing
The module failed or the runner did502 app_webhook_failednothing
No session waits on the id404nothing
The tenant uninstalled the app409 app_not_installednothing
The module is blocked by the operator409 app_blockednothing

Completion is first-wins at the database, so a replay racing the timeout cannot change the outcome. Every completion is audited as app.callback.received with the app, the external id, the status and the event id, never the payload. GET /app-callbacks lists the sessions; the app's page in the console and the run's page show them.

Hand-off to the applicant ​

Some checks need the applicant at the vendor: a liveness check, documents the vendor's own capture collects. An app whose manifest declares handles.handoff exports handoff: given the external id of a pending session, it answers a fresh, short-lived token for the vendor's browser SDK, or null once the vendor has everything. After the submit, while the run has a pending session of such an app, GET /collection-submissions/:id/status answers handoff: true and the collection terminal asks GET /collection-submissions/:id/handoff for a token, again whenever one expires, and shows the vendor's SDK (Embedding). The route is for the applicant only; it answers 409 handoff_unavailable when nothing waits for the applicant and 502 handoff_failed when the app fails. The token is never stored, logged or audited.

Life of a version ​

StatusMeaning
uploadedA tenant's module, checked and stored; seen by that tenant's administrators only, not installable.
publishedInstallable: every tenant sees a platform version, the publishing tenant sees its own.
withdrawnNot newly installable; installs that run it keep running it.
  • Upload (POST /apps/uploads, the module as application/wasm). The API checks the bytes, asks the runner for the manifest (the manifest export, run with nothing), writes the memory maximum into the module and stores it under its hash. A tenant has one version per name and version number (409 conflict). Audited as app.uploaded.
  • Publish (POST /apps/:name/versions/:id/publish). Publishes at once, or, when the tenant's approval settings require approvals for apps (approvals.required, with requiredFor unset or including app), opens a request that another administrator approves (POST …/versions/:id/approve) or rejects (…/reject, the version stays uploaded and can be requested again). With four-eyes on, the requester cannot approve their own request (403 four_eyes). Audited as app.published, app.approval.requested and app.approval.decided.
  • Withdraw (DELETE /apps/:name/versions/:id). Audited as app.withdrawn.
  • Platform versions come from the release's catalogue, published by the API at start-up, and are withdrawn when a release no longer carries them (App catalogue). No tenant can change one.

Life of an install ​

  • Install, configure, upgrade are one call: PUT /apps/:name/install with { versionId, enabled, config } (enabled defaults to true, config to {}). The version must be published and its module not blocked; config is validated against its configSchema (400 validation_error with the issues); the hosts the version declares are recorded as the install's approved hosts. The answer lists missingSecrets, the secrets the version names that have no stored value, and says whether a newer published version of the same publisher exists (updateAvailable). enabled: true is refused with 409 app_not_configured while a secret is missing, so an app with secrets is installed disabled, given its secrets, then enabled. Audited as app.installed the first time and app.configured after.
  • Secrets (POST /apps/:name/secrets/:secret with { value }) are sealed in the tenant's secret store with the deployment's SECRET_STORE_KEY (AES-256-GCM, bound to the tenant, the app and the secret's name) and never returned or logged; storing again replaces the value. A secret may be one the installed version names or one a published version of the app names, so a secret that a newer version adds is stored before the upgrade and the app stays enabled throughout. DELETE on the same path removes it, after which calls fail until a value is stored again. Audited as app.secret.stored and app.secret.revoked, without the value. Without the key both answer 503 secret_store_unavailable.
  • Upgrade is the same PUT with another versionId. Sessions that are still pending finish on the version that started them.
  • Disable (enabled: false) stops new calls: steps that call the app fail, and validation warns (app_disabled).
  • Uninstall (DELETE /apps/:name/install) removes the install and its secrets and keeps the invocations and callbacks. While runs wait on the app's callbacks it answers 409 app_in_use with details.pendingCallbacks; ?force=true fails those sessions with app_uninstalled, which ends each run's wait at once. Audited as app.uninstalled. Definitions that still call the app then fail with unknown_app; GET /apps/:name/usage lists the published ones.
  • Test (POST /apps/:name/test with { action, input }) calls one action of the installed version once with the install's configuration, secrets and hosts, enabled or not: no retries, no circuit, nothing recorded but the app.tested audit event. It answers success with the output, pending with the external id (whose webhook will find no run), error or timeout, with the duration and what the call used. Without runs:context the output values and the error text are [redacted].

Routes ​

RoutePermissionDoes
GET /appsapps:readThe catalogue: platform apps and the tenant's own, one entry per name, with the install.
GET /apps/:nameapps:readOne entry with every version the tenant sees.
GET /apps/:name/versionsapps:readThe versions the tenant sees, newest first, each with its manifest unfolded.
GET /apps/:name/usageapps:readThe published workflows and rules that call the app, with the actions they call.
GET /apps/health?windowMinutes=apps:readPer app: calls, failures, timeouts, p95 and the last call and failure over 5 to 1,440 minutes (60 by default); open circuits; installs on a blocked module.
GET /apps/approvals?open=apps:readPublish requests of the tenant's own versions: open ones oldest first, or decided ones.
POST /apps/uploadsapps:publishUpload a module as an uploaded version.
POST /apps/:name/versions/:id/publishapps:publishPublish (200), or open an approval request (202).
POST /apps/:name/versions/:id/approve, /rejectapps:publishDecide an open request, with an optional { comment }.
DELETE /apps/:name/versions/:idapps:publishWithdraw one of the tenant's own versions.
PUT /apps/:name/installapps:writeInstall, configure, upgrade, enable or disable.
DELETE /apps/:name/install?force=apps:writeUninstall.
POST /apps/:name/secrets/:secret, DELETE on the sameapps:writeStore or remove a secret.
POST /apps/:name/testapps:writeOne test call of an action.
GET /app-invocations, GET /app-invocations/:idapps:readInvocation records, newest first, by appName, workflowRunId, status (repeated) and from/to.
GET /app-callbacksapps:readVendor sessions, newest first, by workflowRunId, status and appName.
POST /webhooks/apps/:app, …/tenants/:tenantIdpublicVendor webhooks (above).
GET /collection-submissions/:id/handoffthe applicantA token for the vendor's browser SDK (above).

Admins hold every apps:* permission; analysts hold apps:read. /apps routes answer 503 apps_unavailable on a deployment without an app runner or object storage; the invocation and callback routes answer either way.

The runner serves POST /v1/inspect, /v1/invoke, /v1/webhooks/handle and /v1/handoff to the API and the worker under the bearer APP_RUNNER_TOKEN, with GET /health and /health/ready open. It fetches modules from GET /internal/app-modules/:sha256 and documents from GET /internal/app-calls/:callId/documents/:id on the API's internal listener (API_INTERNAL_PORT), which no ingress reaches: modules under the same bearer, documents under the call's token, which binds the tenant and the call, expires after five minutes and is signed with APP_CALL_TOKEN_SECRET, a secret the runner never holds. Only calls of an app that declares needs: ['documents'] get a token.

Errors ​

The codes the API answers with, and that a step's or a rule's failed call carries:

CodeStatusWhen
apps_unavailable503The deployment has no app runner or no object storage.
validation_error400An upload that is not a module the platform runs (details.code below), invalid config, input or output.
runner_<code>502The runner failed while inspecting an upload.
conflict409A name and version the tenant already has, a version not in the status the call needs, an open request.
four_eyes403The requester tried to approve their own publish request.
app_not_configured409enabled: true while secrets are missing (details.missingSecrets); a call of a disabled install, or of one whose secret has no value.
app_in_use409Uninstalling while runs wait on the app's callbacks (details.pendingCallbacks).
app_blocked409The module is in APP_BLOCKED_SHA256.
secret_store_unavailable503No SECRET_STORE_KEY to seal or open a secret.
app_call_failed502The module answered an error, or the runner failed the call (details.reason).
app_circuit_open503The app's circuit is open on this worker.
missing_external_id, webhook_rejected, app_webhook_failed, app_not_installed400, 401, 502, 409Webhooks (above).
handoff_unavailable, handoff_failed409, 502Hand-offs (above).

A refused upload carries one of these in details.code: not_wasm, invalid_module (not valid WebAssembly, a bad memory section, a manifest the platform refuses), forbidden_import, imported_memory and missing_export (no manifest or invoke). A body over 32 MiB is 413 payload_too_large.

The runner's own failures (reason of app_call_failed), with the status the runner answers:

CodeStatusMeaning
unauthorized401The bearer token is missing or wrong.
bad_request400The request does not match the contract.
module_not_found404The API has no published module of that hash.
invalid_module422The bytes are not a module the platform runs, or its manifest is bad.
missing_export422The export is not declared by the manifest or not in the module.
timeout504The call ran past its deadline and its thread was terminated.
trapped502The module trapped or threw.
bad_output502The module answered something the contract does not allow.
busy429The runner, or this tenant on it, has too many calls in flight; retried.
module_unavailable503The module could not be fetched from the API; retried.
internal500The runner failed; retried.

The API and the worker add unreachable for a runner that did not answer, also retried. timeout, trapped, bad_output, invalid_module and missing_export count against the app's circuit; the others are the platform's.

Testing helpers (@aletheia-dev/app-sdk/testing) ​

The testing subpath runs a built module through the real host in the author's own tests, with any test runner.

loadApp(source, options?) ​

source is the module's path or bytes. Answers a TestApp with the manifest the module declares, invoke(action, input, { idempotencyKey? }), handleWebhook(request), handoff(externalId) (each resolving to the export's answer, as in Envelopes) and logs (every line the module logged, { level, message, fields }, redacted as the host redacts them).

OptionDefaultNotes
config{}The install's configuration, passed as is (the module applies defaults).
secrets{}Values by secret name; a placeholder for a missing one fails the request.
hoststhe manifest's hostsThe approved hosts; a running vendor stub adds 127.0.0.1.
documentsnone{ [id]: { fileName, contentType, bytes } }; an unknown id fails the read.
tenantIdTEST_TENANT_IDThe tenant the calls run for.
callbackUrlnonectx.callbackUrl in the module.
loggernoneReceives the module's lines and the host's own.

Calls run with the platform's limits and the action's timeoutMs.

vendorStub(handler) ​

Starts an HTTP server on 127.0.0.1 that answers every request with handler(request) ({ method, path, headers, body } in, { status?, headers?, body? } out, an object body sent as JSON) and keeps them in requests. While one runs, loaded apps may reach http://127.0.0.1, which the platform otherwise refuses. origin is its base URL; close() stops it.

signedWebhook(options) ​

Builds the request the API would hand handle_webhook: POST with a JSON-encoded body, lower-cased headers and the query; with secret, the hex HMAC-SHA256 of the body goes into header (default x-signature).

conformance(app, { samples }) and assertConformance ​

Runs the checks the platform applies and answers { ok, checks, failures }, each check { name, ok, message? }:

  • the manifest parses, and configSchema and every action's input and output are JSON Schemas the host accepts;
  • invoke refuses an unknown action;
  • for each sample { action, input, webhook? }: the action is in the manifest, the input validates, and a synchronous action answers ok with an output that validates;
  • an asynchronous action's sample answers pending with an id and, when the sample supplies webhook(externalId) (the vendor's request for that id), handle_webhook answers a completed event naming the same id, whose output validates.

conformance never throws for a failing app; assertConformance throws one error listing every failed check.

Released under the Apache-2.0 License.