playbook-syntax-reconciliation.md40.7 KBView on GitHub # Playbook Syntax Reconciliation — Frontend Tool Notation ↔ Backend Markdown Grammar
## 1) Introduction — goal, present state, future state
We want one playbook syntax: an editor whose on-disk representation is byte-for-byte a markdown grammar the server parses to drive routing and execution. Today there are two divergent implementations that landed on separate branches and were just merged — our frontend "tool notation" mock ([apps/mail/modules/documents/playbook/](apps/mail/modules/documents/playbook/)) renders custom ProseMirror nodes (`#trigger` callouts, `@`-namespace chips, `#slack` integration chips) that persist as opaque Y.js node JSON and never serialize to any grammar the server understands, while the cofounder's backend ([apps/server/src/services/playbook/](apps/server/src/services/playbook/)) is the execution source of truth and reads a strict markdown grammar (`[on:trigger]…[/on]`, `@subagents/crm-updater:`, six reserved `##` headings, `## Stage: {id} — {Label}`, `[entry]`/`[exit]`) out of the document `content` column. This design converges the two onto a single canonical grammar, with two deliberate evolutions of the *reference* notation decided during review (see §1.1): every navigable subdocument reference and entity becomes a `[[ ]]` wikilink token — matching the existing `file-link` node's `[[doc:<uuid>]]` convention ([markdown-bridge.ts](apps/mail/modules/documents/file-link/markdown-bridge.ts)) — and Slack integration chips are **kept and formalized** (not dropped) as `[[slack:…]]` tokens carrying both id and label. It replaces the mock's ad-hoc node model with ProseMirror nodes whose markdown serializer/parser **is** that grammar, teaches the backend reference-resolver to parse `[[ ]]` (accepting the legacy bare `@…` form during transition), and adds the structural enforcement the grammar requires — locked reserved sections, a forced `## Stage:` header with a non-editable `stageId` badge, and non-removable crm-updater / next-steps subagent rows (`[[doc:<uuid>]]:`) inside every `[on:email]` and `[on:meeting]` block — so a saved document is always valid input to `parsePlaybookManifest` and `getPlaybookSection`.
### 1.1 Canonical syntax decisions (this revision)
The persisted form is the markdown `content` column; the editor serializes ProseMirror → markdown on every keystroke via `MarkdownEditor.getMarkdown()` ([document.tsx:62](apps/mail/modules/documents/document.tsx)). **A node that cannot express itself as a markdown token does not exist server-side.** The canonical token set:
| Construct | Canonical token | Notes |
|---|---|---|
| Trigger block | `[on:email]…[/on]` | Unchanged block grammar; `[/on]` required. |
| Stage | `## Stage: {id} — {Label}` | Unchanged. |
| Reserved heading | `## The agent sees these every time` (×6) | Unchanged, exact text. |
| Entry / exit | `[entry]…[/entry]`, `[exit]…[/exit]` | Unchanged. |
| Any doc ref | `[[doc:<uuid>]]`, anchor `[[doc:<uuid>#discovery]]`, optional trailing instruction `[[doc:<uuid>]]: focus on budget` | **One token for every Cedar doc** — resource, KB, custom subagent, **and the crm-updater / next-steps system subagents**. uuid is the only identity. How it's treated (inject content vs. dispatch a subagent) and rendered (file chip vs. agent chip vs. locked CRM-Updater row) is a function of the **resolved doc**, not the token. |
| Slack integration | `[[slack:channel:C0123ABCD\|#deals-acme]]`, `[[slack:dm:U0456DEF\|@jane]]` | id + label inline (no Cedar doc to load from; external API). |
**Uniform shape:** `[[<kind>:<id>]]` — this is **not a new format**. The backend already defines a first-class Cedar Docs inline-reference syntax: `CEDAR_DOC_REFERENCE_TYPE` (`doc`/`conversation`/`task`/`event`/`person`/`company`/`draft`/`user`/`field`) and `CEDAR_DOC_REFERENCE_REGEX = /\[\[(\w+):\s*([^\]]+)\]\]/g` ([document-types.ts:90-106](apps/server/src/services/documents/document-types.ts)), already emitted by the wiki tool (`[[doc: id]]`) and tracked by the save-diff layer (`DOC_LINK_REGEX`, [document-saving/diff.ts:26](apps/server/src/services/document-saving/diff.ts)); the frontend `file-link` node ([markdown-bridge.ts](apps/mail/modules/documents/file-link/markdown-bridge.ts)) renders the same token. So playbook refs **adopt the existing `[[doc:uuid]]` convention** rather than invent one — and `kind = slack` slots into the same `[[type: rest]]` grammar (`[[slack: channel:id|label]]`), optionally as a new `CEDAR_DOC_REFERENCE_TYPE`. `[…]` / `##` remain the block/heading grammar.
Three mechanics this implies: **(a) the stored md is always `[[doc:uuid]]`; "hydration" is render-time, never a storage rewrite.** The frontend `@` menu picks a doc → `getDoc` → inserts `[[doc:uuid]]` *directly*, so content is born canonical — there is no save-time rewrite and no `content`↔`content_yjs` divergence. Each consumer just **renders** the same stored token differently, in-memory: the editor `docRef` NodeView → `getDoc(uuid)` → title chip; the backend resolver → resolved content / `@subagents/…` for the orchestrator prompt; the agent read-document response → optional `@ns/path` for LLM comprehension. The **only** write-transform is the LLM authoring `@resources/x` via the `account-config` skill (it can't know uuids) — a separate write-document path, optional since the resolver accepts both forms. **(b) Semantics resolve live, not from a cached label** — system-token role (crm-updater/next-steps), scope, and broken-state come from `getDoc(uuid)`; `parsePlaybookManifest` is unaffected because it only scans `[on:]` tags, never refs. **(c) Doc ids are already stable** — `writeDocument`/`seedPlaybook` upsert by path and update the same row ([services/documents/index.ts:348](apps/server/src/services/documents/index.ts)), so reseeding a path keeps its id and `[[doc:uuid]]` refs survive. (The mock's "fresh row on reseed", [playbook-implementation-state.md](apps/mail/docs/playbook-implementation-state.md) §5, was a manual demo hack, not app behavior.)
## 2) Present state
### 2.1 Architecture diagram
```text
── OUR FRONTEND MOCK (apps/mail/modules/documents/playbook) ──
# menu ─► triggerNode {config: JSON} @ menu ─► referenceNode {path}
# menu ─► integrationNode {kind: slack} (namespaces: resources,
(TriggerConfig from agent-config editor) knowledge-base, org,
│ subagents, crm-updater,
▼ next-steps, other)
prosemirrorJsonToYDoc ─► Y.XmlElement (opaque) │
│ ▼
│ ✗ NO markdown serializer resolveReferencePath → flat
▼ user/playbooks/{path} ✗ no aopId
documents.content = "" / best-effort ──────────────────────────────┐
│
── COFOUNDER FRONTEND (playground/components/PlaybookEditor.tsx) ── │
<Document> plain Tiptap (NO custom nodes) ─► forceFlush() │
└─► trpc.admin.documents.writeDocument ──────────────┐ │
▼ ▼
── BACKEND (source of truth) ───────────────────────────────────────────
writeDocument (services/documents/index.ts:384)
└─► parsePlaybookManifest(content) ─► documents.metadata.playbook_manifest
EXECUTION: getPlaybookSection ─► parsePlaybookTriggerBlocks(content,event,stage)
└─► resolvePlaybookReferences ─► <always_loaded_context>/<stage_block> XML
(reads the markdown grammar: [on:..]…[/on], @subagents/.., ## Stage:)
```
### 2.2 Step-by-step walkthrough
1. **Insert a trigger via the `#` menu** — `onHashMentionSelect` at [playbookExtensions.ts:22](apps/mail/modules/documents/playbook/playbookExtensions.ts)
- Inserts a `triggerNode` whose only attr is a stringified `TriggerConfig` borrowed from the agent-config editor (`DEFAULT_TRIGGER_CONFIG = { type: 'event_occurred' }`, [TriggerNode.tsx:24](apps/mail/modules/documents/playbook/TriggerNode.tsx)).
- The config model is an object, not the backend's tag grammar:
```ts
// TriggerConfig (frontend) — TriggerConfigEditor.tsx
{ type: 'event_occurred', eventTypes?: ('email'|'meeting'|'slack'|'external_crm'|...)[] }
| { type: 'before_meeting', minutes }
| { type: 'cron', expression }
| { type: 'conversation_change', ... }
```
- Node JSON after insert:
```json
{ "type": "triggerNode", "attrs": { "config": "{\"type\":\"event_occurred\"}" },
"content": [{ "type": "paragraph" }] }
```
2. **Insert a reference via the `@` menu** — `onReferenceSelect` at [playbookExtensions.ts:51](apps/mail/modules/documents/playbook/playbookExtensions.ts) inserts `referenceNode { path }`. Namespaces are resolved by `namespaceForPath` at [references.ts:60](apps/mail/modules/documents/playbook/references.ts): `crm-updater`, `next-steps`, `knowledge-base`, `resources`, `org`, `subagents`, `other`. Note `crm-updater`/`next-steps` are **top-level** namespaces (`@crm-updater`), not `@subagents/crm-updater`.
- `resolveReferencePath` at [references.ts:87](apps/mail/modules/documents/playbook/references.ts) maps every ref to a **flat, aopId-less** path and drops the `#section` anchor:
```ts
resolveReferencePath('resources/templates#discovery-post-demo')
// → 'user/playbooks/resources/templates' ✗ no {aopId}, anchor lost
```
3. **Insert `#slack` / `#imessage` / `#mcp`** — `onHashMentionSelect` inserts an `integrationNode { kind, label }` ([playbookExtensions.ts:40](apps/mail/modules/documents/playbook/playbookExtensions.ts)). **The backend grammar has no integration notation** — these have no parser, no manifest field, no execution meaning.
4. **Persistence** — `prosemirrorJsonToYDoc` (`document-saving/hydrate.ts`) maps any unknown node type to a `Y.XmlElement` so `triggerNode`/`referenceNode`/`integrationNode` round-trip into the editor. No playbook node defines a markdown serializer, so the markdown `content` column is empty / best-effort. The Y.js blob is the only source of truth — opaque to the server.
5. **Cofounder save path** — `PlaybookEditor.tsx` ([apps/mail/app/(routes)/playground/components/PlaybookEditor.tsx](apps/mail/app/(routes)/playground/components/PlaybookEditor.tsx)) uses the plain `<Document>` (no custom nodes), then on save calls `docRef.forceFlush()` + `trpc.admin.documents.writeDocument`. This path expects the `content` column to already be valid backend markdown.
6. **Manifest computation (write time)** — `writeDocument` at [services/documents/index.ts:384](apps/server/src/services/documents/index.ts) calls `parsePlaybookManifest(finalContent)` for any `playbook` doc and stores the result in `metadata.playbook_manifest`. The parser ([trigger-parser.ts:111](apps/server/src/services/playbook/trigger-parser.ts)) scans the text with `TAG_REGEX = /^\s*\[on:([^\]]+)\]/gm` — it sees the `[on:…]` markdown, not node JSON.
- Manifest shape ([trigger-parser.ts:41](apps/server/src/services/playbook/trigger-parser.ts)):
```json
{ "version": 1, "hasGlobalBlock": true, "eventTypes": [{"type":"email"}],
"fieldWatchers": [{"field":"stage","toValue":"won"}],
"dailyCron": {"enabled":true,"expression":"0 7 * * 1-5","timezone":"UTC"},
"beforeMeeting": {"enabled":true,"minutes":60}, "selectionProcedure": null }
```
- With our mock's opaque content, this parse yields an **empty manifest** → routing/cron/field-watchers never register.
7. **Execution read path** — `getPlaybookSection` ([get-playbook-section.ts:42](apps/server/src/services/playbook/get-playbook-section.ts)) reads the `content` column, runs `parsePlaybookTriggerBlocks(content, eventType, currentStage)` ([trigger-parser.ts:247](apps/server/src/services/playbook/trigger-parser.ts)), then `resolvePlaybookReferences` / `resolveAlwaysLoadedContext` ([reference-resolver.ts](apps/server/src/services/playbook/reference-resolver.ts)) to fetch `@resources/…` bodies, and assembles XML:
```xml
<always_loaded_context>…</always_loaded_context>
<cross_cutting_rules>…IGNORE rules…</cross_cutting_rules>
<stage_block>…[on:email] body for current stage…</stage_block>
```
- `parsePlaybookTriggerBlocks` keys entirely off the markdown grammar: `## Stage: {id} —` boundaries ([trigger-parser.ts:360](apps/server/src/services/playbook/trigger-parser.ts)), `[on:tag]…[/on]` blocks ([trigger-parser.ts:394](apps/server/src/services/playbook/trigger-parser.ts)), `[entry]`/`[exit]` ([trigger-parser.ts:334](apps/server/src/services/playbook/trigger-parser.ts)). None of our mock's structures (callouts, chips, blockCallout exit criteria) emit this grammar.
**Net present-state gap:** the mock renders a *different language* (`#trigger` + opaque config, `@`-namespace chips, `#slack` chips, flat paths) than the one the server parses (`[on:…]…[/on]`, `@subagents/…:`, reserved headings, `## Stage:`, `[entry]`/`[exit]`, `{aopId}`-scoped paths). The two never meet because the mock has no markdown serializer.
### 2.3 Syntax diff — mock vs canonical (the core breakdown)
| Concept | Frontend mock (today) | Backend canonical (source of truth) | Authority |
|---|---|---|---|
| Trigger token | `#trigger` callout → `triggerNode` | `[on:…]` line, closed by `[/on]` | [trigger-parser.ts:102](apps/server/src/services/playbook/trigger-parser.ts) |
| Trigger model | opaque `TriggerConfig` JSON in one attr | `any` · `email` · `meeting` · `slack` · `crm-sync` · `before-meeting: N` · `cron: expr [tz]` · `field-change: f → v` | [playbook-anatomy.md](apps/server/.claude/skills/account-config/playbook-anatomy.md) |
| Conditional | none | `[on:email if:deal_value>50000]`, ops `= != > < >= <= isSet isNotSet` | [trigger-parser.ts:211](apps/server/src/services/playbook/trigger-parser.ts) |
| Block close | none (container node) | **explicit `[/on]` required** | [trigger-parser.ts:404](apps/server/src/services/playbook/trigger-parser.ts) |
| Stages | none (flat doc) | `## Stage: {id} — {Label}`, `{id}` must match `conversation.status` | [trigger-parser.ts:360](apps/server/src/services/playbook/trigger-parser.ts) |
| Reserved sections | none | 6 exact `##` headings (Selection procedure, The agent sees…, …reference these…, Org-level processes, At any point…, On demand) | playbook-anatomy.md |
| Entry/exit | `blockCallout` "Exit criteria" + checkbox `taskList` | `[entry]…[/entry]` / `[exit]…[/exit]` | [trigger-parser.ts:323](apps/server/src/services/playbook/trigger-parser.ts) |
| CRM/next-steps | top-level `@crm-updater` / `@next-steps`, inert | `@subagents/crm-updater:` / `@subagents/next-steps:` (trailing `:`, instruction after), forced rows | playbook-anatomy.md "System tokens" |
| Resource ref | `@resources/x`, flat `user/playbooks/x` | `@resources/x`, `@org/resources/x`, `@knowledge-base/x` → `user/playbooks/{aopId}/resources/x.md` | [reference-resolver.ts](apps/server/src/services/playbook/reference-resolver.ts) |
| Section anchor | `#section` stripped on open | `@resources/templates#slug` reads only the `## slug` heading | playbook-anatomy.md "Resource ref syntax" |
| Subagent ref | `@subagents/x` chip | `@subagents/x` / `@org/subagents/x`, optional `: instruction` | playbook-anatomy.md |
| Integrations | `#slack` / `#imessage` / `#mcp` chips | **no notation — unsupported** | (absent from parser) |
| Path scope | flat `user/playbooks/{ref}` | `user/playbooks/{aopId}/…`, `org/playbooks/{orgAopId}/…` | [manifest-utils.ts:16](apps/server/src/services/playbook/manifest-utils.ts) |
| Storage of truth | Y.js blob (opaque nodes) | markdown `content` column + `metadata.playbook_manifest` | [services/documents/index.ts:384](apps/server/src/services/documents/index.ts) |
> The "Backend canonical" column above is the grammar **as it exists on the merged branch today**. This revision evolves the *reference* rows per §1.1: the bare `@resources/…`/`@subagents/…:` becomes the **authoring** form, while the **stored** form is `[[doc:<uuid>]]` (uuid is the only identity; name loaded live); the "Integrations — unsupported" row is reversed (formalized as `[[slack:…]]`). The trigger/stage/reserved/entry-exit rows are unchanged.
## 3) Designed state
### 3.1 Architecture diagram
```text
── UNIFIED PLAYBOOK EDITOR (apps/mail/modules/documents/playbook) ──
[ menu ─► triggerBlock {trigger,args} ── serialize ─► "[on:email]\n…\n[/on]"
@ menu ─► docRef {uuid, anchor, instr?} ── serialize ─► "[[doc:<uuid>#slug]]" / "[[doc:<uuid>]]: instr"
slackToken {kind,id,label} ── serialize ─► "[[slack:channel:C0123|#deals-acme]]"
(badge + role rendered from live getDoc(uuid) → resolved doc path)
"+ stage" ─► stageSection {stageId,label} ─► "## Stage: id — Label"
reservedSection (locked) ─────────────────► "## The agent sees these every time"
[entry]/[exit] block nodes ───────────────► "[entry]…[/entry]"
│
│ markdownSerializer (NEW) ── canonical grammar ─┐
▼ ▼
Y.js blob ◄── markdownParser (NEW) ◄──── documents.content = CANONICAL markdown
│ │
│ schema enforcement (hooks): │
│ • doc = heading reservedSection+ stageSection* │
│ • stageId badge non-editable │
│ • crm-updater/next-steps rows non-removable │
▼ in [on:email]/[on:meeting] ▼
explicit Save ─► forceFlush() ─► trpc.admin.documents.writeDocument
(frontend wrote [[doc:uuid]] directly — nothing to linkify)
│
── BACKEND (stored md unchanged; consumers RENDER it) ──▼────────────
parsePlaybookManifest ─► metadata.playbook_manifest (+ integrations[]) ✓
reference-resolver: hydrateDocRefs [[doc:uuid]]→@ns/path (in-memory) ─► resolve ✅
getPlaybookSection ─► parsePlaybookTriggerBlocks ─► XML ✓ grammar matches
agent read-document: render [[doc:uuid]]→@ns/path (in-memory, optional)
slack send-resolution reads [[slack:…]] id at execution time
```
### 3.2 Step-by-step walkthrough
1. **Canonical node schemas** — new module `apps/mail/modules/documents/playbook/grammar/nodes.ts`. The nodes replace the mock's three, each with a markdown serializer/parser matching the canonical grammar of §1.1:
- `triggerBlock { trigger: string, args: string | null }`, `content: 'block+'`, `defining: true` → `[on:{trigger}{: args}]\n{content}\n[/on]\n` (always emit `[/on]`).
- `stageSection { stageId: string, label: string }` → `## Stage: {stageId} — {label}\n{content}`.
- `reservedSection { heading: string, subtitle: string }` → `## {heading}\n*{subtitle}*\n\n{content}`.
- `docRef { uuid: string, anchor: string|null, instruction: string|null }` (inline atom) → `[[doc:{uuid}#{anchor}]]` or, when an instruction is present, `[[doc:{uuid}]]: {instruction}`. **One node for every doc ref.** A live `getDoc(uuid)` supplies the badge name, scope icon, and broken-state; the NodeView **renders by the resolved doc's path** — `…/resources/*` → file chip, `…/subagents/{name}` → agent chip, `…/subagents/crm-updater` → locked "CRM Updater" row + field-config opener, `…/subagents/next-steps` → locked "Next Steps" row. No separate node or stored role; crm-updater/next-steps are just subagent docs rendered differently.
- `slackToken { kind: 'channel'|'dm', id: string, label: string }` (inline atom) → `[[slack:{kind}:{id}|{label}]]` — id and label **both stored inline** (no Cedar doc to load from). (Replaces the mock `integrationNode`; `imessage`/`mcp` deferred until they have a backend.)
- `entryBlock` / `exitBlock` (`content: 'block+'`) → `[entry]…[/entry]` / `[exit]…[/exit]`.
- The `doc:`/`slack:` tokens share one tokenizer/serializer (`grammar/wikilink.ts`); the `doc:` form is the existing [markdown-bridge.ts](apps/mail/modules/documents/file-link/markdown-bridge.ts) `[[doc:<uuid>]]` pattern verbatim — playbook doc-refs and file-links are the same node.
2. **Markdown serializer** — `grammar/serialize.ts` walks the PM doc → canonical markdown. Round-trip target: feeding `seed-playbook.ts`'s example back through parse→serialize is idempotent.
- Example node → text:
```text
triggerBlock{trigger:'meeting'} > [ docRef{uuid:'1b9c…',instruction:'create Slack task'},
slackToken{kind:'channel',id:'C0123',label:'#deals-acme'} ]
⇒ "[on:meeting]\n[[doc:1b9c…]]: create Slack task\n[[slack:channel:C0123|#deals-acme]]\n[/on]\n"
```
3. **Markdown parser** — `grammar/parse.ts` (Tiptap input + `Document` load path) tokenizes canonical markdown → PM JSON, in the precedence the backend doc fixes ([playbook-editor.md §5](apps/mail/docs/playbook-editor.md)): H1 title → reserved headings (exact) → `## Stage:` → `[on:…]` open / `[/on]` close → `[entry]`/`[exit]` → `[[ ]]` tokens (`[[doc:uuid…]]` → `docRef`, with an optional trailing `: instruction`; `[[slack:…]]` → `slackToken`). The tokenizer also accepts legacy bare `@ns/path` (uuid filled in at next save) so pre-migration content still parses.
- Parsing `## Stage: 1_discovery — Discovery / Demo`:
```json
{ "type": "stageSection", "attrs": { "stageId": "1_discovery", "label": "Discovery / Demo" }, "content": [] }
```
4. **Suggestion menus realigned** — `[` opens the trigger picker (replacing `#trigger`); `@` opens the entity menu. `references.ts` rewritten so:
- Picking a resource/subagent/KB entry resolves its path → `getDoc` (find-or-create) → inserts `docRef { uuid }`; the NodeView renders by the resolved doc's path (file chip / agent chip / CRM-Updater row).
- The two system tokens are offered as crm-updater / next-steps entries (resolve to their seeded subagent docs' uuids).
- **Slack** opens a searchable combobox popover listing the connected workspace's targets — `Cedar Mail DM (you)` first, then channels and user DMs — with type-ahead filter; selecting one inserts `slackToken { kind, id, label }`. Source: a `trpc.integrations.slack.listChannels` endpoint (new if absent); the picker also re-opens when an existing Slack chip is clicked, to re-bind.
- `integrationNode` is replaced by `slackToken` (kept); `imessage`/`mcp` menu entries are dropped until they have a backend.
5. **Structural enforcement (the "hooks")** — schema + ProseMirror plugins make invalid documents unrepresentable:
- **Doc shape**: `doc.content = 'heading reservedSection+ stageSection*'`. The six `reservedSection` nodes are seeded and cannot be deleted or reordered; their `heading` text is non-editable (a `filterTransaction` plugin rejects edits to the heading range).
- **Forced stage header**: `## Stage:` is only ever a `stageSection` node; `stageId` renders as a locked badge (non-editable), `label` is contenteditable. A new stage is created only via "+ Add stage" → picks an unused `conversationFieldDefinitions.status.options` value → emits `stageSection` pre-populated with `[on:email]` and `[on:meeting]` blocks.
- **Forced system-token rows**: when an `[on:email]` or `[on:meeting]` `triggerBlock` is created (or parsed without them), an `appendTransaction` plugin guarantees the AOP's crm-updater and next-steps subagent rows exist (their uuids fetched once per AOP) and blocks their deletion from those two trigger types. Blank instruction is valid (serializes as trailing `:`).
- **Stage id validation**: a decoration shows a yellow badge when `stageId` ∉ `status.options` (data from `trpc.aop.listForUser`).
6. **Backend: resolve `[[doc:uuid]]` natively; render, don't rewrite** — the stored `content` is never mutated. `resolvePlaybookReferences` ([reference-resolver.ts:56](apps/server/src/services/playbook/reference-resolver.ts)) runs a `hydrateDocRefs` pre-pass that maps `[[doc:uuid]]` → `@ns/path` **in-memory**, then the existing `@`-ref logic resolves it (resource → content; subagent → `@subagents/{name}:` dispatch directive); legacy `@ns/path` still accepted (✅ shipped). The agent read-document response optionally renders `[[doc:uuid]]` → `@ns/path` for LLM comprehension (in-memory). `parsePlaybookManifest` optionally surfaces `[[slack:…]]` as `integrations[]`; execution send-resolution reads the id. `seed-playbook.ts` / [playbook-anatomy.md](apps/server/.claude/skills/account-config/playbook-anatomy.md) / `playbook-execution` keep **path-syntax** examples (the authoring form), noting the stored form is `[[doc:uuid]]`.
7. **Open wiring** — `onOpenReference` opens a `docRef` directly by its `uuid`; when the resolved path is the crm-updater subagent it opens the combined instruction+field-config view `readCrmUpdaterDocument` ([playbook-doc-hooks.ts:46](apps/server/src/services/playbook/playbook-doc-hooks.ts)) returns. `[[slack:…]]` opens the channel combobox (re-bind), not a doc. A `docRef` whose `getDoc(uuid)` 404s renders a red chip + quick-create.
8. **Save → manifest** — explicit Save (`forceFlush()` + `trpc.admin.documents.writeDocument`) writes **canonical markdown**, so `parsePlaybookManifest` ([services/documents/index.ts:384](apps/server/src/services/documents/index.ts)) yields a populated manifest (now incl. `integrations[]`) and `getPlaybookSection` reads grammar it understands. The manifest preview already consumes `metadata.playbook_manifest`.
## 4) Implementation phases
> **Migration surface (read first).** The **stored** form is always `[[doc:<uuid>]]`; every surface just **renders** it (no storage round-trips — see §1.1a). This touches already-shipped backend assets: [reference-resolver.ts](apps/server/src/services/playbook/reference-resolver.ts) (+475-line test), [seed-playbook.ts](apps/server/src/services/playbook/seed-playbook.ts), [playbook-anatomy.md](apps/server/.claude/skills/account-config/playbook-anatomy.md), the `playbook-execution` skill, and live demo docs. Doc ids are **already stable** (upsert by path), so `[[doc:uuid]]` needs no reseed change. The strategy that keeps every phase green: the resolver **resolves `[[doc:uuid]]` natively and still accepts legacy `@ns/path`** (✅ shipped, [b78cd24b](apps/server/src/services/playbook/reference-resolver.ts)); the frontend authors uuid directly; the agent read/write path optionally renders/canonicalizes `@ns/path`. A one-time migration (Phase 7) rewrites any remaining `@`-form stored docs to uuid. No flag day.
### Phase 1 — Canonical grammar core (parse/serialize, no UI change)
**Goal:** A standalone module converting between canonical playbook markdown and normalized node JSON, proven idempotent against the backend's seed fixtures.
- [ ] Create `apps/mail/modules/documents/playbook/grammar/tokens.ts` — block/heading regexes mirrored from [trigger-parser.ts:102](apps/server/src/services/playbook/trigger-parser.ts) (`TAG_REGEX`, `IF_CLAUSE_REGEX`, `## Stage:`, reserved headings, `[/on]`, `[entry]`/`[exit]`).
- [ ] Create `grammar/wikilink.ts` — the `[[ ]]` entity tokenizer/serializer, extended from [markdown-bridge.ts](apps/mail/modules/documents/file-link/markdown-bridge.ts): parses `[[doc:{uuid}#{anchor}]]` with an optional trailing `: instruction` → `docRef`, and `[[slack:{kind}:{id}|{label}]]` → `slackToken`; **also** parses legacy `@ns/path` into a `docRef` (resolving path → uuid via `getDoc`) so pre-migration content renders. New inserts always serialize `[[doc:uuid]]`.
- [ ] Create `grammar/serialize.ts` — node JSON → canonical markdown for `triggerBlock`, `stageSection`, `reservedSection`, `docRef`, `slackToken`, `entryBlock`, `exitBlock`; always emit `[/on]`, the trailing `:` when a `docRef` has an instruction, `[[doc:uuid]]`, and `[[slack:…|label]]` with both id and label.
- [ ] Create `grammar/parse.ts` — canonical markdown → node JSON, precedence per §3.2 step 3; `[/on]`/`[/entry]`/`[/exit]` consumed, not emitted.
- [ ] Add `grammar/types.ts` with node-attr types (mirror `EventTypeEntry`/`IfClause` from [trigger-parser.ts:12](apps/server/src/services/playbook/trigger-parser.ts) where overlapping).
**Tests:**
- [ ] `grammar/__tests__/roundtrip.test.ts` — `serialize(parse(md)) === md` for triggers, stages, reserved sections, `[[doc:uuid]]` refs (incl. anchor), system tokens, slack tokens, entry/exit, against a fixture lifted from `seed-playbook.ts`.
- [ ] `grammar/__tests__/legacy-accept.test.ts` — `parse('@resources/x')` and `parse('[[doc:uuid]]')` both yield a `docRef`; serialize always emits the `[[doc:uuid]]` form.
- [ ] `pnpm --filter @cedar/mail test apps/mail/modules/documents/playbook/grammar`
### Phase 2 — Backend: native `[[doc:uuid]]` resolution (✅ shipped) + agent read/write rendering
**Goal:** The server resolves `[[doc:uuid]]` everywhere with zero regression — legacy `@` still resolves, `[[doc:uuid]]` resolves identically (including the execution path), and `[[slack:…]]` is handled. No storage round-trips: hydration is render-time (§1.1a).
> **Verified scope (whole-service read).** Reuse `CEDAR_DOC_REFERENCE_REGEX` ([document-types.ts:106](apps/server/src/services/documents/document-types.ts)). **Critical:** the execution path reads content via a **direct `db.select`** (`readPlaybookContent` [get-playbook-section.ts:213](apps/server/src/services/playbook/get-playbook-section.ts), `readDocumentContent` [reference-resolver.ts:239](apps/server/src/services/playbook/reference-resolver.ts)) — it does **not** pass through the `readDocument` hook. So `resolvePlaybookReferences` must **natively resolve** `[[doc:uuid]]`. `parsePlaybookManifest` ([trigger-parser.ts:111](apps/server/src/services/playbook/trigger-parser.ts)) and `parsePlaybookTriggerBlocks` need **no change** (they scan `[on:]`/blocks, never refs).
- [x] Add `docPathToConventionRef(path)` (uuid's path → `@ns/name`) to [convention-paths.ts](apps/server/src/services/documents/convention-paths.ts).
- [x] **Native resolution (uniform by resolved doc type, no token special-case)** — `hydrateDocRefs` pre-pass in `resolvePlaybookReferences` ([reference-resolver.ts:56](apps/server/src/services/playbook/reference-resolver.ts)) maps `[[doc:uuid(#sec)?]]` → `@ns/path` (in-memory) so the existing `@`-ref logic runs uniformly: resource/kb → inject content; subagent → `@subagents/{name}:` dispatch directive the orchestrator already reads. crm-updater/next-steps fall out naturally — just seeded subagent docs, `[[doc:uuid]]: focus` → `@subagents/crm-updater: focus`. Legacy `@ns/path` still accepted.
- [ ] **Agent read rendering (optional)** — in the playbook read hook ([playbook-doc-hooks.ts](apps/server/src/services/playbook/playbook-doc-hooks.ts)) render `[[doc:uuid]]` → `@ns/path` in the read-document *response* (in-memory, not a stored rewrite) so the LLM sees readable refs.
- [ ] **Agent write canonicalization (optional)** — in the playbook write path, rewrite an LLM-authored `@ns/path` → `[[doc:uuid]]`. The frontend already authors uuid directly, so this only covers `account-config`-skill writes; optional since the resolver accepts `@`.
- [ ] Slack: handle `[[slack:channel:id|label]]` as a `[[type:rest]]` token (sibling of `doc:`); resolve the stored `id` to a Slack post at execution via the orchestrator's existing notification path (no live lookup). *(Open: surface it as a `manifest.integrations[]` entry for the "Posts to" preview — defer unless the preview needs it.)*
- [ ] Keep [seed-playbook.ts](apps/server/src/services/playbook/seed-playbook.ts) / [playbook-anatomy.md](apps/server/.claude/skills/account-config/playbook-anatomy.md) / `playbook-execution` examples in the **authoring** (`@ns/path`) form; note the stored form is `[[doc:uuid]]`.
**Tests:**
- [x] Extend [reference-resolver.test.ts](apps/server/src/services/playbook/__tests__/reference-resolver.test.ts) — `docPathToConventionRef` namespace mapping; `[[doc:uuid]]` → `@resources/name`; `#section` anchor preserved; `[[doc:uuid]]: focus` → `@subagents/crm-updater: focus`; unknown uuid left untouched. (66 pass.)
- [ ] Integration: a `[[doc:uuid]]` and the equivalent `@resources/x` resolve to identical injected content end-to-end.
- [ ] `pnpm --filter @cedar/server test apps/server/src/services/playbook`
### Phase 3 — Canonical ProseMirror nodes (replace mock nodes)
**Goal:** Tiptap nodes whose markdown hooks use the Phase-1 grammar, replacing `triggerNode`/`referenceNode`/`integrationNode`.
- [ ] Add `grammar/nodes/TriggerBlockNode.tsx` (`{ trigger, args }`, `content: 'block+'`, `defining: true`) — colored label per [playbook-editor.md §3.1](apps/mail/docs/playbook-editor.md).
- [ ] Add `grammar/nodes/StageSectionNode.tsx` (`{ stageId, label }`) — locked `stageId` badge + editable `label`.
- [ ] Add `grammar/nodes/ReservedSectionNode.tsx` (`{ heading, subtitle }`) — locked heading, editable subtitle, "Routing" badge for `Selection procedure`.
- [ ] Add `grammar/nodes/DocRefNode.tsx` (single `docRef` inline atom storing `{ uuid, anchor, instruction? }`; badge name/scope/broken-state from a live `getDoc(uuid)`; NodeView branches on the resolved path → file chip / agent chip / locked CRM-Updater + Next-Steps rows) and `grammar/nodes/SlackTokenNode.tsx` (`slackToken` chip showing `label`, stored id+label) — replacing `ReferenceNode.tsx`/`IntegrationNode.tsx`. Reuse the `file-link` tokenizer so playbook doc-refs and file-links are one node.
- [ ] Add `grammar/nodes/EntryExitNode.tsx` — `entryBlock` / `exitBlock` (replacing `BlockCalloutNode.tsx`).
- [ ] Delete [IntegrationNode.tsx](apps/mail/modules/documents/playbook/IntegrationNode.tsx), [TriggerNode.tsx](apps/mail/modules/documents/playbook/TriggerNode.tsx), [ReferenceNode.tsx](apps/mail/modules/documents/playbook/ReferenceNode.tsx), [BlockCalloutNode.tsx](apps/mail/modules/documents/playbook/BlockCalloutNode.tsx) and their imports from [playbookExtensions.ts](apps/mail/modules/documents/playbook/playbookExtensions.ts).
**Tests:**
- [ ] `grammar/__tests__/nodes-render.test.tsx` — each node mounts with fixture attrs; assert trigger label/color table, locked badge, slack chip shows `label`.
- [ ] `pnpm --filter @cedar/mail test apps/mail/modules/documents/playbook`
### Phase 4 — Suggestion menus + pickers
**Goal:** `[` trigger picker, `@` entity menu emitting `[[ ]]` tokens, and a Slack channel/DM picker.
- [ ] Rewrite [references.ts](apps/mail/modules/documents/playbook/references.ts): drop top-level `crm-updater`/`next-steps`/`other`; namespaces become `resources`/`org-resources`/`knowledge-base`/`subagents`/`org-subagents`/`slack`; `subpathToDocPath(subpath, aopId)` returns the find-or-create path.
- [ ] Replace the `#` hash-mention with a `[` trigger picker in [HashMention.ts](apps/mail/modules/documents/playbook/HashMention.ts) / [HashMentionList.tsx](apps/mail/modules/documents/playbook/HashMentionList.tsx): the 8 trigger tags + arg inputs (minutes / cron / field→value).
- [ ] Update [ReferenceMention.ts](apps/mail/modules/documents/playbook/ReferenceMention.ts) / [ReferenceMentionList.tsx](apps/mail/modules/documents/playbook/ReferenceMentionList.tsx): on select, resolve the chosen path → `getDoc` (find-or-create) → insert `docRef { uuid }`; add a **Slack** entry that opens the channel/DM combobox and inserts a `slackToken` with id+label.
- [ ] Rewrite the insert handlers in [playbookExtensions.ts](apps/mail/modules/documents/playbook/playbookExtensions.ts); remove the `integrationNode` branch, add the `slackToken` branch.
**Tests:**
- [ ] `references.test.ts` — `subpathToDocPath('resources/templates', aopId)` → `'user/playbooks/{aopId}/resources/templates.md'`; org + kb variants; inserting a menu entry yields a `docRef` with a non-null uuid.
- [ ] `pnpm --filter @cedar/mail test apps/mail/modules/documents/playbook`
### Phase 5 — Structural enforcement (the "hooks")
**Goal:** Schema + plugins make an invalid playbook unrepresentable — forced reserved sections, stage headers, system-token rows.
- [ ] Set `doc.content = 'heading reservedSection+ stageSection*'` in the playbook schema ([playbookExtensions.ts](apps/mail/modules/documents/playbook/playbookExtensions.ts)); seed the six reserved sections on empty docs.
- [ ] Add a `filterTransaction` plugin rejecting edits to `reservedSection.heading` and `stageSection.stageId` ranges (locked badges).
- [ ] Add an `appendTransaction` plugin that, for every `[on:email]` / `[on:meeting]` block, guarantees a `docRef` to the AOP's crm-updater and next-steps subagent docs exists (their uuids fetched once per AOP) and blocks their removal from those two trigger types.
- [ ] Add "+ Add stage" — picks an unused `status.options` value (`trpc.aop.listForUser`), inserts a `stageSection` pre-populated with `[on:email]`/`[on:meeting]` (each with the forced rows).
- [ ] Add stage-id validation decoration (yellow badge when `stageId` ∉ `status.options`).
**Tests:**
- [ ] `grammar/__tests__/enforcement.test.ts` — deleting a reserved heading / a forced crm-updater row is rejected; a new `[on:email]` block has both forced rows; serialize emits `[[doc:<uuid>]]:` even when the instruction is blank.
- [ ] `pnpm --filter @cedar/mail test apps/mail/modules/documents/playbook`
### Phase 6 — Save path, manifest preview, broken-ref indicators
**Goal:** Explicit-save semantics writing canonical markdown, with manifest preview (incl. integrations) and missing-reference UX.
- [ ] Point the playbook surfaces at canonical `user/playbooks/{aopId}/PLAYBOOK.md`: update [PlaybookDocument.tsx](apps/mail/modules/documents/playbook/PlaybookDocument.tsx) and the `CompanyExplorer` playbook branch to `{aopId}` scoping; reuse [PlaybookEditor.tsx](apps/mail/app/(routes)/playground/components/PlaybookEditor.tsx) save infra.
- [ ] Add the unsaved-changes indicator + Cmd+S + navigation guard ([playbook-editor.md §8](apps/mail/docs/playbook-editor.md)); keep save explicit (no autosave for `documentType: 'playbook'`).
- [ ] Render the manifest preview from `metadata.playbook_manifest`, including a "Posts to" list from `integrations[]`.
- [ ] Cross-reference each `docRef` by `getDoc(uuid)` → red (404 / missing) / orange (placeholder-only) chips + quick-create; `[[slack:…]]` whose id is no longer in the workspace → red "channel not found" chip.
**Tests:**
- [ ] Component test for `PlaybookDocument` save: insert a doc ref via the `@` menu, stub `writeDocument`, assert it receives content already containing `[[doc:<uuid>]]` (frontend authored it directly — no linkify).
- [ ] Manual verification (in the doc): edit `[on:email]` + add a `[[slack:…]]`, save, confirm `metadata.playbook_manifest.eventTypes` and `integrations[]` via the preview.
- [ ] `pnpm --filter @cedar/mail test apps/mail/modules/documents/playbook`
### Phase 7 — Migrate stored content + retire mock seed + reconcile docs
**Goal:** Linkify live `@`-notation playbooks to `[[doc:uuid]]`, drop the demo flat-layout seed, and leave one canonical stored syntax everywhere.
- [ ] Write a one-time migration that canonicalizes any remaining `@ns/path` in the `content` column of every `playbook` doc to `[[doc:<uuid>]]` (resolve path → uuid; system tokens included), recomputing the manifest on write. Idempotent (already-uuid content is a no-op). This is the only place stored content is rewritten — a deliberate one-time pass, not per-save.
- [ ] Replace the mock `playbook-seed.ts` / `playbook-resource-seeds.ts` flat `user/playbooks/{aop}` content with the canonical `{aopId}`-scoped output from [seed-playbook.ts](apps/server/src/services/playbook/seed-playbook.ts); delete the duplicate mock seed once superseded.
- [ ] Update [playbook-implementation-state.md](apps/mail/docs/playbook-implementation-state.md): mark `#trigger`/`@`-namespace/`#slack` notation removed; point at the `[[doc:uuid]]` grammar.
- [ ] Fold the accurate node specs into [playbook-editor.md](apps/mail/docs/playbook-editor.md) (single frontend spec); remove `playbook-doc.md` references to the superseded mock notation.
- [ ] Drop the legacy bare-`@` / path-only acceptance branch in `grammar/wikilink.ts` and reference-resolver only after the migration has run in all environments (follow-up checkbox; not required for this phase to ship).
- [ ] Run `pnpm deps:check`.
**Tests:**
- [ ] Migration unit test — a fixture doc with `@resources/x` + `@subagents/crm-updater:` becomes `[[doc:<uuid>]]` + `[[doc:<uuid>]]:`; running twice is stable.
- [ ] `pnpm --filter @cedar/server test apps/server/src/services/playbook`
- [ ] `pnpm --filter @cedar/mail test apps/mail/modules/documents/playbook`
- [ ] `pnpm types 2>&1 | grep "modules/documents/playbook"` returns no errors.