Skip to content

Apps: authoring ​

This tutorial builds a screening app from an empty directory against the published @aletheia-dev/app-sdk, outside the Aletheia repository: a synchronous action, an asynchronous action that completes through a signed webhook, a document check, tests that run the built module through the real host, and the upload to a tenant. The code below is taken from a finished app, built up a section at a time; CI builds and tests that app outside the workspace whenever the SDK, the host or the app change. A last section, beyond the finished app, hands the applicant of an identity check to the vendor's hosted page.

The contract itself (manifest fields, envelopes, limits, routes, error codes) is in Apps: reference; the functions a module calls are in Apps: host interface; how a release builds and publishes the platform's own apps is in the App catalogue. Many vendors need no app of their own: the catalogue's http-api calls a vendor through an install that holds its endpoint, auth and secrets (App catalogue), whether it answers one request with its result or starts work that the install's signed webhook completes, and sends the tenant's documents in a multipart or binary body once the install turns documents on. A step or a rule that asks a language model needs no app either: the catalogue's model apps answer prompts and decide questions through the vendors' APIs (AI models). Write an app for the rest: a hand-off to the vendor's hosted page, documents inside a JSON body, a webhook signed in a way http-api does not check (an asymmetric signature, a form-encoded or batched webhook) or naming its session only in its URL, or logic and requests of your own, a model's included (below).

Shortcut

pnpm app:new <name> in a checkout of the repository scaffolds this tutorial's end state from templates/app: into catalog/<name>/ as a workspace package, or with --out <dir> as a standalone package that depends on the published SDK. The rest of this page explains what the scaffold contains.

1. Set up the package ​

Node 22 or later. The SDK ships ESM and CommonJS builds with type declarations; zod (v4) is its only peer dependency, and the schemas in your manifest are your own zod instance's.

sh
mkdir acme-screening && cd acme-screening
npm init -y
npm i @aletheia-dev/app-sdk zod
npm i -D typescript vitest @types/node

Set "type": "module" in package.json and add the scripts build: aletheia-app build, test: vitest run and typecheck: tsc --noEmit. The example's tsconfig.json (strict, moduleResolution: bundler, verbatimModuleSyntax, noEmit) works as it is. Its vitest.config.ts raises the timeouts (60 s per test, 120 s per hook), since every test runs the built module in a worker thread.

aletheia-app build bundles your entry with esbuild and compiles it with extism-js, which needs binaryen's wasm-merge and wasm-opt. EXTISM_JS names the extism-js binary and BINARYEN_BIN binaryen's bin directory; without them both are looked up on PATH. In a checkout of the repository, pnpm tools:extism installs the versions the platform builds with (extism-js v1.7.0, binaryen version_133) under .tools/, checksummed; extism-js runs on macOS and on Linux with glibc 2.39 or newer.

Your code runs in QuickJS inside the module, not in Node: there is no node:crypto, no file system, no timer and no event loop between calls to the host. Buffer, TextEncoder, TextDecoder, URLSearchParams, crypto.getRandomValues and crypto.randomUUID exist. Random values come from the host, afresh in every call: crypto reads its bytes there, and defineApp replaces Math.random with one drawn from crypto.getRandomValues, since the runtime's own generator is part of the snapshot the build takes and would answer the same numbers in every call. Draw them in a handler: a value drawn while the module loads is drawn once, when the module is built, and a dependency that kept its own reference to Math.random before defineApp ran keeps the runtime's generator. esbuild bundles whatever the entry imports into one CommonJS file (ES2020), so a dependency must not need Node either.

2. Declare the manifest ​

The manifest is the app's public contract: what it needs from a tenant (configSchema, secrets, hosts, needs) and what it offers (actions, each with a zod input and output).

ts
import { z } from 'zod';
import { defineManifest } from '@aletheia-dev/app-sdk';

const ScreenInput = z.object({
  name: z.string().min(1),
  /** ISO 3166-1 alpha-2, when known; narrows the vendor's candidates. */
  country: z.string().length(2).optional(),
});
const Match = z.object({ name: z.string(), score: z.number().min(0).max(1) });
const ScreenOutput = z.object({ hit: z.boolean(), matches: z.array(Match) });

