Skip to main content
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

Auth and limits

  • 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.

List 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
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:

Set one preference

Upserts one key. The request body is the raw JSON value to store — not a {value: …} wrapper. So setting ui.theme to "dark" is:
And setting crews.bottom_panel_height:
Response: 204 No Content on success. Errors: 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

Request: No request body. The {key} path parameter identifies the preference to delete for the authenticated user. 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:

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: 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.

Notification preferences

Not the same store as the section above. /me/preferences is a schemaless, never-workspace-scoped UI blob; /me/notification-prefs is a workspace-scoped category × channel matrix — which of your notification channels (email, webhook, Slack, Discord, Telegram, …) get pinged for which category of event (approvals, escalations, failed routines, chat replies, …). See the Outbound notifications guide for the full category list, channel setup, and the two-layer (admin allowlist + user preference) model. CLI parity: crewship notify prefs. Implementation: internal/api/notification_prefs.go, backed by internal/notifyroute.PrefStore over the user_notification_prefs table (migration v154). Auth: authenticated session with workspace context (GET/PUT both require workspace_id + the caller’s own user id from context — there is no path or body parameter naming a different user).

Get your matrix

Response: 200 OK
cells is [] (never null) when nothing has been set. Each cell’s state is off or immediate (digest is schema-reserved for a v2 digest window; this MVP never reads or writes it). Errors:

Set cells in your matrix

Request body: { "cells": [{ "category", "channel_id", "state" }] } — one or more cells to upsert.
PUT upserts the cells you send — it is not a full-matrix replace, so a single-cell edit never needs to resend every other cell (unsent cells keep whatever state they already had). category is one of the values in Conventional categories below, or * — the per-channel “mute everything” sentinel cell. channel_id must name a channel the workspace already has (crewship notifychannel add). state is off or immediate. Response: 200 OK — { "ok": true }. Errors:

Conventional categories

The fixed vocabulary — shared by this matrix, the admin per-channel allowlist, and the delivery log. internal/notify.AllCategories, grouped the same way the settings UI and crewship notify prefs render them: * is a sentinel, not a real category — legal as a cell’s category (mutes every category on that channel) but never appears in the allowlist or a delivery log row. agents.message (an agent’s own notify_send tool call) is deliberately not in this matrix at all — it is routed by the agent-channel pairing that authorized it, not by a user’s mute state.