Custom actions
Your own endpoints on a table, backed by a handler, with a permission of their own.
A custom action is an endpoint you add to a table, backed by a handler. It is how you express anything the five generated operations cannot: a query with more than one condition, a workflow across several tables, a calculation, a call to an outside service.
invoices, vendors, payments). Your app has only the tables you create, so put your own table and column names in their place. About the example appPOST /{appUid}/autocrud/{table}/actions/{path}The console calls these Endpoints. Each one is three things together: a route, a permission key, and the handler it runs.
When to reach for one
- The query needs more than a filter: a join, a subquery, a calculation across tables. The REST list filters, sorts and pages one table (see Querying).
- The operation is a workflow, not a write. "Approve this invoice and schedule its payment"
touches
invoicesandpaymentsand must happen together. - You want a narrower permission than a whole table.
invoices:approveis a far better thing to grant a person or an agent thaninvoices:update, which would let them change the amount.
That last point is the one people miss. A custom action lets you grant an intent rather than a capability, which matters most when the caller is an AI agent.
Create one
Write and publish the handler
In the app's Handlers section, write the code the endpoint runs and publish it. The handler
receives the request body as data, and ctx carries the SDK, the caller and the request's
reqParams and reqQuery. Whatever it returns becomes the response body.
// approveInvoice: approve an invoice and schedule its payment, or refuse.
export default async function approveInvoice(data = {}, ctx) {
const { sdk } = ctx;
const invoice = await sdk.read("invoices", data.invoiceUid);
if (!invoice) return { approved: false, reason: "not_found" };
if (invoice.status !== "matched") return { approved: false, reason: `status is ${invoice.status}` };
// Both writes commit together, or neither does.
return sdk.withTransaction(async (tx) => {
await tx.update("invoices", invoice.uid, { status: "approved" });
const payment = await tx.create("payments", {
invoice_uid: invoice.uid,
payment_ref: `PAY-${invoice.invoice_number}`,
amount: invoice.amount,
method: "ach",
scheduled_for: invoice.due_at,
});
return { approved: true, payment_uid: payment.uid };
});
}Create the endpoint
In Endpoints, choose Create Endpoint and fill in:
- Method and Path. The path is everything after
/actions/, such asapprove. It becomesPOST /{appUid}/autocrud/invoices/actions/approve, and the console shows the full URL as you type. - Table: the table the endpoint lives on. Only tables you created can carry one.
- Description: what it does, in a line.
- Allowed actions and Denied actions: the SDK calls the handler may make (below).
- Schema: the payload the endpoint accepts. The assisted builder starts from the table's columns; switch to Manual JSON for a payload of your own.
Assign the handler
Back in Handlers, open the published handler and choose Assign to Endpoint. Assigning derives the Allowed actions from the handler's source, so check the list afterwards rather than typing it from scratch.
Grant it
Add the endpoint's key, invoices:approve, to a policy on the roles that should call it. Until
then it answers 403 to everyone, which is the safe default.
The permission key
The key is {table}:{type}, and the type is derived from the path: approve becomes
invoices:approve, bulk-open becomes invoices:bulk_open. A path may carry parameters
(/actions/:invoiceUid/approve), but they become part of the type (invoices:invoiceuid_approve).
For a clean key, keep the path a verb and send the record's uid in the body.
A path parameter, like every other handle in the API, is a uid, never a numeric id.
What the handler may do
An endpoint's handler does not run with the caller's own table permissions. It runs with exactly
the SDK calls its endpoint lists under Allowed actions, such as invoices:read,
invoices:update and payments:create, inside the caller's organization.
That is what lets you grant invoices:approve to someone who holds neither invoices:update nor
payments:create: they can approve invoices the way your code approves them, and nothing else.
- A call that is not on the list is refused with
403andHANDLER_DEPENDENCY_NOT_ALLOWED. The message names the call and lists what is allowed. - A call on Denied actions is refused with
403andHANDLER_DEPENDENCY_DENIED, even if it is also allowed. Use it to rule out a power explicitly, such as raw SQL. - An empty list allows nothing: the first SDK call fails with
HANDLER_DELEGATION_MISSING, telling you to fill it in. - Organization scope still applies. The handler reads and writes the caller's organization's rows in a scoped table, exactly as the caller would.
- Powers beyond table access (raw SQL, email, storage writes) also need the handler's capabilities, frozen into the version you published.
Calling it
curl -X POST "$AUTHORIC_URL/$APP_UID/autocrud/invoices/actions/approve" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "invoiceUid": "0199b8e4-2a6f-7c31-8d5e-41f0a9c37b12" }'The payload is checked against the endpoint's schema before the handler runs. A payload that fails it is refused.
They can be agent tools too
An endpoint becomes a tool for your agents when you tick Expose as agent tool on it, and an agent is offered it only when the agent's role is also granted the endpoint's key. Neither alone is enough: a role with every permission still gets no endpoint you left unticked.
When you expose one, give it:
- a tool name (it defaults to the action type),
- a tool description that says when an agent should use it and what it returns, so it picks this tool over similar ones,
- a risk level: Low runs on its own; Moderate and High pause the agent's run for a person's approval before the handler runs. A newly exposed endpoint starts at Moderate, so it pauses for approval; lower it to Low only for calls that are safe to run unattended. Choose deliberately: anything that moves money or changes what someone else sees deserves a pause.
The endpoint's schema doubles as the tool's input schema, so the agent is held to the same payload as any other caller. How agents find their tools and how risk decides a pause is covered in Agent tools.
This is the cleanest way to give an agent real power safely: write the risky operation once, as a handler that enforces its own rules, and expose that rather than the raw table.
Things that trip people up
- Job-only handlers cannot back an endpoint. A handler marked to run only for scheduled jobs is
never run by an endpoint; marking one that an endpoint still uses is refused with
409andHANDLER_KIND_BOUND. See Scheduled jobs. - Publish before you test the endpoint. The endpoint runs the published version and its frozen
capabilities. Granting a capability later does nothing until you publish again
(
SDK_CAP_NOT_IN_MANIFEST). - Two live handlers with one name run neither:
409withHANDLER_NAME_AMBIGUOUS. Rename or archive one. - Route conflicts. A table cannot have two endpoints with the same method and path.