Actions and writes
Two mechanisms send work to your own code: write replaces where a surface saves, and an api action puts a button on a screen that calls a route.
The action shape
An action is { label, kind } plus whatever the kind needs. It is the same shape everywhere: a list view's actions, row_actions and bulk_actions, a detail view's actions and section actions, an empty state's action, and a banner's action.
{
"label": { "en": "Receive goods", "de": "Ware vereinnahmen" },
"icon": "add-circle",
"kind": "api",
"method": "POST",
"endpoint": "/inventories/receive",
"variant": "primary",
"confirm": {
"title": { "en": "Receive goods" },
"message": { "en": "Books a delivery into stock and writes an inbound row in the movements ledger." }
},
"fields": [
{ "name": "sku", "type": "text", "label": "SKU" },
{ "name": "quantity", "type": "number", "label": { "en": "Quantity received" },
"required": true, "validation": { "min": 0.001 } },
{ "name": "location_code", "type": "text", "label": { "en": "Location code" },
"description": { "en": "Leave empty to use the tenant's default location." } }
],
"submitLabel": { "en": "Book receipt" },
"success_message": { "en": "Receipt booked." }
}
That is a real shipped example, from the inventories app's stock list.
The 11 kinds — and which are proven
navigate api link create delete
edit-dialog bulk-edit bulk-adjust
inline-create assign refresh
| Kind | Does | Status |
|---|---|---|
api | Calls your app's route (endpoint, method) | In production use |
navigate | Moves to a Cockpit route (to) | In production use |
create | Opens the entity's create form | In production use |
delete | Deletes the record, with a guard | In production use |
link | Opens an external URL (url) | Supported, unexercised |
edit-dialog | Edits the record in a dialog | Supported, unexercised |
bulk-edit | Edits every selected row | Supported, unexercised |
bulk-adjust | Applies a percent or absolute adjustment to a column across selected rows | Supported, unexercised |
inline-create | Adds a row without leaving the list | Supported, unexercised |
assign | Opens an assignment picker | Supported, unexercised |
refresh | Re-fetches the view | Supported, unexercised |
Common fields on any action:
| Key | Does |
|---|---|
icon | A Solar icon name — see Navigation. |
variant | primary, secondary, danger or ghost. |
confirm | A confirmation dialog with title and message. |
success_message | The toast after it lands. |
refresh | Which views to re-fetch afterwards. |
placement | header or footer. |
enabled_when + disabled_reason | Conditional, with the reason said out loud. |
note | A sentence beside the button saying what the action does NOT do. The app owns this claim; the renderer must never invent one. |
result | How the response body is used: toast, dialog, navigate or download. |
prefill | Values seeded into the dialog. |
guard | On delete: the same blocker configuration a detail view's delete takes. |
adjust | On bulk-adjust: what to move and by how much. |
note earns its place. "Receiving goods does not create a purchase order" is exactly the kind of thing an operator needs and a generic renderer cannot know.
write — where a surface saves
By default the renderer writes the entity directly, and that is the right default for an admin surface: a position column, a label, a boolean flag gain nothing from a round trip through your function that would write the same column back.
"write": { "method": "POST", "endpoint": "/suppliers" }
| Field | Required | Meaning |
|---|---|---|
endpoint | Yes | Your app's own namespaced route. :param is substituted from the route params and the record id. |
method | No | Defaults to what the surface is doing — POST for a create, PATCH for an edit, DELETE for a delete. Say PUT if you model an update as a full replacement. |
Declare it for what the database cannot do on its own: a contact that must also become a platform user, an organization mirrored to a team, a stock movement that has to leave a ledger entry behind it.
write is accepted on a form view, a detail view's edit, and — per verb — on a detail view's child list. A verb left out of a child list's write stays a plain row write, so a collection can route its creates through the app and keep editing a position column as the cheap write it is.
How an endpoint is resolved
Both write.endpoint and an api action's endpoint resolve against your app's own capability routes under /v1:
endpoint: /inventories/receive
│
▼
POST https://api.revenexx.com/v1/inventories/receive
The request carries the operator's Authorization: Bearer <jwt>, plus X-Revenexx-Tenant and X-Revenexx-Market. The Cockpit calls the same public route a partner integration would — there is no privileged internal path.
manifest.capabilities.json gives the operator a 404 after they click. Declare the route, and remember that a capability route uses {braces} while a manifest endpoint uses :param:{
"type": "define",
"capability": "inventories.receive",
"version": "1.0.0",
"route": { "method": "POST", "path": "/inventories/receive" },
"response": { "title": "StockReceived", "type": "object" }
}
fields — the request-body dialog
An api action dispatches with an empty body by default. To collect arguments first, declare fields: the same field shapes a form view uses, rendered as a dialog, with the collected values becoming the JSON request body.
{
"label": { "en": "Adjust stock" },
"kind": "api",
"method": "POST",
"endpoint": "/inventories/adjust",
"fields": [
{ "name": "quantity", "label": { "en": "Change by" }, "type": "number", "required": true },
{ "name": "reason", "label": { "en": "Reason" }, "type": "select", "required": true,
"vocabulary": "inventories.movement-reasons" }
],
"submitLabel": { "en": "Apply" },
"result": "dialog"
}
This is what makes a capability that takes arguments reachable from the Cockpit at all. Without it, only the parameterless half of your app's state machine can be driven from the UI — which is exactly the gap that left /v1/inventories/adjust unreachable while an editable on_hand field wrote straight through.
fields is only meaningful for kind: "api". Omitting it dispatches immediately with an empty body, which is what every action that predates the key does — so adding it never changes an existing one.
submitLabel and submit_label are both accepted; the snake-case spelling is tolerated for symmetry with formView.submit_label.
result: "dialog" and dry runs
{
"label": { "en": "Preview rule" },
"kind": "api",
"method": "POST",
"endpoint": "/products/categories/:id/rules/preview",
"result": "dialog"
}
result decides what happens to the response body. dialog shows what came back, which is the whole point of a dry run — a preview, a readiness report, a usage count. download is for an export; navigate follows an id in the response; toast is the default.
Several commerce routes are built for exactly this shape: rule previews, market readiness, tax-class usage.
Bulk action dispatch — two semantics
| Kind | Dispatch |
|---|---|
api | Called once per selected row, with :id substituted per row. |
navigate | Entered once, receiving the whole selection as ?ids=<comma-separated>. |
An api bulk action over 500 rows is 500 requests. If that is wrong for your route, publish a batch capability and use a navigate action into a form that posts it.
Choosing between the two
| Situation | Use |
|---|---|
| Edit a label, a flag, a position | Nothing. The row write is correct. |
| A save with a side effect the database cannot produce | write on the form or edit |
| An operation that is not a save at all — receive, ship, recompute, refresh | An api action |
| An operation that needs arguments | An api action with fields |
| A read whose answer is the point — a preview, a diagnosis | An api action with result: "dialog" |
| Move the operator somewhere | navigate |
Where to go next
- Capabilities — declaring the route an endpoint reaches.
- Forms — the field shapes a dialog reuses.
- List views — where toolbar, row and bulk actions live.
- Detail views —
edit,deleteand per-verb child writes.