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

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

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

  1. Bump to Ultra plan ($39.99/mo, 25,000 requests) → comfortably handles ~50,000 sessions/month
  2. Tighter cachingttl=86400 (24h) on search_theme instead 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:

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

  1. 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.
  2. Email digeststreamlit has a scheduling pattern via a sidecar cron + headless query. Run the same dashboard query nightly, email top movers to your team.
  3. 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.