# Netdexperts — Agent Guide

> **Paginated.** This guide is ~60 KB. Fetch one slice with `GET /api/help?section=<key>` instead of the whole wall:
>
> - `discovery` — Short IDs, MCP availability, the discovery endpoints, orientation.
> - `reading` — Board exploration ladder, search/agg/filter grammar, common reads.
> - `writing` — Create/update/delete items, cells, columns, groups (CRUD recipes).
> - `views` — Manipulating a board view: visibility, group, filter, sort.
> - `building` — Boards, folders, automations, templates, feature flags, column types.
> - `permissions` — The permission ledger + the zero-knowledge vault / column encryption.
>
> (Showing the FULL document.)

## 🛑 STOP — Orientation required before any work

If you are an AI agent and the task touches this app (boards, items,
columns, cells, views, folders, automations, ingestion, anything),
your first three actions, in order, are non-negotiable:

1. **Finish reading this document past the "Board exploration ladder"**
   section below. You'll learn the discovery flow + filter grammar +
   common-mistake list.
2. **Open `backend/mcp_server/server.py` in the netdexperts repo** —
   it's a stdio MCP with 238 typed tools wrapping every
   endpoint listed below. Skim the tool docstrings; each tells you when to
   use it vs alternatives. **The catalog IS the API map.** If you have MCP
   support, use it. If not, every tool maps 1:1 to an HTTP endpoint
   listed in the canonical-API section below.
3. **Call `list_boards` (or `GET /r/boards`)** — see what boards actually
   exist; the live call is the only source of truth for the board set (this
   doc deliberately quotes no board count — it would rot). The right board
   is rarely the obvious guess.

Only after those three are done should you mutate data, propose new
infrastructure, or make domain decisions. **Skipping orientation has
cost prior agents dozens of wasted turns reinventing things that
already existed.**

## Short ID system

Every entity has a compact `short_id`. The first 1-2 characters encode the
type, so a single token is usually enough to act on it:

| Prefix | Type    | Example    |
|--------|---------|------------|
| `b`    | Board   | `bB`      |
| `i`    | Item    | `i4k`      |
| `g`    | Group   | `g7m`      |
| `c`    | Column  | `cl1`      |
| `v`    | View    | `vt1`      |
| `f`    | Folder  | `f1a`      |

Columns carry a second character that names the column type:

| Code  | Column type | Example  |
|-------|-------------|----------|
| `l`   | label       | `cl1`    |
| `lt`  | long text   | `clt1`   |
| `em`  | email       | `cem1`   |
| `n`   | numbers     | `cn1`    |
| `d`   | date        | `cd1`    |

(Full mapping — all 22 column types: the MCP
`list_type_codes` tool, or
`backend/app/utils/short_id.py:COLUMN_TYPE_CODES`.)

Views carry a second character for the view type:

| Code  | View type | Example  |
|-------|-----------|----------|
| `t`   | table     | `vt1`    |
| `k`   | kanban    | `vk1`    |
| `ca`  | calendar  | `vca1`   |
| `g`   | graph     | `vg1`    |
| `fm`  | form      | `vfm1`   |

(The `form` view type + its `fm` code land in the view-type registry
sweep; see `backend/app/utils/short_id.py:VIEW_TYPE_CODES` for the
authoritative, full mapping.)

A cell is referenced as `{item}.{column}`, e.g. `i4k.cl1`.

## Compact API canonical: `/r/{short}`

Every read works the same way:

```
GET /r/bB           → board (full)
GET /r/bB?f=clean   → board (lean, self-describing; AI-canonical — see below)
GET /r/bB?f=min     → board (94% smaller; ids + names only)
GET /r/bB?f=std     → board (76% smaller; standard fields)
GET /r/i4k           → item
GET /r/cl1           → column
```

Mutations are equally compact:

```
PUT /r/{board}/{item}/{column}    → set a cell
POST /r/{board}/batch             → batch ops (move, archive, etc.)
```

The legacy `/boards/{uuid}/...` REST routes were retired at the short-id
migration cutover — the compact `/r/{short}` surface is the only API.

## Board exploration ladder (READ THIS FIRST)

**Never start with `?f=full` or `?f=std` on an unfamiliar board.** That
dumps the entire item set into your context and burns thousands of tokens
before you've even decided what to look at. Boards can hold thousands of
items × dozens of columns. Walk the ladder, top to bottom, only going
deeper when the layer above didn't answer your question.

| Tier | Endpoint | Returns | Bytes | When to use |
|------|----------|---------|-------|-------------|
| 0 | `GET /api/state?board={b}` | Board summary + view + available actions list | ~2 KB | Always first. Tells you the board's name, item/column counts, current view, and every endpoint you can call against it. Auth required (like the whole ladder) — an unauthenticated or cross-tenant `?board=` returns 404, same as a missing board. |
| 1 | `GET /r/{b}?schema=only` | Columns (with full settings — labels, dropdown options, multi flags), groups, views — **no items, no cells** | ~5–40 KB | Learn the schema. Every label key, every dropdown option, every view config. |
| 2 | `GET /r/{b}?f=min` | Bare item list: `{id, name, group}` + columns: `{short_id, name, type}` | ~3–10 KB | Get the item-id roster + column types. No cell values. |
| 3 | `GET /r/{b}/search?q=<terms>&limit=10` | `{item_short, score, snippet}` for matches | ~500 B – 2 KB | Find items by keyword across name + every `value_text`. Supports `column:value` filters (`status:done foo bar`). |
| 3 | `GET /r/{b}/agg?group_by={col_short}&agg=count` | `{group_value: count, …}` | ~200 B | Distribution by column. "How many items per status?" |
| 4 | `GET /r/{b}/{item_short}?f=std` | One item with cells (std fields, no monster long_text) | ~1–5 KB | Read a single hit from step 3 in depth. |
| 4 | `GET /r/{b}/{item_short}/{col_short}` | One cell | ~200 B | Read a specific long_text or json cell that's bloating step 4. |
| 5 | `GET /r/{b}?f=std` or `?f=full` | Every item + every value | 50–500+ KB | LAST RESORT. Only when you genuinely need every row's every value (export, full-board audit). |

**The right pattern for "find me an item about X":**

```
1. GET /api/state?board=bK                                 → orientation
2. GET /r/bK/search?q=X&fields=cn26,cl89,cl87&limit=10     → candidates + ranked cells
3. GET /r/bK/{best_match}?f=std                            → the one you want
4. (act on it)
```

(Column ids in these examples are illustrative — pull the real ones for
your target board from `get_board_schema` / `GET /r/{b}?f=min` first.)

That's ~5 KB total. The bulk-dump alternative is ~100 KB.

### `?f=clean` — the self-describing shape (distinct from min/std/full)

`min` / `std` / `full` are sparse projections of the RAW stored row (fewer
fields, same storage-shaped keys). **`?f=clean` is different**: it returns a
lean, agent-shaped bundle for EVERY entity — board, item, cell, column, group,
view, folder, automation, board/view templates — as `{id, name, type, value, …}`
with:

- references resolved to `{id, name}` (folders, dependency nodes, automation
  trigger/action columns, template source boards — never bare long ids),
- secret / encrypted cells **masked** (`value: null` + `has_value`; ciphertext
  never on the wire),
- trail `links[]` on single-entity reads (re-read / up / set) so you can walk
  folder → board → items → item → cell → set without reading docs.

Reach for `?f=clean` when you want the data **agent-ready** rather than a
projection of the storage layout. Examples:

```
GET /r/{b}?f=clean                  → board: meta + columns index + item headers
GET /r/{b}/{i}?f=clean              → item: header + per-column value bundles + links
GET /r/{b}/{i}/{c}?f=clean          → one cell: {id,name,type,value,…} + links
GET /r/{b}/automations?f=clean      → trigger/action with column refs resolved
GET /r/board-templates?f=clean      → templates with folder/source refs + counts
```

