# Netdexperts — Agent Guide

## 🛑 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 135 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 /boards`)** — see what boards actually
   exist. ~18 today; the right one 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   | `bqr`      |
| `i`    | Item    | `i4k`      |
| `g`    | Group   | `g7m`      |
| `c`    | Column  | `cs1`      |
| `v`    | View    | `vt1`      |
| `f`    | Folder  | `f1a`      |

Columns carry a second character that names the column type:

| Code  | Column type | Example  |
|-------|-------------|----------|
| `s`   | status      | `cs1`    |
| `lt`  | long text   | `clt1`   |
| `em`  | email       | `cem1`   |
| `n`   | numbers     | `cn1`    |
| `d`   | date        | `cd1`    |

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`    |

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

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

Every read works the same way:

```
GET /r/bqr           → board (full)
GET /r/bqr?f=min     → board (94% smaller; ids + names only)
GET /r/bqr?f=std     → board (76% smaller; standard fields)
GET /r/i4k           → item
GET /r/cs1           → column
```

Mutations are equally compact:

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

The verbose `/boards/{uuid}/items/{uuid}/values` REST endpoints still work,
but `/r/{short}` is preferred — fewer tokens, fewer round-trips.

## 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. |
| 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=btj                                 → orientation
2. GET /r/btj/search?q=X&fields=cn26,cl89,cl87&limit=10     → candidates + ranked cells
3. GET /r/btj/{best_match}?f=std                            → the one you want
4. (act on it)
```

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

**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/btj/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=btj,bqr&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/btj/agg?group_by=cdd19         # → {"rag": 7, "obsidian": 4, …}
GET /r/btj/agg?group_by=cl87          # → {"review": 5, "ingested": 12, …}
GET /r/btj/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 128-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:** 128 — search, agg, batch read, cross-board search, CRUD
  for boards/items/cells/columns/groups/views/folders/automations/files,
  FiftyCAL, 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 has a long internal PK and a short_id. AI
  agents must use short_ids exclusively — long IDs never appear in API
  responses, MCP returns, CLI output, URLs, or 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".
- **Agent workflow columns.** Boards that drive AI agent work need this
  system column set: status, priority, agent, model, goal, directive,
  done_when, skill, mcp, permissions, workspace, prompt. The default
  scaffold creates them automatically.
- **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.
- **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.

## 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-…", "col-…"]}}` — camelCase key, LONG col-ids, REPLACES the prior list. Use `GET /r/{board}?schema=only` to map short→long. |
| 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-…", …]}}` — camelCase, long ids; replaces. |
| Column widths | `{"settings": {"columnWidths": {"col-…": 240, …}}}` — camelCase, long-id → px-int. |
| 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 gotcha.** The frontend reads `settings.hiddenColumns`
(camelCase, long ids). The backend's legacy body shorthand
`{"hidden_columns": ["col_short", …]}` stores it at
`settings.hidden_columns` (snake_case, short ids) which the frontend
will NOT see. ALWAYS use the camelCase key inside `settings` for any
new write. Same convention for `columnOrder`, `columnWidths`,
`hiddenSubItemColumns`, `subItemColumnOrder`, `subItemColumnWidths`.

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

- Lock column      → `PATCH /r/{board}/columns/{c}` body `{"is_locked": true}`
- Archive column   → `DELETE /r/{board}/columns/{c}` (soft-delete; cells preserved)
- 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. **Don't write column short_ids into view settings arrays.** The
   frontend uses LONG col-ids for lookups. Use `GET ?schema=only` to
   map short→long.
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"
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/${folder}/icon`, { method: 'PUT', body: JSON.stringify({ v: 'database' }) })
await fetch(`/r/${folder}/color`, { method: 'PUT', body: JSON.stringify({ v: blue.shades[1] }) })  // dark blue
```

## `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.bqr.title
board.{short}.action.{name}         e.g. board.bqr.action.create
board.{short}.view.{viewShort}      e.g. board.bqr.view.vt1
board.{short}.row.{itemShort}       e.g. board.bqr.row.i4k
board.{short}.group.{groupShort}    e.g. board.bqr.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_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=bqr&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 /boards`  ·  MCP `list_boards`  ·  CLI `netdex board list`

### List boards

```js
fetch('/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/bqr', {
  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/bqr', { method: 'DELETE', headers: { Authorization: `Bearer ${t}` }})

// Hard-delete — cascades to columns / groups / items / values / views.
fetch('/r/bqr?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/bqr?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/bqr/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/bqr/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/bqr/items/iaaa', { method: 'DELETE', headers: { Authorization: `Bearer ${t}` }})
```

Items can't be deleted while locked or while marked `is_leader`.

### 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/bqr/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/bqr/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/bqr/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/bqr/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/bqr/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/bqr/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/bqr/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/bqr/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/bqr/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/bqr/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/bqr/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/bqr/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.

## 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: 'cs1'` 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/bqr/columns', {
  method: 'POST',
  headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    type: 'status',
    title: 'State',
    settings: { labels: { todo: { text: 'Todo', color: '#aaa' } } }
  })
}).then(r => r.json()).then(console.log)
```

For status/label/priority/dropdown/single_select/multi_select/numbers/date/formula columns, `settings` is REQUIRED — the column rejects writes until configured.

#### `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/bqr/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.

#### `delete_column` — Delete a column (cascades to its values).
`DELETE /r/{board_short}/columns/{column_short}`  ·  MCP `delete_column`  ·  CLI `netdex column delete`

### Delete a column

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

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

### Add a label option to a column

```js
fetch('/r/bqr/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/bqr/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/bqr/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/bqr/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/bqr/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/bqr/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/bqr/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).

### Automations

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

### Create an automation

```js
fetch('/r/bqr/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`, `run_agent`, `spawn_agent`, `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/bqr/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/bqr/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/bqr', {
  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`, `leaderItems`, `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=bqr&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` | string |
| `numbers` | number |
| `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 |
| `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`, `decimal_places`, `decimals`, `currency`, `minimum`, `maximum` |
| `checkbox` | `trueLabel`, `falseLabel`, `trueColor`, `falseColor`, `style` |
| `date` | `time_on`, `format` |
| `dropdown` | `options`, `labels`, `multi` |
| `label` | `labels`, `labelOrder` |
| `formula` | `expression` |
| `summary` | `aggregation`, `source_column_id` |
| `cross_board_ref` | `target_board_id`, `target_board_short_id`, `multi`, `filter_view_id` |
| `button` | `type`, `properties`, `required` |

### 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 |
|------|------------------------|------------------|
| `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 `buz`
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 b1p 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.
