---
title: "Run your own code"
description: "Publish a JavaScript or Python function that reads, checks and then writes through your actions, plan a run for one of your users, have them approve the exact writes, and apply the plan with a receipt for each write."
---

Some logic does not fit in one action. A refund should go out only when the order is paid and the amount is no more than the order total, and then the customer should get an email. Letting the model chain those calls means trusting it to run the checks every time. Running the logic on your own server means holding the Stripe and email keys there and building the approval, the limits and the record yourself.

A **hosted function** is that logic as your own code, run by Anlyon with no network, no environment variables and no secrets. Its only way to change anything is calling your actions, so every write is limited, approved and receipted like any action call. And its writes can be approved as one exact list before any of them is sent.

This guide builds a function called `refund-with-checks`, in JavaScript and in Python, and runs it for one of your users:

- A **plan run** reads the order for real and records the refund and the email without sending them.
- **The user approves** those exact two writes in your app.
- An **apply run** sends them, each as its own action call with its own receipt.

Read [Hosted functions](/execution/hosted-functions) for what holds a function in, and why.

## Before you start

You need three actions in the environment. A call is a read when the action's method is `GET`, and a write otherwise.

| Action | Method | What it does |
| --- | --- | --- |
| `get-order` | `GET` | Reads an order from your shop's API: `status`, `total` (in cents), `charge` and `email` |
| `refund-order` | `POST` | Refunds a Stripe charge: `charge` and `amount` |
| `send-email` | `POST` | Sends one email with Resend: `to`, `subject` and `text` |

See [Actions](/execution/actions) and [Govern any HTTP API](/guides/any-http-api) to declare them.

Give `refund-order` and `send-email` an approval policy with **A person must approve the call** and **Decided by: End user**. The plan then goes to the user the run acts for. See [Decide who approves a plan](#decide-who-approves-a-plan) for the rules, and [Act for your users](/guides/act-for-your-users) for end users, their connections and their approvals.

Keys. None of the `functions:*` scopes is granted by default:

- An **operator key** with `functions:write` publishes the function.
- Your **app's key** with `functions:run` and `actions:invoke` runs it, with `functions:read` reads its runs, and with `end-users:approvals` relays the user's decision.

In the console, publishing needs a role that manages actions, and running needs a role that invokes them.

## Write the function

The function gets the run's `input` and the `anlyon` binding:

| Call | What it does |
| --- | --- |
| `anlyon.read(action, input)` | Calls a `GET` action. Any other action is refused with `NOT_A_READ`. |
| `anlyon.invoke(action, input)` | Calls any action. |
| `anlyon.log(message)` | Adds a line to the run's `logs`. |

Each call answers `{ ok: true, result }` or `{ ok: false, error: { code, message } }`. It does not throw. `ok: false` means the call was refused and nothing was sent: `ACTION_NOT_ALLOWED`, `PLAN_REQUIRED`, `RUN_DIVERGED`, or the code an action call would get, such as `END_USER_NOT_CONNECTED`. `ok: true` means the call was made: `result` is the action invocation, `result.json` the API's reply parsed as JSON (`null` when it is not JSON), `result.body` the same reply as text, and `result.status` the invocation's outcome. In a plan run a write answers `{ ok: true, result: { planned: true } }`.

```javascript JavaScript
// refund-with-checks.js
export default async function run(input, anlyon) {
  const lookup = await anlyon.read('get-order', { order: input.order });
  if (!lookup.ok) return { refunded: false, reason: lookup.error.code };
  const order = lookup.result.json;

  if (order.status !== 'paid') return { refunded: false, reason: 'not_paid' };
  if (input.amount > order.total) return { refunded: false, reason: 'more_than_total' };

  const refund = await anlyon.invoke('refund-order', { charge: order.charge, amount: input.amount });
  if (!refund.ok) return { refunded: false, reason: refund.error.code };

  const email = await anlyon.invoke('send-email', {
    to: order.email,
    subject: `Your refund for order ${input.order}`,
    text: `We refunded ${input.amount / 100} to your card.`,
  });
  await anlyon.log(`refund for ${input.order}, email ${email.ok ? 'queued' : 'refused'}`);
  return { refunded: true, emailed: email.ok };
}
```

```python Python
# refund_with_checks.py
async def run(input, anlyon):
    lookup = await anlyon.read("get-order", {"order": input["order"]})
    if not lookup["ok"]:
        return {"refunded": False, "reason": lookup["error"]["code"]}
    order = lookup["result"]["json"]

    if order["status"] != "paid":
        return {"refunded": False, "reason": "not_paid"}
    if input["amount"] > order["total"]:
        return {"refunded": False, "reason": "more_than_total"}

    refund = await anlyon.invoke(
        "refund-order", {"charge": order["charge"], "amount": input["amount"]}
    )
    if not refund["ok"]:
        return {"refunded": False, "reason": refund["error"]["code"]}

    email = await anlyon.invoke(
        "send-email",
        {
            "to": order["email"],
            "subject": f"Your refund for order {input['order']}",
            "text": f"We refunded {input['amount'] / 100} to your card.",
        },
    )
    await anlyon.log(f"refund for {input['order']}, email {'queued' if email['ok'] else 'refused'}")
    return {"refunded": True, "emailed": email["ok"]}
```

JavaScript is an ES module whose default export is `run(input, anlyon)`. Python is a module that defines `run(input, anlyon)`. The binding's calls are awaited, so this one is `async def run`. The function has no `fetch`, no sockets, no environment variables and no secrets. The Stripe and Resend keys stay in the actions.

## Publish the function

Create the function once, then publish the source as a version. A version never changes. Publishing again adds a new one, and runs use the newest.

```typescript TypeScript
// scripts/publish-refund.ts
import { readFileSync } from 'node:fs';
import { Client } from '@anlyonhq/sdk';

const ops = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! });

await ops.functions.create({
  name: 'refund-with-checks',
  description: 'Refund a paid order when the amount is within its total, then email the customer.',
});

const { data } = await ops.functions.publish('refund-with-checks', {
  language: 'javascript',
  source: readFileSync('refund-with-checks.js', 'utf8'),
  inputSchema: {
    type: 'object',
    properties: { order: { type: 'string' }, amount: { type: 'integer' } },
    required: ['order', 'amount'],
  },
  budgets: { maxActionCalls: 5 },
});

console.log(data!.version.allowedActions); // ['get-order', 'refund-order', 'send-email']
```

```bash curl
curl -s -X POST https://api.anlyon.com/api/v2/functions \
  -H "Authorization: Bearer $ANLYON_OPERATOR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "refund-with-checks", "description": "Refund a paid order when the amount is within its total, then email the customer." }'

node -e '
const source = require("fs").readFileSync("refund-with-checks.js", "utf8");
console.log(JSON.stringify({
  language: "javascript",
  source,
  inputSchema: { type: "object", properties: { order: { type: "string" }, amount: { type: "integer" } }, required: ["order", "amount"] },
  budgets: { maxActionCalls: 5 },
}));' > version.json

curl -s -X POST https://api.anlyon.com/api/v2/functions/refund-with-checks/versions \
  -H "Authorization: Bearer $ANLYON_OPERATOR_KEY" \
  -H "Content-Type: application/json" \
  -d @version.json
```

```python Python
# scripts/publish_refund.py
import os
from pathlib import Path

from anlyon import Client

ops = Client(api_key=os.environ["ANLYON_OPERATOR_KEY"])

ops.functions.create(
    name="refund-with-checks",
    description="Refund a paid order when the amount is within its total, then email the customer.",
)

published = ops.functions.publish(
    "refund-with-checks",
    language="python",
    source=Path("refund_with_checks.py").read_text(),
    input_schema={
        "type": "object",
        "properties": {"order": {"type": "string"}, "amount": {"type": "integer"}},
        "required": ["order", "amount"],
    },
    budgets={"max_action_calls": 5},
)

print(published.data["version"]["allowedActions"])  # ['get-order', 'refund-order', 'send-email']
```

The version's `allowedActions` is the list of actions it may call. Any other action is refused with `ACTION_NOT_ALLOWED`, and nothing is sent. Here it was left out, so Anlyon read it from the literal names in the source's `anlyon.read` and `anlyon.invoke` calls. A source that builds an action name at run time, or does not parse, is refused with `400 ALLOWED_ACTIONS_REQUIRED`: list `allowedActions` yourself. Each listed action must exist and be enabled (`400 ALLOWED_ACTION_UNAVAILABLE`). A version may list at most 100 actions.

The version also carries `review`: what the source reads and writes, and, when your workspace has [Anlyon Vigil](/trust-control/vigil) on, a short AI summary. Approvers see it beside the plan. It is advice. It never changes what a run may call.

## Plan a run for a user

Your server runs the function for the user who is signed in. A client from `forUser()` sends `Anlyon-End-User`, so every call the function makes uses that user's connections and counts against their limits. `mode` defaults to `plan`.

```typescript TypeScript
// app/api/refunds/route.ts
import { Client } from '@anlyonhq/sdk';
import { auth } from '@/lib/auth';

const anlyon = new Client({ apiKey: process.env.ANLYON_API_KEY! });

export async function POST(request: Request) {
  const session = await auth();
  if (!session) return new Response('Sign in.', { status: 401 });
  const { order, amount } = (await request.json()) as { order: string; amount: number };

  const user = anlyon.forUser(session.user.id);
  const { data: plan } = await user.functions.run('refund-with-checks', { input: { order, amount } });

  // 'planned' when the checks failed and nothing would be written.
  // 'awaiting_approval' with the writes and one approval otherwise.
  return Response.json({ runId: plan!.id, status: plan!.status, plan: plan!.plan, output: plan!.output });
}
```

```bash curl
curl -s -X POST https://api.anlyon.com/api/v2/functions/refund-with-checks/runs \
  -H "Authorization: Bearer $ANLYON_API_KEY" \
  -H "Anlyon-End-User: user_123" \
  -H "Content-Type: application/json" \
  -d '{ "input": { "order": "ord_123", "amount": 12000 } }'
```

```python Python
import os

from anlyon import Client

anlyon = Client(api_key=os.environ["ANLYON_API_KEY"])


def plan_refund(user_id: str, order: str, amount: int):
    user = anlyon.for_user(user_id)
    plan = user.functions.run("refund-with-checks", {"order": order, "amount": amount}).data
    # "planned" when the checks failed and nothing would be written.
    # "awaiting_approval" with the writes and one approval otherwise.
    return {"runId": plan["id"], "status": plan["status"], "plan": plan["plan"], "output": plan["output"]}
```

The request answers when the run ends. In the plan run, the read of `get-order` is sent for real. It changes nothing. Each write is recorded and nothing is sent. The run ends `awaiting_approval`:

```json
{
  "id": "frn_8Kq2LmZx",
  "functionName": "refund-with-checks",
  "mode": "plan",
  "status": "awaiting_approval",
  "endUserId": "user_123",
  "input": { "order": "ord_123", "amount": 12000 },
  "calls": [
    { "sequence": 1, "action": "get-order", "kind": "read", "state": "dispatched", "invocationId": "inv_3Hf9", "refusal": null, "plannedSequence": null },
    { "sequence": 2, "action": "refund-order", "kind": "write", "state": "intent", "invocationId": null, "refusal": null, "plannedSequence": null },
    { "sequence": 3, "action": "send-email", "kind": "write", "state": "intent", "invocationId": null, "refusal": null, "plannedSequence": null }
  ],
  "plan": {
    "digest": "sha256:4b1c…",
    "writes": [
      { "sequence": 2, "action": "refund-order", "input": { "charge": "ch_3P9x", "amount": 12000 } },
      { "sequence": 3, "action": "send-email", "input": { "to": "ada@example.com", "subject": "Your refund for order ord_123", "text": "We refunded 120 to your card." } }
    ],
    "approval": { "id": "apr_7Tn4", "status": "pending", "decider": "end_user" },
    "appliedByRunId": null
  }
}
```

The plan is one approval, of kind `function_plan`. It is bound to `plan.digest`, which covers the version, the input, the end user and every write in order, so it can never be carried out by another version, with other input, or for another user. A plan run whose checks fail makes no writes. It ends `planned`, needs no approval and has nothing to apply.

### Decide who approves a plan

Anlyon reads the environment's approval policies as if each write in the plan were called on its own, then combines them into the plan's one approval:

- **The user decides** when the run is for a user and every write's policy says `decidedBy: "end_user"`. A write that no policy matches counts as the user's when the workspace default sends such calls to the user. See [Workspace defaults](/trust-control/workspace-defaults).
- **Your team decides** otherwise, in the console's inbox, in Slack, or with `POST /api/v2/approvals/{id}/approve`. The plan needs the most approvals any of its writes' policies asks for, and at least one.
- **A write a policy denies denies the plan.** Its approval comes back `denied` at once, and the plan cannot be applied.
- **A policy that auto-approves a write does not auto-approve a plan.** To let a low-risk write skip the plan, use [straight through](#send-low-risk-writes-straight-through).

## Let the user approve the plan

The user sees the exact writes and decides. `payload.writes` on the approval is the list from the plan.

```typescript TypeScript
// app/api/refunds/[approvalId]/route.ts
import { Client } from '@anlyonhq/sdk';
import { auth } from '@/lib/auth';

const anlyon = new Client({ apiKey: process.env.ANLYON_API_KEY! });

export async function GET(_request: Request, { params }: { params: Promise<{ approvalId: string }> }) {
  const session = await auth();
  if (!session) return new Response('Sign in.', { status: 401 });
  const { approvalId } = await params;
  const { data } = await anlyon.endUsers.approvals.list(session.user.id, { status: 'pending' });
  const approval = (data ?? []).find((a) => a.id === approvalId && a.kind === 'function_plan');
  return Response.json(approval ?? null);
}

export async function POST(request: Request, { params }: { params: Promise<{ approvalId: string }> }) {
  const session = await auth();
  if (!session) return new Response('Sign in.', { status: 401 });
  const { approvalId } = await params;
  const { decision } = (await request.json()) as { decision: 'approve' | 'deny' };
  const { data } = await anlyon.endUsers.approvals.decide(session.user.id, approvalId, { decision });
  return Response.json({ status: data?.approval.status });
}
```

```bash curl
curl -s "https://api.anlyon.com/api/v2/end-users/user_123/approvals?status=pending" \
  -H "Authorization: Bearer $ANLYON_API_KEY" \
  -H "Anlyon-End-User: user_123"

curl -s -X POST https://api.anlyon.com/api/v2/end-users/user_123/approvals/apr_7Tn4/decide \
  -H "Authorization: Bearer $ANLYON_API_KEY" \
  -H "Anlyon-End-User: user_123" \
  -H "Content-Type: application/json" \
  -d '{ "decision": "approve" }'
```

```python Python
def pending_plans(user_id: str):
    approvals = anlyon.end_users.approvals.list(user_id, status="pending").data
    return [a for a in approvals if a["kind"] == "function_plan"]


def decide_plan(user_id: str, approval_id: str, decision: str):
    decided = anlyon.end_users.approvals.decide(user_id, approval_id, decision=decision).data
    return decided["approval"]["status"]
```

Approving a plan sends nothing. Your server applies it in the next step. The API answers `404` for an approval that belongs to another user.

## Apply the plan

Run the function again with `mode: "apply"` and the plan run's id. The apply run uses the plan's version, its input and its end user, so leave `input` out.

```typescript TypeScript
const user = anlyon.forUser(session.user.id);
const { data: applied } = await user.functions.run('refund-with-checks', { mode: 'apply', planRunId: runId });

console.log(applied!.status); // 'succeeded'
```

```bash curl
curl -s -X POST https://api.anlyon.com/api/v2/functions/refund-with-checks/runs \
  -H "Authorization: Bearer $ANLYON_API_KEY" \
  -H "Anlyon-End-User: user_123" \
  -H "Content-Type: application/json" \
  -d '{ "mode": "apply", "planRunId": "frn_8Kq2LmZx" }'
```

```python Python
user = anlyon.for_user(user_id)
applied = user.functions.run("refund-with-checks", mode="apply", plan_run_id=run_id).data

print(applied["status"])  # "succeeded"
```

The function runs from the start. It reads the order again, and the checks run again on what it reads now. Each write must equal the plan's next write: the same action, the same input, in the same order. A write that matches is sent as its own action invocation, under the user's limits, with its own receipt.

Both SDKs refuse `mode: "apply"` without a plan run id, and a plan run id with any other mode, before anything is sent.

## Read the receipt

```typescript TypeScript
const { data: run } = await user.functions.runs.get(applied!.id);
console.log(run!.receipt);

// Each write has its own action receipt.
const refundCall = run!.calls.find((call) => call.action === 'refund-order');
const { data: invocation } = await user.actions.invocation(refundCall!.invocationId!);
console.log(invocation!.status, invocation!.effectId);
```

```bash curl
curl -s https://api.anlyon.com/api/v2/function-runs/frn_9Wm1 \
  -H "Authorization: Bearer $ANLYON_API_KEY" \
  -H "Anlyon-End-User: user_123"
```

```python Python
run = user.functions.runs.get(applied["id"]).data
print(run["receipt"])

# Each write has its own action receipt.
refund_call = next(c for c in run["calls"] if c["action"] == "refund-order")
invocation = user.actions.invocation(refund_call["invocationId"]).data
print(invocation["status"], invocation["effectId"])
```

```json
{
  "functionId": "fn_2Wd8",
  "functionVersionId": "fnv_9Rk1",
  "digest": "sha256:9f86…",
  "inputDigest": "sha256:1a2b…",
  "mode": "apply",
  "planDigest": "sha256:4b1c…",
  "approvalId": "apr_7Tn4",
  "planRunId": "frn_8Kq2LmZx",
  "calls": [
    { "sequence": 1, "action": "get-order", "kind": "read", "state": "dispatched", "invocationId": "inv_5Jc2", "refusal": null, "plannedSequence": null },
    { "sequence": 2, "action": "refund-order", "kind": "write", "state": "dispatched", "invocationId": "inv_6Lp0", "refusal": null, "plannedSequence": 2 },
    { "sequence": 3, "action": "send-email", "kind": "write", "state": "dispatched", "invocationId": "inv_6Lp1", "refusal": null, "plannedSequence": 3 }
  ],
  "outcome": "succeeded",
  "budget": null,
  "finishedAt": "2026-10-24T10:02:11.000Z"
}
```

The receipt names the version and its digest, the input digest, the plan and its approval, and every call with the invocation it became. `plannedSequence` is the plan's write that each write matched. The run itself also carries `endUserId`, `output` and the lines from `anlyon.log`. A key that requires an end user reads only its own user's runs.

## Handle an apply that does not match the plan

Say the order is refunded from your shop's admin after the plan was approved. On the apply run, `get-order` now reads `status: "refunded"`, so the function returns before it writes anything. It made none of the two approved writes, so the run ends `diverged`, and nothing is sent:

```json
{
  "id": "frn_4Vb7",
  "mode": "apply",
  "status": "diverged",
  "error": { "code": "RUN_DIVERGED", "message": "The run made 0 of the 2 approved writes. The rest were not sent." },
  "planRunId": "frn_8Kq2LmZx"
}
```

A write that differs from the plan is stopped before it is sent. If the order's email had changed, the refund would match and be sent, and the email would be refused on its call:

```json
{ "sequence": 3, "action": "send-email", "kind": "write", "state": "refused", "invocationId": null, "refusal": "RUN_DIVERGED", "plannedSequence": null }
```

The run then ends `diverged` and sends nothing further. A write beyond the plan diverges the same way. Reads may differ from the plan run: the world can change between plan and apply. Writes may not. To go on, plan again. The new plan shows what the function would write now, and needs its own approval.

An approved write can also fail to go through when it is sent: refused by a limit, failed at the provider, or held for its own approval because its policy now asks for more approvals than the plan got. The run then ends `failed` with `APPLY_STOPPED`, and the writes after it are not sent.

## Send low-risk writes straight through

A version can let some writes skip the plan. Publish it with `straightThrough: { actions: ['send-email'], maxWrites: 1 }` (`straight_through={"actions": [...], "max_writes": 1}` in Python), then run with `mode: "straight_through"`. Each call is sent as it is made, and is governed like any action call: limited, held when its policy needs an approval, and receipted. A write to any other action, or one more than `maxWrites`, is refused with `PLAN_REQUIRED`, and nothing is sent. Each `straightThrough` action must be in `allowedActions`. Without `straightThrough`, a straight-through run can read and cannot write.

## Run it from an agent over MCP

Each published function is also an MCP tool named `function_<name>` on `https://api.anlyon.com/mcp`, beside the action tools. It takes `input` and, to apply an approved plan, `planRunId`. Called without `planRunId`, it plans. A caller without `functions:read` sees no function tools. See [MCP servers](/ai-tools/mcp-servers#tools).

## Set budgets

A run that runs out of a budget ends `budget_exceeded`, with `budget` naming which one. Set them per version with `budgets`.

| Budget | Default | Most a version may set |
| --- | --- | --- |
| `maxActionCalls`: action calls in one run, recorded writes included | 50 | 500 |
| `maxCpuMs`: CPU time, in ms | 1,000 | 30,000 |
| `maxSubrequests`: calls through the binding, refused ones included | 100 | 1,000 |
| `timeoutMs`: wall time, in ms | 30,000 | 300,000 |

Budgets bound one run. Money and other quantities stay bounded by your [impact limits](/execution/impact-limits). See [Hosted functions](/execution/hosted-functions#budgets).

## Fix a refused run

| What you see | Why | What to do |
| --- | --- | --- |
| `503 FUNCTION_RUNNER_UNAVAILABLE` | The runner could not be reached, or hosted functions are not configured where you called. Nothing ran. | Retry later. If it persists, contact support. |
| `404 FUNCTION_NOT_FOUND` | No function with that name in this key's environment. | Check the name and the key's environment. |
| `409 FUNCTION_NOT_PUBLISHED` | The function has no version yet. | Publish a version. |
| `400` `Invalid input: …` | The input does not match the version's `inputSchema`. | Fix the input. |
| `400 END_USER_REQUIRED` | The key requires an end user and the run named none. | Run with `forUser()`. |
| `409 PLAN_NOT_APPROVED` | The plan's approval is pending, denied or expired. | Wait for the approval, or plan again. |
| `409 PLAN_ALREADY_APPLIED` | A plan is applied once. | Plan again for another approval. |
| `409 PLAN_EXPIRED` | The plan was approved more than 24 hours ago. Nothing was sent. | Plan again for a new approval. |
| `409 PLAN_HAS_NO_WRITES` or `409 NOT_A_PLAN` | The run you named ended `planned`, or is not a plan run. | Apply a plan run that ended `awaiting_approval`. |
| `409 PLAN_INPUT_MISMATCH` | The apply run was given input different from the plan's. | Leave `input` out of the apply run. |
| Run `diverged`, `RUN_DIVERGED` | A write did not match the approved plan. | Plan again. |
| Run `failed`, `APPLY_STOPPED` | An approved write was refused, failed or held when sent. | Read the call's invocation and fix the cause, then plan again. |
| Call refused, `PLAN_REQUIRED` | A straight-through run wrote outside its policy. | Add the action to `straightThrough`, or run with `mode: "plan"`. |
| Call refused, `ACTION_NOT_ALLOWED` | The action is not in the version's `allowedActions`. | Publish a version that lists it. |
| Run `budget_exceeded` | The run used up the budget named in `budget`. | Raise that budget in a new version. |
| `403 AUTHORIZATION_ERROR` naming a scope | Running needs `functions:run` and `actions:invoke`. Reading runs needs `functions:read`. | Create a key with those scopes. |
| `409 ENVIRONMENT_HALTED` | The environment is halted. A halt also stops a running function call by call. | Resume the environment. See [Environments](/trust-control/environments). |

**[Hosted functions](/execution/hosted-functions)**

Confinement, the allow-list, plans and budgets.

**[Act for your users](/guides/act-for-your-users)**

End users, their connections and their approvals.

**[Govern any HTTP API](/guides/any-http-api)**

Declare the actions a function calls.

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

Policies, deciders and the inbox.
