Appearance
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.
The contract itself (manifest fields, envelopes, limits, routes, error codes) is in Apps: reference; the functions a module calls are in Apps: host interface; the apps every deployment ships are in the App catalogue.
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/nodeSet "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, crypto.getRandomValues and crypto.randomUUID exist. 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 },
},
},
});nameis the key the API, the console,call_appsteps andapprules use: lower case, digits and dashes, one path segment (approvals,healthanduploadsare taken by the API's own paths).version(1.2.0) names one immutable module: any change to the app, code included, needs a new version.configSchemadescribes 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.secretsare names, with an optional description the console shows. A tenant stores a value for each before the install can be enabled; your code never reads one (section 3).hostsare the only hostnames the install may send requests to (*.vendor.examplecovers the subdomains ofvendor.example, notvendor.exampleitself). The console shows them to the administrator who installs the app, and the host refuses every other one, which is why abaseUrlin the configuration cannot point the app elsewhere.needs: ['documents']lets calls read the tenant's documents (section 7); without it the platform hands the call no document access.webhookExternalIdtells the API where a vendor's webhook carries the job's id (section 5).category,vendor,docsUrlandpricingNotedescribe the app in the console's catalogue;capabilitiesare free-form tags (sanctions.screen,document.verify).- Per action:
timeoutMsbounds one attempt, the whole call included (100 ms to 60 s, 15 s by default);retry(maxAttempts1 to 5 including the first,backoffMsmultiplied by the attempt number) is applied only when it is safe, whichidempotent: truedeclares or the caller's idempotency key implies. Workflow steps and rules always pass a key.
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 withconfigSchema.ctx.http(request): one HTTP request through the host, answered at once (the module waits while the host does the work).bodyis a string,{ json },{ base64 }, a document or a multipart form; the answer hasstatus, lower-casedheaders,okandjson().ctx.secret(name): the placeholder{{secret:apiKey}}, which the host replaces with the install's value in the URL, a header or a body it sends.ctx.hmacandctx.hmacVerify: signatures computed by the host, keyed by a secret's name.ctx.documents.read(id),ctx.log,ctx.tenantId,ctx.callId, and for asynchronous workctx.callbackUrlandctx.idempotencyKey.
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. A handler may be async.
Throw AppError with retryable: true for a failure another attempt may fix (a vendor outage, a rate limit). Any other throw, and any request the host refuses (a host the install did not approve, a secret without a value, a limit reached), fails the call without a retry.
4. Build it and test it with the SDK's helpers
sh
npm run build # aletheia-app build: dist/app.wasm and dist/app.jsonThe 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), 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));
const CALLBACK_URL = 'https://api.example/webhooks/apps/acme-screening';
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 `{{secret:apiKey}}`; 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 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 } or { status: 'error', message, retryable? }, and app.logs holds every line the module logged. 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 install 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 tenant, 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 webhook has to name its tenant before any code runs, because the tenant's secrets are what the signature check needs. Two ways, and the example uses both:
- the callback URL carries the id:
ctx.callbackUrlplus?externalId=. For a tenant's own appctx.callbackUrlis the deployment'sWEBHOOK_PUBLIC_URLfollowed by/webhooks/apps/acme-screening/tenants/<tenantId>; a platform app's has no/tenants/…part; - 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' }). This is how a vendor that takes one webhook URL per account, configured in its dashboard, is served.
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. 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), 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.
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'], 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). 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 } },
});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.
Installing is one route for install, configuration and upgrade. An app with secrets is installed disabled, given its secrets, then enabled (apps:write); a test call then runs one action with the stored configuration:
bash
curl -s -X PUT $API/apps/acme-screening/install -H "authorization: Bearer $ADMIN_PAT" \
-H 'content-type: application/json' \
--data '{"versionId":"6c1f...","enabled":false,"config":{"threshold":0.9}}' | jq -c '.missingSecrets'
# ["apiKey","webhookSecret"]
curl -s -X POST $API/apps/acme-screening/secrets/apiKey -H "authorization: Bearer $ADMIN_PAT" \
-H 'content-type: application/json' --data '{"value":"..."}'
curl -s -X POST $API/apps/acme-screening/secrets/webhookSecret -H "authorization: Bearer $ADMIN_PAT" \
-H 'content-type: application/json' --data '{"value":"..."}'
curl -s -X PUT $API/apps/acme-screening/install -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/apps/acme-screening/test -H "authorization: Bearer $ADMIN_PAT" \
-H 'content-type: application/json' --data '{"action":"screen","input":{"name":"Evil Corp"}}' | jq .statusFrom 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 app's webhook address with the vendor: <WEBHOOK_PUBLIC_URL>/webhooks/apps/acme-screening/tenants/<tenantId>, which the app's page in the console shows as its inbound webhook endpoint. A new release of the app is a new version: build, upload and publish it, then move the install with the same PUT (store a secret the new version adds before moving, and approve the hosts it adds in the console). Installs on the previous version keep running it until they move.