export const manifest = defineManifest({
  name: 'acme-screening',
  version: '0.1.0',
  description: 'Screens names and identity documents against the acme-screening API.',
  /** Where the console's catalogue files the app. */
  category: 'Sanctions & PEP',
  vendor: 'acme-screening',
  docsUrl: 'https://docs.acme-screening.example',
  pricingNote: 'How acme-screening charges, in one line.',
  capabilities: ['sanctions.screen'],
  configSchema: z.object({
    baseUrl: z.url().default('https://api.acme-screening.example'),
    /** Candidates at or above this score count as a hit. */
    threshold: z.number().min(0).max(1).default(0.85),
  }),
  secrets: ['apiKey', { name: 'webhookSecret', description: 'Signs the vendor webhooks.' }],
  /** The only hosts the install may call; a `baseUrl` elsewhere is refused by the host. */
  hosts: ['api.acme-screening.example'],
  needs: ['documents'],
  /** How the API finds the job a webhook completes, before any code of ours runs. */
  webhookExternalId: { json: '/reference' },
  actions: {
    screen: {
      description: 'Screens one name synchronously.',
      input: ScreenInput,
      output: ScreenOutput,
      timeoutMs: 10_000,
      idempotent: true,
      retry: { maxAttempts: 3, backoffMs: 250 },
    },
  },
});
  • name is the app's name in the catalogue, the API's /apps/:name routes and the console: lower case, digits and dashes, one path segment (approvals, health, metering and uploads are taken by the API's own paths). A tenant installs the app under names of its own, which call_app steps and app rules use (section 10). version (1.2.0) names one immutable module: any change to the app, code included, needs a new version.
  • configSchema describes the tenant's non-secret configuration. The SDK serialises it to JSON Schema, the API validates an install's configuration against it, and your code receives it parsed by the zod schema, defaults applied.
  • secrets are names, with an optional description the console shows. Each install stores a value for each before it can be enabled; your code never reads one (section 3).
  • hosts are the only hosts the install may send requests to (*.vendor.example covers the subdomains of vendor.example, not vendor.example itself), each at the scheme's default port unless it names another (api.vendor.example:8443). The administrator who saves an install approves them, and the host refuses every other host and port, which is why a baseUrl in the configuration cannot point the app elsewhere. A vendor whose address differs per tenant is declared the other way round, below.
  • needs: ['documents'] lets calls read the tenant's documents (section 7); without it the platform hands the call no document access. An app can leave the choice to each install, below.
  • webhookExternalId tells the API where a vendor's webhook carries the job's id (section 5), or each install does, below.
  • title, category, vendor, docsUrl and pricingNote describe the app in the console's catalogue, title naming it where its name would not do (ChatGPT (OpenAI API), 60 characters at most); capabilities are free-form tags (sanctions.screen, document.verify).
  • Per action: timeoutMs bounds one attempt, the whole call included (100 ms to 120 s, 15 s by default); retry (maxAttempts 1 to 5 including the first, backoffMs multiplied by the attempt number) is applied only when it is safe, which idempotent: true declares or the caller's idempotency key implies. Workflow steps and rules always pass a key. After a timeout, or a runner that failed or stopped answering mid-call, the module's requests may have reached the vendor, so only an idempotent action is tried again. Each attempt is a call of the install, metered and weighed against the tenant's quotas and the install's budgets (Quotas and metering). mappable (JSON Pointers into the input, ['/variables']) names the fields run data may fill: a step's or rule's inputMapping of another field fails the call before the module runs. Declare it when the input is a template the action fills, as the catalogue's http-api does, so run data never writes the template (Input mapping).

Hosts and secrets that follow the configuration ​

Some vendors give each customer an address of its own (a region, a tenant subdomain), and some need a secret only in one mode. The manifest can leave both to the install's configuration:

ts
export const manifest = defineManifest({
  // ...
  configSchema: z.object({
    /** The account's own API, e.g. https://eu.api.acme-screening.example; no default. */
    endpoint: z.url(),
    /** `live` reaches the vendor's production service, which takes a key. */
    mode: z.enum(['sandbox', 'live']).optional(),
  }),
  /** Each install calls the host of its `endpoint`, approved when the install is saved. */
  hostsFromConfig: ['/endpoint'],
  secrets: [{ name: 'apiKey', requiredWhen: [{ config: '/mode', in: ['live'] }] }],
  // ...
});
  • hostsFromConfig names up to five config fields that hold URLs, as JSON Pointers. An install calls the manifest's hosts and the exact host of each URL its configuration gives there, its port included when it is not the default, which the administrator approves when saving the install. The platform refuses a URL that is not https, carries credentials or a port browsers refuse, names an IP address or localhost, or names one of the deployment's own hosts, at any port. Such a field must not have a .default(): the platform reads the configuration as saved, so a URL your code would fill in is a host nobody approved, and the build refuses a manifest whose field has one.
  • requiredWhen makes a secret required only when one of its conditions holds: { config: '/mode', in: ['live'] } when the configured value is one of in, and { config: '/mode' } when the configuration has a value there at all. A secret without it is always required. The conditions read the configuration as saved, without your schema's defaults, so a condition on a defaulted field sees a value only when the install sets one; that is why mode above has none and your code treats a missing mode as sandbox. A call carries the required secrets only, so a placeholder for a secret its configuration does not require fails the request.
  • secretsFromConfig points at a config array of variables { key, value?, secret?, stepPlaces? } when the tenant names its own secrets (a generic connector's environment, as in the catalogue's http-api): the key of each secret variable becomes a secret of the install, its value stored like any secret and never kept in the configuration, and required once the configuration uses it as <<key>> or lists places a step may use it in (stepPlaces: path, query, headers, body), which your code decides how to honour.

The rules in full are in Hosts and secrets of an install.

Documents and webhooks that follow the configuration ​

An app that sends documents to its vendor can let each tenant decide whether it does, and an app whose vendor's webhooks name the job in a place that differs per account can let each install say where:

ts
/** Where in the body the vendor signs: a JSON Pointer. */
const Source = z.object({ json: z.string() });

export const manifest = defineManifest({
  // ...
  configSchema: z.object({
    /** Sends the applicant's documents to the vendor; off until an administrator turns it on. */
    documents: z.boolean().optional(),
    /** Where this account's webhooks carry the job's id: `{ json: '/data/id' }`. */
    webhook: z.object({ externalId: Source }).optional(),
  }),
  needs: ['documents'],
  documentsFromConfig: '/documents',
  webhookExternalIdFromConfig: '/webhook/externalId',
  // ...
});
  • documentsFromConfig points at a boolean field, with needs: ['documents']. A call reads documents only while the install's configuration as saved holds true there: otherwise the platform hands it no document access, and a read answers documents are not available to this call. Turning documents on or off applies with the save, waits for no approval, and is audited.
  • webhookExternalIdFromConfig points at the field that holds the source, in place of webhookExternalId, for an app with a handleWebhook: { json: pointer }, { header: name } or { query: name }. The API reads it from the configuration as saved, before any code of yours runs; an install whose configuration holds none has no webhook URL (webhookUrl: null) and receives no webhooks. Let the field's schema allow only what your vendor's signature covers, the body as above or a header it signs, and the platform refuses to save any other source: an id read from the query or an unsigned header would let whoever replays a signed request name another pending session with it (section 6).
  • Neither field may have a .default(), for the reason hostsFromConfig may not: the platform reads what the administrator saved, and the build refuses a manifest whose field has one.

3. Write a synchronous action ​

defineApp({ manifest, actions }) turns the manifest and one handler per action into the module's exports. Your handler receives the input parsed with the action's zod schema (defaults applied); a call from a workflow or a rule is also validated against the action's JSON Schema before it reaches the module, and what the handler returns is validated against the output schema before the run sees it.

ts
import {
  AppError,
  defineApp,
  type ActionInput,
  type ActionOutput,
  type AppConfig,
  type AppContext,
  type HttpRequest,
} from '@aletheia-dev/app-sdk';

export type Manifest = typeof manifest;
type Config = AppConfig<Manifest>;
type Ctx = AppContext<Config>;
export type ScreenResult = ActionOutput<Manifest, 'screen'>;

/** What the vendor answers from `POST /v1/screen` and posts back for a job. */
const VendorResult = z.object({ matches: z.array(Match) });

/** Applies the install's threshold to the vendor's candidates. */
function toResult(vendor: z.output<typeof VendorResult>, config: Config): ScreenResult {
  const matches = vendor.matches
    .filter((match) => match.score >= config.threshold)
    .sort((a, b) => b.score - a.score);
  return { hit: matches.length > 0, matches };
}

/**
 * One call to the vendor. The API key goes as `ctx.secret('apiKey')`, a placeholder the host
 * replaces on its way out: the value never enters the module. A vendor outage is retryable, a
 * refusal is not.
 */
function callVendor(ctx: Ctx, path: string, request: Omit<HttpRequest, 'url'>): unknown {
  const response = ctx.http({
    ...request,
    url: `${ctx.config.baseUrl}${path}`,
    headers: { authorization: `Bearer ${ctx.secret('apiKey')}`, ...request.headers },
  });
  if (!response.ok) {
    throw new AppError(`acme-screening: ${path} failed with HTTP ${response.status}`, {
      retryable: response.status >= 500,
    });
  }
  return response.json();
}

function screen(input: ActionInput<Manifest, 'screen'>, ctx: Ctx): ScreenResult {
  const body = callVendor(ctx, '/v1/screen', { method: 'POST', body: { json: input } });
  const result = toResult(VendorResult.parse(body), ctx.config);
  ctx.log.info('acme-screening: screened', { hit: result.hit, candidates: result.matches.length });
  return result;
}

export default defineApp({ manifest, actions: { screen } });

Everything a call needs is on the context:

  • ctx.config: the install's configuration, parsed with configSchema.
  • ctx.http(request): one HTTP request through the host, answered at once (the module waits while the host does the work). body is a string, { json }, { jsonText } (JSON text sent byte for byte, so a large number keeps every digit), { form } (a record of fields, or [name, value] pairs when a name repeats), { base64 }, a document or a multipart form; auth and sign have the host authenticate and sign it (below); the answer has status, lower-cased headers, ok and json().
  • ctx.secret(name): the placeholder {{secret:apiKey:TAG}}, which the host replaces with the install's value in the URL (percent-encoded), a header value, a body or an auth it sends: in a JSON body or JSON text, inside a string only, escaped for it.
  • ctx.hmac and ctx.hmacVerify: signatures computed by the host, keyed by a secret's name.
  • ctx.cache: text the install keeps between its calls, such as a vendor's token (below).
  • ctx.ai: a language model's answer to a prompt, or its decisions on questions, asked through the host with a key of the install's (below).
  • ctx.documents.read(id), ctx.log, ctx.tenantId, ctx.callId, and for asynchronous work ctx.callbackUrl and ctx.idempotencyKey.

TAG is the call's secret tag: a random value the host draws for every call and hands the module alone, and the host fills only the placeholders that carry it. Text your code did not write with it is data, whatever it looks like: an applicant named {{secret:apiKey}} in the input, a webhook's body or a vendor's answer goes to the vendor as it is and never pulls a secret into a request. Write placeholders with ctx.secret(name) only: one written by hand without the tag goes out as text, and one kept from another call is not filled. Keep them out of requests except where the host fills them: a request that carries the tag anywhere else, such as a header's name or a base64 body, is refused. A log line or an answer that echoes one reads {{secret:apiKey:[tag]}}: the host redacts the tag as it redacts a secret. An install's configuration may name a secret too, as {{secret:NAME}} in its text; ctx.config holds that text with the call's placeholder in its place, so it fills when you send it.

Let the host write a secret into JSON. The host fills a string body as it is, so JSON you build by hand around ctx.secret(name) breaks, or says something else, when the secret holds a " or a \. In a { json } body, or in { jsonText } when the bytes matter (a number larger than a double holds), the host fills the placeholder inside its JSON string, escaped for it:

ts
// Breaks when the key holds " or \: the body is sent as the text it is.
ctx.http({ method: 'POST', url, body: `{"key":"${ctx.secret('apiKey')}"}` });

// Filled escaped for the string it stands in.
ctx.http({ method: 'POST', url, body: { json: { key: ctx.secret('apiKey') } } });
ctx.http({
  method: 'POST',
  url,
  body: { jsonText: `{"key":"${ctx.secret('apiKey')}","amount":12345678901234567890123}` },
});

defineApp also installs a global fetch over the same host function, so ported code that awaits fetch(url, init) works under the same hosts and limits. It sends a string body as it is, an ArrayBuffer or a typed array as { base64 }, URLSearchParams as a { form } of its pairs in their order (with content-type: application/x-www-form-urlencoded;charset=UTF-8 unless the request sets one), and any other value as { json }. The host fills a form's placeholders before it encodes them, so ctx.secret(name) works in its values. A form the host cannot take (more than 100 fields, or a name empty or over 200 characters) goes as its text, where a placeholder is already encoded and cannot be filled: fetch rejects such a body when it carries one of ctx.secret's. A handler may be async.

Throw AppError with retryable: true for a failure another attempt may fix (a vendor outage, a rate limit), and retryAfterMs when the vendor said how long to wait (its retry-after, in ms, an hour at most): the next attempt waits that long where its retry policy would wait less, ten minutes at most. Any other throw, and any request the host refuses (a host the install did not approve, a secret the call does not carry, a placeholder where the host fills none, a field the host does not know, a limit reached), fails the call without a retry. A request that failed in transit throws HttpNetworkError, an AppError whose network says how far it got: not_sent when the connection was never made, unknown when the request may have reached the vendor. It is not retryable either; catch it and throw an AppError with retryable: true when another attempt is safe, always after not_sent and after unknown only for a request the vendor treats as idempotent (a GET, or one that carries ctx.idempotencyKey). A retryable failure also counts towards the install's circuit and a final one does not, so call a vendor's refusal of the request itself (a 4xx) final: one misconfigured step then cannot shut the install for every caller.

Let the host authenticate the request ​

The app above sends its key as a header with a placeholder in it. A vendor that wants Basic credentials, a signature over the request or a signed token needs a value computed from a secret, which your code never holds. Name the scheme in auth and the secrets with ctx.secret(name): the host fills them in, builds the authentication over the request it is about to send, and keeps what it derives out of every answer, log line and error, where it reads [auth:basic], [auth:jwt] and so on. These requests are not part of the finished app, which does not declare the configuration fields and secrets they use:

ts
// Basic: the host sends `authorization: Basic base64(username:password)`.
ctx.http({
  url: `${ctx.config.baseUrl}/v1/accounts/me`,
  auth: { authType: 'basic', username: ctx.config.accountId, password: ctx.secret('password') },
});

// AWS Signature Version 4 over the final method, URL, headers and body, for a vendor behind
// Amazon API Gateway with IAM authorisation.
ctx.http({
  method: 'POST',
  url: `${ctx.config.baseUrl}/v1/screen`,
  body: { json: input },
  auth: {
    authType: 'aws-signature',
    accessKey: ctx.secret('awsAccessKeyId'),
    secretKey: ctx.secret('awsSecretAccessKey'),
    region: ctx.config.region,
    serviceName: 'execute-api',
  },
});

// A JWT the host signs with the install's private key for each request, valid for five
// minutes from then: `authorization: Bearer <token>`.
ctx.http({
  url: `${ctx.config.baseUrl}/v1/screenings`,
  auth: {
    authType: 'jwt',
    algorithm: 'RS256',
    privateKey: ctx.secret('signingKey'),
    payload: { iss: ctx.config.clientId, aud: ctx.config.baseUrl, iat: 'now', exp: '5m' },
    jwtHeaders: { kid: ctx.config.keyId },
  },
});
  • auth has the shape of Hoppscotch's auth object: basic, bearer (the header the app above writes itself), api-key (a header, or a query parameter with addTo: 'QUERY_PARAMS'), digest (the host answers the vendor's challenge with the cookies it set, which costs a second request, and a third when the vendor says its nonce went stale), aws-signature, hawk (the host verifies the vendor's server-authorization when the answer carries one), jwt and akamai-eg (Akamai EdgeGrid, signed as Akamai's own clients sign: accessToken, clientToken and clientSecret, and the headersToSign and maxBodySize of the API client). Every field is listed in the host interface.
  • The host signs exactly what it sends, afresh for each request, so a vendor's replay window holds across retries. A JWT carries the claims you give it and no others; its iat, nbf and exp may be relative to the signing time ('now', '5m').
  • A request that sets a header its auth sets too (an authorization of its own) is refused, as is one whose URL has a query parameter the auth adds.
  • A vendor with an HMAC scheme of its own, over a timestamp, the method, the path and the body, takes sign: the host fills {{request:method}}, {{request:target}} and {{request:host}} in its prefix and suffix with the request as sent (Signing a request).

