Throw one of these from an action, an adapter, or a custom route and FlowPanel
turns it into the right HTTP status with a message that is safe to show. Throw
anything else and the caller gets a generic `500` — the detail is logged, never
sent.

That split is the point of the hierarchy: `safeMessage` is the part you have
decided an operator may read.

## FlowpanelError

The base class. Every other error extends it.

```ts
export class FlowpanelError { constructor(code: FlowpanelErrorCode, safeMessage: string); }
```

| Property | Meaning |
| --- | --- |
| `code` | Stable machine-readable tag, e.g. `"validation_failed"`. |
| `safeMessage` | Shown to the operator verbatim. Keep internals out of it. |
| `status` | HTTP status from `FLOWPANEL_ERROR_STATUS[code]`. |

Construct it directly for a case the subclasses do not cover:

```ts excerpt
throw new FlowpanelError("conflict", "This order is already being processed");
```

## The subclasses

| Class | `code` | Status | Default message | Raised by FlowPanel? |
| --- | --- | --- | --- | --- |
| `FlowpanelValidationError` | `validation_failed` | 422 | Validation failed | yes |
| `FlowpanelAuthError` | `unauthenticated` | 401 | Authentication required | yours to throw |
| `FlowpanelAccessError` | `forbidden` | 403 | Forbidden | yes |
| `FlowpanelFieldAccessError` | `field_forbidden` | 403 | `Field "x" cannot be changed.` | yes |
| `FlowpanelOperationDisabledError` | `operation_disabled` | 403 | This operation is disabled. | yes |
| `FlowpanelUnknownFieldError` | `unknown_field` | 400 | `Unknown field: "x".` | yes |
| `FlowpanelNotFoundError` | `not_found` | 404 | Not found | yes |
| `FlowpanelConflictError` | `conflict` | 409 | Conflict | no — yours to throw |
| `FlowpanelRateLimitError` | `rate_limited` | 429 | Rate limit exceeded | yes |

Each takes an optional message overriding the default.

The last column matters when you write a `hooks.onError` or a log filter.
FlowPanel raises `FlowpanelAccessError` for rejected admin/resource access and
an unbound tenant scope, `FlowpanelFieldAccessError` for prohibited field
reads/writes, `FlowpanelOperationDisabledError` for disabled mutations,
`FlowpanelUnknownFieldError` for fields outside the resource contract,
`FlowpanelRateLimitError` from the request prologue, and the not-found and
validation errors from resource routes. `FlowpanelAuthError` and
`FlowpanelConflictError` are available for application code. (The render path
still catches `FlowpanelAuthError` if you throw one from a page — see below.)

### FlowpanelValidationError

```ts
export class FlowpanelValidationError { constructor(fieldErrors: Record<string, string>, message?: string); }
```

The one subclass with extra state: `fieldErrors`, keyed by field name — and it
is the **first** constructor argument, with the message second:

```ts excerpt
constructor(fieldErrors: Record<string, string>, message = "Validation failed");
```

Forms render the entries against the matching inputs instead of showing one
banner.

```ts excerpt
import { FlowpanelValidationError } from "@flowpanel/kit";

throw new FlowpanelValidationError({
  email: "That address is already registered",
});

// with a banner message as well:
throw new FlowpanelValidationError({ email: "Already registered" }, "Could not save");
```

## Config-time errors are not in this hierarchy

`defineAdmin` throws plain `Error`s, deliberately: they happen while your
config module evaluates, long before there is a request to map a status onto.
They carry no `code`, no `safeMessage` and no `status`, and no `hooks.onError`
sees them — the process fails to boot instead.

What they cover: duplicate resource names, dashboard paths and queue keys;
duplicate action keys within one list; a resource or queue named after a
reserved route segment (`dashboards`, `drawer`, `queues`); a column a resource
points at that the adapter does not report; an omitted `columns` on a ref the
adapter cannot introspect; an unresolvable cross-resource reference; and a
`rowClick: "drawer"` with no drawer.

Each names the config path at fault and, where it can, the near match. See
[Troubleshooting](/docs/troubleshooting#defineadmin-throws-when-the-app-boots)
for the full table.

## On the page render path

`FlowpanelAuthError` and `FlowpanelAccessError` thrown while rendering a page are
caught by the shell, not surfaced as a crash:

- `auth.signInUrl` is set → redirect there.
- `auth.forbiddenUrl` is set and the visitor is signed in → redirect there.
- Neither → an inline "Sign in required" or "Access denied" panel.

Other errors propagate to your Next.js error boundary.

## Returning versus throwing

Inside an action's `run` you have both options:

```ts excerpt
run: async (row, input, ctx) => {
  if (row.status === "refunded") {
    return { ok: false, error: "Already refunded" };   // 200 with a failure body
  }
  throw new FlowpanelConflictError("Order is locked"); // 409
}
```

Return `{ ok: false }` for an outcome the operator caused and can fix — the UI
shows the message next to the action. Throw when the request itself was not
valid, and you want the status code to say so.

## Errors the routes produce themselves

Some generated-UI responses never pass through the hierarchy at all — the
route answers directly with a string `error`. These compatibility routes are
distinct from the v1 JSON CRUD API and protected controllers, whose
`FlowpanelResultError` contains `code`, `message`, optional `fieldErrors`, and
`requestId`.

### 400 — malformed request body

A POST that declares `Content-Type: application/json` but carries a body that
does not parse is rejected before anything else runs:

```json
{ "ok": false, "error": "invalid JSON body" }
```

That covers the row-, bulk-, dashboard- and drawer-action routes and the
inline-cell update. A non-JSON content type is parsed as form data instead and
never reaches this branch.

### 404 — unknown resource, action or dashboard

The message depends on `NODE_ENV`, on purpose. In **production** it is terse,
so the response confirms nothing about what does exist:

```json
{ "ok": false, "error": "resource not found" }
```

Outside production it names what was requested and what is registered, which
turns "why is my action 404-ing" into a one-look answer:

```json
{
  "ok": false,
  "error": "action not found: \"refnud\". Registered actions: \"refund\", \"cancel\"."
}
```

`kind` is one of `resource`, `action` or `dashboard`.

### 403 / 422 from the guard pipeline

`403 Cross-origin write requests are not allowed.` when the default browser
origin guard blocks a mutation; `403 This admin is read-only.` when
`readOnly: true` blocks a write;
`422 too many ids (max 1000)` when a bulk action exceeds its cap;
`422 validation failed` with an `issues` array when an action `form` fails
validation. See [Actions → Validation](/docs/reference/actions#validation).
