An action is a server-side function the operator triggers from the UI. FlowPanel
gives you four places to put one, all sharing the same result type and the same
guard pipeline: the admin-wide role gate, the resource gate, then the action's
own `requireRole` — before `run` is ever called.

Actions never become Server Actions. They are POST routes under
`/api/flowpanel/…`, mounted by `handlers(config)`.

## RowAction

Type-preserving helper for a row action with a dedicated form payload.

```ts
export function rowAction<Row, Input extends ActionInput = ActionInput, Output = never>(definition: RowAction<Row, Input, Output>): RowAction<Row, Input, Output>;
```

Offered on a single row, inline or in the row menu.

```ts excerpt
import { resource, rowAction } from "@flowpanel/kit";
import { orders } from "@/db/schema";

type RefundInput = { reason: string };

const refundOrder = rowAction<typeof orders.$inferSelect, RefundInput>({
  key: "refund",
  label: "Refund",
  icon: "circle-dollar-sign",
  variant: "destructive",
  confirm: { title: "Refund this order?", confirmLabel: "Refund" },
  requireRole: "admin",
  disabled: (row) => (row.status === "refunded" ? "Already refunded" : false),
  form: [{ name: "reason", label: "Reason", type: "textarea", required: true }],
  run: async (row, input, ctx) => {
    await refund(ctx.db, row.id, input.reason);
    return { ok: true, message: `Refunded ${row.id}`, refresh: true };
  },
});

resource(orders, {
  columns: ["id", "status"],
  actions: [refundOrder],
});
```

The form describes an action-specific `ActionInput`, not columns on the row.
Use `rowAction<Row, Input>()` to get exact types for both handler arguments and
compile-time checks for every `form[].name`. The helper returns the same plain
object you could write inline; it has no runtime cost.

```ts twoslash
import { rowAction } from "@flowpanel/kit";

type User = { id: string; status: "active" | "suspended" };
type SuspendInput = { reason: string; notify: boolean };

export const suspendUser = rowAction<User, SuspendInput>({
  key: "suspend",
  label: "Suspend",
  icon: "ban",
  form: [
    { name: "reason", type: "textarea", required: true },
    { name: "notify", type: "checkbox" },
  ],
  run: async (user, input) => ({
    ok: true,
    message: `${user.id}: ${input.reason}`,
  }),
});
```

**RowAction** — Action offered on a single row of the list.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `key` | `string` | yes | Stable identifier, used in the action's URL. |
| `label` | `string` | yes |  |
| `icon` | `IconName` | no | Serializable Lucide icon rendered beside the label. |
| `variant` | `ActionVariant` | no | Button styling. |
| `placement` | `"menu" \| "inline"` | no | `"inline"` renders a button in the row; `"menu"` puts it in the row menu. |
| `confirm` | `ActionConfirm` | no | Ask for confirmation before running. |
| `form` | `array` | no | Inputs collected before `run`, passed to it as `input`. |
| `inputSchema` | `object` | no | Cross-field input validation for trusted action code. |
| `outputSchema` | `object` | no | Required before arbitrary `data` may cross the client boundary. |
| `hidden` | `function` | no | Hide the action for rows it does not apply to. Evaluated server-side per row. |
| `disabled` | `function` | no | Disable with a reason. A string is shown to the operator. |
| `access` | `AccessRule` | no | Restrict the action to a role. Enforced before `run`. |
| `when` | `function` | no | Server-enforced row condition, evaluated only after a scoped row load. |
| `unsafe` | `array` | no | Explicitly opt trusted code into raw database access. |
| `requireRole` | `array \| string` | no |  |
| `run` | `function` | yes | Server-side handler. Runs only after every guard has passed. |

`hidden` runs server-side for every row on the page, and the row's action list
is filtered before it reaches the client — a hidden action is not merely
invisible, its route returns `404` for that row.

## BulkAction

Type-preserving helper for a bulk action with a dedicated form payload.

```ts
export function bulkAction<Row, Input extends ActionInput = ActionInput, Output = never>(definition: BulkAction<Row, Input, Output>): BulkAction<Row, Input, Output>;
```

Applied to the operator's selection. `run` receives the ids, capped at 1000 per
request — the cap is a **rejection, not a truncation**: over 1000 ids the route
answers `422 too many ids (max 1000)` and `run` never executes, so nothing is
half-applied. Page your own batches if you need more.

**BulkAction** — Action applied to every selected row at once.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `key` | `string` | yes | Stable identifier, used in the action's URL. |
| `label` | `string` | yes |  |
| `icon` | `IconName` | no | Serializable Lucide icon rendered beside the label. |
| `variant` | `ActionVariant` | no | Button styling. |
| `confirm` | `ActionConfirm` | no | Ask for confirmation before running. |
| `form` | `array` | no | Inputs collected before `run`, passed to it as `input`. |
| `inputSchema` | `object` | no | Cross-field input validation for trusted action code. |
| `outputSchema` | `object` | no | Required before arbitrary `data` may cross the client boundary. |
| `access` | `AccessRule` | no | Restrict the action to a role. Enforced before `run`. |
| `max` | `number` | no | Maximum selected ids accepted by this action. |
| `unsafe` | `array` | no | Explicitly opt trusted code into raw database access. |
| `requireRole` | `array \| string` | no |  |
| `run` | `function` | yes | Server-side handler, given every selected id. Capped at 1000 ids per call. |

