playbook-editor.md23.9 KBView on GitHub
# Playbook Editor — Custom ProseMirror Nodes

## 1. Current state

The PlaybookEditor uses the same `Document` component (`@/modules/documents/document`) as the rest of the app — Tiptap + Y.js, same provider registry, same live-sync infrastructure. Playground path: `apps/mail/app/(routes)/playground/components/PlaybookEditor.tsx`.

- **PLAYBOOK.md**: `Document` component with Y.js live sync. Save = `forceFlush()` + admin `writeDocument` (to recompute manifest + sync selection procedure to DB).
- **Subagent/resource files**: Also use `Document`. Save = `forceFlush()` only (Y.js handles content column sync; no manifest needed).
- **Frontmatter**: YAML frontmatter fields in subagent files use blank lines between each field so ProseMirror preserves them as separate paragraphs on round-trip.

The Y.js write path is intentionally consistent with the rest of the app. Custom ProseMirror nodes (documented below) are the next step to properly render playbook-specific syntax.

---

## 2. Playbook anatomy (ground truth)

Full syntax reference: `apps/server/.claude/skills/account-config/playbook-anatomy.md`

### Document structure (top to bottom)

```
# {AOP Name} Playbook                     ← H1 title (standard)

## Selection procedure                    ← Reserved heading (locked text, editable body)
Conversations where Cedar is selling...   ← Free text — synced to routing DB on save

## The agent sees these every time        ← Reserved heading (locked)
@resources/overall-goal                   ← ResourceRef inline node
@resources/email-style
@resources/coaching-framework
@org/resources/company-sop

## The agent can reference these...       ← Reserved heading (locked)
@resources/templates
@knowledge-base/company-background

## Org-level processes                    ← Reserved heading (locked)
@org/deals-playbook

## At any point in this deal              ← Reserved heading (locked)
[on:any]                                  ← TriggerBlock node (open)
Ignore: automated tools, OOO...
[/on]                                     ← TriggerBlock close (explicit)

[on:before-meeting: 30]
@subagents/meeting-prep
[/on]

[on:cron: 0 7 * * 1-5]
@subagents/daily-agenda
[/on]

## On demand                              ← Reserved heading (locked heading)
@subagents/bant-coach — BANT coaching    ← SubagentRef with description

## Stage: discovery_completed — Discovery Completed   ← StageSection node

Entry: First call booked
Exit: Prospect qualifies on BANT

[on:email]
@subagents/crm-updater: focus on need, champion   ← SubagentRef (system, with instruction)
@subagents/next-steps: nudge every 3 days         ← SubagentRef (system, with instruction)
[/on]

[on:meeting]
@subagents/crm-updater:
@subagents/next-steps: create Slack channel task
@subagents/bant-coach                             ← SubagentRef (custom)
[/on]

[on:field-change: stage → discovery_completed]
@subagents/crm-updater: focus on required fields
@subagents/next-steps: cancel pending follow-ups
[/on]
```

### `[/on]` closing delimiter

Every trigger block is closed with `[/on]` on its own line. The parser uses `[/on]` as the authoritative block boundary. If `[/on]` is absent (backward compat), the parser falls back to the next `[on:...]` or `##` heading.

**This is required by the frontend** — the callout renderer for each trigger block needs an explicit end boundary. Prose between blocks (notes, dividers) must not be swallowed into the preceding block.

---

## 3. Custom ProseMirror nodes

### 3.1 `triggerBlock` — block node

**Markdown syntax:**
```
[on:email]
content here
[/on]

[on:before-meeting: 30]
content here
[/on]

[on:cron: 0 7 * * 1-5 America/New_York]
content here
[/on]

[on:field-change: stage → won]
content here
[/on]

[on:any]
content here
[/on]
```

**What it is:** A container block bounded by `[on:tag]` … `[/on]`. Renders as a Notion-style callout. The `[/on]` is required in serialization — never omit it.