**Filter + project + sort in one call.** The search endpoint AND-combines
whitespace-separated terms, accepts a rich `column:value` filter grammar,
projects extra cells onto every match with `?fields=`, and lets you override
the score sort with `?sort=`. So "every item tagged `rag` with status review
and Value ≥ 70, sorted by Value desc, plus their Effort cells" is one GET:

```
GET /r/bK/search?q=tags:rag status:review cn26:>=70&fields=cl89&sort=cn26:desc&limit=50
```

Each match comes back as:

```json
{ "item_short": "iixo", "score": 6,
  "snippet": "…", "cells": { "cl89": "m" } }
```

**Filter grammar — all AND-combined:**

| Syntax | Meaning |
|--------|---------|
| `bare text` | substring on name + every value_text |
| `-bug` | negation at the term level |
| `col:val` | exact match (text / label / status) or membership (tags / multi-select) |
| `col:a\|b\|c` | OR within a column |
| `col:!val` | NOT inside a `col:val` |
| `col:>=70` | numeric `>=` (also `>`, `<`, `<=`) |
| `col:50..90` | inclusive numeric range |
| `col:>=2026-05-01` | date comparison (ISO 8601) |
| `col:2026-05-01..2026-05-31` | date range |
| `col:empty` / `col:!empty` | empty / non-empty check |

**Modifiers:**

| Param | Effect |
|-------|--------|
| `&fields=cn26,cl89` | project those cells onto each match (no follow-up cell calls) |
| `&sort=cn26:desc` | override score sort (asc / desc) |
| `&cursor=<opaque>&limit=200` | paginate big result sets |

**Bulk projection without searching** — when you already have the item ids
(e.g. from a UI selection or an external loop), POST the batch read:

```
POST /r/{b}/batch
{ "op": "read", "ids": ["i4k","i9p","ix5y"], "fields": ["cn26","cl89","cl87"] }
→ { "ok": true, "op": "read", "items": [{item_short, name, cells:{…}}, …] }
```

**Cross-board search** — find an item anywhere with one call:

```
GET /r/search?q=tags:vault&in=*&fields=cn26&limit=50
GET /r/search?q=status:review cn26:>=70&in=bK,bB&sort=cn26:desc
```

Each match comes tagged with `board_short` so the agent knows where it lives.
Tenant-scoped — non-super-admins only see boards in their own tenant.

**For grouped/aggregate views** (e.g. "how many items in each status",
"how many items per tag"): use `/r/{b}/agg`. For multi-label / tags
columns it flattens the JSON array — each label gets counted on its
own. Never pull all items just to count.

```
GET /r/bK/agg?group_by=cdd19         # → {"rag": 7, "obsidian": 4, …}
GET /r/bK/agg?group_by=cl87          # → {"review": 5, "ingested": 12, …}
GET /r/bK/agg?group_by=cl91&agg=avg:cn26   # → avg value per domain
```

