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" };
}datais the input: the request body for an endpoint, the tool arguments for an agent, the job's fixed payload for a scheduled run.ctxis the context:ctx.sdk(your app's data and services, already scoped), the caller asctx.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
statusand a stringcodeis answered with that status and{ error, message, code }; anything else is a500.
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.appConfigfor 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 isHANDLER_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 by | Who it runs as | What authorizes its SDK calls |
|---|---|---|
| An endpoint (a custom action on a table) | The caller of the endpoint | The 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 API | The agent, or the key's agent | The same endpoint the tool is |
| An approval being replayed | Whoever's call paused for it | The same envelope the paused call had |
| A scheduled job (on schedule, Run now, Resume) | The job's own principal | The roles bound to that principal |
| Run live in the console | The principal you choose | The endpoint's list, as a real request; a job-only handler, that principal's roles |
| Dry run in the console | You, in a sandbox | Your 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_MANIFESTotherwise). 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 isHANDLER_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
kindfor the handler inauthoric.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.jobtoo (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:
| Status | When |
|---|---|
202 with { pending: true, approval } | The handler paused on sdk.awaitApproval; a person decides, then it is replayed. See Approvals. |
Your status and code | The handler threw an error carrying them. |
403 | The caller lacks the endpoint's action, or the handler is job-only (HANDLER_PLATFORM_ONLY). |
500 | The handler threw an ordinary error. The message is in the body. |
503 | HANDLER_WORKER_BUSY or HANDLER_WORKER_UNAVAILABLE. Transient: retry. |
504 | The 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
| Code | Status | Meaning |
|---|---|---|
HANDLER_NOT_FOUND | 404 | No live handler in the app has that name. |
HANDLER_NAME_AMBIGUOUS | 409 | Two live handlers share the name, so neither runs. Rename or archive one. |
HANDLER_PLATFORM_ONLY | 403 | The handler is job-only and this was not its scheduler or its owner's console. |
HANDLER_KIND_BOUND | 409 | You tried to make a handler job-only while an endpoint still runs it. |
HANDLER_NOT_IMPLEMENTED | 501 | The endpoint has no published handler behind it. |
SDK_CAP_NOT_IN_MANIFEST | 403 | The code used a power its published version was not frozen with. Publish again. |
SDK_UNKNOWN_METHOD | 400 | The code called an SDK member that does not exist. The message suggests the closest names. |
TABLE_LOCKED | 423 | The table is locked against that operation. |
Every code is listed in the error reference.
Next
The handler context
data, ctx.user, ctx.audience, ctx.job and the rest.
Test and publish
Dry run, run live, review and the frozen manifest.
Capabilities & governance
The elevated powers, and who must hold them.
Handler SDK reference
Every method, generated from the contract.
Scheduled jobs
Run a handler on a schedule, as its own principal.
Custom actions
Put a handler behind an endpoint.