Use `bulkAction<Row, Input>(definition)` when the bulk form has a typed
payload. Its handler receives `string[]` ids followed by that input.

When a resource has delete enabled and declares no `bulkActions`, `defineAdmin`
adds a default delete action. Opt out with `bulkActions: []`.

## DashboardAction

Type-preserving helper for a dashboard action with a dedicated form payload.

```ts
export function dashboardAction<Input extends ActionInput = ActionInput, Output = never>(definition: DashboardAction<Input, Output>): DashboardAction<Input, Output>;
```

Rendered in a dashboard's page header. It gets no row — only the form input.

**DashboardAction** — Action declared on a dashboard, rendered in the dashboard page header.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `key` | `string` | yes | Stable identifier, used in the action's URL. |
| `label` | `string` | yes |  |
| `icon` | `IconName` | no | Serializable Lucide icon rendered beside the label. |
| `variant` | `ActionVariant` | no | Button styling. |
| `confirm` | `ActionConfirm` | no | Ask for confirmation before running. |
| `form` | `array` | no | Inputs collected before `run`, passed to it as `input`. |
| `inputSchema` | `object` | no | Cross-field input validation for trusted action code. |
| `outputSchema` | `object` | no | Required before arbitrary `data` may cross the client boundary. |
| `access` | `AccessRule` | no | Restrict the action to a role. Enforced before `run`. |
| `unsafe` | `array` | no | Explicitly opt trusted code into raw database access. |
| `requireRole` | `array \| string` | no |  |
| `run` | `function` | yes | Server-side handler. Runs only after every guard has passed. |

Use `dashboardAction<Input>(definition)` for the same typed form contract
without a row type.

Set `hideActionsBar: true` on the dashboard to suppress the bar these render in.

## DrawerAction

Rendered in the drawer footer for the open row. Its `form` uses the lighter
`DrawerFieldFormSpec` rather than `FieldDef`.

**DrawerAction** — Button rendered in the drawer footer for the open row.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `key` | `string` | yes | Stable identifier, used in the action's URL. |
| `label` | `string` | yes |  |
| `variant` | `"destructive" \| "default"` | no | `"destructive"` styles the button as dangerous. |
| `confirm` | `string` | no | Ask for confirmation with this message before running. |
| `form` | `array` | no | Inputs collected before `run`, passed to it as `formData`. |
| `palette` | `boolean` | no | Also offer the action in the command palette. |
| `access` | `AccessRule` | no | Roles allowed to see and execute this action. |
| `when` | `function` | no | Server-enforced row condition evaluated after the scoped row load. |
| `unsafe` | `array` | no | Explicitly opt trusted code into raw database access. |
| `requireRole` | `array \| string` | no |  |
| `run` | `function` | yes | Server-side handler. Runs only after every guard has passed. |

### DrawerFieldFormSpec

**DrawerFieldFormSpec** — Input field of a `DrawerAction.form`.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | yes | Key this input contributes to the action's `formData`. |
| `type` | `"switch" \| "select" \| "textarea" \| "text" \| "number"` | no | Control to render. Defaults to `"text"`. |
| `options` | `array` | no | Choices for `select`. |
| `required` | `boolean` | no | Reject an empty value. Enforced server-side. |
| `validate` | `function \| object` | no | Per-input validation, run server-side before `run`. |

## ActionResult

What every `run` returns. The success shape drives what the operator sees next.

**ActionResult** — What an action returns. On success the runtime may show a toast, refresh the
list, redirect, or hand the operator a file — driven entirely by these keys.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `ok` | `boolean` | yes |  |

`ActionInput` is the default `Record<string, unknown>` payload used when no
more specific input type is supplied.

Returning `{ ok: false, error }` is the way to fail a run cleanly — the message
reaches the operator verbatim, so keep internals out of it. Throwing works too:
a `FlowpanelError` maps to its own status, anything else becomes a generic
`500` with the detail logged rather than sent.

## ActionContext

The last argument to every `run`.

**ActionContext** — What every action's `run` receives as its last argument.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `db` | `Db` | yes |  |
| `unsafe` | `object` | no | Explicit escape hatch for trusted action implementations. |
| `actorId` | `string \| null` | yes | Caller identity — same derivation the audit trail uses (`auth.userId`, then `session.id`, then `session.user.id`). |
| `publish` | `function` | yes | Push a realtime message to every connected admin. |
| `requestId` | `string` | no | Stable correlation id generated once per request. |
| `req` | `object` | yes | The incoming request. Built from the real headers, even during page renders. |
| `session` | `Session \| null` | yes | Result of `AuthConfig.session` for this request. |
| `role` | `string` | yes | Result of `AuthConfig.role` for this session. |
| `scope` | `Scope` | yes | Tenant scope resolved by the admin-wide `scope`. Null when unscoped. |
| `ip` | `string \| null` | yes | Caller IP from `x-forwarded-for`, when the proxy sets it. |
| `userAgent` | `string \| null` | yes |  |

`ctx.db` is your own client — the adapter hands it through untouched, so you
write ordinary queries. `ctx.publish` pushes a realtime message to every
connected admin; widgets and lists that name the channel in `realtime` refresh.

## Validation

An action with a `form` is validated server-side before `run`:

1. `required` fields must be present.
2. Each field's `validate` runs — a Zod schema or a function returning a message.
3. Only then does `run` receive `input`.

A failure returns `422` with an `issues` array. The client renders them against
the matching inputs.
