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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
// 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);
}
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 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.
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.
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.
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.
// 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;
}
}
// 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);
}
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.
includeTabsContent parameter was added in a later version of the Docs API. Using an older client library silently returns only the first tab’s content, or an empty body for tabbed-only documents. Pin your API version and test with both document formats.