> ## Documentation Index
> Fetch the complete documentation index at: https://docs.crewship.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# User Preferences

> Per-user JSON-blob key-value store backing UI settings (panel sizes, last-opened tabs, density, …). Each key is opaque to the server; the frontend owns the schema.

User preferences are a per-user, string-keyed store of raw JSON values backing UI settings — panel sizes, last-opened tabs, density, and the like. They follow the user across browsers, devices, and workspaces (preferences are never workspace-scoped), and the endpoints below let the frontend list, upsert, and delete individual keys.

The handler is **deliberately schemaless on the value side**. The frontend writes whatever JSON shape it wants under a key; the server stores the bytes and hands them back unchanged on read. This keeps UI iteration fast — adding a new preference doesn't need a migration or a backend release.

Implementation: `internal/api/user_preferences.go`. Persisted in `user_preferences` (migration v58) as `(user_id, pref_key, pref_value, updated_at)` with a `UNIQUE(user_id, pref_key)` constraint.

## Endpoints

| Method | Endpoint                                                 | Purpose                           |
| ------ | -------------------------------------------------------- | --------------------------------- |
| GET    | [`/api/v1/me/preferences`](#list-preferences)            | List all preferences for the user |
| PUT    | [`/api/v1/me/preferences/{key}`](#set-one-preference)    | Upsert one preference key         |
| DELETE | [`/api/v1/me/preferences/{key}`](#delete-one-preference) | Delete one preference key         |

## Auth and limits

<Note>
  * All endpoints require an authenticated session.
  * The `user_id` is taken from the session — never from request bodies or query parameters.
  * **Key constraint**: `[a-zA-Z0-9._-]{1,64}`. Anything else returns `400`. Keys land in URL paths, so this is a defensive parse before they reach the SQL layer.
  * **Value cap**: 16 KB per key. Larger payloads return `413 Request Entity Too Large`.
  * There is no admin endpoint to read another user's preferences.
</Note>

***

## List preferences

```
GET /api/v1/me/preferences
```

Returns every preference row for the authenticated user as a flat map of `key → JSON value`. Missing keys are simply absent from the map; the frontend falls back to its compiled-in defaults.

**Response:** `200 OK`

```json theme={null}
{
  "ui.density": "comfortable",
  "ui.theme": "system",
  "crews.bottom_panel_height": 220,
  "chat.composer.send_on_enter": true,
  "chat.last_session_per_agent": {
    "agt_viktor": "cht_a1b2",
    "agt_eva": "cht_c3d4"
  }
}
```

Values are **parsed JSON** — a number stays a number, an object stays an object. The handler round-trips through `json.RawMessage` so the FE doesn't have to JSON-decode each value separately.

**Errors:**

| Status | Condition                                                                  |
| ------ | -------------------------------------------------------------------------- |
| 401    | Not authenticated.                                                         |
| 500    | DB error or invalid JSON in a stored row (corruption — should not happen). |

***

## Set one preference

```
PUT /api/v1/me/preferences/{key}
Content-Type: application/json
```

Upserts one key. The request body is the **raw JSON value** to store — not a `{value: …}` wrapper. So setting `ui.theme` to `"dark"` is:

```http theme={null}
PUT /api/v1/me/preferences/ui.theme
Content-Type: application/json

"dark"
```

And setting `crews.bottom_panel_height`:

```http theme={null}
PUT /api/v1/me/preferences/crews.bottom_panel_height
Content-Type: application/json

220
```

**Response:** `204 No Content` on success.

**Errors:**

| Status | Condition                           |
| ------ | ----------------------------------- |
| 400    | Invalid key (regex), or empty body. |
| 401    | Not authenticated.                  |
| 413    | Body exceeds the 16 KB cap.         |
| 500    | DB error.                           |

The PUT is atomic via SQLite UPSERT (`INSERT … ON CONFLICT(user_id, pref_key) DO UPDATE`), so concurrent writes from two devices don't lose data — last write wins.

***

## Delete one preference

```
DELETE /api/v1/me/preferences/{key}
```

Removes the row for `(user_id, key)`. Idempotent — deleting a non-existent key returns 204, not 404. The frontend's "reset to default" action just deletes the key.

**Response:** `204 No Content`.

**Errors:**

| Status | Condition          |
| ------ | ------------------ |
| 400    | Invalid key.       |
| 401    | Not authenticated. |
| 500    | DB error.          |

***

## Conventional keys

The server does not enforce these names, but the frontend uses them consistently. Listing them here so integrators know what to read/write:

| Key                           | Type   | Purpose                                                                                                                                                                 |
| ----------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ui.theme`                    | string | `"light"`, `"dark"`, or `"system"`.                                                                                                                                     |
| `ui.density`                  | string | `"compact"`, `"comfortable"`, or `"spacious"`.                                                                                                                          |
| `crews.bottom_panel_height`   | number | Pixel height of the bottom panel in `/crews`. First consumer of this table — the schema is intentionally generic so further preferences land without another migration. |
| `chat.composer.send_on_enter` | bool   | If false, Enter inserts newline and Cmd-Enter sends.                                                                                                                    |
| `chat.last_session_per_agent` | object | Map `agentId → chatId` so the chat surface re-opens the last session per agent.                                                                                         |

Adding a new key is purely a frontend change — no API or migration work needed. Use the dotted-namespace convention (`<surface>.<setting>`) so a future grep stays scannable.

## Related

* [Chat & Sessions guide](/guides/chat-sessions) — uses `chat.*` keys.
* [Migrations — v58](/guides/migrations) — `user_preferences` schema.
