Skip to content

Apps: host interface ​

What a module may import and how each import behaves: the seven host functions, their requests, answers and limits, and what the host does around them. The TypeScript SDK wraps all of it (ctx.http, ctx.hmac, ctx.hmacVerify, ctx.documents.read, ctx.log, ctx.cache, ctx.ai, ctx.secret), so this page is for authors who write a module in another language through one of Extism's plug-in development kits (Rust and Go among them), and for anyone who wants to know what the SDK does underneath. The contract is version 2 (interface: 2 in the manifest), the only one the platform runs: a module of another version is refused with manifest: interface: expected 2, …, by aletheia-app build, by the upload (400 validation_error whose details.code is invalid_module) and by a runner that loads it (the call fails with invalid_module). Its schemas are packages/core/src/app.ts, for the envelopes app-exports.ts, for the requests that reach a vendor app-http.ts and their auth app-http-auth.ts, for the placeholders that name a secret app-secret-placeholders.ts, for the install's cached values app-cache.ts, for the models' requests app-ai.ts, their answers app-ai-answers.ts and the schemas an answer is held to app-ai-schema.ts, and for an approved host app-host-pattern.ts. The catalogue's apps are ordinary modules on this interface, with no host function of their own: each call of http-api is one http_request (App catalogue), and a model app asks its model with one ai request a call (AI models).

Imports and exports ​

A module imports the host functions it uses from the namespace extism:host/user, the one every Extism PDK can import from. Besides them it may import Extism's own kernel (extism:host/env, which the PDKs use for memory and input and output) and WASI preview 1 for its clock and random numbers. Nothing else: no WASI socket (sock_*) or path (path_*) function, so no file system and no socket, and Extism's built-in HTTP is switched off, so a PDK's own HTTP client reaches nothing. The upload refuses a module that imports anything else. A module may import any of these functions and leave the others out: one that imports neither cache nor ai runs as any other, under the same interface version.

Random numbers come from WASI random_get (crypto.getRandomValues in the JavaScript PDK), which the host answers afresh in every call. A generator whose state lives in the module answers the same numbers in every call, since each call starts from the module as it was built: the JavaScript PDK snapshots its runtime at the build, Math.random's state included. The SDK's defineApp replaces Math.random with one drawn from crypto.getRandomValues; a module built without the SDK (the Rust or Go PDK, the bare JavaScript PDK) draws from random_get or crypto itself.

Each host function takes the offset of a block in Extism's memory that holds a JSON request and returns the offset of a block that holds the JSON answer. A function that cannot do what was asked answers { "error": "…" }: http_request adds network for a request that failed in transit (below), and ai the failure's kind and whether another attempt may pass, retryable (below). The module decides what to do with it: the SDK throws an AppError, an HttpNetworkError for a failure in transit, neither retried, and an AiError for a model request, retryable when the host says so.

Every function reads its request strictly. A request that is not JSON answers request is not JSON, and one outside the contract answers invalid request: followed by each problem as path: message. A field the host does not know, at any depth, is refused rather than read without it, and the request is not carried out. The error names the field by its path and says why it may be there:

json
{
  "error": "invalid request: auth.scheme: unknown field (the app may be built with a newer SDK than this platform runs)"
}

The objects whose names are the module's own take any name: headers, a { form } body's names, a JWT's payload and jwtHeaders, a log line's fields, and in an ai request a connection's headers and queryParams, a decision's JSON state and an output's JSON Schema. The manifest export's answer is read the same way where a module is checked, at the upload and in aletheia-app build: a manifest field the platform does not know refuses the module (manifest: handles.other: unknown field (…)). A published version's manifest is read without such fields when its module runs, so it is checked once, when it is uploaded.

The exports take their envelope as the call's input and write their answer as its output (see Envelopes); each returns 0, the failures being in the answer. This is the interface file the SDK's build hands the JavaScript PDK:

ts
declare module 'main' {
  export function manifest(): I32;
  export function invoke(): I32;
  export function handle_webhook(): I32;
  export function handoff(): I32;
}

declare module 'extism:host' {
  interface user {
    http_request(ptr: I64): I64;
    hmac(ptr: I64): I64;
    hmac_verify(ptr: I64): I64;
    document_read(ptr: I64): I64;
    log(ptr: I64): I64;
    cache(ptr: I64): I64;
    ai(ptr: I64): I64;
  }
}

The module defines its own memory. The platform writes the manifest's memoryMiB (16 pages of 64 KiB per MiB) into the module as its memory's maximum when the module is uploaded, which is the cap the engine enforces; a module that imports its memory is refused, and the runner refuses one whose memory has no maximum. A module whose maximum is that already, as the SDK's build writes it, is stored byte for byte, so a signature over the built file holds (Apps: authoring); any other is rewritten by the upload, after which a signature over the file built no longer matches it: set the maximum before signing.

Every function is bound to one call: its tenant, the install's configuration, the secrets that configuration requires and the install's approved hosts, the call's secret tag (Secrets), the call's limits, the install's cached values (cache), for an action's call the runner's models (ai) and, for a call whose install reads documents, the tenant's documents (document_read). Nothing a module passes can change the binding. During the manifest export every function refuses.

Secrets ​

A module never holds a secret's value. It names a secret of the install with a placeholder that carries the call's secret tag, {{secret:NAME:TAG}}, wherever a request carries it, and the host replaces the placeholder on the way out, in the form the place needs:

  • in the URL, percent-encoded: a value holding &, /, # or @ stays one piece of text and cannot change the URL's host, path or query. A secret therefore cannot hold a URL's structure, and a whole base URL kept in a secret is refused (URL is not valid);
  • in header values, as it is;
  • in a string body as it is, inside the strings of a { json } body (keys included) before the body is serialised, so a value holding " or \ is escaped like any text, inside the strings of a { jsonText } body escaped for a JSON string, in the names and values of a { form } body before they are encoded, and in the values of multipart parts;
  • in the string fields of auth, which the host then builds (Authenticating a request);
  • in the prefix and suffix of a signature;
  • as the whole of a credential of an ai request's connection, which the host hands the model's SDK (Connecting to a vendor).

