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):

For a small team (5 analysts, ~3 sessions/day each):

That's above the 3,000/month Pro plan ceiling. Two options:

  1. Bump to Ultra plan ($39.99/mo, 20,000 requests) → comfortably handles ~40,000 sessions/month
  2. Tighter caching, with ttl=86400 (24h) on search_theme instead of 1h. Repeated queries within a day are free. Drops live usage to 50% 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:

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":

  1. 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.
  2. Send an email digest. streamlit has a scheduling pattern via a sidecar cron + headless query. Run the same dashboard query nightly, email top movers to your team.
  3. 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 df by the uploaded ticker list before display.

Get an API key and you'll have this dashboard running before your team's afternoon stand-up.

Related reading