Google Meet Transcript Harvesting & LLM Summaries

Data Engineering & Processing advanced Building with AI ~30 min read

Overview

Google Meet automatically generates meeting transcripts as Google Docs, but extracting those transcripts programmatically, parsing their structure, and turning raw dialogue into actionable intelligence requires a multi-stage pipeline that spans the Google Drive API, Google Docs API, OAuth2 token management, LLM-powered summarization, and CRM reconciliation. This is one of the more complex document processing workflows because it bridges three Google APIs, handles two different transcript document formats, and must reconcile meeting participants with CRM contacts.

Google Meet transcripts come in two forms: standalone Google Docs (the original format) and tabbed documents (a newer format where the transcript is one tab within a multi-tab Doc). The pipeline must detect which format a transcript uses, fetch the content through the appropriate API path, and normalize both into a common intermediate representation before LLM processing.

The meeting-intelligence pipeline implements this full flow: discovering new transcripts via the Drive API, fetching their content with refreshed OAuth tokens, sending the raw transcript to Anthropic’s Claude for summarization and action item extraction, matching participant names against CRM contacts, and writing structured meeting records back to the markdown data lake. The result is an automated system that turns every Google Meet call into a searchable, structured CRM entry within minutes of the meeting ending.

Core Concepts

Transcript Discovery via Drive API

Google Meet transcripts are saved as Google Docs in the meeting organizer’s Drive. The pipeline discovers new transcripts by querying the Drive API with a MIME type filter (application/vnd.google-apps.document) and a name pattern filter (Meet transcripts follow a predictable naming convention like “Meeting transcript — [Title]”). The query also filters by modification time to avoid reprocessing old transcripts.

Key Concept: Drive Search Query Syntax

The Drive API uses a custom query language for file search. Transcript discovery combines multiple predicates: mimeType = 'application/vnd.google-apps.document' and name contains 'transcript' and modifiedTime > '2024-01-01T00:00:00'. The query runs as a paginated list operation, returning file IDs and metadata that the next stage uses to fetch content. Rate limiting and pagination handling are critical for accounts with thousands of documents.

Standalone vs. Tabbed Documents

Google introduced tabbed documents in 2024, where a single Doc can contain multiple tabs. Meeting transcripts may appear as a tab within a document that also contains meeting notes or agenda items. The pipeline must check whether a document has tabs (via the Docs API includeTabsContent parameter), iterate through tabs to find the transcript tab, and extract content from the correct tab. Standalone documents are simpler: the entire document body is the transcript.

Key Concept: Two Fetch Paths

For standalone docs, a single documents.get call returns the full body. For tabbed docs, you must first list tabs, identify the transcript tab (by title or content heuristics), then extract that tab’s body. The pipeline tries the tabbed path first (since it’s a superset) and falls back to standalone parsing if no tabs are found. This graceful degradation handles both old and new transcript formats.

OAuth2 Token Lifecycle

Accessing Google Drive and Docs APIs requires OAuth2 tokens. The pipeline uses a service account or user-delegated OAuth flow with refresh tokens. Access tokens expire after one hour, so the pipeline must detect expiration (via 401 responses or proactive TTL checks) and use the refresh token to obtain a new access token before retrying the failed request. Token storage must be secure—refresh tokens grant long-lived access to the user’s Drive.

Key Concept: Token Refresh Middleware

Rather than checking token expiry before every API call, implement refresh as HTTP middleware: intercept 401 responses, refresh the token, and automatically retry the original request. This keeps token management out of business logic and handles edge cases like tokens expiring mid-pagination. The middleware pattern also centralizes token storage and logging.

LLM-Powered Transcript Summarization

Raw meeting transcripts are verbose and hard to scan. The pipeline sends the normalized transcript text to Claude with a structured prompt that requests: a 2–3 sentence executive summary, a list of key decisions made, action items with assigned owners, topics discussed, and any competitive intelligence mentioned. The prompt includes the meeting participants list and their CRM roles to help the LLM attribute statements correctly.

Key Concept: Structured Output Extraction

The summarization prompt requests output in a specific JSON schema, not free-form text. This enables programmatic parsing of the LLM’s response: action items become task records, decisions become changelog entries, and the summary becomes the meeting record’s body. Using a schema also makes it easy to validate completeness—if the LLM omits a required field, the pipeline can retry with a more explicit prompt.

How It Works

