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.

### Did you mean

A name that is not registered but is close to one that is gets the suggestion
appended to the error: an exact match once casing and `_`/`-` are ignored, else
the closest name within an edit distance of two.

```text
resource "users" points at column "emial" via columns[0], but the adapter
reports no such column. Did you mean "email"? Known columns: "id", "email".
```

It applies to `columns`, `filters`, `expose`, `defaultSort.field`, `rowKey`,
`create.fields` / `update.fields` names, and to the resource a
`detail.tabs[]` / `drawer.tabs[]` entry or a `reference` points at.

### Warnings that do not stop the boot

Some config problems are real but do not justify refusing to start. `defineAdmin`
collects them on the resolved config as `warnings: readonly string[]`, prints each
one once per process in development, and `flowpanel doctor` lists them. (`doctor`
evaluates `flowpanel.config.ts` to read them; if that fails — a missing
`DATABASE_URL`, say — it prints one line saying the warnings were not checked
instead of passing silently.)

The one warning today is a create form that cannot succeed:

```text
resource users: create is enabled but required column `email` has no field;
creation will always fail
```

It fires for a column the adapter reports as non-nullable, not generated, not
defaulted (`ColumnMeta.hasDefault`, which both shipped adapters populate) and not
the primary key, when nothing a create write carries would fill it: not
`create.fields`, not `columns` when `create.fields` is absent, and not
`create.defaultValues`. An adapter that does not report `hasDefault` makes a
defaulted column look required; declare it in `create.fields` or
`create.defaultValues` to say otherwise.

## 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 — except for a failed
adapter read, which has a surface of its own.

### A query that failed

When the adapter's `list` or `get` throws while a list page, a detail page or a
related tab renders, FlowPanel renders its own card in place of the data instead
of letting the page crash into the host's error boundary. The card names the
resource and the operation through `labels.errors.queryTitle`, adds
`labels.errors.queryHint`, and carries the request id the failure was logged
under:

```text
users: list failed
Check the column list and the database schema. Details are in the server log.
Request 5f3c…
```

The cause always goes to `console.error` with that same id, and outside
production the card shows the message too. Nothing else about it changes with the
environment. A `FlowpanelError` — an access denial, a not-found — keeps
travelling, so the auth boundary above still sees it, and so does a `redirect()`
thrown from your own code.

Dashboard and detail-tab widgets already have their own boundary: a widget whose
query or render throws becomes one failed card, with its copy from
`labels.errors.widgetTitle`, `widgetHint` and `retry`.

## 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).
