---
name: cdb
description: Read and write the cdb cleanroom chip-fabrication database over its HTTP API — look up chips/wafers and their process history, record fabrication steps and measurements, attach data files, and turn transcribed lab recordings or notes into entries for human review. Use whenever the user mentions cdb, the chip database, logging a fab step, or ingesting a cleanroom recording.
---

# cdb — chip database API

cdb tracks cleanroom fabrication as a graph: **nodes** are physical objects
(wafers, chips, flip-chips), **edges** are process steps that turn input nodes
into output nodes, and **chars** (characterizations) are measurements attached
to one node. Every change is a git commit attributed to the account that made
it. cdb has **no built-in speech-to-text or LLM** — you are the intelligence;
this API is the only way in.

Ids: nodes `n-xxxxxx` (permanent; names can change and repeat), edges and
chars `<type>-<YYYYMMDD>-<NNN>` (e.g. `sem_microscopy-20261021-002`). Every
read and write result carries the `id`; keep it.

## Setup

- Base URL: `https://linqs-cdb.dev` (below: `$CDB_URL`).
- Token: `$CDB_TOKEN`, sent as `Authorization: Bearer $CDB_TOKEN`. Never
  print, log or commit it. If there is none, ask the user to create one in
  the GUI (**⋯ → API tokens**, e.g. named `<them>-claude`; or on the server
  `cdb user token <name> --owner <them>`) and put it where they keep
  secrets. There is no way to get a token yourself. A token acts under its
  own name (what your direct writes are recorded as), is never an admin, and
  stops working if its owner is disabled. Every token **expires** (1 hour to
  90 days, chosen by its owner); after that it answers 401 and the user must
  make a new one. A **read-only** token answers 403 to every write (a batch
  of read ops is fine): tell the user rather than retrying.
- **Project:** every new entry must belong to a project. Send
  `X-CDB-Project: <name>` (comma-separate several) on requests that create
  entries, or pass `"projects": [...]` on a create op, a batch or a proposal
  (that wins over the header). Names are case-insensitive; unknown names are
  refused; `default` means "none" and does not count. Edits to existing
  entries need no project. `GET /api/v1/projects` lists them. If you don't
  know which project the work belongs to, **ask** — never guess. Only people
  create projects (in the GUI).
- Check it works: `curl -s -H "Authorization: Bearer $CDB_TOKEN" $CDB_URL/api/v1`
- JSON bodies (`-H 'Content-Type: application/json'`) except uploads
  (multipart). Request bodies over 2 MB are refused with 413.

### Errors

Always read the message — it says what to fix — then send a corrected call.