In tests, the vendor stub receives what the vendor would: request.headers.authorization holds the credential the host built from the test's secrets, and an answer or a log line of the module that repeats a Basic credential reads [auth:basic].

Keep a vendor's token between calls ​

A vendor that trades a credential for a short-lived token, such as OAuth's client credentials or a login that answers a session, would see a token request in every call. ctx.cache keeps text between the calls of an install: fetch the token once, keep it while the vendor takes it, and read it from there in the calls after. Like the requests above, these are not part of the finished app:

ts
/** The vendor's token, fetched when the install keeps none and kept for the calls after. */
function vendorToken(ctx: Ctx): string {
  const kept = ctx.cache.get('token');
  if (kept !== undefined) return kept;
  const response = ctx.http({
    method: 'POST',
    url: `${ctx.config.baseUrl}/oauth/token`,
    body: {
      form: {
        grant_type: 'client_credentials',
        client_id: ctx.config.clientId,
        client_secret: ctx.secret('clientSecret'),
      },
    },
  });
  if (!response.ok) {
    throw new AppError(`acme-screening: the token request failed with HTTP ${response.status}`, {
      retryable: response.status >= 500,
    });
  }
  const issued = response.json() as { access_token: string; expires_in: number };
  // A minute short of the vendor's expiry, so no request leaves with a token about to lapse.
  ctx.cache.set('token', issued.access_token, { ttlSeconds: Math.max(1, issued.expires_in - 60) });
  return issued.access_token;
}

