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

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: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 } }.

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

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.

Was this page helpful?