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.
| Property | Values | What it means |
|---|---|---|
| Trigger point | before-request, after-request, endpoint | Before: can rewrite the payload or reject the request. After: sees the response. endpoint is the response, for a custom route. |
| Execution mode | synchronous, asynchronous | Sync blocks the request. Async is fire-and-forget. Set on the endpoint itself for endpoint functions. |
| Methods | GET POST PATCH DELETE | Which verbs trigger it — all four by default, including GET. |
| Order | integer, default 100 | Functions 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
| Property | What it holds |
|---|---|
req.body | The 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_id | What is being operated on. |
req.tenant_id / req.user_id | Who triggered it. |
req.api_base_url | This project's REST API. |
req.shape | After-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. Mutatingreq.bodyin place also works. - Raise
Halt(status, code, message)— reject the request. Allowed statuses are400,401,403,404,409,422and429. Anything else becomes a 500; a Function cannot forge a success or a redirect. - Raise anything else — handled per the Function's
error_handlingsetting:failrejects the request,logandignorecontinue 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
| Trigger | Budget | Notes |
|---|---|---|
Synchronous before-request | 2s | The whole chain, not per Function. Sits in front of a ~30ms write. |
Synchronous after-request | 5s | The write is already committed. |
Synchronous endpoint | 30s | Keep 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. |
| Asynchronous | 5 min | Nobody 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
| Variables | Secrets | |
|---|---|---|
| Read as | req.env["KEY"] | req.secrets["KEY"] |
| Stored | Plain text | Encrypted |
| Readable back | Yes — that is the point | Never, by any API |
| Set with | set_variable | set_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.
/tmpdoes not survive between invocations. - Languages other than Python.
Diagnosing
| Symptom | Cause |
|---|---|
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 failed | Read the deployment's message. Usually a handler path that is not in the bundle. |
| A Function is not running | Check 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 public | The endpoint's auth is still "required", the default. |