Meeting Integration
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:
- Meeting summary — stored as a memory item with
source=granola - Participants — upserted as contacts in the CRM
- Action items — created as tasks with
created_by=NULL
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:
- 429 Too Many Requests — exponential backoff with jitter, up to 3 retries per poll cycle. If retries are exhausted, the cycle is skipped and retried at the next interval.
- 401 / 403 — logged as a warning (insufficient Granola plan or expired key), not an error. The integration is not disabled — it will retry at the next interval.
- Claude extraction failure — if Claude fails to extract contacts or action items, the memory item is still stored. Missing contacts and tasks are silently skipped.
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. |