Run your own code
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 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 and Govern 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 for the rules, and 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:writepublishes the function. - Your app’s key with
functions:runandactions:invokeruns it, withfunctions:readreads its runs, and withend-users:approvalsrelays 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 } }.
// 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 };
}# 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.
// 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']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# 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 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.
// 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 });
}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 } }'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:
{
"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. - 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
deniedat 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.
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.
// 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 });
}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" }'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.
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'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" }'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
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);curl -s https://api.anlyon.com/api/v2/function-runs/frn_9Wm1 \
-H "Authorization: Bearer $ANLYON_API_KEY" \
-H "Anlyon-End-User: user_123"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"]){
"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:
{
"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:
{ "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.
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. See Hosted functions.
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. |

