How I Automated Customer Research (And Stopped Drowning in Transcripts)


We have 104 customer call recordings. Demos, onboarding sessions, feedback calls, user interviews. Every single one is transcribed, timestamped, and searchable - on my laptop, as markdown files, ready for Claude to query.

Six months ago, those transcripts lived in Fireflies. To use one, I’d log in, search, scroll through the web UI, copy-paste quotes into a Google Doc. By the time I found the insight I needed, I’d forgotten why I was looking.

Now I type /ux-research and Claude searches all 104 transcripts, finds relevant quotes, and challenges my assumptions with actual customer data. The infrastructure that makes this possible is embarrassingly simple: a 300-line bash script that runs against the Fireflies API.

Here’s exactly how to build it.

What You’ll End Up With

Before we get into the how - here’s what the output looks like. Each transcript becomes a structured markdown file:

research/calls/
├── 2026-03-03-interview-utilisateur-billabex-katia.md
├── 2026-03-02-visio-mickael-et-yassine-billabex.md
├── 2026-02-27-billabex-v2-iniwave.md
└── ... (104 files, 47,000 lines of searchable research)

Each file looks like this:

# Interview utilisateur Billabex (Katia)

**Date:** 2026-03-03
**Duration:** 38.0 min
**Host:** N/A
**Participants:** katia, gilles
**Fireflies ID:** 01KJ889OFHIOEZHIOU7846872A1

## Summary

- Suppression de comptes Zoho Books: Un compte ne peut être supprimé
  tant qu'il est connecté à Books...
- Gestion des tâches: Annuler une tâche ferme la relance...

## Action Items

_No action items identified_

## Keywords

Bilabex, Zoho Books, tâches, facturation, synchronisation

## Transcript

[00:01] **Katia**: Il va arriver.
[00:03] **Gilles**: On va l'attendre.
[00:10] **Franck**: Bonjour, Gilles.

Metadata at the top. AI-generated summary and action items. Full transcript with speaker names and timestamps. All in plain markdown - no proprietary format, no database, no special tools to read it.

Why markdown? Because every AI coding tool - Claude Code, Cursor, Copilot - can read markdown natively. Your transcripts become first-class context, not locked in a SaaS you have to tab into.

What You Need

  • A Fireflies.ai account with transcripts
  • A Fireflies API key (Settings > Developer > API Key)
  • curl and jq installed (brew install jq on Mac)
  • A folder where you want your transcripts to land

That’s it. No Node.js, no Python, no dependencies beyond what’s already on your machine.

Step 1: Store Your API Key Securely

First rule: never put API keys in your repo. This setup script stores your key in ~/.config/fireflies/config.json with restricted file permissions:

#!/bin/bash

# Fireflies Setup Script
# Securely stores Fireflies API key in user config directory
# Usage: ./scripts/setup-fireflies.sh [API_KEY]

set -euo pipefail

CONFIG_DIR="$HOME/.config/fireflies"
CONFIG_FILE="$CONFIG_DIR/config.json"

# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'

log_info() {
    echo -e "${GREEN}[INFO]${NC} $1"
}

log_warn() {
    echo -e "${YELLOW}[WARN]${NC} $1"
}

log_error() {
    echo -e "${RED}[ERROR]${NC} $1"
}

# Check if jq is installed
if ! command -v jq &> /dev/null; then
    log_error "jq is required but not installed"
    log_info "Install with: brew install jq"
    exit 1
fi

