Functions API
Functions run your server-side JavaScript next to your data, on a URL of their own. Each function is one bundled ES module that you deploy from the CLI or the console, and it reaches your other altengine services through capabilities you grant it explicitly — nothing else.
When to use one
An end user's identity token is scoped by row rules to one collection per request, and transactions, query joins, containers and automation take an organization key. Work that needs any of those — accept an invite: add a member, mark the invite used, bump a counter, all or nothing — goes in a function, which runs with the grants you give it and is called by your front end as an ordinary URL.
Your first function
A function is a module with a default export and a fetch method — the same shape as a Web/Service Worker handler.
// hello.js
export default {
async fetch(request, env) {
const url = new URL(request.url);
return Response.json({ hello: "world", path: url.pathname });
},
};Deploy it with the CLI:
altengine deploy --instance prod --name hello ./hello.jsIt is then live at its own hostname, and every path under the function name reaches it:
curl https://your-subdomain-fn.altengine.app/hello
curl https://your-subdomain-fn.altengine.app/hello/any/sub/pathServe it from your own name instead with a custom domain.
The subdomain is chosen when you create the functions instance, and the -fn is added by us: each service has its own suffix, so a functions instance and a blob instance can share a name without ever colliding. A subdomain may not contain --. Change it later under Settings → Address: every URL already shared stops working, each version's address moves with it, and any custom domain follows automatically. The console shows the full URL — copy it from there rather than assembling one. Function URLs are public: anything a function does not check for itself, anyone can reach. If a function should be private, verify a token inside it — see Checking who is calling.
What a function is handed
fetch(request, env) gets a standard Request and an env object holding exactly two kinds of thing: your secrets, and one client per service you granted. A service you did not grant is absent from env — not present-but-refused — so a missing grant shows up immediately as env.datastore is undefined.
These globals are available: Request, Response, Headers, URL, URLSearchParams, fetch, console, crypto, atob/btoa, TextEncoder/TextDecoder, and the usual JavaScript built-ins. There is no filesystem, no process, and no npm resolution at runtime — your bundle must be self-contained, which is what the CLI's bundler produces.
Request headers we set
Every inbound x-ae-* header is stripped before your code sees it, so a caller cannot forge them. We then set:
| Header | Value |
|---|---|
x-ae-trigger | What started this invocation — see below. |
x-ae-request-id | A unique id for this invocation, also returned in error responses. |
x-ae-fn | The function name. |
x-ae-version | The deployed version running. |
x-ae-trigger | Started by |
|---|---|
http | A request to the function's URL. |
cron | One of its schedules. |
container | A container job finishing, when the function is that instance's Call on completion. |
automation | An automation run finishing, when the function is its on_complete. |
Granting access to your services
A function's grants use the same shape as an API key's: a map of service or service:instance to read, write, or full. An instance-specific grant overrides a service-wide one.
altengine deploy --instance prod --name invite \
--grants datastore:appdb=write,auth:users=read ./invite.jsGrants are the blast radius of the function. A function granted datastore:appdb cannot reach datastore:otherdb at all, and one granted read cannot write. Grant the least it needs — deleting requires full, not write.
Row-level rules do not apply to a function. It runs at organization level, bounded only by its grants.
Available clients
Every method takes a target object naming the instance (and namespace, where the service has one) as its first argument, and every method is async.
// env.datastore
await env.datastore.put({ instance, namespace }, collection, documents)
await env.datastore.get({ instance, namespace }, collection, keys)
await env.datastore.delete({ instance, namespace }, collection, keys)
await env.datastore.query({ instance, namespace }, collection, request)
await env.datastore.aggregate({ instance, namespace }, collection, request)
await env.datastore.transaction({ instance, namespace }, operations)
await env.datastore.listIndexes({ instance, namespace }, collection)
await env.datastore.createIndex({ instance, namespace }, collection, fields, unique)
await env.datastore.deleteIndex({ instance, namespace }, collection, indexId)
// env.search
await env.search.index({ instance, namespace }, index, documents)
await env.search.get({ instance, namespace }, index, ids)
await env.search.delete({ instance, namespace }, index, ids)
await env.search.search({ instance, namespace }, index, request)
await env.search.schema({ instance, namespace }, index)
await env.search.listIndexes({ instance, namespace }, { q, limit })
await env.search.deleteIndex({ instance, namespace }, index)
// env.auth
await env.auth.verifyToken({ instance }, token)
await env.auth.getUser({ instance }, uid)
await env.auth.listUsers({ instance }, { q, limit })
await env.auth.setClaims({ instance }, uid, claims)
await env.auth.setProfile({ instance }, uid, profile)
await env.auth.setDisabled({ instance }, uid, disabled)
await env.auth.deleteUser({ instance }, uid)
await env.auth.revokeSessions({ instance }, uid)
// env.channel
await env.channel.publish({ instance }, channel, data)
await env.channel.token({ instance }, { channels, ttlSeconds, publish, presenceId })
await env.channel.presence({ instance }, channel)
// env.blob
await env.blob.put({ instance }, name, content, { contentType, public, meta })
await env.blob.uploadUrl({ instance }, { name, size, contentType, public })
await env.blob.get({ instance }, id)
await env.blob.bytes({ instance }, id)
await env.blob.list({ instance }, { prefix, limit, cursor })
await env.blob.setPublic({ instance }, id, isPublic)
await env.blob.delete({ instance }, ids)
// env.container
await env.container.run({ instance }, { image, cmd, env, size, timeout_ms }) // job (write)
await env.container.get({ instance }, jobId) // job (read)
await env.container.list({ instance }, { status, limit, before }) // { jobs, cursor } (read)
await env.container.cancel({ instance }, jobId) // job (write)
await env.container.logs({ instance }, jobId, cursor) // { lines, cursor, note? } (read)
// env.automation
await env.automation.run({ instance }, request) // run (write)
await env.automation.get({ instance }, runId) // run (read)
await env.automation.list({ instance }, { status, script, agentId, limit, cursor }) // { runs, cursor } (read)
await env.automation.cancel({ instance }, runId) // run (write)
await env.automation.logs({ instance }, runId, cursor) // (read)
await env.automation.artifacts({ instance }, runId) // files with download URLs (read)
await env.automation.agents({ instance }, { q, limit, cursor }) // { agents, cursor } (read)
await env.automation.deliver({ instance }, key, value, { script, agentId }) // { delivered, runs, unreachable, undetermined } (write)
await env.automation.send({ instance }, runId, key, value) // { delivered, undetermined? } (write)env.container returns the job object itself — { id, status, image, size, exit_code, timeout_ms, error, started_at, ended_at, cost_usd } — where REST wraps it in { job }, and cancel on a job that already ended returns it unchanged. Pass list's cursor back as before for the next page (25 by default, up to 100); pass logs's cursor back as its third argument until it is null.
env.automation.run takes the REST run body and returns as soon as the run exists — queued or running; it does not wait for the run to finish. Name a function of yours as on_complete to handle the result. run, get and cancel return the run object without the result field REST's run GET carries. deliver hands a value to every run on the instance waiting on that key (narrow it with script or agentId) and throws when none is; send delivers to one run by id.
Otherwise, request and response shapes match the REST APIs exactly — see the Datastore, Search, Auth, Channel, Blob, Containers and Automation references. Omit namespace for the default one. Work done from a function is metered exactly as the same work over HTTP.
These clients are the only way a function reaches another altengine service: altengine's own hostnames are refused by the outbound allowlist, so an API key stored in a secret is no use.
Three worth knowing
env.blob.uploadUrl lets a browser upload a file without your function touching the bytes. The size is signed into the URL, so the function decides whether this user may upload and how large, and hands back a URL the browser PUTs to directly.
env.container.run is how a browser action starts a container job: containers refuse end-user tokens.
env.channel.token mints a channel token for decisions an auth instance's access rules cannot express — this user was invited to this room, this tier includes this feed:
export default {
async fetch(request, env) {
const { sub: uid } = await env.auth.verifyToken({ instance: "users" }, bearer(request));
const rooms = await env.datastore.query({ instance: "appdb" }, "memberships", {
where: [{ field: "uid", op: "=", value: uid }],
});
// Only the rooms this user is actually a member of, decided here rather than in the page.
return Response.json(await env.channel.token({ instance: "live" }, {
channels: rooms.documents.map((d) => `room.${d.data.roomId}`),
ttlSeconds: 3600,
presenceId: uid,
}));
},
};The result carries token, expires_at, the channels it was granted, and ws_endpoint + protocols for the browser's new WebSocket(ws_endpoint, protocols) (see Token auth; ws_url is deprecated). Minting a subscriber token needs a read grant; passing publish: true needs write.
The case functions were built for
export default {
async fetch(request, env) {
const { inviteId, uid } = await request.json();
// All of this commits together, or none of it does.
await env.datastore.transaction({ instance: "appdb" }, [
{ op: "put", collection: "members", key: `m_${uid}`, data: { uid, role: "member" } },
{ op: "put", collection: "invites", key: inviteId, data: { used_by: uid } },
{ op: "delete", collection: "pending", key: inviteId },
]);
await env.channel.publish({ instance: "live" }, "org-updates", { joined: uid });
return Response.json({ ok: true });
},
};Checking who is calling
A function URL is public, so if the work is user-specific, verify the caller's identity token yourself. Pass it from your front end and check it with the auth client:
const token = (request.headers.get("authorization") || "").replace(/^Bearer /, "");
const identity = await env.auth.verifyToken({ instance: "users" }, token);
if (!identity) return new Response("unauthorized", { status: 401 });
// identity.sub is the end user's id — now do the privileged work on their behalf.A bad or expired token returns null rather than throwing, and so does the token of an end user who has since been disabled, deleted, reset or signed out everywhere (within about a minute — see revocation). A good one returns the token's verified claims: sub and uid (the end user's id — the value the other env.auth methods take as their uid), identifier, email, email_verified, profile, claims, exp and typ (always "id"). Authorize on claims, which only you can set; profile is whatever the user typed at sign-up.
Secrets
Store API keys and tokens as instance secrets (Functions → your instance → Secrets) rather than in your source. Secrets are write-only: the console returns names, never values, so once saved a secret can be replaced but not read back.
A name is 1–64 characters of A-Z, 0-9 and _, starting with a letter; DATASTORE, SEARCH, AUTH and CHANNEL are reserved. Each secret has one of two exposures:
| Exposure | Where the value lives | Use when |
|---|---|---|
env | In your function's env.NAME. | Your code itself needs the value — signing a webhook payload, decrypting something. |
Outbound only | Nowhere your code can read it — it is filled in on the outbound request. | The value is only ever sent to an API. Write {{SECRET_NAME}} in a request header and we substitute it on the way out. |
// STRIPE_KEY is stored as "Outbound only", sent to api.stripe.com.
const res = await fetch("https://api.stripe.com/v1/charges", {
method: "POST",
headers: { authorization: "Bearer {{STRIPE_KEY}}" },
});Prefer Outbound only where you can.
An outbound-only secret names where it may go, under Sent to:
| Setting | Meaning |
|---|---|
| Hosts (required) | 1–10 hosts the value may be sent to, in allowlist syntax (api.stripe.com, *.example.com). Each must also be reachable through the instance's outbound allowlist. |
| Query parameter (optional) | The one query parameter the value may be placed in, for an API that takes its key there (?key={{MAPS_KEY}}). Without it, the secret is expanded in headers only. |
A {{NAME}} for one of your secrets anywhere else — another host, another query parameter, any query parameter when none is named — refuses the whole request with 403 EGRESS_BLOCKED, naming the secret and the rule, never the value. The value is never substituted into the host, the path or the body. A placeholder that names no secret is sent as written.
An outbound-only secret with no hosts — the Secrets tab warns about each one — is expanded into headers toward any allowlisted host and refused in a query string. Give it hosts.
Saving a secret without its value keeps the stored one, but only for a change that narrows where it can go. Adding a host its current hosts do not cover, setting a different query parameter, or switching it to env needs the value sent again; without it the save is refused with 400.
Outbound requests
By default a function cannot make outbound requests at all — fetch() throws. To call an external API, add its hostname to the instance's outbound allowlist (Functions → your instance → Settings).
api.stripe.com
*.githubusercontent.comRules worth knowing before you debug a blocked call:
- HTTPS only. Plaintext
http://is refused. - Hostnames only — IP addresses are always refused, and so are
localhost,.internaland.local. *.example.commatches subdomains but notexample.comitself; add the apex separately if you need it.- Redirects are re-checked at every hop against the same list, and credentials are dropped when following one.
- altengine's own hostnames are always refused, even if listed — and so is any custom domain active on altengine, yours or anyone's. Use the
envclients to reach your services.
Once the instance has an allowlist, a blocked request does not throw: fetch() resolves to a 403 with a body naming the reason. Check error.code to tell it from the remote API's own 403.
{ "error": { "code": "EGRESS_BLOCKED",
"message": "outbound fetch blocked: 'example.org' is not in this instance's outbound allowlist" } }Calling a function from a browser
By default a function sends no CORS headers. To call one from a web page, set the allowed origins on the instance: either * or an exact list (up to 20). Preflight OPTIONS requests are answered without running your function, so they are not billed.
Credentials are never allowed, so call a function with an explicit token in a header rather than relying on cookies.
A cookie your function sets is always host-only: the Domain attribute is removed from every Set-Cookie it returns.
Running on a schedule
A function can run on a timer as well as on request. Give it one or more five-field cron expressions, in UTC, in the Schedule field when you deploy — or with the CLI:
# with a key saved and ALTENGINE_INSTANCE set, as under CLI below
altengine deploy --schedule "0 3 * * *" --name nightly ./nightly.js
altengine deploy --schedule "0 9 * * 1-5" --schedule "0 12 * * 6" --name digest ./digest.js
altengine deploy --unschedule --name nightly ./nightly.jsA function takes up to five expressions; use several for times one expression cannot say, such as “09:00 on weekdays and 12:00 on Saturday”.
Supported in each field: *, a number, ranges (1-5), lists (9,17) and steps (*/15). Names like MON, and the L/W/# extensions, are not supported. There is no seconds field: the scheduler ticks once a minute.
A scheduled run arrives as a POST with x-ae-trigger: cron and this body:
{ "crons": ["0 9 * * 1-5", "0 12 * * 6"], "scheduled_for": 1767225600000 }crons is always a list, even with one schedule.
- 30 seconds. A scheduled run is waited for 30 seconds. Past that it is no longer waited for and may be cut off — hand longer work to a container job or an automation run.
- One at a time. If a run is still going when the next is due, that occurrence is skipped, not queued. It runs again on the first tick after it finishes.
- At or after, never early. At most 25 of one organization's due schedules start in a given minute; the rest start on the following ticks, so a run can start late.
- No retries. A run that fails is not retried; it appears in Logs like any other failure.
- Billed normally. A scheduled run is an invocation like any other.
- Omitting the schedule keeps it. Deploying without
--scheduleleaves the existing schedules in place. Use--unschedule(or clear the field) to remove them. - Paused for suspended or closed organizations.
Errors and logs
When a function throws, or returns a 5xx, we record it under Functions → your instance → Logs, grouped by error rather than logged one line per failure. Each group shows the message, how often it has happened, when it started and last happened, the stack, and the console output from the most recent failure. Occurrence counts are approximate, and groups are kept for 14 days.
For everything else there is live tail: open the Logs tab, start a tail, and console.log output from invocations streams in while you watch. Nothing is stored — close the page and collection stops. Only invocations that happen while the tail is open appear.
An uncaught error returns a JSON body with the request_id we set on the invocation, so you can match a report to a specific call.
Limits
| Limit | Value |
|---|---|
| Bundle size | 5 MB per function, after bundling; the console editor loads source up to 1 MB |
| Functions per instance | 100 |
| Versions kept | 3 per function by default, 1–50 — see Versions |
| Secrets | 32 per instance, 4 KB each; an outbound-only secret names up to 10 hosts |
| Outbound allowlist | 20 hostnames per instance |
| CORS origins | 20 per instance |
| Subrequests | 50 per invocation by default, 0–1,000 (subRequests) |
| CPU budget | 50 ms per invocation by default, 1–30,000 (cpuMs) — see below |
| Schedules | 5 cron expressions per function |
| Invocation rate | 6,000/minute per instance by default; lower it in Settings |
Set cpuMs and subRequests per function in the deploy body; the console shows them, and a redeploy keeps them. The CPU setting is a budget you are measured against, not a hard stop: an invocation that overruns it is counted (the instance's Usage tab shows CPU overruns) rather than killed at it, and a separate hard ceiling of a second or two ends a runaway. Don't build anything that depends on a function being stopped at a precise CPU figure.
CLI
The altengine CLI bundles and deploys. Bundling happens on your machine: an import that cannot be resolved fails locally with a filename and line number.
alt install altlimit/altengine # the CLI, if you do not have it
altengine login # once: an org API key, not echoed; in CI set ALTENGINE_API_KEY
export ALTENGINE_INSTANCE=prod # or pass --instance to each command
altengine deploy --name hello ./hello.js
altengine functions list
altengine functions versions hello # the function NAME is positional here
altengine functions rollback --version 3 hello
altengine functions pull hello # print deployed source
altengine functions delete --version 2 hello # one version; without --version, the functiondelete asks first on a terminal; in a script it needs --yes. deploy, functions list and functions versions take --json for one JSON document on stdout.
Deploying needs a full grant on the functions instance. It also needs the access it hands out: a key can only give a function grants it holds itself. The refusal names the grant that is missing.
On redeploy, anything you omit is kept — pushing new code without --grants or --schedule does not clear the grants or schedules the function already has.
To delete a version, use altengine functions delete --version, the function's version history in the console, the functions_delete_version MCP tool, or the API. The version being served cannot be deleted (409). Deleting a function removes its schedules and every version.
REST API
The CLI calls these; any organization API key can (end-user identity tokens are refused). Base path https://api.altengine.net/v1/functions/{instance}.
| Request | Grant | Does |
|---|---|---|
GET / | read | Lists functions: name, active_version, grants, cpu_ms, sub_requests, schedules, url. |
POST /deploy | full | Stores a new version. Returns 201 { name, version, size_bytes, active }. |
GET /{name}/versions | read | Stored versions, newest first, and active_version. |
GET /{name}/versions/{version}/code | read | A version's source: { name, version, code }. |
POST /{name}/activate | full | Body { "version": 3 }. Serves a stored version; returns { name, active_version, previous_version }. |
DELETE /{name}/versions/{version} | full | Deletes one version that is not being served. |
DELETE /{name} | full | Deletes the function, its schedules and every version. |
POST /v1/functions/prod/deploy
{ "name": "hello", // 1–63 of a-z 0-9 _ -, starting alphanumeric
"code": "export default { … }", // one bundled ES module, ≤ 5 MB
"grants": { "datastore:appdb": "write" },
"cpuMs": 50, "subRequests": 50,
"schedules": ["0 3 * * *"], // null or [] clears
"activate": true } // false: store without servingOmitted fields keep the deployed function's values. A few names are reserved (api, admin, v1, assets, site and similar); the refusal says so.
Versions
Each deploy stores a new version. The newest 3 per function are kept — Versions to keep in Settings, or keepVersions (1–50) on the config. Older ones are deleted after each deploy, never the active one, and lowering the setting deletes the extras when you save. Every retained version is billed.
Every stored version runs at its own URL:
curl https://your-subdomain--v3-fn.altengine.app/helloAn old version runs with the function's current grants and secrets, so deploying a fix does not take the old one offline — delete the version to remove its URL. A deleted or pruned version returns 404. Versioned URLs work on the platform hostname only, not on a custom domain. The console's version history shows 50 at a time, with Load more when older ones exist.
GET /v1/functions/{instance}/{name}/versions
{ "active_version": 4,
"versions": [
{ "version": 4, "size_bytes": 812, "sha256": "…", "created_at": …,
"url": "https://your-subdomain--v4-fn.altengine.app/hello" },
{ "version": 3, … } ] }Running locally
The same CLI runs an emulator with every service, functions included:
altengine dev # http://127.0.0.1:9191
altengine deploy --url http://127.0.0.1:9191 --key dev \
--instance main --name hello ./hello.js
curl http://127.0.0.1:9191/fn/main/helloLocally there are no per-instance subdomains, so a function is served from /fn/{instance}/{function} instead. Read paths off request.url rather than assuming a fixed prefix and the same code works in both places. console.log output goes to the terminal running the emulator.
The emulator runs the scheduler too, ticking each minute, with the same one-at-a-time behaviour. Nothing fires merely because you restarted it — a function runs because its expression came due while the emulator was watching.
The emulator enforces the same grants, secret exposures, outbound allowlist and CORS rules, so a call that production will refuse fails locally too. It is a development tool, not a security sandbox — run only code you trust, and keep it bound to localhost.
Pricing
Functions bill on three axes:
| Metric | Rate |
|---|---|
| Invocations | $2.00 per million |
| CPU time | $0.10 per million milliseconds |
| Retained versions | $0.10 per version per month, prorated |
Every retained version costs its monthly charge whether or not it is ever called. Delete functions you have finished with, and lower Versions to keep to hold less history. Calls your function makes to Datastore, Search, Auth or Channel are metered on those services exactly as the equivalent REST calls would be.
See Pricing for these rates alongside every other service, and how the $3/month free tier applies.