**For label/dropdown values** (e.g. "what are the valid Status options on
this board?"): step 1 (`schema=only`) returns the full `settings.labels`
JSON for every label/status/dropdown column — keys, display text, colors,
order. `agg?group_by={col}` then tells you which of those options are
ACTUALLY in use. Never `?f=full` a board just to discover what dropdown
values exist.

## MCP availability — there's a 238-tool server for this app

**If your agent supports MCP, USE IT instead of raw HTTP.** A stdio MCP
server wraps every endpoint listed in this guide as a typed tool with
JSON-schema parameters and when-to-use descriptions.

- **Code:** `backend/mcp_server/server.py` in the netdexperts.com repo
- **Entry:** `python -m backend.mcp_server` (stdio transport)
- **Auth:** `NETDEX_VAULT_KEY` env var (same as the API's `X-Vault-Key`)
- **Tool count:** 238 typed tools — search, agg, batch read, cross-board search, CRUD
  for boards/items/cells/columns/groups/views/folders/automations/files,
  prerequisites, item tags, view templates, board templates,
  connectors, idea-ingest pipeline, multi-entry helpers (email × 4 +
  phone × 4 + link × 4 + location × 4).
- **`search_items`** is the entry tool for finding anything — supports
  the full grammar (col:val + ranges + OR + NOT + empty + projection +
  sort + cursor) and works across one board or every accessible board.

**Don't build a parallel MCP — this one already has full coverage.** If
a capability is missing, add a tool to `server.py` (~30 LOC wrapper).

Other unrelated MCPs the operator runs (Apify, Stripe, GitHub, etc.) are
Docker-on-AVPadmin containers — those serve different APIs, not this one.
See `~/.claude/mcp.json` for that list.

## Project conventions

Before creating boards, items, columns, or groups, read the canonical
machine-readable rules at `GET /api/conventions`. The narrative summary:

- **ID system.** Every entity's id IS its short_id — there are no long
  internal ids anymore. short_ids are the only ids anywhere: API
  responses, MCP returns, CLI output, URLs, and aria-labels. Prefixes:
  `f` folder, `b` board, `g` group, `i` item, `c{type}` column,
  `v{type}` view, `a` automation.
- **Grouping.** Don't create real groups (`BoardGroup` rows). Keep every
  item in the default group and use `set_view_group` to define a custom
  bucket-grouped view. Only call `create_group` if Aaron explicitly asks.
- **Status labels.** Default keys are `waiting`, `working`, `done`,
  `approved`, `bug`, `redo`, `blocked`, `need_human`. Agents set
  `working` / `done` / `bug` / `blocked` / `need_human` only. `approved`
  and `redo` are human-only — Aaron uses them to finish or send work back.
  `done` means "pending Aaron's review", not "finished".
- **Workflow state.** There is no auto-created "system column set" — new
  boards start empty; create the columns you need explicitly (the old
  status/priority/agent/model/goal/directive system types were removed).
  To track agent workflow state, add a `label` column (conventionally
  titled "Status") whose option keys use the status vocab above
  (`waiting` / `working` / `done` / …). A column titled "Status" is what
  the auto-status-update automation keys off.
- **Date format.** ISO 8601 (`YYYY-MM-DD`); with time use the `T`
  separator (`YYYY-MM-DDTHH:MM:SS`). Date columns default to
  `time_off:false`.
- **Phone default.** Phone column entries default to `country='US'`. AI
  agents adding phones should pass an ISO 3166-1 alpha-2 country code
  alongside the number.
- **Secrets.** API keys, integration tokens, and other secrets must be
  stored in a `secret` column type (encrypted at rest via AES-GCM). Never
  put them in plain `text` / `long_text` cells.
- **Creating an item.** Fetch the board schema first
  (`GET /r/{board}?schema=only`), decide which cells to fill, then call
  `POST /r/{board}/items` with `{name, group?, initial_values?}` (or the
  `create_item` MCP tool with `initial_values`) to do create + fill in
  one round-trip. The create 400s (`missing_required_columns`) if any
  `is_required` column ends up unfilled.
- **Conditional requiredness (form-rules / required-columns v2).** Some
  boards carry IF-THEN rules that make a column required *depending on the
  values you send* (e.g. "if platforms contains youtube:video → cover_image
  required"). These live on the board (`Board.form_rules`, per-board), sit
  ON TOP of the static `is_required` baseline, and are enforced at
  item-create only. **Never hardcode a rule or the grammar** — read the
  board's rule set + the live vocab (ops `equals|contains|contains_any|
  not_empty|empty`; effects `require` [server-enforced] + `show`/`hide`
  [UI hint only]) from `GET /r/{board}/form-rules` (MCP:
  `get_board_form_rules`), and validate the full payload against it BEFORE
  `create_item`. Evaluation is atomic + order-free — submit the whole
  payload, don't sequence columns. On a conditional miss the 400 carries a
  `conditional` list of `{column, name, rule}` (0-based rule index). Rule
  authoring is `PUT /r/{board}/form-rules` (manage role; 409 on
  schema-locked boards) — reserved for the Forms rules UI, no MCP tool.
  See `GET /api/conventions` → `required_columns.conditional_rules`.
- **Creating a board.** List templates first (`list_templates` /
  `GET /boards/board-templates`); if one matches, use
  `create_board_from_template` and customise after. Build from scratch
  only when no template fits.

## Chat / messages (bex/ixdp)

Per-item chat threads, reactions, read receipts, and broadcasts. All
endpoints are short-id-only and live under `/r/`.

- `GET /r/{b}/{i}/messages[?since=<msg_id>]` — list messages oldest-first;
  optional cursor returns only messages created strictly AFTER `since`.
- `POST /r/{b}/{i}/messages` — body `{content, thinking?, tool_uses?, reply_to_id?, column_id?}`.
  Returns the persisted message including the bex/ixdp fields.
- `POST /r/{b}/{i}/messages/{msg_id}/react` — body `{emoji}`. Idempotent
  (duplicate adds are no-ops). Returns the updated reaction map.
- `DELETE /r/{b}/{i}/messages/{msg_id}/react?emoji=<e>` — Idempotent.
- `POST /r/{b}/{i}/messages/{msg_id}/read` — Idempotent read receipt (ON
  CONFLICT DO NOTHING via composite PK message_id+seat_id).
- `GET  /r/{b}/{i}/messages/{msg_id}/read_by` — `[{seat_id, read_at}, ...]`
  of seats who have read this message.
- `POST /r/messages/broadcast` — body `{body, tag|targets, project_id?, thinking?, tool_uses?}`.
  Fans out one copy per target; all copies share `broadcast_origin_id`.
- `GET  /r/at-tag/resolve?tag=@liveagents` — resolves a tag to its current
  target list. v1 tags: `@<seat-name>`, `@<project-name>`, `@liveagents`,
  `@aaron`. Unknown tags return `[]`.

WebSocket: `chat_message` event broadcasts to every client subscribed to
the board on any successful message create, reaction, or broadcast.

`GET /r/{b}/messages/counts` returns per-item `{count, sent, received,
last_direction, preview, unread}`. `preview` is the latest message snippet
(60 chars); `unread` is messages NOT yet read by the calling seat AND not
authored by them.

## Manipulating a board view (Visibility, Group, Filter, Sort)

The toolbar buttons in the table UI — Filter, Group, Visibility,
Features — all write into the **view** entity, not the board or
columns directly. Mutating the column's `is_visible` flag is the
WRONG thing to do — that's a board-level lock that hides a column
permanently across every view, and the UI's Visibility menu won't
let the user toggle it back. **Always update view settings.**

Endpoints (compact, AI-canonical):

- `GET  /r/{board}/views`                 → list views on the board
- `POST /r/{board}/views`                 → create a new view
- `PATCH /r/{board}/views/{view_short}`   → mutate filters / group / hidden columns / sort

Body shapes for `PATCH /r/{board}/views/{view_short}`:

| Toolbar feature | What to PATCH | Storage shape |
|-----------------|---------------|---------------|
| Visibility (hide column) | `{"settings": {"hiddenColumns": ["<col_short>", …]}}` — camelCase key, column short_ids, REPLACES the prior list. |
| Visibility (subitem column) | `settings.hiddenSubItemColumns` — same shape. |
| Group | `{"group_by": "<col_short>"}` (or null to clear) — sets the view's `group_by_column_id`. |
| Filter | `{"filters": [{"column": "<col_short>", "op": "eq", "value": "..."}, …]}` — list of filter rules; replaces the existing list. |
| Sort | `{"sort_config": [{"column": "<col_short>", "direction": "asc|desc"}]}` — multi-key sort. |
| Column order | `{"settings": {"columnOrder": ["__checkbox", "__name", "<col_short>", …]}}` — camelCase, column short_ids; replaces. |
| Column widths | `{"settings": {"columnWidths": {"<col_short>": 240, …}}}` — camelCase, col short_id → px-int. |
| Freeze columns | `{"settings": {"freezeBoundaryColumnId": "<col_short>"}}` — pins the Name column + every column up to AND including this one on horizontal scroll. `"__name"` freezes only the Name column; `null` unfreezes. Sub-item table mirror: `settings.subItemFreezeBoundaryColumnId`. (MCP: `set_view_freeze_boundary` / `set_view_sub_item_freeze_boundary` — they resolve the short_id to the long ID the SPA reader expects.) |
| Default view flag | `{"is_default": true}` — only one per board can be default. |

Settings deep-merge: any `settings` object you PATCH gets *shallow*-merged
over the existing settings at the top-level key (so PATCHing only
`hiddenColumns` won't clobber `columnOrder` etc.). Each top-level key is
atomic — sending `hiddenColumns: [a, b]` REPLACES the array, not adds to it.

**Storage-key note.** The frontend reads `settings.hiddenColumns`
(camelCase) only. The backend body shorthand `{"hidden_columns": [...]}`
is normalized to `settings.hiddenColumns` on write — and any legacy
snake-case `settings.hidden_columns` left by older writes self-heals on
the next edit — so both the shorthand and an explicit
`{"settings": {"hiddenColumns": [...]}}` PATCH land at the camelCase key
the SPA reads. Same camelCase convention for `columnOrder`,
`columnWidths`, `hiddenSubItemColumns`, `subItemColumnOrder`,
`subItemColumnWidths`, `freezeBoundaryColumnId`,
`subItemFreezeBoundaryColumnId`.

**Features (lock / read-only / archive / templates) live on different
endpoints:**

- Lock column      → `PATCH /r/{board}/columns/{c}` body `{"is_locked": true}`
- ⚠️ **Delete column** → `DELETE /r/{board}/columns/{c}` is **PERMANENT** —
  it cascade-deletes EVERY cell value in that column and drops the column row.
  Cells are **NOT** preserved and there is **NO undo**. (Contrast: deleting a
  *board* is a soft-archive by default.) To merely *hide* a column without
  losing data, **archive** it instead (next line).
- Archive column (hide, reversible) → add the column's id to
  `feature_flags.archived_columns` via
  `PATCH /r/{board}` body `{"feature_flags": "{"archived_columns": ["<col_id>"]}"}`
  (the MCP `archive_column` tool wraps this). Data stays in the DB, hidden from
  default views; reverse with the MCP `restore_column` tool.
- Archive item     → `DELETE /r/{board}/items/{i}` (soft-delete)
- Save as template → `POST /boards/board-templates` (legacy, JWT)

## Common mistakes to avoid

1. **Don't `GET /r/{board}` (or `?f=std` / `?f=full`) as your first move
   on an unknown board.** That dumps every item + every value and burns
   tokens on data you haven't decided you need. Walk the "Board
   exploration ladder" above — `/api/state` → `?schema=only` → `/search`
   or `/agg` → single-item read — and only fall to full-board when you
   genuinely need every row.
2. **Don't pull the full board to count or filter.** Use
   `GET /r/{b}/agg?group_by={col}` for counts and
   `GET /r/{b}/search?q={col}:{value}` for filtering. Both return tiny
   payloads server-side.
3. **Don't pull the full board to discover dropdown / label / status
   values.** `GET /r/{b}?schema=only` returns each column's full
   `settings.labels` (or `settings.options`) JSON with no item data.
4. **Don't `PATCH column.is_visible = false` to hide a column.** That's a
   board-level lock. Use the view-settings `hiddenColumns` path above.
5. **Don't write `hidden_columns` (snake_case) into view settings.** The
   frontend reads `hiddenColumns` only. Snake key is silently ignored.
6. **Write each column's short_id into view settings arrays** — it IS the
   column's canonical id (post short-id migration there is no separate
   long id to map to).
7. **Don't recreate dropped columns by writing to their short_id.** The
   ID may have been reused for a new column.

The same rules in machine-readable form:

```js
fetch('/api/conventions').then(r => r.json()).then(console.log)
```

## Visual customisation: icons + colors

Folders, boards, groups, labels, and dropdown options carry visual
metadata: a string `icon` and a hex `color`. Agents that want their
output to look right (and not pick a hex that lands outside the
in-app picker's swatch grid) should read these two endpoints first:

- `GET /api/icons` — every pickable icon name + 16 category groups +
  Phosphor's keyword tags. Filter by `?search=…` (matches name OR
  keyword — typing `save` returns floppy-disk + file-text + archive-box)
  or `?group=files` (single bucket).
- `GET /api/colors` — the 16-color palette with 10 shades each
  (Tailwind 900..50, darkest → lightest). `value` is the canonical
  mid-tone (Tailwind-500); use any `shades[i]` for darker/lighter.

Both fields accept any string; values from these endpoints just match
the in-app picker so agent edits look "native". Pre-Wave-2 short keys
(`folder`, `archive`, `code`, `zap`, `briefcase`, …) are still
accepted for backward compat — see `legacy_keys` in `/api/icons`.

```js
// "save my folder as a database icon, dark blue"
// Folder icon + color are plain fields on the folder — set them with a single
// PATCH to /r/folders/{f}. (There is NO /r/{folder}/icon or /color endpoint —
// those 405.)
const icons = await fetch('/api/icons?search=database').then(r => r.json())
const colors = await fetch('/api/colors').then(r => r.json())
const blue = colors.palette.find(c => c.label === 'Blue')
await fetch(`/r/folders/${folder}`, {
  method: 'PATCH',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ icon: 'database', color: blue.shades[1] }),  // dark blue
}).then(r => r.json()).then(console.log)
```

**Folder CRUD** (every board MUST live in a folder):

```js
// create — server mints the short id; returns the new folder
await fetch('/r/folders', {
  method: 'POST',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'System', color: blue.shades[1], icon: 'database' }),
}).then(r => r.json()).then(console.log)

