Skip to content
Preview: outbound-sdk is not on npm yet and its repository is private. Instantly and HeyReach are beta, Smartlead is unverified.What is verified
outbound-sdk
Esc
↑↓navigate↵open⌘Jpreview
On this page

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 null is unknown, not zero. Ben has no label, so parts.interest is null. 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.engagement and 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_open low.
  • On an unverified adapter, check a list against the vendor’s own lead view before calling from it.

Was this page helpful?