# Get API key from argument or prompt
if [ $# -eq 1 ]; then
    API_KEY="$1"
else
    echo -n "Enter your Fireflies API key: "
    read -r API_KEY
fi

# Validate API key format (basic check)
if [ -z "$API_KEY" ]; then
    log_error "API key cannot be empty"
    exit 1
fi

# Create config directory with restricted permissions
mkdir -p "$CONFIG_DIR"
chmod 700 "$CONFIG_DIR"

# Save API key to config file
cat > "$CONFIG_FILE" << EOF
{
  "api_key": "$API_KEY",
  "configured_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF

# Restrict config file permissions (read/write for owner only)
chmod 600 "$CONFIG_FILE"

log_info "API key securely stored in: $CONFIG_FILE"
log_info "File permissions set to 600 (owner read/write only)"
log_warn "Never commit this file to git or share it"

# Test API key
log_info "Testing API connection..."

TEST_RESPONSE=$(curl -s -X POST https://api.fireflies.ai/graphql \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer $API_KEY" \
    -d '{"query": "query { transcripts(limit: 1) { id title } }"}')

if echo "$TEST_RESPONSE" | jq -e '.data.transcripts' > /dev/null 2>&1; then
    log_info "API key is valid and working!"
    log_info "You can now run: ./scripts/sync-fireflies.sh"
else
    log_error "API key validation failed"
    echo "$TEST_RESPONSE" | jq '.errors' 2>/dev/null || echo "$TEST_RESPONSE"
    exit 1
fi

Run it:

chmod +x scripts/setup-fireflies.sh
./scripts/setup-fireflies.sh

It prompts for your key, stores it with chmod 600, and tests the connection. If you see “API key is valid and working!” - you’re good.

Step 2: The Sync Script

This is the core. It fetches transcripts from Fireflies’ GraphQL API, converts them to structured markdown, and tracks what’s already been synced so you never re-process the same call:

#!/bin/bash

# Fireflies Transcript Sync Script
# Fetches new transcripts from Fireflies API and saves them as markdown files
# Usage: ./scripts/sync-fireflies.sh

set -euo pipefail

# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
CALLS_DIR="$PROJECT_ROOT/research/calls"
CONFIG_FILE="$HOME/.config/fireflies/config.json"
STATE_FILE="$PROJECT_ROOT/research/calls/.sync-state.json"

# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color

# Functions
log_info() {
    echo -e "${GREEN}[INFO]${NC} $1" >&2
}

log_warn() {
    echo -e "${YELLOW}[WARN]${NC} $1" >&2
}

log_error() {
    echo -e "${RED}[ERROR]${NC} $1" >&2
}

# Check dependencies
check_dependencies() {
    local missing_deps=()

    for cmd in curl jq; do
        if ! command -v "$cmd" &> /dev/null; then
            missing_deps+=("$cmd")
        fi
    done

    if [ ${#missing_deps[@]} -ne 0 ]; then
        log_error "Missing dependencies: ${missing_deps[*]}"
        log_info "Install them with: brew install ${missing_deps[*]}"
        exit 1
    fi
}

# Load API key from config
load_api_key() {
    if [ ! -f "$CONFIG_FILE" ]; then
        log_error "Config file not found: $CONFIG_FILE"
        log_info "Run: scripts/setup-fireflies.sh to configure API key"
        exit 1
    fi

    FIREFLIES_API_KEY=$(jq -r '.api_key' "$CONFIG_FILE")

    if [ -z "$FIREFLIES_API_KEY" ] || [ "$FIREFLIES_API_KEY" = "null" ]; then
        log_error "Invalid API key in config file"
        exit 1
    fi
}

# Load synced transcript IDs from state file
load_synced_ids() {
    if [ -f "$STATE_FILE" ]; then
        jq -r '.synced_ids[]' "$STATE_FILE" 2>/dev/null || true
    fi
}

# Check if ID is already synced
is_synced() {
    local id="$1"
    local synced_ids="$2"
    echo "$synced_ids" | grep -qx "$id"
}

# Add ID to synced list
add_synced_id() {
    local new_id="$1"

    local existing_ids=$(load_synced_ids)
    local all_ids=$(echo -e "$existing_ids\n$new_id" | sort -u | grep -v '^$')

    mkdir -p "$(dirname "$STATE_FILE")"

    local ids_json=$(echo "$all_ids" | jq -R . | jq -s .)
    echo "{\"synced_ids\": $ids_json, \"last_sync\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > "$STATE_FILE"
}

# Fetch transcripts from Fireflies API
fetch_transcripts() {
    local limit="${1:-50}"

    log_info "Fetching transcripts from Fireflies API..."

    local query='query Transcripts {
  transcripts(limit: '"$limit"') {
    id
    title
    date
    duration
    host_email
    participants
    summary {
      overview
      action_items
      keywords
    }
    sentences {
      text
      speaker_name
      start_time
    }
  }
}'

    local response=$(curl -s -X POST https://api.fireflies.ai/graphql \
        -H "Content-Type: application/json" \
        -H "Authorization: Bearer $FIREFLIES_API_KEY" \
        -d "{\"query\": $(echo "$query" | jq -Rs .)}")

    if [ $? -ne 0 ]; then
        log_error "Failed to fetch transcripts from API"
        exit 1
    fi

    # Check for API errors
    local error=$(echo "$response" | jq -r '.errors[0].message // empty')
    if [ -n "$error" ]; then
        log_error "API error: $error"
        exit 1
    fi

    echo "$response"
}

# Convert timestamp to readable date
format_date() {
    local timestamp="$1"
    # Fireflies uses milliseconds since epoch
    if [[ "$OSTYPE" == "darwin"* ]]; then
        date -r $((timestamp / 1000)) '+%Y-%m-%d'
    else
        date -d @$((timestamp / 1000)) '+%Y-%m-%d'
    fi
}

# Format duration in minutes
format_duration() {
    local duration="$1"
    printf "%.1f min" "$duration"
}

# Create markdown file from transcript
create_markdown() {
    local transcript="$1"
    local synced_ids="$2"

    local id=$(echo "$transcript" | jq -r '.id')
    local title=$(echo "$transcript" | jq -r '.title')
    local date=$(echo "$transcript" | jq -r '.date')
    local duration=$(echo "$transcript" | jq -r '.duration')
    local host_email=$(echo "$transcript" | jq -r '.host_email // "N/A"')

    # Check if already synced
    if is_synced "$id" "$synced_ids"; then
        log_warn "Skipping already synced: $title"
        return 1
    fi

    local formatted_date=$(format_date "$date")
    local formatted_duration=$(format_duration "$duration")

    # Create filename: YYYY-MM-DD-title-slug.md
    local slug=$(echo "$title" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//')
    local filename="$formatted_date-$slug.md"
    local filepath="$CALLS_DIR/$filename"

    # Get participants as comma-separated list
    local participants=$(echo "$transcript" | jq -r '.participants[]?' 2>/dev/null | tr '\n' ', ' | sed 's/, $//')
    if [ -z "$participants" ]; then
        participants="N/A"
    fi

    # Get overview
    local overview=$(echo "$transcript" | jq -r '.summary.overview // "No summary available"')

    # Start creating markdown content
    cat > "$filepath" << EOF
# $title

**Date:** $formatted_date
**Duration:** $formatted_duration
**Host:** $host_email
**Participants:** $participants
**Fireflies ID:** $id

## Summary

$overview

## Action Items

EOF

    # Add action items
    local has_action_items=false
    while IFS= read -r item; do
        if [ -n "$item" ]; then
            echo "- [ ] $item" >> "$filepath"
            has_action_items=true
        fi
    done < <(echo "$transcript" | jq -r '.summary.action_items[]?' 2>/dev/null || true)

    if [ "$has_action_items" = false ]; then
        echo "_No action items identified_" >> "$filepath"
    fi

    # Add keywords
    local keywords=$(echo "$transcript" | jq -r '.summary.keywords[]?' 2>/dev/null | tr '\n' ', ' | sed 's/, $//')
    cat >> "$filepath" << EOF

## Keywords

$keywords

## Transcript

EOF

    # Add full transcript with speakers and timestamps
    local has_sentences=false
    while IFS= read -r sentence; do
        if [ -n "$sentence" ]; then
            local speaker=$(echo "$sentence" | jq -r '.speaker_name // "Unknown"')
            local text=$(echo "$sentence" | jq -r '.text')
            local start_time=$(echo "$sentence" | jq -r '.start_time // 0' | cut -d. -f1)
            local minutes=$((start_time / 60))
            local seconds=$((start_time % 60))

            printf "[%02d:%02d] **%s**: %s\n" "$minutes" "$seconds" "$speaker" "$text" >> "$filepath"
            has_sentences=true
        fi
    done < <(echo "$transcript" | jq -c '.sentences[]?' 2>/dev/null || true)

    if [ "$has_sentences" = false ]; then
        echo "_Transcript not available_" >> "$filepath"
    fi

    log_info "Created: $filename"

    # Return the ID to mark as synced
    echo "$id"
}

# Main execution
main() {
    log_info "Starting Fireflies transcript sync..."

    check_dependencies
    load_api_key

    # Ensure calls directory exists
    mkdir -p "$CALLS_DIR"

    # Load previously synced IDs
    local synced_ids=$(load_synced_ids)

    # Fetch transcripts
    local response=$(fetch_transcripts 50)
    local count=$(echo "$response" | jq '.data.transcripts | length')

    if [ "$count" -eq 0 ]; then
        log_info "No transcripts found"
        exit 0
    fi

    log_info "Found $count transcripts"

    # Process each transcript
    local new_count=0
    while IFS= read -r transcript; do
        local new_id=$(create_markdown "$transcript" "$synced_ids" || true)
        if [ -n "$new_id" ]; then
            add_synced_id "$new_id"
            ((new_count++))
        fi
    done < <(echo "$response" | jq -c '.data.transcripts[]')

    if [ "$new_count" -gt 0 ]; then
        log_info "Successfully synced $new_count new transcripts"
    else
        log_info "No new transcripts to sync"
    fi

    log_info "Sync complete!"
}

# Run main function
main "$@"

That’s the whole thing. Let me walk through the key decisions.

Why This Design Works

GraphQL over REST. Fireflies uses a GraphQL API. One query fetches everything - metadata, summary, action items, keywords, and the full transcript with speaker names and timestamps. No pagination headaches for a typical call volume.

State tracking. The .sync-state.json file stores IDs of transcripts already processed. Run the script ten times - it only creates files for new calls. This is what makes it safe to automate.

Slug-based filenames. Each file gets named YYYY-MM-DD-title-slug.md. When you’re looking for a specific call, the date and title are right in the filename. No UUIDs, no lookup tables.

Structured markdown output. The format isn’t random. Metadata at the top means AI tools can quickly filter by date or participants. The summary section gives a quick overview without reading the full transcript. Keywords enable grep-based discovery. And the full transcript with [MM:SS] **Speaker**: text format means you can trace any insight back to who said it and when.

Step 3: Run It

chmod +x scripts/sync-fireflies.sh
./scripts/sync-fireflies.sh

First run:

[INFO] Starting Fireflies transcript sync...
[INFO] Fetching transcripts from Fireflies API...
[INFO] Found 50 transcripts
[INFO] Created: 2026-03-03-interview-utilisateur-billabex-katia.md
[INFO] Created: 2026-03-02-visio-mickael-et-yassine-billabex.md
...
[INFO] Successfully synced 50 new transcripts
[INFO] Sync complete!

Second run:

[INFO] No new transcripts to sync
[INFO] Sync complete!

The Fireflies API returns 50 transcripts per call by default. If you have more, adjust the limit in fetch_transcripts or run it multiple times after clearing the state file.

Step 4: Make It Automatic

A cron job runs the sync every 15 minutes:

crontab -e

Add:

*/15 * * * * /path/to/your/project/scripts/sync-fireflies.sh >> /tmp/fireflies-sync.log 2>&1

After a team call ends, Fireflies transcribes it within a few minutes. Fifteen minutes later, the transcript is sitting in my repo as a markdown file. I never think about it.

What This Actually Unlocks

The sync is just plumbing. The value is what happens downstream.

When I run /ux-research to explore a feature idea, Claude searches all 104 transcripts for relevant mentions. It finds patterns I’d miss manually - a pain point mentioned by 12 different customers, a feature request that keeps coming up in onboarding calls, or a workflow complaint that contradicts my assumptions.

Here’s a real example. I was designing an invoice import flow and assumed the main issue was file format compatibility. Claude searched the transcripts, found 47 mentions of “data quality” across calls, and pushed back:

The recurring complaint is invoices importing with wrong amounts or missing client references - not format issues.

That single correction - backed by actual customer quotes with timestamps - saved weeks of building the wrong thing.

Without the sync, those insights were locked in a web UI I’d check once and forget. With it, every customer conversation becomes permanent, searchable context that gets smarter as the corpus grows.

The 30-Minute Version

If you want to try this today:

  1. Get your Fireflies API key (Settings > Developer)
  2. Save the two scripts above as setup-fireflies.sh and sync-fireflies.sh
  3. Run setup, then sync
  4. Open one of the generated markdown files in your AI coding tool
  5. Ask it a question about the call

That’s it. No framework, no database, no infrastructure. Just an API, a bash script, and markdown files your AI can read.

The whole thing took me an afternoon to build. It’s been running for three months without a single failure. Sometimes the boring solution is the right one.


Part of a series on building an AI-powered operating system for a 3-person SaaS - see also I Built My Operating System with AI and The Day I Stopped Writing Prompts and Started Writing Commands.