# Granola Write API Integration — Design

**Date:** 2026-03-02
**Status:** Approved (Approach A)
**Author:** kbx-dev

## Goal

Push prep notes into Granola before meetings so they appear when the meeting starts.
Primary consumer: cos-dev's `/prep` command via Python API.

## Approach

Extend existing `GranolaClient` with write methods. Add `markdown_to_prosemirror()` converter.
Expose via CLI (`kbx granola push`) and Python API.

## API Surface

### GranolaClient methods (new)

```python
def get_user_id(self) -> str
    """Get current user's UUID from /v1/get-current-user."""

def find_document(self, title: str | None = None, calendar_uid: str | None = None,
                  since: str | None = None) -> dict | None
    """Find a Granola doc by title substring or calendar event UID.
    Returns the doc dict or None."""

def create_document(self, title: str, calendar_event: dict | None = None) -> dict
    """Create a new Granola doc. Returns created doc dict with id.
    calendar_event: Google Calendar event data (from gm) for linking."""

def update_document_notes(self, doc_id: str, markdown: str,
                          prepend: bool = False) -> dict
    """Write markdown to a doc's notes body.
    If prepend=True and doc has existing notes, prepend above them with --- separator.
    Converts to ProseMirror JSON + plain text + markdown."""
```

### Markdown → ProseMirror converter

```python
def markdown_to_prosemirror(markdown: str) -> dict
    """Convert markdown to ProseMirror doc JSON.
    Each paragraph/heading gets a unique UUID in attrs.id.
    Handles: headings (##), paragraphs, bold, italic, code, lists, links, horizontal rules."""
```

### CLI

```
kbx granola push <calendar-uid> --notes "# Prep\n- Topic A"
kbx granola push <calendar-uid> --notes-file prep.md
kbx granola push <calendar-uid> --notes "..." --title "1:1 with Alice"
```

- `CALENDAR_UID` (positional, required): Google Calendar event ID or iCalUID
- `--notes` / `--notes-file`: Content to write (mutually exclusive, one required)
- `--title`: Meeting title for auto-created docs (default: "Meeting")
- Notes are always prepended above existing content (no replace mode)
- `--notes` interprets `\n` and `\t` escape sequences; `--notes-file` reads verbatim
- If no document exists for the UID, one is created automatically with
  `google_calendar_event: {"id": calendar_uid}`
- Title-based matching available only via the Python API `find_document(title=...)`

## Payload Format (proven working)

```json
{
  "id": "<document_uuid>",
  "updated_at": "2026-03-02T18:39:55.862Z",
  "notes": {"type": "doc", "content": [...]},
  "notes_plain": "plain text version",
  "notes_markdown": "markdown version"
}
```

- `updated_at` MUST include milliseconds + Z suffix — use the doc's existing value to avoid
  shifting future meetings into Granola's "Today" view
- Each ProseMirror node needs `attrs.id` (UUID v4)
- Empty paragraphs: `{"type": "paragraph", "attrs": {"id": "<uuid>"}}`  (no content key)

## Prepend Strategy

When `prepend=True` and the doc has existing notes:
1. Fetch current doc via `list_documents` (already have notes content)
2. Build new markdown: `new_notes + "\n\n---\n\n" + existing_notes_markdown`
3. Convert combined markdown to ProseMirror
4. Write via `update-document`

## User-Agent

Bump globally from `Granola/5.354.0` → `Granola/7.45.0`.

## Files Changed

- `src/kb/sync/granola.py` — GranolaClient write methods, markdown_to_prosemirror()
- `src/kb/cli.py` — `kbx granola push` command
- `tests/test_sync_granola.py` — tests for all new code

## Not in Scope

- Document panels (create-document-panel didn't visibly appear in app)
- Calendar event creation from CLI (cos-dev has this data, CLI doesn't)
- Batch writes
