Product Intelligence
187 lines

Plan overview

Voice of Customer Research

Scan recent customer calls for a target keyword, extract the exact quotes around it, synthesize an opinionated answer to a research question, and publish it to Google Drive.

Best for

Product marketing · Product · Founders

Runs with

Avoma · Google Drive · Keyword research

At a glance

04

Outputs

01

Capabilities

03

Teams

Core outputs

Executive summary

Key themes

Supporting quotes

Shared Drive doc

Capability requirements

MCP

What it solves

The truth about a feature or objection is buried across dozens of call transcripts, and reading them one by one doesn't scale.

Turns hours of transcript reading into one structured research pass.

Grounds every claim in verbatim customer quotes with links.

Publishes a shareable artifact instead of a one-off answer.

Workflow

1

Fetch recent external meetings and locate keyword mentions per transcript.

2

Extract the verbatim quotes and surrounding context around each mention.

3

Synthesize a sharp, evidence-backed answer and push it to Google Drive.

Implementation

Review the underlying FML plan — the sessions, tools, and typed schemas that define this workflow — and see exactly how it is instructed for repeatable execution.

FML Plan

voc.fml

187 lines

Copy
system("You are an elite Product Marketing Manager and senior Voice of the Customer analyst for Barndoor AI. Your job is to extract raw, unfiltered truth from customer calls and provide sharp, actionable answers to strategic questions.")

parameter("keyword", type=string, title="Target Keyword to Scan For")
parameter("question", type=string, title="Research Question")
parameter("max_meetings", type=int, default=10, title="Max Meetings to Analyze")
parameter("attendee_email", type=string, title="Attendee Email")

require mcp Avoma
require mcp Google-Drive

# ── Session 1: Fetch meetings ─────────────────────────────────────────────────
session("fetch_meetings") {
    use mcp Avoma

    + Call get_current_datetime to get the current UTC time.
      Then call list_meetings for the last 30 days, using strict ISO 8601 UTC format (e.g. YYYY-MM-DDThh:mm:ssZ) for from_date and to_date.
      Use attendee_emails: ["{{ .params.attendee_email }}"]
      Paginate if needed, up to {{ .params.max_meetings }} total meetings.
      Only include meetings where a transcript or recording is available.

    - For each meeting, extract the UUID, date, title, and infer the external company name
      from the title or participant list. Exclude internal only meetings.
      Limit output strictly to {{ .params.max_meetings }} meetings.

    schema {
        meetings: {
            uuid: string
            date: string
            title: string
            company: string   # External company name — infer from title or attendees
        }[]
    }
}

# ── Session 2: Read transcript and extract relevant excerpts ──────────────────
session("read_transcript",
        after="fetch_meetings",
        expect="len(context.fetch_meetings.meetings) > 0",
        iterate="context.fetch_meetings.meetings") {

    # 1. Fetch the full transcript explicitly via PreCall
    call("get_meeting_transcript") -> transcript {
        uuid = "{{ .it.uuid }}"
    }

    # 2. Use pure JS to find the keyword and slice out only the relevant surrounding text
    call("extract_snippets") -> snippets {
        keyword = "{{ .params.keyword }}"
        transcript = $(vars.transcript)
        code(
            (() => {
                const kw = args.keyword.toLowerCase();
                const t = typeof args.transcript === 'string' ? args.transcript : JSON.stringify(args.transcript);
                let result = "";
                let searchIdx = 0;
                let matches = 0;
                
                // Find up to 5 occurrences of the keyword and grab a window of text around each
                while (matches < 5) {
                    const idx = t.toLowerCase().indexOf(kw, searchIdx);
                    if (idx === -1) break;
                    
                    const start = Math.max(0, idx - 1000);
                    const end = Math.min(t.length, idx + 1000);
                    result += "\n\n... " + t.substring(start, end) + " ...\n";
                    
                    searchIdx = idx + kw.length + 1000;
                    matches++;
                }
                
                return result === "" ? "Keyword not found in this transcript." : result;
            })();
        )
    }

    # 3. Inject ONLY the small snippets into the LLM context, avoiding the 504 timeout!
    context "Transcript snippets containing the keyword:\n{{ .vars.snippets }}"

    - You are reading extracted transcript snippets from a meeting between Barndoor AI and {{ .it.company }} on {{ .it.date }}.
      Meeting URL: https://app.avoma.com/meetings/{{ .it.uuid }}
      Transcript URL: https://app.avoma.com/meetings/{{ .it.uuid }}/transcript

      The snippets below contain the target keyword: "{{ .params.keyword }}"

      Read the surrounding conversation in the snippets to see if it helps answer this specific question:
      "{{ .params.question }}"

      If the extracted text provides relevant information, extract the quote and context. 
      If the keyword was used in a way that doesn't help answer the question, return empty arrays. Do not force matches.
      Be sure to output both the standard meeting URL and the transcript URL provided above.

    schema {
        company: string
        date: string
        meeting_url: string
        transcript_url: string        # URL to the full Avoma transcript
        relevant_excerpts: {
            speaker: string
            quote: string             # The exact, raw quote from the transcript snippets
            context: string           # Brief explanation of what was being discussed
        }[]
    }[]
}

