Static analyses are a 2010s pattern. In 2026 the right delivery format for an internal research tool is an interactive dashboard where your team can ask their own questions without filing a Jira ticket.
This guide builds exactly that: a Streamlit app that lets anyone on your team type in a theme keyword ("agentic AI", "share buybacks", "China exposure", "supply chain"), filter by sector and quarter, and see who's talking about it most — visualized as a sortable table, a sector-breakdown bar chart, and a trend line across quarters.
Setup: ~45 minutes. Total monthly cost (Streamlit Community Cloud free + earningscalls API): ~$25-30.
What It Looks Like
The app has three panes:
┌─────────────────────────────────────────────────────────┐
│ Theme Tracker [theme input] [sector] [quarter] │ ← sidebar filters
├─────────────────────────────────────────────────────────┤
│ │
│ Mentions across S&P 500 (Q1 2026): 184 companies │
│ ───────────────────────────────────── │
│ 📊 Sector breakdown bar chart │
│ 📈 Trend across last 4 quarters │
│ 📋 Sortable table of top mentioners │
│ │
└─────────────────────────────────────────────────────────┘
The user types, dashboard updates in 2-3 seconds (cached past the first hit), team gets answers without you in the loop.
Step 1: Setup
pip install streamlit pandas requests plotly
app.py:
import streamlit as st
import pandas as pd
import plotly.express as px
import requests
import os
from datetime import date
API_KEY = os.environ.get("EARNINGSCALLS_API_KEY") or st.secrets["EARNINGSCALLS_API_KEY"]
BASE = "https://earningscalls.dev/api/v1"
HEADERS = {"X-API-Key": API_KEY}
st.set_page_config(
page_title="Earnings Theme Tracker",
page_icon="📞",
layout="wide",
)
# Instrumentation — show users how many requests they've used this session
if "req_count" not in st.session_state:
st.session_state.req_count = 0
def call(endpoint, params=None):
st.session_state.req_count += 1
r = requests.get(f"{BASE}{endpoint}", headers=HEADERS, params=params)
r.raise_for_status()
return r.json()
st.secrets lets you ship the API key safely on Streamlit Community Cloud. Locally, fall back to env var.
Step 2: Cache Aggressively
Streamlit re-runs the entire script on every interaction. Without caching, every slider drag would burn 50 API requests. Use @st.cache_data with reasonable TTLs:
@st.cache_data(ttl=3600) # 1 hour
def search_theme(query, sector, date_from, date_to):
"""Paginated search across all transcripts."""
results = []
page = 1
while True:
params = {
"q": query,
"type": "transcripts",
"date_from": date_from,
"date_to": date_to,
"page": page,
"limit": 50,
}
if sector and sector != "All sectors":
params["sector"] = sector
data = call("/search", params=params)
results.extend(data["results"])
if len(data["results"]) < 50:
break
page += 1
if page > 10: # safety cap
break
return results
Same query for the same hour returns instantly without an API roundtrip.
@st.cache_data(ttl=86400) # 24 hours — sector list rarely changes
def list_sectors():
return ["All sectors"] + sorted(call("/sectors")["sectors"])
Step 3: Sidebar Filters
with st.sidebar:
st.markdown("### Filters")
theme = st.text_input(
"Theme keyword",
value="agentic AI",
help="Try: AI agents, share buyback, China exposure, layoffs, supply chain"
)
sector = st.selectbox("Sector", list_sectors())
quarter = st.selectbox(
"Quarter",
["Q1 2026", "Q4 2025", "Q3 2025", "Q2 2025"],
index=0
)
st.divider()
st.caption(f"API requests this session: **{st.session_state.req_count}**")
st.caption(f"Plan budget left: ~{5000 - st.session_state.req_count} req/mo")
The session-counter footer is the trust-building detail. Users see the cost of each filter change in real time.
Step 4: Map Quarter To Date Window
QUARTER_WINDOWS = {
"Q1 2026": ("2026-01-01", "2026-04-30"),
"Q4 2025": ("2025-10-01", "2026-01-31"),
"Q3 2025": ("2025-07-01", "2025-10-31"),
"Q2 2025": ("2025-04-01", "2025-07-31"),
}
date_from, date_to = QUARTER_WINDOWS[quarter]
Window goes ~1 month past quarter-end to catch late filers. Adjust to your reporting calendar conventions.
Step 5: The Main View — Run Search + Aggregate
with st.spinner(f"Searching {quarter} transcripts for '{theme}'..."):
hits = search_theme(theme, sector, date_from, date_to)
# Aggregate per company
records = {}
for hit in hits:
ticker = hit["company"]["ticker"]
if ticker not in records:
records[ticker] = {
"ticker": ticker,
"company": hit["company"]["name"],
"sector": hit["company"].get("sector", "—"),
"mentions": 0,
"call_date": hit.get("call_date"),
"snippet": hit.get("snippet", "")[:200],
}
records[ticker]["mentions"] += hit.get("match_count", 1)
df = pd.DataFrame(records.values()).sort_values("mentions", ascending=False)
Step 6: Display — Three Visualizations
# Headline metric
total_companies = len(df)
total_mentions = df["mentions"].sum()
col1, col2, col3 = st.columns(3)
col1.metric("Companies mentioning", f"{total_companies:,}")
col2.metric("Total mentions", f"{total_mentions:,}")
col3.metric(
"Avg per company",
f"{(total_mentions / total_companies):.1f}" if total_companies else "—"
)
# Bar chart — sector breakdown
if not df.empty:
sector_summary = (
df.groupby("sector")
.agg(companies=("ticker", "count"), mentions=("mentions", "sum"))
.reset_index()
.sort_values("companies", ascending=False)
)
fig = px.bar(
sector_summary,
x="sector",
y="companies",
title=f"Companies mentioning '{theme}' by sector",
hover_data=["mentions"],
color="companies",
color_continuous_scale="Viridis",
)
st.plotly_chart(fig, use_container_width=True)
# Trend across quarters (slower — opt-in via expander)
with st.expander("Trend across last 4 quarters (extra API requests)"):
if st.button(f"Compute trend for '{theme}'"):
trend_data = []
for q_label, (df_, dt) in QUARTER_WINDOWS.items():
q_hits = search_theme(theme, sector, df_, dt)
trend_data.append({
"quarter": q_label,
"companies": len({h["company"]["ticker"] for h in q_hits}),
})
trend_df = pd.DataFrame(trend_data)
trend_df = trend_df.iloc[::-1] # chronological order
fig = px.line(
trend_df, x="quarter", y="companies", markers=True,
title=f"Companies mentioning '{theme}' over time"
)
st.plotly_chart(fig, use_container_width=True)
Putting the trend behind a button is intentional — it requires 3-4× the API requests of a single-quarter search, so users opt in only when they want it.
# Sortable table of top mentioners
st.markdown("### Top mentioners")
st.dataframe(
df[["ticker", "company", "sector", "mentions", "call_date"]],
use_container_width=True,
hide_index=True,
column_config={
"ticker": st.column_config.TextColumn("Ticker", width="small"),
"mentions": st.column_config.ProgressColumn(
"Mentions",
min_value=0,
max_value=int(df["mentions"].max()) if not df.empty else 1,
),
},
)
Streamlit's ProgressColumn renders inline bars in each row — visually compact, makes the distribution obvious at a glance.
Step 7: Deploy
Two options:
Streamlit Community Cloud (free):
# 1. Push your code to a GitHub repo
# 2. Connect at share.streamlit.io
# 3. Add EARNINGSCALLS_API_KEY in Secrets management
# Done.
Self-hosted (Docker, $5 VPS):
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8501
CMD ["streamlit", "run", "app.py", "--server.address=0.0.0.0"]
Cost Receipts
Per session of active use (analyst exploring 5-6 different theme queries):
- Each search: 1-6 pagination requests (depending on result count)
- Sector list: 1 request, cached 24h
- Average session: 15-30 API requests
For a small team (5 analysts, ~3 sessions/day each):
- 5 × 3 × 25 = 375 requests/day = ~7,500/month
That's above the 5,000/month Pro plan ceiling. Two options:
- Bump to Ultra plan ($39.99/mo, 25,000 requests) → comfortably handles ~50,000 sessions/month
- Tighter caching —
ttl=86400(24h) onsearch_themeinstead of 1h. Repeated queries within a day are free. Drops live usage to ~50% of current → Pro plan suffices.
For most teams the right move is the higher tier — analyst time saved by interactive exploration vastly outweighs $15/month upgrade.
Why Streamlit For This
I've shipped the same idea in three frameworks: a custom React/Next.js app, a Retool internal tool, and Streamlit. Streamlit wins for this shape:
- Code-first — your data analyst already writes Python; they don't need to learn JSX or Retool's drag-and-drop
- Free hosting that's actually production-ready (Community Cloud handles auth, HTTPS, custom domains)
- No frontend build step — push to GitHub, dashboard updates in seconds
- Inline charts via Plotly without writing chart configs
The tradeoff: Streamlit re-runs the whole script on every interaction, which is wasteful without good caching. The fix is exactly what we did above — @st.cache_data everywhere it makes sense.
If your use case has 100+ simultaneous users, switch to a real React frontend. For 5-50 internal users — Streamlit is dramatically faster to ship.
Extending This
Three additions that take this from "demo" to "indispensable internal tool":
- Snippet display in the table — show the 2-sentence excerpt around each match. Already in the hit response, just render it as an expandable row.
- Email digest —
streamlithas a scheduling pattern via a sidecar cron + headless query. Run the same dashboard query nightly, email top movers to your team. - Watchlist mode — instead of "show me all S&P 500 mentioning X", let the user upload a CSV of tickers and only show those. Easy: filter
dfby the uploaded ticker list before display.
Get an API key and you'll have this dashboard running before your team's afternoon stand-up.