Skip to content
Anlyon
Esc
↑↓navigate↵open⌘Jpreview
On this page

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 anlyon binding.
  • 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},
).data

A 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("…") and anlyon.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, with POST /api/v2/end-users/{endUserId}/approvals/{approvalId}/decide. Otherwise the team decides, in the console, in Slack or with POST /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_approve policy 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 input out; other input answers 409 PLAN_INPUT_MISMATCH. Another end user’s plan answers 404.
  • 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_DIVERGED before it is sent. The run ends diverged and nothing further is sent. A run that makes fewer writes than the plan also ends diverged. 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 failed with APPLY_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 answers 409 PLAN_NOT_APPROVED, and one with no writes 409 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_EXPIRED and 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, refused or failed), the reason it was refused, and the action invocation it became;
  • the outcome: succeeded, failed, budget_exceeded, planned, awaiting_approval or diverged.

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: functions on the TypeScript and Python clients, and on a client from forUser() or for_user().
  • MCP: each published function is a tool named function_<name>, beside the action tools. It takes input, and planRunId to apply an approved plan.

Was this page helpful?