The host draws a tag for every call of an export, a retry included: 16 random bytes in base64url, 22 characters such as w3Kp9xV2mQ8rT1zL5nB7cA. The module finds it in its envelope as call.secretTag (Envelopes), which the host sets over anything a caller sent. The SDK's ctx.secret(name) writes the placeholder; a module built without the SDK writes it from the envelope:

js
const apiKey = `{{secret:apiKey:${envelope.call.secretTag}}}`;

An install's configuration names a secret without a tag, {{secret:NAME}} (Hosts and secrets of an install). Before the module runs, the host writes each such reference in a string of the envelope's config as the call's placeholder (keys stay as they are), so a module that sends text of its configuration sends the secret.

The host fills only the placeholders that carry the call's tag. A placeholder without the tag, or with another call's, is text and goes out as it is wherever it stands. Text the module did not write with the tag therefore cannot name a secret, whatever it holds: run data such as an applicant named {{secret:apiKey}}, a webhook's body, a hand-off's session id, a vendor's answer. Run data and webhooks are written before the call draws its tag, and the tag never reaches a vendor. A placeholder with the tag whose secret the call does not carry fails the request (secret NAME is not set for this install); text without the tag never does.

The tag stays in the host. A request that holds it where the host fills nothing is refused before anything is sent, so no vendor can learn it and write a placeholder the call would fill: in a header's name, a base64 body, a multipart part's name, file name or content type, a { jsonText } body outside a string, outside a placeholder in the URL, a header's value, the body or the auth, and anywhere in an ai request but its connection's credentials. The error names the place, never the tag:

json
{ "error": "a secret placeholder stands where the host does not fill it: a header name" }

The tag may stand in a signature's prefix and suffix, which are hashed and never sent. Out of the call it reads [tag]: the host redacts it from the module's log lines and answers like a secret (below), so a placeholder the module echoes shows as {{secret:apiKey:[tag]}}, which fills nothing.

A base64 body and document bodies are sent as they are. A call carries the secrets the install's configuration requires (Hosts and secrets of an install) and no others, and does not start while one of them has no value: a placeholder for any other secret fails the request. hmac, hmac_verify and sign take a key by name instead, { "secret": "webhookSecret" }, and an ai connection takes its credentials by placeholder or by name.

The host also keeps the values out of everything that leaves a call. Each call has a redaction list that starts with the call's secret values and its tag. While the call runs, the host adds every value it derives from them, before it sends it: the whole authorization value an auth sets, a Basic credential in base64, a digest response, a signed JWT, an AWS, Hawk or EdgeGrid signature, the credential headers a model's SDK sends for an ai request. An auth's credential fields join it too, so a credential the module wrote as text is hidden like a secret, and so does every cached value the call reads or sets (cache). The list hides each value in the forms it travels in: as it is, escaped inside a JSON string, URL-encoded, form-encoded and in base64. It does so in one pass that tries the longest form first. Through it go the error answers of the host functions, every log line, the module's own answer (as text, then string by string once parsed, so a value a vendor echoed back never reaches an invocation record), the message of a module that traps and the runner's own failure messages. A secret reads [secret:NAME], a derived value [auth:TYPE] ([auth:basic], [auth:jwt], [auth:ai]), a cached value [cache:NAME] and the tag [tag]. A value keeps the first marker it got, so a cached value equal to a secret reads [secret:NAME]. None is a placeholder, so redacted output that a later step passes on as input is text wherever it goes. Values shorter than four characters are not searched for, and their encoded forms only from eight characters.

http_request ​

One HTTP request, sent by the host while the module waits.

json
{
  "method": "POST",
  "url": "https://api.acme-screening.example/v1/screen",
  "auth": { "authType": "bearer", "token": "{{secret:apiKey:w3Kp9xV2mQ8rT1zL5nB7cA}}" },
  "body": { "json": { "name": "Evil Corp" } },
  "timeoutMs": 10000,
  "responseAs": "text"
}
FieldDefaultNotes
methodGETGET, POST, PUT, PATCH, DELETE or HEAD.
url4,096 characters at most, secret placeholders allowed (percent-encoded where they land).
headers{}Names are lower-cased; values may hold placeholders.
bodynoneA string, { json }, { jsonText }, { form }, { base64 }, { document: id } or { multipart: [...] } (below).
authnoneThe vendor's authentication, which the host builds from the install's secrets (below).
signnoneAn HMAC the host computes over the final request and puts in a header (below).
timeoutMs10000100 to 30,000 ms, within the call's own deadline.
responseAstexttext answers bodyText (UTF-8), base64 answers bodyBase64 (for binary bodies).

Any other field is refused, here and in the objects below: a body, a multipart part, an auth of each type, sign and its key (Imports and exports).

The answer is { status, headers, bodyText } or { status, headers, bodyBase64 }, header names lower-cased and repeated headers joined by a comma and a space. Any status is an answer; only a request that could not be completed is an { error }. With a digest auth the answer is the last request's: the first one's when the vendor did not challenge, otherwise the second's, or the third's after a stale nonce.

When sending failed after the host handed the request to the network, the error answer says how far the request got in network:

json
{ "error": "getaddrinfo ENOTFOUND api.vendor.example", "network": "not_sent" }
networkWhenSending it again
not_sentThe connection was never made: the name did not resolve (ENOTFOUND, EAI_AGAIN), the connection was refused (ECONNREFUSED), the host or network was unreachable, the connect timed out, the TLS handshake failed. No byte of the request left the host.Safe for any request: the vendor cannot have it.
unknownThe request may have arrived: the connection was reset or closed before the response ended, or no answer came within timeoutMs once connected. A request sent on a kept-alive connection counts as connected.Safe only for a request the vendor treats as idempotent.