// rename / update — PATCH any subset of {name, color, icon, sort_order, parent_folder_id}
await fetch(`/r/folders/${folder}`, {
  method: 'PATCH',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Renamed' }),
})

// delete — refuses (400) if active boards still live in the folder
//          (archive or move them first; archived boards are auto-detached)
await fetch(`/r/folders/${folder}`, { method: 'DELETE', headers: { Authorization: `Bearer ${t}` }})
```

## `data-testid` naming convention

Stable, hierarchical hooks for Playwright / Chrome AI selectors:

```
nav.{name}                          e.g. nav.board-switcher
auth.{page}.{element}               e.g. auth.login.submit
board.{short}.{element}             e.g. board.bB.title
board.{short}.action.{name}         e.g. board.bB.action.create
board.{short}.view.{viewShort}      e.g. board.bB.view.vt1
board.{short}.row.{itemShort}       e.g. board.bB.row.i4k
board.{short}.group.{groupShort}    e.g. board.bB.group.g7m
item.{short}.{action}               e.g. item.i4k.action.save
view.{short}.{element}              e.g. view.vca1.today
```

Test IDs use entity short_ids (not array indices) for stability across
re-renders, additions, and reorderings.

## AI Mode URL param

Append `?mode=ai` to any route for an AI-optimized rendering. (Reserved —
not yet implemented; safe to ignore today.)

## Common tasks

### Find an item by status (or any keyword)

Do NOT pull the whole board. Use the search endpoint:

```
GET /r/{boardShort}/search?q=status:done&limit=10
GET /r/{boardShort}/search?q=vault rag&limit=10
```

Returns `{item_short, score, snippet}`. Then fetch only the one(s) you
want with `GET /r/{boardShort}/{itemShort}?f=std`. See the "Board
exploration ladder" section above for the full ranking-by-cost order.

### Add a row

1. Click `data-testid="board.{short}.action.add-item"` (or use the
   AddRowButton at the bottom of any group).
2. Or POST to `/r/{boardShort}` with an item payload.

### Update a cell

1. Click the cell to enter edit mode.
2. Or PUT `/r/{boardShort}/{itemShort}/{colShort}` with `{"v": "<new>"}`.

### Switch views

1. Click a view tab — `data-testid="board.{short}.view.{viewShort}"`.
2. The URL updates to `/{boardShort}/{viewShort}`.

### Open the item detail panel

1. Click an item row — `data-testid="board.{short}.row.{itemShort}"`.
2. The right-side panel opens with full column values.
3. Save: `data-testid="item.{itemShort}.action.save"`.
4. Close: `data-testid="item.{itemShort}.action.close"`.

## Doing CRUD as a browser AI agent

Once you're logged in (`POST /auth/login`), the JWT lives in
`localStorage["access_token"]`. Every CRUD operation can be done from the
browser console with `fetch()` calls. The recipes below assume:

```js
const t = localStorage.getItem('access_token')
```

If you're an automation agent (no browser), use the `X-Vault-Key` header
instead of `Authorization: Bearer ...` — same endpoints, different auth.

## Action recipes
These recipes are generated from `backend/app/actions_registry.py`. Adding an `Action(...)` entry there makes its recipe appear here automatically.

### Discovery

#### `get_api_root` — The self-teaching API trail's discovery root — start here.
`GET /api/`  ·  MCP `get_api_root`

### Start at the API root

```js
fetch('/api/').then(r => r.json()).then(console.log)
```

The discovery ROOT of the self-teaching API trail. A cold agent given only this URL can walk the entire API by following its `links[]` — to the discovery nodes (conventions, column-types, icons, colors, state) and then into the tenant's boards. Public; links into instance data are flagged `auth_required`.

#### `get_sitemap` — Return every navigable URL pattern.
`GET /api/sitemap`  ·  MCP `get_sitemap`

### Discover available routes

```js
fetch('/api/sitemap').then(r => r.json()).then(console.log)
```

Public — no auth required. Returns the list of every navigable URL pattern, plus a pointer to the compact API canonical (`/r/{short}`).

#### `get_help` — Markdown agent-orientation guide (this document, generated).
`GET /api/help`  ·  MCP `get_help`

### Read the agent guide

```js
fetch('/api/help').then(r => r.text()).then(console.log)
```

The body is markdown generated from `backend/app/actions_registry.py`. If you're an MCP/agent author, fetch this once at session start.

#### `get_state` — Plain-English page state plus available actions.
`GET /api/state`

### Get oriented on the current page

```js
fetch('/api/state?board=bL&view=vt1').then(r => r.json()).then(console.log)
```

Returns a structured description of the page (board name, view name, item/column/group counts, filter state, and an `available_actions` array generated from the registry). Without `?board=`, returns a minimal envelope.

#### `get_conventions` — Machine-readable project rules (grouping, status labels, ID system).
`GET /api/conventions`

### Read project conventions

```js
fetch('/api/conventions').then(r => r.json()).then(console.log)
```

Conventions like 'never create real groups, prefer set_view_group', 'agents set done not approved', and the create_item / create_board preferred patterns.

#### `get_icons` — Pickable icon registry (folder/board/group icons + keyword search).
`GET /api/icons`

### Discover available icons

```js
// Full registry — 174 picker icons + 16 legacy short keys, grouped
// into 16 buckets (work, files, finance, tech, …).
fetch('/api/icons').then(r => r.json()).then(console.log)

// Search by intent — typing 'save' finds floppy-disk + file-text +
// archive-box + database. Same data the in-app picker uses.
fetch('/api/icons?search=save').then(r => r.json()).then(console.log)

// Single bucket
fetch('/api/icons?group=files').then(r => r.json()).then(console.log)
```

Used to validate `folder.icon` and `board.icon` values. Pre-Wave-2
short keys (`folder`, `archive`, `code`, `zap`, `briefcase`, …)
are also accepted — see `legacy_keys` in the response.

#### `get_colors` — 16-color palette with 10 shades each (folder/board/label colors).
`GET /api/colors`

### Discover available colors

```js
fetch('/api/colors').then(r => r.json()).then(console.log)
// → { palette: [{label:'Red', value:'#EF4444', shades:[...10 hex...]}, ...] }
```

Each entry has the canonical mid-tone (`value`, Tailwind-500) and a
10-stop shade ramp (`shades[0]` darkest → `shades[9]` lightest).
Use these hexes in `folder.color`, `board.color`, `group.color`,
label/dropdown option colors so values match the in-app picker.

#### `list_boards` — Discover available boards.
`GET /r/boards`  ·  MCP `list_boards`  ·  CLI `netdex board list`

### List boards

```js
fetch('/r/boards', { headers: { Authorization: `Bearer ${t}` }})
  .then(r => r.json()).then(console.log)