The end-to-end pipeline runs as a scheduled job (cron or triggered after calendar events). It discovers new transcripts, fetches and parses them, generates summaries, reconciles participants with CRM contacts, and writes structured meeting records to the markdown data lake.

  1. Discover: Query Drive API for new/modified transcript documents since the last run.
  2. Fetch: For each transcript, refresh OAuth token if needed, then fetch document content via Docs API.
  3. Detect format: Check for tabbed document structure; extract transcript tab or full body accordingly.
  4. Parse: Convert Google Docs structural elements (paragraphs, timestamps, speaker labels) into plain text with speaker attribution.
  5. Summarize: Send parsed transcript to Claude with structured extraction prompt.
  6. Reconcile: Match speaker names from the transcript against CRM contact records using fuzzy matching.
  7. Write back: Create or update a meeting markdown file in the data lake with frontmatter (date, attendees, company) and the generated summary as body.
// TypeScript: Discover new transcripts via Drive API
import { google } from "googleapis";

interface TranscriptFile {
  id: string;
  name: string;
  modifiedTime: string;
  owners: Array<{ emailAddress: string }>;
}

async function discoverTranscripts(
  auth: any,
  sinceDate: string
): Promise<TranscriptFile[]> {
  const drive = google.drive({ version: "v3", auth });
  const transcripts: TranscriptFile[] = [];
  let pageToken: string | undefined;

  do {
    const res = await drive.files.list({
      q: [
        "mimeType = 'application/vnd.google-apps.document'",
        "name contains 'transcript'",
        `modifiedTime > '${sinceDate}'`,
        "trashed = false",
      ].join(" and "),
      fields: "nextPageToken, files(id, name, modifiedTime, owners)",
      pageSize: 100,
      pageToken,
    });

    if (res.data.files) {
      transcripts.push(...(res.data.files as TranscriptFile[]));
    }
    pageToken = res.data.nextPageToken ?? undefined;
  } while (pageToken);

  return transcripts;
}
// TypeScript: Fetch transcript content handling both formats
async function fetchTranscriptContent(
  auth: any,
  fileId: string
): Promise<string> {
  const docs = google.docs({ version: "v1", auth });

  // Try tabbed document first
  const doc = await docs.documents.get({
    documentId: fileId,
    includeTabsContent: true,
  });

  const tabs = doc.data.tabs;
  if (tabs && tabs.length > 0) {
    // Find the transcript tab
    const transcriptTab = tabs.find(
      tab => tab.tabProperties?.title?.toLowerCase().includes("transcript")
    ) ?? tabs[0];

    return extractTextFromBody(
      transcriptTab.documentTab?.body
    );
  }

  // Fallback: standalone document
  return extractTextFromBody(doc.data.body);
}

function extractTextFromBody(body: any): string {
  if (!body?.content) return "";

  return body.content
    .filter((el: any) => el.paragraph)
    .map((el: any) =>
      el.paragraph.elements
        .map((e: any) => e.textRun?.content ?? "")
        .join("")
    )
    .join("")
    .trim();
}
// TypeScript: LLM summarization with structured output
import Anthropic from "@anthropic-ai/sdk";

interface MeetingSummary {
  executive_summary: string;
  decisions: string[];
  action_items: Array<{
    owner: string;
    task: string;
    deadline?: string;
  }>;
  topics_discussed: string[];
  competitive_mentions: string[];
}

async function summarizeTranscript(
  transcript: string,
  participants: string[]
): Promise<MeetingSummary> {
  const client = new Anthropic();

  const response = await client.messages.create({
    model: "claude-sonnet-4-20250514",
    max_tokens: 2048,
    messages: [{
      role: "user",
      content: `Analyze this meeting transcript and extract structured information.

Participants: ${participants.join(", ")}

Transcript:
${transcript}

Respond with JSON matching this schema:
{
  "executive_summary": "2-3 sentence summary",
  "decisions": ["decision 1", ...],
  "action_items": [{"owner": "name", "task": "description", "deadline": "if mentioned"}],
  "topics_discussed": ["topic 1", ...],
  "competitive_mentions": ["competitor or product mentioned", ...]
}`
    }],
  });

  const text = response.content[0].type === "text"
    ? response.content[0].text : "";
  return JSON.parse(text);
}

Practical Applications

Automated CRM Updates

After every client call, the pipeline automatically creates a meeting record in the CRM data lake. The frontmatter includes the meeting date, attendees (reconciled to CRM contact IDs), associated company, and tags derived from topics discussed. The body contains the LLM-generated summary. This eliminates the most tedious part of CRM maintenance: manually logging meeting notes.

Action Item Tracking

Action items extracted from transcripts are written to a centralized task tracker or appended to the relevant client’s markdown file. The pipeline assigns owners based on speaker attribution in the transcript and flags items without clear deadlines. Weekly digest emails surface overdue action items by querying the data lake.

