Hosted functions
Run your own JavaScript or Python in Anlyon with no network, environment variables or secrets, so multi-step logic can read, decide and write only through governed actions, with every write planned and approved as an exact list before it is sent.
A hosted function is your own code, run by Anlyon. It holds logic that one action cannot: read something, check it, then decide what to write. Its writes are still action calls, so they are limited, approved and receipted like any other.
Why run logic as a hosted function
Some work is more than one call: refund an order only if it was paid and not already refunded, or label every open issue that matches a rule. Written as agent tool calls, each step is a separate decision by the model, and a reviewer approves one write at a time without seeing the whole change. Written as your own service, the code holds live keys again.
- Risk. The code has no network and no credentials. Its one way out is the actions its version lists, and each call goes through the same checks as an action call your agent makes. A reviewer approves the exact list of writes, and an apply that tries to send anything else is stopped before it is sent.
- Cost. A run has budgets for action calls, CPU time, binding calls and wall time, so a loop cannot run away. Money and other quantities stay under your impact limits.
- Effort. You do not host, sandbox or monitor a service for it. Every run keeps a receipt with the source digest, the input digest and every call in order.
How a function is confined
The guarantee does not come from reading your code. Code can build an action name at run time or assemble a payload from what it reads, and no reading of the source can say in general what it will do. The guarantee comes from two things:
- Where the code runs. It has no way out except the
anlyonbinding. - What it may call. Every call goes to an action its version lists, and through the same checks as an action call your agent makes.
A function runs with:
- No network.
fetch()and sockets throw. - No environment variables.
- No secrets. A credential lives in an action and is put on the request when Anlyon sends it. The function never sees it.
Its one way to affect anything is the binding it is passed:
| Call | What it does |
|---|---|
anlyon.invoke(action, input) |
Calls an action |
anlyon.read(action, input) |
Calls an action whose method is GET. Any other action is refused with NOT_A_READ |
anlyon.log(message) |
Adds a line to the run’s logs (at most 100) |
invoke and read answer { ok: true, result } or { ok: false, error: { code, message } }. They do not throw. result is the action invocation: result.json is the app’s reply parsed as JSON (null when it is not JSON), result.body the same reply as text, and result.responseStatus its HTTP status. A write in a plan run answers { ok: true, result: { planned: true } }. A call is a read when the action’s method is GET and a write otherwise, whichever of the two you used.
Each call goes through Anlyon’s governed path for that action: the impact limits, the approval policy, the halt switch, and the receipt. When the run is for an end user (Anlyon-End-User), every call uses that user’s connections and counts against their limits.
Write a function
A JavaScript function is an ES module whose default export is async function run(input, anlyon). A Python function is a module that defines run(input, anlyon) or async def run(input, anlyon). One module, at most 256 KB. input is checked against the version’s inputSchema before the run starts, and what run returns is the run’s output.
// refund-if-paid.js: refund an order only when it was paid.
export default async function run(input, anlyon) {
const order = await anlyon.read('get-order', { id: input.orderId });
if (!order.ok) return { refunded: false, reason: order.error.message };
if (order.result.json.status !== 'paid') {
anlyon.log(`Order ${input.orderId} is ${order.result.json.status}; nothing to refund.`);
return { refunded: false, reason: 'not paid' };
}
const refund = await anlyon.invoke('refund-order', {
charge: order.result.json.chargeId,
amount: order.result.json.amount,
});
return { refunded: refund.ok, planned: refund.ok && refund.result.planned === true };
}
get-order and refund-order are actions in the same environment: a GET and a POST.
Create and publish a function
Publishing and deleting need functions:write. A version never changes: publishing again appends one, with its own source, allow-list and budgets, and it becomes the version runs use.
import { readFile } from 'node:fs/promises';
import { Client } from '@anlyonhq/sdk';
// An operator key with functions:write.
const ops = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! });
await ops.functions.create({ name: 'refund-if-paid', description: 'Refund an order only when it was paid.' });
const { data: published } = await ops.functions.publish('refund-if-paid', {
language: 'javascript',
source: await readFile('./refund-if-paid.js', 'utf8'),
allowedActions: ['get-order', 'refund-order'],
inputSchema: {
type: 'object',
properties: { orderId: { type: 'string' } },
required: ['orderId'],
},
budgets: { maxActionCalls: 5, timeoutMs: 10_000 },
});curl -X POST https://api.anlyon.com/api/v2/functions \
-H "Authorization: Bearer $ANLYON_OPERATOR_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "refund-if-paid", "description": "Refund an order only when it was paid." }'
jq -n --rawfile source refund-if-paid.js '{
language: "javascript",
source: $source,
allowedActions: ["get-order", "refund-order"],
inputSchema: { type: "object", properties: { orderId: { type: "string" } }, required: ["orderId"] },
budgets: { maxActionCalls: 5, timeoutMs: 10000 }
}' | curl -X POST https://api.anlyon.com/api/v2/functions/refund-if-paid/versions \
-H "Authorization: Bearer $ANLYON_OPERATOR_KEY" \
-H "Content-Type: application/json" \
--data-binary @-import os
from pathlib import Path
from anlyon import Client
# An operator key with functions:write.
ops = Client(api_key=os.environ["ANLYON_OPERATOR_KEY"])
ops.functions.create(name="refund-if-paid", description="Refund an order only when it was paid.")
published = ops.functions.publish(
"refund-if-paid",
language="javascript",
source=Path("refund-if-paid.js").read_text(),
allowed_actions=["get-order", "refund-order"],
input_schema={
"type": "object",
"properties": {"orderId": {"type": "string"}},
"required": ["orderId"],
},
budgets={"maxActionCalls": 5, "timeoutMs": 10_000},
).dataA function name is 1 to 100 characters of a-z A-Z 0-9 _ -. The operations: create, publish a version, list versions and get a version with its source.
Limit what a version may call
Each version lists the actions it may call in allowedActions, up to 100. A call to any other action is refused with ACTION_NOT_ALLOWED and nothing is sent. The list is checked where the function runs and again by the API.
- Each listed action must exist in the environment and be enabled when you publish (
400 ALLOWED_ACTION_UNAVAILABLE). - Left out at publish, the list is read from the source’s literal
anlyon.invoke("…")andanlyon.read("…")calls. - A source that computes an action name, or does not parse, must list it (
400 ALLOWED_ACTIONS_REQUIRED).
At publish, Anlyon also reads the source for calls in loops and for network, file, process, environment and eval APIs. When Anlyon Vigil is on for the workspace, an AI review adds a short summary and a suggested risk tier. Both are stored as the version’s review and shown to approvers beside a plan. They are advice. Nothing that allows or refuses a call reads them: a run may call what allowedActions lists, whatever the review says.
Set the run budgets
Each version sets its own budgets. A run that runs out ends budget_exceeded, and budget names which one.
| Budget | What it counts | Default | Range |
|---|---|---|---|
maxActionCalls |
Action calls in one run. In a plan run, each recorded write counts | 50 | 1 to 500 |
maxCpuMs |
CPU time in one run, in ms | 1,000 | 1 to 30,000 |
maxSubrequests |
Calls through the binding in one run, refused ones included | 100 | 1 to 1,000 |
timeoutMs |
Wall time of one run, in ms | 30,000 | 100 to 300,000 |
Budgets bound one run. Money and other quantities are bounded by your impact limits, which every write counts against as usual.
Your plan sets how many runs the workspace may start a month, how many may go at once and how many may start a minute. See hosted function limits. A run’s action calls count as governed actions; the run itself counts once.
Plan, approve, then apply a run
Running needs functions:run and actions:invoke. A run answers when it ends. Its mode says what happens to writes:
mode |
Reads | Writes |
|---|---|---|
plan (the default) |
Run for real | Recorded and not sent. The run ends planned with no writes, or awaiting_approval with one approval for all of them |
apply, with planRunId |
Run for real | Sent, each only if it equals the plan’s next write |
straight_through |
Run for real | Sent as made, only to the actions the version’s straightThrough policy names |
import { Client } from '@anlyonhq/sdk';
const anlyon = new Client({ apiKey: process.env.ANLYON_API_KEY! }); // functions:run and actions:invoke
// 1. Plan: reads run, writes are recorded.
const { data: plan } = await anlyon.functions.run('refund-if-paid', { input: { orderId: 'ord_123' } });
if (plan!.status === 'awaiting_approval') {
console.log(plan!.plan!.writes); // [{ sequence: 2, action: 'refund-order', input: { charge, amount } }]
console.log(plan!.plan!.approval!.id); // apr_..., decided in the console, in Slack or through the API
// 2. After the plan is approved, apply it.
const { data: applied } = await anlyon.functions.run('refund-if-paid', { mode: 'apply', planRunId: plan!.id });
console.log(applied!.status, applied!.output);
}curl -X POST https://api.anlyon.com/api/v2/functions/refund-if-paid/runs \
-H "Authorization: Bearer $ANLYON_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "input": { "orderId": "ord_123" } }'
# After data.plan.approval is approved, with the plan run's data.id:
curl -X POST https://api.anlyon.com/api/v2/functions/refund-if-paid/runs \
-H "Authorization: Bearer $ANLYON_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "mode": "apply", "planRunId": "frn_V1StGXR8_Z5jdHi6B-myT" }'import os
from anlyon import Client
anlyon = Client(api_key=os.environ["ANLYON_API_KEY"]) # functions:run and actions:invoke
plan = anlyon.functions.run("refund-if-paid", {"orderId": "ord_123"}).data
if plan["status"] == "awaiting_approval":
print(plan["plan"]["writes"], plan["plan"]["approval"]["id"])
# After the plan is approved:
applied = anlyon.functions.run("refund-if-paid", mode="apply", plan_run_id=plan["id"]).data
print(applied["status"], applied["output"])A plan run with writes answers like this (abridged to the fields you act on):
{
"success": true,
"data": {
"id": "frn_V1StGXR8_Z5jdHi6B-myT",
"functionName": "refund-if-paid",
"mode": "plan",
"status": "awaiting_approval",
"endUserId": null,
"output": { "refunded": true, "planned": true },
"actionCalls": 2,
"calls": [
{ "sequence": 1, "action": "get-order", "kind": "read", "state": "dispatched", "invocationId": "inv_01JABCDEF", "refusal": null, "plannedSequence": null },
{ "sequence": 2, "action": "refund-order", "kind": "write", "state": "intent", "invocationId": null, "refusal": null, "plannedSequence": null }
],
"plan": {
"digest": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"writes": [{ "sequence": 2, "action": "refund-order", "input": { "charge": "ch_3P9x", "amount": 12000 } }],
"approval": { "id": "apr_V1StGXR8_Z5jdHi6B-myT", "status": "pending", "decider": "team" },
"appliedByRunId": null
}
}
}
How a plan is approved
The approval is of kind function_plan and is bound to plan.digest, a SHA-256 over the version, the input, the end user and every write in order. Approving it approves that exact list.
- Who decides. The policy for each write is checked as if the write were called on its own. When the run is for an end user and every write’s policy has
decidedBy: "end_user", the end user decides, in your app, withPOST /api/v2/end-users/{endUserId}/approvals/{approvalId}/decide. Otherwise the team decides, in the console, in Slack or withPOST /api/v2/approvals/{id}/approve. - How many approvers. The plan needs as many approvers as the strictest matching write policy asks for. With no matching policy, it needs one approval by the team.
- Denied outright. A write that its policy denies outright denies the plan. An
auto_approvepolicy does not skip the plan’s approval. - Expiry. A plan approval that nobody decides in time expires like any approval, and the plan is never applied.
How an apply is checked
- It uses the plan’s version, input and end user. Leave
inputout; other input answers409 PLAN_INPUT_MISMATCH. Another end user’s plan answers404. - The function runs again from the start. Reads may differ from the plan run: the world can change in between.
- Each write must equal the plan’s next write: the same action, the same input, in the same order. A match is sent as its own action invocation, with its own receipt.
- A write that does not match, or one more than the plan has, is refused with
RUN_DIVERGEDbefore it is sent. The run endsdivergedand nothing further is sent. A run that makes fewer writes than the plan also endsdiverged. Plan again for a new approval. - An approved write that does not go through (refused by a limit, failed, or held for its own approval because its policy now needs more approvers) ends the run
failedwithAPPLY_STOPPED. The writes after it are not sent. - A plan is applied at most once (
409 PLAN_ALREADY_APPLIED). A plan that is not approved answers409 PLAN_NOT_APPROVED, and one with no writes409 PLAN_HAS_NO_WRITES. - A plan is applied within 24 hours of its approval. Later, what it read may have changed, so the apply answers
409 PLAN_EXPIREDand nothing is sent. Plan again for a new approval.
Run straight through for routine writes
straight_through sends each call as it is made. Its writes must be to the actions in the version’s straightThrough: { actions, maxWrites } policy, up to maxWrites writes a run (1 to 500). Each listed action must also be in allowedActions. Any other write is refused with PLAN_REQUIRED and nothing is sent. Each write is still limited, held by its policy when it needs approval, and receipted. A version without straightThrough can read in this mode and cannot write.
await ops.functions.publish('label-new-issues', {
language: 'javascript',
source,
allowedActions: ['github-list-issues', 'github-add-labels'],
straightThrough: { actions: ['github-add-labels'], maxWrites: 20 },
});
await anlyon.functions.run('label-new-issues', { mode: 'straight_through', input: { repo: 'acme/app' } });
Read the run receipt
Each run keeps a receipt. Read it with functions.runs.get(id) (both SDKs) or GET /api/v2/function-runs/{id}, and list a function’s runs with functions.listRuns(name) / functions.list_runs(name):
- the function version and the SHA-256 of its source;
- the SHA-256 of the input, and the end user the run acted for;
- for a plan or an apply, the plan digest and the approval;
- every call in order, with its state (
intent,dispatched,refusedorfailed), the reason it was refused, and the action invocation it became; - the outcome:
succeeded,failed,budget_exceeded,planned,awaiting_approvalordiverged.
Each write that was sent also has its own action receipt. See Receipts and grades.
Handle run errors
A function’s own failures end the run with status and error.code; request problems answer an HTTP error.
| Where | Code | Meaning | What to do |
|---|---|---|---|
| Publish | 400 ALLOWED_ACTION_UNAVAILABLE |
A listed action does not exist or is disabled. | Create or enable it, or remove it from the list. |
| Publish | 400 ALLOWED_ACTIONS_REQUIRED |
The source computes an action name, or does not parse. | List the actions in allowedActions. |
| Run | 409 FUNCTION_NOT_PUBLISHED |
The function has no version yet. | Publish one. |
| Run | 409 PLAN_NOT_APPROVED, PLAN_ALREADY_APPLIED, PLAN_HAS_NO_WRITES, PLAN_INPUT_MISMATCH, PLAN_EXPIRED |
The apply does not fit the plan run, or the plan was approved more than 24 hours ago. | Plan again, or wait for the approval. |
| Run | 409 ENVIRONMENT_HALTED |
The environment’s kill switch is on. A run already going is stopped call by call. | Wait for an operator to resume it. |
| Run | 503 FUNCTION_RUNNER_UNAVAILABLE |
The function runner could not be reached. Nothing ran, and the run is not counted. | Retry shortly. |
| Run | 402 PAYMENT_REQUIRED |
The workspace has no plan. Nothing ran. | Choose a plan in Settings → Plan & billing. |
| Run | 429 FUNCTION_RUN_CONCURRENCY_LIMIT |
The plan’s runs at once are all going. Nothing ran. | Retry when a run ends. |
| Run | 429 FUNCTION_RUN_RATE_LIMITED |
The plan’s run starts for this minute are used. Nothing ran. | Retry in a minute. |
| Run | 429 QUOTA_EXCEEDED |
This month’s runs are used. Nothing ran. | Wait for the 1st (UTC), or change plan. |
| Call result | ACTION_NOT_ALLOWED, NOT_A_READ, PLAN_REQUIRED |
The call was refused and nothing was sent. The function gets { ok: false } and carries on. |
Fix the allow-list, the method, or the straightThrough policy. |
| Call result | CREDENTIAL_NO_LONGER_ALLOWED |
The key that started the run was revoked or lost functions:run or actions:invoke, or the person who started it can no longer run actions here. Each call checks again. Nothing was sent. |
Start the run with a credential that may run it. |
| Call result | RUN_ENDED |
The function called after run returned, for example from waitUntil. Nothing was sent. |
Await every call before run returns. |
Run status diverged |
RUN_DIVERGED |
An apply’s write did not match the plan. | Plan again for a new approval. |
Run status failed |
APPLY_STOPPED, FUNCTION_THREW |
An approved write did not go through, or your code threw. | Read error.message and the calls. |
Run status budget_exceeded |
A budget ran out; budget names it. |
Raise the budget, or do less per run. |
Until hosted functions are available, every functions route answers 404.
Delete a function
Delete a function with DELETE /api/v2/functions/{name}, functions.delete(name) in either SDK, or Delete on the function’s page in the console. It leaves the list and refuses new runs. Its versions and runs stay, as the record of what it did. Its name is free to use again.
Grant the right scopes
| Scope | Lets a key |
|---|---|
functions:write |
Create, publish and delete functions |
functions:run |
Run a function. Needs actions:invoke as well |
functions:read |
List functions and versions, and read runs, which hold their input and output |
These scopes are privileged: none is granted by default, and none is offered on an OAuth consent screen. Keep functions:write off the key that runs functions, so the agent cannot rewrite the code or the allow-list it runs under. In the console, publishing needs the role that manages actions (admin or member), and running the role that invokes them. A key created with endUserRequired refuses a run without Anlyon-End-User, and reads its own end user’s runs and no others. See Authentication.
Where functions appear
- API: the Functions group in the API reference.
- SDKs:
functionson the TypeScript and Python clients, and on a client fromforUser()orfor_user(). - MCP: each published function is a tool named
function_<name>, beside the action tools. It takesinput, andplanRunIdto apply an approved plan.