```

Or via CLI: `netdex board list`.

### Authentication

#### `submit_login` — Authenticate with email + password.
`POST /auth/login`  ·  CLI `netdex login`

### Authenticate

```js
fetch('/auth/login', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'you@example.com', password: '...' })
}).then(r => r.json()).then(d => {
  localStorage.setItem('access_token', d.access_token)
})
```

Agent / automation alternative: pass `X-Vault-Key: <key>` instead of `Authorization: Bearer <jwt>`. Same endpoints, different auth.

#### `create_first_admin` — Bootstrap form for the first admin user.
`POST /auth/setup`

### First-time admin bootstrap

Only reachable when zero users exist in the database. Creates the first admin account.

### Boards

#### `create_board` — Create an empty board from scratch.
`POST /r`  ·  MCP `create_board`

### Create a board

```js
fetch('/r', {
  method: 'POST',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'New project', folder: 'f1a' })
}).then(r => r.json()).then(console.log)
```

`folder` is optional (board lands at the root if omitted). Prefer `create_board_from_template` when a matching template exists — see `/api/conventions create_board_pattern`.

#### `create_board_from_template` — Instantiate a new board from a template (preferred over from-scratch).
`POST /r/from-template`  ·  MCP `create_board_from_template`

### Create a board from a template

```js
fetch('/r/from-template', {
  method: 'POST',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ template_id: 'tpl-xyz', name: 'My new board' })
}).then(r => r.json()).then(console.log)
```

List templates first via `list_templates` / GET `/r/board-templates`.

#### `update_board` — Update board metadata (name, description, color, icon, folder).
`PATCH /r/{board_short}`

### Update board metadata

```js
fetch('/r/bL', {
  method: 'PATCH',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'NetDex (renamed)', color: '#00d4ff', icon: 'rocket' })
}).then(r => r.json()).then(console.log)
```

#### `delete_board` — Soft-archive (default) or hard-delete a board with ?permanent=true.
`DELETE /r/{board_short}`

### Archive or delete a board

```js
// Soft-archive — board still readable, hidden from active lists.
fetch('/r/bL', { method: 'DELETE', headers: { Authorization: `Bearer ${t}` }})

// Hard-delete — cascades to columns / groups / items / values / views.
fetch('/r/bL?permanent=true', { method: 'DELETE', headers: { Authorization: `Bearer ${t}` }})
```

### Templates & schema

#### `list_templates` — List board templates available for create_board_from_template.
`GET /r/board-templates`  ·  MCP `list_templates`

### List board templates

```js
fetch('/r/board-templates', { headers: { Authorization: `Bearer ${t}` }})
  .then(r => r.json()).then(console.log)
```

### Items

#### `list_items` — Read all items on a board.
`GET /r/{board_short}`  ·  MCP `list_items`  ·  CLI `netdex board show`

### Read a board (sparse)

```js
fetch('/r/bL?f=min', { headers: { Authorization: `Bearer ${t}` }})
  .then(r => r.json()).then(console.log)
```

Use `?f=min` for ids+names only (94% smaller), `?f=std` for standard fields with inline cell values (76% smaller), or omit for full payload.

#### `get_item` — Read a single item by its short_id.
`GET /r/{board_short}/{item_short}`  ·  MCP `get_item`  ·  CLI `netdex item get`

### Read one item

```js
fetch('/r/bL/iaaa', { headers: { Authorization: `Bearer ${t}` }})
  .then(r => r.json()).then(console.log)
```

#### `create_item` — Add a new row to a board (with optional initial_values).
`POST /r/{board_short}/items`  ·  MCP `create_item`  ·  CLI `netdex item create`

### Create an item

```js
fetch('/r/bL/items', {
  method: 'POST',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Buy groceries', group: 'g7m' })
}).then(r => r.json()).then(console.log)
```

`group` is optional; without it the item lands in the board's first group. Pass `initial_values: {col_short: value}` to fill cells in the same call (preferred — see `/api/conventions create_item_pattern`).

#### `update_item` — Update item top-level fields (name/status/group).
`PATCH /r/{board_short}/items/{item_short}`  ·  MCP `update_item`  ·  CLI `netdex item update`

### Update an item

```js
// PATCH on the compact namespace (Phase 7 preserves long ids).
fetch(`/r/${boardShort}/items/${itemShort}`, {
  method: 'PATCH',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Renamed' })
}).then(r => r.json()).then(console.log)
```

Or via the MCP tool `update_item` (preferred — accepts short_ids).

#### `delete_item` — Permanently delete an item.
`DELETE /r/{board_short}/items/{item_short}`  ·  MCP `delete_item`  ·  CLI `netdex item delete`

### Delete an item

```js
fetch('/r/bL/items/iaaa', { method: 'DELETE', headers: { Authorization: `Bearer ${t}` }})
```

Items can't be deleted while locked.

### Cells

#### `get_cell` — Read one cell. Append `?bare=true` for the scalar shape.
`GET /r/{board_short}/{item_short}/{column_short}`  ·  MCP `get_item_values`  ·  CLI `netdex cell get`

### Read one cell as a bare scalar

```js
fetch('/r/bL/iaaa/cs1?bare=true', { headers: { Authorization: `Bearer ${t}` }})
  .then(r => r.json()).then(console.log)
// -> {"v": "done"}
```

#### `set_cell` — Set a single cell value.
`PUT /r/{board_short}/{item_short}/{column_short}`  ·  MCP `set_value`  ·  CLI `netdex cell set`

### Update a cell

```js
fetch('/r/bL/iaaa/cs1', {
  method: 'PUT',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ v: 'done' })
}).then(r => r.json()).then(console.log)
```

For multi-entry cells (email/phone/link/location), prefer the granular MCP helpers (`add_email`, `set_main_phone`, etc.).

#### `bulk_set_cells` — Set many cells on one item at once.
`POST /r/{board_short}/{item_short}/cells`  ·  MCP `set_values_bulk`  ·  CLI `netdex cells set`

### Bulk-update many cells on one item

```js
fetch('/r/bL/iaaa/cells', {
  method: 'POST',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ writes: [
    { c: 'cs1',  v: 'done' },
    { c: 'cn1',  v: 42      }
  ] })
}).then(r => r.json()).then(console.log)
```

### Groups

#### `add_group` — Create a group on a board.
`POST /r/{board_short}/groups`  ·  MCP `create_group`

### Create a group

```js
fetch('/r/bL/groups', {
  method: 'POST',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Backlog' })
}).then(r => r.json()).then(console.log)
```

Per project convention: prefer `set_view_group` (bucket grouping) over creating a real group. Only call this when explicitly asked.

#### `delete_group` — Delete a group (must be empty or archived).
`DELETE /r/{board_short}/groups/{group_short}`

### Delete a group

```js
fetch('/r/bL/groups/g7m',   { method: 'DELETE', headers: { Authorization: `Bearer ${t}` }})
```

Groups can't be deleted while they hold non-leader items; archive the group or move items first.

### Lifecycle (semantic actions)

#### `claim_item` — Agent claims an item for execution. Validates prereqs + dependency columns.
`POST /r/{board_short}/{item_short}/claim`  ·  MCP `claim_item`  ·  CLI `netdex item claim`

### Claim an item

```js
fetch('/r/bL/iaaa/claim', {
  method: 'POST',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ agent: 'browser-agent' })
})
```

Body optional. Validates prereqs + dependency columns first; rejects the claim if any are unmet.

#### `complete_item` — Mark an item complete (writes Status + FIN).
`POST /r/{board_short}/{item_short}/complete`  ·  MCP `complete_item`  ·  CLI `netdex item complete`

### Complete an item

```js
fetch('/r/bL/iaaa/complete', {
  method: 'POST',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ output: 'Done!', actual_tokens: 1234 })
})
```

`done` means 'pending Aaron's review' — only Aaron can flip the item to `approved`.

#### `fail_item` — Mark an item failed (requires error reason).
`POST /r/{board_short}/{item_short}/fail`  ·  MCP `fail_item`  ·  CLI `netdex item fail`

### Fail an item

```js
fetch('/r/bL/iaaa/fail', {
  method: 'POST',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ error: 'Stuck on X' })
})
```

#### `block_item` — Mark an item blocked (requires reason).
`POST /r/{board_short}/{item_short}/block`  ·  MCP `block_item`  ·  CLI `netdex item block`

### Block an item

```js
fetch('/r/bL/iaaa/block', {
  method: 'POST',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ reason: 'Waiting on the user' })
})
```

### Search & aggregation

#### `search` — Search items by name or cell text.
`GET /r/{board_short}/search`  ·  MCP `search_items`  ·  CLI `netdex search`

### Search

```js
fetch('/r/bL/search?q=status:done', { headers: { Authorization: `Bearer ${t}` }})
  .then(r => r.json()).then(console.log)
