Reference

Functions and custom endpoints

Functions are server-side Python that runs on an entity's request lifecycle. Functions is one primitive, not several. A webhook is a Function that makes an outbound HTTP call. An integration is a Function that calls a third-party API. A validation rule is a Function that rejects the request. There is no separate webhook, trigger, or automation feature to look for.

The shape of a Function

Four properties decide what a Function does.

PropertyValuesWhat it means
Trigger pointbefore-request, after-request, endpointBefore: can rewrite the payload or reject the request. After: sees the response. endpoint is the response, for a custom route.
Execution modesynchronous, asynchronousSync blocks the request. Async is fire-and-forget. Set on the endpoint itself for endpoint functions.
MethodsGET POST PATCH DELETEWhich verbs trigger it — all four by default, including GET.
Orderinteger, default 100Functions on one trigger run lowest-first, in a single invocation, each receiving the previous one's output.

A Function record is metadata plus a handler path. Creating, editing, reordering, disabling or deleting one takes effect within 30 seconds and deploys nothing. Only changing the code itself requires a deploy.

Writing one

from aaly_runner import Halt

def handler(req):
    if req.body["total"] > 10_000:
        raise Halt(422, "ORDER_TOO_LARGE", "Orders are capped at 10,000")
    req.body["reviewedAt"] = req.context["requestId"]
    return req.body

The request object

PropertyWhat it holds
req.bodyThe payload (before) or the response (after). Mutable. On before-request it is always a dict — a GET or DELETE carries no payload, so you get {}, never None. On after-request it can be None, meaning a 204.
req.env["MODE"]Non-secret variables.
req.secrets["STRIPE_KEY"]Secrets, fetched on first access.
req.entity / req.method / req.record_idWhat is being operated on.
req.tenant_id / req.user_idWho triggered it.
req.api_base_urlThis project's REST API.
req.shapeAfter-request only: item, collection, or empty.

What returning means

  • Return a dict — it becomes the payload the next Function receives, and ultimately what gets written or returned.
  • Return None — nothing changed. Mutating req.body in place also works.
  • Raise Halt(status, code, message) — reject the request. Allowed statuses are 400, 401, 403, 404, 409, 422 and 429. Anything else becomes a 500; a Function cannot forge a success or a redirect.
  • Raise anything else — handled per the Function's error_handling setting: fail rejects the request, log and ignore continue with the payload untouched.

A Halt in an after-request Function is logged and ignored. The write is already committed, and reporting failure for a committed write is worse than the alternative.

Calling your own API

Call it as ordinary REST, exactly as your OpenAPI spec documents:

import urllib.request, json

def handler(req):
    url = f"{req.api_base_url}/inventory?sku={req.body['sku']}"
    with urllib.request.urlopen(url) as r:
        stock = json.load(r)
    if not stock["items"]:
        raise Halt(409, "OUT_OF_STOCK", "That SKU is unavailable")
    return req.body

Calls to your own Aaly API are routed internally and authenticate automatically. Calls anywhere else — Stripe, Slack, your own server — go out over ordinary HTTPS untouched.

By default a Function acts as the user who triggered the request, so it can never do anything that user could not. Set run_as: "system" for a project-scoped identity. tenantId is inherited either way, so a Function can never reach another tenant's data.

Budgets

TriggerBudgetNotes
Synchronous before-request2sThe whole chain, not per Function. Sits in front of a ~30ms write.
Synchronous after-request5sThe write is already committed.
Synchronous endpoint30sKeep well under 29s in practice — the API gateway cuts the connection at roughly 29s with a raw 504 before a clean FUNCTION_TIMEOUT can be returned.
Asynchronous5 minNobody is waiting. Sized for genuinely slow background work.

Before-request Functions must be idempotent and side-effect-free. When the budget is exceeded the engine stops waiting, but your Function may still be running, and there is no way to cancel it. Treat before-request as a validation gate: read, decide, return. Do side effects in an after-request Function, where the outcome is known.

Custom endpoints

A custom endpoint is a non-CRUD route attached to an entity, such as GET /orders/summary. The URL is always /{entity}/{action} — one path segment after the entity, with no nested paths.

Create the route first, then attach one or more Functions to it:

# create_endpoint(entity_id="ENT7", action="summary", method="GET")
#   -> returns an endpoint id, e.g. "EPT1"
# create_function(entity_id="EPT1", name="build_summary", trigger_point="endpoint")
#   -> note: "entity_id" here is the ENDPOINT's id

def handler(req):
    return {"totalOrders": 42, "totalRevenue": 18230.50}

There is no wrapped CRUD operation behind this trigger point — the chain's final return value is the HTTP response. Returning a plain dict wraps it as a 200 with that dict as the JSON body. To control the status code, headers, or return something that isn't JSON, return the full envelope:

def download_report(req):
    import base64
    pdf_bytes = build_report(req.body)
    return {
        "statusCode": 200,
        "headers": {"Content-Type": "application/pdf"},
        "body": base64.b64encode(pdf_bytes).decode(),
        "isBase64Encoded": True,
    }

Validation is opt-in

An endpoint with no declared fields accepts free-form JSON — req.body is whatever the caller sent, unvalidated. This is deliberate: a custom endpoint's contract is often not an entity's schema.

To turn on validation, attach fields to the endpoint the way you would to an entity, with a location of "body" or "query":