**Schema:**
```typescript
triggerBlock: {
  attrs: {
    trigger: string,   // 'email' | 'meeting' | 'slack' | 'crm-sync' | 'any' | 'before-meeting' | 'cron' | 'field-change'
    args: string | null,  // '30' | '0 7 * * 1-5' | 'stage → won' | null
  },
  content: 'block+',
  group: 'block',
  defining: true,
}
```

**Rendering:** Notion-style callout box. The trigger tag name renders as a colored label/chip at the top:

| Trigger | Color | Label |
|---|---|---|
| `any` | gray | Always |
| `email` | blue | On email |
| `meeting` | green | After meeting |
| `slack` | purple | On Slack |
| `crm-sync` | orange | On CRM sync |
| `before-meeting: N` | teal | `N` min before meeting |
| `cron: expr` | violet | `expr` (cron) |
| `field-change: f → v` | amber | `f` → `v` |

**Markdown serializer:**
```typescript
serializeTriggerBlock(state, node) {
  const args = node.attrs.args ? `: ${node.attrs.args}` : '';
  state.write(`[on:${node.attrs.trigger}${args}]\n`);
  state.renderContent(node);
  state.write(`[/on]\n`);  // ← always emit closing tag
}
```

**Markdown parser:**
1. Match `^\s*\[on:([^\]]+)\]` at line start → open block, parse trigger + args
2. Collect content until `^\s*\[\/on\]` (preferred) or next `[on:...]` / `##` (fallback)
3. `[/on]` is consumed and not emitted as content

---

### 3.2 `stageSection` — block node

**Markdown syntax:**
```
## Stage: discovery_completed — Discovery Completed
```

**Schema:**
```typescript
stageSection: {
  attrs: {
    stageId: string,   // must match conversation.status — non-editable, shown as locked badge
    label: string,     // display name — freely editable
  },
  content: 'block+',
  group: 'block',
}
```

**Rendering:** Section header with a non-editable `stageId` badge + editable `label` text + visual separator + content area.

**Markdown serializer:**
```typescript
serializeStageSection(state, node) {
  state.write(`## Stage: ${node.attrs.stageId} — ${node.attrs.label}\n`);
  state.renderContent(node);
}
```

---

### 3.3 `reservedSection` — block node

Six reserved headings whose text is locked (cannot be renamed):

```
## Selection procedure
## The agent sees these every time
## The agent can reference these whenever necessary
## Org-level processes
## At any point in this deal
## On demand
```

`## Selection procedure` is special: it has no trigger syntax, just free text. Its content is synced to `agent_operating_procedures.selection_procedure` in the DB on every save — editing it directly changes how Cedar routes new conversations to this AOP. Render it as a `reservedSection` node with a "Routing" badge to distinguish it from the other reserved sections.

**Schema:**
```typescript
reservedSection: {
  attrs: {
    heading: string,   // exact heading text — locked
    subtitle: string,  // italic description line — editable
  },
  content: 'block+',
  group: 'block',
}
```

**Rendering:** Locked heading text + editable italic subtitle + content.