A request the host refuses never reached the network and answers without network: a URL, host or header it does not allow, a name that resolves to a private address (refused where a DNS error would come), a request or response over its limit, an auth it cannot build, a Digest challenge it cannot answer, and the call having ended. With digest, the request whose sending failed is the one network describes. In one case an answer that came is an error with network: unknown: a hawk answer whose server-authorization does not verify, since the vendor got the request (Authenticating a request).

The rules the host applies before it sends anything:

  • The URL is https and carries no user name or password; http is accepted only on a runner that allows private networks (APP_RUNNER_ALLOW_PRIVATE_NETWORKS, for development stacks).
  • Its hostname, lower-cased, and its port must be one of the hosts the install approved: the manifest's hosts and the hosts of the URLs its configuration names at hostsFromConfig (Hosts). api.vendor.example matches itself only, *.vendor.example matches one or more labels before .vendor.example but not vendor.example itself. A host without a port is reached at the scheme's default port only (443), so with api.vendor.example approved, https://api.vendor.example:8443/ answers port 8443 of api.vendor.example is not in the hosts the install approved; api.vendor.example:8443 is reached at 8443 only. A port browsers refuse (the Fetch standard's bad ports, such as 22, 25 or 6667) is refused whatever the hosts say. On a runner that allows private networks any port of an approved hostname passes. An IP address as the host is refused unless private networks are allowed.
  • The URL's path, once its placeholders are filled, has no . or .. segment, which the URL would resolve before it is sent: /v1/../admin, /v1/%2e%2e/admin and a secret stored as .. in a segment are refused (URL must not have a . or .. segment in its path), and so is a segment that holds one between slashes once decoded (..%2Fadmin), as a vendor that decodes %2F would read it. Dots in a segment (report..pdf) and in the query are text.
  • The host resolves the name once and refuses a private, loopback or link-local address, and a NAT64 (64:ff9b::/96 and the local-use 64:ff9b:1::/48) or 6to4 (2002::/16) one, which carries an IPv4 address a gateway would reach for it. It connects to the address it checked, so the name cannot change between the check and the connection. Redirects are not followed.
  • A connection stays open for the same tenant's next request to the same host for a few seconds (APP_RUNNER_KEEPALIVE_MS, 4 s), never for another tenant's: a kept connection stays on the address checked when it opened, and each new one resolves and checks the name again. A GET, HEAD, PUT or DELETE that meets a kept connection the vendor closed while it was idle is sent once more on a new connection; any other request answers network: unknown.
  • A request that sets host, content-length, transfer-encoding, connection, expect or upgrade, or whose auth would, is refused; the host sets content-length itself, user-agent to Aletheia-Apps/1 unless the request sets one, and content-type from the body when the request sets none.
  • Nothing is overwritten: a header that both the request and its auth set, a query parameter that the auth adds and the URL has already, and a header of the request or its auth that sign writes are refused.
  • The call's secret tag where the host fills no placeholder is refused (Secrets).
  • At most 25 requests per call, every request a digest auth sends included; a response body over 8 MiB fails the request.

Bodies:

BodySent as
"text"the string, placeholders replaced
{ "json": value }the value as JSON, placeholders replaced inside its strings and keys before it is serialised, content-type: application/json
{ "jsonText": "…" }the JSON text byte for byte, placeholders replaced inside its strings and keys (below), content-type: application/json
{ "form": { name: value } }application/x-www-form-urlencoded with up to 100 fields, placeholders replaced in the names and values before they are encoded
{ "form": [[name, value], …] }the same from [name, value] pairs, sent in their order, a name repeated as often as given (a=1&a=2)
{ "base64": "…" }the decoded bytes
{ "document": "<id>" }one of the tenant's clean documents, with its content type, streamed by the host
{ "multipart": [part, …] }multipart/form-data with up to 20 parts: { name, value, contentType? } or { name, filename?, document } (the document's own file name and content type by default)

A multipart text part's contentType is a media type, its parameters allowed (text/plain; charset=utf-8), without a control character: anything else is refused before anything is sent (invalid request: body.multipart.0.contentType: expected a media type like text/plain, without control characters), so no line break in it adds a header to the part. The host sets the content-type of a multipart body, multipart/form-data with its boundary, and of a document body, the document's type, unless the request sets one: a request that names its own sends that one instead. Without the boundary a vendor cannot read a multipart body, so leave the header out there.

A { jsonText } body is for JSON whose bytes matter, such as a number larger than a double holds: the host sends the text as the module wrote it and fills a placeholder only inside a string, a key or a value, escaping the value for a JSON string, so a secret holding " or \ keeps the text valid. A placeholder outside a string is refused (Secrets), and so is text that does not parse as JSON (body: jsonText is not JSON).

A document body or part counts as a document read and is read like one (document_read): only by a call whose install reads documents, and only a document the call's token allows. Its bytes never enter the module.

Authenticating a request: auth ​

A vendor may want a credential the module must not see, or a signature over the request. With auth, the host builds it: the module names the install's secrets in the auth's fields with placeholders, and the host fills them, builds the auth over the request it is about to send and adds what it built. The module never sees a secret, a key, a signature or a token:

json
{
  "method": "GET",
  "url": "https://api.vendor.example/v1/accounts/me",
  "auth": {
    "authType": "basic",
    "username": "acme",
    "password": "{{secret:password:w3Kp9xV2mQ8rT1zL5nB7cA}}"
  }
}

auth has the shape of Hoppscotch's auth object, its authType and its field names, so a request exported from Hoppscotch carries over; a JWT's payload and jwtHeaders are JSON objects here rather than text. Every string field may hold the call's placeholders. addTo is HEADERS (the default) or QUERY_PARAMS. By authType, the fields and what the host sends:

  • none: the request as it is.
  • basic (username, password): authorization: Basic base64(username:password). A username holding : is refused.
  • bearer (token): authorization: Bearer <token>.
  • api-key (key, value, addTo): the header key: value, or the query parameter key=value.
  • digest (username, password): the request, then, when the answer is a 401 whose www-authenticate holds a Digest challenge, the same request with authorization: Digest … computed from the challenge (RFC 7616: MD5, SHA-256 and SHA-512-256 and their -sess forms, with qop auth or auth-int, or none for an RFC 2069 server). The host answers the first challenge it can, as a vendor lists them most preferred first, with qop auth when it is offered and auth-int (a hash of the body bytes as sent) when only that is; it echoes opaque, sends the username hashed when the challenge sets userhash and as username* (RFC 8187) when it is not printable ASCII. The second request carries back the cookies the 401 set, after the request's own cookie and without a name it sends already: each value as the vendor wrote it, unless the cookie has expired (Max-Age, which wins, or Expires; Max-Age=0 deletes it) or its Path does not match the request's. When the second request is answered 401 with a Digest challenge that says stale=true and gives a new nonce, the host answers it once more (a fresh cnonce, nc=00000001) with the cookies of both 401s; a second stale nonce is the answer. Any other first answer is the answer; a Digest challenge the host cannot answer is an { error } with the reason (digest auth: the vendor's challenge cannot be answered: …). A request that sets authorization itself, or whose sign writes it, is refused before anything is sent.
  • aws-signature (accessKey, secretKey, region, serviceName, serviceToken for a session token, addTo): AWS Signature Version 4 over the final request, made with aws4fetch. In the headers, authorization, x-amz-date and, with serviceToken, x-amz-security-token; for s3 also x-amz-content-sha256, the body's SHA-256, so S3 checks the bytes sent (a request that sets it keeps its value). Presigned, with QUERY_PARAMS, the X-Amz-Date, X-Amz-Security-Token, X-Amz-Algorithm, X-Amz-Credential, X-Amz-SignedHeaders and X-Amz-Signature query parameters, and for s3 X-Amz-Expires (a day unless the URL sets it) over an unsigned payload. content-type, user-agent and the other headers aws4fetch leaves out stay unsigned, which AWS accepts. A request that sets x-amz-date, or a URL that has an X-Amz-* parameter the signature adds, is refused. Write a space in the URL's query as %20 and a plus sign as %2B: aws4fetch reads a + there as a space when it signs.
  • hawk (authId, authKey, algorithm, includePayloadHash, ext, app, dlg, nonce, timestamp): authorization: Hawk …, a MAC (sha256 by default, or sha1) over the timestamp, a nonce, the method, the path and query, the host, the port (443 or 80 when the URL names none), with includePayloadHash the payload hash (over the media type, lower case and without its parameters, and the body bytes as sent, an empty body included), ext, and app and dlg when app is set; dlg without app is refused. authId, nonce, ext, app and dlg hold printable ASCII without " or \, what a Hawk server reads. nonce and timestamp (seconds) are for tests: the host makes a fresh nonce and reads its clock otherwise. When the answer carries server-authorization, the host checks it as a Hawk client does: its mac, over hawk.1.response with the request's timestamp, nonce, method, path and query, host, port, app and dlg and the header's own hash and ext, under authKey; and with hash, the payload hash of the answer's media type and body bytes. An answer that fails the check is an { error } with network: unknown, since the request reached the vendor: a header the host cannot read (hawk auth: the vendor's server-authorization cannot be read), a MAC that differs (hawk auth: the vendor's server-authorization does not verify) or a body whose payload hash differs (hawk auth: the vendor's answer does not match the hash in its server-authorization). An answer without the header is accepted, as Hawk makes it optional.
  • jwt (algorithm, secret or privateKey, isSecretBase64Encoded, payload, jwtHeaders, addTo, headerPrefix, paramName): a JSON Web Token of the claims in payload, signed with jose for each request under secret for HS256 (the default), HS384 and HS512 (base64-decoded first with isSecretBase64Encoded), or under the PEM privateKey for RS* and PS* (an RSA key of 2048 bits or more) and ES* (a key on the algorithm's curve). The claims go as given, except that iat, nbf and exp may be text relative to the signing time: "now", "5m", "-30s" (seconds, minutes, hours or days). jwtHeaders join alg in its header and may not set it. Sent as authorization: <headerPrefix><token> (by default Bearer and a space), or as the query parameter paramName (token by default). A PEM key is PKCS #8, or PKCS #1 for RSA and SEC 1 for EC; an encrypted key and a key typed for RSA-PSS only are refused, and a base64 secret may be standard or URL-safe. Its errors read jwt auth: ….
  • akamai-eg (accessToken, clientToken, clientSecret, host, headersToSign, maxBodySize, nonce, timestamp): Akamai EdgeGrid, as Akamai's own clients sign, checked against their published test vectors. The host sends authorization as EG1-HMAC-SHA256 client_token=…;access_token=…;timestamp=…;nonce=…;signature=…, where the signature is an HMAC-SHA256, in base64, under a key derived from clientSecret and the timestamp (yyyyMMddTHH:mm:ss+0000, UTC), over the method, the scheme, the host (with a port that is not the default), the path and the query as sent, the headers headersToSign names (comma-separated, signed in that order when the request sends them, each value trimmed and its inner white space one space) and, for a POST, the SHA-256 of the first maxBodySize bytes of the body (131,072 by default, which must match the API client's max_body at Akamai). host, the API client's host, is optional; one that is not the URL's host is refused, since the signature would cover a host the request never reaches. headersToSign cannot name authorization or a header the host sets (user-agent, content-length, host and the other reserved ones), nor the header of the request's sign, which the host sets after EdgeGrid has signed. nonce and timestamp are for tests: the host makes a fresh UUID and reads its clock otherwise. A token or nonce holding a space, ; or a character beyond printable ASCII is refused. The three credentials are redacted whether they are placeholders or text, and the signing key and the signature read [auth:akamai-eg]. Its errors read akamai-eg auth: ….

How the host builds it:

  • Over the final request. The URL has its secrets filled and its host approved, the headers have their lower-cased names and the body's content-type, and the body is the exact bytes the host will send. The auth is built over that request, sign over the request with the auth's additions, and the host then adds user-agent and content-length, which neither covers.
  • Additions, never replacements. An auth's query parameters follow the URL's own query, which stays as it was, each name and value percent-encoded. A header the request sets already, a query parameter the URL has already, a header the host reserves and a name that is not a header name are refused before anything is sent: header authorization is set by both the request and its auth.
  • For each request. Signatures, timestamps and nonces are made for each request, so another attempt signs afresh within the vendor's replay window; the runners' clocks must be kept in time (NTP). digest sends up to three requests, each counted against the call's 25.
  • Redacted. What the auth derives from a secret joins the call's redaction list before it is sent (Secrets) and reads [auth:TYPE], such as [auth:basic] or [auth:jwt].
  • Failures. An auth the host cannot build, such as a key that does not parse, answers an { error } like any refused request, and nothing more is sent.

The catalogue's http-api passes its install's Hoppscotch auth object on as this auth, with each credential field the call's placeholder of one of the install's secret variables and a JWT's payload and jwtHeaders parsed from their JSON text; its aletheia-hmac auth becomes a sign (App catalogue: Auth).

Signing a request: sign ​

Some vendors sign each request over its exact bytes with an HMAC of their own design, for instance an HMAC-SHA256 over a timestamp, the method, the path and the body. With sign, the host computes that signature itself over the request it is about to send, after its auth and with a multipart or document body included, so the body never has to enter the module:

json
{
  "headers": { "x-acme-timestamp": "1760000000" },
  "sign": {
    "header": "x-acme-signature",
    "algorithm": "sha256",
    "key": { "secret": "signingKey" },
    "keyEncoding": "utf8",
    "encoding": "hex",
    "prefix": "1760000000{{request:method}}{{request:target}}"
  }
}

The signature is the HMAC (sha1, sha256 or sha512) under the key of prefix + the encoded body + suffix, both optional, in hex (the default) or base64, set as header. The key is a secret of the install by name, or { "text": "…" }. Its text becomes the key's bytes as hmac reads a key (below): as UTF-8 (keyEncoding: "utf8", the default), or decoded from base64 or hex for a vendor that hands out a binary key that way; a key that does not decode is refused (sign: the key is not base64). In prefix and suffix the host fills, in one pass, so that a value it filled is never read again:

TextFilled with
{{secret:NAME:TAG}}the install's secret, when TAG is the call's (Secrets)
{{request:method}}the method, upper case
{{request:target}}the path and the query exactly as sent, an auth's query parameters included
{{request:host}}the host header: the hostname, and the port when it is not the scheme's default

Any other {{request:…}} name is refused when the request is parsed. What only the module knows, such as the timestamp, it writes itself, and sends in a header too when the vendor asks for one. A request whose headers or auth set header already is refused (sign: header x-acme-signature is set already). Each request is signed afresh, every request a digest auth sends included.

hmac ​

{ algorithm, key, keyEncoding, data, encoding } answers { "signature": "…" }: the HMAC of data ({ "text": "…" } as UTF-8, or { "base64": "…" }) under key ({ "secret": "name" } or { "text": "…" }) with sha1, sha256 or sha512, in hex (the default) or base64.

keyEncoding says how the key's text becomes its bytes, as for sign:

keyEncodingThe key's bytes
utf8 (default)the text as UTF-8
base64the text decoded from base64, standard or URL-safe, padded or not; a leading whsec_, the form a Standard Webhooks secret comes in, is dropped first
hexthe text decoded from hex, in either case

A key that does not decode answers { "error": "the key is not base64" } (or hex), never the key. A vendor that hands out a binary webhook secret in base64, as Standard Webhooks and Svix do (whsec_ followed by the base64), is stored as it comes and named with "keyEncoding": "base64".

hmac_verify ​

The same request with a signature to check answers { "valid": true | false }. The comparison takes constant time, a hex signature compares regardless of case, and a signature of another length is simply not valid. This is how a webhook's signature is checked under the install's webhook secret:

json
{
  "algorithm": "sha256",
  "key": { "secret": "webhookSecret" },
  "data": { "text": "{\"id\":\"evt-1\",\"reference\":\"run-1:step-2\"}" },
  "signature": "5d2f…"
}

A Standard Webhooks signature is the base64 HMAC-SHA256 of the message id, the timestamp and the body joined by ., under the decoded secret, so the module checks each v1, value of the webhook-signature header with { "key": { "secret": "webhookSecret" }, "keyEncoding": "base64", "data": { "text": "<id>.<timestamp>.<body>" }, "encoding": "base64", "signature": "…" }.

The event a webhook names ​

The API checks what handle_webhook answers against the request, for every app (Asynchronous actions and webhooks): an event whose externalId is not the id the API found the session by, before the module ran (?externalId=, or where the manifest or the install's configuration says), answers 401 webhook_rejected and records nothing. Read the id from what the vendor signed. Read the eventId, by which the API counts a re-delivery once, and the status where the signature covers them too (Event ids and what a signature covers). A vendor that signs no event id gets the default one, which a module computes through hmac:

json
{
  "algorithm": "sha256",
  "key": { "text": "aletheia-webhook-event-id" },
  "keyEncoding": "utf8",
  "data": { "base64": "<the external id as UTF-8, a 0x00 byte, then the raw body, in base64>" },
  "encoding": "hex"
}

The SDK's webhookEventId(ctx, externalId, request.body) sends exactly this request.

document_read ​

{ "id": "<document id>" } answers the document's metadata with its bytes in the module's memory:

json
{
  "id": "0b1a2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
  "fileName": "passport.png",
  "contentType": "image/png",
  "sizeBytes": 48211,
  "bytes": 1966080
}

bytes is the offset of a memory block holding the document, which the module reads and frees (Memory.find(bytes) in the JavaScript PDK); it is the answer's last key, so a module without a JSON parser can still find it. Only the tenant's own documents with status clean are readable, and the bytes are the platform's processed copy (type checked, images re-encoded), never the raw upload. At most 10 reads per call, document bodies of http_request included.

The host reads them through the API's document check with the call's token (Documents of a call), which the platform mints for an invoke only, and only when the install reads documents: its version declares needs: ['documents'] and, when the manifest names the install's documents switch (documentsFromConfig), the install's configuration holds true there. Every read of any other call answers { "error": "documents are not available to this call" }: a webhook's or a hand-off's, a call through an install whose switch is off, and every call on a deployment without object storage. The token names the call's subject when it has one, a run's step or an app rule, so such a call reads that subject's documents only; a test call from the install's page has no subject and gets a token only for a caller who may read every document of the tenant (documents:read). A document the token does not allow (another tenant's or another subject's), one that is not clean and one that does not exist answer the same error, document not available, so a module cannot tell them apart.

log ​

{ level, message, fields? } with level one of debug, info, warn and error, a message of 2,000 characters at most and optional JSON fields, answers {}. The line goes to the runner's log tagged with tenantId, appName, installName and callId, which override any field of the same name, so a line cannot claim another tenant or install. Message and fields are redacted. A call keeps 200 lines: the first line past the limit answers { "error": "log limit reached" }, later ones {}, and none is logged. A request outside the contract answers its invalid request error and logs nothing of the module's. A module's logger may drop that answer without a word (the SDK's does, since logging never fails a call), so the host logs the call's first such refusal itself, as a warning app log refused with the error and the call's tags, outside the 200.

cache ​

Text a module keeps between the calls of its install, such as a vendor's token or session, so it need not fetch it in every call. One operation per request, named by op:

RequestAnswer
{ "op": "get", "name": "token" }{ "value": "…", "expiresAt": "2026-10-11T09:00:00.000Z" }, or { "value": null } when there is no live value of that name (never set, expired, deleted)
{ "op": "set", "name": "token", "value": "…", "ttlSeconds": 3600 }{ "expiresAt": "…" }: the value is kept for ttlSeconds, a day at most (a longer time is cut to 86,400 s), in place of the one of that name
{ "op": "delete", "name": "token" }{ "deleted": true }, or false when the call held no live value of that name

A name follows a secret's rule: a letter, then up to 63 letters, digits or _. A value is text of at least 8 characters, since a shorter one is too common to redact, and of 8 KiB as UTF-8 at most. A request outside these answers its invalid request error.

The module holds the values: it reads one and writes it where the vendor wants it, such as an auth's token. Each value a call reads or sets joins the call's redaction list (Secrets) before the module gets the answer, so the call's answer, log lines and errors show it as [cache:NAME], whatever the module does with it.

How the values live:

  • Per install, sealed. The platform keeps them in its database, sealed and bound to the install like its secrets, and hands each call the live ones with its context. No other install or tenant sees them, and the API lists their names and expiries, never the values (Cached values of an install).
  • Stored when the call ends. A call sees what it sets at once. What it changed goes back with its answer, whatever the module answered (a token fetched before an error is kept), and is stored once the call has ended, for the install's later calls. A call that traps, runs past its deadline or answers outside the contract (bad_output) stores nothing.
  • Concurrent calls. Calls of one install may run at once; of two values set under one name, the one stored last stays. A delete removes the value the call started with, never one another call stored since: a call that drops a token its vendor refused does not drop the fresh one another call fetched meanwhile.
  • Cleared with what minted them. Storing or revoking a secret of the install, changing its configuration, moving it to another version, removing it, and a clear by an administrator remove its values. A call that read them before the clear stores nothing, so a token fetched with a replaced secret never lands.
  • Within a budget. An install keeps the deployment's budget, 16 values and 64 KiB by default (APP_CACHE_ENTRIES, APP_CACHE_TOTAL_BYTES); past it, the values written longest ago go when a call's changes are stored.

A call sets at most 64 values, a value set again counting again; the next set answers { "error": "cache limit reached: a call sets 64 values at most" }. A deployment without SECRET_STORE_KEY keeps no cached values: every operation answers { "error": "cached values are not available to this call" }, which a module that can work without them catches.

ai ​

A request to a language model, which the host sends while the module waits: an answer to a prompt (generate) or decisions on named questions about a state (decide). The module names the vendor's connection and the install's secret that holds its key; the host fills the key for the call alone, sends the request through the Vercel AI SDK in the runner to one of the install's hosts, and checks the answer against what was asked. The module never holds a key. Only an action's call (invoke) reaches models: in a webhook's or a hand-off's call, every request answers models are not available to this call.

json
{
  "op": "generate",
  "connection": { "provider": "anthropic", "apiKey": { "secret": "anthropicApiKey" } },
  "model": "claude-haiku-4-5",
  "instructions": "Answer in one sentence.",
  "prompt": "Why would one order be charged twice?",
  "maxOutputTokens": 1024,
  "timeoutMs": 30000
}
FieldDefaultNotes
opgenerate or decide.
connectionWhere the request goes and the keys it takes, by provider (below).
modelThe model as the vendor names it, an Azure deployment, or a Bedrock model or inference profile: 200 characters at most of letters, digits and ._:/@+-, without a . or .. segment.
timeoutMsas long as the call allows1,000 to 120,000 ms, within the call's own deadline (below).

generate adds:

FieldDefaultNotes
prompt, messagesOne of them: the prompt, or a conversation of 1 to 100 turns { role, content }, role user or assistant, text only.
instructionsnoneWhat the model is told before the prompt or the conversation (the system prompt).
outputtextHow the answer is shaped (below).
maxOutputTokensnone1 to 32,768.
temperature, topPnone0 to 2, and 0 to 1.
stopSequencesnoneUp to 4, each 100 characters at most.
seednoneAn integer, for a vendor that takes one.

output is { "type": "text" }, free text; object with a JSON schema, an object the schema describes; array with an element schema, between minItems and maxItems (1,000 at most); choice with 2 to 255 options, one of them as written (each once, 200 characters at most); or json, any JSON value, held to no schema. Each but text may give the model a name (a letter, then up to 63 letters, digits, _ or -) and a description (500 characters at most).

A schema keeps to what every vendor's structured output takes, OpenAI's strict mode the narrowest of them. An object output's schema describes an object. Every object lists each of its properties in required and sets additionalProperties: false, so a field the answer may leave out is one whose type admits null ("type": ["string", "null"]); every node names a type, an enum, a const or an anyOf, and an array one schema in items. $ref, $defs, definitions, pattern, patternProperties, oneOf, allOf, not, if, then, else, dependentRequired, dependentSchemas, unevaluatedProperties, unevaluatedItems, prefixItems and contains are refused. A schema is 32 KiB as JSON, 10 levels deep and 500 properties together at most, and compiles as a manifest's schemas do (JSON Schema at the boundary). A request whose schema breaks a rule is refused before anything is sent, each problem at its JSON Pointer: output.schema: /properties/name: pattern is not supported.

decide adds:

FieldDefaultNotes
stateWhat the questions are about: text, a JSON object, or 1 to 50 parts in order, { "type": "text", "text": "…" } or { "type": "json", "value": … }; 256 KiB at most as JSON. An array is a list of parts: a JSON array goes inside an object or a part.
questions1 to 32, each id once (below).
enginethe provider'snative, the vendor's decisions API, or adapter, a language model asked for structured output; without it, native for typesafe and adapter for the others.

A question is { id, type, instructions } and, by type: boolean, the probability it holds, with criteria ({ true, false }, each optional) that say what makes it hold or not; choice, one of 2 to 255 choices, each { value, description? } and each value once; score, a position on 2 to 10 ordered levels, from the lowest. Instructions, criteria, descriptions and levels are 4,000 characters each at most. An id and a choice's value are a letter, then up to 63 letters, digits, _ or -, and none of the names every object inherits (constructor, toString, valueOf and the like): each is a segment of the path that reads its answer (answers.fraud.probability). typesafe decides natively only, native is otherwise openai's alone (an Azure resource has no Decisions API), and an openai-compatible server decides only with supportsStructuredOutputs: a request outside these answers unsupported before anything is sent.

Connecting to a vendor: connection ​

providerFieldsWithout a baseUrl
anthropicapiKey, baseUrlhttps://api.anthropic.com/v1
openaiapiKey, baseUrl, organization, project, api (responses by default, or chat)https://api.openai.com/v1
azureapiKey, resourceName or baseUrl (one of them), apiVersion (v1 by default, or a dated one such as 2024-10-21), apihttps://<resourceName>.openai.azure.com/openai
google (Gemini)apiKey, baseUrlhttps://generativelanguage.googleapis.com/v1beta
typesafe (Jev)apiKey, baseUrlhttps://api.typesafe.ai/v1
bedrockregion, auth ({ type: 'api-key', apiKey } or { type: 'sigv4', accessKeyId, secretAccessKey, sessionToken? }), baseUrlhttps://bedrock-runtime.<region>.amazonaws.com, in amazonaws.com.cn for cn- and amazonaws.eu for eusc- regions
openai-compatiblename, baseUrl, apiKey (optional), apiKeyHeader, headers, queryParams, supportsStructuredOutputsnone: it needs one

Each credential, apiKey and Bedrock's auth.apiKey, auth.accessKeyId, auth.secretAccessKey and auth.sessionToken, names one of the install's secrets: the call's placeholder alone, as ctx.secret(name) writes it, or the secret by name, { "secret": "anthropicApiKey" }. Other text is refused (expected ctx.secret(name) or { secret: name }, never a key), so a key a module wrote out never leaves it. The host fills each for the call and hands the connection to the model's SDK, which sends the key as the vendor's API wants it, or signs the request with the keys of a sigv4 connection. A placeholder of another call, a secret the call does not carry and one whose value is blank are refused before anything is sent, named by their field and never their text: connection.apiKey: secret anthropicApiKey is not set for this install. The call's tag anywhere else in the request, a prompt, a header or the model, is refused too (the call's secret tag stands outside a connection's credentials), so no placeholder reaches a vendor.

A connection reaches its baseUrl (a proxy's, a gateway's, a server of the tenant's), an http(s) URL without credentials, a query or a fragment, 2,000 characters at most; without one, the vendor's own API in the table. Either way the URL's host is one of the install's hosts, checked as http_request checks one (below): the manifest's hosts name the vendors' APIs (api.anthropic.com), and a base URL from the install's configuration is approved with it through hostsFromConfig (Hosts). Elsewhere the request answers connection base URL: host llm.example is not in the hosts the install approved.

OpenAI's organization and project are visible ASCII, 200 characters at most, and api picks the Responses API, which OpenAI and Azure are asked with storing off (store: false), or Chat Completions. An Azure apiVersion of v1 reaches the resource's v1 API, a dated one the deployment's own path. An openai-compatible connection speaks OpenAI's Chat Completions to any server: name (lower case, such as openrouter) names it to the SDK; its key goes in authorization: Bearer, or as it is in the lower-case header apiKeyHeader, or nowhere without apiKey; headers and queryParams, 20 of each at most, go with every request, a header's value printable ASCII and no header one that carries a credential (authorization, proxy-authorization, cookie, api-key, x-api-key, x-goog-api-key, ocp-apim-subscription-key): a key goes in apiKey. supportsStructuredOutputs says the server holds a model to a JSON Schema (response_format: json_schema): decisions need it, and without it an object, an array or a choice is asked for as JSON (json_object), which the host then checks against what was asked.

The runner's SDK builds each provider for the call alone, from these fields and nothing else: it sends every request through the call's checked fetch (below), never retries one itself (the platform retries whole attempts), records no telemetry, downloads nothing, and reaches no model through the AI Gateway. When it starts, the runner clears the variables the providers would fall back to from its environment (OPENAI_API_KEY, ANTHROPIC_BASE_URL, AWS_REGION and the others) and logs their names, so no request goes out with a key or to an address of the deployment's.

Sending a request ​

Before anything is sent, the host:

  • counts the request against the call's 20 ai requests, a refused one too (model request limit of 20 per call reached);
  • reads it strictly, 1 MiB at most: a field it does not know, at any depth, is refused and named (Imports and exports);
  • refuses the call's tag outside the credentials, fills each credential and approves the base URL (above);
  • checks what the provider can do (a decision's engine) and an output's schema;
  • gives the request its time: its timeoutMs, two minutes without one, cut to what the call has left less a second it keeps for the module to answer. With less than a second to give, nothing is sent (the call has no time left for a model request, a retryable timeout); a vendor that does not answer within it fails the request the same way (the model did not answer in time).

Every HTTP request the model's SDK then makes goes out as http_request's do (above): to an approved host at its port, over https (http only where private networks are allowed), without credentials or a . or .. segment in its URL; on a connection the tenant's earlier requests kept alive, or on a new one whose name is resolved and checked; no redirect followed, a 3xx answered as it came. Each counts against the call's 10 requests to model vendors (model request limit of 10 per call reached), never its 25 HTTP requests. The host asks for gzip, deflate or brotli and decodes them, a response 8 MiB at most as it comes and once decoded, and runs none past the ai request's time. The values of the headers that carry a credential (authorization, x-api-key, api-key, x-goog-api-key, a connection's apiKeyHeader, SigV4's x-amz-security-token and the others) join the call's redaction list as [auth:ai] before they are sent.

Answers and failures ​

generate answers:

json
{
  "output": "The payment was retried after a timeout and both attempts went through.",
  "finishReason": "stop",
  "usage": { "inputTokens": 120, "outputTokens": 16 },
  "model": "claude-haiku-4-5-20251001",
  "warnings": []
}

output is the text, the object or array of the schema, the option chosen or the JSON value; finishReason is stop, length (out of tokens), content-filter (held back by the vendor), tool-calls, error or other; usage holds the tokens as the vendor counted them, without a count it did not give; model is the model the vendor says answered, and warnings what the vendor said without failing the request, such as a setting the model ignored (20 at most, of 500 characters each).

decide answers each question by its id:

json
{
  "answers": {
    "fraud": { "type": "boolean", "probability": 0.2, "confidence": 0.8 },
    "tier": {
      "type": "choice",
      "choice": "high",
      "probabilities": { "low": 0.3, "high": 0.7 },
      "confidence": 0.7
    }
  },
  "usage": { "inputTokens": 300, "outputTokens": 4 },
  "model": "jev-latest",
  "method": "native",
  "warnings": []
}

A boolean's probability is P(true): 0.98 a strong yes, 0.02 a strong no. A choice answers the choice made and a score its score, a fractional position on the levels from 0; probabilities, the distribution over the options or over the levels by index from "0", comes from a native engine only. confidence is how sure the answer is: the vendor's own measure when it gives one, else the probability of the outcome held likeliest (for a boolean, the larger of P(true) and P(false)); a choice or a score from the adapter has none. method says how the answers were made: native, or adapter, whose boolean probability is the model's own estimate.

The host checks an answer against its request before the module gets it: text for text, one of the options for choice, a value for json, the schema for object, and for array an array within its bounds whose every element matches; for decide, one answer to every question asked and to no other, of its question's type, a choice among its values and a score within its levels. An answer outside these fails with output. The answer is redacted with the call's list, so a key a vendor echoes reads [secret:NAME] (Secrets), and is 1 MiB at most.

A request without an answer answers its failure, which always carries kind and retryable:

json
{
  "error": "the vendor answered 429: Rate limit reached",
  "kind": "vendor",
  "retryable": true,
  "status": 429,
  "retryAfterMs": 3000
}
kindWhenretryable
requestThe host refused the request before sending it: a field, a schema, a limit, a host the install did not approve, a credential it cannot fill, the call's tag outside the credentials, a call without models or one that ended.no
vendorThe vendor answered an error, its HTTP status given, or its SDK failed otherwise.for 408, 409, 429 and 5xx, but not a spent quota (insufficient_quota)
networkThe request failed in transit.yes
timeoutNo answer within the request's time, or too little of the call's time left to send it.yes
refusalThe model refused; for a decision, questionIds names the questions it refused.no
outputThe answer is outside what was asked: the schema, the options, the questions, its size.no
unsupportedThe connection cannot do what was asked: a question type, an engine, decisions on a server without structured outputs, a generate on a typesafe connection, which decides only.no

retryAfterMs is the wait a retryable vendor's error asked for (its retry-after-ms or retry-after, or Gemini's RetryInfo), an hour at most, and usage the tokens a request used before it failed, as an answer outside its schema still does. The SDK's ctx.ai throws a failure as an AiError, an AppError with these fields, whose retryable and retryAfterMs an action that lets it go passes on as its own failure (Apps: authoring).

The tokens of every answer, and of every failure that reports them, join the call's usage as usage.ai, with the HTTP requests sent to model vendors: { "requests": 2, "inputTokens": 420, "outputTokens": 20 }. It comes back with the call's answer, whatever the module answered, and with a timeout, a trap or an answer outside the contract, since the vendor spent the tokens all the same; the invocation record keeps it (Limits of a call). The host logs a debug line app ai per request with the call's tags, what was asked (op) of which provider and model, its requests and tokens, its time and a failure's kind and error, and one app ai request per HTTP request with its method, host, status and time; neither holds a prompt, a state or an answer. The runner counts them on aletheia.app.ai.requests, aletheia.app.ai.tokens and aletheia.app.ai.duration (Operations).

Limits ​

The host counts per call and refuses past these: 25 HTTP requests (each request a digest auth sends counts, three at most), 8 MiB per response, 10 document reads, 200 log lines, 64 cache sets, 20 ai requests and 10 requests to model vendors (each ai request two minutes at most and 1 MiB asked or answered, each vendor's response 8 MiB), 1 MiB for the export's answer, and the deadline (the action's timeoutMs for invoke, cut to the time its step or rule has left, 15 s for handle_webhook and handoff, 5 s for manifest). A call past its deadline is stopped by terminating the thread it runs in, whatever the module is doing. See Limits of a call.

Released under the Apache-2.0 License.