```

#### `aggregate` — Aggregate items by a grouping column.
`GET /r/{board_short}/agg`  ·  CLI `netdex agg`

### Aggregate

```js
fetch('/r/bL/agg?group_by=cs1&agg=count', { headers: { Authorization: `Bearer ${t}` }})
  .then(r => r.json()).then(console.log)

// Sum a numeric column per status
fetch('/r/bL/agg?group_by=cs1&agg=sum:cn1', { headers: { Authorization: `Bearer ${t}` }})
```

### Multi-entry helpers (email/phone/link/location)

#### `add_email` — Add an email entry to an email-typed cell.
`PUT /r/{board_short}/{item_short}/{column_short}`  ·  MCP `add_email`

### Adding an email entry

Prefer the granular MCP tool `add_email` over hand-rolling the full `value_json` array. Same applies to `remove_email`, `set_main_email`, `update_email`, and the parallel phone/link/location helpers.

### Permissions (general-permission ledger)

#### `list_permissions` — List permission rows on a scope (tenant/folder/board/column/feature/panel).
`GET /api/permissions`  ·  MCP `list_permissions`

### List the permission ledger

```js
// All permission rows visible to you in this tenant.
fetch('/api/permissions', { headers: { Authorization: `Bearer ${t}` }})
  .then(r => r.json()).then(console.log)

// Filter to one scope — scope_type ∈ tenant|folder|board|column|feature|panel.
fetch('/api/permissions?scope_type=board&scope_id=bL',
  { headers: { Authorization: `Bearer ${t}` }})
  .then(r => r.json()).then(console.log)