**Markdown serializer:**
```typescript
serializeReservedSection(state, node) {
  state.write(`## ${node.attrs.heading}\n`);
  if (node.attrs.subtitle) state.write(`*${node.attrs.subtitle}*\n\n`);
  state.renderContent(node);
}
```

---

### 3.4 `resourceRef` — inline node

**Markdown syntax:**
```
@resources/overall-goal
@resources/templates#post-meeting-follow-up
@org/resources/company-sop
@knowledge-base/company-background
```

**Schema:**
```typescript
resourceRef: {
  attrs: {
    ref: string,              // 'overall-goal', 'company-background', etc.
    section: string | null,   // 'post-meeting-follow-up' (after #) or null
    scope: 'user' | 'org' | 'knowledge-base',
  },
  inline: true,
  atom: true,
  group: 'inline',
}
```

**Rendering:** Clickable chip/pill with scope icon (user / globe / book). Clicking opens the referenced file. Show broken indicator if file doesn't exist.

**Markdown serializer:**
```typescript
serializeResourceRef(state, node) {
  const prefix = node.attrs.scope === 'org' ? '@org/resources'
    : node.attrs.scope === 'knowledge-base' ? '@knowledge-base'
    : '@resources';
  const section = node.attrs.section ? `#${node.attrs.section}` : '';
  state.write(`${prefix}/${node.attrs.ref}${section}`);
}
```

---

### 3.5 `subagentRef` — inline node

All subagent invocations use the `@subagents/` prefix uniformly — including `crm-updater` and `next-steps`. There are no bare `@crm-updater:` tokens.

**Markdown syntax:**
```
@subagents/crm-updater:                          ← system, no instruction (blank = base behavior)
@subagents/crm-updater: focus on need, champion  ← system, with instruction
@subagents/next-steps:                           ← system, no instruction
@subagents/next-steps: create Slack channel task ← system, with instruction
@subagents/bant-coach                            ← custom subagent, no instruction
@subagents/bant-coach: focus on budget           ← custom subagent, with instruction
@org/subagents/deal-review                       ← org-level subagent
```

**Schema:**
```typescript
subagentRef: {
  attrs: {
    type: 'subagent' | 'org-subagent' | 'crm-updater' | 'next-steps',
    name: string | null,         // for 'subagent' | 'org-subagent' (e.g. 'bant-coach')
    instruction: string | null,  // text after ':' for any type; null when blank
  },
  inline: true,
  atom: true,
  group: 'inline',
}
```

**Type detection:**
- `@subagents/crm-updater` → `type: 'crm-updater'`, `name: null`
- `@subagents/next-steps` → `type: 'next-steps'`, `name: null`
- `@subagents/{other}` → `type: 'subagent'`, `name: '{other}'`
- `@org/subagents/{name}` → `type: 'org-subagent'`, `name: '{name}'`

**Rendering:**

`crm-updater` and `next-steps` — always rendered as distinct rows in trigger blocks, even when instruction is blank:
- CRM Updater: database icon + "CRM Updater" label + editable inline instruction text. Clicking icon opens `#crm-updater` field schema inline. Placeholder when blank: "use base behavior."
- Next Steps: task icon + "Next Steps" label + editable inline instruction text. Placeholder when blank: "use base behavior."
- Both show a lock icon (system subagents, content editable but cannot be removed from `[on:email]` / `[on:meeting]` blocks).

Custom subagents — card/chip with subagent name. Lock icon for system subagents (`meeting-prep`, `daily-agenda`). Clicking opens the subagent file.

**Markdown serializer:**
```typescript
serializeSubagentRef(state, node) {
  const instruction = node.attrs.instruction ? `: ${node.attrs.instruction}` : ':';
  switch (node.attrs.type) {
    case 'crm-updater':
      state.write(`@subagents/crm-updater${instruction}`);
      break;
    case 'next-steps':
      state.write(`@subagents/next-steps${instruction}`);
      break;
    case 'org-subagent':
      state.write(`@org/subagents/${node.attrs.name}${node.attrs.instruction ? ': ' + node.attrs.instruction : ''}`);
      break;
    default:  // 'subagent'
      state.write(`@subagents/${node.attrs.name}${node.attrs.instruction ? ': ' + node.attrs.instruction : ''}`);
  }
}
```

Note: `crm-updater` and `next-steps` always serialize with a trailing `:` even when instruction is null — this is required syntax.

---

## 4. Document-level schema

```typescript
const playbookSchema = new Schema({
  nodes: {
    doc: { content: 'heading reservedSection+ stageSection*' },
    heading: { ... },          // H1 for the playbook title
    reservedSection: { ... },  // includes "On demand" as the last reserved section
    stageSection: { ... },
    triggerBlock: { ... },
    paragraph: { ... },
    resourceRef: { ... },
    subagentRef: { ... },
    text: { ... },
  },
  marks: { ... },
});
```

---

## 5. Parsing logic

Order matters — match these before passing to the standard paragraph parser:

