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", "n/a"),
"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 "n/a"
)
# 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. It's visually compact and 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 3,000/month Pro plan ceiling. Two options:
- Bump to Ultra plan ($39.99/mo, 20,000 requests) → comfortably handles ~40,000 sessions/month
- Tighter caching, with
ttl=86400(24h) onsearch_themeinstead of 1h. Repeated queries within a day are free. Drops live usage to50% of current (3,750/month), still just above Pro, so trim the ticker list or combine with option 1.
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:
- It's 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)
- There's 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 much faster to ship.
Extending This
Three additions that take this from "demo" to "indispensable internal tool":
- Add snippet display in the table and show the 2-sentence excerpt around each match. Already in the hit response, just render it as an expandable row.
- Send an email digest.
streamlithas a scheduling pattern via a sidecar cron + headless query. Run the same dashboard query nightly, email top movers to your team. - Add a 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.