Most of the analysts I know follow earnings season the same way: a watchlist of 20-40 tickers, a finger refreshing Bloomberg, frantic Slack threads at 4:01 PM ET.
The waste here isn't intellectual — it's just lookup latency. By the time you've opened the transcript, scrolled to guidance, and skimmed the Q&A, the trade is already pricing in. The first 90 seconds after a call drops is where the alpha lives.
Here's a Slack bot that fixes that. It runs on a $5/month server, watches your tickers, and the moment any of them publish a new earnings call, posts a Slack message with:
- Ticker + company + call date
- 4-bullet AI-generated summary
- Top 3 key metrics extracted from the transcript
- Direct link to the full transcript
Real-world setup time: 30 minutes. Recurring cost: $3-7/month depending on watchlist size.
Architecture
Three moving parts:
- Cron loop — every 5 minutes during earnings season (8 AM - 8 PM ET on weekdays), check earningscalls.dev for new transcripts on your watchlist
- Claude summarization — when a new transcript appears, send to Claude (Anthropic API) for a structured 4-bullet summary
- Slack post — format and send to your channel via incoming webhook
That's it. No database (we use a flat JSON file for "last seen"), no Redis, no auth flows.
Step 1: Project Setup
mkdir earnings-bot && cd earnings-bot
npm init -y
npm install node-cron @anthropic-ai/sdk dotenv
.env:
EARNINGSCALLS_API_KEY=ec_xxxxx
ANTHROPIC_API_KEY=sk-ant-xxxxx
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T0.../B0.../xxxxx
WATCHLIST=NVDA,MSFT,AAPL,META,GOOG,AMZN,TSLA,CRM,NFLX,ORCL
The WATCHLIST is a comma-separated ticker list you maintain in env.
Step 2: Track What's New
Cleanest pattern: store the latest earnings_id we've already alerted on, per ticker, in a JSON file. On each tick, fetch the latest call per ticker — if the ID differs from what we stored, it's new.
// state.js
import fs from "node:fs";
const FILE = "./.state.json";
export function loadState() {
try { return JSON.parse(fs.readFileSync(FILE, "utf8")); }
catch { return {}; } // empty on first run — all tickers will trigger
}
export function saveState(state) {
fs.writeFileSync(FILE, JSON.stringify(state, null, 2));
}
On first run, the file doesn't exist → every ticker will be considered "new" and alert. To avoid spamming on bootstrap, run once with INIT_ONLY=true to populate state without sending alerts. (Code below shows this.)
Step 3: Fetch + Detect New Calls
// api.js
const BASE = "https://earningscalls.dev/api/v1";
let requestCount = 0;
export function getRequestCount() { return requestCount; }
export async function call(endpoint, params = {}) {
requestCount++;
const url = new URL(`${BASE}${endpoint}`);
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
const r = await fetch(url, {
headers: { "X-API-Key": process.env.EARNINGSCALLS_API_KEY },
});
if (!r.ok) throw new Error(`API ${r.status}: ${await r.text()}`);
return r.json();
}
export async function getLatestCall(ticker) {
const d = await call("/transcripts/recent", { ticker, limit: 1 });
return d.results || null;
}
We instrument requestCount because — as in every post in this series — knowing the literal API cost is the basis for sizing the system.
Step 4: Summarize With Claude
This is where you get real signal. A raw transcript is 8,000-15,000 words; nobody on your team is reading it before market open. Claude can compress to 4 actionable bullets in ~6 seconds.
// summarize.js
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic(); // reads ANTHROPIC_API_KEY from env
const PROMPT = `You are an equity research analyst. Read the earnings call
transcript below and produce a structured summary.
Output exactly this format:
BULLETS:
- [bullet 1]
- [bullet 2]
- [bullet 3]
- [bullet 4]
METRICS:
- [metric 1: value]
- [metric 2: value]
- [metric 3: value]
Rules:
- Bullets must capture: (1) headline result vs consensus, (2) guidance change,
(3) most important strategic update, (4) most concerning risk surfaced in Q&A
- Metrics must be quantitative figures stated in the call (revenue, margin,
guidance ranges, KPIs)
- Be terse. No marketing language. No "the company" — use the ticker.
TRANSCRIPT:
{transcript}`;
export async function summarize(ticker, transcriptText) {
const msg = await client.messages.create({
model: "claude-haiku-4-5", // fast + cheap, plenty good for this
max_tokens: 512,
messages: [{
role: "user",
content: PROMPT.replace("{transcript}", transcriptText.slice(0, 60000))
}],
});
return msg.content.text;
}
A few choices:
claude-haiku-4-5— for summary tasks at this length, Haiku is 5× cheaper than Sonnet and the quality difference is marginal. Use Sonnet only if you find Haiku miscategorizing your asks.- Truncate to 60k chars — covers ~12k words = the full call for most companies. If you have outlier-long calls (banks), bump to 100k.
- Structured output via prompt — easier than tool-use for this simple shape. We parse the response below.
Step 5: Post To Slack
// slack.js
export async function postToSlack(call, summary) {
const blocks = [
{
type: "header",
text: { type: "plain_text", text: `📞 ${call.ticker} — Earnings Call` }
},
{
type: "section",
fields: [
{ type: "mrkdwn", text: `*Company:*\n${call.company_name}` },
{ type: "mrkdwn", text: `*Call Date:*\n${call.call_date}` },
]
},
{
type: "section",
text: { type: "mrkdwn", text: "```" + summary + "```" }
},
{
type: "actions",
elements: [{
type: "button",
text: { type: "plain_text", text: "Read full transcript →" },
url: `https://earningscalls.dev/transcripts/${call.slug}`,
style: "primary",
}]
}
];
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ blocks }),
});
}
Slack's Block Kit is verbose but renders nicely — the bot's messages look intentional, not like a script vomit.
Step 6: The Cron Loop
Tie it all together:
// main.js
import "dotenv/config";
import cron from "node-cron";
import { call, getLatestCall, getRequestCount } from "./api.js";
import { loadState, saveState } from "./state.js";
import { summarize } from "./summarize.js";
import { postToSlack } from "./slack.js";
const TICKERS = process.env.WATCHLIST.split(",").map(t => t.trim());
const INIT_ONLY = process.env.INIT_ONLY === "true";
async function checkAll() {
const state = loadState();
let alertsSent = 0;
for (const ticker of TICKERS) {
try {
const latest = await getLatestCall(ticker);
if (!latest) continue;
const lastSeenId = state[ticker];
if (lastSeenId === latest.earnings_id) continue; // nothing new
console.log(`[NEW] ${ticker} — ${latest.call_date}`);
if (!INIT_ONLY) {
// Fetch full transcript text
const tdata = await call(`/transcripts/${latest.earnings_id}`);
const transcript = tdata.transcript || "";
// Summarize via Claude
const summary = await summarize(ticker, transcript);
// Post to Slack
await postToSlack({ ...latest, ticker }, summary);
alertsSent++;
}
// Update state regardless (so we don't alert twice)
state[ticker] = latest.earnings_id;
} catch (err) {
console.error(`[ERR] ${ticker}:`, err.message);
}
}
saveState(state);
console.log(`[TICK] ${TICKERS.length} tickers checked, ${alertsSent} alerts sent, ${getRequestCount()} API requests so far`);
}
// Run immediately on startup
checkAll().catch(console.error);
// Then every 5 minutes during US market hours
// (Mon-Fri 12:30 UTC - 23:30 UTC = 8:30 AM - 7:30 PM ET, +30min buffer for after-market calls)
cron.schedule("*/5 12-23 * * 1-5", () => {
checkAll().catch(console.error);
});
Bootstrap once with INIT_ONLY=true node main.js to populate .state.json without firing alerts. Then run normally.
Deploying
This runs anywhere Node.js runs — Railway, Fly.io, Render, a $5 VPS. No persistent storage needed beyond the .state.json file (and if you lose it, you'll just get one round of duplicate alerts on the next tick).
A Dockerfile if you want to ship it:
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "main.js"]
The Receipts
For a 10-ticker watchlist during a typical earnings week (Mon-Fri, calls clustered around 4-6 PM ET):
- Polling cost — 10 tickers × 60 ticks/week × 1 request each = 600 requests/week
- Transcript fetch on new call — ~10 of your tickers report per week × 1 request = 10 requests/week
- Total: ~610 requests/week ≈ 2,440/month
That's 48.8% of the Pro plan's monthly budget (5,000 req/mo). The polling dominates. If you want to keep it lean, two optimizations:
- Reduce polling cadence — every 15 min instead of 5 → cuts polling 3×, total drops to ~860/month (17%)
- Use earnings calendar to short-circuit — call
/earnings/upcomingonce per day to know which tickers report today, only poll those. Drops polling to maybe ~30/day during earnings season.
The Claude API cost is separate but tiny: ~10 summaries/week × $0.0015 each (Haiku, 12k tokens in / 250 out) = $0.06/week = under $0.25/month.
Total monthly cost for the system: ~$25 for the API tier, $0.25 for Claude, $5 for the VPS = $30/month. Cheaper than a Bloomberg subscription's daily latte budget.
Extending This
Once the base is shipped, every team's wishlist is the same:
- Sentiment delta vs. last quarter — diff this call's tone vs the previous one, post the delta in the Slack message
- Earnings beat/miss — call the
/earnings/:idmetadata endpoint, includeeps_actual vs eps_consensusif available - Threaded discussion — make each alert a thread head, post follow-up messages as you (the human) add reactions
- Per-channel watchlists — split watchlists per Slack channel (
#tech-watch,#consumer-watch), one bot instance per channel
All of these are 20-30 lines on top of the base. The base does the hard part: timely transcript ingestion + AI compression. Everything else is presentation.
Get an API key and have this running before tomorrow's open.