diff --git a/docs/docs.json b/docs/docs.json
index c5356b35..5ba1d57f 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -93,6 +93,7 @@
"pages": [
"v3/guides/integrations/claude-code",
"v3/guides/integrations/crewai",
+ "v3/guides/integrations/granola",
"v3/guides/integrations/langgraph",
"v3/guides/integrations/mcp",
"v3/guides/integrations/n8n",
diff --git a/docs/v3/guides/integrations/granola.mdx b/docs/v3/guides/integrations/granola.mdx
new file mode 100644
index 00000000..3eb9fcdc
--- /dev/null
+++ b/docs/v3/guides/integrations/granola.mdx
@@ -0,0 +1,142 @@
+---
+title: "Granola"
+icon: 'microphone'
+description: "Import meeting notes and transcripts from Granola into Honcho"
+sidebarTitle: 'Granola'
+---
+
+Import your [Granola](https://granola.ai) meeting data into Honcho to build queryable representations of the people you meet with. The transfer script handles participants, transcripts, and summaries — mapping them onto Honcho's peer and session model.
+
+
+The full code is available on [GitHub](https://github.com/plastic-labs/honcho/tree/main/examples/granola).
+
+
+## Quick Start
+
+```bash
+pip install honcho-ai httpx
+export HONCHO_API_KEY="your-key-from-app.honcho.dev"
+
+python honcho_granola.py
+```
+
+The script will:
+1. Open your browser for Granola OAuth authentication
+2. Fetch all meetings and their content
+3. Walk you through each meeting interactively — confirm peers, choose import mode, skip meetings you don't want
+4. Print a summary of what was transferred
+
+## Honcho Mapping
+
+| Granola Concept | Honcho Concept | Details |
+|-----------------|----------------|---------|
+| Your Granola account | Workspace (`granola`) | One workspace for all meetings |
+| Meeting participant | Peer | Email as ID for deduplication across meetings |
+| Individual meeting | Session (`meeting-{id}`) | One session per meeting |
+| Transcript turns | Messages with attribution | Two-person calls get full speaker attribution |
+| Meeting summary | Message from note creator | Multi-person calls store the summary |
+
+## Key Design Decisions
+
+**Email as Peer ID.** The script uses email addresses as the basis for peer IDs, normalized to a URL-safe format (e.g., `vince@plasticlabs.ai` becomes `vince-plasticlabs-ai`). This ensures consistent identification across meetings — if you meet someone in 5 different calls, all conversations accumulate under the same peer.
+
+```python
+# These all resolve to the same peer:
+honcho.peer("vince-plasticlabs-ai") # From Meeting A
+honcho.peer("vince-plasticlabs-ai") # From Meeting B
+```
+
+**Auto-Detecting "Me".** Granola marks the note creator in its participant list with `(note creator)`. The script uses this to identify you automatically.
+
+```
+Participants: Abigail (note creator) from Plasticlabs ,
+ Vince Trost from Plasticlabs
+```
+
+**Two-Person Calls: Full Attribution.** When exactly one other participant is present *and* the transcript contains `Them:` turns, the transcript is stored with speaker-attributed messages. Consecutive same-speaker turns are merged before storing.
+
+```python
+session.add_messages([
+ me.message("What's your timeline for the launch?"),
+ them.message("We're targeting Q2, but it depends on the API integration."),
+])
+```
+
+**Multi-Person Calls: Summary Mode.** Granola's transcript uses `Them:` for all non-creator speakers with no disambiguation — in a 4-person call, everyone else is just `Them:`. Rather than guess incorrectly, the script stores Granola's summary as your record of the meeting, with participants in metadata.
+
+```python
+session.add_messages([
+ me.message(
+ f"Meeting: Product Planning\n"
+ f"Date: Mar 5, 2026 2:00 PM\n"
+ f"Participants: Vince Trost from Plasticlabs, Jordan from Acme Corp\n\n"
+ f"{meeting_summary}",
+ metadata={
+ "participants": "Vince Trost from Plasticlabs, Jordan from Acme Corp",
+ "mode": "summary",
+ "granola_meeting_id": meeting_id,
+ }
+ )
+])
+```
+
+The summary is attributed to you because it's *your* record of what happened. Granola captured your notes from a meeting where those people were present.
+
+**Interactive Confirmation.** When the script encounters a new participant, it prompts before creating the peer. For multi-person calls that are actually 1:1s (extra participants listed but didn't speak), you can override the detection.
+
+**Noisy Transcripts Preserved.** Granola's raw transcripts are often fragmented (`Me: Yeah. Them: Yeah. Me: And.`). The script merges consecutive same-speaker turns but otherwise preserves the raw content. Honcho's reasoning extracts signal from noisy data.
+
+## Querying After Import
+
+```python
+from honcho import Honcho
+
+honcho = Honcho(workspace_id="granola", api_key=os.environ["HONCHO_API_KEY"])
+
+# Peer IDs are normalized from emails: vince@plasticlabs.ai -> vince-plasticlabs-ai
+vince = honcho.peer("vince-plasticlabs-ai")
+print(vince.chat("What is Vince working on?"))
+print(vince.chat("What concerns has Vince raised?"))
+
+me = honcho.peer("abigail-plasticlabs-ai")
+print(me.chat("What topics do I discuss most frequently?"))
+```
+
+## Combining with Other Sources
+
+Because meetings live in a standard Honcho workspace, you can enrich peer representations with data from other channels:
+
+```python
+# Same workspace, same peer — data accumulates
+vince = honcho.peer("vince-plasticlabs-ai")
+me = honcho.peer("abigail-plasticlabs-ai")
+
+discord_session = honcho.session("discord-general-2024-03")
+discord_session.add_messages([
+ vince.message("Just shipped the new API version!"),
+ me.message("Congrats! How's the migration guide coming?"),
+])
+
+# Queries now draw from both meeting transcripts AND Discord history
+vince.chat("What has Vince shipped recently?")
+```
+
+## Troubleshooting
+
+| Issue | Fix |
+|-------|-----|
+| Granola OAuth fails | Ensure you have a paid Granola plan (MCP requires Pro+). Clear cached token and retry. |
+| Missing transcripts | Free tier has no transcript access. The script falls back to summary content. |
+| 500 errors from Honcho | Check for null bytes or control characters in transcript content. The script sanitizes these automatically. |
+| Rate limiting with many meetings | The script processes sequentially with delays. Honcho ingestion is async — don't poll for immediate results. |
+
+## Next Steps
+
+
+
+ See how the Granola integration maps to common Honcho patterns.
+
+
+ Source code and example script.
+
+
diff --git a/examples/granola/honcho_granola.py b/examples/granola/honcho_granola.py
index 43e55a20..b294d830 100644
--- a/examples/granola/honcho_granola.py
+++ b/examples/granola/honcho_granola.py
@@ -20,10 +20,11 @@ import json
import os
import re
import sys
+import traceback
import webbrowser
from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler
-from urllib.parse import parse_qs, urlparse
+from urllib.parse import parse_qs, urlencode, urlparse
import threading
import httpx
from typing import Any, TypedDict, cast
@@ -104,9 +105,6 @@ class OAuthCallbackHandler(BaseHTTPRequestHandler):
pass # Suppress HTTP request logging
-TOKEN_CACHE_FILE = os.path.expanduser("~/.granola_token.json")
-
-
class GranolaMCPClient:
"""Client for interacting with Granola MCP server."""
@@ -117,26 +115,6 @@ class GranolaMCPClient:
self.resource_metadata: dict[str, Any] = {}
self.client_id: str | None = None
self.pkce_verifier: str | None = None
- self._load_cached_token()
-
- def _load_cached_token(self):
- """Load a previously cached access token if it exists."""
- try:
- with open(TOKEN_CACHE_FILE) as f:
- data = json.load(f)
- self.access_token = data.get("access_token")
- self.client_id = data.get("client_id")
- except (FileNotFoundError, json.JSONDecodeError):
- pass
-
- def _save_cached_token(self):
- """Cache the access token to disk."""
- with open(TOKEN_CACHE_FILE, "w") as f:
- json.dump({
- "access_token": self.access_token,
- "client_id": self.client_id,
- }, f)
- os.chmod(TOKEN_CACHE_FILE, 0o600) # readable only by owner
def _generate_pkce(self) -> tuple[str, str]:
"""Generate PKCE code verifier and challenge."""
@@ -172,25 +150,6 @@ class GranolaMCPClient:
except Exception as e:
print(f" Could not fetch PRM: {e}")
- # Alternative: Make an unauthenticated request to MCP and parse WWW-Authenticate
- try:
- response = await self.http_client.post(
- GRANOLA_MCP_URL,
- json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
- headers={"Content-Type": "application/json"}
- )
-
- if response.status_code == 401:
- www_auth = response.headers.get("WWW-Authenticate", "")
- print(f" Got 401 with WWW-Authenticate header")
- # Parse the header to extract auth server URL
- # Format: Bearer realm="...", resource="...", scope="..."
- if "resource=" in www_auth:
- # Extract resource metadata URL
- pass
- except Exception as e:
- print(f" Could not probe MCP endpoint: {e}")
-
return self.resource_metadata
async def discover_oauth_metadata(self) -> dict[str, Any]:
@@ -204,7 +163,6 @@ class GranolaMCPClient:
if auth_servers:
auth_server_url = auth_servers[0] if isinstance(auth_servers[0], str) else auth_servers[0].get("issuer")
else:
- # Use the auth server URL we discovered from the error message
auth_server_url = "https://mcp-auth.granola.ai"
# Fetch authorization server metadata
@@ -223,7 +181,7 @@ class GranolaMCPClient:
except Exception:
continue
- # Fallback based on what we learned from the error
+ # Fallback to known Granola auth endpoints
self.auth_metadata = {
"authorization_endpoint": "https://mcp-auth.granola.ai/oauth2/authorize",
"token_endpoint": "https://mcp-auth.granola.ai/oauth2/token",
@@ -265,28 +223,8 @@ class GranolaMCPClient:
return {}
- async def _test_cached_token(self) -> bool:
- """Test if the cached token is still valid."""
- if not self.access_token:
- return False
- try:
- # Try a lightweight MCP call
- await self.call_mcp_tool("list_meetings", {"limit": 1})
- return True
- except Exception:
- self.access_token = None
- return False
-
async def authenticate(self) -> bool:
"""Perform OAuth authentication with Granola following MCP spec."""
- # Try cached token first
- if self.access_token:
- print("\n🔐 Testing cached Granola token...")
- if await self._test_cached_token():
- print("✅ Cached token is valid!")
- return True
- print(" Cached token expired, re-authenticating...")
-
global auth_result
auth_result = {"code": None, "error": None}
@@ -300,9 +238,8 @@ class GranolaMCPClient:
client_id = client_info.get("client_id") or self.client_id
if not client_id:
- # The error showed a client_id was already assigned, extract it
- print(" Using pre-registered client flow...")
- client_id = "granola-transfer-client"
+ print("❌ No client_id obtained from DCR. Cannot authenticate.")
+ return False
# Step 3: Generate PKCE (required by OAuth 2.1 / MCP spec)
self.pkce_verifier, pkce_challenge = self._generate_pkce()
@@ -314,8 +251,7 @@ class GranolaMCPClient:
if supported_scopes:
scope = " ".join(supported_scopes)
else:
- # Don't specify scope - let Granola provide default scopes
- # The error was "invalid_scope" so we shouldn't guess
+ # Don't specify scope — let Granola provide default scopes
scope = None
# Build authorization URL
@@ -342,7 +278,6 @@ class GranolaMCPClient:
if resource_url:
auth_params["resource"] = resource_url
- from urllib.parse import urlencode
query = urlencode(auth_params)
full_auth_url = f"{auth_url}?{query}"
@@ -392,7 +327,6 @@ class GranolaMCPClient:
if response.status_code == 200:
token_response = response.json()
self.access_token = token_response.get("access_token")
- self._save_cached_token()
print("✅ Successfully authenticated with Granola!")
return True
else:
@@ -550,7 +484,6 @@ class GranolaMCPClient:
# Return the raw text — it likely contains the notes in XML/markup format
# This is still valuable content to store in Honcho
- print(f" [DEBUG] get_meetings returned non-JSON (first 500 chars): {text[:500]}")
return {"id": meeting_id, "raw_content": text}
async def get_meeting_transcript(self, meeting_id: str) -> str | None:
@@ -574,17 +507,11 @@ class GranolaMCPClient:
if transcript:
return str(transcript)
- print(f" [DEBUG] Transcript result structure: {json.dumps(result, default=str)[:300]}")
return None
except Exception as e:
print(f" Transcript unavailable: {e}")
return None
- async def query_meetings(self, query: str) -> dict[str, Any]:
- """Query meetings with natural language."""
- result = await self.call_mcp_tool("query_granola_meetings", {"query": query})
- return result
-
async def close(self):
"""Close the HTTP client."""
await self.http_client.aclose()
@@ -672,13 +599,12 @@ def parse_transcript_turns(transcript: str) -> list[TranscriptTurn]:
def analyze_transcript(transcript: str) -> dict[str, Any]:
"""Return stats about a transcript."""
turns = parse_transcript_turns(transcript)
- me_turns = [t for t in turns if t["speaker"] == "Me"]
- them_turns = [t for t in turns if t["speaker"] == "Them"]
+ me_count = sum(1 for t in turns if t["speaker"] == "Me")
+ them_count = len(turns) - me_count
total_words = sum(len(t["text"].split()) for t in turns)
return {
- "turns": turns,
- "me_count": len(me_turns),
- "them_count": len(them_turns),
+ "me_count": me_count,
+ "them_count": them_count,
"total_words": total_words,
}
@@ -888,48 +814,8 @@ class HonchoClient:
msg_meta = metadata if start == 0 else None
messages.append(me_peer.message(chunk, metadata=msg_meta, created_at=created_at))
- print(f" [DEBUG] store_summary: {len(messages)} message(s), first chunk len={len(messages[0].content) if messages else 0}")
for i in range(0, len(messages), 100):
- batch = messages[i : i + 100]
- try:
- session.add_messages(batch)
- except Exception:
- # Dump debug info for the failing batch
- for j, msg in enumerate(batch):
- print(f" [DEBUG] msg[{j}]: peer_id={msg.peer_id!r}, content_len={len(msg.content)}, has_metadata={msg.metadata is not None}, created_at={msg.created_at!r}")
- # Check for problematic chars
- bad_chars: list[str] = []
- for k, ch in enumerate(msg.content):
- if ord(ch) == 0 or (ord(ch) < 32 and ch not in '\n\r\t'):
- bad_chars.append(f"pos {k}: {ch!r} (ord={ord(ch)})")
- if bad_chars:
- print(f" [DEBUG] bad chars: {bad_chars[:10]}")
- else:
- print(f" [DEBUG] no bad chars found")
- # Raw HTTP debug — bypass SDK to see actual server response
- print(f" [DEBUG] Making raw HTTP request to see full error...")
- try:
- import httpx as _httpx
- raw_client = _httpx.Client(timeout=30.0)
- base = self.client.base_url
- key = os.environ.get("HONCHO_API_KEY", "")
- url = f"{base}/v3/workspaces/{self.workspace_id}/sessions/{session_id}/messages"
- raw_body = {
- "messages": [{"content": "test", "peer_id": batch[0].peer_id}]
- }
- raw_resp = raw_client.post(
- url,
- json=raw_body,
- headers={
- "Authorization": f"Bearer {key}",
- "Content-Type": "application/json",
- },
- )
- print(f" [DEBUG] URL: {url}")
- print(f" [DEBUG] Raw response: {raw_resp.status_code} {raw_resp.text[:1000]}")
- except Exception as e2:
- print(f" [DEBUG] Raw HTTP debug failed: {e2}")
- raise
+ session.add_messages(messages[i : i + 100])
return session_id
@@ -1074,6 +960,12 @@ async def main():
" [Enter] import as summary / [2] actually 2-person / [k] skip: ",
["", "2", "k"], default=""
)
+ else:
+ # No transcript or no other participants — offer summary or skip
+ choice = prompt_choice(
+ " [Enter] import as summary / [k] skip: ",
+ ["", "k"], default=""
+ )
if choice == "k":
print(" -> Skipped")
@@ -1177,7 +1069,6 @@ async def main():
except Exception as e:
print(f" -> FAILED: {e}")
- import traceback
traceback.print_exc()
results["failed"] += 1
@@ -1196,7 +1087,6 @@ async def main():
sys.exit(0)
except Exception as e:
print(f"\nTransfer failed: {e}")
- import traceback
traceback.print_exc()
sys.exit(1)
finally: