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)
curlandjqinstalled (brew install jqon 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:
- Get your Fireflies API key (Settings > Developer)
- Save the two scripts above as
setup-fireflies.shandsync-fireflies.sh - Run setup, then sync
- Open one of the generated markdown files in your AI coding tool
- 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.