| status | body | meaning |
|---|---|---|
| 400 | `{"error": ...}` | cdb refused the call: unknown/ambiguous name, unknown type, field or project, missing argument or one of the wrong JSON type (each op's `input_schema` is checked: a string where it says array is refused, not split), no project for a create. Batches and proposals add `errors: [{"index", "error"}]` per op/step. |
| 400 | `{"detail": ...}` | bad query parameter or body (bad regex, `since` not `YYYY-MM-DD`, empty `ops`/`steps`, too many ops) |
| 401 | `detail` | token missing, wrong, deleted or expired — ask the user; don't retry |
| 403 | `detail` | owner not approved/disabled, a read-only token writing, or a route agents may not use (see rule 2) |
| 404 | `detail` | unknown op name, `/entries/<id>` not found, proposal not open |
| 413 | `detail` | body or upload too large |
| 429 | `detail` | too many requests (rate, write time, in flight, upload allowance) — wait, then retry more slowly |
| 507 | `detail` | the server is low on disk; writes are paused — tell the user |
| 422 | `detail` (list) | body is not the JSON shape the endpoint expects |

Note: `POST /api/v1/ops/get_entry` on a missing id is a 400 `error`, not a 404.

## Ground rules (these matter more than completeness)

1. **Blank beats wrong.** Never guess a field value. Omit a field that was not
   stated. If it was stated but garbled or ambiguous, send it with
   `"good": false`. Only `"good": true` when you are confident.
2. **You cannot confirm or approve anything.** Every write lands
   *unconfirmed* in the human review queue, and your work (direct writes and
   proposals alike) is confirmed or approved only by the person your token
   acts for. Agent tokens may read any GET endpoint but write only through
   `/api/v1/...`; anything else (confirm, confirm-all, approve/reject,
   delete/restore) answers 403. Don't look for workarounds.
3. **Resolve names before using them.** Names are NOT unique and spoken names
   get mangled ("SIS 12 A" = `SIS-12-A`). Run `search_nodes` first and pass
   the node id (`n-xxxxxx`) — a name shared by several nodes is rejected as
   ambiguous (the error lists the candidate ids). **Prefer recency**: work
   being described is almost always on the chip in progress, so take the
   candidate with the latest `last_used`, unless the user clearly points at
   an older one (a date, "the old one", a distinguishing detail). If nothing
   matches, use the name as given in an edge/char: cdb creates a
   *placeholder* node (type `other`) for a person to check — better than
   attaching work to the wrong chip. Don't `create_node` a chip that already
   exists: it makes a second node with the same name (exception: creating a
   name held by one placeholder fills that placeholder in — it stays a
   placeholder until the person who reviews it marks it real).
4. **Unknown subject:** if no chip is named, infer it from context (earlier
   steps, `list_recent`) and set `"inferred_subject": true`. If you cannot,
   use node name `UNATTACHED` with `inferred_subject: true`.
5. **Shape of steps.** A normal process on a chip is an edge with the same
   node as input and output. Cleaving/dicing: 1 input → N outputs (list them,
   or `output_base` + `output_count` for `<base>.1 … <base>.N`, at most 500).
   **Same-day order:** steps that share a date have no defined order.
   When a day's steps have no times, give them times a minute apart in the
   order they happened (`…T09:00:00`, `…T09:01:00`, …); if some steps that
   day do have real times, slot the others a minute after the step they
   followed. That small nudge is the convention — it is not an invented
   time, just an ordering. Junction deposition on mask +
   target: 2 inputs → 1 output. Flip-chip bonding: 2 chips → 1 `flipchip`
   node; flip-chip separation: 1 flipchip → 2 chips. New outputs become
   placeholders of type `other`; to get a real type (e.g. `flipchip`),
   `create_node` it first in the same batch/proposal and reference it by
   name. Measurements (microscopy, profilometry, probing, cooldown, ...)
   are chars, not edges. "Moved X to the office" is `set_location`, not a
   new entry.
6. **One described process = one entry.** Do not invent extra entries; put
   anything stated that has no field into `notes`, close to verbatim.
7. Only `rename_node` when the user explicitly asks to relabel a chip — never
   to "fix" a mis-transcription (resolve to the existing node instead). Only
   `set_projects` when the user asks to move an entry between projects.
8. Field names come from `GET /api/v1/schema` — they differ per type and
   change, so fetch them. Unknown fields are rejected (except on `other`
   types); `location` is allowed on every type. Field values up to ~10 KB,
   notes and transcripts up to ~1 MB.
   **`location` and `tool` values come from one shared list**
   (`GET /api/v1/catalog`, also `catalog` in the schema): use a name
   from it exactly as spelled there ("npc acid bench" said aloud is
   `NPC acid bench` if that is the list's spelling). If what was said
   matches nothing in the list, write it as said and name it in your
   summary or proposal so a person can add it; agents can't edit the list.
9. **Dates:** `YYYY-MM-DDTHH:MM:SS` in fab-local wall-clock time (US
   Pacific), no `Z` or offset — convert first. If only the day or month is
   known, `YYYY-MM-DD` or `YYYY-MM` is accepted (never invent a time).
   Anything else is refused with 400. Give the time the work was
   *performed*; omitted = now. When back-filling old work, also give nodes
   their `created` date, or they appear as made today.
10. **Groups:** chips that belong together (one wafer's pieces, one
    experiment or test series) go in a named group (`create_group`); a node
    is in at most one group. Check `list_groups` first and extend an
    existing group (`update_group` `add_members`) rather than making a
    near-duplicate.

## Discover

| call | returns |
|---|---|
| `GET /api/v1/schema` | `types.{node,edge,char}[]` with `type`, `fields`, `open_fields`, `retires_inputs`; `catalog` (the locations & tools list; `locations` is the same list); `projects` |
| `GET /api/v1/catalog` | `items`: the names `location` and `tool` values are picked from (rule 8); `fields: ["location", "tool"]` |
| `GET /api/v1/projects` | `projects: [{name, entries, ...}]` and `users` (who recorded entries) |
| `GET /api/v1/ops` | `ops: [{name, description, input_schema, writes}]` |
| `GET /api/v1/ops?format=anthropic` / `openai` | `tools`: the same as ready-made LLM tool definitions |
| `GET /api/openapi.json` | the full OpenAPI schema (needs the token) |

If you are wiring cdb into another model's tool loop, pass it
`/api/v1/ops?format=...` verbatim and route each tool call to
`POST /api/v1/ops/<name>` (or collect the writes into a proposal).

## Operations

`POST /api/v1/ops/<name>` with the arguments as the JSON body; the answer is
`{"result": ...}`. Ops see every project (no filtering).

Read:
- `search_nodes {query}` — fuzzy name match, best 8: `{matches: [{id, name, type, placeholder, location, created, last_modified, last_used}]}`
- `list_nodes {}` — up to 100 nodes, most recently used first
- `get_entry {id}` — summary of one entry (id or unambiguous node name)
- `list_recent {limit=10}` — latest edges/chars, newest last (limit 1..200)
- `get_history {node, limit?, offset?}` — every step from the node's root ancestor (so a diced piece includes its wafer's steps), oldest first, 200 per page (`limit` at most 1000); the answer's `total` counts them all and `next_offset`, when present, fetches the next page
- `list_groups {}` — named groups: `{groups: [{id, name, members, member_names, notes, color}]}`

These summaries give field *values* only and trim notes to 400 characters;
for `good`/`confirmed` flags, full notes and files use
`GET /api/v1/entries/<id>`.

Write (all return the entry summary):
- `create_node {name, type, fields?, notes?, created?}` — `created`: when the object was made (back-filling); default now
- `create_edge {type, inputs, outputs | output_base+output_count, date?, fields?, notes?, inferred_subject?}` — `outputs` is required unless `output_base`+`output_count` are given
- `create_char {type, node, date?, fields?, notes?, inferred_subject?}`
- `update_field {id, field, value, good}` (`value: ""` clears it)
- `append_notes {id, text}`
- `set_location {node, location, date?}` — `date` when the move happened (back-filling); the location field shows the latest move
- `attach_file {id, path}` — record a file reference on an entry: a path in
  the repo (`data/n-…/x.csv`), or an **external location** — an `https://`
  link (e.g. a Google Drive image), an absolute path, a Windows drive or a
  share (`Z:\group\Fab\run3\sem.png`). It is stored as text and never
  fetched. A relative path pointing outside the repo is refused. To store
  the file itself in cdb, upload it (below).
- `create_group {name, members, notes?, color?, failed?}`, `update_group {id, name?, notes?, color?, failed?, add_members?, members?}` — members are node ids; `failed: true` marks a batch that went wrong
- `rename_node {node, new_name}`, `set_projects {id, projects}` (complete new list) — only on request (rule 7)

All three creates also take `projects`, `files` (file references to attach
in the same call, same rules as `attach_file` — so a batch or proposal can
attach files to entries it creates) and `source`, the provenance shown to
the reviewer: for a recording `{"audio": "REC_….wav", "segment": ["00:03:10",
"00:04:02"]}`, for a text source `{"document": "Run3 notes.md", "lines":
[449, 507]}` (put the line range of the passage each entry came from).
Batches take a default `source`; proposals take `audio` or `document`. `fields` is
`{"<field>": {"value": ..., "good": true|false}}`.
`excerpt` (a short verbatim quote) is kept only in proposals, where the
reviewer sees it. An optional argument sent as `null` counts as not given
(strict-mode tool calls may send every argument); a wrong type is a `400`.

## Other endpoints

- `GET /api/v1/search?q=<text>` → `{q, total, results: [entry]}`, each
  result as `GET /api/v1/entries/<id>` gives it except that notes over 500
  characters are cut there (`notes_truncated: true`: fetch the entry for
  all of them); an answer over 10 MB is cut short too (`truncated: true`
  — narrow the search).
  Searches node names, ids, types, field values, notes and attached file
  paths (not a process's input/output names — use `get_history` for a
  chip's steps). Case-insensitive substring; uppercase `AND` / `OR` combine
  terms (`HF AND piranha`; lowercase `and` is a word); a term holding any
  of `^ $ [ ] ( ) | * + ? { } \` is a regex (`^SIS-12`) — a `.` alone
  does not make one, so `W1.2` is plain text but `^W1.2` is a regex where
  `.` is any character. Over-long or slow regexes are refused, as are
  comments, inline flags like `(?i)` (search is case-insensitive anyway;
  `(?-i:...)` can't make part of it case-sensitive and is refused too),
  recursion and fuzzy matching. Parameters: `projects=a,b` (`default` = untagged), `users=x,y`
  (who recorded it), `since=YYYY-MM-DD` (process date; node creation date),
  `kinds=node,edge,char`, `sources=name,type,fields,notes,files`,
  `limit=50`, at most 500 (`total` counts all hits). A query is at most 500
  characters and 16 terms, which share one 2 s time budget.
- `GET /api/v1/entries/<id>` — one entry in full: `fields` with
  `value/good/confirmed`, `files`, `notes`, `projects`, `source`, inputs/
  outputs or node (with `_display` names), `location_history`, and `review`
  (what a person still has to check: `blank`, `uncertain`, `unconfirmed`).
- `POST /api/v1/entries/<id>/files` — multipart upload (`-F file=@sem.png`);
  stored under `data/<node>/` (an edge's first output) and attached. Returns
  `{"stored": "<path>", "entry": {...}}`. Images (jpg, png, tif, heic, ...)
  up to 10 MB are re-encoded to a JPEG of ~1 MB (≤2400 px; the name becomes
  `.jpg`); other files up to 1 MB are stored as is. Larger: 413.
- `GET /api/datafile?path=<stored path>` — download an attached file
  (`&download=true` to force a download).

## Choosing how to write

| situation | use |
|---|---|
| user told you exactly what to record, in this conversation | `POST /api/v1/ops/<name>` or `/batch` |
| several related writes that must land together | `POST /api/v1/batch` |
| you are interpreting something the user isn't checking live — a recording, a transcript, a notebook photo, a long log | `POST /api/v1/proposals` |
| the user explicitly asked you to add it all and won't be approving steps (e.g. a bulk import of old notes) | `POST /api/v1/batch` — the user's instruction wins |

When unsure, propose: a proposal costs the user one click; a wrong entry
costs them a cleanup. But an explicit "add it" from the user overrides
this — direct writes are still unconfirmed and land in the review queue,
so the user reviews them there instead.

### Batch

```json
POST /api/v1/batch
{"projects": ["SOISMASH02"],
 "ops": [
   {"op": "create_edge", "args": {"type": "resist_spin", "inputs": ["n-3fa21c"], "outputs": ["n-3fa21c"],
     "date": "2026-10-21T14:05:00",
     "fields": {"resist": {"value": "PMMA A4", "good": true},
                "spin_rpm": {"value": "4000", "good": true}}}},
   {"op": "set_location", "args": {"node": "n-3fa21c", "location": "cleanroom"}}],
 "source": {"audio": "REC_20261021_140210.wav"},
 "dry_run": false}
```

Up to 200 ops creating at most 1000 entries in all (nodes, steps and their
outputs), applied in order as one commit; if any op fails the whole
batch is rolled back (`400`, `errors` names the failing index) and nothing
is written. Writes are rate-limited per person (240 write requests a
minute across all their tokens, and about 20 s of write time a minute;
`429` beyond): batch them, and on a 429 wait and retry. One token has at
most 8 requests in flight at once, one person (all their tokens) 12, of
which at most 2 writes — send writes one after another, not in parallel; uploads are limited to 30 a minute and
500 MB a day per person. Numbers must be
finite (`NaN`/`Infinity` are refused with `400`). A batch's results are at
most 20 MB in all: past that it fails (`400`) — split it, or page
`get_history` with `limit`/`offset`.
Success: `{"results": [...], "created": [ids]}`. Batch-level
`projects`/`source` are defaults for every create. `"dry_run": true`
returns `{"ok": true, "summaries": [...]}` after a *shallow* check (op names,
types, fields, projects, dates, file paths, targets of updates); it does
not resolve node names, so an ambiguous name or a missing `set_location` node can still fail
the real run — pass ids.

### Proposals (human review)

```json
POST /api/v1/proposals
{"summary": "Two steps on SIS-12-A: PMMA spin, then ebeam exposure.",
 "projects": ["SOISMASH02"],
 "audio": "REC_20261021_140210.wav",
 "recorded": "2026-10-21T14:02:10",
 "transcript": "<the full transcript or source text — the reviewer reads it>",
 "steps": [
   {"op": "create_edge",
    "args": {"type": "resist_spin", "inputs": ["n-3fa21c"], "outputs": ["n-3fa21c"],
             "fields": {"spin_rpm": {"value": "4000", "good": true}}},
    "excerpt": "process notes, spun PMMA A4 at four thousand"}]}
```

Steps are write ops only (up to 200, creating at most 1000 entries). One
person — all their agent tokens together — may have at most 50 proposals
waiting for review, 20 MB in all (`429` beyond that); one proposal is at
most 2 MB. They get the same shallow check as a
dry run; any invalid step rejects the whole submission (`400` with per-step
`errors`) — fix and resend. Returns `201` with the proposal (`id`, derived
from the `audio` file name, else `p-<timestamp>`; `steps`; `open_steps`).
A person reviews it in the GUI's **pending** queue, may edit any step, and
approves or rejects each one; approved steps are applied then, recorded under
the approver's name with `audio` as provenance. `create_edge`/`create_char`
steps without a `date` get `recorded` (+1 s per step index, keeping order);
`recorded` (YYYY-MM-DDTHH:MM[:SS], fab-local; anything else is a `400`)
defaults to now.

- `GET /api/v1/proposals/<id>` — each step's `status` (`proposed`,
  `approved`, `rejected`, `failed`) and `result` (created id or error). A
  failed step stays open for the reviewer to fix. Once every step is approved
  or rejected the proposal is closed and this returns 404 — find the entries
  with `list_recent` or search.
- `GET /api/v1/proposals` — every open proposal, from all users.
- `DELETE /api/v1/proposals/<id>` — withdraw your own proposal (rejects every
  step not yet approved; approved ones stay). Someone else's: 403.

## Workflow: a lab recording

The user provides speech-to-text (e.g. whisper — force the language, use a
VAD filter: cleanroom audio is mostly noise, and whisper hallucinates on it).
Then:

1. Read the whole transcript. Operators usually bracket each entry with the
   cue phrases "process notes" … "end notes", but treat them as hints: they
   forget the end cue, paraphrase, or describe a step with no cue at all.
   Ignore chatter; record every clearly described step, measurement, or
   movement, in the order performed.
2. `GET /api/v1/schema` once (its `catalog` gives the location and tool
   names to use); `search_nodes` every chip mentioned and note
   the ids. Settle the project (ask if the recording doesn't make it obvious).
3. Map each step to one write op with only the values actually said
   (rule 1). Use the recording's start time (fab-local) as `recorded`; give a
   step its own `date` only if a time was said.
4. Submit one proposal per recording with the full transcript, a short
   summary, the `audio` file name, and an `excerpt` per step. Tell the user
   it is waiting in the pending queue (and its id).

## Workflow: direct entry ("log a 4000 rpm PMMA spin on SIS-12-A")

`search_nodes` → pick the id (rule 3) → `create_edge` with the header or
`projects` → report the returned `id`. To add an image afterwards: upload it
to that id.

## Workflow: questions about the database

`search_nodes` → `get_history` answers "what has been done to SIS-12-A?";
`/api/v1/search?q=ebeam&projects=...&since=...` finds entries across chips.
Before quoting values, read `GET /api/v1/entries/<id>` (or use the full
entries in search results) and say when a value is unconfirmed
(`confirmed: false`) or flagged uncertain (`good: false`).

## Examples

```sh
H="Authorization: Bearer $CDB_TOKEN"; J='Content-Type: application/json'
P="X-CDB-Project: SOISMASH02"
curl -s -H "$H" -H "$J" -X POST $CDB_URL/api/v1/ops/search_nodes -d '{"query": "SIS 12 A"}'
curl -s -H "$H" -H "$J" -X POST $CDB_URL/api/v1/ops/get_history -d '{"node": "n-3fa21c"}'
curl -s -H "$H" "$CDB_URL/api/v1/search?q=hardbake"
curl -s -H "$H" "$CDB_URL/api/v1/search?q=spin&projects=SOISMASH02&since=2026-10-01"
curl -s -H "$H" -H "$J" -H "$P" -X POST $CDB_URL/api/v1/ops/create_char -d '{"type": "sem_microscopy", "node": "n-3fa21c", "date": "2026-10-21T15:30:00"}'
curl -s -H "$H" -F file=@sem.png $CDB_URL/api/v1/entries/sem_microscopy-20261021-002/files
curl -s -H "$H" -o sem.jpg "$CDB_URL/api/datafile?path=data/n-3fa21c/sem.jpg"
curl -s -H "$H" $CDB_URL/api/v1/proposals/REC_20261021_140210
```