1. `# {title}` → `heading` (H1)
2. `## The agent sees these every time` (and 4 other exact reserved headings) → `reservedSection`
3. `## Stage: {id} — {label}` → `stageSection`
4. `[on:{trigger}]` or `[on:{trigger}: {args}]` at line start → open `triggerBlock`; collect until `[/on]` (or next `[on:...]` / `##` as fallback)
5. `[/on]` at line start → close current `triggerBlock` (consumed, not rendered)
6. `@resources/{ref}`, `@org/resources/{ref}`, `@knowledge-base/{ref}` → `resourceRef`
7. `@subagents/crm-updater` or `@subagents/crm-updater: {text}` → `subagentRef` (type='crm-updater')
8. `@subagents/next-steps` or `@subagents/next-steps: {text}` → `subagentRef` (type='next-steps')
9. `@subagents/{name}` or `@subagents/{name}: {text}` → `subagentRef` (type='subagent')
10. `@org/subagents/{name}` → `subagentRef` (type='org-subagent')

All other content → `paragraph`.

---

## 6. How the server uses the document

At execution time, `getPlaybookSection` on the server reads the PLAYBOOK.md `content` column, parses it, and assembles a prompt for the orchestrator wrapped in XML sections:

```xml
<playbook_section>
  <always_loaded_context>
    {resolved @resources/ content}
  </always_loaded_context>

  <global_rules>
    {[on:any] block content — IGNORE rules, before-meeting, cron}
  </global_rules>

  <stage_instructions trigger="email" stage="discovery_completed">
    {[on:email] block content for this stage}
  </stage_instructions>

  <org_section>
    {org playbook instructions}
  </org_section>
</playbook_section>
```

The frontend editor does NOT show these XML tags. They are assembled server-side at execution time and never written back to the document.

---

## 7. Editor component

The PlaybookEditor uses the same `Document` component (`@/modules/documents/document`) as the rest of the app — Tiptap + Y.js, same provider registry, same live-sync infrastructure. This means agent writes are reflected immediately without a manual refresh.

```tsx
<Document
  ref={docRef}
  documentId={selectedDocInfo?.id ?? null}
  onChange={(md) => { setEditorContent(md); setIsDirty(true); }}
/>
```

## 8. Save path

**Playbook docs require explicit save — no auto-save.**

Unlike other documents in the app (which auto-flush Y.js updates on blur/debounce), playbook saves must be triggered manually. Rationale: playbooks drive execution — an accidental half-written trigger block or broken syntax should never silently become active. The user must intentionally commit changes.

Implementation requirements for the frontend engineer:
- **Disable the `CedarYjsProvider` auto-flush behavior for `documentType: 'playbook'`** — the provider should hold changes locally without sending them to the server until explicitly told to
- **Save button** → call `forceFlush()` (drain pending Y.js updates) then `trpc.admin.documents.writeDocument` (updates content AND recomputes `playbook_manifest` in metadata)
- **Cmd+S** → same as Save button
- **Unsaved changes indicator** — show "Unsaved changes" in toolbar when `isDirty = true`
- **Navigation guard** — prompt before leaving with unsaved changes

Current implementation: uses `forceFlush()` + admin `writeDocument`. Auto-flush is not yet disabled — this is a known gap until `CedarYjsProvider` supports explicit-save mode for specific document types.

The full save sequence:
```
Save button clicked
  → docRef.current.forceFlush()           (flush pending Y.js state)
  → trpc.admin.documents.writeDocument    (write content + recompute manifest)
    → services/documents: writeDocument   (updates content column + playbook_manifest)
    → services/document-saving: writeFileAsYjs (updates content_yjs)
  → refetchDocs()                         (refresh manifest preview)
```

---

## 8. Component location

Current: `apps/mail/app/(routes)/playground/components/PlaybookEditor.tsx`

Future Tiptap editor: `apps/mail/modules/playbook/components/PlaybookTiptapEditor.tsx`

Extract file tree and manifest preview as standalone components usable both in playground and in-conversation.

---

## 9. File navigator (full spec)

### 9.1 Tree structure