/** `callVendor` with the kept token: on a 401 the token goes, and the request is sent once more. */
function callVendor(ctx: Ctx, path: string, request: Omit<HttpRequest, 'url'>): unknown {
  const send = () =>
    ctx.http({
      ...request,
      url: `${ctx.config.baseUrl}${path}`,
      auth: { authType: 'bearer', token: vendorToken(ctx) },
    });
  let response = send();
  if (response.status === 401) {
    ctx.cache.delete('token');
    response = send();
  }
  if (!response.ok) {
    throw new AppError(`acme-screening: ${path} failed with HTTP ${response.status}`, {
      retryable: response.status >= 500,
    });
  }
  return response.json();
}
  • get answers undefined when the install keeps no live value of that name: none was set, it expired, a call deleted it, or the platform cleared the install's values. It clears them when a secret of the install is stored or revoked, its configuration or version changes, and when an administrator clears them, so a token fetched with a replaced secret is never used again.
  • set keeps the value for ttlSeconds, a day at most. The call sees it at once, and the install's later calls once the call has ended, whatever it answered: a token fetched by a call that then failed is kept. Calls of one install may run at once, and each may fetch a token; the one stored last stays. delete removes the value the call read, never a fresher one another call stored meanwhile.
  • The token is text the module holds, which the host cannot fill like a secret: send it yourself, in an auth or a header. Every value a call reads or sets is redacted from its answer, its log lines and its errors as [cache:NAME], here [cache:token].
  • The values are the install's: every call of it reads them, whatever its run subject. Keep what the vendor gives the install there, such as a token or a session, never a subject's data (a document's text, personal data), which the calls for other subjects would then read.
  • A name follows a secret's rule, and a value has 8 characters to 8 KiB; a call sets 64 values at most, and an install keeps 16 values and 64 KiB by default, the values written longest ago going first. A method the host refuses throws an AppError, as a deployment without SECRET_STORE_KEY refuses them all: catch it where the app should still work there, fetching a token in every call. The rules are in the host interface.

In tests, loadApp keeps a test app's values from one call to the next as the platform does: its cache option gives the values the first call starts with ({ token: 'test-token-1' }), app.cache.values shows what the install keeps, in clear, and app.cache.writes what the last call changed: after a 401, the token callVendor fetched in place of the refused one ({ token: { value, expiresAt } }), or { token: null } when that fetch failed. The module's answers and log lines show [cache:token], as on the platform.

Ask a model ​

An action can ask a language model through the host: ctx.ai.generate for an answer to a prompt, as text or as JSON of a schema you give, and ctx.ai.decide for decisions on named yes-or-no, choice and score questions about a state. The request names the vendor's connection and the install's secret that holds its key; the host fills the key, sends the request to one of the install's hosts through the runner's models and checks the answer against what was asked, so the key never enters the module. Like the requests above, this is not part of the finished app: an action, given to defineApp with the others, that has a model sum up the vendor's candidates for a reviewer, with Anthropic's key among the install's secrets and its API among the hosts.

ts
import { AiError } from '@aletheia-dev/app-sdk';

export const manifest = defineManifest({
  // ...
  secrets: [
    'apiKey',
    { name: 'webhookSecret', description: 'Signs the vendor webhooks.' },
    { name: 'anthropicApiKey', description: "Anthropic's API key, for the reviewers' notes." },
  ],
  /** Anthropic's own API, which a connection without a base URL reaches. */
  hosts: ['api.acme-screening.example', 'api.anthropic.com'],
  actions: {
    // ...
    explain: {
      description: "Sums up the vendor's candidates for a reviewer, through a model.",
      input: ScreenOutput,
      output: z.object({ note: z.string().nullable() }),
      /** The model's answer is most of the call, and gets what the call has left. */
      timeoutMs: 60_000,
      idempotent: true,
      retry: { maxAttempts: 2, backoffMs: 1000 },
    },
  },
});

function explain(
  input: ActionInput<Manifest, 'explain'>,
  ctx: Ctx,
): ActionOutput<Manifest, 'explain'> {
  try {
    const answer = ctx.ai.generate({
      // The install's secret by name: the host fills the key, which never enters the module.
      connection: { provider: 'anthropic', apiKey: { secret: 'anthropicApiKey' } },
      model: 'claude-haiku-4-5',
      instructions: 'Sum up these sanctions candidates for a reviewer, in two sentences.',
      prompt: JSON.stringify(input.matches),
      maxOutputTokens: 1024,
    });
    return { note: String(answer.output) };
  } catch (error) {
    // The model refused: the reviewer reads the candidates without a note.
    if (error instanceof AiError && error.kind === 'refusal') return { note: null };
    throw error;
  }
}
  • The connection names the vendor by provider (anthropic, openai, azure, google for Gemini, typesafe for Jev, bedrock or openai-compatible) and each key by the install's secret, { secret: name } or ctx.secret(name): never the key's text, which the host refuses. Declare the secret, with requiredWhen when only some installs use a model, and the host of the vendor's API in hosts: without a baseUrl the request goes to the vendor's own API, here https://api.anthropic.com/v1. A baseUrl (a proxy's, a gateway's, a server of the tenant's) must be one of the install's hosts: take it from the configuration and name its field in hostsFromConfig. Every field is in the host interface.
  • output shapes a generate answer: text (the default), an object or an array of a JSON Schema, one of a choice's options, or any json. A schema keeps to what every vendor's structured output takes: every property in required, additionalProperties: false, a field that may be missing nullable ("type": ["string", "null"]), and no $ref, pattern, oneOf or allOf. ctx.ai.decide asks up to 32 boolean, choice and score questions about a state and answers each by its id: the probability a boolean holds, the option chosen, the position on the levels, and how sure each answer is (confidence) where that can be told.
  • The host checks the answer before your code gets it: answer.output is the text, the option, or JSON the schema describes, and answer.usage the tokens the vendor counted. The call's record keeps the tokens of all its requests (usage.ai); the vendor bills them, and no quota of the platform counts them.
  • A request runs for its timeoutMs (1 s to 2 min, two minutes without one), cut to what the call has left less a second the host keeps for your code: give the action a timeoutMs that covers the model's answer, up to 120 s. A call makes 20 ai requests at most, and ctx.ai reaches models in an action's call only: in handleWebhook and handoff it throws.
  • A request without an answer throws an AiError, an AppError with the host's kind (request, vendor, network, timeout, refusal, output or unsupported), retryable, retryAfterMs, the vendor's status and the usage the request cost. Catch the kinds your action can do without, as explain does a refusal, and let the rest go: the action then fails as the vendor did, a 429, a 5xx, a failure in transit or a timeout retryably and after the wait the vendor asked for, a refused request or an answer outside the schema for good. A retryable failure counts toward the install's circuit, as any does.
  • An action whose output's shape its input gives, such as an answer to a schema the step writes, declares it with outputFromInput, so the editors offer the fields later steps read: { kind: 'schema', output: '/output', input: '/output/schema' } (the output at /output is what the JSON Schema at the input's /output/schema describes), or { kind: 'keys', output: '/answers', input: '/questions', key: 'id' } (the output at /answers has a member for each item of the input's /questions, named by its id). The catalogue's model apps declare both, jev, which only decides, the second (Output paths).
  • Run data in a prompt is text the model reads, instructions an applicant wrote into it included: keep your own text apart from it, and let no model's answer alone approve what a person should see (Prompt injection).

In the tests of section 4, give loadApp the models its calls reach: aiStub() answers every ai request without a network, once the host has checked it as on the platform (its fields and schema, its keys filled from secrets, its base URL among app.hosts), and keeps each in requests as the host made it, keys filled, for the test to check what was asked:

ts
import { AiCallFailure, aiStub, STUB_AI_TEXT } from '@aletheia-dev/app-sdk/testing';

const keys = { ...secrets, anthropicApiKey: 'sk-ant-test' };
const candidates = { hit: true, matches: [{ name: 'Ivan Blocked', score: 0.95 }] };

describe('explain', () => {
  it('asks the model with the install key, which the module never holds', async () => {
    const ai = aiStub();
    const app = await load({ secrets: keys, ai });
    expect(await app.invoke('explain', candidates)).toEqual({
      status: 'ok',
      output: { note: STUB_AI_TEXT },
    });
    expect(ai.requests[0]).toMatchObject({
      op: 'generate',
      call: {
        connection: { provider: 'anthropic', apiKey: 'sk-ant-test' },
        baseUrl: 'https://api.anthropic.com/v1',
      },
    });
  });

  it('fails as the vendor did, with the wait it asked for', async () => {
    const limited = new AiCallFailure('the vendor answered 429: slow down', {
      kind: 'vendor',
      retryable: true,
      status: 429,
      retryAfterMs: 2000,
    });
    const app = await load({
      secrets: keys,
      ai: aiStub({
        generate: () => {
          throw limited;
        },
      }),
    });
    expect(await app.invoke('explain', candidates)).toEqual({
      status: 'error',
      message: 'the vendor answered 429: slow down',
      retryable: true,
      retryAfterMs: 2000,
    });
  });
});

aiStub() answers as stubGenerated and stubDecided do: STUB_AI_TEXT for text, a value of an object's or an array's schema (each field's const or first enum value, else stub text, 0, true or null), the first option, {} for JSON; P(true) 0.5, the first option and the first level for a decision, with distributions on a native engine; 10 input and 5 output tokens each. With aiStub({ generate, decide }) functions of your own answer, given the request as the host made it, and may call those two; one that throws an AiCallFailure (from @aletheia-dev/app-sdk/testing too) plays the vendor's failure, as above, and any other throw is a final vendor one. Without ai, every request answers models are not available to this call, as a webhook's does on the platform.

4. Build it and test it with the SDK's helpers ​

sh
npm run build   # aletheia-app build: dist/app.wasm and dist/app.json

The build bundles src/index.ts, compiles it, reads the manifest back from the module through the real host (a manifest the platform would refuse fails the build, and so does a field the platform does not know, named by its path: actions.screen.retry.jitter: unknown field), writes the manifest's memory size into the module as its maximum, and writes dist/app.json beside it: the name, version, SHA-256, size and manifest.

@aletheia-dev/app-sdk/testing runs the built module through @aletheia-dev/app-host, the same code the platform's runner uses, so a test exercises exactly what will be uploaded. The tests do not import the app's source: it runs inside the module, and the test talks to its exports the way the platform does.

ts
import { fileURLToPath } from 'node:url';
import {
  loadApp,
  vendorStub,
  type LoadAppOptions,
  type StubRequest,
  type StubResponse,
  type VendorStub,
} from '@aletheia-dev/app-sdk/testing';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';

const MODULE = fileURLToPath(new URL('../dist/app.wasm', import.meta.url));
/** The install's webhook URL, which the platform hands the module as `ctx.callbackUrl`. */
const CALLBACK_URL =
  'https://api.example/webhooks/app-installs/0f1e2d3c-4b5a-4697-8a8b-9c0d1e2f3a4b';
const secrets = { apiKey: 'test-key', webhookSecret: 'wh-secret' };

type Match = { name: string; score: number };

/** Plays the vendor: every endpoint answers with the same candidate list, or an outage. */
let vendor: (request: StubRequest) => StubResponse = () => ({ status: 503, body: 'down' });
const answers = (matches: Match[]) => {
  vendor = () => ({ status: 200, body: { matches } });
};

let stub: VendorStub;
beforeAll(async () => {
  stub = await vendorStub((request) => vendor(request));
});
afterAll(() => stub.close());

const load = (options: LoadAppOptions = {}) =>
  loadApp(MODULE, {
    config: { baseUrl: stub.origin, threshold: 0.9 },
    secrets,
    callbackUrl: CALLBACK_URL,
    ...options,
  });

const lastRequest = () => stub.requests.at(-1)!;

describe('screen', () => {
  it('applies the threshold to the vendor candidates and authenticates the call', async () => {
    const app = await load();
    answers([
      { name: 'Ivan Blocked', score: 0.95 },
      { name: 'Ivan B.', score: 0.6 },
    ]);
    expect(await app.invoke('screen', { name: 'Ivan Blocked' })).toEqual({
      status: 'ok',
      output: { hit: true, matches: [{ name: 'Ivan Blocked', score: 0.95 }] },
    });
    const request = lastRequest();
    expect(request.path).toBe('/v1/screen');
    // The module sent the placeholder ctx.secret('apiKey') wrote; the host put the value in.
    expect(request.headers.authorization).toBe('Bearer test-key');
  });

  it('fails on a vendor outage, retryably', async () => {
    const app = await load();
    vendor = () => ({ status: 503, body: 'down' });
    expect(await app.invoke('screen', { name: 'x' })).toEqual({
      status: 'error',
      message: 'acme-screening: /v1/screen failed with HTTP 503',
      retryable: true,
    });
  });
});

loadApp(module, options) takes the configuration, the secrets, the documents, the cached values, the models (ai, above), the tenant id and the callback URL a call runs with, and answers what the platform's runner would: app.invoke(action, input) resolves to { status: 'ok', output }, { status: 'pending', externalId, callbackTimeoutSeconds? } or { status: 'error', message, retryable?, retryAfterMs? }, and app.logs holds the lines the module logged: the host's own, such as the app http_request line of each request, go only to a logger you pass. Calls reach the hosts the platform would approve for that configuration, listed in app.hosts, and a configured URL the platform would refuse fails the call with the reason. vendorStub(handler) serves your handler on 127.0.0.1 and records the requests; while one runs, loaded apps may call it over http, which the platform otherwise refuses. Run npm run build && npm test.

5. Add an asynchronous action ​

Some vendors answer later. Declare the action with async: { callbackTimeoutSeconds }, start the job and return pending(externalId):

ts
    screenAsync: {
      description: 'Starts a screening job; the verdict arrives through a signed webhook.',
      input: ScreenInput,
      output: ScreenOutput,
      timeoutMs: 10_000,
      idempotent: true,
      async: { callbackTimeoutSeconds: 3600 },
    },
ts
import { pending } from '@aletheia-dev/app-sdk';

function screenAsync(input: ActionInput<Manifest, 'screenAsync'>, ctx: Ctx) {
  if (!ctx.callbackUrl) {
    throw new AppError(
      'acme-screening: the deployment exposes no webhook URL (WEBHOOK_PUBLIC_URL)',
    );
  }
  // Our own reference becomes the external id: the vendor echoes it in the webhook, and the
  // callback URL carries it so the platform can find the session before it verifies anything.
  const reference = ctx.idempotencyKey ?? crypto.randomUUID();
  const callbackUrl = `${ctx.callbackUrl}?externalId=${encodeURIComponent(reference)}`;
  callVendor(ctx, '/v1/jobs', {
    method: 'POST',
    body: { json: { ...input, reference, callbackUrl } },
  });
  ctx.log.info('acme-screening: job started', { externalId: reference });
  return pending(reference);
}

The platform records the session against the install, the run, the step and the version that started it, sets the run to waiting_callback and waits up to callbackTimeoutSeconds (at most seven days) for a webhook naming the external id (200 characters at most). Using ctx.idempotencyKey as the reference means a retried step finds the same job at the vendor. The id must be the call's own: an id another session of the install already holds, another step's, run's or round's, fails the step at once. If the worker stops after recording the session but before the run hears of it, the step runs again: the run keeps waiting on the session recorded first, whatever id the vendor gives the second request, and the worker logs the other one, which its invocation record keeps too.

A call can ask for a shorter wait than the action declares, for a vendor whose job takes a time the input or the vendor's answer says: pending(reference, { callbackTimeoutSeconds: 600 }). The run then waits the shorter of the two, never longer than the declared value. The wait is a whole number of seconds from 1 to 604,800; pending throws for any other, which fails the call.

ctx.callbackUrl is the install's own webhook URL: the deployment's WEBHOOK_PUBLIC_URL followed by /webhooks/app-installs/<installId>. The URL names the install, and so the tenant and the secrets the signature check needs; the external id then picks the session among that install's, before any code runs. It reaches the platform in one of two ways, and the example uses both:

  • the callback URL carries it: ctx.callbackUrl plus ?externalId=;
  • the manifest declares where the request carries it, webhookExternalId: a JSON Pointer into a JSON body ({ json: '/reference' }), a header ({ header: 'x-job-id' }) or a query parameter ({ query: 'job' }), or leaves that to each install's configuration (webhookExternalIdFromConfig, section 2). This is how a vendor that takes one webhook URL per account, configured in its dashboard, is served: one install per account, each registered with its own URL.

6. Handle the webhook ​

handleWebhook(request, ctx) receives the vendor's request (method, lower-cased headers, query, the raw body bytes, text() and json()) with the install's context. Check the signature over the raw body first, then read the event:

ts
import {
  ignored,
  rejected,
  type WebhookEvent,
  type WebhookRejected,
  type WebhookRequest,
} from '@aletheia-dev/app-sdk';

/** Header the vendor signs its webhooks into: hex HMAC-SHA256 of the raw body. */
export const SIGNATURE_HEADER = 'x-acme-screening-signature';

/** Body of the vendor's webhook for an asynchronous job. */
export const WebhookBody = z.object({
  id: z.string().min(1),
  /** Our reference for the job, echoed back: the external id the platform waits on. */
  reference: z.string().min(1),
  status: z.enum(['completed', 'failed']),
  result: VendorResult.optional(),
  error: z.string().optional(),
});

function handleWebhook(
  request: WebhookRequest,
  ctx: Ctx,
): WebhookEvent | WebhookRejected | typeof ignored {
  const signature = request.headers[SIGNATURE_HEADER];
  const valid =
    signature !== undefined &&
    ctx.hmacVerify({
      algorithm: 'sha256',
      key: { secret: 'webhookSecret' },
      data: { text: request.text() },
      signature,
    });
  if (!valid) return rejected('acme-screening: invalid webhook signature');
  let parsed: unknown;
  try {
    parsed = request.json();
  } catch {
    return rejected('acme-screening: webhook body is not JSON');
  }
  const body = WebhookBody.safeParse(parsed);
  if (!body.success) return rejected('acme-screening: unexpected webhook body');
  const { id, reference, status, result, error } = body.data;
  if (status === 'failed') {
    return {
      externalId: reference,
      eventId: id,
      status: 'failed',
      error: error ?? 'vendor failure',
    };
  }
  if (!result) return ignored; // a completion without a result is not something we can act on
  return {
    externalId: reference,
    eventId: id,
    status: 'completed',
    output: toResult(result, ctx.config),
  };
}

export default defineApp({
  manifest,
  actions: { screen, screenAsync, screenDocument },
  handleWebhook,
});

ctx.hmacVerify computes the HMAC on the host with the install's webhookSecret and compares it in constant time; the secret never enters the module. A vendor that hands out its secret in base64, as Standard Webhooks does (whsec_ and the base64), is verified with keyEncoding: 'base64', which decodes the stored text, whsec_ dropped, into the key's bytes. Return rejected(message) for anything you cannot trust (the API answers 401), ignored for a request that is not for you (202, nothing changes), or an event: externalId is the id you returned from pending(), eventId the vendor's event id (the API drops a second event with the same id for the same install), and status is completed, failed or pending (still in progress). A completed event's output becomes the step's output, so it must match the action's output schema, which the conformance check in section 9 verifies. A webhook runs on the version that started the session, so an upgrade in between changes nothing for it.

Read the event from what the vendor signed. The API refuses an event whose externalId is not the id it found the session by (401, nothing recorded), so, with the id read from the signed body, a request whose query or unsigned header points that body at another session completes none; an id read from the query or an unsigned header would let it. The catalogue's http-api reads the id only from what its signing schemes sign; in an app of your own, that is yours to keep. A vendor whose signed webhook names the session only in its URL, such as the ?externalId= of the callback URL you handed it, leaves nothing signed to read the id from: an app for it reads the id from the URL, and a replayed request can then complete another pending session of the install, within a timestamped signature's tolerance or, under one without a timestamp, at any time. Read eventId and status where the signature covers them as well: in the signed body, or a header the vendor signs, never the query. A vendor that signs no event id gets the default one, webhookEventId(ctx, externalId, request.body), a digest of the external id and the body through the host: a re-delivery of the same body counts once, and the same body for another session is another event (Event ids and what a signature covers).

In tests, signedWebhook({ body, secret, header }) builds the request the API would hand the module, signed with the hex HMAC-SHA256 of the JSON body:

ts
import { signedWebhook } from '@aletheia-dev/app-sdk/testing';

const webhook = (body: unknown, secret = secrets.webhookSecret) =>
  signedWebhook({ body, secret, header: SIGNATURE_HEADER });

it('verifies the signature and maps the vendor outcome', async () => {
  const app = await load();
  const completed = {
    id: 'evt-1',
    reference: 'run-1:step-2',
    status: 'completed',
    result: { matches: [{ name: 'Someone', score: 0.99 }] },
  };
  expect(await app.handleWebhook(webhook(completed))).toEqual({
    status: 'event',
    event: {
      externalId: 'run-1:step-2',
      eventId: 'evt-1',
      status: 'completed',
      output: { hit: true, matches: [{ name: 'Someone', score: 0.99 }] },
    },
  });
  expect(await app.handleWebhook(webhook(completed, 'wrong'))).toEqual({
    status: 'rejected',
    message: 'acme-screening: invalid webhook signature',
  });
});

7. Send a document ​

Document checks receive a document id in their input. The simplest way to send the document to a vendor is to name it as the request body, { document: id }: the host streams the bytes into the request with their content type, and they never enter the module.

ts
    screenDocument: {
      description: 'Screens the holder of an identity document the applicant uploaded.',
      input: z.object({ documentId: z.string().min(1) }),
      output: ScreenOutput,
      timeoutMs: 30_000,
    },
ts
/** The document goes as the body by its id: the host streams it, the bytes never enter the module. */
function screenDocument(input: ActionInput<Manifest, 'screenDocument'>, ctx: Ctx): ScreenResult {
  const body = callVendor(ctx, '/v1/documents/screen', {
    method: 'POST',
    body: { document: input.documentId },
  });
  ctx.log.info('acme-screening: document screened', { documentId: input.documentId });
  return toResult(VendorResult.parse(body), ctx.config);
}

A multipart form takes document parts the same way ({ name, filename?, document }). When the module itself has to look at the bytes, ctx.documents.read(id) answers { id, fileName, contentType, sizeBytes, bytes }. Either way the app must declare needs: ['documents'] (and an install with a documents switch must turn it on, section 2), only the tenant's own documents with status clean can be read, at most ten per call, and the bytes are the platform's processed copy, never the raw upload (Documents). A call of a run's step or an app rule reads its subject's documents only: another subject's answers as one that does not exist, so a document id run data names cannot reach another applicant's passport. Webhook and hand-off calls read none. In tests, pass the documents to loadApp:

ts
const bytes = new TextEncoder().encode('not really a PNG');
const app = await load({
  documents: { [DOCUMENT_ID]: { fileName: 'id.png', contentType: 'image/png', bytes } },
});

loadApp hands documents to invoke calls only, and only while the platform would: the manifest needs them and its switch, when it has one, is true in config. Give a document a subjectId and pass one to invoke (app.invoke('screenDocument', input, { subjectId })) to play a run's subject: a call that names another subject cannot read it.

8. Logging ​

ctx.log.debug|info|warn|error(message, fields?) goes to the runner's log, tagged with the tenant, the app and the call id, which your fields cannot override. Up to 200 lines per call are kept, each up to 2,000 characters. Log request ids, external ids and outcomes; secret values are removed from every line by the host, but personal data and vendor payloads are yours to keep out.

9. Check conformance ​

assertConformance(app, { samples }) runs the checks the platform applies: the manifest parses and its schemas are valid JSON Schema, an unknown action is refused, and for each sample the input validates, a synchronous action answers ok with an output that validates, and an asynchronous one answers pending with an id that the sample's webhook completes with a valid output. It throws one error listing every failed check; conformance returns the same result without throwing.

ts
import { assertConformance } from '@aletheia-dev/app-sdk/testing';

it('passes the checks the platform applies', async () => {
  const app = await load({
    documents: {
      [DOCUMENT_ID]: { fileName: 'id.png', contentType: 'image/png', bytes: new Uint8Array(8) },
    },
  });
  answers([{ name: 'Someone', score: 0.95 }]);
  await assertConformance(app, {
    samples: [
      { action: 'screen', input: { name: 'Someone', country: 'DE' } },
      {
        action: 'screenAsync',
        input: { name: 'Someone' },
        webhook: (externalId) =>
          webhook({
            id: 'evt-c',
            reference: externalId,
            status: 'completed',
            result: { matches: [{ name: 'Someone', score: 0.95 }] },
          }),
      },
      { action: 'screenDocument', input: { documentId: DOCUMENT_ID } },
    ],
  });
});

10. Upload, publish and install ​

An app reaches a tenant as an uploaded module. In the console: Connect › Apps › Upload app takes dist/app.wasm, shows the manifest the platform read from it (actions, secrets and the hosts it calls) and publishes it. Through the API, with an administrator's token (apps:publish):

bash
curl -s -X POST $API/apps/uploads -H "authorization: Bearer $ADMIN_PAT" \
  -H 'content-type: application/wasm' --data-binary @dist/app.wasm \
  | jq '.version | {id, name, version, status, hosts}'
# { "id": "6c1f...", "name": "acme-screening", "version": "0.1.0", "status": "uploaded", "hosts": ["api.acme-screening.example"] }
curl -s -X POST $API/apps/acme-screening/versions/6c1f.../publish -H "authorization: Bearer $ADMIN_PAT" \
  | jq '{status: .version.status, approval: .approval.id}'
# { "status": "published", "approval": null }

The upload checks that the file is a module the platform runs and reads its manifest without running anything else; the version stays uploaded, visible to the tenant's administrators only, until it is published. When the tenant requires approvals for apps, the publish answers 202 with an open request instead, and another administrator approves it in Operate › Approvals or with POST /apps/acme-screening/versions/:id/approve. On a deployment whose operator reviews the tenant's apps (appReview required on GET /tenants/me), the publish, once approved, answers 202 with the operator's pending review and the version stays uploaded until the operator approves it, which publishes it, or rejects it with a comment, after which the tenant can publish it again or upload a fix as a new version (Life of a version). A version number that sorts above the last one (0.1.1 after 0.1.0, a prerelease such as 0.2.0-rc.1 below 0.2.0) is what makes a new version the app's latest and offers it to the installs of the previous ones. On a deployment that asks who built its modules, attach the module's signature between the upload and the publish (Sign the module).

An install is the app put to use under a name of the tenant's own, with its configuration, its secrets and its webhook URL. The first is usually named after the app; a second account at the same vendor is a second install of the same app (acme-screening-eu), configured, keyed and called separately. POST /app-installs creates one, PUT /app-installs/:install configures or upgrades it (apps:write). A save lists in approveHosts the hosts it adds to the install's, which for a new install is every host it calls (409 hosts_not_approved otherwise, naming them). An install whose configuration requires secrets is created disabled, given its secrets, then enabled; a test call then runs one action with the stored configuration:

bash
curl -s -X POST $API/app-installs -H "authorization: Bearer $ADMIN_PAT" \
  -H 'content-type: application/json' \
  --data '{"name":"acme-screening","versionId":"6c1f...","enabled":false,"config":{"threshold":0.9},"approveHosts":["api.acme-screening.example"]}' \
  | jq -c '{hosts, missingSecrets, webhookUrl}'
# {"hosts":["api.acme-screening.example"],"missingSecrets":["apiKey","webhookSecret"],"webhookUrl":"https://api.example/webhooks/app-installs/9b2d..."}
curl -s -X POST $API/app-installs/acme-screening/secrets/apiKey -H "authorization: Bearer $ADMIN_PAT" \
  -H 'content-type: application/json' --data '{"value":"..."}'
curl -s -X POST $API/app-installs/acme-screening/secrets/webhookSecret -H "authorization: Bearer $ADMIN_PAT" \
  -H 'content-type: application/json' --data '{"value":"..."}'
curl -s -X PUT $API/app-installs/acme-screening -H "authorization: Bearer $ADMIN_PAT" \
  -H 'content-type: application/json' \
  --data '{"versionId":"6c1f...","enabled":true,"config":{"threshold":0.9}}' | jq -c '{enabled, missingSecrets}'
# {"enabled":true,"missingSecrets":[]}
curl -s -X POST $API/app-installs/acme-screening/test -H "authorization: Bearer $ADMIN_PAT" \
  -H 'content-type: application/json' --data '{"action":"screen","input":{"name":"Evil Corp"}}' | jq .status

From there a call_app step or an app rule calls an action by the install's name, building the input from literal values and paths into the run context (input mapping):

json
{
  "id": "screen",
  "type": "call_app",
  "app": "acme-screening",
  "action": "screen",
  "inputMapping": { "name": "submission.legalName", "country": "submission.country" },
  "outputKey": "sanctions",
  "next": "rules"
}

Register the install's webhook URL with the vendor: its webhookUrl, <WEBHOOK_PUBLIC_URL>/webhooks/app-installs/<install id>, which the install's page in the console shows. Each install has its own, so two accounts at one vendor never share one. A new release of the app is a new version: build, upload and publish it, then move each install with PUT /app-installs/:install (store a secret the new version requires before moving, and list the hosts it adds in approveHosts). Installs on the previous version keep running it until they move.

When the tenant requires approvals for apps, a save that adds hosts, a new install's included, answers 202 and waits for another administrator, who approves or rejects it in Operate › Approvals or on the install's page. The install keeps serving as it was meanwhile; a new one waits switched off, its secrets can be stored meanwhile, and the approval switches it on when they are (Saves that wait for an approval).

Sign the module ​

A deployment can record who built the modules it runs. Its operator sets APP_SIGNATURES (Environment variables): optional takes a Sigstore bundle with a version and shows who signed its module, from what source, and whether the operator trusts that signer; required publishes, installs and upgrades to a tenant's version only once a signer the operator trusts has signed it (409 provenance_required otherwise, before any approval or review opens). Aletheia verifies bundles and keeps none of your keys: sign with cosign or GitHub's artifact attestations.

Sign dist/app.wasm as the build wrote it. The build writes the module's memory maximum into it, so the upload stores those bytes as they are and a signature over them holds. A module from another toolchain whose maximum is not the one its manifest's memoryMiB names is rewritten by the upload, and a signature over the file answers 400 provenance_invalid with details.reason digest_mismatch: write the maximum before signing.

Keyless, the signature names the identity that signed (a GitHub workflow, an email) and is recorded in Sigstore's public transparency log. With a key, the operator pins its public key (cosign generate-key-pair makes an ECDSA P-256 pair; hand over cosign.pub):

bash
# Keyless: in a CI job with an OIDC token, or in a browser sign-in on a workstation
cosign sign-blob dist/app.wasm --bundle app.sigstore.json --yes
# With a key the deployment pins
cosign sign-blob dist/app.wasm --key cosign.key --bundle app.sigstore.json

The log entry and the timestamp cosign records with a signature, a key's included, are verified against the deployment's trusted roots (Sigstore's public instance unless the operator names others). A keyless signature from a GitHub workflow names its repository, commit and ref in its certificate, which the platform records as the module's source. GitHub's build provenance attestation does too, as an in-toto statement that names the module's SHA-256:

yaml
permissions:
  contents: read
  id-token: write
  attestations: write
steps:
  - uses: actions/checkout@v7
  - run: npm ci && npm run build
  - id: attest
    uses: actions/attest-build-provenance@v4
    with:
      subject-path: dist/app.wasm
  # The step's bundle-path output is the bundle's file. Later, from anywhere:
  # gh attestation download dist/app.wasm --repo acme/screening-app (one bundle per line)

An attestation from a private repository is signed by GitHub's own Sigstore instance: it verifies once the deployment's operator adds GitHub's trusted root (Operations).

Attach the bundle to the uploaded version with PUT /apps/:name/versions/:id/provenance (apps:publish), before publishing it. In the console, Upload app takes it once the module is uploaded, as Signature or attestation:

bash
curl -s -X PUT $API/apps/acme-screening/versions/6c1f.../provenance \
  -H "authorization: Bearer $ADMIN_PAT" -H 'content-type: application/json' \
  --data-binary @app.sigstore.json | jq -c '.provenance | {kind, signer, trusted, trustedSigner}'
# {"kind":"signature","signer":{"type":"keyless","identity":"https://github.com/acme/screening-app/.github/workflows/release.yml@refs/tags/v0.1.0","issuer":"https://token.actions.githubusercontent.com"},"trusted":true,"trustedSigner":"acme-release"}

The answer is the version with its provenance: the kind (signature or attestation), the signer, the source, the transparency log entry and when it was signed, which the platform verified, and whether the operator's policy trusts the signer (trusted, trustedSigner, the policy's name for it). A signature the operator does not trust is recorded all the same and shown as not trusted; trust is computed again wherever the version is shown, so it follows the operator's policy. A new bundle replaces the version's until a publish request or the operator's review of it opens; from then on, and once it is published or withdrawn, the provenance is fixed (409 provenance_locked). 400 provenance_invalid names why a bundle proves nothing of the module in details.reason: malformed, unsupported (another content or key type), digest_mismatch or signature; 404 signatures_off means the deployment takes no provenance.

11. Hand the applicant to the vendor ​

A screening needs nothing more from the applicant, so the finished app ends with section 10. Some checks do: a liveness check, or documents the vendor's own capture collects, which the applicant completes on the vendor's hosted page for the session. An app whose asynchronous action starts such a session hands the applicant over with a handoff handler. Given the session's external id and the address to send the applicant back to, it answers the page and when its link stops working, or null once the vendor needs nothing more from the applicant:

ts
import { type Handoff, type HandoffRequest } from '@aletheia-dev/app-sdk';

/** The vendor's link to its hosted page for one session. */
const VendorLink = z.object({
  /** The applicant did their part: the vendor now reviews and posts its webhook. */
  done: z.boolean(),
  url: z.url(),
  expiresAt: z.iso.datetime({ offset: true }),
});

function handoff(request: HandoffRequest, ctx: Ctx): Handoff | null {
  const session = encodeURIComponent(request.externalId);
  const body = callVendor(ctx, `/v1/sessions/${session}/link`, {
    method: 'POST',
    body: { json: { returnUrl: request.returnUrl } },
  });
  const link = VendorLink.parse(body);
  if (link.done) return null;
  return { url: link.url, expiresAt: link.expiresAt };
}

export default defineApp({ manifest, actions: { verify }, handleWebhook, handoff });

With a handoff, the build declares handles.handoff in the manifest. The page must be https on one of the install's hosts, which the platform checks before it sends anyone there (a page anywhere else fails the hand-off with handoff_failed): add the vendor's page host to hosts (verify.acme-screening.example beside the API's), or take it from a config URL with hostsFromConfig. The handler runs with the install's configuration, secrets and hosts under a 15 s deadline.

After the submit, while the applicant's run waits for a session of an app that hands off, the collection terminal asks the platform for the page (GET /collection-submissions/:id/handoff). When your handler answers one, it shows Continue to verification, which sends the applicant there: in the same tab, or in a new one when the form is embedded in a merchant's page (Embedding). When it answers null, the terminal goes straight on to the progress screens. The vendor sends them back to returnUrl, their page of the submission in the terminal, which waits for the vendor's webhook (section 6) and then shows the outcome. An expired link is asked for again, so keep links short-lived; the platform never stores one and audits only its host (Hand-off to the applicant).

In tests, app.handoff(externalId, returnUrl?) runs the export as the platform does and throws when the page is one the platform would refuse:

ts
describe('handoff', () => {
  it("answers the vendor's page for the session, made for the way back", async () => {
    const app = await load();
    const page = { url: `${stub.origin}/s/job-1`, expiresAt: '2030-01-01T00:00:00Z' };
    vendor = () => ({ status: 200, body: { done: false, ...page } });
    const returnUrl = 'https://collect.example/flow/5b0e1c2d';
    expect(await app.handoff('job-1', returnUrl)).toEqual({ status: 'ok', handoff: page });
    expect(JSON.parse(lastRequest().body.toString())).toEqual({ returnUrl });
    vendor = () => ({ status: 200, body: { done: true, ...page } });
    expect(await app.handoff('job-1')).toEqual({ status: 'ok', handoff: null });
  });
});

returnUrl defaults to TEST_RETURN_URL. With the vendor stub running, its http://127.0.0.1:<port> pages pass, as http calls to it do.

Released under the Apache-2.0 License.