Relationship Intelligence

By accumulating meeting summaries over time, the system builds a timeline of every interaction with a client. An agent can query “what have we discussed with Acme Corp in the last 6 months?” and get a chronological summary derived from multiple meeting records. This contextual awareness enables more informed sales conversations.

Key Concept: CRM Contact Reconciliation

Transcript speaker names often don’t match CRM records exactly: “Sarah J.” in the transcript might be “Sarah Johnson” in the CRM. The reconciliation step uses fuzzy string matching (Levenshtein distance, token overlap) and email-to-name mapping (from calendar invite attendees) to link speakers to CRM contacts. Unresolved names are flagged for human review rather than silently dropped.

Implementation Patterns

Idempotent Processing

The pipeline must be safe to re-run on the same transcript. Each transcript is identified by its Drive file ID, and the pipeline maintains a processing log (or checks for an existing meeting record with that file ID) to avoid duplicate summaries. If reprocessing is desired (e.g., after improving the summarization prompt), the existing record is updated rather than duplicated.

Token Refresh with Retry

// TypeScript: OAuth2 token refresh middleware
import { OAuth2Client } from "google-auth-library";

class TokenManager {
  private client: OAuth2Client;
  private accessToken: string;
  private expiresAt: number;

  constructor(
    clientId: string,
    clientSecret: string,
    refreshToken: string
  ) {
    this.client = new OAuth2Client(clientId, clientSecret);
    this.client.setCredentials({ refresh_token: refreshToken });
    this.accessToken = "";
    this.expiresAt = 0;
  }

  async getAuth(): Promise<OAuth2Client> {
    const now = Date.now();
    // Refresh 5 minutes before expiry
    if (now >= this.expiresAt - 300_000) {
      const { credentials } = await this.client.refreshAccessToken();
      this.accessToken = credentials.access_token ?? "";
      this.expiresAt = credentials.expiry_date ?? now + 3600_000;
      this.client.setCredentials(credentials);
    }
    return this.client;
  }
}

// Usage: wrap every API call
async function withAuth<T>(
  tokenManager: TokenManager,
  fn: (auth: OAuth2Client) => Promise<T>
): Promise<T> {
  try {
    const auth = await tokenManager.getAuth();
    return await fn(auth);
  } catch (err: any) {
    if (err.code === 401) {
      // Force refresh and retry once
      const auth = await tokenManager.getAuth();
      return await fn(auth);
    }
    throw err;
  }
}

Fuzzy Contact Matching

// TypeScript: Reconcile transcript speakers to CRM contacts
interface CRMContact {
  id: string;
  full_name: string;
  email: string;
  company: string;
}

function reconcileSpeakers(
  speakerNames: string[],
  contacts: CRMContact[],
  calendarAttendees: Array<{ email: string; displayName: string }>
): Map<string, CRMContact | null> {
  const result = new Map<string, CRMContact | null>();

  // Build email-to-contact lookup from calendar attendees
  const emailToContact = new Map<string, CRMContact>();
  for (const attendee of calendarAttendees) {
    const contact = contacts.find(c => c.email === attendee.email);
    if (contact) {
      emailToContact.set(
        attendee.displayName.toLowerCase(),
        contact
      );
    }
  }

  for (const speaker of speakerNames) {
    const normalized = speaker.toLowerCase().trim();

    // Try exact match via calendar display name
    if (emailToContact.has(normalized)) {
      result.set(speaker, emailToContact.get(normalized)!);
      continue;
    }

    // Try fuzzy match against CRM contacts
    let bestMatch: CRMContact | null = null;
    let bestScore = 0;

    for (const contact of contacts) {
      const score = tokenOverlapScore(
        normalized, contact.full_name.toLowerCase()
      );
      if (score > bestScore && score >= 0.6) {
        bestScore = score;
        bestMatch = contact;
      }
    }

    result.set(speaker, bestMatch);
  }

  return result;
}

function tokenOverlapScore(a: string, b: string): number {
  const tokensA = new Set(a.split(/\s+/));
  const tokensB = new Set(b.split(/\s+/));
  const intersection = [...tokensA].filter(t => tokensB.has(t));
  return intersection.length / Math.max(tokensA.size, tokensB.size);
}
Key Concept: Calendar as Ground Truth for Attendees

The Google Calendar API provides the definitive attendee list with email addresses, while the transcript only has display names. Joining calendar attendees to transcript speakers gives you the email-to-name mapping needed for CRM reconciliation. Always query the associated calendar event before attempting fuzzy matching—it resolves most ambiguities without heuristics.

Common Pitfalls

Further Reading