```
PLAYBOOK.md                      [playbook badge]
resources/
  overall-goal.md                [lock icon]
  email-style.md                 [lock icon]
  templates.md                   [lock icon]
  coaching-framework.md          [lock icon]
  my-custom-resource.md
subagents/
  crm-updater.md                 [lock icon]
  next-steps.md                  [lock icon]
  meeting-prep.md                [lock icon]
  daily-agenda.md                [lock icon]
  bant-coach.md                  [enabled/disabled toggle]
--- Org ---
org/resources/
  company-sop.md                 [globe icon] [lock for reps]
```

### 9.2 Lock rules

Locked = lock icon shown, delete disabled. Edits always allowed.

- **System subagents**: `subagent frontmatter.system === true` — crm-updater, next-steps, meeting-prep, daily-agenda
- **Seed resources**: `overall-goal.md`, `email-style.md`, `templates.md`, `coaching-framework.md` (by basename)
- **Org files**: delete disabled for all reps (org admin only)

### 9.3 Broken ref indicators

Parse PLAYBOOK.md to extract all `@subagents/`, `@resources/`, `@org/subagents/`, `@org/resources/` refs. Cross-reference against the file list.

- **Red chip** in editor + **red dot** on file tree folder — file referenced but doesn't exist. Show tooltip: "File not found — create it?" with quick-create button.
- **Orange chip** — file exists but body is all unfilled placeholder text (`[brackets]` still present). Seeded but not customised.
- **Banner at top** if any `## The agent sees these every time` refs are broken: "X resources are missing — execution will be incomplete."

### 9.4 Org vs user distinction

Org files shown in a separate "Org" section in the tree with a globe icon. Reps see them as read-only (no delete, no edit).

### 9.5 File creation

- **"+ Resource"** button → prompts for name → creates with `fill_instructions` frontmatter template
- **"+ Subagent"** button → prompts for name → creates with standard subagent frontmatter stub
- Locked files have no delete button

### 9.6 Enabled/disabled toggle

Custom subagents (non-system) with `enabled: false` show dimmed with a toggle. System subagents (`system: true`) have no toggle — their active state is controlled by whether they appear in PLAYBOOK.md.

---

## 10. Subagent file editor

Split view when a `subagents/*.md` file is selected:

### 10.1 Frontmatter panel (top)

| Field | Input | Notes |
|---|---|---|
| `name` | read-only | auto-slug |
| `description` | text input | shown in file tree and playbook inline |
| `when_to_use` | textarea | seen by orchestrator before deciding to fire |
| `model` | select | haiku / sonnet / opus |
| `enabled` | toggle | non-system subagents only |
| `system` | badge | display only, not editable |
| `fill_instructions` | blue info box | "How to fill this out: ..." — display only |

### 10.2 Instructions body

Tiptap editor (or textarea initially) for content below the closing `---`. This is what the agent runs.

### 10.3 System subagent badge

crm-updater, next-steps, meeting-prep, daily-agenda show a "System" badge. Instructions are always editable — users customise the base behavior.

---

## 11. Resource file editor

When a `resources/*.md` file is selected:

- Extract `fill_instructions` from the YAML frontmatter → show as a **blue info callout above the editor**: "How to fill this out: {text}"
- The editable body is everything below the closing `---`
- Locked resource files show: "Required by execution system — cannot be deleted, content is editable"

---

## 12. Trigger block UX (inside PLAYBOOK.md editor)

### 12.1 Adding a trigger block

"+" button at the bottom of any section opens a picker:

```
Pick a trigger:
○ On email
○ After meeting
○ On Slack message
○ On CRM sync
○ N minutes before meeting   [N input: default 30]
○ When field changes          [field picker] → [value picker]
○ On cron schedule            [expression input + human-readable preview]
```

New blocks for `email` and `meeting` are pre-populated with `@subagents/crm-updater:` and `@subagents/next-steps:` (always present, blank instruction).

### 12.2 Arg editing

