Handlers

Your own server-side code, run in a sandbox with a governed SDK, reached through endpoints, agents and schedules.

A handler is a JavaScript function you write and Authoric runs on the server. Use one when logic belongs next to the data: a workflow that touches several tables, a calculation, an operation that must be atomic, a call to an outside service, a nightly sweep.

A handler does not run on its own. Something runs it: a request to an endpoint, an agent calling it as a tool, a scheduled job, or you, testing it in the console. The same published code serves all of them, and the platform decides, for each one, what the handler may do.

The shape

Every handler is one default-exported async function of two arguments:

export default async function refundOrder(data, ctx) {
  const { sdk } = ctx;

  const order = await sdk.read("orders", data.orderUid);
  if (!order) {
    throw Object.assign(new Error("No such order."), { status: 404, code: "ORDER_NOT_FOUND" });
  }
  if (order.status !== "paid") {
    throw Object.assign(new Error("Only a paid order can be refunded."), {
      status: 409,
      code: "ORDER_NOT_REFUNDABLE",
    });
  }

  await sdk.withTransaction(async (tx) => {
    await tx.update("orders", order.uid, { status: "refunded" });
    await tx.create("refunds", { order_uid: order.uid, amount_cents: order.total_cents });
  });

  return { orderUid: order.uid, status: "refunded" };
}
  • data is the input: the request body for an endpoint, the tool arguments for an agent, the job's fixed payload for a scheduled run.
  • ctx is the context: ctx.sdk (your app's data and services, already scoped), the caller as ctx.user, route parameters, the app's configuration, and, in a scheduled run, ctx.job. See The handler context.
  • What you return becomes the response body, as JSON.
  • What you throw becomes an error response. An error carrying a numeric status and a string code is answered with that status and { error, message, code }; anything else is a 500.

Rows cross the SDK named by uid, never by a numeric id, and a reference arrives as <name>_uid (customer_uid) holding the other row's uid. Return the same shape: a handler's result must never carry an id. See Returning results below.

Where it runs

Handlers run in Deno, in an isolated sandbox, one fresh worker per invocation:

  • the sandbox holds no database credentials. Every sdk.* call travels to the API, which checks it and runs it. There is no way to reach the database except through the SDK;
  • it can read only its own app's handler files, has no environment variables (use ctx.appConfig for configuration), and can reach only the outside hosts the deployment allows;
  • it is killed at its timeout, so an endless loop costs one invocation, not the worker. A request's handler has 60 seconds by default; a scheduled job sets its own timeout per turn;
  • capacity is bounded per app, so one app's burst cannot take every slot. Over capacity a call waits briefly, then fails with HANDLER_WORKER_BUSY (503, retry). If the sandbox cannot be reached at all, it is HANDLER_WORKER_UNAVAILABLE (503, retry).

The SDK's full surface is in the Handler SDK reference, generated from the same contract the console's editor reads.

A handler has versions

You edit a draft. What runs is the published version: a frozen snapshot of the code, together with its capability manifest, the list of elevated SDK powers that code uses (raw SQL, email, storage writes, and so on). Editing the draft changes nothing that runs until a new version is published, and the manifest a version was published with is the one it keeps.

Draft

Write in the console under Code › Handlers, or generate a draft with AI from a description. Each save is checked: code that uses a power you do not hold, or builds raw SQL from strings, is refused before it is stored.

Dry run

Run the editor's code against your real data inside a transaction that is always rolled back. Outside calls (email, Stripe, tool servers) are recorded, not made.

Review and publish

Submit the version for review, approve it, publish it. The approver is checked against the version's powers too: nobody can approve a power they could not have written.

The details, including run live and repositories, are in Test and publish.

Ways a handler runs

Run byWho it runs asWhat authorizes its SDK calls
An endpoint (a custom action on a table)The caller of the endpointThe caller must hold the endpoint's own action; inside, the endpoint's allowed actions list decides each SDK call
An agent tool, over MCP or the agent APIThe agent, or the key's agentThe same endpoint the tool is
An approval being replayedWhoever's call paused for itThe same envelope the paused call had
A scheduled job (on schedule, Run now, Resume)The job's own principalThe roles bound to that principal
Run live in the consoleThe principal you chooseThe endpoint's list, as a real request; a job-only handler, that principal's roles
Dry run in the consoleYou, in a sandboxYour own SDK capabilities; per-call checks are skipped and every write is rolled back

Two things hold on every row of that table:

  • The org scope follows the caller. Inside the app, a call reads and writes only its caller's organization; the app owner's jobs and agents reach the organizations their org reach allows. Handler code never filters by org itself, and cannot widen the scope by passing an org.
  • The manifest is enforced. Raw SQL, email, storage, scheduled-job management, cross-org reads and external tool servers work only if the published version's frozen manifest includes them (SDK_CAP_NOT_IN_MANIFEST otherwise). See Capabilities & governance.

A handler behind an endpoint is a privilege boundary

When an endpoint runs a handler, the caller needs exactly one permission: the endpoint's own action (say orders:refund). Inside, each SDK call is checked against the endpoint's allowed actions list, not against the caller's other permissions. That is the point: you can let an agent refund an order without letting it update orders in general, because the handler, which you reviewed, is the only thing that can.

  • A call the list does not name is refused with HANDLER_DEPENDENCY_NOT_ALLOWED, which names the action. A call the endpoint explicitly denies is HANDLER_DEPENDENCY_DENIED.
  • Assigning a handler to an endpoint derives the list from the handler's source. Re-assign it after the code starts calling something new.
  • A handler that no endpoint fronts has no list, so its SDK calls are refused (HANDLER_DELEGATION_MISSING) except when a scheduled job runs it, which uses the job's own roles.
  • A few acts are always the caller's own and never the endpoint's: deciding an approval, and giving someone a role.

See Custom actions for defining the endpoint.

Job-only handlers

Some code should never be reachable from a request: a bulk mailing, a sweep across every organization's rows. Mark that handler job-only (its kind is worker). Then it runs only:

  • for the platform's scheduler: its scheduled firings, Run now and Resume;
  • for the app's owner, in person, in the console: Run live and Dry run.

Never from an endpoint, an agent, an MCP or external-agent tool, an API key, or an approval replay. Any of those gets HANDLER_PLATFORM_ONLY (403), whatever path it took to name the handler. The check sits where every run resolves the handler's name, so even an endpoint row written by hand cannot reach it.

  • In the console: open the handler under Code › Handlers, and under Who may run it choose Make job-only.
  • In a repository: set kind for the handler in authoric.json:
{
  "handlers": {
    "nightlyDunning": { "kind": "worker" }
  }
}

The other rules of kind:

  • Any author may make a handler job-only, but not while an endpoint still runs it: unassign it first, or the change is refused with HANDLER_KIND_BOUND (409).
  • Undoing it widens who may run the code, so only the app's owner, in the console, can let endpoints and agents run a job-only handler again.
  • A job-only handler runs on its principal's own roles, like every scheduled run.
  • When you try it from the console, it gets a ctx.job too (with no job or run uid), so code written for a job can be tested before it is scheduled.

Calling a handler

A handler with an endpoint is called like any other API route, with the caller's own credentials. The console shows each endpoint's URL on the handler's page.

curl -X POST "$AUTHORIC_URL/$APP_UID/autocrud/orders/actions/refund" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "orderUid": "0199a3c2-7f1e-7b4a-9c3d-5e8f2a1b6c90" }'

