diff --git a/CLAUDE.md b/CLAUDE.md index 7471404521..e274eb8148 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,6 +133,8 @@ useRegisterRef(key, handle, context?, options?) Action types and routing live in `src/engine/{actionTypes.ts, handlers.ts, navigator.ts, queue.ts}`. Add new actions there rather than special-casing inside `AIDrawer.tsx`. +That engine is the _local dispatch_ layer. The **wire contract** — what the app sends the MCP client and what it may send back — is a separate, narrower thing: see [`docs/ai-action-contract.md`](docs/ai-action-contract.md). Read it before changing what the model may return, or before adding a refKey the model should be able to address. + ## Testing with Cypress Specs live in `cypress/e2e//.spec.js`. Required setup: a `cypress.env.json` at the repo root with `{ "email", "password" }` plus the env vars consumed by the custom commands (`API_AUTH`, `COOKIE_NAME`, etc. — see `cypress.config.js`). diff --git a/docs/ai-action-contract.md b/docs/ai-action-contract.md new file mode 100644 index 0000000000..3d095dbf3f --- /dev/null +++ b/docs/ai-action-contract.md @@ -0,0 +1,256 @@ +# The AI action contract + +What the manager app sends the MCP client, and what the MCP client may send back. + +This is one contract with three surfaces. Two exist today — **AI content** (a content item's fields) and **AI code** (the code editor) — and **Studio** is the third. They differ only in which refKeys are addressable and what a value means for each. If you have implemented the content or code surface, Studio is the same exchange with a wider refKey namespace and one decision to make on each turn: field or file. + +**Studio's half of this document is a specification, not a live payload.** Everything in §2 marked _(Studio)_ is what the app will send once the surface ships; nothing sends it today. The request fields marked _(live)_ and all of §3 are in production now. + +**Every field in this request has to change a decision you make.** Studio knows a great deal about what the user is looking at — which toggle they are in, which page they opened, which element is highlighted — and almost none of it belongs on the wire. What arrives is what the model cannot derive: what it may write, what it is pointed at, and the text of the files it may rewrite. + +**In Studio the AI sits above the mode toggle.** Studio has a content mode, a layout mode, and a `full` mode that is the union of the two and the default for a user entitled to both. The drawer is not scoped by that toggle: one chat can change copy and code, and a single response may contain both a content-field write and a code-file write. The only thing that narrows what you may write is the user's permissions, delivered as `capabilities`. The request deliberately does **not** tell you which mode the UI is currently in — that would invite you to refuse a change the user is entitled to make. + +**Three actors, and this document keeps them apart.** The **app** is manager-ui, which composes the request, applies what comes back, and owns the UI. The **client** is the MCP service you implement: it receives the request, prompts the model, and returns actions. The **model** is what your system instruction steers — you own that instruction, so the rules in §6 are yours to encode, not ours to enforce. + +Audience: whoever implements the client. Nothing here describes manager-ui internals you have to care about. + +Related: [`studio.md`](studio.md) for what Studio itself is. + +--- + +## 1. The exchange + +`POST ${MCP_DOMAIN}/client` (`src/shell/services/mcp.ts`), with a bearer token and an `X-Instance-Zuid` header. The body is the prompt plus context. + +**The response body must be a JSON object with a `data` key.** `data` is either the action array itself, or a string containing it: + +```json +{ + "data": [ + { "type": "SET_VALUE", "payload": { "refKey": "title", "value": "…" } } + ] +} +``` + +A bare array as the response body reads as `undefined` and breaks the drawer — the app reads `aiResponse.data` (`AIDrawer.tsx:138-143`). Two tolerances exist and should not be relied on: a ` ```json ` fence around a string `data` is stripped, and a lone object is wrapped in an array. + +**You own the system instruction.** There are prompt builders in `src/shell/views/Shell/systemInstructions.ts`, but only `suggestionSystemInstruction` is used, and only by the Generate Suggestions button — the main prompt path sends no `systemInstruction` key at all. The other two builders in that file are unreferenced exports. Do not treat them as the schema of record; this document is. + +--- + +## 2. The request — what the app sends the client + +There are two entry points and they do not send the same body. **Generate Suggestions** sends only `{ prompt, systemInstruction, temperature }` and expects `SYSTEM_SUGGESTION` back. Everything else in this document is the main prompt path: + +| Field | Type | | Notes | +| ---------------------------- | ------------------------ | ---------- | --------------------------------------------------------------------------------------- | +| `prompt` | string | live | the user's text | +| `tone` | string | live | a full descriptive phrase, e.g. `"Professional - Serious, formal, and authoritative"` | +| `language` | string | live | BCP-47, e.g. `"en-US"` | +| `modelZuid` | string | live | content model of the item being edited | +| `itemZuid` | string | live | content item being edited | +| `registryKeys` | string[] | live | every refKey currently addressable — **the authoritative list, recomputed per request** | +| `refRegistry` | string[] | live | per-refKey context, as display strings — see below | +| `filename`, `code`, `fields` | string, string, object[] | live | present only when the code editor is open | +| `temperature` | number | live | 0.5 | +| `surface` | `"studio"` | _(Studio)_ | absent on the content and code surfaces | +| `selection` | object \| null | _(Studio)_ | what the user has selected on the canvas | +| `capabilities` | string[] | _(Studio)_ | what this user may change: `["content"]`, `["layout"]`, or both. The only gate | +| `sources` | object[] | _(Studio)_ | `{ refKey, filename, code, fields }` per code file in scope — see below | + +### `refRegistry` is not parseable JSON + +Each entry is built by string interpolation and the inner quotes are not escaped: + +``` +"title": "{"ZUID":"7-000000-000000","datatype":"text","currentValue":"Our Pricing"}" +``` + +`JSON.parse` on an entry throws. Treat these as text for the model to read, and take the authoritative key list from `registryKeys`. The keys inside vary by refKey: + +- **content fields** — `ZUID`, `contentModelZUID`, `currentValue`, `datatype`, `required`, `settings`, `label`, `name`, `maxLength`, `minLength` +- **`meta-title` / `meta-description`** — the entire content item object, a different shape +- **`code-editor`** — `fileName`, `code`, `fields` + +### `selection` _(Studio)_ + +When the selected element resolves to a content field: + +| Field | Type | | +| ----------------------- | ------ | ---------------------- | +| `studioId` | string | optional | +| `fieldZuid` | string | required | +| `fieldType` | string | optional; the datatype | +| `itemZuid`, `modelZuid` | string | optional | + +When it resolves to a region of a code file, the selection carries that region plus the element's editable slots. The app composes this from two internal objects, so expect exactly these keys. **Which of the two shapes arrives is how you tell what the user is pointing at** — there is no mode flag: + +| Field | Type | | +| ------------ | ----------- | --------------------------------------------------------- | +| `codeId` | string | the view file's ZUID; matches a `sources[].refKey` suffix | +| `layoutId` | string | opaque; do not address it in a response | +| `breadcrumb` | `{label}[]` | e.g. `simple_page.html › section › h1` | +| `tagName` | string | the selected element's tag | +| `slots` | slot[] | below | + +A slot is one editable thing on the element: + +| Field | Type | Notes | +| ------------------------------------------------------------- | ----------------------- | ----------------------------------------------------------- | +| `kind` | `"attribute" \| "text"` | | +| `key` | string | the attribute name, or `"text"` | +| `attr` | string | set when `kind` is `"attribute"` | +| `isDynamic` | boolean | true when the slot is bound to a content field | +| `value` | string | the **rendered** value | +| `sourceValue` | string | optional; the **template** value, e.g. `{{this.title}}` | +| `layoutEditable` | boolean | false means the template for this slot could not be located | +| `control` | `"text" \| "select"` | optional | +| `booleanAttr` | boolean | optional; presence-toggled attribute | +| `textIndex` | number | optional; which text run of the element this is | +| `fieldZuid`, `fieldType`, `itemZuid`, `modelZuid`, `studioId` | string | optional; present when `isDynamic` | + +`value` and `sourceValue` are **not interchangeable** — §6. + +### `sources` _(Studio)_ + +The current text of every code file the selection can reach, plus the Parsley vocabulary for it: + +```json +[ + { + "refKey": "view:11-000000-000000", + "filename": "pricing.html", + "code": "…", + "fields": [ + { "name": "title", "label": "Title", "type": "text" }, + { "name": "plan_name", "label": "Plan name", "type": "text" } + ] + } +] +``` + +`fields` is the same array the code surface already sends, and it is **the only list of field names you may reference in Parsley**. `{{this.}}` is valid only for a `name` that appears there; anything else renders empty on the live site. Do not introduce a cross-model reference (`{{MODEL.FIELD}}`) that is not already present in `code` or explicitly requested — you are not given the other models. + +`fields` is meaningful for views only. Stylesheets and scripts carry no Parsley, so ignore it there and never emit a field reference into a `.css` or `.js` file. + +--- + +## 3. The response — what the client returns + +Two action types. The contract has no third. + +```json +{ + "data": [ + { "type": "SET_VALUE", "payload": { "refKey": "…", "value": "…" } }, + { "type": "SYSTEM_OUTPUT", "payload": { "value": "…" } } + ] +} +``` + +| Type | When | +| --------------- | ------------------------------------------------------------------------------------------ | +| `SET_VALUE` | You are changing something. `refKey` must be one the request offered. | +| `SYSTEM_OUTPUT` | Everything else: an answer, a clarification, a refusal, "no writable target matches that". | + +`SET_VALUE` is the only write. Every capability below is a `SET_VALUE` against a different refKey — there is no separate action for styling, or reordering, or duplicating. + +(The app's internal action enum is wider than this — it also implements click, focus, blur and navigate. Those are not part of this contract and the model should not emit them.) + +--- + +## 4. refKeys, and what `value` means for each + +| refKey | Addresses | `value` is | +| -------------------------------- | ------------------------------------------------------------- | ----------------------- | +| `` | a content field of the selected item, keyed by its field name | the new field value | +| `meta-title`, `meta-description` | the item's SEO fields | the new value | +| `code-editor` | the open file in the code app | the entire new file | +| `view:` _(Studio)_ | a view file (HTML/Parsley template) | **the entire new file** | +| `stylesheet:` _(Studio)_ | an instance stylesheet | **the entire new file** | +| `script:` _(Studio)_ | an instance script | **the entire new file** | + +The three Studio file refKeys behave exactly like `code-editor`, which is also a whole-file replacement. + +**Both families are addressable at once.** In Studio a request can carry content-field refKeys and file refKeys together, and a single response may write to both. + +**Only write to a file refKey that arrived in `sources`.** Studio fetches views today and nothing else, so in the first release `sources` carries views only and `stylesheet:` / `script:` will be absent until the app wires those file types in. Treat `sources` as the list of what exists, exactly as `registryKeys` is for fields. + +**Never infer a refKey.** Use what arrived in `registryKeys`, and respect `capabilities`. Not every field of an item is addressable: eleven datatypes are deliberately excluded — `uuid`, `files`, `internal_link`, `one_to_one`, `one_to_many`, `block_selector`, `yes_no`, `dropdown`, `date`, `datetime`, `integration`. + +Content-field refKeys are **bare field names**, so if two items on a page both have `title`, only one `title` refKey exists and it is whichever registered last. The `ZUID` and `contentModelZUID` inside that refKey's `refRegistry` entry tell you which item you actually got; if that is not the one the user meant, say so in a `SYSTEM_OUTPUT` rather than writing to it. + +--- + +## 5. What a Studio user can ask for, and what the model should emit + +| The user wants | Emit | Against | +| ---------------------------------------------------- | --------------- | ------------------------------------------------------------------------ | +| Rewrite this heading / paragraph / copy | `SET_VALUE` | the field refKey, or the view file when the text is static | +| Change SEO title or description | `SET_VALUE` | `meta-title` / `meta-description` | +| Generate or replace an image | `SET_VALUE` | the media field refKey; `value` is the DAM file ZUID, which begins `3-` | +| Restyle this element | `SET_VALUE` | the **view file** — a page-scoped `