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

# Mutations

> Create, update, delete, restore, and batch nodes.

All writes go through one endpoint. The `action` field selects the operation.

```
POST /api/v1/mutate
```

Node actions: `add`, `update`, `delete`, `restore`, `batch`, `classify`. Phase actions (`addIteration`, `updateIteration`, `deleteIteration`, `reorderIterations`) are on the [Iterations](/api-reference/iterations) page. Project actions (`addProject`, `renameProject`) are on the [Projects](/api-reference/projects) page.

Send an `Idempotency-Key` header on every write so a retry never double-applies. See [Idempotency](#idempotency).

## Add a node

Creates a node with a generated ID.

```bash theme={null}
curl -X POST "https://productbrain.com/api/v1/mutate" \
  -H "Authorization: Bearer pb_..." \
  -H "Idempotency-Key: 7b1f2c84-2f1e-4b0a-9c3d-your-uuid" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "add",
    "projectId": "my-project",
    "node": {
      "type": "job",
      "parentId": "approach-1",
      "data": {
        "label": "Upload progress bar reaches 100% for a 500MB file",
        "maturity": "mvp",
        "iteration": "MVP"
      }
    }
  }'
```

**Response:**

```json theme={null}
{
  "success": true,
  "node": {
    "id": "job-199",
    "type": "job",
    "parentId": "approach-1",
    "data": {
      "label": "Upload progress bar reaches 100% for a 500MB file",
      "maturity": "mvp",
      "iteration": "MVP"
    }
  },
  "_meta": {
    "focus": { "nodeId": "job-199", "viewCommand": { "type": "zoomToNode", "nodeId": "job-199" } }
  }
}
```

`_meta.focus.viewCommand` can be passed straight to [`POST /view-command`](/api-reference/view) to show the user what you just did. A job or task created without an `iteration` lands in `Later` and the response says so in `_meta._tip`.

### Parent rules

| Type | Parent | Parent type |
| - | - | - |
| Goal | None. Do not send a `parentId`. | |
| Need | Required | Goal |
| Bet (`approach`) | Required | Need |
| Job | Required | Bet (`approach`) |
| Task | None | |

A task is standalone work that does not fit the tree (bugs, polish, captures). It is created without a `kind`, and the API classifies it as `memo` or `do-it` asynchronously. Re-read the node, or call `{ "action": "classify", "nodeId": "task-1" }` to classify it on demand.

## Update a node

PATCH semantics. Include only the fields you want to change. The rest are preserved.

```bash theme={null}
curl -X POST "https://productbrain.com/api/v1/mutate" \
  -H "Authorization: Bearer pb_..." \
  -H "Content-Type: application/json" \
  -d '{
    "action": "update",
    "projectId": "my-project",
    "nodeId": "job-45",
    "data": { "status": "done" }
  }'
# → { "success": true, "node": { "id": "job-45", "type": "job", "parentId": "approach-3", "data": { ... } } }
```

`parentId` can be sent alongside `data` to reparent a node. The new parent must be the right type.

Arrays are replaced, not merged. To change one entry of `externalRefs`, `serves`, `demand` or `blockedBy`, send the whole new list. Moving a bet under a need it already serves also removes that need from `serves`.

```bash theme={null}
# Point a job at its Jira issue
curl -X POST "https://productbrain.com/api/v1/mutate" \
  -H "Authorization: Bearer pb_..." \
  -H "Content-Type: application/json" \
  -d '{
    "action": "update",
    "projectId": "my-project",
    "nodeId": "job-45",
    "data": { "externalRefs": [{ "system": "jira", "key": "PROJ-1234", "url": "https://acme.atlassian.net/browse/PROJ-1234" }] }
  }'
```

### Status

`done` means live in production. That is the whole gate. There is no separate verification step.

* To close a job or task: `{ "status": "done" }`.
* To reopen one: `{ "status": "open" }`. It is stored as `null` and `completedAt` is cleared. An explicit `{ "status": null }` does the same.
* `"delivered"` is deprecated. It is still accepted, but treat it as "not done yet" and do not set it for new work.

A closed phase cannot hold open work. Reopening a job whose phase is `done` returns `400` with a `_meta._tip` that names the phase and both ways out: reopen into a live phase in the same call (`{ "status": "open", "iteration": "Later" }`), or reopen the phase first. If an update leaves a job open inside a done phase by another route, the job is moved to `Later` and `_meta._tip` says so.

An unknown `nodeId` returns `400 { "success": false, "errors": ["Node with id 'job-999' not found"] }`.

## Delete a node

Hard delete.

```bash theme={null}
curl -X POST "https://productbrain.com/api/v1/mutate" \
  -H "Authorization: Bearer pb_..." \
  -H "Content-Type: application/json" \
  -d '{
    "action": "delete",
    "projectId": "my-project",
    "nodeId": "job-99"
  }'
```

```json theme={null}
{
  "success": true,
  "deleted": { "id": "job-99", "type": "job", "label": "..." },
  "orphanedChildren": [],
  "_meta": { "_tip": "Hard delete ... recover only via the changelog (there is no undo)." }
}
```

Two things to know before deleting a node that has children:

* Everything beneath the node is removed too. The database enforces the parent link, so the children and grandchildren go with it. The response lists the direct children in `orphanedChildren`, but they are no longer in the project.
* Only the node you named is written to the changelog. Its descendants are not, so they cannot be restored. If you want a subtree recoverable, delete the leaves first, one call each, or use a [batch](#batch-mutations).

The LLM guide currently says that delete does not cascade and that a restored child comes back orphaned. The behaviour above is what the API does today (the parent link is a foreign key with `ON DELETE CASCADE`), and the guide is being corrected. This page documents observed behaviour on this point.

## Restore a node

Recover a hard-deleted node from the changelog. It returns with its original `id`, `type`, `parentId`, and `data`. Identify it by `nodeId` (restores the most recent deletion of that node) or by a specific `changelogId`.

```bash theme={null}
curl -X POST "https://productbrain.com/api/v1/mutate" \
  -H "Authorization: Bearer pb_..." \
  -H "Content-Type: application/json" \
  -d '{
    "action": "restore",
    "projectId": "my-project",
    "nodeId": "job-99"
  }'
# → { "success": true, "node": { ... }, "_meta": { "_tip": "Restored job-99 ...", "focus": { ... } } }
```

* `409` if a node with that id already exists (nothing to restore). `404` if no deletion is on record, which is also what you get for a node that was removed as a descendant of another delete.
* The original parent must still exist. If it was deleted too, restore the parent first. Restoring under a missing parent currently fails with `500 { "success": false, "errors": ["Failed to restore node"] }`.
* Restore does not cascade. It brings back the single node, not its former children.

## Batch mutations

Several operations in one request. A batch may mix `add`, `update`, `delete`, and `addIteration`.

```bash theme={null}
curl -X POST "https://productbrain.com/api/v1/mutate" \
  -H "Authorization: Bearer pb_..." \
  -H "Idempotency-Key: 5a2d9e10-...-your-uuid" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "batch",
    "projectId": "my-project",
    "mutations": [
      { "action": "addIteration", "iteration": { "name": "Checkout" } },
      {
        "action": "add",
        "node": {
          "id": "draft-job-1",
          "type": "job",
          "parentId": "approach-1",
          "data": { "label": "First checkpoint", "iteration": "Checkout" }
        }
      },
      { "action": "update", "nodeId": "job-100", "data": { "status": "done" } }
    ]
  }'
```

```json theme={null}
{
  "success": true,
  "results": [
    { "success": true, "iteration": { "name": "Checkout", "status": "active" } },
    { "success": true, "node": { "id": "job-2", "type": "job", "parentId": "approach-1", "data": { "..." : "..." } } },
    { "success": true, "node": { "id": "job-100", "..." : "..." } }
  ]
}
```

How a batch behaves:

* **Validation is all-or-nothing.** Every mutation is validated before anything is written. One invalid mutation rejects the whole batch with `400 { "success": false, "errors": ["mutations[1]: ..."] }`.
* **Phases first.** `addIteration` entries are applied before any node mutation, so a job can reference a phase created in the same batch.
* **Temporary IDs.** To reference a node created earlier in the same batch, give it an `id` starting with `draft-` and use that as the `parentId` of a later mutation. It is remapped to the real ID on apply. Do not send other client IDs. Any `id` that does not start with `draft-` is used as the real primary key.
* **Apply is sequential, not a single transaction.** If a write fails part-way, the API compensates where it safely can: it deletes the nodes and phases this batch created, newest first, and the `500` reports what it undid.

```json theme={null}
{
  "success": false,
  "error": "Failed to create node in batch: ...",
  "rolledBack": { "nodes": 2, "iterations": 1 },
  "partial": "...",
  "_meta": { "_tip": "..." }
}
```

Read `partial` before you retry:

* Absent: nothing from the batch survives. `rolledBack` says how much was undone. Fix the cause and retry the whole batch.
* Present: nothing was rolled back (`rolledBack` is `{ "nodes": 0, "iterations": 0 }`) and the batch is partially applied. Reconcile from `GET /nodes` and do not retry.

Compensation is only safe while the batch has applied nothing but creates. Once an `update` or a `delete` has run, undoing the creates could take pre-existing rows with them, so the API stops, changes nothing further, and tells you. Two cases it cannot cover: a function timeout mid-compensation (you see no response at all, so treat it like `partial`), and a timeout after the write succeeded (this is what the `Idempotency-Key` fixes). Smaller batches shrink the blast radius.

## Idempotency

Make any `mutate` call (single or batch) safe to retry by sending an `Idempotency-Key` **header**: an opaque string you generate per logical operation (a UUID is ideal).

```bash theme={null}
curl -X POST "https://productbrain.com/api/v1/mutate" \
  -H "Authorization: Bearer pb_..." \
  -H "Idempotency-Key: 7b1f2c84-...-your-uuid" \
  -H "Content-Type: application/json" \
  -d '{ "action": "add", "projectId": "my-project", "node": { "type": "goal", "data": { "label": "..." } } }'
```

* **Replay.** A second request with the same key and the same body returns the original response unchanged, with an `Idempotent-Replayed: true` response header, instead of applying again.
* **Conflict.** The same key with a different body returns `409 { "success": false, "errors": ["Idempotency-Key already used with a different request body"] }`. Generate a fresh key per operation.
* **In flight.** If the first request is still running, a retry returns `409` ("still in progress, retry shortly").
* **Window.** Keys are remembered for 24 hours. Scope is per user.

<Warning>
  The key is a header. An `idempotencyKey` field in the request body is ignored: two requests with the same body key both create a node. The MCP server's `idempotencyKey` tool argument is fine because the shim turns it into the header.
</Warning>

`addProject` is the one action that does not honour the key. Pass an explicit `project.id` instead, so a repeat fails on the existing project rather than creating a second one.

Without an `Idempotency-Key`, retries are not deduplicated. Re-read with `GET /nodes` or search and re-submit only what is missing.

## Validation

The API validates every mutation before applying it:

* **Label is required** on all nodes.
* **Parent must exist** and be the correct type (need under goal, bet (`approach`) under need, job under bet).
* **Fields must match the node type.** Setting `iteration` on a goal or `maturity` on a task is rejected with `'iteration' can only be set on job or task nodes, not on goal`.
* **Kano values:** `must-have`, `incremental`, `delighter`. `performance` is accepted as an alias for `incremental` and stored as sent.
* **Size values:** `skateboard`, `vespa`, `car`, `truck`, `antonov`
* **Maturity values:** `mvp`, `releasable`
* **Bet status (`approachStatus`) values:** `development`, `validation`, `resolved`, `retired`
* **Status values:** `done`, or `open` to reopen (`delivered` is accepted but deprecated)
* **Task kind:** `memo` or `do-it`, with `urgent` only on a `do-it` task
* **External references (`externalRefs`):** any node type, at most 50 entries, no `system` + `key` pair twice, with only `system`, `key` and an `https://` `url`. A rejection's `_meta._tip` restates the shape. See [External references](/api-reference/nodes#external-references).
* **Demand (`demand`):** needs only, at most 25 entries, each with `who` and an `at` date, plus optional `revenue`, `size`, `source` and `ask`. A rejection's `_meta._tip` restates the shape. See [Demand](/api-reference/nodes#demand-evidence-on-a-need).
* **Served needs (`serves`):** bets only, at most 5 existing need ids, no repeats, never the bet's own parent. See [A bet that serves more than one need](/api-reference/nodes#a-bet-that-serves-more-than-one-need).

Validation errors return `400 { "success": false, "errors": ["'kano' must be one of: must-have, incremental, performance, delighter"] }` plus `_meta.guide`. Access and tier refusals use `{ "error": "<code>", "message": "..." }` instead. See [Authentication](/api-reference/authentication).

## Free-tier node cap

The free Builder tier caps nodes per project. Read the live number from [`GET /tier-status`](/api-reference/tier-status) (`limits.node_cap`). It is a soft ceiling:

* An `add` or `batch` from under the cap is allowed to overshoot, so a multi-node branch is never stranded half-written.
* At or over the cap, a new `add` or `batch` returns `403 { "error": "node_cap_reached", "current", "node_cap", "delete_to_resume" }`. `update` and `delete` are never gated.
* The `add` that crosses the cap succeeds, and its response carries `_meta.advisory { "type": "node_cap_reached", ... }`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.