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.

The examples on this page use the tables of a sample invoicing app (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 app
POST /{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 invoices and payments and must happen together.
  • You want a narrower permission than a whole table. invoices:approve is a far better thing to grant a person or an agent than invoices: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 as approve. It becomes POST /{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 403 and HANDLER_DEPENDENCY_NOT_ALLOWED. The message names the call and lists what is allowed.
  • A call on Denied actions is refused with 403 and HANDLER_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 409 and HANDLER_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: 409 with HANDLER_NAME_AMBIGUOUS. Rename or archive one.
  • Route conflicts. A table cannot have two endpoints with the same method and path.

Next

On this page