create_field(entity_id="EPT1", name="amount", type="number",
             required=true, location="body")

Nothing is persisted for these fields; they exist purely to validate the request. A free-form endpoint is not a validation bypass for the rest of the platform — whatever a Function writes by calling a real entity's POST or PATCH still goes through that entity's full schema validation.

Public endpoints have no caller identity

auth defaults to "required". Setting auth: "public" accepts a request with no Authorization header at all, for public forms, webhooks, or asset delivery. That means there is no caller to inherit — req.tenant_id and req.user_id are None, and automatic authentication has nothing to authenticate as.

To write data from a public endpoint, pass an explicit Authorization header, which wins over the automatic one:

import json, urllib.request

def handler(req):
    # No caller identity — validate req.body yourself before trusting it.
    request = urllib.request.Request(
        f"{req.api_base_url}/leads",
        data=json.dumps(req.body).encode(),
        headers={"Authorization": f"Bearer {req.secrets['SERVICE_API_KEY']}",
                 "Content-Type": "application/json"},
        method="POST")
    urllib.request.urlopen(request)
    return {"status": "received"}

That key is exactly as powerful as the user it was minted for. There is no per-endpoint permission scoping today, so treat it as a full-privilege service account: mint it against a dedicated user rather than your own admin account, and validate req.body defensively. It is the only thing standing between an anonymous caller and whatever that key can do. See Limits for the RBAC gap this sits on top of.

Sync or async is a property of the route

Unlike before/after-request, a custom endpoint's execution mode belongs to the whole route, not to any one attached Function. synchronous (the default) returns the chain's output as the response. asynchronous returns an immediate 202 {"status": "accepted"} and runs the chain in the background — useful for a webhook receiver that needs to acknowledge fast.

Variables and secrets

Two separate things, deliberately — not one thing with a flag.

mode = req.env["STRIPE_MODE"]      # a variable: plain config, readable back
key  = req.secrets["STRIPE_KEY"]   # a secret: encrypted, fetched on access
VariablesSecrets
Read asreq.env["KEY"]req.secrets["KEY"]
StoredPlain textEncrypted
Readable backYes — that is the pointNever, by any API
Set withset_variableset_secret — a human enters the value

Secrets take no value over MCP. set_secret returns a link for a human to enter the value in the Aaly app, because a live credential must not pass through an agent's context or a conversation transcript.

A secret's value can be a single string or a structured object, which matters for anything with an expiry: an OAuth access token, its refresh token and its expiry are one secret, not three, so a refresh replaces them in a single write. Split across records they could tear, and a lost refresh token means the customer has to re-consent.

Both resolve through two levels. A project-level default applies to every tenant that has no override; a tenant-level value overrides it for that tenant only. A Function always sees the merged view for the tenant it is running for — neither level is visible separately at execution time.

Keys cannot begin with AWS_ or AALY_. The same key may exist as both a variable and a secret; they never collide.

The code bundle

One zip per project, holding every handler and any shared modules. Your layout is preserved verbatim, and handler paths point into it:

requirements.txt          # vendored before zipping
shared/validators.py      # from shared import validators — your module
orders/before.py          # handler: "orders.before.validate"
orders/after.py           # handler: "orders.after.notify"

The platform does not run pip on your code — vendor dependencies yourself:

pip install -r requirements.txt -t .
zip -r bundle.zip . -x '*.pyc' -x '__pycache__/*'

Then upload the zip, deploy it, and poll until the deployment reports ready or failed. Deploying replaces the whole bundle at once, so a bad bundle affects every Function in the project — though a failed deploy leaves the previous working bundle running, and rolling back means deploying an earlier file id. aaly_runner/ is a reserved name and a bundle containing it is rejected.

Testing locally

Two loops, neither of which needs a deploy. The first runs just your handler, with no engine and no credentials:

python -m scripts.run_function --bundle ./my-functions \
    --handler orders.before.validate --body '{"quantity": 500}'

It builds the same event the engine builds and calls the same runner the deployed Function calls, so chain order, timeouts and Halt semantics are the real ones.

The second attaches the real engine to your local bundle:

export AALY_FN_LOCAL_BUNDLE=./my-functions
python -m utils.local_server 3000

Two things still need a live deployment: req.secrets, which resolves through the engine, and calls back to your own API. Non-secret req.env works in both.

What Functions cannot do yet

  • Scheduled or cron execution. Something has to make a request. Trigger recurring work from an external scheduler calling a custom endpoint.
  • React to failures. After-request runs on 2xx only.
  • Cross-project access. A Function reaches its own project's data.
  • Persist anything locally. /tmp does not survive between invocations.
  • Languages other than Python.

Diagnosing

SymptomCause
FUNCTION_RUNNER_UNAVAILABLE (503)No bundle deployed, and a Function is set to fail closed. Deploy, or disable it.
FUNCTION_TIMEOUT (504)The chain exceeded its budget.
FUNCTION_ERROR (500)The Function raised. The real exception is in the logs, never in the response — exception text can contain anything.
ENDPOINT_NOT_IMPLEMENTED (501)A custom endpoint exists with no Functions attached yet.
Deployment failedRead the deployment's message. Usually a handler path that is not in the bundle.
A Function is not runningCheck that it's enabled, that methods includes this verb, and that the last deployment reached ready. Metadata changes take up to 30 seconds.
401 on a route you expected to be publicThe endpoint's auth is still "required", the default.