Build a morning list of who to call first, with the reason for each rank
One function that ranks leads by a score built from the vendor's interest label, replies, clicks, opens and how recent the last reply is, and prints each score with its parts. It is a ranking you can check, not a prediction.
A rep with an hour to make calls wants the leads in order. hotLeads reads the leads and gives each a score from what the vendor reports about them: a positive interest label, replies, clicks, opens, and how recent the last reply is. It returns the leads highest score first.
The score is a weighted sum, and every part of it comes back with the lead. It is not a prediction: no model is trained or called, and nothing says a lead with 95 points is more likely to buy than one with 31. It says the first has a positive label and replied twice, and the second replied once. The weights are inputs, so you can change what “first” means.
Before you start
- Node 22 or later.
- The code below runs on the in-memory adapter, so it needs no vendor key. Each sample on this page is a file in the repository that the test suite runs; the output shown is what it printed.
- For real data: an API key for a tool that reports per-lead counts. Today that is Instantly. Read “What each vendor can serve” below before you plan around Smartlead or HeyReach.
What you get
1. Ava Chen <ava.chen@example.com>, CEO, Northwind Example
score 95: label 50, replies 20, recency 15, clicks 5, opens 5
marked Interested, replied 2 time(s), last reply 3 day(s) ago, 1 click(s), 6 open(s).
2. Ben Ortiz <ben.ortiz@example.com>, Head of Sales, Tailspin Example
score 31: label unknown, replies 10, recency 18, clicks 0, opens 3
replied 1 time(s), last reply 1 day(s) ago, 3 open(s).
3. Cara Lind <cara.lind@example.com>
score 22: label 0, replies 10, recency 10, clicks 0, opens 2
replied 1 time(s), last reply 7 day(s) ago, 2 open(s).
4. Hal Berg <hal.berg@example.com>, COO
score 19: label unknown, replies 0, recency unknown, clicks 15, opens 4
3 click(s), 4 open(s).
5. Dev Rao <dev.rao@example.com>
score 5: label unknown, replies 0, recency unknown, clicks 0, opens 5
40 open(s).
Read 8 leads: 5 scored above 0, 1 had nothing to score, 2 unsubscribed or bounced.
Write the list
import type { Outbound } from "outbound-sdk";
import type { HotLeadParts, InsightsExtension } from "outbound-sdk/insights";
// A part is null when the vendor reports nothing for that signal. That is
// "unknown", not zero points.
const part = (name: string, points: number | null): string =>
`${name} ${points === null ? "unknown" : points}`;
const breakdown = (parts: HotLeadParts): string =>
[
part("label", parts.interest),
part("replies", parts.replies),
part("recency", parts.recency),
part("clicks", parts.clicks),
part("opens", parts.opens),
].join(", ");
/**
* Who to call first, as text. Give a campaign ref to rank one campaign;
* without one, every lead the tool lists is ranked.
*/
export const callList = async (
outbound: Outbound & InsightsExtension,
campaign: string | null = null
): Promise<string> => {
const { data, meta } = await outbound.insights.hotLeads({
campaign,
limit: 10,
});
const lines = data.leads.flatMap((lead, index) => [
`${index + 1}. ${lead.name ?? "No name"} <${lead.email ?? lead.linkedinUrl ?? lead.lead}>${
lead.title === null ? "" : `, ${lead.title}`
}${lead.company === null ? "" : `, ${lead.company}`}`,
` score ${lead.score}: ${breakdown(lead.parts)}`,
` ${lead.reason}`,
]);
const { totals } = data;
lines.push(
"",
`Read ${totals.leadsRead} leads: ${totals.hot} scored above 0, ` +
`${totals.withoutData} had nothing to score, ${totals.excluded} unsubscribed or bounced.`
);
// A read stopped by max_leads ranks part of the leads. A vendor with no
// per-lead counts ranks on less. Both are said in the notes.
if (!meta.complete || meta.notes.length > 0) {
lines.push("", ...meta.notes.map((note) => `Note: ${note}`));
}
return lines.join("\n");
};
The score is the sum of five parts, with these defaults:
| Part | Points | Input |
|---|---|---|
| The vendor’s interest label is a positive one | 50 | weight_interest, positive_labels |
Each reply, up to count_cap |
10 | weight_reply |
Each click, up to count_cap |
5 | weight_click |
Each open, up to count_cap |
1 | weight_open |
A reply received right now, fading evenly to 0 after recency_days |
20 | weight_recency |
count_cap is 5 and recency_days is 14. positive_labels defaults to Interested, Meeting Booked and Meeting Completed, matched without regard to case.
Read the output above against that table:
- Ava scores 95: 50 for the label, 20 for two replies, 15 because her last reply was 3.5 of 14 days ago, 5 for a click and 5 for opens. She has six opens; the cap counts five.
- Cara is third although the vendor labelled her
Not Interested. A label that is not positive earns 0 points, and her reply and its recency still count. The ranking does not read what a reply says. - Dev has 40 opens and 5 points. The cap is there so opens cannot outrank a reply.
- A part that is
nullis unknown, not zero. Ben has no label, soparts.interestisnull. The function prints “unknown”.
Who is left out:
- A lead with no label and no engagement data is not scored 0. It is left out and counted in
totals.withoutData. - Unsubscribed and bounced leads are left out and counted in
totals.excluded. - A lead whose known parts add up to 0 is not on the list.
Try it without a key
Eight leads on one made-up tool.
// One made-up email tool with eight leads. The clock in the samples is
// 2026-11-14 12:00 UTC, so "3.5 days ago" is 2026-11-11 00:00.
import type { MemoryData } from "outbound-sdk/memory";
export const CAMPAIGN = "memory:acme:cmp_founders";
export const seed: MemoryData = {
campaigns: [{ id: "cmp_founders", name: "Founders Q4" }],
leads: [
// A positive label, two replies, the last 3.5 days ago, a click, six opens.
{
campaignId: "cmp_founders",
company: "Northwind Example",
email: "ava.chen@example.com",
engagement: {
clicks: 1,
lastRepliedAt: "2026-11-11T00:00:00.000Z",
opens: 6,
replies: 2,
},
firstName: "Ava",
id: "lead_ava",
interest: "Interested",
lastName: "Chen",
status: "replied",
title: "CEO",
},
// No label. One reply, 1.4 days ago.
{
campaignId: "cmp_founders",
company: "Tailspin Example",
email: "ben.ortiz@example.com",
engagement: {
clicks: 0,
lastRepliedAt: "2026-11-13T02:24:00.000Z",
opens: 3,
replies: 1,
},
firstName: "Ben",
id: "lead_ben",
lastName: "Ortiz",
status: "replied",
title: "Head of Sales",
},
// Labelled not interested, but replied a week ago.
{
campaignId: "cmp_founders",
email: "cara.lind@example.com",
engagement: {
clicks: 0,
lastRepliedAt: "2026-11-07T12:00:00.000Z",
opens: 2,
replies: 1,
},
firstName: "Cara",
id: "lead_cara",
interest: "Not Interested",
lastName: "Lind",
status: "replied",
},
// Forty opens and nothing else. Opens stop counting at count_cap.
{
campaignId: "cmp_founders",
email: "dev.rao@example.com",
engagement: { clicks: 0, opens: 40, replies: 0 },
firstName: "Dev",
id: "lead_dev",
lastName: "Rao",
},
// Nothing reported: no label and no counts.
{
campaignId: "cmp_founders",
email: "eli.park@example.com",
id: "lead_eli",
},
// Unsubscribed and bounced leads are never on the list.
{
campaignId: "cmp_founders",
email: "fay.novak@example.com",
engagement: { replies: 1 },
id: "lead_fay",
status: "unsubscribed",
},
{
campaignId: "cmp_founders",
email: "gus.hale@example.com",
id: "lead_gus",
status: "bounced",
},
// Three clicks and four opens, no reply.
{
campaignId: "cmp_founders",
email: "hal.berg@example.com",
engagement: { clicks: 3, opens: 4, replies: 0 },
firstName: "Hal",
id: "lead_hal",
lastName: "Berg",
title: "COO",
},
],
};
import { createOutbound } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";
import { memory } from "outbound-sdk/memory";
import { callList } from "./call-list.ts";
import { seed } from "./seed.ts";
const outbound = createOutbound({
adapter: memory({ data: seed, workspaceId: "acme" }),
plugins: [insights()],
});
console.log(await callList(outbound));
That is the script that printed the list above.
Change what “first” means
Every weight is an input. This run makes a recent reply worth most, shortens the recency window to a week and ignores opens.
import { createOutbound } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";
import { memory } from "outbound-sdk/memory";
import { seed } from "./seed.ts";
const outbound = createOutbound({
adapter: memory({ data: seed, workspaceId: "acme" }),
plugins: [insights()],
});
// The weights are inputs. Here a recent reply matters most, opens count for
// nothing, and the vendor's "Not Interested" label is left as it is.
const { data } = await outbound.insights.hotLeads({
recency_days: 7,
weight_interest: 10,
weight_open: 0,
weight_recency: 60,
});
for (const lead of data.leads) {
console.log(String(lead.score).padStart(6), lead.email);
}
65 ava.chen@example.com
58 ben.ortiz@example.com
15 hal.berg@example.com
10 cara.lind@example.com
Ben is now seven points behind Ava, and Dev is gone: with opens worth nothing he scores 0.
Point it at your vendor
Swap the adapter. Nothing else changes.
import { createOutbound } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";
import { instantly } from "outbound-sdk/instantly";
// Reads INSTANTLY_API_KEY.
export const outbound = createOutbound({
adapter: instantly({ workspaceId: "acme" }),
// One list may spend at most 30 vendor requests.
maxRequests: 30,
plugins: [insights()],
});
Call the function from a scheduler you already run and send the text where the reps will see it. The SDK stores nothing and schedules nothing. The list has no phone numbers: a lead record has no phone field.
What each vendor can serve
The insight, run on the in-memory adapter limited to what each vendor’s adapter declares. This shows which calls a vendor’s adapter would refuse and which notes it adds. It does not show what that vendor’s API returns.
import { createOutbound, OutboundError } from "outbound-sdk";
import { heyreach } from "outbound-sdk/heyreach";
import { insights } from "outbound-sdk/insights";
import { instantly } from "outbound-sdk/instantly";
import { memory } from "outbound-sdk/memory";
import { smartlead } from "outbound-sdk/smartlead";
import { VENDORS } from "../_shared/as-vendor.ts";
import type { Vendor } from "../_shared/as-vendor.ts";
import { seed } from "./seed.ts";
// What each vendor's adapter can do once an instance has resolved it: a
// capability the adapter leaves out is `false` here. This guide turns on
// `leads.engagement`, which Smartlead's adapter leaves out.
const DECLARED = {
heyreach: () =>
createOutbound({ adapter: heyreach({ apiKey: "not-used" }) }).capabilities,
instantly: () =>
createOutbound({ adapter: instantly({ apiKey: "not-used" }) }).capabilities,
smartlead: () =>
createOutbound({ adapter: smartlead({ apiKey: "not-used" }) }).capabilities,
};
// Scores are not printed here: the in-memory data has counts a real vendor
// may not report. Only what is refused, and what is noted, carries over.
const attempt = async (vendor: Vendor, campaign: string | null) => {
const outbound = createOutbound({
adapter: memory({
capabilities: DECLARED[vendor](),
data: seed,
name: vendor,
workspaceId: "acme",
}),
plugins: [insights()],
});
const scope = campaign === null ? "every lead" : "one campaign";
try {
const { meta } = await outbound.insights.hotLeads({ campaign });
console.log(`${vendor}, ${scope}: ok`);
for (const note of meta.notes) {
console.log(` note: ${note}`);
}
} catch (error) {
if (!OutboundError.is(error)) {
throw error;
}
console.log(`${vendor}, ${scope}: ${error.code}: ${error.message}`);
}
};
for (const vendor of VENDORS) {
// oxlint-disable-next-line no-await-in-loop -- one vendor at a time.
await attempt(vendor, null);
}
await attempt("heyreach", `heyreach:acme:cmp_founders`);
instantly, every lead: ok
smartlead, every lead: ok
note: This tool reports no per-lead open, click or reply counts, so scores rest on the interest label and the lead's status.
heyreach, every lead: Unsupported: hot_leads needs a campaign here: the heyreach adapter cannot list every lead (leads.list)
heyreach, one campaign: ok
note: This tool reports no per-lead open, click or reply counts, so scores rest on the interest label and the lead's status.
The ranking needs per-lead reply, click and open counts (leads.engagement). Without them only the interest label and a replied status are left to score on.
- Instantly declares
leads.engagementand can list every lead. It serves the full ranking. - Smartlead’s adapter does not declare
leads.engagement: the only per-lead counts are in an endpoint whose response is not documented, and in a CSV export. The call succeeds with a note, and scores rest on the label and the status. - HeyReach reports no per-lead counts at all, and has no list of every lead, so the call without a campaign is refused. With a campaign ref it runs, but the adapter’s notes say a campaign’s leads carry no interest label either. On HeyReach this ranking has almost nothing to rank on. Do not build a call list on it.
What it costs
One request per page of leads read. max_leads (default 500, at most 20,000) sets the ceiling, and a vendor request returns about 50 to 100 leads. On Smartlead a read without a campaign walks every campaign’s leads, so pass campaign. maxRequests on the instance caps the whole call; past it the call fails with RateLimited instead of spending your vendor quota.
When it cannot answer
import { createOutbound, OutboundError } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";
import { memory } from "outbound-sdk/memory";
import { CAMPAIGN, seed } from "./seed.ts";
const outbound = createOutbound({
adapter: memory({ data: seed, workspaceId: "acme" }),
plugins: [insights()],
});
// 1. max_leads stopped the read: the ranking covers the leads that were read.
const capped = await outbound.insights.hotLeads({ max_leads: 3 });
console.log(
"capped:",
capped.data.totals.leadsRead,
capped.meta.complete,
capped.meta.notes
);
// 2. `limit` only shortens the list. The answer is still complete.
const short = await outbound.insights.hotLeads({ limit: 1 });
console.log(
"limit 1:",
short.data.leads.length,
"of",
short.data.totals.hot,
short.meta.complete
);
// 3. No lead has a label or a count: nobody is ranked, and nobody scores 0.
const empty = createOutbound({
adapter: memory({
data: {
campaigns: [{ id: "c1" }],
leads: [{ campaignId: "c1", email: "new@example.com", id: "l1" }],
},
}),
plugins: [insights()],
});
const nothing = await empty.insights.hotLeads({});
console.log("no data:", nothing.data.leads.length, nothing.data.totals);
// 4. A negative weight is refused before any request.
try {
await outbound.insights.hotLeads({ weight_open: -1 });
} catch (error) {
if (OutboundError.is(error)) {
console.log("bad weight:", error.code);
}
}
// 5. A tool that cannot list every lead needs a campaign.
const perCampaign = createOutbound({
adapter: memory({
capabilities: { "leads.list": false },
data: seed,
workspaceId: "acme",
}),
plugins: [insights()],
});
try {
await perCampaign.insights.hotLeads({});
} catch (error) {
if (OutboundError.is(error)) {
console.log("no campaign:", error.code, "-", error.message);
}
}
const scoped = await perCampaign.insights.hotLeads({ campaign: CAMPAIGN });
console.log("with a campaign:", scoped.data.totals.hot, "ranked");
capped: 3 false [ 'Stopped at 3 leads; more exist.' ]
limit 1: 1 of 5 true
no data: 0 { excluded: 0, hot: 0, leadsRead: 1, scored: 0, withoutData: 1 }
bad weight: Invalid
no campaign: Unsupported - hot_leads needs a campaign here: the memory adapter cannot list every lead (leads.list)
with a campaign: 5 ranked
| What happened | What you get | What to do |
|---|---|---|
More leads than max_leads |
meta.complete is false and a note says where it stopped. The ranking covers the leads read. |
Raise max_leads, or rank one campaign. |
limit is smaller than the number of hot leads |
A shorter list. totals.hot still counts them all and meta.complete stays true. |
Nothing. |
| No lead has a label or a count | An empty list, with the leads in totals.withoutData |
Check that the vendor reports per-lead counts. |
| A negative weight, or another input out of range | Invalid, before any request |
Fix the value. |
| The adapter cannot list every lead and no campaign was given | Unsupported, naming leads.list |
Pass campaign. |
| The adapter reports no per-lead counts | An answer with a note. Count parts are null. |
Treat the list as a list of labelled leads, not a ranking. |
| A rejected key | Unauthorized. It is not retried. |
Check the key and its scope. |
Limits
- The score orders leads. It does not estimate a chance of anything, and two scores are only comparable under the same weights.
- It reads the label and the counts, never the reply text. A lead who replied “stop emailing me” and was not marked unsubscribed still earns reply points.
- Ties are broken by the most recent reply, then by lead ref.
- One entry per lead record. A person in two campaigns appears twice, scored on each record’s own counts.
- Open counts depend on tracking pixels, which mail clients load on their own. Keep
weight_openlow. - On an
unverifiedadapter, check a list against the vendor’s own lead view before calling from it.