# ── Session 3: Synthesize Answer ──────────────────────────────────────────────
session("synthesize_answer", after="read_transcript") {
    context "Transcript excerpts matching keyword '{{ .params.keyword }}': {{ .context.read_transcript | json }}"

    - You are analyzing the extracted transcript data to answer the user's specific question:
      "{{ .params.question }}"

      Write a highly synthesized, opinionated answer based ONLY on the provided excerpts.
      - Don't just list what was saidsynthesize the underlying themes.
      - Highlight the most powerful quotes to prove your points.
      - If the transcripts do not contain enough information to confidently answer the question, explicitly state that.

    schema {
        question_asked: string
        target_keyword: string
        executive_summary: string       # A crisp, 1-2 paragraph answer to the question
        key_themes: string[]            # 2-3 main takeaways or patterns discovered
        supporting_evidence: {
            company: string
            quote: string
            meeting_url: string
            transcript_url: string
            relevance: string           # Why this quote helps answer the question
        }[]
    }
}

# ── Session 4: Push to Google Drive ───────────────────────────────────────────
session("push_to_drive", after="synthesize_answer") {
    # Removed `use mcp Google-Drive` to prevent invalid MCP schema from crashing the LLM.

    # 1. Format the synthesized answer as Markdown in JS
    call("format_markdown") -> md_content {
        s = $(context.synthesize_answer)
        code(
            (() => {
                const s = args.s || {};
                let md = `# VoC Synthesis: ${s.target_keyword || "N/A"}\n\n`;
                md += `**Question:** ${s.question_asked || "N/A"}\n\n`;
                md += `## Executive Summary\n${s.executive_summary || "N/A"}\n\n`;
                
                md += `## Key Themes\n`;
                (s.key_themes || []).forEach(t => {
                    md += `- ${t}\n`;
                });
                md += `\n`;
                
                md += `## Supporting Evidence\n`;
                (s.supporting_evidence || []).forEach(e => {
                    md += `### ${e.company || "Unknown Company"}\n`;
                    md += `> "${e.quote}"\n\n`;
                    md += `**Relevance:** ${e.relevance}\n\n`;
                    if (e.meeting_url) md += `[Meeting Link](${e.meeting_url}) | `;
                    if (e.transcript_url) md += `[Transcript](${e.transcript_url})\n\n`;
                });
                
                return md;
            })();
        )
    }

    # 2. Call upload_file explicitly as a PreCall
    call("upload_file") -> uploadResult {
        name     = "VoC Synthesis: {{ .params.keyword }}"
        parents  = "1Lp_fvBgjjVoDK-qPZX-AlpKSjOTn19G4"
        mimeType = "application/vnd.google-apps.document"
        content  = $(vars.md_content)
    }

    context "Upload Result: {{ .vars.uploadResult | json }}"

    - The file has been successfully uploaded to Google Drive.
      Extract the returned `id` and `name` from the upload result context.
      CRITICAL WARNING: The upload result context includes the parent folder ID (`1Lp_fvBgjjVoDK-qPZX-AlpKSjOTn19G4`). 
      DO NOT extract this folder ID as the file ID. You MUST extract the unique `id` generated for the new file.
      Then, construct the `file_url` using this format: https://docs.google.com/document/d/<id>/edit

    schema {
        file_id: string
        file_name: string
        file_url: string
    }
}