```

Each row is `{id, subject_type, subject_id, scope_type, scope_id, capability, effect, …}`. `subject_type` ∈ `user` | `rank_at_least`; `capability` ∈ `view` | `edit` | `manage`; `effect` ∈ `allow` | `deny`.

🛑 **Human-only fence.** `/api/permissions/*` is mounted human-only. A per-seat-JWT agent gets **403** here (that means 'not a human / master-key caller', not 'bad request'); an `X-Vault-Key` automation passes.

#### `get_effective_permissions` — Resolve a user's effective capabilities on a board (cascade-resolved + ETag).
`GET /api/permissions/effective`  ·  MCP `get_effective_permissions`

### Read effective (resolved) permissions

```js
fetch('/api/permissions/effective?board=bL',
  { headers: { Authorization: `Bearer ${t}` }})
  .then(r => r.json()).then(console.log)
// → cascade-resolved capabilities (tenant → folder → board → column),
//   with a `permissions_rev` ETag for cache invalidation.
```

Unlike `list_permissions` (raw rows) this returns the RESOLVED view/edit/manage capabilities after the tenant→folder→board→column cascade + allow/deny precedence. Human-only fence applies (403 for seat agents; X-Vault-Key passes).

#### `grant_permission` — Grant (or deny) a capability to a subject on a scope. Rank-gated, downward-only.
`POST /api/permissions/grant`  ·  MCP `grant_permission`

### Grant or deny a capability

```js
fetch('/api/permissions/grant', {
  method: 'POST',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    subject_type: 'user',        // or 'rank_at_least'
    subject_id: 'uC',
    scope_type: 'board',          // tenant|folder|board|column|feature|panel
    scope_id: 'bL',
    capability: 'edit',           // view|edit|manage
    effect: 'allow'               // allow|deny
  })
}).then(r => r.json()).then(console.log)
```

Writes are RANK-GATED + DOWNWARD-ONLY: you must hold `manage` on the scope (or own it), the subject must be STRICTLY below your live rank, and you can only grant a capability you yourself hold. Re-grant with a new `effect` to flip allow↔deny. Human-only fence applies.

#### `revoke_permission` — Delete a permission row by id.
`DELETE /api/permissions/{perm_id}`  ·  MCP `revoke_permission`

### Revoke a permission row

```js
fetch(`/api/permissions/${permId}`,
  { method: 'DELETE', headers: { Authorization: `Bearer ${t}` }})
```

`permId` comes from a `list_permissions` row's `id`. Same rank-gate as grant. Human-only fence applies.

### Vault & column encryption (zero-knowledge)

#### `vault_overview` — Zero-knowledge vault + column encryption: how the /vault/* surface works.
`GET /vault/items`

### Vault + column encryption (zero-knowledge)

netdex has a **zero-knowledge** vault and per-column encryption. The encryption key is derived IN THE BROWSER from the user's passphrase (`/vault/kdf` holds only the KDF params + a verifier — never the key); plaintext is encrypted client-side and only ciphertext crosses the wire. There is **no platform master key** — the server literally cannot read an encrypted cell or vault item.

Read paths (all need auth; all return ciphertext / metadata only):

```js
// KDF params for this user's vault (to re-derive the key client-side).
fetch('/vault/kdf',   { headers: { Authorization: `Bearer ${t}` }})
// The user's encrypted vault items (ciphertext blobs + metadata).
fetch('/vault/items', { headers: { Authorization: `Bearer ${t}` }})
// Owner-escrow recipients + key-wrap state for a scope.
fetch('/vault/recipients',  { headers: { Authorization: `Bearer ${t}` }})
fetch('/vault/scope-state', { headers: { Authorization: `Bearer ${t}` }})
```

Write paths (`PUT /vault/kdf`, `PUT /vault/items/{id}`, the `/vault/wraps/*` escrow + share endpoints, `/vault/scopes`) all take client-encrypted payloads. See the full route list in /api/sitemap (`/vault/*`).

🛑 **Frontend-only by design — no MCP tool.** An automation agent (X-Vault-Key / per-seat JWT) can read ciphertext + metadata but **cannot decrypt** (no passphrase, no key). To READ an encrypted cell's plaintext over the API a human mints a short-lived secret-read token (`POST /auth/secret-read/start`) and passes it as the `X-Secret-Read` header with `?f=clean`; without it encrypted cells come back masked (`value: null` + `has_value`).

## Configuration operations
Anything an MCP agent can configure is also a single fetch() against the compact API. PATCH endpoints accept partial dicts (any field omitted stays unchanged); shorthand such as `group_by: 'cl1'` resolves the column on the server.

### Columns

#### `add_column` — Add a new column to a board.
`POST /r/{board_short}/columns`  ·  MCP `add_column`  ·  CLI `netdex column add`

### Create a column

```js
fetch('/r/bL/columns', {
  method: 'POST',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    type: 'label',
    title: 'State',
    settings: { labels: { todo: { text: 'Todo', color: '#aaa' } } }
  })
}).then(r => r.json()).then(console.log)
```

Configurable types (`label`, `dropdown`, `date`, `formula`, and others) take a `settings` payload at create-time and reject value writes until configured. Don't guess the `type` or its settings: the authoritative per-type `settings_schema` is generated at GET /api/column-types (and the 'Configurable column types' table below). (numbers no longer requires configuration: its multi-entry Values rows self-describe via kind/unit per entry.)

#### `update_column` — Update column title / sort_order / visibility / settings.
`PATCH /r/{board_short}/columns/{column_short}`  ·  MCP `configure_column`  ·  CLI `netdex column update`

### Update a column

```js
fetch('/r/bL/columns/cs1', {
  method: 'PATCH',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    title: 'State',
    settings: { format: 'currency' }
  })
}).then(r => r.json()).then(console.log)
```

Settings are shallow-merged into the existing dict by default. Add `?merge=false` to replace wholesale.

#### `lock_column` — Lock or unlock a column to prevent value writes + structural edits.
`PATCH /r/{board_short}/columns/{column_short}`  ·  MCP `update_column`

### Lock / unlock a column

Locking a column freezes it: cell-value writes AND structural edits (title, settings, width, delete) are all rejected until it is unlocked. The ONLY field accepted on a locked column is `is_locked` itself — so you must unlock before editing.

```js
// Lock it (send is_locked alone)
fetch('/r/bL/columns/cs1', {
  method: 'PATCH',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ is_locked: true })
}).then(r => r.json()).then(console.log)

// Unlock it before making other edits
fetch('/r/bL/columns/cs1', {
  method: 'PATCH',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ is_locked: false })
}).then(r => r.json()).then(console.log)
```

Via the MCP: `update_column(board_id, column_id, is_locked=true)` (or `is_locked=false` to unlock). Attempting any other field while locked returns **403** with the disallowed fields listed — unlock in its own call first.

#### `delete_column` — PERMANENTLY delete a column and cascade-delete all its cell values.
`DELETE /r/{board_short}/columns/{column_short}`  ·  MCP `delete_column`  ·  CLI `netdex column delete`

### Delete a column (PERMANENT)

⚠️ **This is permanent and cannot be undone.** It cascade-deletes EVERY cell value in the column, then drops the column row. Cells are NOT preserved. (Deleting a *board* is a soft-archive by default; deleting a *column* is not.)

```js
fetch('/r/bL/columns/cs1',  { method: 'DELETE', headers: { Authorization: `Bearer ${t}` }})
```

To merely **hide** a column without destroying its data, ARCHIVE it instead: add the column's id to `feature_flags.archived_columns` via `PATCH /r/{board}` (the MCP `archive_column` tool wraps this; `restore_column` reverses it). Archived columns stay in the DB, hidden from default views.

#### `add_label_option` — Add a label option to a label / dropdown column.
`POST /r/{board_short}/columns/{column_short}/labels`  ·  MCP `create_label_option`

### Add a label option to a column

```js
fetch('/r/bL/columns/cs1/labels', {
  method: 'POST',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ key: 'in_review', text: 'In Review', color: '#ffaa00' })
}).then(r => r.json()).then(console.log)
```

Returns 409 if the key already exists. Use this instead of PATCH-ing the whole `settings.labels` dict — it preserves existing options atomically.

### Views

#### `create_view` — Create a view (table/kanban/calendar/timeline/graph).
`POST /r/{board_short}/views`  ·  MCP `create_view`

### Create a new view

```js
fetch('/r/bL/views', {
  method: 'POST',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Sprint Kanban', type: 'kanban' })
})
```

`type` is one of `table`, `kanban`, `calendar`, `timeline`, `graph`.

#### `update_view` — Update a view — name, settings, filters, group_by, sort_config, hidden_columns.
`PATCH /r/{board_short}/views/{view_short}`  ·  MCP `update_view`

### Apply a filter to a view

```js
fetch('/r/bL/views/vt1', {
  method: 'PATCH',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    filters: [
      { column: 'cs1', op: 'equals', value: 'done' },
      { column: 'cl18', op: 'in', value: ['high', 'critical'] }
    ]
  })
})
```

Supported ops: `equals`, `not_equals`, `contains`, `gt`, `lt`, `in`, `not_in`, `is_empty`, `is_not_empty`. Pass `filters: null` to clear.

### Hide columns on a view

```js
fetch('/r/bL/views/vt1', {
  method: 'PATCH',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ hidden_columns: ['cn1', 'clt2'] })
})
```

### Sort a view

```js
fetch('/r/bL/views/vt1', {
  method: 'PATCH',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    sort_config: [
      { column: 'cpi1', direction: 'asc' },
      { column: 'cd1',  direction: 'desc' }
    ]
  })
})
```

Same endpoint accepts `group_by`, `name`, `settings`. Any field omitted stays unchanged.

#### `delete_view` — Delete a view permanently.
`DELETE /r/{board_short}/views/{view_short}`  ·  MCP `delete_view`

### Delete a view

```js
fetch('/r/bL/views/vt1', { method: 'DELETE', headers: { Authorization: `Bearer ${t}` }})
```

#### `set_view_group` — Bucket-group a view by a column without creating real groups.
`PATCH /r/{board_short}/views/{view_short}`  ·  MCP `set_view_group`

### Change group-by on a view

```js
fetch('/r/bL/views/vt1', {
  method: 'PATCH',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ group_by: 'cs1' })   // or null to clear
})
```

Preferred over `create_group` per project convention (see /api/conventions grouping).

#### `set_view_freeze_boundary` — Freeze (pin) the leftmost table columns up to a boundary so they stay sticky on horizontal scroll.
`PATCH /r/{board_short}/views/{view_short}`  ·  MCP `set_view_freeze_boundary`

### Freeze columns on a table view

The Name column plus every column from the left edge up to AND INCLUDING the boundary column stays pinned while the rest of the table scrolls horizontally. The value is the boundary column's long id (the SPA reader's shape); `__name` freezes ONLY the Name column; `null` unfreezes everything.

```js
fetch('/r/bL/views/vt1', {
  method: 'PATCH',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ settings: { freezeBoundaryColumnId: 'cl1' } })
})
```

Use `freezeBoundaryColumnId: null` to unfreeze, or `'__name'` for a name-only freeze. Via MCP use `set_view_freeze_boundary(board, view, 'cl1')` — it resolves the short id to the long id. The sub-item table has a mirror: `set_view_sub_item_freeze_boundary` (settings.subItemFreezeBoundaryColumnId).

### Automations

#### `create_automation` — Create an automation (trigger -> action).
`POST /r/{board_short}/automations`  ·  MCP `create_automation`

### Create an automation

```js
fetch('/r/bL/automations', {
  method: 'POST',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    name: 'Auto-promote to working',
    trigger_type: 'value_changed',
    trigger_config: { column_id: 'cs1', to_value: 'ready' },
    action_type: 'set_value',
    action_config: { column_id: 'cs1', value: 'working' }
  })
})
```

Supported `trigger_type`: `value_changed`, `item_status_changed`, `chat_message_received`. Supported `action_type`: `set_value`, `move_item`, `verify_and_score`, `write_to_document`, `emit_event`, `triage_bug_report`.

#### `update_automation` — Update an automation.
`PATCH /r/{board_short}/automations/{auto_short}`  ·  MCP `update_automation`

### Update or disable an automation

```js
fetch('/r/bL/automations/aXXX', {
  method: 'PATCH',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ is_enabled: false })
})
```

#### `delete_automation` — Delete an automation permanently.
`DELETE /r/{board_short}/automations/{auto_short}`  ·  MCP `delete_automation`

### Delete an automation

```js
fetch('/r/bL/automations/aXXX', {
  method: 'DELETE',
  headers: { Authorization: `Bearer ${t}` }
})
```

### Feature flags

#### `set_features` — Toggle board feature flags via PATCH /r/{board} with feature_flags={...}.
`PATCH /r/{board_short}`  ·  MCP `set_features`

### Toggle board feature flags

```js
fetch('/r/bL', {
  method: 'PATCH',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    feature_flags: {
      showFilter: false,
      showGroupBy: true,
      showAutomations: false,
      compactMode: true
    }
  })
})
```

Recognized flags: `subItems`, `addItems`, `editColumns`, `resizeColumns`, `reorderColumns`, `visibilityPanel`, `showFilter`, `showGroupBy`, `showAutomations`, `groupHeaders`, `freezeColumns`, `showArchive`, `showInspector`, `showSearchBar`, `allowViewEditing`, `itemDetails`. Unknown keys are dropped silently.

## Discovering current page state

```js
// Every navigable URL pattern (frontend routes + every registry-backed API endpoint).
fetch('/api/sitemap').then(r => r.json()).then(console.log)

// Plain-English page state plus available actions.
fetch('/api/state?board=bB&view=vt1').then(r => r.json()).then(console.log)

// This guide.
fetch('/api/help').then(r => r.text()).then(console.log)
```

## Cell type write shapes

| Column type | `v` shape |
|-------------|-----------|
| `text` | string |
| `long_text` | multi-entry array |
| `numbers` | multi-entry array |
| `checkbox` | boolean |
| `link` | multi-entry array |
| `email` | multi-entry array |
| `phone` | multi-entry array |
| `location` | multi-entry array |
| `date` | ISO 8601 string |
| `dropdown` | string | array |
| `label` | string (label key) |
| `chat` | array of object |
| `secret` | string |
| `file` | array of object |
| `cross_board_ref` | array of string |
| `execution_log` | array of object |
| `dependency` | array of string |
| `api_lookup` | see helper / server-managed |
| `button` | object |

### Configurable column types

These types accept a `settings` payload at create-time. Without configuration the column may reject value writes or fall back to server defaults:

| Type | Settings keys |
|------|---------------|
| `numbers` | `format`, `unit`, `displayUnit`, `showUnitConversion`, `decimal_places`, `decimals`, `currency`, `currencyCode`, `displayCurrency`, `fxProvider`, `fxRefreshMode`, `fxRefreshIntervalMinutes`, `measurementSystem`, `durationUnit`, `displayDurationUnit`, `showDurationConversion`, `bytesUnit`, `displayBytesUnit`, `showBytesConversion`, `weightUnit`, `displayWeightUnit`, `distanceUnit`, `displayDistanceUnit`, `showMeasurementConversion`, `percentBasisMode`, `percentBasis`, `minimum`, `maximum` |
| `checkbox` | `trueLabel`, `falseLabel`, `trueColor`, `falseColor`, `style`, `positive_label_mode`, `positive_label_custom`, `negative_label_mode`, `negative_label_custom` |
| `date` | `format`, `time_on`, `hour_format`, `show_tz`, `date_style`, `default_tz` |
| `dropdown` | `options`, `labels`, `multi` |
| `label` | `labels`, `labelOrder` |
| `formula` | `expression` |
| `file` | `type`, `properties` |
| `summary` | `aggregation`, `source_column_id` |
| `cross_board_ref` | `target_board_id`, `target_board_short_id`, `multi`, `filter_view_id` |
| `dependency` | `relation_type` |
| `smart_rollup` | `target_type`, `source_subitem_col`, `source_kind_filter`, `source_provenance_col`, `aggregation`, `confidence_weights`, `threshold`, `ttl_days`, `agent_context_md`, `dep_keys` |
| `api_lookup` | `source_item_id`, `source_slug`, `allow_freetext`, `min_chars`, `extras_to_capture`, `use_self_as_source` |
| `button` | `type`, `properties` |

### Multi-entry column types

These types store an array of entries with one marked `isMain:true`. Each entry carries a type tag — use the helpers (`add_*`, `set_main_*`, `update_*`, `remove_*`) rather than raw `set_value`.

| Type | Allowed entry-type tags | Helper MCP tools |
|------|------------------------|------------------|
| `long_text` | `feedback`, `reference`, `project`, `user`, `code`, `plan`, `aar`, `other` | `add_markdown`, `remove_markdown`, `set_main_markdown`, `update_markdown` |
| `numbers` | `number`, `currency`, `percent`, `bytes`, `duration`, `weight`, `distance`, `area`, `volume`, `temperature`, `pressure`, `speed`, `data_rate`, `energy`, `power`, `flow_rate`, `fuel_economy` | `add_number_entry`, `update_number_entry`, `remove_number_entry`, `set_main_number_entry` |
| `link` | `website`, `github`, `linkedin`, `twitter`, `youtube`, `docs`, `figma`, `other` | `add_link`, `remove_link`, `set_main_link`, `update_link` |
| `email` | `work`, `personal`, `school`, `other` | `add_email`, `remove_email`, `set_main_email`, `update_email` |
| `phone` | `mobile`, `cell`, `line`, `work`, `home`, `fax`, `android`, `iphone`, `other` | `add_phone`, `remove_phone`, `set_main_phone`, `update_phone` |
| `location` | `residential`, `commercial`, `mixed` | `add_location`, `remove_location`, `set_main_location`, `update_location` |

## Authentication

```js
// Get a JWT. Store it under whatever key your app already uses.
fetch('/auth/login', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'you@example.com', password: '...' })
}).then(r => r.json()).then(d => {
  localStorage.setItem('access_token', d.access_token)
})
```

Agent / automation alternative: pass `X-Vault-Key: <key>` in place of
`Authorization: Bearer <jwt>`. The compact API accepts either.

## Connectors (third-party integrations)

Third-party integrations (Email/IMAP, Slack, etc.) live on the `bD`
Connectors board — one row per provider with Status / Provider / Auth
Method / Credentials / Config / Last Sync / Health columns. Always go
through the connector framework, not custom integrations. Credentials
are AES-GCM encrypted at rest in the `secret` column.

```js
// 1. Discover available providers (Email/IMAP, etc.)
fetch('/api/connectors').then(r => r.json()).then(console.log)

// 2. Connect an IMAP mailbox (basic auth)
fetch('/api/connectors/imap/connect', {
  method: 'POST',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    credentials: { username: 'me@gmail.com', password: 'app-pw' },
    config: { host: 'imap.gmail.com', port: 993, mailbox: 'INBOX' }
  })
}).then(r => r.json()).then(console.log)

// 3. Sync — pulls last 24h of headers onto the bA Email board
fetch('/api/connectors/imap/sync', {
  method: 'POST', headers: { Authorization: `Bearer ${t}` }
}).then(r => r.json()).then(console.log)

// 4. Health check (NOOP, no data pulled)
fetch('/api/connectors/imap/health',
  { headers: { Authorization: `Bearer ${t}` }}).then(r => r.json()).then(console.log)

// 5. Disconnect — wipes credentials, marks the row disconnected
fetch('/api/connectors/imap/disconnect', {
  method: 'POST', headers: { Authorization: `Bearer ${t}` }
}).then(r => r.json()).then(console.log)
```

MCP tools: `list_connectors`, `connect_provider`, `sync_provider`,
`disconnect_provider`, `get_connector_health`. See `/api/conventions`
`connector_pattern` for the full rule.

## Multi-tenancy

Every entity is scoped to a `tenant_id`. AI agents see only their own
tenant's data — cross-tenant access returns 404, never 403 (don't leak
existence). The default deployment is single-tenant (`AVP`); adding a
second tenant is a one-time setup operation.

```js
// Who am I?
fetch('/api/tenant/me', { headers: { Authorization: `Bearer ${t}` }})
  .then(r => r.json()).then(console.log)
// -> { id, short_id, name, slug, plan, is_super_admin }

// What's my tenant using?
fetch('/api/tenant/usage', { headers: { Authorization: `Bearer ${t}` }})
  .then(r => r.json()).then(console.log)
// -> { boards, items, columns, users }
```

Super-admins (Aaron) can cross tenants via the `/admin/*` routes.

## Trail — next steps

This guide is one node in a self-navigating API trail. Start at the root `GET /api/` for the full link graph, then follow these. The discovery layer is public; reads into instance data need auth.

- **root** — `GET /api/` — The API discovery root — the full trail graph.
- **sitemap** — `GET /api/sitemap` — Every navigable URL + API route (method, path, MCP tool, CLI).
- **conventions** — `GET /api/conventions` — Machine-readable project rules — read before any CRUD.
- **column-types** — `GET /api/column-types` — The 22 column types + their write/settings schemas (the create/fill contract).
- **icons** — `GET /api/icons` — Pickable icon registry (search + groups).
- **colors** — `GET /api/colors` — 16-color palette × 10 shades for option/board/group colors.
- **state** — `GET /api/state` — Per-page orientation + available actions. Base call is public; `?board={short}` is tenant-scoped (auth required — an unauthed/cross-tenant board 404s). _(auth required)_
- **boards** — `GET /r/boards` — List the tenant's boards (the entry into instance data). _(auth required)_
