# Actions *Layer: Backend · custom (non-CRUD) operations* An **action** is a custom, non-CRUD endpoint on a resource — `complete`, a state-machine transition, a batch over selected rows. You write one Python function; codegen introspects its type annotations to build the route, request and response schemas, permission gate, and per-row visibility. ## How it works Codegen introspects the handler signature — a resource-model parameter means an **object** action at `POST /{pk}/{slug}`, its absence a **collection** action at `POST /{slug}` — and emits the schemas plus an `x-action` OpenAPI extension whose `bulk` flag the FE action-catalog reads to bucket the action. The `can` guard gates execution (403) *and* drives visibility: `available_actions` omits the action from a row's serialized `actions` when it returns false, so the FE drops the button with no predicate. ## The config you write **You write** — `be/jsonnet/resources/presets.libsonnet` (the `action()` shorthand): ```jsonnet action(name, fn, status_code=null, can=null, bulk=false):: { type: "action", // dispatch to the Action op regardless of name name: name, // URL slug + x-action name fn: fn, // dotted path to the async handler; its signature drives everything // status_code overrides the default: 204 for `-> None`, else 200. [if status_code != null then "status_code"]: status_code, // can: dotted (resource, session) -> bool; gates 403 + per-row visibility. [if can != null then "can"]: can, // bulk: stamp x-action.bulk; FE surfaces it on the multi-selection bar. [if bulk then "bulk"]: bulk, }, ``` An **object action** with a visibility guard — **You write** — `be/config/resources/task.jsonnet`: ```jsonnet local resource = import "be/resources/presets.libsonnet"; operations: [ { name: "get", representation: "resource" }, // ...create / update / delete... resource.action( name="complete", fn="tracker.actions.complete_task", // Server-side visibility gate. available_actions drops "complete" // from a row's `actions` list when this returns False -- the FE's // resolveObjectActions then filters the button out, no `!row.completed` // predicate on the FE side. Also gates execution: 403 if called anyway. can="tracker.guards.can_complete_task", ), // pre/post hooks are REJECTED on actions -- the handler's own `fn` is the // user entry point, so do preprocessing inline (hooks are create/update only). ] ``` The handler's annotations *are* the schema. **You write** — `be/tracker/actions.py` + `be/tracker/guards.py`: ```python class CompleteRequest(BaseModel): note: str = "" class CompleteResponse(BaseModel): id: str status: str note: str async def complete_task( task: Task, # model param -> OBJECT action at POST /{id}/complete db: AsyncSession, # request-scoped session, committed after the handler body: CompleteRequest, # -> request schema (omit for a body-less action) ) -> CompleteResponse: # -> response model (200). `-> None` emits 204; must be BaseModel|None task.completed = True db.add(task) # enqueue the downstream job via get_queue(db) in the SAME transaction # (transactional outbox) -- commit makes both durable together. ... return CompleteResponse(id=str(task.id), status="completed", note=body.note) async def can_complete_task(task: Task, _session: Session) -> bool: # True iff not yet completed -> a completed task's row dump omits # "complete". Autogen success tests are SKIPPED when `can` is set (the # scaffold can't build a passing row) -- write the happy-path test yourself. return not task.completed ``` Codegen collects these into `_generated/tracker/actions/task.py` as `ActionSpec(name="complete", can=can_complete_task, is_object_action=True)`; the serializers call `available_actions(...)` per row against that `can`. Row reps carry the `actions` envelope by default — opt out with `exclude_actions: true` on sync/cross-resource reps (see [fields](fields.md)). A **bulk** action needs a collection-scope handler (no model param) taking the selected ids in its body. **You write** — `be/config/inventory_resources/asset.jsonnet` + `be/inventory/actions.py`: ```jsonnet { name: "retire", type: "action", fn: "inventory.actions.retire_assets", bulk: true, status_code: 204 }, // bulk on an object action is contradictory ``` ```python class RetireAssetsRequest(BaseModel): ids: list[uuid.UUID] async def retire_assets( *, body: RetireAssetsRequest, # no model param -> COLLECTION action at POST /assets/retire db: AsyncSession, session: Session, ) -> None: # -> 204 if not body.ids: return await db.execute( update(Asset).where(Asset.id.in_(body.ids)).values(status=AssetStatus.RETIRED), ) ``` ## The frontend codegen emits A typed catalog per resource — action names per scope plus the collection-permissions hook, regenerated every run. **Generated** — `fe/src/_generated/actions/inventory/asset.gen.ts` (do not edit): ```typescript export const assetActionCatalog = defineActionCatalog()({ object: ["view", "update", "delete", "send_email", "download_asset_pdf"], collection: ["view", "create"], bulk: ["retire"], // the bulk bucket, straight from x-action.bulk useCollectionPermissions: () => // bound suspense hook for header/bulk gating useGetAssetCollectionPermissionsSuspense().data, }); ``` You own the definitions module, scaffolded once with every action `null` (opted out) and typed against the catalog — an action the backend adds later won't compile until you give it a definition or leave it `null`. Each entry carries exactly ONE of `view` (an overlay `` renders with a `ctx`), `to` (a link), or `onAction` (inline, no form). **Scaffolded, safe to edit** — `fe/src/actions/inventory/asset.tsx`: ```tsx import { Dialog, Drawer, DrawerBody, FormErrors, FSHForm, SelectInput, Stack, TextFieldInput, toast, useFSHForm, defineResourceActions, type BulkActionContext, type ObjectActionContext, } from "@fsh/components-library"; import { assetActionCatalog } from "../../_generated/actions/inventory/asset.gen"; import { downloadAssetPdf } from "../../_generated/actions/inventory/asset_download.gen"; import { AssetSendEmail } from "../../_generated/actions/inventory/asset_send_email.gen"; import { useGetAssetSuspense } from "../../_generated/queries/inventory/asset.gen"; import { assetRetireAction, deleteAsset, updateAsset } from "../../_generated/sdk/inventory/asset.gen"; import { assetStatus } from "../../enums/assetStatus"; import { urls } from "../../routes"; // Object overlay: ctx is ObjectActionContext (ctx.id / ctx.close / ctx.navigate). // Build the form with useFSHForm + FSHForm, call the generated SDK fn, toast + close. function AssetEditDrawer({ ctx }: { ctx: ObjectActionContext }) { const { data: asset } = useGetAssetSuspense({ path: { id: ctx.id } }); const form = useFSHForm<{ name: string; status: AssetStatus }>({ defaultValues: { name: asset.name, status: asset.status }, submit: (values) => updateAsset({ path: { id: ctx.id }, body: values }).then(() => { toast("Asset updated.", { variant: "success" }); ctx.close(); }), }); return ( ); } // Bulk overlay: reads ctx.ids (BulkActionContext) and posts them to the collection SDK fn. function AssetRetireConfirm({ ctx }: { ctx: BulkActionContext }) { const count = ctx.ids.length; const onConfirm = (): Promise => assetRetireAction({ body: { ids: [...ctx.ids] } }).then(() => { toast(`Retired ${count} assets.`, { variant: "success" }); ctx.close(); }); return onConfirm */ />; } export const assetActions = defineResourceActions(assetActionCatalog, { resource: "asset", // the ?resource= key the overlay host addresses object: { view: { label: "View", icon: "view", priority: 30, rowLink: true, // the whole-row click to: (a) => urls.assets.detail.href({ id: a.id }) }, update: { label: "Edit", icon: "edit", priority: 20, view: ({ ctx }) => }, send_email: { label: "Send email", icon: "send", iconOnly: true, priority: 25, variant: "subtle", view: ({ ctx }) => }, download_asset_pdf: { label: "Download PDF", icon: "download", iconOnly: true, priority: 15, variant: "subtle", onAction: (a) => downloadAssetPdf(a.id) }, // inline, no form delete: { label: "Delete", icon: "delete", priority: 10, destructive: true, menuOnly: true, // danger tone, kebab-only view: ({ ctx }) => }, }, collection: { view: { label: "Assets", icon: "columns", to: urls.assets.list().to, page: true }, // list page (palette) create: { label: "New asset", icon: "add", priority: 100, to: urls.assets.create().to }, }, bulk: { retire: { label: "Retire", icon: "archive", priority: 10, destructive: true, menuOnly: true, view: ({ ctx }) => }, }, }); ``` The object surface gates each row against its own serialized `actions` (`resolveObjectActions` keeps only definitions the row still lists); collection and bulk gate off `useCollectionPermissions`. When `can_complete_task` returns false the backend drops `complete` from the row and the button vanishes — no FE predicate. Invalidate the relevant queries after the SDK call so `actions` refresh; how the catalog feeds the row menu, page header, and selection bar is [table configuration](table-configuration.md). See [permissions](permissions.md) for how `can` composes with OPA and [fields](fields.md) for the `actions` envelope and `exclude_actions`.