# ProductBrain LLM guide (full text): https://productbrain.com/docs/llm-guide.md > The complete contract for any LLM operating on a ProductBrain plan: data model, > hierarchy rules, field validation, every endpoint, and the placement workflow. > Canonical URL: https://productbrain.com/docs/llm-guide.md > Index: https://productbrain.com/llms.txt # ProductBrain LLM Operations Guide **Guide version: 2026-10-14.** What changed since: [changelog](https://docs.productbrain.com/changelog). **Fetch it fresh.** Fetch tools often hand back a cached copy of this file, and a stale copy teaches you rules that have since changed. Add a cache buster to the URL, any value that changes per fetch, such as the current time: `https://productbrain.com/docs/llm-guide.md?cb=`. If the version date above is older than the newest changelog entry, fetch again with a new `cb`. The same applies to every file under `productbrain.com/docs/`. This guide teaches any LLM how to read, understand, and modify a ProductBrain project. After reading this, you should be able to generate a well-formed brain from a codebase or artifact. The definitions you need to model correctly are inline below. The longer concept docs live at [docs.productbrain.com](https://docs.productbrain.com) (index for agents: [docs.productbrain.com/llms.txt](https://docs.productbrain.com/llms.txt)). Every docs page is also served as markdown by adding `.md` to its URL. ## What is ProductBrain? A logic-driven product planning tool built on a business-outcome decision tree (Goal → Need → Bet → Job), with user story mapping for sequencing and Kano for judging bets. The core insight: a single dataset rendered as either a **tree** (strategy view) or a **matrix** (execution view). **It is NOT:** - A Jira clone (no time estimates, no velocity) - A user story repository (jobs are verifiable checkpoints, not "As a user...") - A customer interview tool (logic-driven validation, not interview-driven) ## The Hierarchy Four levels in the planning tree, plus a standalone Task type: ``` Goal (green) -- Need (blue) -- Bet (violet) -- Job (gray) Task (standalone, no parent) ``` | Level | Question it Answers | Required Fields | Optional Tags | |-------|---------------------|-----------------|---------------| | **Goal** | What's the business outcome? | label | measure, description | | **Need** | What persistent problem do users face that stands between us and the goal? | label, parentId (goal) | description | | **Bet** | What's our bet on how to meet the need? | label, parentId (need), **measure** | **kano**, **size**, **approachStatus**, description, notes | | **Job** | What can you verify from the deployed product? | label, parentId (bet) | **maturity**, **iteration**, status, description, notes | | **Task** | Ad-hoc work outside the tree? | label | **iteration**, status, description | A bet is stored as `type: "approach"`. Its node ids are `approach-N`, its lifecycle field is `approachStatus`, and API filters use `type=approach`. Say "bet" to people and keep `approach` in data. If you send `bet` where a type goes (`type=bet` on `/nodes` or `/search`, `"type": "bet"` on an add), the API reads it as `approach`, says so in `_meta._tip`, and always returns `approach`. Full definitions: [Goals](https://docs.productbrain.com/framework/goals.md) · [Needs](https://docs.productbrain.com/framework/needs.md) · [Bets](https://docs.productbrain.com/framework/bets.md) · [Jobs](https://docs.productbrain.com/framework/jobs.md) · [Kano](https://docs.productbrain.com/framework/kano.md) ### Parent-Child Rules - **Goals** have no parent (root nodes) - **Needs** must have a Goal parent - **Bets** must have a Need parent - **Jobs** must have a Bet parent - **Tasks** are standalone (`parentId: null`). For bugs, polish, and work discovered during testing that doesn't fit the hierarchy. Tasks appear in the **Misc column** of matrix view. ### What counts as a Goal A Goal is a **business outcome**: what success looks like for the business that owns the plan. Revenue, retention, market position, or something the business must be able to do. Goals sit at the top and rarely change. **The test: who benefits directly?** If the answer is the business, it's a Goal. If the answer is "a user can now do X", it's a Need, and it belongs under a Goal. - Goal: "Net dollar retention above 100%" (a business result, with the number in `measure`) - Goal: "Build a sustainable audio library business" - Not a Goal: "Users can manage their audio library" (a user capability, so a Need) - Not a Goal: "Ship v2" (an activity, with no outcome in it) A target such as an OKR key result goes in the Goal's optional `measure`. The test for when an objective is a measure rather than a goal of its own is under the field rules below. ### What counts as a Need A Need is a **persistent condition a user faces**. It does not go away when you ship. You address it better over time, and it changes slowly and independently of delivery. That includes needs users could not name themselves. **The fastest test: can it be finished?** Needs persist and improve. If it can be finished, it is a Bet or a Job. Evidence that customers feel a need (who, what's behind them, when) goes in the need's `demand` field. It explains why the need matters, and it never orders the plan. - Need: "Users can compare prices across stores" - Not a Need: "Add a price comparison page" (that's a Bet on how to address the need) - Not a Need: "Users are happy" (too vague to hang a bet from) **Internal users count if you can name the person and the job.** A support lead who produces a customer's compliance report by hand, or a partnerships lead who can't size an opportunity without an engineer, faces a real, persistent condition. A statement about your own systems with nobody on the other end does not count. Nobody experiences "we have no read-optimised copy of our data". People experience its consequences. **Reframe organisational conditions into user conditions before placing them.** Older products carry many items phrased as internal complaints. Entered verbatim they sit in the tree as permanent grievances. Ask who feels it and what they can't do: | Organisational condition (don't enter as-is) | User condition (the Need) | |---|---| | "We have no read layer over our data" | "A commercial user can answer a question without an engineer" | | "Our object model doesn't match how customers think about a booking" | "Customers can find and change a booking in the terms they use for it" | | "Reports are generated by hand" | "An account manager can hand a customer their compliance report on request" | The fix for the organisational condition (build the read layer) then becomes a Bet under that Need. **Internal needs carry a higher evidential bar.** A customer need is validated when customers pay, complain with money attached, or leave. An internal need is validated only by the team agreeing with itself. Say whose job it is and what it costs them before placing one. ### Type-Specific Field Rules Fields are validated per node type. Setting a field on the wrong type is rejected: | Field | Valid on | |-------|----------| | `iteration` | job, task | | `maturity` | job | | `kano` | approach | | `size` | approach | | `measure` | goal, approach | | `approachStatus` | approach | | `blockedBy` | approach | | `serves` | approach | | `externalRefs` | goal, need, approach, job, task | | `demand` | need | `measure` means something different on each type. On a bet it is required: it says how you will know the bet is working. On a goal it is optional: a target for the outcome, such as an OKR key result or a number to hit this quarter. Needs, jobs and tasks reject it. **Before putting an objective on a goal's `measure`, run this test:** "If the objective changed next quarter, would the goal underneath it have to change? If not, the objective was a measure on a goal that already existed." When the goal would survive the change, keep the goal and set the objective as its `measure`. When the goal would have to change, the objective is the goal itself. `externalRefs` points a node at the same thing in another system: a Jira key, a roadmap-tool id. It's a list of up to 50 entries, and one system may appear several times, because one node often traces to several items in the same tool (a goal behind two roadmap objectives, a bet spread over three epics): ```json "externalRefs": [ { "system": "jira", "key": "PROJ-1234", "url": "https://acme.atlassian.net/browse/PROJ-1234" }, { "system": "roadmap", "key": "RM-0168" }, { "system": "roadmap", "key": "RM-0171" } ] ``` - `system`: lowercase slug, 2-32 characters (`a-z`, `0-9`, `-`), starting with a letter. Free-form: name the tool (`jira`, `linear`, `productboard`). - `key`: the id in that system, 1-128 characters. - `url`: optional, `https://` only. - The same `system` + `key` twice on one node is rejected. Several keys in one system are fine. - No other fields. On update, send the whole list: it replaces the old one. `[]` or `null` clears it. A reference is a label and nothing more. ProductBrain never infers hierarchy, sync or a relationship from it: two nodes with the same key are not linked, and a key doesn't move or update anything. Several nodes may point at the same key (two jobs under one coarse Jira story). The mutate response's `_meta._tip` names the other nodes when that happens, so you can spot a duplicate. To find the node for a key, use the exact lookup on `GET /nodes` (see Read Nodes). Search doesn't index keys. `serves` lists the other needs a bet also helps, by id. A bet still has exactly one parent: the need it is placed under, the one it is scoped against. Its Kano, size and measure refer to that parent only. Use `serves` when one build helps several needs (an automation engine, an export layer), instead of splitting it into one bet per need: ```json "serves": ["need-14", "need-22"] ``` - Bets only. Each id must be an existing need in the project, and not the bet's own parent. Needs under another goal are fine. - At most 5, no repeats. On update, send the whole list: it replaces the old one. `[]` or `null` clears it. - Moving a bet under a need it already serves makes that need the parent and removes it from `serves` in the same write. - When a write leaves a bet serving 3 or more extra needs, the response carries `_meta.advisory` with `type: "approach_serves_many"`. It usually means one of two things: the bet is infrastructure whose real need is missing (write that need, often an internal one with a named person, and place the bet under it), or the needs it serves overlap and should merge. - If a served need is deleted, its id stays in `serves` and is ignored on read. Restoring the need brings the link back. `serves` is not `blockedBy`. `blockedBy` is a delivery dependency between bets: this bet can't start until that one lands. Never use `blockedBy` to mean "also serves", and never use `serves` for sequencing. To read both directions: a bet's `data.serves` lists what it serves. `GET /nodes?serves=need-14` lists the bets placed elsewhere that serve need-14, and `GET /tree?nodeId=need-14&direction=context` returns them as `alsoServedBy`, next to the need's own `children`. `demand` records the evidence that a need is real: who felt it, what's behind them, and when it was observed. It's a list of up to 25 entries on the need: ```json "demand": [ { "who": "Acme Logistics", "revenue": { "amount": 1200, "currency": "USD", "period": "month" }, "at": "2026-09-14", "source": "sales-call", "ask": "SSO with their Okta tenant" }, { "who": "Globex", "size": "40 seats", "at": "2026-03-02" } ] ``` - `who`: required. An account or a segment, not a named person, 1-80 characters. - `at`: required. The date it was observed, `YYYY-MM-DD`. - `revenue`: optional. `amount` (0 or more), `currency` (ISO 4217, capitals, such as `USD`), `period` (`once`, `month` or `year`). Recorded as entered: never converted or added up. - `size`: optional. What's behind them when it isn't money, such as "40 seats", 1-60 characters. - `source`: optional. A lowercase slug, such as `sales-call`, `support`, `churn` or `crm`. - `ask`: optional. What they asked for, in their words, at most 140 characters. Never a transcript or interview notes. - No other fields. On update, send the whole list: it replaces the old one. `[]` or `null` clears it. Needs only. Put the evidence on the need even when the customer named a solution ("add SSO"): the need outlasts any bet, and the `ask` keeps their words. **Evidence, never a ranking.** The date belongs to the entry, not the need. A need takes on no time dimension: nothing ages, schedules, ranks or sorts a need by its evidence. No endpoint filters or sorts by `demand`, and there is no total. Cite it when someone asks why a need matters. Don't reorder needs, bets or phases by it: the tree and its row order decide priority. Share links never show it, and search doesn't index it. Bets do **not** carry a phase. Their presence in the matrix is determined by whether any of their child jobs are assigned to the selected phase. ### Speak the user's language Users, and the agents they point at you, rarely use these exact words. **Translate their vocabulary into the model silently, and never correct them** ("we call that a phase" is the wrong reply). Map *inward*; let them keep their own words. | They say | You mean | |---|---| | objective, outcome, north star | **Goal** | | opportunity, problem, job-to-be-done, theme, the "why" | **Need** | | approach, epic, initiative, solution, feature area, the "how" | **Bet** (stored as `type: "approach"`) | | **iteration, sprint, milestone, release, cycle, version** | **Phase** (stored in the `iteration` field) | | story, requirement, deliverable, acceptance criterion, checkpoint | **Job** | | todo, note, memo, idea, quick capture | **Task** | | backlog, inbox | the Misc / task inbox (`Later` + unassigned) | **The ambiguous ones ("ticket," "log," "item") resolve by the question, not the word:** *Is it a verifiable checkpoint that belongs under a Bet in the plan?* → **Job**. *Is it a loose capture not yet placed in the tree?* → **Task**. Infer from context; never make the user pick the right noun. ## Tags and Their Meaning > For the full model rationale see `docs/model-hierarchy.md`. ### Kano Classification (on Bets) Describes the **market maturity tier** of this bet. What satisfaction level is it targeting? The same need can have multiple bets at different Kano tiers (e.g., manual windows is must-have, power windows is incremental, auto-close on rain is delighter). | Tag | Meaning | Example | |-----|---------|---------| | `must-have` | Table stakes. Causes dissatisfaction if absent, no delight if present. | Basic Stripe checkout, ToS page | | `incremental` | More is better, linear satisfaction. | Self-serve billing portal, annual plan | | `delighter` | Unexpected, disproportionate satisfaction. | AI-maintained tree integrity, proactive coaching | > **Backwards compatibility:** The API also accepts `performance` as an alias for `incremental`. Existing nodes with `kano: "performance"` are treated identically to `incremental`. ### Bet Status (`approachStatus`) Lifecycle phase of the bet, distinct from job completion: | Status | Meaning | |--------|---------| | `development` | Jobs are being built | | `validation` | Shipped to users, being tested against the measure | | `resolved` | The bet paid off and delivered its value | | `retired` | The bet failed or was superseded | ### Maturity (on Jobs) The quality bar for this job, **MVP or releasable**. Set at planning time. | Tag | Meaning | |-----|---------| | `mvp` | It works, it's testable, the bet can be validated. Rough edges acceptable. | | `releasable` | Polished enough to stay. You're not embarrassed when a stranger uses it. | **Never use maturity to indicate completion.** Use `status: "done"` instead. ### Status (on Jobs and Tasks) Tracks whether the work is live. Orthogonal to maturity. **The rule is simple: `done` = in production.** A job/task is `done` when the change that delivers it is live in the **production** product (`main`). That's the whole gate. A release fact, not a verification judgment. There is **no separate proof/UAT step**: "in prod" *is* the gate. (We tried a verification gate; it just made the human the bottleneck.) - Absent. Not started or in progress. Work merged only to `staging` is **not** done (not in production yet). - `status: "done"`. Live in production. Set `data.status = "done"`. - `status: "open"`. **Reopen** something that was closed: a job marked `done` by mistake, or work that turned out not to be finished after all. Send `data: { status: "open" }` on a job or task; an explicit `data: { status: null }` means the same thing. Either way the node goes back to the absent-status state above and `completedAt` is cleared. It's an ordinary update — it lands in the changelog and fires a `node.updated` webhook like any other. `"open"` is only meaningful on jobs and tasks; other node types have no status. - `status: "delivered"`. **Legacy/deprecated.** Delivery-tracker integrations used to set this on container-close; they now set `done` (your tracker, with your own DoD/UAT rules, is the source of truth, PB adds no second gate). Treat any `delivered` value as **"not done yet."** Still accepted for back-compat; don't set it for new work. - **Modeling an existing product?** Express *delivered vs. still in flight* mainly through the bet's status, not its jobs: a shipped, settled bet is `approachStatus: resolved`/`validation` and stays **job-free**. Don't enumerate it. Only *actively-worked* bets get jobs; within those, mark genuinely-shipped checkpoints `done` (read from source, i.e. code, flags, releases, not from assumption) and leave the rest open. Don't manufacture `done` jobs across the shipped core; that's coverage, not judgment, and it buries the live frontier. **One restriction on reopening: a closed phase can't hold open work.** If the job's phase is `done`, reopening it returns `400` rather than quietly leaving an open job inside an archived release. The error's `_meta._tip` names the phase and gives you both ways out — reopen it into a live phase in the same call, `data: { status: "open", iteration: "Later" }`, or reopen the phase itself with `{action: "updateIteration", iteration: {name: "", status: "active"}}`. The same check applies inside a `batch`, where it rejects the whole batch before anything is written. ### Sizing (on Bets) Relative weight of the bet, NOT time estimates: | Size | Feel | |------|------| | `skateboard` | Trivial, just do it | | `vespa` | Small but real, low risk | | `car` | Substantial, common investment level | | `truck` | Large, complex, needs coordination | | `antonov` | Decompose immediately: this is a program, not a bet | ## Phases Phases are horizontal delivery slices across bets. They are not sprints (no fixed time box) and not releases (no deployment coupling). They are thematic groupings of work that should ship together. Phases exist for **WIP control**, to beat Parkinson's law by capping what's in flight, not to time-box ceremonies. **Name a phase by its theme. The user value it delivers, not a dev milestone.** A phase name should read like an outcome ("Pre-Launch", "Self-serve onboarding", "Offline-first"), never a generic bucket. The API **rejects** the names `Later`, `Backlog`, `Polish`, and `Catch-all` outright. They carry no theme. Time-box or scrum-default names like "Next Up", "Sprint", or "Phase 2" aren't auto-rejected but defeat the purpose just as much; avoid them. When you can't derive a coherent value theme for a group of jobs, leave them in Later rather than inventing a placeholder phase. **Milestone phases are a valid override. A real event, not a lazy bucket.** Value-theme is the default, but a concrete **event or milestone** (a Show HN, a launch, demo day, a conference, an investor demo) legitimately overrides theme-grouping, and a plan may mix theme-phases and milestone-phases freely. What makes a milestone phase valid rather than lazy: a **specific driving event + a deadline + a clear "what we ship for it."** That is the opposite of the forbidden generic buckets (`Later`, `Backlog`, `Polish`, `Catch-all`), which stay banned because they're just where un-themed work gets dumped. The test: **"Is a real event/milestone driving this bundle?"** → a milestone phase is fine. **"Is this just where un-themed work gets dumped?"** → forbidden; find the theme. - **System phases** (e.g. "Later") are non-deletable. Later is the inbox. All unassigned work defaults here. Later is hidden from the matrix unless explicitly selected. - **Done phases** are hidden from the UI and matrix view. - **Current phase** = the earliest active non-system phase by `sort_order`. When the user says "add to the current phase" or "assign to the current phase", query the iterations endpoint and pick this one. Don't ask. - A need does **not** need to be completely met before moving on. Ship the must-have bet, soak it, then progress higher-value work elsewhere. **Before you finish, audit the phases you created.** Building a whole plan tends to leave stray phases behind — re-read your phases against your jobs and fix them so the plan stands on its own, rather than leaving it for a later hand-edit: - **No empty phases.** Every non-system phase must hold at least one job. An empty phase renders as a blank column in the matrix and reads as unfinished. Delete any phase you created but left unused (`deleteIteration`), or assign jobs to it. - **No status buckets.** If one phase holds most of the jobs, it's a dumping ground, not a value theme — split it into shippable slices. Smell test: a single phase larger than all the others combined needs breaking up. - **No duplicate themes.** Merge phases that describe the same value under different names (e.g. "Native analytics" and "First-party analytics" are one phase). - **Prune, don't accumulate.** Re-planning or regenerating a plan strands orphan phases; delete them. The finished plan should contain only phases a reader can trace to real, themed work — leave nothing to clean up after the fact. ```bash # List all phases (endpoint: /api/v1/iterations) curl -s "$API_URL/iterations?projectId=$PROJECT_ID" \ -H "Authorization: Bearer $API_KEY" | jq '.iterations' # Active phases only curl -s "$API_URL/iterations?projectId=$PROJECT_ID&status=active" \ -H "Authorization: Bearer $API_KEY" | jq '.iterations' # Get the current phase (first active non-system by sort order) curl -s "$API_URL/iterations?projectId=$PROJECT_ID¤t=true" \ -H "Authorization: Bearer $API_KEY" | jq '.iteration' ``` ## The Notes Field **Critical for LLM-to-LLM context transfer.** - `description` = human-facing summary. Keep it scannable (1-3 sentences). - `notes` = extended context. Rationale, alternatives considered, edge cases, discussion history. When making decisions or capturing tradeoffs, **write to notes liberally**. Future LLM sessions read notes to understand *why* something exists, not just *what* it is. ```json { "label": "WebSocket bidirectional sync", "description": "Real-time sync between agent and UI without polling.", "notes": "## Decision (Feb 2026)\nChose WebSocket over polling for latency.\n\n## Alternatives Considered\n- Long polling: simpler but higher latency\n- SSE: one-way only, doesn't support agent->server" } ``` ## Data Storage ProductBrain uses **Supabase** (Postgres) as the source of truth. Data is stored in four tables: | Table | Purpose | |-------|---------| | `projects` | Project metadata (name, overview, summary) | | `nodes` | All tree nodes and tasks | | `iterations` | Iteration definitions with sort order | | `changelog` | Append-only audit log of all mutations | Realtime sync uses Supabase Realtime (postgres_changes) for cross-client updates. ## Node ID Conventions IDs are generated atomically by the mutation API when adding nodes. You never need to generate IDs manually. Format: `{type}-{sequential number}` (e.g., `goal-1`, `need-15`, `approach-23`, `job-109`, `task-8`). ## API ProductBrain exposes a REST API for LLM access. Writes go through `POST /api/v1/mutate`. Pass an `Idempotency-Key` so a retried write never double-applies ([Idempotency](#idempotency)). Reads go through `GET /api/v1/search` (semantic) or `GET /api/v1/nodes` (bulk). Auth via `Bearer` API key. **Attribution.** Every write is recorded in the changelog with its source (the app UI vs. an API key, and *which* key) so a change can be traced back to its origin. Optionally pass a header **`X-Agent-Id: `** on writes to tag them with a readable agent name (the authenticating key is always recorded regardless. An agent can't spoof which key it used). Best practice: give each agent its own key, so the key *is* the fingerprint. ### Your Toolkit at a Glance Before reaching for raw text processing, know what ProductBrain gives you. Each capability is one HTTP endpoint (and one MCP tool of the same name); the deep reference for each is linked below. **Prefer these over improvising**. Especially the workflows, which carry ProductBrain's methodology so you don't re-derive it from generic scrum defaults. | You want to… | Use | What it's for | |---|---|---| | **Write the plan** | `POST /api/v1/mutate` ([Add Node](#add-node)) | Create/update/delete nodes and phases. Batch many changes in one call. | | **Find a node by meaning** | `GET /api/v1/search` ([Search Nodes](#search-nodes)) | Ranked semantic matches with ancestor paths. **Search first, always**. Never pull all nodes to find placement. | | **Bulk read** | `GET /api/v1/nodes` ([Read Nodes](#read-nodes)) | All nodes (e.g. "every job in phase X"). Use for bulk, not for lookup. | | **Traverse structure** | `GET /api/v1/tree` ([Navigate the Tree](#navigate-the-tree)) | Ancestor chain or subtree. Instead of pulling everything flat. | | **See what changed, and who changed it** | `GET /api/v1/changelog` ([Read Plan History](#read-plan-history)) | Every add/update/delete/restore, newest first, each one attributed to the app or to a named agent. The only way to find a node after it is deleted. | | **List / manage phases** | `GET /api/v1/iterations`, `addIteration` ([Phases](#phases), [Phase CRUD](#phase-crud)) | Read phases, resolve the current phase, create/reorder. | | **Run the planning state machine** | `POST /api/v1/workflow` ([Hosted Workflow API](#hosted-workflow-api-drive-the-planner-with-your-own-llm)) | The smart paths, run with **your** LLM (zero PB credits): `add` (classify + place a feature), `task-curate` (triage the inbox), `phase-assign` (fit Later jobs into themed phases). Prefer these for their named jobs over hand-rolling the same work with `mutate`. | | **Drive the live canvas** | `POST /api/v1/view-command`, `GET /api/v1/view-state` ([View Control](#view-control)) | Switch tree/matrix, select a phase, zoom to a node; read what the user is looking at. | | **Materialize as files** | `GET /api/v1/export-okf` ([Export](#export-okf-bundle)) | Serialize the brain to portable files. | | **Tell us what to improve** | `POST /api/v1/feedback` ([Feedback](#send-feedback-to-productbrain)) | Feature requests, friction, bugs, notes about PB itself. Read by the PB team, never added to your plan. | When a job has a dedicated workflow (`add`, `task-curate`, `phase-assign`), reach for it before composing the same change by hand. It injects your project context and validates structure, so the result matches ProductBrain's model instead of a generic default. ### Setup ```bash export API_KEY="" export API_URL="https://productbrain.com/api/v1" ``` The API key is per-user. Generate one from the app's API Key modal or obtain it from your environment's secret store. ### MCP Server If your client speaks the **Model Context Protocol** (Claude Code, Claude Desktop, Cursor), you can connect over MCP instead of raw HTTP. The server is a thin shim over this same versioned `/api/v1` API. Each tool maps to one endpoint documented below, responses carry the same `_meta` coaching, and you can drop to raw HTTP any time. Bringing your own LLM over MCP consumes zero ProductBrain AI credits. ```bash # Claude Code, one command, then the tools are available in-session claude mcp add productbrain \ -e PRODUCTBRAIN_API_KEY=pb_your_key \ -e PRODUCTBRAIN_PROJECT_ID=your-project-id \ -- npx -y @productbrain-com/mcp ``` For Claude Desktop / Cursor, add an `mcpServers` entry with `"command": "npx"`, `"args": ["-y", "@productbrain-com/mcp"]`, and the same `PRODUCTBRAIN_API_KEY` / `PRODUCTBRAIN_PROJECT_ID` env. Full client setup: https://docs.productbrain.com/api-reference/mcp Every agent-facing endpoint in this guide has a tool. Each one calls that endpoint and nothing else, so a tool result is the endpoint's JSON verbatim — including the `_meta` coaching described below: | Area | Tools | Endpoints | |---|---|---| | The plan | `search`, `list_nodes`, `get_tree`, `mutate`, `list_iterations`, `run_workflow` | `/search`, `/nodes`, `/tree`, `/mutate`, `/iterations`, `/workflow` | | History | `get_changelog` | `/changelog` | | The view | `set_view`, `read_view` | `/view-command`, `/view-state` | | Projects | `list_projects`, `create_project`, `rename_project`, `archive_project` | `/projects` (list, archive) and `/mutate` (`addProject`, `renameProject`) | | Export | `export_okf` | `/export-okf` | | Webhooks | `list_webhooks`, `create_webhook`, `update_webhook`, `delete_webhook` | `/webhooks` | | Share links | `create_share_link`, `list_share_links`, `revoke_share_link` | `/share` | | Members and budget | `list_members`, `add_agent_seat`, `invite_contributor`, `revoke_member`, `tier_status` | `/members`, `/tier-status` | | Feedback | `submit_feedback` | `/feedback` | Two conveniences the shim adds, because the REST split would otherwise leak into your prompt: `create_project` and `rename_project` are tools even though the server implements them as `/mutate` actions rather than verbs on `/projects`; and `mutate` takes an `idempotencyKey` argument that becomes the `Idempotency-Key` header. Everything else is a one-to-one pass-through — the tier gates, error shapes and one-time secrets are exactly as documented below. ### Paste a plan (no connection to ProductBrain) If your user can't connect you to ProductBrain (a managed device where they can't install anything or add a connector, or no API key), you can still put a plan in front of them. Write the plan as **PLAN.md** and ask them to paste it into the app: **Import plan** in the top bar. ProductBrain parses it deterministically (no AI reads it), shows a preview with counts, the tree and any warnings, and adds it to their current project in one batch. A node whose label already exists under the same parent (or, for a task, anywhere in the project) is reused, not duplicated, so pasting an updated plan adds only what is new. The import only adds: it never edits or deletes, so a job you tick `[x]` in a plan that's already imported must be marked done in the app (the preview says which). The PLAN.md format (the full reference is the product-plan skill's [plan-shape.md](https://github.com/moxzas/productbrain-plugin/blob/main/skills/product-plan/references/plan-shape.md)): ```markdown ## Goal: Members trust their balance Measure: support tickets about balances per month ### Need: A member can't tell why a balance changed #### Approach: Explain every change in plain words [validation] Measure: share of balance views that open an explanation - [ ] Job: A member sees the reason for each change (phase: Explain) Notes: start with fees - [x] Job: The statement lists fees on their own line ## Tasks - [ ] Task: Rotate the statement signing key ``` - `## Goal:`, `### Need:`, `#### Approach:` are the three heading levels (a bet is written `#### Approach:`, the type ProductBrain stores; the import also accepts `#### Bet:`). A bet's status goes in brackets: `[development]` (the default), `[validation]`, `[resolved]`, `[retired]`. - A job is a list item under a bet. `[x]` means done: live in production. `(phase: )` is optional; a phase the project doesn't have yet is created with the import. - A `Measure:` line under a goal or bet is its measure; other text under a heading is its description; an indented `Notes:` line under a job is its notes. - `{#goal-1}` ids from a synced PLAN.md are ignored on import: the project assigns its own ids. The JSON tree in [Example: Minimal Well-Formed Tree](#example-minimal-well-formed-tree) pastes too (`type: "bet"` or `"approach"`). Prefer PLAN.md: your user can read and edit it before pasting. On the free tier the import is refused, with the reason, if it would take the project past the node cap. ### Working through chat (no connector) If you can read public URLs but can't call the API (a managed laptop where nothing can be installed and connectors are admin-only), you can still work on a plan turn after turn. Your user is the transport. The full contract is the [agent relay format](https://productbrain.com/docs/agent-relay-format.md); this is the shape of it. 1. **Read.** Your user starts a chat session in ProductBrain (**Agent relay** in the top bar) and pastes you a starter prompt with an agent read link: `https://productbrain.com/a/`. Fetch it. It serves the whole plan as markdown, every node with its id in brackets and every field written the way you will write it back. A large plan gives a summary with a link per goal. The last line is a reload link with a fresh nonce: follow that, not a URL you build, when you need the current state. 2. **Write.** Answer with one `PB-CHANGES v1` block in a code fence: a header with the operation count, one operation per line (`ADD`, `UPDATE`, `MOVE`, `DELETE`, `ADD phase`), fields on `key: value` lines, `END`. Labels run to the end of the line, no quotes. A new node can be named `AS @b1` and referenced later in the same block. `key: -` clears a field. 3. **Reply.** Your user pastes the block into ProductBrain, reviews the diff, applies it, and pastes back a `PB-REPLY v1`: one `OK` or `ERR … TIP: …` line per operation, the new ids, any API coaching, and a fresh read link. Fix any `ERR` lines and send the whole block again; the batch is all-or-nothing. ``` PB-CHANGES v1 project: ivvy ops: 3 ADD bet Self-serve venue onboarding UNDER need-12 AS @b1 measure: share of venues live without a support ticket kano: must-have UPDATE job-44 status: done DELETE task-12 END ``` The block becomes exactly one [batch](#batch-mutations) with the same validation as any API write, so everything in this guide about fields, phases, `serves`, `demand` and `externalRefs` applies. The read link is a capability: it expires (30 days by default), it can be revoked, and it never opens the shared view (nor does a share token open it). ### Rate Limits The API is rate-limited **per IP address** (currently around 100 requests per minute). Exceed it and you get an HTTP `429`. Back off and retry after about a minute. This is a coarse, infrastructure-level guard against abuse, not a fine-grained per-key quota. Two honest caveats so you can design around it: - **The limit is per IP, not per API key (for now).** If you drive the API from several clients behind one IP (an office network, a shared CI runner, a serverless platform that reuses egress IPs), they share one budget and can throttle each other. A per-key limit is on the roadmap; until it ships, give a heavy workload its own IP where you can. - **Prefer one batch over many calls.** `POST /api/v1/mutate` takes a batch of mutations in a single request (see Batch Mutations). One batch beats a loop of single writes. It's faster and stays well under the limit. This is unrelated to credits: the API surface is credit-free (below). Rate limits are about request volume, not cost. ### Versioning & Deprecation Policy The API is versioned in the URL path. `https://productbrain.com/api/v1/...` is canonical. The commitment: - **We will not break your integration without warning.** Breaking changes (removing or renaming fields, endpoints, or actions; tightening validation; changing error shapes) only ship in a new version (`/api/v2/...`). - **The previous version keeps working for at least 6 months** after a new version ships. - **Breaking changes are announced in the [changelog](https://docs.productbrain.com/changelog) at least 30 days** before the new version goes live. - **Additive changes** (new endpoints, new optional fields, new mutation actions) ship on the current version without notice. Write your integration to tolerate unknown fields. The older unversioned paths (`/api/nodes`, `/api/mutate`, ...) continue to work indefinitely and return the same responses as their `/api/v1/` equivalents, with two extra response headers: ```http Deprecation: true Link: ; rel="successor-version", ; rel="deprecation" ``` If your client sees a `Deprecation` header, switch the base URL to `/api/v1`. Nothing else changes. There is no sunset date for the unversioned paths; if one is ever set, it will be announced per the policy above and signalled with a `Sunset` header. An unknown or mistyped `/api/*` path, including any `/api/v2/*` path while v2 does not exist, returns `404` with `Content-Type: application/json`, `Cache-Control: no-store`, and the body `{ "error": "unknown_endpoint", "message": "...", "_meta": { "_tip": "..." } }`, where `_meta._tip` lists the valid v1 endpoints. Key off `error`; `message` names the path as the router saw it and is best-effort (a mistyped `/api/v1/foo` may be reported as `/api/foo`). A client should never see HTML from `/api/*`; if it does, that is a bug, not a signal about the API. This contract is enforced mechanically: a frozen API contract test suite runs in CI against every release candidate, and breaking it blocks the deploy. ### Response coaching: the `_meta` block Every agent-facing endpoint can return a `_meta` object alongside its data. How the API coaches your agent in-band, so you rarely need to come back to this guide mid-run. All fields optional: - **`_meta._tip`** (string). A next-step or self-correction nudge: how to recover from an empty search, what to do on a 404, why a command no-op'd, what a one-time secret means. - **`_meta.advisory`** (object). Structured backpressure, e.g. `{ type: "inbox_overflow", memos, threshold, suggestedFlow }` when untriaged tasks pile up. - **`_meta.focus`** (object). A `{ nodeId, viewCommand }` to drive the canvas to what just changed; feed `viewCommand` straight to `POST /api/v1/view-command`. - **`_meta.guide`** (string). On a `400`, `401` or `403` from the six core endpoints — `mutate`, `search`, `nodes`, `tree`, `iterations`, `workflow` — the URL of this guide, so an agent that arrived without it can find the contract from the error alone instead of guessing at the shape and retrying. Always `https://productbrain.com/docs/llm-guide.md`. Success responses don't carry it, and neither do the support endpoints (`share`, `webhooks`, `members`, `projects`, `tier-status`) yet. Every `/api/*` response, success or error, on every endpoint, also carries the response header `X-Powered-By: ProductBrain; guide=https://productbrain.com/docs/llm-guide.md`, so the contract is discoverable from any response even where the body has no `_meta`. If you are reading this because an error pointed you here, you can also start from [`https://productbrain.com/llms.txt`](https://productbrain.com/llms.txt) — the standard discovery file, with a one-line map of every endpoint. The whole guide is also served as a single plain-text file at [`https://productbrain.com/llms-full.txt`](https://productbrain.com/llms-full.txt). Treat `_meta` as advisory and forward-compatible (new fields may appear). The loop is **act → read `_meta` → adjust**. You'll hit it at: - `search` → `_meta { result_count, applied_limit, applied_type, truncated, weak_match }` + a tip on empty/weak results, so you never mutate off a 0.2 match. - `delete` → top-level `deleted` and `deletedDescendants` (every node removed with it: delete cascades to the whole subtree), plus a `_meta._tip` on how to restore them. `orphanedChildren` is kept for compatibility and is always `[]`. - `mutate` add/batch → the inbox advisory + a `focus` view-nudge. - `view-command` → confirms *enqueued, not applied*: it returns a `commandId`; re-read `GET /api/v1/view-state` and check `last_applied_command.id === commandId` before the next command (advances even on a no-op). An unresolved target returns `success:false`, not a silent no-op. - a `workflow` terminal → a `_meta.viewCommand` to show the user the change. **The whole API surface is credit-free.** `mutate`, `search`, `tree`/`nodes`/`iterations`, `workflow` (your own LLM), `view-command`/`view-state`, `webhooks`, `share`, `members`, `projects` consume **no** ProductBrain AI credits. Bring your own model. ### List Projects ```bash curl -s "$API_URL/projects" \ -H "Authorization: Bearer $API_KEY" | jq '.projects' ``` Response: `{ "projects": [{ "id": "example-abc123", "name": "My Project", "archived": false, "role": "owner" }, ...] }` Archived projects are **hidden by default**. Pass `?includeArchived=true` to list them too. ```bash export PROJECT_ID="" ``` ### Create a Project Bootstrap a brain end-to-end without the app. Creates the project and seeds the system "Later" phase, so nodes can be added immediately. ```bash curl -X POST "$API_URL/mutate" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "action": "addProject", "project": { "name": "My New Product" } }' # Returns: { "success": true, "project": { "id": "my-new-product", "name": "My New Product" }, # "iterations": [{ "name": "Later", "status": "active", "system": true }] } ``` - `project.id` is optional. Defaults to the slugified name (lowercase, dashes). Project ids are a **global namespace**: if the id is taken you get a 400 with `already taken`. Pass an explicit, more specific `project.id`. - Tier project limits apply (e.g. Free = 1 project). Exceeding them returns 403 `project_limit`. - The "Later" system phase is seeded automatically; jobs and tasks created without an `iteration` default into it. ### Rename a Project Change a project's display **name**. Owner-only. ```bash curl -X POST "$API_URL/mutate" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "action": "renameProject", "projectId": "'"$PROJECT_ID"'", "name": "New Display Name" }' # Returns: { "success": true, "project": { "id": "", "name": "New Display Name" } } ``` - Renames the **display name only**. The project `id` is the global-namespace key that every node, phase, webhook, and share link references. It is **immutable** and unchanged by a rename. Keep using the same `projectId` for all reads and writes. - `name` is required, trimmed, and capped at 100 characters. - Owner-only: a project member can edit content but cannot rename the project (403 `project_access_denied`). ### Archive a Project Hide a project from the default project list **without deleting it**. Archiving is reversible. A visibility flag, not a destructive delete (deletes cascade and can't be undone). Use it to keep experimental or dead projects out of your working list. ```bash # Archive curl -X PATCH "$API_URL/projects" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "projectId": "'"$PROJECT_ID"'", "archived": true }' # Returns: { "project": { "id": "...", "name": "...", "archived": true, "role": "owner" }, "_meta": { "tip": "..." } } # Unarchive curl -X PATCH "$API_URL/projects" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "projectId": "'"$PROJECT_ID"'", "archived": false }' ``` - Archived projects disappear from `GET /api/v1/projects` unless you pass `?includeArchived=true`. Their nodes, phases, share links, and data are untouched. Archiving only affects listing. - Owner-only: a member cannot archive a shared project (404 if you don't own it). ### Search Nodes Semantic search over all nodes. Every node has a vector embedding. Search matches by meaning, not exact text. ```bash curl -s "$API_URL/search?q=barcode+scanning&projectId=$PROJECT_ID" \ -H "Authorization: Bearer $API_KEY" | jq '.results' ``` Returns ranked candidates with similarity scores and ancestor paths (the full lineage: goal → need → bet). Use this to: - **Find existing work** before creating a node, avoids duplicates - **Resolve placement**. Which parent does a new node belong under? - **Look up a node** when you know the concept but not the ID If results are ambiguous, drill into a candidate: `GET /api/v1/tree?nodeId=&direction=context` returns its ancestors, siblings, and children. **When to use search vs bulk read:** | I need to... | Use | |---|---| | Find a node by concept or description | `GET /api/v1/search?q=...` | | Check if work already exists before creating | `GET /api/v1/search?q=...` | | Find the right parent for a new node | `GET /api/v1/search?q=...` | | List all jobs in a phase | `GET /api/v1/nodes?type=job&iteration=X` | | Export the full tree | `GET /api/v1/nodes` | | Count nodes by type | `GET /api/v1/nodes?type=X` | The first three are the most common operations. Use search. ### Read Nodes Bulk read with optional filters. Returns all matching nodes without ranking. ```bash # All nodes curl -s "$API_URL/nodes?projectId=$PROJECT_ID" \ -H "Authorization: Bearer $API_KEY" | jq '.nodes' # Goals only curl -s "$API_URL/nodes?projectId=$PROJECT_ID&type=goal" \ -H "Authorization: Bearer $API_KEY" | jq '.nodes' # Jobs in a specific phase curl -s "$API_URL/nodes?projectId=$PROJECT_ID&type=job&iteration=Cloud" \ -H "Authorization: Bearer $API_KEY" | jq '.nodes' # The node(s) for Jira issue PROJ-1234 (exact match on externalRefs) curl -s "$API_URL/nodes?projectId=$PROJECT_ID&externalSystem=jira&externalKey=PROJ-1234" \ -H "Authorization: Bearer $API_KEY" | jq '.nodes' # Bets placed under other needs that also serve need-14 curl -s "$API_URL/nodes?projectId=$PROJECT_ID&serves=need-14" \ -H "Authorization: Bearer $API_KEY" | jq '.nodes' ``` `externalSystem` and `externalKey` match exactly and case-sensitively. Either one alone also works: `externalSystem=jira` returns every node that points into Jira. They combine with `type` and `iteration`. The MCP `list_nodes` tool takes the same two arguments. `serves=need-14` returns the bets whose `serves` lists need-14. The need's own bets (its children) are not included: get those from `GET /tree` or by their `parentId`. The MCP `list_nodes` tool takes `serves` too. The response is `{ "nodes": [...], "_meta": {...} }`. `_meta` carries: - **`currentIteration`**: the name of the active phase, or `null` - **`iterations`**: `{ "active": [names], "total": n }` - **`nodeCounts`**: how many of each type are in the returned set, e.g. `{ "goal": 3, "need": 7 }` - **`_tip`**: present only when there's advice, such as using search instead of a large unfiltered read Each node carries **`createdAt`** (an ISO-8601 timestamp) alongside `id`, `type`, `parentId`, and `data`. So you can tell how old a node is and when it entered the plan. `createdAt` is also returned on `GET /search` results and `GET /tree`. Iterations are read via `GET /api/v1/iterations`. See the Iterations section above. ### Export (OKF bundle) Export the whole brain as an [Open Knowledge Format](https://github.com/GoogleCloudPlatform/knowledge-catalog) bundle (OKF v0.1), Google Cloud's vendor-neutral markdown-for-agents format. The brain's node model maps ~1:1 onto OKF's typed-concept graph, so an OKF-aware agent reads it with no translation layer. ```bash curl -s "$API_URL/export-okf?projectId=$PROJECT_ID" \ -H "Authorization: Bearer $API_KEY" | jq '.files | keys' ``` Returns `{ okf_version, project, file_count, files, _meta }`, where `files` maps each bundle path to its markdown content (`_meta.source` is `"ProductBrain"`, `_meta.url` and `_meta.guide` say where the bundle came from and where this contract lives; `index.md` carries the same as `source` / `source_url` frontmatter), write each one to disk to materialize the directory: - **One concept file per node**, organized by type: `goals/.md`, `needs/.md`, `approaches/.md`, `jobs/.md`, `tasks/.md` (filename = concept ID). - **Frontmatter:** the one required OKF field `type` (`Goal`/`Need`/`Approach`/`Job`/`Task`, so a bet exports as `type: Approach` under `approaches/`), plus `title`, `description`, `timestamp`, and the node's type-specific fields (`status`, `maturity`, `kano`, `size`, `approachStatus`, `iteration`, `measure`) as custom keys. `externalRefs` export as `externalRefs: ["jira:PROJ-1234"]` (`system:key` strings), and the body adds "Tracked in Jira as [PROJ-1234](url)." A bet's `serves` exports as `serves: ["need-14"]`, with "Also serves the Need [label](/needs/need-14.md)." in its body, and the need's file says "Also served by [label](/approaches/approach-9.md)." A need's `demand` exports as a `## Demand` section listing each entry in stored order. The frontmatter gets only `demandCount`, never revenue. - **Relationships are standard markdown links** named in prose (OKF's convention): parent, `blockedBy`, and children link to other concept files (`[label](/needs/.md)`). - A root **`index.md`** declares `okf_version: "0.1"` and lists every concept. This is a third agent-legible surface alongside `GET /nodes` (JSON) and the MCP server. ### Add Node The API handles ID generation and user_id lookup automatically. ```bash curl -X POST "$API_URL/mutate" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "action": "add", "projectId": "'"$PROJECT_ID"'", "node": { "type": "job", "parentId": "approach-1", "data": { "label": "Add upload progress indicator", "maturity": "releasable", "iteration": "MVP" } } }' # Returns: { "success": true, "node": { "id": "job-199", "type": "job", ... } } ``` ### Add Task ```bash curl -X POST "$API_URL/mutate" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "action": "add", "projectId": "'"$PROJECT_ID"'", "node": { "type": "task", "data": { "label": "Fix tooltip overlap on small screens", "iteration": "Cloud" } } }' ``` ### Update Node Uses PATCH semantics. Only include the fields you want to change. Existing data fields are preserved (merged with the provided fields). ```bash curl -X POST "$API_URL/mutate" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "action": "update", "projectId": "'"$PROJECT_ID"'", "nodeId": "job-45", "data": { "label": "Updated label", "status": "done" } }' ``` ### Delete Node (Hard Delete) ```bash curl -X POST "$API_URL/mutate" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "action": "delete", "projectId": "'"$PROJECT_ID"'", "nodeId": "job-99" }' ``` **Delete cascades.** Deleting a node deletes its entire subtree with it: children, grandchildren, and so on down to the jobs. Nothing is left orphaned. Before the delete, every node in the subtree gets its own changelog entry, so each one is **recoverable** with the `restore` action (below). The response tells you exactly what went: ```json { "success": true, "deleted": { "id": "goal-5", "type": "goal", "label": "…" }, "deletedDescendants": ["need-3", "approach-7", "job-12"], "orphanedChildren": [], "_meta": { "_tip": "Deleted goal-5 and its whole subtree with it — 3 descendant node(s)…" } } ``` - `deletedDescendants` lists every node removed along with the target, parents before children. It is capped at 200 ids. When a subtree is larger, `deletedDescendantsOmitted` gives the count left off the list. Every node is still logged and restorable, listed or not. - `orphanedChildren` is kept for response-shape compatibility and is always `[]`. - Each deleted node fires its own `node.deleted` webhook. If you only meant to remove the node itself, reparent its children first (`update` with a new `parentId`), then delete. ### Restore a Deleted 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 curl -X POST "$API_URL/mutate" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "action": "restore", "projectId": "'"$PROJECT_ID"'", "nodeId": "job-99" }' ``` - `409` if a node with that id already exists (nothing to restore); `404` if no deletion is on record. - **Restore top-down.** A node can't come back under a parent that is still deleted: that restore fails with a `500` (`Failed to restore node`) and changes nothing. After deleting a subtree, restore the deleted node first, then each id in `deletedDescendants` in the order listed (parents come before their children). - Restore does not cascade. It brings back the single node, not its former children — restore each of them too. ### Read Plan History Every mutation is recorded: what changed, the data on both sides, when, and **who** — the app, or a specific agent by its key prefix and self-declared id. `restore` can undo a delete, but only if you know the node id; this is how you find it. ```bash curl -s "$API_URL/changelog?projectId=$PROJECT_ID&limit=20" \ -H "Authorization: Bearer $API_KEY" ``` ```json { "entries": [ { "id": 55524, "action": "delete", "nodeId": "job-482", "label": "Price comparison loads in under 2s", "at": "2026-09-24T06:25:00.120Z", "by": { "source": "api_key", "apiKeyPrefix": "pb_e1878b605", "agentId": "release-bot", "label": "agent release-bot" }, "changedFields": [], "before": { "label": "Price comparison loads in under 2s", "maturity": "mvp" }, "after": null } ], "_meta": { "result_count": 1, "applied_limit": 20, "has_more": true, "next_before": "2026-09-24T06:09:02.443Z", "_tip": "1 deletion(s) in this page. Undo one with POST /api/v1/mutate {action:\"restore\", nodeId:…}" } } ``` Filters, all optional and combinable: `nodeId` (one node's history), `action` (`add|update|delete|restore`), `source` (`app|api_key`), `agentId`, `since` and `before` (ISO timestamps), `limit` (1–200, default 50). - **Newest first.** Page with `before=`, not an offset: the table only grows at the head, so an offset would re-read or skip rows as new writes land mid-page. `has_more` tells you when to stop. - `changedFields` is populated on `update` and names only the keys that actually differ, so a one-field edit reads as one field. - `before`/`after` carry the node data on each side. On `add`, `before` is null; on `delete`, `after` is null. - **Finding a deleted subtree:** `delete` removes the node's whole subtree, and each removed node gets its own entry. Filter `action=delete` and restore top-down — a child cannot be restored under a parent that is still deleted. - An invalid `action`, `source`, `since`, `before` or `limit` is a `400` naming what was wrong. A `limit` above 200 is capped rather than refused. - Access is owner-or-member. A project that is not yours and a project that does not exist answer the same way, so this endpoint cannot be used to discover other people's project ids. ### Batch Mutations Validate-all-then-apply semantics. If any mutation fails validation, the entire batch is rejected. A batch may mix node mutations (`add`/`update`/`delete`) with `addIteration`. Useful for assigning jobs to phases while creating any new phases they need in the same request. Validation is all-or-nothing (any invalid mutation rejects the whole batch before anything is written), and new phases are created before the jobs that reference them, so a batch can never strand a job on a phase that doesn't exist. **A `delete` inside a batch behaves exactly like a single [delete](#delete-node-hard-delete).** It cascades to the whole subtree, and every removed node is logged and restorable. Its result carries the same `deleted`, `deletedDescendants` (plus `deletedDescendantsOmitted` past 200) and `orphanedChildren: []`. The subtree is the tree as it stands at that point in the batch, so it includes children added earlier in the same batch and excludes nodes reparented away. When any delete cascaded, `_meta._tip` says how to restore. A later `delete` of a node that an earlier delete in the same batch already removed (the same id again, or anything in its subtree) writes nothing. It is not logged or webhooked a second time. Its result is `{ "success": true, "deleted": null, "alreadyDeletedBy": "" }`. The node is gone, as you asked, and the result tells you it was already gone. **What "atomic" does and doesn't mean here.** Apply is sequential, not a single database transaction. If a mutation fails partway through, the API *compensates* where it safely can: it deletes the nodes and phases that this batch created, newest first, and the `500` reports exactly what it undid. ```jsonc { "success": false, "error": "Failed to create node in batch: ...", "rolledBack": { "nodes": 2, "iterations": 1 }, // what was undone "partial": "…", // present ONLY if nothing could be "_meta": { "_tip": "…" } } ``` Read `partial` before you retry — it is the whole signal: - **Absent** — nothing from the batch survives. `rolledBack` says how much was undone. Fix the cause and retry the whole thing. - **Present** — **nothing was rolled back** (`rolledBack` is `{nodes: 0, iterations: 0}`) and the batch is partially applied. Reconcile from `GET /api/v1/nodes`; do not retry. The two are exclusive by design. 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 (a node reparented under a batch-created parent would be cascade-deleted along with it), and destroying data the batch never created is worse than the partial write being cleaned up. So in that case the API stops, changes nothing, and tells you. Two things compensation can't cover, so design around them: - **A process killed mid-compensation** (a function timeout) leaves the partial state behind anyway. You'll see no response at all rather than a `500`, so treat a missing response like the `partial` case: read the tree and reconcile. - **A timeout *after* the write succeeded** looks like a failure to you but committed on the server. This is the one an `Idempotency-Key` (see [Idempotency](#idempotency)) does fix — a replay returns the original result instead of re-applying, so always send one. Smaller batches shrink the blast radius for all of the above. ```bash curl -X POST "$API_URL/mutate" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "action": "batch", "projectId": "'"$PROJECT_ID"'", "mutations": [ { "action": "add", "node": { "type": "job", "parentId": "approach-1", "data": { "label": "First job", "maturity": "mvp", "iteration": "MVP" } } }, { "action": "update", "nodeId": "job-100", "data": { "status": "done" } } ] }' ``` ### Idempotency Any `mutate` call (single or batch) is made safe to retry by sending an `Idempotency-Key` header: an opaque string you generate per logical operation (a UUID is ideal): ```bash curl -X POST "$API_URL/mutate" \ -H "Authorization: Bearer $API_KEY" \ -H "Idempotency-Key: 7b1f2c84-...-your-uuid" \ -H "Content-Type: application/json" \ -d '{ "action": "add", "projectId": "'"$PROJECT_ID"'", "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` header) instead of applying again. An agent that retries after a timeout never double-creates. - **Conflict**. The same key with a *different* body returns `409` (you reused a key for a new operation; generate a fresh one). - **In flight**. If the first request is still running, a retry returns `409` ("in progress, retry shortly"). - **Window**. Keys are remembered for **24 hours**, then expire. Scope is per user. Use a fresh key per operation. Without an `Idempotency-Key`, retries are not deduplicated. Re-read with `GET /api/v1/nodes` or `search` and re-submit only what's missing. ### Free-tier node cap The free Builder tier caps the number of nodes **per project**. Read the live limit from `GET /api/v1/tier-status` → `limits.node_cap` (don't hard-code a number; it changes). Paid tiers are uncapped. It's a **soft ceiling, not a hard stop**: - An `add`/`batch` from **under** the cap is allowed to **overshoot**. A multi-node branch won't be stranded mid-way (e.g. 48 + a 4-node batch lands at 52). - Once you're **at/over** the cap, a new `add`/`batch` returns **`403 { error: "node_cap_reached", current, node_cap, delete_to_resume }`**. No new nodes until you delete `delete_to_resume` to drop **below** the cap. `delete`/`update` are never gated, so pruning always works. - The `add` that *crosses* the cap **succeeds** but its response carries **`_meta.advisory { type: "node_cap_reached", current, node_cap, delete_to_resume }`** so your agent knows it's now over and how many to prune before adding more. `delete_to_resume` is the count to remove to get back under the cap (e.g. at 55/50 it's 6, not "a node"). ### Phase CRUD Phases are managed through the mutation API, same as nodes. The API actions use the legacy term "iteration" in endpoint names. ```bash # Create a phase curl -X POST "$API_URL/mutate" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "action": "addIteration", "projectId": "'"$PROJECT_ID"'", "iteration": { "name": "Self-serve onboarding" } }' # Name phases by value theme, not a dev milestone or generic bucket # (see Phases above). To FIT EXISTING JOBS into phases, prefer the # phase-assign workflow (below), it themes and names phases for you, # reusing an existing phase when a job fits and creating one only when # none does. Use addIteration directly when you already know the # themed phase you want to create. # Update a phase (PATCH semantics) curl -X POST "$API_URL/mutate" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "action": "updateIteration", "projectId": "'"$PROJECT_ID"'", "iterationName": "Self-serve onboarding", "data": { "status": "done" } }' # Marking done auto-sets completedAt. Fields: name, status, gitTags, sortOrder, completedAt. # Delete a phase (system phases cannot be deleted) curl -X POST "$API_URL/mutate" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "action": "deleteIteration", "projectId": "'"$PROJECT_ID"'", "iterationName": "Self-serve onboarding" }' # Reorder phases curl -X POST "$API_URL/mutate" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "action": "reorderIterations", "projectId": "'"$PROJECT_ID"'", "order": ["Pre-Launch", "AI I/O", "Month 1", "Month 2–3", "Later"] }' ``` ### Navigate the Tree Structured traversal. Use instead of pulling all nodes flat. ```bash # Ancestor chain (leaf to root), includes path array of IDs curl -s "$API_URL/tree?projectId=$PROJECT_ID&nodeId=job-1&direction=up" \ -H "Authorization: Bearer $API_KEY" | jq '{path: .path, labels: [.nodes[] | .data.label]}' # Subtree (depth-first, with depth field on each node) curl -s "$API_URL/tree?projectId=$PROJECT_ID&nodeId=goal-1&direction=down" \ -H "Authorization: Bearer $API_KEY" | jq '.nodes[] | {id, depth, label: .data.label}' # Siblings (same parent, excluding the queried node) curl -s "$API_URL/tree?projectId=$PROJECT_ID&nodeId=approach-1&direction=lateral" \ -H "Authorization: Bearer $API_KEY" | jq '.nodes[] | {id, label: .data.label}' # Full context: ancestors + siblings + children in one call curl -s "$API_URL/tree?projectId=$PROJECT_ID&nodeId=approach-1&direction=context" \ -H "Authorization: Bearer $API_KEY" | jq '{ancestors: [.ancestors[] | .data.label], siblings: [.siblings[] | .data.label], children: [.children[] | .data.label]}' ``` On a need, `direction=context` also returns `alsoServedBy`: the bets placed under other needs whose `serves` lists this one. Its `children` are still only its own bets. ### Semantic Search Find nodes by meaning, not exact label match. Uses embeddings + cosine similarity. Results include an `ancestors` array (id, type, label from node to root) for context. ```bash # Search by natural language curl -s "$API_URL/search?projectId=$PROJECT_ID&q=reduce+churn+through+onboarding&limit=5" \ -H "Authorization: Bearer $API_KEY" | jq '.results[] | {id: .node.id, label: .node.data.label, similarity, ancestors}' # Filter by node type curl -s "$API_URL/search?projectId=$PROJECT_ID&q=onboarding&type=approach&limit=3" \ -H "Authorization: Bearer $API_KEY" | jq '.results[]' ``` Parameters: `q` (required), `projectId` (required), `type` (optional filter), `limit` (optional, default 5, max 20). ### View Control Control the ProductBrain UI from the API. Commands are delivered via Realtime. The app responds instantly. **When to drive it. From the conversation, not just on explicit commands.** This is what separates an agent that *describes* the plan from one that *shows* it. Whenever the user wants to **look at, focus on, jump to, or compare** something ("what's open in the launch phase?", "show me that bet", "let's look at the blockers"), answer **and** move their canvas: `selectIteration` to surface a phase, `zoomToNode` / `selectNode` to put a node in front of them, `setViewMode` to switch tree↔matrix to suit the question. Read `GET /api/v1/view-state` first (what are they already looking at?), act, then confirm with `last_applied_command.id`. A user with a live canvas should rarely have to hunt on screen for the thing they just asked you about. ```bash # Switch to matrix view curl -X POST "$API_URL/view-command" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{"projectId": "'"$PROJECT_ID"'", "command": {"type": "setViewMode", "mode": "matrix"}}' # Switch to tree view curl -X POST "$API_URL/view-command" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{"projectId": "'"$PROJECT_ID"'", "command": {"type": "setViewMode", "mode": "tree"}}' # Select a phase curl -X POST "$API_URL/view-command" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{"projectId": "'"$PROJECT_ID"'", "command": {"type": "selectIteration", "iterationName": "Alpha release"}}' # Zoom to a node (move the camera) curl -X POST "$API_URL/view-command" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{"projectId": "'"$PROJECT_ID"'", "command": {"type": "zoomToNode", "nodeId": "approach-1"}}' # Select a node. Highlight it + open its side panel (= a user click). null nodeId = deselect. curl -X POST "$API_URL/view-command" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{"projectId": "'"$PROJECT_ID"'", "command": {"type": "selectNode", "nodeId": "job-12"}}' # Expand a node curl -X POST "$API_URL/view-command" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{"projectId": "'"$PROJECT_ID"'", "command": {"type": "expandNode", "nodeId": "goal-1"}}' # Collapse a node curl -X POST "$API_URL/view-command" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{"projectId": "'"$PROJECT_ID"'", "command": {"type": "collapseNode", "nodeId": "goal-1"}}' # Focus on specific goals curl -X POST "$API_URL/view-command" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{"projectId": "'"$PROJECT_ID"'", "command": {"type": "focusGoals", "goalIds": ["goal-1", "goal-2"]}}' ``` Command types: `setViewMode`, `selectIteration`, `expandNode`, `expandAll`, `collapseNode`, `focusGoals`, `zoomToNode` (camera), `selectNode` (highlight + side panel). **Build-your-own demo sessions** (`/demo` → no-login session, key in the URL fragment) run on the **live app render path**: every node you `add` pops onto the visitor's canvas within ~1s — no reload dance, no waiting for a walk-through to "reveal" the plan. Narrate as you build; the canvas follows. Two demo-specific behaviors: newly created branches auto-expand as they arrive, and `zoomToNode`/`selectNode` also reveal a collapsed target (the visitor can't click chevrons — the canvas is watch-only, driven only by you). Commands sent while the visitor's tab is reconnecting are not lost — they replay in order on rejoin. **View-aware targeting + confirming a command landed:** - A node-targeting command (`selectNode`/`zoomToNode`) **auto-switches to matrix** when the target is a **job/task**. The only view that renders them. Pass `"autoSwitch": false` to stay in the current view instead. - **Goal/Need/Bet are NOT auto-moved.** Which view + expansion shows one best is *your* call (the user might want it in the current phase, or dropped to overview): compose `setViewMode` / `selectIteration` / `focusGoals` / `expandNode` yourself. The response `_tip` tells you the target's type so you know which case you're in. - **Confirm any command applied** by re-reading `GET /api/v1/view-state`: the POST returns a `commandId`; check `last_applied_command.id === commandId` (it advances even on a **no-op**, so this works when the view didn't visibly change). `clients_connected:false` means no live client picked it up. ### Read View State See what the user is currently looking at before sending view commands: ```bash curl -s "$API_URL/view-state?projectId=$PROJECT_ID" \ -H "Authorization: Bearer $API_KEY" | jq '.' ``` Response: ```json { "view_mode": "tree", "selected_iteration": "Alpha release", "expanded_nodes": ["goal-1", "need-1"], "focused_goals": ["goal-1"], "selected_node": "approach-1", "updated_at": "2026-05-17T...", "last_applied_command": { "id": 1514, "type": "selectNode", "at": "2026-05-17T..." }, "command_seq": 1514, "clients_connected": true, "last_client_seen": "2026-05-17T..." } ``` Use this to (1) make contextual decisions. E.g. zoom to a node only if the user is in tree view, check which iteration is selected before adding jobs; and (2) **confirm a command landed**. Match `last_applied_command.id` (= `command_seq`) to the `commandId` your `view-command` returned (advances even on a no-op). `clients_connected:false` = no live client, so a command can't apply yet. ### Hosted Workflow API (drive the planner with your own LLM) `POST /api/v1/workflow` runs ProductBrain's planning state machine and hands the prompts to **your** model. ProductBrain supplies the project context, the prompt engineering, and the structural validation; you supply the LLM. The result is a well-formed Goal → Need → Bet → Job tree no matter which model you use. And the API applies it, so there are no drafts to manage. **Three flows:** `add` (classify a thing and place it, find the parent or create the branch), `task-curate` (process the task inbox: merge / promote / create / skip / discard), `phase-assign` (fit unassigned jobs into phases, create new phases only when needed). **`task-curate` is human-in-the-loop by design.** Your classification is a set of *proposals*, not an auto-apply. `advance` applies exactly the actions you submit and nothing else: validation is all-or-nothing, then the batch is applied (see Retry safety below for what a mid-apply failure looks like). So for an inbox sweep, present each `promote`/`create`/`discard` to the user first and submit only their confirmed choices; set anything they decline to `skip` (stays in the inbox) or `discard`. The human stays in charge of what enters the plan. The `GET /workflow` catalog carries this as an inline `advisory` on the `task-curate` flow, and the `start` response repeats it in `_meta._tip`, so a cold agent doesn't have to infer it. **Discover the contract programmatically**, `GET /api/v1/workflow` (no auth) returns a machine-readable catalog: the protocol plus each flow's `input` and `advance.response` shape. Build against that, not against this prose. **The loop:** ```bash # 1. Start. Returns a prompt (your project tree is injected) and an opaque state. curl -X POST "$API_URL/workflow" -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "projectId": "'"$PROJECT_ID"'", "action": "start", "flow": "add", "input": "barcode price scanning" }' # → { "done": false, "prompt": "...", "state": { ... } } ``` 2. Send `prompt` to your own LLM. Parse its reply into a structured response: `{ "message": "...", "signal": "classified|confirmed|draft_ready|needs_clarification", "context": {...}, "draftNodes": [...] }` (`add`), or with a `phases` / `actions` array (`phase-assign` / `task-curate`). Each entry in `draftNodes` is `{ "type": "goal|need|approach", "parentId"?: "...", "data": { "label": "...", "description": "...", ... } }`. Every field goes inside `data`; a top-level `label` is ignored and the apply then fails for a missing `data.label`. A need chains to the last goal drafted before it in the same array and a bet (`approach`) to the last need; `parentId` is only consulted when there is no such earlier parent, and the `matchedNodeId` anchor is the fallback when neither an earlier parent nor `parentId` resolves. `task-curate` actions are `merge`, `promote`, `create`, `skip` and `discard`; the catalog gives each one's fields. ```bash # 3. Advance. Submit the structured response. The machine validates it and # returns the next prompt, a precise *retryable* error, or the terminal apply. curl -X POST "$API_URL/workflow" -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "projectId": "'"$PROJECT_ID"'", "action": "advance", "state": , "response": , "nextInput": "yes" }' # nextInput is the user's reply to the prompt you just showed them. It becomes the # final User turn of the next prompt and is recorded in the history on the advance # after that. Omit it and the server uses "Go ahead and draft it.", which suits the # add flow's confirm → draft step; send the real reply whenever the user said more. # Only the add flow has a next prompt, so the other two flows ignore it. # mid-flow → { "done": false, "prompt": "...", "state": { ... } } # bad input → 400 { "error": "Step ... expected one of: ...", "retry": true, "state": } # terminal → { "done": true, "changeset": [ ... ], "focus": "goal-7" } ``` **Structured input only.** The machine takes a parsed response object, not raw text. If it's malformed it returns a precise error and you retry with your LLM. **Retry safety.** When a terminal apply fails, the `500` tells you which of four situations you're in, so you never have to guess: | Response | What happened | What to do | |---|---|---| | `{ retry: true, partialApply: false }` | Rejected before any write — validation, tier, or the node cap. **Nothing was written.** | Fix the cause named in `error` and re-call `advance` with the same `state`. | | `{ retry: false, partialApply: false, alreadyApplied: true }` | A previous attempt at **this same advance** already landed. Nothing was written twice. | Read `GET /api/v1/nodes` to pick up the ids. Do **not** retry. | | `{ retry: true, partialApply: false }` with "still running" in the tip | An identical apply is in flight. | Wait a few seconds, then re-call `advance` with the same `state`. | | `{ retry: false, partialApply: true }` | The apply failed mid-flight and may have *partially* committed. | Read `GET /api/v1/nodes` and reconcile before anything else. | The workflow's internal call to `/api/v1/mutate` carries an `Idempotency-Key` derived from your `state` + `response`. That is what protects you when the apply succeeded but the response never reached you — the common client-timeout case. Retrying that advance **cannot** double-write. One honest limit: it cannot *replay* the original changeset either. The branch is wired against the live tree, and the first apply changed that tree, so the retry builds a different request under the same key — which is exactly why you get `alreadyApplied: true` rather than the original `changeset`. Your change is applied; read the tree to get the ids. **Payload limits.** Oversized requests are rejected with a `400` before anything is written: at most 50 draft nodes per `add`, 100 phases and 1000 total job assignments per `phase-assign`, 200 actions (each creating at most 10 nodes) per `task-curate`. Only the most recent 100 turns of the conversation history in `state` are fed to the prompt — the `state` you get back is not trimmed, so if you're holding a very long conversation, the envelope keeps growing even though the model only ever sees that window. These are blast-radius guards, not product limits — split the work across several flows if you genuinely need more. **The changeset** is `{ action, nodeId, type, before, after }[]` plus a `focus` hint. Exactly what the apply did. Use it to: - **show the user**, feed `focus` / changed ids to `POST /api/v1/view-command` (`zoomToNode`, `selectIteration`) to drive their canvas; - **revert**, if they say "undo that", reconstruct the inverse via `/api/v1/mutate` (`add`→delete, `update`→restore `before`, `delete`→re-add `before`). The workflow uses your model, so it draws **no AI credits**. It's available on **any Builder license, including the free Builder tier**, and on Team (or membership of a Team-owned project). Strategist plans don't call `/workflow` directly; the Strategist UI drives the same state machine under the hood. ### Outbound Webhooks Receive HMAC-signed POSTs when nodes or phases change. This is the integration layer. Make.com, Zapier, and n8n receive webhooks natively, so a Jira/Linear/GitHub handoff is a half-day workflow, not a custom integration. ```bash # Register a webhook (the secret is shown ONCE, store it) curl -X POST "$API_URL/webhooks" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "projectId": "'"$PROJECT_ID"'", "url": "https://hook.make.com/abc123", "events": ["node.updated", "iteration.updated"] }' # Returns: { "success": true, "webhook": { "id": "...", "url": "...", "events": [...], "secret": "whsec_..." } } # List registered webhooks (secrets are never returned again) curl -s "$API_URL/webhooks?projectId=$PROJECT_ID" \ -H "Authorization: Bearer $API_KEY" | jq '.webhooks' # Reconfigure in place, SAME id, secret preserved, your receiver keeps verifying. # (Don't delete+recreate to change a URL/events, that mints a new secret and breaks you.) curl -X PATCH "$API_URL/webhooks" \ -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \ -d '{ "projectId": "'"$PROJECT_ID"'", "id": "", "url": "https://new.example.com/hook", "events": ["node.added"] }' # Rotate the secret (returned once). Pass rotateSecret:true; the old secret dies. curl -X PATCH "$API_URL/webhooks" \ -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \ -d '{ "projectId": "'"$PROJECT_ID"'", "id": "", "rotateSecret": true }' # Remove a webhook curl -X DELETE "$API_URL/webhooks?projectId=$PROJECT_ID&id=" \ -H "Authorization: Bearer $API_KEY" ``` **Events:** `node.added`, `node.updated`, `node.deleted`, `iteration.added`, `iteration.updated`, `iteration.deleted`. Omit `events` to subscribe to all. Up to 10 webhooks per project. **URL rules:** `https` only, and the host must be publicly routable. Rejected with `400 url must be publicly routable`: `localhost` (with or without a trailing dot) and any `*.localhost`, `*.local`, `*.internal` name; `0.0.0.0`, `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`; and IPv6 literals for loopback (`[::1]`), unspecified (`[::]`), link-local (`[fe80::/10]`), unique-local (`[fc00::/7]`) every IPv4-mapped address (`[::ffff:...]`, dotted or hex) and the deprecated IPv4-compatible form (`[::a.b.c.d]`). Deliveries never follow redirects: a `3xx` from your receiver counts as a failed attempt (retried, then failed), so point the webhook at the final URL. **Payload** (POST, `Content-Type: application/json`): ```json { "event": "node.updated", "project_id": "my-project", "timestamp": "2026-06-11T00:00:00.000Z", "node": { "id": "job-45", "type": "job", "parentId": "approach-3", "data": { "label": "...", "status": "done" } }, "node_url": "https://app.productbrain.com/?project=my-project&node=job-45" } ``` `node_url` is a deep link that opens the plan on that node for anyone with access to the project (a job or task switches the canvas to the matrix). Put it in whatever your receiver creates, a tracker issue for instance, so a teammate can follow it back; the Make blueprints in `integrations/make/` already do. Iteration events carry `"iteration": { "name": "...", "status": "..." }` instead of `node` (renames also include `previous_name`) and no `node_url`. **Headers:** `X-ProductBrain-Event` (event type), `X-ProductBrain-Delivery` (the delivery ID, **the same value on every retry of one event**, so dedup on it), `X-ProductBrain-Signature: sha256=`. The HMAC-SHA256 of the raw request body using your webhook secret. Verify it: ```js const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); const valid = crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); ``` **Delivery semantics:** at-least-once. Every event is **persisted durably** before we attempt it, so a down receiver does not lose the event. We attempt delivery immediately; if your endpoint returns a non-2xx status or doesn't respond within 5 seconds, we retry with exponential backoff (~2, 4, 8, 16, 32 minutes) up to **6 attempts total**, then mark the delivery `failed`. Each attempt re-signs the body with your webhook's *current* secret, so [rotating the secret](#webhooks) never breaks an in-flight retry. Because it's at-least-once, **your receiver must be idempotent**. The same event can arrive more than once (e.g. you process it but acknowledge slowly, past the 5s window, so we retry). Dedup on `X-ProductBrain-Delivery`, which is stable across every retry of one event. The last delivery time and HTTP status are visible on each webhook in the list response. Poll that to monitor health. A delivery that exhausts all 6 attempts is marked `failed` and not retried further; reconcile by reading `GET /api/v1/nodes`. ### Share Links Mint a public, read-only link to a project view, then audit and revoke it. A share link is a **public credential**. Anyone with the URL sees the shared view (titles, descriptions, structure; free-text notes, `externalRefs`, `serves` and `demand` excluded), no login. Node fields reach a share link only when they're on its allow-list, so a new field is private until it's deliberately added. ```bash # Mint, optional `expiresInDays` TTL; optional `settings.includedGoalIds` to scope it curl -X POST "$API_URL/share" -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "projectId": "'"$PROJECT_ID"'", "expiresInDays": 30 }' # → { "token": "...", "url": "https://productbrain.com/shared/", "expiresAt": "..." } # List, audit every link (each has active / expiresAt / revokedAt) curl -s "$API_URL/share?projectId=$PROJECT_ID" -H "Authorization: Bearer $API_KEY" | jq '.tokens' # Revoke, kill a leaked or stale link (effective within ~30s, edge cache) curl -X DELETE "$API_URL/share?projectId=$PROJECT_ID&token=" \ -H "Authorization: Bearer $API_KEY" ``` Without `expiresInDays` a link never expires. Treat the token as a long-lived secret, list periodically, and revoke what you don't recognise. **Search engines and shared views.** Every shared view is `noindex` by default: the HTML document at `/shared/` carries `X-Robots-Tag: noindex` and a robots meta, and it unfurls with per-link Open Graph (the project name and node counts, never node text). A link can opt in to being indexed, one token at a time, never project-wide: ```bash # Mint an indexable link curl -X POST "$API_URL/share" -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \ -d '{ "projectId": "'"$PROJECT_ID"'", "settings": { "public_index": true } }' # Or flip an existing link (evidenceUrl optional: a productbrain.com page that explains how the plan was made) curl -X PATCH "$API_URL/share" -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \ -d '{ "projectId": "'"$PROJECT_ID"'", "token": "", "publicIndex": true }' # → { "success": true, "url": "https://productbrain.com/shared/", "settings": { "public_index": true } } ``` An indexable link's page is server-rendered: ``, description, canonical, `index,follow`, and the plan (labels and descriptions, goal → need → bet → job, notes excluded) as plain HTML under the interactive canvas, so a crawler or an assistant that fetches the URL reads the whole tree. `publicIndex: false` returns it to the default. `publicIndex` must be a boolean (`400 invalid_public_index` otherwise). **`expiresInDays` rule:** omit it for no expiry. If you send it, it must be a JSON number that is an integer from 1 to 3650. Anything else is `400 invalid_expires_in_days` and nothing is minted: a numeric string (`"30"`), `0`, a negative number, a fraction, or `null`. The `message` says what was received and what is valid, and `_meta._tip` says how to fix it (for a string: send a JSON number, not a string). Earlier versions silently ignored a bad value and minted a never-expiring public link; now the request fails so a bad TTL cannot turn into a permanent credential. ### Agent Seats An **agent seat** is an identity of its own — its own `pb_` key, its own line in the changelog — so an agent's writes are attributable and revocable without touching a human's key. Minting one is owner-only and requires the **Team** tier. (On Builder you get the owner seat plus your own key from the app's API Key modal; provision your agent with that instead. Asking anyway returns `403 team_tier_required`.) ```bash # One project curl -X POST "$API_URL/members" -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "projectId": "'"$PROJECT_ID"'", "agent": { "name": "Delivery Agent" } }' # Several projects, ONE identity and ONE key (you must own all of them) curl -X POST "$API_URL/members" -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "agent": { "name": "Fleet Agent" }, "projectIds": ["alpha", "beta", "gamma"] }' # → { "success": true, "member": { "userId": "agent_…", "type": "agent", … }, # "projects": ["alpha","beta","gamma"], "apiKey": "pb_…" } # Who is on a project (keys are never returned again) curl -s "$API_URL/members?projectId=$PROJECT_ID" -H "Authorization: Bearer $API_KEY" | jq # Remove a seat from ONE project curl -X DELETE "$API_URL/members?projectId=$PROJECT_ID&userId=agent_…" \ -H "Authorization: Bearer $API_KEY" ``` Prefer `projectIds` when one agent works across several of your projects: it's one key to store, rotate and audit instead of one per project, and it costs **one seat** no matter how many projects, because seats are counted by distinct identity. Pass the `projectId` you mean on each request as usual. Three things to know before you call it: - **`apiKey` is returned once.** There is no retrieval. Store it before you do anything else. - **You must own every project you name.** If you don't, the call fails with `403 not_owner` and a message naming the offending ids — and nothing is created, so it's safe to fix the list and retry. - **Adding a project later means POSTing the full list again**, which mints a *new* identity and key. Name every project up front. Removing the seat from one project leaves the key working on the rest. Removing its **last** membership revokes the key. ### Send Feedback to ProductBrain When PB gets in your way (a missing capability, an endpoint that made you work around it, a confusing response, a bug), tell us. `POST /api/v1/feedback` records it for the ProductBrain team. It is **not** added to your plan and is never sent to a model. ```bash curl -X POST "$API_URL/feedback" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -H "X-Agent-Id: my-agent" \ -d '{ "kind": "feature_request", "summary": "Move several jobs to a new bet in one call", "details": "Reparenting 12 jobs took 12 update mutations. A batch reparent would have been one.", "context": { "endpoint": "/api/v1/mutate" }, "projectId": "'"$PROJECT_ID"'" }' # → { "success": true, "id": "…", "createdAt": "…", "_meta": { "_tip": "Feedback recorded …" } } ``` | Field | Required | Rules | |---|---|---| | `kind` | yes | `feature_request`, `friction`, `bug`, or `note` | | `summary` | yes | 1–200 characters. Make it stand on its own: there is no reply channel yet. | | `details` | no | Up to 5000 characters. What you tried, what happened, what would have helped. | | `context` | no | A JSON object up to 4KB, e.g. `{ "endpoint", "workflow" }`. | | `projectId` | no | Recorded only if your key can access that project. Otherwise it is dropped and the `_tip` says so. | Limits: **20 items per user per 24 hours** (`429 rate_limited` after that), so fold related points into one item's `details`. Feedback is stored as written, so keep credentials, customer records and confidential plan content out. ### Placing New Work in the Tree Before creating a node, search for where it belongs: ```bash # 1. Search, find the right parent curl -s "$API_URL/search?q=users+cant+compare+prices&projectId=$PROJECT_ID" \ -H "Authorization: Bearer $API_KEY" | jq '.results[:3]' # Response includes ancestor paths, often enough to decide placement: # { "id": "need-5", "label": "Compare prices across stores", "similarity": 0.87, # "ancestors": [{ "id": "goal-1", "type": "goal", "label": "Help shoppers save money" }] } # 2. Context (only if ambiguous), see siblings and children curl -s "$API_URL/tree?nodeId=need-5&direction=context&projectId=$PROJECT_ID" \ -H "Authorization: Bearer $API_KEY" | jq '.' # 3. Create under the right parent curl -X POST "$API_URL/mutate" ... ``` At most two calls. Never pull all nodes to find placement. ## Common Patterns ### Capturing Decisions in Notes When you make a decision or discuss tradeoffs, write to the relevant node's notes: ```bash curl -X POST "$API_URL/mutate" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "action": "update", "projectId": "'"$PROJECT_ID"'", "nodeId": "approach-5", "data": { "notes": "## Decision\nChose X over Y because..." } }' ``` ### Adding Work Discovered During Implementation When implementing a job, if you discover new work needed: 1. Add new jobs under the same bet (or a new bet if it's a different one) 2. Leave them unassigned to any phase (backlog) 3. Note in the original job's notes that you discovered additional work ### Marking Work Complete ```bash # Only include the field you're changing, PATCH semantics preserves existing data curl -X POST "$API_URL/mutate" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "action": "update", "projectId": "'"$PROJECT_ID"'", "nodeId": "job-45", "data": { "status": "done" } }' ``` ### Old notes conventions Shared bets, external references and demand were once recorded as lines in notes. Each now has its own field. Move any old lines across. **A bet that serves more than one need** now has its own field: `serves` (see Type-Specific Field Rules). If a bet still has `Serves: need-N (label)` lines in its notes from the old convention, move each id into `serves` and delete the line. Don't split one build into several bets to give each need a child: that hides the shared dependency. And never use `blockedBy` to mean "also serves": it stays a delivery dependency between bets. **A reference to another system** now has its own field: `externalRefs` (see Type-Specific Field Rules). If a node still has `System: key` lines in its notes from the old convention, move each one into `externalRefs` (`Jira: PROJ-1234` becomes `{ "system": "jira", "key": "PROJ-1234" }`) and delete the line. The Jira integration still finds a job by the labels it writes into Jira, so to claim an existing Jira issue for a job, also add the labels from the Jira integration guide. **Who asked for it** now has its own field: `demand` on the need (see Type-Specific Field Rules). If a bet or job still has `Demand: <who> | <revenue or size> | <YYYY-MM-DD>` lines in its notes from the old convention, move each into the `demand` list of the need above it (`who`, the magnitude as `revenue` or `size`, the date as `at`) and delete the line. ## Generating a Brain from a Project When asked to create a ProductBrain tree from a codebase or artifact: ### Working Incrementally For large projects, do NOT attempt to build the entire tree in one pass. Work top-down in layers: 1. **First pass: Goals and Needs only.** Get the strategic structure right before going deeper. Write all goals, then decompose each into needs. Stop and verify. 2. **Second pass: Bets.** For each need, identify bets. Write them with measures. Stop and verify. 3. **Third pass: Jobs (only where needed).** Only decompose into jobs for bets that are actively being worked or have clear deliverables. Many bets don't need jobs yet. Each pass should be committed to the database before starting the next. This prevents half-baked trees where the top is thoughtful and the bottom is filler. It also lets you catch structural mistakes early. A bad goal pollutes everything beneath it. ### 1. Identify Goals (1-6 typically) Goals are the top-level outcomes. Ask: "What is this project trying to achieve?" Examples: - "Deliver a usable audio knowledge platform" - "Build a sustainable product business" - "AI augments the founder's thinking" ### 2. Decompose into Needs For each goal, ask: "What persistent problems do users face that stand between us and this?" Apply the tests in [What counts as a Need](#what-counts-as-a-need): if it can be finished it is not a Need, and an internal condition must name the person who feels it. ### 3. Identify Bets For each need, ask: "How might we address this?" Multiple bets per need is normal. They can be alternatives (pick one) or complementary (pursue in parallel). Either is fine. **The test:** "Is this a coherent bet you could pursue independently?" Each bet should stand on its own as something that could succeed or fail. If it only makes sense as part of delivering something else, it's a job, not a bet. Example of the mistake: - Bad: "LLM monitors brain", "Notification UI", "Encode heuristics" as three bets. They are implementation pieces of one bet - Good: "Proactive coaching from canonical sources" as one bet, with those three as jobs beneath it - Complementary bets might be: "Proactive coaching" AND "On-demand coaching via chat" (could pursue both) - Alternative bets might be: "Push notifications" vs "Pull-based dashboard" (choose one) **Every bet needs a measure**. How will you know it's working? Classify each bet with Kano. What satisfaction tier is it targeting? The same need can carry bets at different tiers. - Table stakes: infra, reliability, auth, payments - `must-have` - More-is-better improvements - `incremental` - Unexpected delight, wow moments - `delighter` Size them relatively: - Quick wins, obvious implementations - `skateboard` or `vespa` - Real engineering effort - `car` - Complex, cross-cutting - `truck` - Needs decomposition - `antonov` (then break it down) ### 4. Break into Jobs For each bet, ask: "What checkpoints prove this is working?" A job is a **verifiable checkpoint**. An observable result you can confirm before moving to the next step. Not an implementation task, not a full feature, but a gate: if this doesn't pass, nothing after it matters. **Three fields, three audiences:** - **Label** = what you can observe ("Upload modal opens and accepts PDF files") - **Description** = where to verify it (UI, devtools, API response, console trace) - **Notes** = how to build it (implementation detail, for coding agents: not shown prominently in UI) **Examples, bet: "PDF document import":** | Job label | Description | Notes | |-----------|-------------|-------| | Upload modal opens and accepts PDF files | Click import → modal appears, PDF file type accepted | Wire file input with accept=".pdf", add modal component | | Server confirms receipt and returns document ID | Devtools network tab: POST /api/import returns 200 with { documentId } | Multer middleware, S3 or temp storage, return ID | | Extracted key points visible in devtools | Console: trace shows extracted sections and headings | pdf-parse library, LLM extraction prompt, log output | | Goal tree renders from extracted content | Canvas shows goal + needs + bets populated from PDF | Map extraction output to draft nodes via useDraftMode | | User can review and edit tree before committing | Draft nodes editable in side panel, commit/discard buttons work | Reuse existing draft commit flow | Each job is independently verifiable. A strategist checks the UI-visible ones (1, 4, 5). A builder agent checks all of them. If job 3 fails, you know the break is between "server received the file" and "extraction worked". No untangling. **This is also execution discipline for agents.** When working through jobs: 1. Do the work for this checkpoint 2. Verify it passes (check what the description says to check) 3. Commit 4. Move to the next job **Never batch multiple checkpoints into one pass.** Each job is a gate. If you skip verification, you compound errors and spend days debugging instead of hours building. Format: **Outcome-first, not implementation-first.** - Good: "Share link shows read-only tree" (observable checkpoint) - Good: "Filtered phase shows only assigned jobs" (verifiable in UI) - Bad: "Create payments table migration" (implementation step, goes in notes) - Bad: "As a user, I want to pay with my credit card" (user story format) Tag with maturity: - Foundation, dependencies, spikes - `mvp` - User-visible value - `releasable` **Job Granularity Guidelines:** - **Jobs are verification gates, not brainstorming.** Don't decompose into jobs until the bet is actively being worked. The bet captures the hypothesis. Jobs are the checkpoints when ready to execute. - **Each job should be verifiable in under a minute.** If you can't confirm it passed quickly, it's too big. Split it. If it takes 10 seconds to check, it's the right size. - **When in doubt, use a Task.** If work feels disconnected from the strategic tree, useful but not advancing a specific goal, create a Task instead. Tasks are guilt-free parking for real work that doesn't fit the hierarchy. - **The bet is often the job.** For simple bets, don't create jobs at all. The bet itself is the trackable unit. Only decompose when there's genuine complexity worth tracking separately. *Caveat: if you need to schedule the work, create a single job: bets can't be assigned to phases directly.* ### 5. Assign to Phases (Optional) If the project has release milestones, assign jobs and tasks to phases. Unassigned jobs are backlog. Captured but not committed. Only jobs and tasks carry phase assignments (via the `iteration` field in the data model). **To fit unassigned (Later) jobs into phases, prefer the `phase-assign` workflow** ([Hosted Workflow API](#hosted-workflow-api-drive-the-planner-with-your-own-llm)) over hand-rolling `addIteration` + updates. It groups jobs by **value theme** (MVP jobs first, then vacuuming orphaned releasable siblings), names each phase accordingly, reuses an existing phase when a job fits its theme, and creates a new one only when nothing fits. So you get themed phases instead of a generic "Next Up" bucket. The API rejects only `Later`, `Backlog`, `Polish` and `Catch-all` (trimmed, case-insensitive) with a `400 { retry: true }`; a name like "Sprint" is discouraged by the prompt but not rejected, so check the proposed names before you advance. It's strictly additive: it never moves already-assigned jobs or renames existing phases, and it leaves a job in Later if it genuinely fits nowhere. When you do create a phase by hand, name it by theme (see [Phases](#phases)). ## Example: Minimal Well-Formed Tree ```json { "nodes": [ { "id": "goal-1", "type": "goal", "data": { "label": "Build a sustainable audio library business", "description": "Creators pay to keep, organise and reach their audio here.", "measure": "500 paying creators by the end of Q4" } }, { "id": "need-1", "type": "need", "parentId": "goal-1", "data": { "label": "Upload audio reliably", "description": "Table stakes. If uploads fail, nothing else matters." } }, { "id": "approach-1", "type": "approach", "parentId": "need-1", "data": { "label": "Chunked upload with resume", "measure": "99.9% upload success rate for files up to 2GB", "kano": "must-have", "size": "car", "description": "Handle large files and flaky connections gracefully." } }, { "id": "job-1", "type": "job", "parentId": "approach-1", "data": { "label": "Upload progress bar reaches 100% for a 500MB file", "maturity": "mvp", "iteration": "MVP", "description": "UI: progress bar advances smoothly. Devtools: chunked PUT requests visible in network tab. Server returns upload ID on completion.", "notes": "Use tus.io or custom chunked endpoint. 5MB chunks, retry on failure. Store reassembled file in S3. Return { uploadId } on final chunk." } }, { "id": "task-1", "type": "task", "data": { "label": "Fix upload timeout on slow connections", "iteration": "MVP", "description": "Discovered during testing. Not tied to a specific bet." } } ], "iterations": [ { "name": "MVP", "status": "active" }, { "name": "Later", "status": "active", "system": true } ] } ``` ## Validation Rules 1. **Non-goal/task nodes require parentId**, needs need goals, bets need needs, jobs need bets 2. **Label is required** on all nodes 3. **Measure is expected** on bets. The API does not enforce it. It is optional on goals and rejected on needs, jobs and tasks 4. **Kano is expected** on bets 5. **IDs must be unique** (enforced by primary key) 6. **Parent must exist** (enforced by foreign key) 7. **Fields must be on correct node type**, e.g., `iteration` only on jobs/tasks, `maturity` only on jobs 8. **`externalRefs`**: at most 50, no `system` + `key` pair twice, only `system`/`key`/`url`; `url` must be `https://` 9. **`serves`**: bets only; existing need ids in the project, not the bet's own parent, no repeats, at most 5 10. **`demand`**: needs only; at most 25 entries, each with `who` and an `at` date; only `who`/`at`/`revenue`/`size`/`source`/`ask` 11. **Deletes are permanent**. No soft delete, no undo. Changelog retains audit trail. ## Schema Reference See `docs/BRAIN_SCHEMA.json` for the complete JSON Schema. Use it to validate generated brains before committing. ## Documentation Full product documentation is published at [docs.productbrain.com](https://docs.productbrain.com). If your user asks about how the UI works, why the tree behaves a certain way, or what the framework concepts mean, fetch the docs. They cover the model, both views, phase planning, and Kano classification in detail. Key pages: - [Docs index for agents](https://docs.productbrain.com/llms.txt): every page, one line each - [Framework overview](https://docs.productbrain.com/framework/overview.md): the Goal → Need → Bet → Job model - [Goals](https://docs.productbrain.com/framework/goals.md), [Needs](https://docs.productbrain.com/framework/needs.md), [Bets](https://docs.productbrain.com/framework/bets.md), [Jobs](https://docs.productbrain.com/framework/jobs.md): each level in depth - [Kano classification](https://docs.productbrain.com/framework/kano.md): must-have, incremental, delighter - [Planning Tree vs Delivery Map](https://docs.productbrain.com/framework/tree-vs-matrix.md): how the two views work - [Phases](https://docs.productbrain.com/framework/iterations.md): planning, scoping, the Later inbox - [Builder intro](https://docs.productbrain.com/guides/builder-intro.md): how the API fits the builder workflow --- *This guide is the contract between ProductBrain and any LLM that operates on it. If behavior diverges from this document, the document should be updated.*