# Agent relay format: `PB-CHANGES v1` and `PB-REPLY v1`

How an agent that can't call the ProductBrain API edits a plan anyway, turn after turn, with the user as the transport.

The agent reads the plan through a link ([agent read link](#the-agent-read-link)), writes its changes as a `PB-CHANGES` block, the user pastes that block into ProductBrain (**Agent relay** in the top bar), ProductBrain applies it as one batch and hands back a `PB-REPLY` block, and the user pastes the reply into the chat. Nothing in the loop needs an install, a connector, or an API key in the chat.

Who this is for: a product manager on a managed laptop whose Claude (or any assistant) can only fetch public URLs over GET, can't run code, and can't be given a connector. Everything below is designed around what that assistant can do: read a pasted or linked URL, and write text.

The format is line-based, not JSON, because chat copy-paste mangles JSON (curly quotes, backslash-escapes, bullets, truncation). Every line here survives that, and what doesn't survive is detected.

---

## `PB-CHANGES v1` (what the agent writes)

```
PB-CHANGES v1 project: ivvy ops: 5
ADD bet Self-serve venue onboarding UNDER need-12 AS @b1
  description: Venues set up without a support call
  measure: share of venues live without a ticket
ADD job A venue is live within ten minutes of signing up UNDER @b1
  phase: Onboarding
UPDATE job-44 status: done
MOVE approach-9 UNDER need-3
DELETE task-12
END
```

### Shape

1. **Header line**: `PB-CHANGES v1` followed by `project: <id>` (optional, checked against the open project) and `ops: <n>` (required: the number of operations in the block).
2. **Operations**, one per line, in order. Fields for an operation follow it on `key: value` lines.
3. **`END`** on its own line.

`ops:` and `END` are how a truncated paste is caught. If the count doesn't match the operations found, or `END` is missing, the block is refused as a whole with the reason, and nothing is applied. Everything else is reported per line.

### Operations

| Line | Meaning |
|---|---|
| `ADD <type> <label> UNDER <parent>` | Create a node. `<type>` is `goal`, `need`, `bet`, `job` or `task` (`approach` is accepted for `bet`). The label runs to the end of the line, no quotes. `UNDER` names the parent: an id (`need-12`) or a local ref (`@b1`). Goals and tasks have no parent, so no `UNDER`. |
| `ADD <type> <label> UNDER <parent> AS @<ref>` | Same, and names the new node `@<ref>` so later lines in the same block can refer to it (`UNDER @b1`, `blockedBy: @b1`, `serves: @n2`). Refs are letters, digits, `-` and `_`. Each ref is defined once. |
| `ADD phase <name>` | Create a phase (an iteration). Use it before the jobs that name it in the same block. |
| `UPDATE <id>` | Change fields on an existing node. The fields follow on `key: value` lines. One field may sit on the same line: `UPDATE job-44 status: done`. |
| `MOVE <id> UNDER <parent>` | Reparent a node. A need under a goal, a bet under a need, a job under a bet. Goals and tasks don't move. |
| `DELETE <id>` | Delete a node and everything under it. The reply says what went with it. Deletes are restorable from the plan's history, but the relay window asks the user to confirm them. |

Keywords (`ADD`, `UNDER`, `AS`, `UPDATE`, `MOVE`, `DELETE`, `END`) and type names are case-insensitive. Ids are plain tokens of the form `goal-1`, `need-12`, `approach-9`, `job-44`, `task-3`, exactly as the read link shows them in brackets. `bet-9` is read as `approach-9`.

### Fields

A field is a `key: value` line after the operation it belongs to. Indent it if you can. Indentation isn't required, because chat UIs often strip it. A bullet (`- key: value`) is fine too.

| Key | On | Value |
|---|---|---|
| `label` | any | New label, on `UPDATE` only. An `ADD` takes its label from the operation line. |
| `description` | any | Text. |
| `notes` | any | Text. Context for the next agent: decisions, constraints, links. |
| `measure` | goal, bet | Text. Required on a bet (how you'll know it's working), optional on a goal (a target). |
| `kano` | bet | `must-have`, `incremental`, `performance` or `delighter`. |
| `size` | bet | `skateboard`, `vespa`, `car`, `truck` or `antonov`. |
| `approachStatus` | bet | `development`, `validation`, `resolved` or `retired`. `betStatus` is accepted. |
| `blockedBy` | bet | Ids of the bets this one waits on, comma-separated: `approach-4, @b1`. |
| `serves` | bet | Ids of the other needs this bet also helps, comma-separated. Never its own parent. |
| `maturity` | job | `mvp` or `releasable`. |
| `phase` | job, task | A phase name. `Later` is the inbox. `iteration` is accepted. |
| `status` | job, task | `done` (live in production) or `open` (reopen). |
| `kind` | task | `memo` or `do-it`. |
| `urgent` | task | `yes` or `no`. Only on a do-it task. |
| `externalRefs` | any | One entry per line: `<system> <key> [<url>]`, such as `jira PROJ-123 https://acme.atlassian.net/browse/PROJ-123`. Up to 3 entries, one per system. Repeat the key for each entry. |
| `demand` | need | One entry per line, `name=value` pairs separated by `;`: `who=Acme Logistics; at=2026-09-14; revenue=1200 USD/month; size=40 seats; source=sales-call; ask=SSO with their Okta tenant`. `who` and `at` are required. Repeat the key for each entry. Up to 25. |
| `under` | add, move | The parent, as an alternative to `UNDER` on the operation line. |

Keys are case-insensitive, and `blocked-by`, `blocked_by`, `external-refs` and `evidence` (for `demand`) are read as their canonical names.

**Clearing a field.** `key: -` clears it (ProductBrain stores `null`). Works for every field, including lists.

**Lists replace.** `blockedBy`, `serves`, `externalRefs` and `demand` are sent whole: what's in the block is the new list. To add one entry, write the existing ones too (the read link shows them in this same syntax, ready to copy).

**Multi-line values.** Two ways, both fine: continue on the next lines, indented at least one space, or write a literal `\n` inside the value. A line that starts with a known field key and a colon always starts a new field.

### What's tolerated

The parser normalises what chat UIs do to text before it reads anything:

- curly quotes and typographic dashes become straight ones, and non-breaking spaces become spaces
- backslash-escapes a markdown renderer adds (`\_`, `\*`, `\[`, `\#`) are removed
- list bullets and numbering in front of a line (`- `, `* `, `• `, `1. `) are removed, and so is `**bold**` around the keywords
- blank lines are skipped
- anything before the `PB-CHANGES v1` line or after `END` is ignored, including code fences and the prose around them, so the user can paste the whole message

### What's an error, and where

A problem on one line is reported for that line, with a tip, and the rest of the block is still read. The block is applied only when every line is clean, because the batch behind it is all-or-nothing. The reply (below) carries every line's result so the agent can fix and resend once.

Refused as a whole, nothing read further: no `PB-CHANGES v1` header, an `ops:` count that is missing or doesn't match, no `END`, or a `project:` that names a different project than the one open.

Checked before anything is sent: unknown operation or type, missing label, missing or extra `UNDER`, unknown id form, undefined or duplicated `@ref`, a field that doesn't exist or doesn't belong on that type, a value outside its list, a malformed `externalRefs` or `demand` entry.

Checked by ProductBrain when applied (the same rules as the API, see the [agent guide](https://productbrain.com/docs/llm-guide.md)): the id exists, the parent is the right type, a phase exists and isn't closed, a bet's `serves` names existing needs, the free-tier node cap. A server error comes back on the operation's line with the server's own message and tip.

### Mapping to the API

A block becomes exactly one `POST /api/v1/mutate` `batch` request, one mutation per operation, in order: `ADD` is `add` (with a `draft-relay-<ref>` client id when the node has a ref, so later mutations in the batch can name it and the server remaps it), `ADD phase` is `addIteration`, `UPDATE` is `update` with `data`, `MOVE` is `update` with `parentId`, `DELETE` is `delete`. `key: -` sends `null`. No new write path and no new semantics: whatever the API refuses, the relay refuses.

---

## `PB-REPLY v1` (what ProductBrain hands back)

The user copies this from the relay window and pastes it into the chat. One line per operation, in the block's order, then any coaching the API returned, then a fresh read link.

Applied:

```
PB-REPLY v1 project: ivvy applied: yes ops: 5
OK 1 ADD bet @b1 -> approach-131 "Self-serve venue onboarding"
OK 2 ADD job -> job-412 "A venue is live within ten minutes of signing up"
OK 3 UPDATE job-44
OK 4 MOVE approach-9 UNDER need-3
OK 5 DELETE task-12
READ: https://productbrain.com/a/<token>?r=k7f2
END
```

Not applied, because some lines failed (either in the window or on the server):

```
PB-REPLY v1 project: ivvy applied: no ops: 5
OK 1 ADD bet @b1 "Self-serve venue onboarding"
ERR 2 line 6: phase is "Onboarding" but no such phase exists. TIP: add it first in the same block: ADD phase Onboarding
OK 3 UPDATE job-44
ERR 4 line 8: Node with id 'need-3' not found. TIP: read the plan again (the READ link) and use the ids shown in brackets.
OK 5 DELETE task-12
NOTE: nothing was applied. Fix the ERR lines and send the whole block again.
READ: https://productbrain.com/a/<token>?r=m2q9
END
```

Refused as a whole:

```
PB-REPLY v1 applied: no
ERR block: ops: 5 declared, 3 found, and no END line. TIP: the paste looks cut off. Send the block again in one code fence.
END
```

Lines you may see:

- `OK <n> <op> …`: applied. An `ADD` shows the new id after `->`. A `DELETE` says how many descendants went with it.
- `ERR <n> line <l>: <message> TIP: <how to fix>`: that operation failed. `<l>` is the line in the block you sent, counted from the `PB-CHANGES` line as line 1.
- `NOTE:` what happened to the block as a whole: nothing applied because a line failed (the batch is all-or-nothing), or a server failure and whether it was undone.
- `TIP:` and `ADVISORY:`: coaching from the API about the whole batch (a bet now serving many needs, a task inbox filling up).
- `READ: <url>`: a fresh read of the plan after the change. The `?r=` part is a nonce so your fetch tool doesn't return a cached copy. Fetch it before writing the next block.

`->` is plain ASCII on purpose: the reply is pasted from a browser into a chat, and arrows and quotes are the first things to get mangled.

---

## The agent read link

`GET https://productbrain.com/a/<token>` serves the plan as markdown, written for an agent: every node with its id in brackets (`[need-12] …`) and every field in the same `key: value` syntax `PB-CHANGES` uses, so a value can be copied into an `UPDATE` as it is. `?format=json` returns the same data as JSON.

The page is sized to fit one fetch. A small plan is inlined whole. A large one shows each goal with its needs and a link to that goal's full subtree (`?g=goal-7`). Phases, tasks, the condensed writing rules and a link to this spec are always on the root page. The last line is a reload link with a fresh nonce. Follow it for a current read.

Rules of the link:

- It is a capability. Anyone holding the URL can read the whole plan, notes included. Treat it like a password: don't post it, and revoke it when the session is over.
- It expires (30 days by default) and can be revoked at any time from the relay window or with `DELETE /api/v1/share`.
- It is read-only. There is no URL that changes the plan. Writes only happen when the user pastes a block.
- It is separate from share links. A share link's token doesn't work here, and this token doesn't open the shared view.
- Pages are `noindex` and sent with `Cache-Control: no-store`.

Minting one (the relay window does this for the user, and the API form is the same share endpoint):

```bash
curl -X POST "$API_URL/share" -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{ "projectId": "'"$PROJECT_ID"'", "kind": "agent-read", "expiresInDays": 30 }'
# → { "token": "…", "kind": "agent-read", "url": "https://productbrain.com/a/<token>", "expiresAt": "…" }
```

---

## A full turn

1. The user pastes the starter prompt (from the relay window) into the chat. It names ProductBrain, gives the read link, and asks for changes as `PB-CHANGES v1`.
2. The agent fetches the link, reads the plan, and answers with a block.
3. The user pastes the block into **Agent relay**. The window shows a diff: what's added, changed (before and after), moved and deleted, with any line errors. Deletes need a confirmation. The user clicks **Apply**.
4. The window shows the `PB-REPLY v1` block with a **Copy** button. The user pastes it into the chat.
5. The agent reads the reply, follows the `READ:` link when it needs the new state, and writes the next block.

The agent never needs a URL it hasn't been given: the starter prompt carries the first read link, and every reply carries the next one.
