Intermediate · about 4-6 hours
Weekly Sales Report: Code Does the Maths, AI Writes the Summary
Build a scheduled job that pulls opportunities from HighLevel, calculates the week's sales metrics in plain code, asks Claude for a short narrative about those exact numbers, checks that the narrative didn't invent any, and posts the report to Slack.
You'll end up with: Every Monday morning the sales channel gets a report with correct numbers and a two-paragraph summary, without anyone opening the CRM.
Before you start
- □A HighLevel sub-account with opportunities in at least one pipeline (a test sub-account is fine)
- □A Claude API key and a Slack workspace where you can add an app
- □A GitHub repository and Node.js installed locally
- □Comfort reading TypeScript and running scripts from a terminal
Architecture
Schedule
A GitHub Actions cron trigger starts the job every Monday at a fixed time in your team's time zone.
Fetch
The script pages through HighLevel's opportunity search with a read-only Private Integration token.
Compute
Plain TypeScript counts new, won and lost deals, won value and win rate for this week and last week, plus the deltas.
Narrate
Claude receives only the computed metrics as JSON and writes a short summary. It never sees raw CRM records.
Validate
A guard checks every number in the narrative against the computed metrics. If one doesn't match, the report ships without the narrative.
Deliver
The script posts the numbers block and the narrative to a Slack channel through an incoming webhook.
Log
The run logs the week, record counts and whether the narrative passed, with no contact names or emails.
Build it, step by step
Tick steps off as you build.
Write the metric definitions before any code
Decide exactly what each number means, because the report is only as trustworthy as its definitions. For example: a deal counts as won this week if its status is won and its last status change falls inside the week. New deals are counted by created date. Win rate is won divided by won plus lost, rounded to a whole percent, and it's empty when nothing closed.
Write these down in the repo README and agree them with whoever reads the report. If you skip this, the first time the numbers differ from the CRM dashboard nobody will know which one is right.
Create a read-only HighLevel token and store secrets
In the sub-account settings, create a Private Integration with only the opportunities read scope. Private Integration tokens go in the Authorization header as a Bearer token, alongside a Version header.
Store the token, location id, API base URL, Claude key and Slack webhook URL as GitHub Actions secrets, and in a local .env file that is listed in .gitignore. Commit only a .env.example with the variable names. A read-only scope means a bug or a leaked token can't change your pipeline.
bash # .env.example — names only, never values GHL_API_BASE=https://services.leadconnectorhq.com GHL_LOCATION_ID= GHL_TOKEN= ANTHROPIC_API_KEY= SLACK_WEBHOOK_URL=Pull every opportunity, page by page
The opportunity search endpoint returns at most 100 records per page and a meta object that tells you whether there is a next page. Loop until there isn't, and set a hard page cap so a pagination bug can't loop forever. Throw on any non-200 response: a report built from half the data is worse than no report.
typescript // src/ghl.ts export interface Opp { status: "open" | "won" | "lost" | "abandoned"; monetaryValue?: number; createdAt: string; lastStatusChangeAt?: string; } export function requireEnv(name: string): string { const value = process.env[name]; if (!value) throw new Error(`Missing environment variable ${name}`); return value; } export async function fetchOpportunities(): Promise<Opp[]> { const all: Opp[] = []; for (let page = 1; page <= 50; page++) { const url = new URL("/opportunities/search", requireEnv("GHL_API_BASE")); url.searchParams.set("location_id", requireEnv("GHL_LOCATION_ID")); url.searchParams.set("status", "all"); url.searchParams.set("limit", "100"); url.searchParams.set("page", String(page)); const res = await fetch(url, { headers: { Authorization: `Bearer ${requireEnv("GHL_TOKEN")}`, Version: "v3", // the only value the current endpoint reference lists Accept: "application/json", }, }); if (!res.ok) throw new Error(`HighLevel returned ${res.status} on page ${page}`); const body = (await res.json()) as { opportunities: Opp[]; meta: { nextPage?: number | null } }; all.push(...body.opportunities); if (!body.meta.nextPage) return all; } throw new Error("Stopped after 50 pages. Filter by pipeline or raise the cap on purpose."); }Compute the metrics in plain code, and test them
This is the step that must be deterministic. Counting, summing and percentages are exact operations, so they belong in code you can unit test, not in a model that might round differently on each run. Compute this week and last week from the same data, then compute the deltas too, so the model never has to do arithmetic.
Write a unit test with a handful of hand-made opportunities, including one with no monetary value and a week where nothing closed.
typescript // src/metrics.ts import type { Opp } from "./ghl"; export interface WeekMetrics { newDeals: number; won: number; lost: number; wonValue: number; winRatePct: number | null; } function inRange(iso: string | undefined, start: Date, end: Date): boolean { if (!iso) return false; const time = Date.parse(iso); return time >= start.getTime() && time < end.getTime(); } export function weekMetrics(opps: Opp[], start: Date, end: Date): WeekMetrics { const closed = opps.filter((opp) => inRange(opp.lastStatusChangeAt, start, end)); const won = closed.filter((opp) => opp.status === "won"); const lost = closed.filter((opp) => opp.status === "lost"); const decided = won.length + lost.length; return { newDeals: opps.filter((opp) => inRange(opp.createdAt, start, end)).length, won: won.length, lost: lost.length, wonValue: Math.round(won.reduce((sum, opp) => sum + (opp.monetaryValue ?? 0), 0)), winRatePct: decided === 0 ? null : Math.round((won.length / decided) * 100), }; }Ask Claude for the narrative only
Send the model a flat JSON object of finished numbers, including the deltas, and tell it to use only those numbers. Use a fast, low-cost model: the task is short writing, not reasoning. Don't send temperature or top_p; current API versions reject them, so steer the output with the instructions instead.
Never send raw opportunity records. The model doesn't need contact names to describe a trend, and leaving them out keeps personal data out of a third-party request.
typescript // src/narrative.ts import Anthropic from "@anthropic-ai/sdk"; const client = new Anthropic(); // reads ANTHROPIC_API_KEY export async function writeNarrative(metrics: Record<string, number | null>): Promise<string> { const response = await client.messages.create({ model: "claude-haiku-5-5", max_tokens: 400, system: "You write a weekly sales summary for a team chat. Use only the numbers in the JSON. " + "Do not calculate, estimate or round any number, and do not mention dates. " + "Do not guess at causes. Write 2 short paragraphs of plain text.", messages: [{ role: "user", content: JSON.stringify(metrics) }], }); return response.content .filter((block) => block.type === "text") .map((block) => block.text) .join("") .trim(); }Guard the narrative against invented numbers
Instructions reduce made-up numbers; they don't eliminate them. Add a cheap check: pull every number out of the narrative and confirm it appears in the metrics you sent. If any number fails, post the report without the narrative and log why. The numbers block is always rendered by your code, so the report stays correct even when the AI step fails.
typescript // src/guard.ts export function numbersAreGrounded(text: string, metrics: Record<string, number | null>): boolean { const allowed = new Set( Object.values(metrics) .filter((value): value is number => value !== null) .map((value) => String(Math.abs(value))), ); const found = text.match(/[0-9][0-9,]*(?:[.][0-9]+)?/g) ?? []; return found.every((raw) => allowed.has(raw.replace(/,/g, ""))); }Post to Slack, with a dry-run switch
An incoming webhook accepts a JSON body with a text field. The webhook URL is itself a secret: Slack revokes URLs it finds leaked in public repos. Add a --dry-run flag that prints the message instead of posting, so you can test against real data without spamming the channel.
typescript // src/slack.ts import { requireEnv } from "./ghl"; export async function postToSlack(text: string, dryRun: boolean): Promise<void> { if (dryRun) { console.log(text); return; } const res = await fetch(requireEnv("SLACK_WEBHOOK_URL"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }), }); if (!res.ok) throw new Error(`Slack returned ${res.status}: ${await res.text()}`); }Schedule it with GitHub Actions
Scheduled workflows run from the default branch and use UTC unless you set a timezone. Pick an odd minute rather than the top of the hour: GitHub says scheduled runs can be delayed or dropped at busy times, and the start of every hour is one of them. Keep a manual trigger for reruns, and a concurrency group so two runs can't post at once.
yaml # .github/workflows/weekly-report.yml name: weekly-sales-report on: schedule: - cron: "7 8 * * 1" timezone: "America/New_York" workflow_dispatch: concurrency: weekly-sales-report jobs: report: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 24 - run: npm ci - run: npx tsx src/report.ts env: GHL_API_BASE: ${{ secrets.GHL_API_BASE }} GHL_LOCATION_ID: ${{ secrets.GHL_LOCATION_ID }} GHL_TOKEN: ${{ secrets.GHL_TOKEN }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Example run
- Monday 08:07 New York time, the workflow starts. The script sets the reporting week to the 7 days that ended at midnight UTC.
- The fetch returns 312 opportunities across 4 pages. The log records: pages=4 records=312.
- The compute step produces this week: newDeals 41, won 9, lost 6, wonValue 18450, winRatePct 60. Last week: newDeals 37, won 7, lost 7, wonValue 13900, winRatePct 50. Deltas: newDeals +4, won +2, wonValue +4550, winRatePct +10.
- Claude returns: "The team closed 9 deals this week for 18,450 in won value, up 4,550 on last week. Win rate rose to 60 percent from 50 percent. New pipeline also grew, with 41 new deals against 37 the week before."
- The guard extracts 9, 18450, 4550, 60, 50, 41 and 37. Every one is in the metrics, so the narrative is approved.
- Slack receives one message: a numbers block rendered by code (this week, last week, change) followed by the narrative. The log records narrative=approved posted=true.
Test it
- ✓Unit-test weekMetrics with fixtures: a won deal on the week boundary, a deal with no monetaryValue, and a week with zero closed deals, which must give winRatePct null rather than 0 or NaN.
- ✓Unit-test numbersAreGrounded with a narrative containing an invented number such as "15 decided deals" when 15 isn't in the metrics. It must return false, and the posted report must contain the numbers block without the narrative.
- ✓Failure case: set GHL_TOKEN to an invalid value and run with --dry-run. The job must fail loudly with the HTTP status, post nothing, and show as failed in the Actions tab.
- ✓Failure case: point SLACK_WEBHOOK_URL at a revoked webhook. The run must fail with Slack's status and response text instead of reporting success.
- ✓Run the job once with workflow_dispatch and --dry-run against real data, and compare the won count and won value with the HighLevel pipeline view for the same dates.
Watch out for
- !Letting the model do the maths. Even a good model can round, transpose or invent a figure, and a wrong revenue number in a team channel costs trust you won't easily win back.
- !Time zones at the week boundary. A deal won at 11pm Sunday in New York is Monday in UTC. Pick one boundary rule, write it in the definitions and test it.
- !lastStatusChangeAt reflects the latest change only. A deal won and then reopened drops out of the won count, so expect small differences from reports that use a different rule.
- !Pulling every opportunity forever. Fetching status=all gets slower as the account grows. Filter by pipeline or stop paging once records are older than the comparison window.
- !Public repos pause schedules. GitHub disables scheduled workflows in a public repository after 60 days with no activity, and the report silently stops.
- !Rerunning by hand double-posts. Slack webhooks have no deduplication, so check the channel before you trigger a manual rerun.
