Skip to main content
Rowboat integrates with Granola to automatically sync your meeting notes and make them searchable by your AI assistant.

Overview

The Granola integration:
  • Syncs meeting notes from your Granola workspace
  • Converts ProseMirror format to markdown
  • Tracks document updates automatically
  • Preserves frontmatter metadata
  • Runs every 5 minutes when enabled
Granola is an AI-powered meeting notes app that runs on macOS. It automatically takes notes during meetings.

How It Works

Authentication

Rowboat reads your Granola access token from the local config file:
const GRANOLA_CONFIG_PATH = path.join(
  homedir(),
  'Library',
  'Application Support',
  'Granola',
  'supabase.json'
);

interface SupabaseJson {
  workos_tokens?: string; // JSON string containing WorkosTokens
}

const content = fs.readFileSync(GRANOLA_CONFIG_PATH, 'utf-8');
const supabaseJson = JSON.parse(content);
const tokens = JSON.parse(supabaseJson.workos_tokens);
const accessToken = tokens.access_token;
Granola must be installed and authenticated on your Mac for this integration to work.

Sync Configuration

const SYNC_DIR = path.join(WorkDir, 'granola_notes');
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Every 5 minutes
const API_DELAY_MS = 1000; // 1 second between API calls
const MAX_BATCH_SIZE = 10; // Max 10 docs per sync

API Integration

Rowboat uses Granola’s v2 API:
const GRANOLA_API_BASE = 'https://api.granola.ai';
const GRANOLA_CLIENT_VERSION = '6.462.1';

function getHeaders(accessToken: string) {
  return {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
    'User-Agent': `Granola/${GRANOLA_CLIENT_VERSION}`,
    'X-Client-Version': GRANOLA_CLIENT_VERSION,
  };
}

const response = await fetch(`${GRANOLA_API_BASE}/v2/get-documents`, {
  method: 'POST',
  headers: getHeaders(accessToken),
  body: JSON.stringify({
    limit: 10,
    offset: 0,
    include_last_viewed_panel: true,
  }),
});

Document Format

ProseMirror to Markdown Conversion

Granola stores notes in ProseMirror format. Rowboat converts them to markdown:
function convertProseMirrorToMarkdown(content: ProseMirrorNode): string {
  if (node.type === 'heading') {
    const level = node.attrs?.level || 1;
    return `${'#'.repeat(level)} ${text}\n\n`;
  }
  
  if (node.type === 'paragraph') {
    return `${text}\n\n`;
  }
  
  if (node.type === 'bulletList') {
    return items.map(item => `- ${item}`).join('\n') + '\n\n';
  }
  
  if (node.type === 'orderedList') {
    return items.map((item, i) => `${i+1}. ${item}`).join('\n') + '\n\n';
  }
}

Output Format

Each document is saved with frontmatter:
---
granola_id: abc123
title: "Team Standup - Jan 1"
created_at: 2024-01-01T10:00:00Z
updated_at: 2024-01-01T10:30:00Z
---

# Team Standup - Jan 1

## Attendees
- Alice
- Bob

## Discussion Points
- Product roadmap review
- Sprint planning

## Action Items
- [ ] Update design mockups
- [ ] Schedule follow-up meeting

State Tracking

Rowboat tracks which documents have been synced:
interface SyncState {
  lastSyncDate: string;
  syncedDocs: Record<string, string>; // { documentId: updated_at }
}

// Check if document needs sync
const docUpdatedAt = doc.updated_at || doc.created_at;
const lastSyncedAt = state.syncedDocs[doc.id];
const needsSync = !lastSyncedAt || lastSyncedAt !== docUpdatedAt;

if (needsSync) {
  // Convert and save document
  state.syncedDocs[doc.id] = docUpdatedAt;
  saveState(state);
}
Only documents that are new or have been updated since the last sync are processed.

Rate Limiting

Rowboat implements rate limiting protection:
async function callWithRateLimit<T>(
  operation: () => Promise<T>,
  operationName: string
): Promise<T | null> {
  let retries = 0;
  let delay = 60000; // 1 minute
  
  while (retries < 3) {
    try {
      return await operation();
    } catch (error) {
      if (error.message.includes('429')) {
        retries++;
        await sleep(delay);
        delay *= 2; // Exponential backoff
      } else {
        throw error;
      }
    }
  }
  return null;
}

API Delays

// Add delay between API calls
if (offset > 0) {
  await sleep(1000); // 1 second delay
}

const docsResponse = await getDocuments(accessToken, 10, offset);

Pagination

Rowboat fetches documents in batches:
let offset = 0;
let hasMore = true;

while (hasMore) {
  const docsResponse = await getDocuments(accessToken, MAX_BATCH_SIZE, offset);
  
  if (docsResponse.docs.length === 0) {
    hasMore = false;
    break;
  }
  
  // Process documents...
  
  offset += docsResponse.docs.length;
  
  if (docsResponse.docs.length < MAX_BATCH_SIZE) {
    hasMore = false; // Last page
  }
}

Enabling the Integration

The Granola integration must be explicitly enabled:
// Configuration stored in ~/.rowboat/config/granola.json
{
  "enabled": true
}
const granolaRepo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
const config = await granolaRepo.getConfig();

if (!config.enabled) {
  console.log('[Granola] Sync disabled in config');
  return;
}

Activity Logging

Rowboat logs all Granola sync activity:
await serviceLogger.log({
  type: 'changes_identified',
  service: 'granola',
  runId,
  level: 'info',
  message: `Granola updates: ${totalChanges} changes`,
  counts: {
    newNotes: newCount,
    updatedNotes: updatedCount
  },
  items: changedTitles.slice(0, 5), // First 5 titles
  truncated: changedTitles.length > 5,
});

Trigger Manual Sync

import { triggerSync } from './granola/sync';

triggerSync(); // Wakes up sync immediately

Troubleshooting

No Access Token Found

Make sure Granola is installed and you’re logged in. The config file should exist at: ~/Library/Application Support/Granola/supabase.json

Rate Limit Errors

If you see rate limit errors:
  • Rowboat will automatically retry with exponential backoff
  • Reduce MAX_BATCH_SIZE if the problem persists
  • Increase SYNC_INTERVAL_MS to sync less frequently

Missing Documents

Rowboat syncs all documents in your Granola workspace. If documents are missing, check that they’re not deleted in Granola.

ProseMirror Conversion Issues

If notes don’t convert correctly:
  • Rowboat tries last_viewed_panel.content first (most recent)
  • Falls back to notes field (older format)
  • Falls back to notes_markdown or notes_plain if available
const lastViewedContent = doc.last_viewed_panel?.content;
if (lastViewedContent?.type === 'doc') {
  md += convertProseMirrorToMarkdown(lastViewedContent);
} else if (doc.notes?.type === 'doc') {
  md += convertProseMirrorToMarkdown(doc.notes);
} else if (doc.notes_markdown) {
  md += doc.notes_markdown;
}

Files Synced

LocationDescription
~/rowboat/granola_notes/{id}_{title}.mdMeeting notes
~/rowboat/granola_notes/sync_state.jsonSync state tracking

Configuration File

{
  "enabled": true
}
Location: ~/.rowboat/config/granola.json

Privacy & Security

  • Local Token: Uses token from Granola’s local config
  • Local Storage: All notes stored locally on your machine
  • No Cloud Sync: Notes never sent to external servers
  • Read-Only: Only reads notes, never modifies Granola data

Build docs developers (and LLMs) love