- `[on:before-meeting: 30]` — inline number input in the callout header
- `[on:cron: 0 7 * * 1-5]` — inline cron expression input + human-readable preview ("Weekdays at 7am ET")
- `[on:field-change: stage → won]` — two dropdowns: field selector (from `#crm-updater` field schema) + value selector (from that field's options)

### 12.3 `@subagents/crm-updater:` and `@subagents/next-steps:` behavior

These always appear in `[on:email]` and `[on:meeting]` blocks, even when blank. They are NOT removable from those blocks. Blank instruction = "use base behavior from subagents/crm-updater.md / subagents/next-steps.md."

Other trigger types (`[on:field-change]`, `[on:any]`, etc.) may include them optionally.

### 12.4 Removing a trigger block

`×` button in top-right corner of the callout. Confirm if content is non-empty. Removing the block emits nothing (no `[on:...]` or `[/on]` in the output).

---

## 13. Stage section UX

### 13.1 Stage ID validation

`stageId` in `## Stage: {id} — {Label}` must match a value in `conversationFieldDefinitions.status.options`. If not: **yellow warning badge** — "Stage ID 'xyz' doesn't match any known status — events won't route here."

Data source: `trpc.aop.listForUser` → `conversationFieldDefinitions.status.options`.

### 13.2 Adding a stage

"+ Add stage" button → picker shows all status options not already in the playbook → creates `## Stage: {id} — {Label}` with default `[on:email]` and `[on:meeting]` blocks (each pre-populated with crm-updater + next-steps).

### 13.3 Stage label editing

Label (after ` — `) is contenteditable. Stage ID (before ` — `) is a non-editable locked badge.

### 13.4 Entry/Exit fields

Plain text paragraphs in the stage section. Show with a small "Entry:" / "Exit:" label prefix, visually distinct from trigger blocks.

---

## 14. `## On demand` section

Reserved heading (locked text). Show a **"Manual only"** badge. Nothing in this section auto-executes.

Each `@subagents/name` entry renders as a subagent chip with a **"→ Run"** button that triggers: `"Run {subagent name} for this deal"` in the chat. Button only shown in conversation context, not in playground.

---

## 15. System token rendering in trigger blocks

`@subagents/crm-updater:` and `@subagents/next-steps:` render as distinct styled rows — NOT as regular paragraph text.

**CRM Updater** (`@subagents/crm-updater: focus on need, champion`):
- Icon: database/field icon
- Label: "CRM Updater" (locked)
- Editable inline text: instruction after `:` (placeholder: "use base behavior")
- Clicking icon opens `#crm-updater` field schema inline

**Next Steps** (`@subagents/next-steps: create Slack channel task`):
- Icon: task/checklist icon
- Label: "Next Steps" (locked)
- Editable inline text: instruction after `:` (placeholder: "use base behavior")

Both show a lock icon indicating they are system subagents.

---

## 16. Scope indicators (`@org/...` vs `@...`)

| Ref | Icon | Color | Edit rule |
|---|---|---|---|
| `@resources/name` | user icon | default | editable |
| `@org/resources/name` | globe icon | teal | org admin only |
| `@subagents/name` | user icon | default | editable |
| `@org/subagents/name` | globe icon | teal | org admin only |
| `@knowledge-base/path` | book icon | purple | editable |

---

## 17. Manifest preview

Already implemented. Shows after saving PLAYBOOK.md. Source: `documents.metadata.playbook_manifest` (computed server-side by `parsePlaybookManifest` in `apps/server/src/services/playbook/trigger-parser.ts`).

Displays:
- Subscribed event types (from `[on:email]`, `[on:meeting]`, etc.)
- Field watchers (from `[on:field-change]`)
- Cron schedule with human-readable expression
- Before-meeting config (minutes)

Future: add "Last executed" timestamp per trigger type.

---

## 18. Autosave / dirty state

- "Unsaved changes" indicator in toolbar when `isDirty = true`
- Autosave on blur (5s debounce after last keystroke)
- Cmd+S saves immediately
- On save: manifest preview refreshes from the response

---

## 19. Playground vs main app

Currently: playground only (`/playground`).

Future: slide-over or tab in conversation detail view. File tree, editor, and manifest preview are separate reusable components.

"Create Playbook" and "Migrate from existing config" buttons stay playground-only. The editor itself is used anywhere.