Meeting Integration

Phase 7 · Granola Team & Enterprise

Paid tier required

Meeting integration is available on Team and Enterprise plans only. The granola-sync service will not start for Starter teams.

Overview

xbrain integrates with Granola to automatically turn meeting notes into team knowledge. Every 5 minutes, the granola-sync service polls the Granola API, fetches new meeting notes, and passes them through Claude to extract:

All three writes happen atomically — either all succeed or all roll back. Note-level idempotency ensures a meeting is never processed twice, even if the polling loop restarts.

How It Works

The granola-sync service runs as a separate container in Docker Compose. Here is the full processing pipeline for a single polling cycle:

flowgranola-sync polls every 5 minutes
  │
  ├─ SELECT team integrations from granola_integrations WHERE last_polled_at < now() - 5min
  │
  ├─ For each team integration:
  │   ├─ UPDATE last_polled_at = now()   ← at-most-once delivery
  │   ├─ Decrypt Fernet API key
  │   ├─ GET /notes from Granola API (with pagination, 429 backoff)
  │   │
  │   └─ For each note:
  │       ├─ Dedup check: SELECT id FROM memory_items WHERE source_ref = note_id
  │       │   └─ Already exists? → skip
  │       │
  │       ├─ Claude extraction: summary, participants[], action_items[]
  │       │
  │       └─ Atomic write (one transaction):
  │           ├─ INSERT memory_item (source='granola', source_ref=note_id)
  │           ├─ UPSERT contacts for each participant
  │           └─ INSERT tasks for each action item

At-most-once + note dedup = exactly-once-effective

last_polled_at is updated before fetching notes. If the service crashes mid-fetch, the notes from that window are skipped on next run. The source_ref dedup on memory_items provides a second safety net — if a note was partially processed before the crash, its dedup check blocks re-insertion.

Setup

Step 1 — Get a Granola API key

Log in to Granola, go to Settings → Integrations → API, and generate a personal API key. This key is scoped to your Granola account and gives read access to your meeting notes.

Step 2 — Register the integration (admin only)

Only team admins can register a Granola integration. The API key is encrypted with Fernet before storage — it is never stored in plaintext.

bashPOST /v1/admin/granola-integration

curl -X POST "https://api.grooveos.app/v1/admin/granola-integration" \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -H "X-Team-Scope: excalibur" \
  -H "Content-Type: application/json" \
  -d '{
    "api_key": "grn_live_...",
    "default_project_scope": "general",
    "default_truth_level": "WORKING"
  }'

# Response
{"id": "gi_abc123...", "team_scope": "excalibur", "status": "active"}

Step 3 — Verify the environment variables

env — .env# Required for granola-sync
GRANOLA_API_BASE=https://api.granola.ai/v1
GRANOLA_POLL_INTERVAL_SECONDS=300   # 5 minutes

# Fernet key for API key encryption
# Uses OAUTH_CREDENTIALS_ENCRYPTION_KEY as fallback if FERNET_KEY is unset
FERNET_KEY=your-fernet-key

# Claude for extraction
ANTHROPIC_API_KEY=sk-ant-...

Step 4 — Start granola-sync

The granola-sync service is included in the main docker-compose.yml but starts only when a valid integration is registered in the database. Restart the service to pick up a newly registered integration:

bashdocker compose restart granola-sync
docker compose logs -f granola-sync

Admin API

All admin endpoints require a user with is_admin=true on their team record.

Get current integration

bashGET /v1/admin/granola-integration

curl "https://api.grooveos.app/v1/admin/granola-integration" \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -H "X-Team-Scope: excalibur"

Update integration

bashPATCH /v1/admin/granola-integration

curl -X PATCH "https://api.grooveos.app/v1/admin/granola-integration" \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -H "X-Team-Scope: excalibur" \
  -H "Content-Type: application/json" \
  -d '{"api_key": "grn_live_new..."}'

Delete integration

bashDELETE /v1/admin/granola-integration

curl -X DELETE "https://api.grooveos.app/v1/admin/granola-integration" \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -H "X-Team-Scope: excalibur"

Manual ingest (webhook-compatible)

Push a Granola note directly without waiting for the 5-minute polling cycle. Useful for testing or webhook-based setups.

bashPOST /v1/integrations/granola/ingest

curl -X POST "https://api.grooveos.app/v1/integrations/granola/ingest" \
  -H "Authorization: Bearer $JWT" \
  -H "X-Team-Scope: excalibur" \
  -H "Content-Type: application/json" \
  -d '{
    "note_id": "grn_note_abc123",
    "title": "Investor call — Series A",
    "summary": "Discussed Q2 targets. Alice will send updated deck by Friday.",
    "participants": [
      {"email": "alice@example.com", "name": "Alice Martin"},
      {"email": "bob@vc.fund", "name": "Bob Chen"}
    ],
    "action_items": [
      {"title": "Send updated deck to Bob", "assignee_email": "alice@example.com", "due_date": "2026-05-09"}
    ],
    "created_at": "2026-05-07T10:00:00Z"
  }'

What Gets Stored

Extracted from note Stored as Dedup key
Meeting summary memory_items (source=granola) (source_ref=note_id, team_scope)
Participants contacts (source=granola) (email, team_scope)
Action items tasks (created_by=NULL) No dedup — one task per action item per ingest

Rate Limits & Error Handling

The Granola polling loop handles API rate limits gracefully:

Error Reference

HTTP Status When Resolution
403 Team is on Starter plan or caller is not an admin (for admin endpoints) Upgrade plan or use an admin JWT.
409 Integration already registered for this team (on POST) Use PATCH to update the existing integration.
404 No integration registered for the team (on GET/PATCH/DELETE) Register the integration first with POST.
422 Missing api_key on creation Include the Granola API key in the request body.