Appearance
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
prefixandsuffixof a signature; - as the whole of a credential of an
airequest'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"
}| Field | Default | Notes |
|---|---|---|
method | GET | GET, POST, PUT, PATCH, DELETE or HEAD. |
url | 4,096 characters at most, secret placeholders allowed (percent-encoded where they land). | |
headers | {} | Names are lower-cased; values may hold placeholders. |
body | none | A string, { json }, { jsonText }, { form }, { base64 }, { document: id } or { multipart: [...] } (below). |
auth | none | The vendor's authentication, which the host builds from the install's secrets (below). |
sign | none | An HMAC the host computes over the final request and puts in a header (below). |
timeoutMs | 10000 | 100 to 30,000 ms, within the call's own deadline. |
responseAs | text | text 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" }network | When | Sending it again |
|---|---|---|
not_sent | The 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. |
unknown | The 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;
httpis 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
hostsand the hosts of the URLs its configuration names athostsFromConfig(Hosts).api.vendor.examplematches itself only,*.vendor.examplematches one or more labels before.vendor.examplebut notvendor.exampleitself. A host without a port is reached at the scheme's default port only (443), so withapi.vendor.exampleapproved,https://api.vendor.example:8443/answersport 8443 of api.vendor.example is not in the hosts the install approved;api.vendor.example:8443is 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/adminand 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%2Fwould 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::/96and the local-use64: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. AGET,HEAD,PUTorDELETEthat meets a kept connection the vendor closed while it was idle is sent once more on a new connection; any other request answersnetwork: unknown. - A request that sets
host,content-length,transfer-encoding,connection,expectorupgrade, or whoseauthwould, is refused; the host setscontent-lengthitself,user-agenttoAletheia-Apps/1unless the request sets one, andcontent-typefrom the body when the request sets none. - Nothing is overwritten: a header that both the request and its
authset, a query parameter that theauthadds and the URL has already, and a header of the request or itsauththatsignwrites 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
digestauth sends included; a response body over 8 MiB fails the request.
Bodies:
| Body | Sent 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 headerkey: value, or the query parameterkey=value.digest(username,password): the request, then, when the answer is a 401 whosewww-authenticateholds a Digest challenge, the same request withauthorization: Digest …computed from the challenge (RFC 7616:MD5,SHA-256andSHA-512-256and their-sessforms, withqopauthorauth-int, or none for an RFC 2069 server). The host answers the first challenge it can, as a vendor lists them most preferred first, withqopauthwhen it is offered andauth-int(a hash of the body bytes as sent) when only that is; it echoesopaque, sends the username hashed when the challenge setsuserhashand asusername*(RFC 8187) when it is not printable ASCII. The second request carries back the cookies the 401 set, after the request's owncookieand without a name it sends already: each value as the vendor wrote it, unless the cookie has expired (Max-Age, which wins, orExpires;Max-Age=0deletes it) or itsPathdoes not match the request's. When the second request is answered 401 with a Digest challenge that saysstale=trueand 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 setsauthorizationitself, or whosesignwrites it, is refused before anything is sent.aws-signature(accessKey,secretKey,region,serviceName,serviceTokenfor a session token,addTo): AWS Signature Version 4 over the final request, made withaws4fetch. In the headers,authorization,x-amz-dateand, withserviceToken,x-amz-security-token; fors3alsox-amz-content-sha256, the body's SHA-256, so S3 checks the bytes sent (a request that sets it keeps its value). Presigned, withQUERY_PARAMS, theX-Amz-Date,X-Amz-Security-Token,X-Amz-Algorithm,X-Amz-Credential,X-Amz-SignedHeadersandX-Amz-Signaturequery parameters, and fors3X-Amz-Expires(a day unless the URL sets it) over an unsigned payload.content-type,user-agentand the other headersaws4fetchleaves out stay unsigned, which AWS accepts. A request that setsx-amz-date, or a URL that has anX-Amz-*parameter the signature adds, is refused. Write a space in the URL's query as%20and a plus sign as%2B:aws4fetchreads a+there as a space when it signs.hawk(authId,authKey,algorithm,includePayloadHash,ext,app,dlg,nonce,timestamp):authorization: Hawk …, a MAC (sha256by default, orsha1) over the timestamp, a nonce, the method, the path and query, the host, the port (443 or 80 when the URL names none), withincludePayloadHashthe payload hash (over the media type, lower case and without its parameters, and the body bytes as sent, an empty body included),ext, andappanddlgwhenappis set;dlgwithoutappis refused.authId,nonce,ext,appanddlghold printable ASCII without"or\, what a Hawk server reads.nonceandtimestamp(seconds) are for tests: the host makes a fresh nonce and reads its clock otherwise. When the answer carriesserver-authorization, the host checks it as a Hawk client does: itsmac, overhawk.1.responsewith the request's timestamp, nonce, method, path and query, host, port,appanddlgand the header's ownhashandext, underauthKey; and withhash, the payload hash of the answer's media type and body bytes. An answer that fails the check is an{ error }withnetwork: 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,secretorprivateKey,isSecretBase64Encoded,payload,jwtHeaders,addTo,headerPrefix,paramName): a JSON Web Token of the claims inpayload, signed withjosefor each request undersecretforHS256(the default),HS384andHS512(base64-decoded first withisSecretBase64Encoded), or under the PEMprivateKeyforRS*andPS*(an RSA key of 2048 bits or more) andES*(a key on the algorithm's curve). The claims go as given, except thatiat,nbfandexpmay be text relative to the signing time:"now","5m","-30s"(seconds, minutes, hours or days).jwtHeadersjoinalgin its header and may not set it. Sent asauthorization: <headerPrefix><token>(by defaultBearerand a space), or as the query parameterparamName(tokenby 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 readjwt 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 sendsauthorizationasEG1-HMAC-SHA256 client_token=…;access_token=…;timestamp=…;nonce=…;signature=…, where the signature is an HMAC-SHA256, in base64, under a key derived fromclientSecretand 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 headersheadersToSignnames (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 firstmaxBodySizebytes of the body (131,072 by default, which must match the API client'smax_bodyat 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.headersToSigncannot nameauthorizationor a header the host sets (user-agent,content-length,hostand the other reserved ones), nor the header of the request'ssign, which the host sets after EdgeGrid has signed.nonceandtimestampare 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 readakamai-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,signover the request with the auth's additions, and the host then addsuser-agentandcontent-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).
digestsends 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:
| Text | Filled 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:
keyEncoding | The key's bytes |
|---|---|
utf8 (default) | the text as UTF-8 |
base64 | the 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 |
hex | the 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:
| Request | Answer |
|---|---|
{ "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
}| Field | Default | Notes |
|---|---|---|
op | generate or decide. | |
connection | Where the request goes and the keys it takes, by provider (below). | |
model | The 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. | |
timeoutMs | as long as the call allows | 1,000 to 120,000 ms, within the call's own deadline (below). |
generate adds:
| Field | Default | Notes |
|---|---|---|
prompt, messages | One of them: the prompt, or a conversation of 1 to 100 turns { role, content }, role user or assistant, text only. | |
instructions | none | What the model is told before the prompt or the conversation (the system prompt). |
output | text | How the answer is shaped (below). |
maxOutputTokens | none | 1 to 32,768. |
temperature, topP | none | 0 to 2, and 0 to 1. |
stopSequences | none | Up to 4, each 100 characters at most. |
seed | none | An 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:
| Field | Default | Notes |
|---|---|---|
state | What 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. | |
questions | 1 to 32, each id once (below). | |
engine | the provider's | native, 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
provider | Fields | Without a baseUrl |
|---|---|---|
anthropic | apiKey, baseUrl | https://api.anthropic.com/v1 |
openai | apiKey, baseUrl, organization, project, api (responses by default, or chat) | https://api.openai.com/v1 |
azure | apiKey, resourceName or baseUrl (one of them), apiVersion (v1 by default, or a dated one such as 2024-10-21), api | https://<resourceName>.openai.azure.com/openai |
google (Gemini) | apiKey, baseUrl | https://generativelanguage.googleapis.com/v1beta |
typesafe (Jev) | apiKey, baseUrl | https://api.typesafe.ai/v1 |
bedrock | region, auth ({ type: 'api-key', apiKey } or { type: 'sigv4', accessKeyId, secretAccessKey, sessionToken? }), baseUrl | https://bedrock-runtime.<region>.amazonaws.com, in amazonaws.com.cn for cn- and amazonaws.eu for eusc- regions |
openai-compatible | name, baseUrl, apiKey (optional), apiKeyHeader, headers, queryParams, supportsStructuredOutputs | none: 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
airequests, 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 retryabletimeout); 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
}kind | When | retryable |
|---|---|---|
request | The 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 |
vendor | The 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) |
network | The request failed in transit. | yes |
timeout | No answer within the request's time, or too little of the call's time left to send it. | yes |
refusal | The model refused; for a decision, questionIds names the questions it refused. | no |
output | The answer is outside what was asked: the schema, the options, the questions, its size. | no |
unsupported | The 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.