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:

Real-world setup time: 30 minutes. Recurring cost: $3-7/month depending on watchlist size.

Architecture

Three moving parts:

  1. Cron loop — every 5 minutes during earnings season (8 AM - 8 PM ET on weekdays), check earningscalls.dev for new transcripts on your watchlist
  2. Claude summarization — when a new transcript appears, send to Claude (Anthropic API) for a structured 4-bullet summary
  3. 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:

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

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:

  1. Reduce polling cadence — every 15 min instead of 5 → cuts polling 3×, total drops to ~860/month (17%)
  2. Use earnings calendar to short-circuit — call /earnings/upcoming once 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:

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.