The body is the handler's return value. Other answers you should expect:

StatusWhen
202 with { pending: true, approval }The handler paused on sdk.awaitApproval; a person decides, then it is replayed. See Approvals.
Your status and codeThe handler threw an error carrying them.
403The caller lacks the endpoint's action, or the handler is job-only (HANDLER_PLATFORM_ONLY).
500The handler threw an ordinary error. The message is in the body.
503HANDLER_WORKER_BUSY or HANDLER_WORKER_UNAVAILABLE. Transient: retry.
504The handler ran past its timeout.

Returning results

The SDK's typed helpers (read, readList, create, …) already speak in uids: no row they return carries id, org_id or tenant_id. Raw SQL is different. sdk.sql runs inside the database and sees id and raw foreign keys, which is what makes its joins fast. What a handler returns must never carry an id: select uid, and a referenced row's uid through the join, for anything that leaves.

A handler returns exactly what you build, so build the result from the fields you mean to return, rather than returning a row minus the fields you remembered to remove: a column added to the table later would otherwise go out with it.

// Join on ids inside the database; return uids.
const rows = await sdk.sql`
  SELECT o.uid, o.status, c.uid AS customer_uid
    FROM orders o
    JOIN customers c ON c.id = o.customer_id
   WHERE o.status = ${"open"}
`;
return { orders: rows.map((r) => ({ uid: r.uid, status: r.status, customerUid: r.customer_uid })) };

readList returns an envelope when you page

sdk.readList returns a plain array only when you pass no limit, offset or paginate. With any of them it returns { items, paging }, and treating that as an array fails silently (.length is undefined). Use sdk.readPage when you always want the envelope.

sdk.readList without a limit returns every matching row up to 10,000. Past that it answers 422 READ_LIST_TOO_LARGE rather than a partial list: walk a large table with sdk.readPage, or narrow the read with filters.

Errors you will meet

CodeStatusMeaning
HANDLER_NOT_FOUND404No live handler in the app has that name.
HANDLER_NAME_AMBIGUOUS409Two live handlers share the name, so neither runs. Rename or archive one.
HANDLER_PLATFORM_ONLY403The handler is job-only and this was not its scheduler or its owner's console.
HANDLER_KIND_BOUND409You tried to make a handler job-only while an endpoint still runs it.
HANDLER_NOT_IMPLEMENTED501The endpoint has no published handler behind it.
SDK_CAP_NOT_IN_MANIFEST403The code used a power its published version was not frozen with. Publish again.
SDK_UNKNOWN_METHOD400The code called an SDK member that does not exist. The message suggests the closest names.
TABLE_LOCKED423The table is locked against that operation.

Every code is listed in the error reference.

Next

On this page