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: