---
title: "Hosted functions"
description: "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](/execution/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](/execution/actions) 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](/execution/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](/execution/end-user-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`.

```javascript
// 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.

```typescript TypeScript
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 },
});
```

```bash curl
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 @-
```

```python Python
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](/api-reference/functions/create-function), [publish a version](/api-reference/functions/publish-function-version), [list versions](/api-reference/functions/list-function-versions) and [get a version](/api-reference/functions/get-function-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](/trust-control/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.

<a id="budgets"></a>

### 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](/execution/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](/account/billing#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 |

```typescript TypeScript
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);
}
```

```bash curl
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" }'
```

```python Python
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):

```json
{
  "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.

```typescript
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}`](/api-reference/functions/get-function-run), 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](/execution/outcomes).

## 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](/authentication).

## Where functions appear

- **API:** the Functions group in the [API reference](/api-reference/functions/run-function).
- **SDKs:** `functions` on the [TypeScript](/guides/typescript-sdk#functions) and [Python](/guides/python-sdk#functions) 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.

## Related

**[Run your own code](/guides/run-your-own-code)**

A worked example from source to applied plan.

**[Actions](/execution/actions)**

The calls a function makes, and where the credentials live.

**[Approvals and policies](/trust-control/approvals)**

The policies that decide who approves a plan.

**[Impact limits](/execution/impact-limits)**

Caps every write counts against, whichever function makes it.
