---
title: Build a morning list of who to call first, with the reason for each rank
description: 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.
sidebar:
  label: Who to call first
related:
  - /docs/insights/people
  - /guides/unanswered-replies
  - /guides/person-across-email-and-linkedin
---

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.

> **Preview: not published yet**
>
> `outbound-sdk` is not on npm, and its GitHub repository is private, so there
> is no install command that works today. Instantly and HeyReach are `beta`:
> their reads were run, read-only, against one live workspace each. Smartlead is
> `unverified`: written from its public documentation and tested offline only.
> See [what is verified](/docs/status).

## 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

```text title="Output"
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

```ts title="call-list.ts"
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.

```ts title="seed.ts"
// 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",
    },
  ],
};
```

```ts title="run.ts"
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.

```ts title="weights.ts"
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);
}
```

```text title="Output"
    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.

```ts title="outbound.ts"
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.

```ts title="vendors.ts"
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`);
```

```text title="Output"
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

```ts title="failures.ts"
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");
```

```text title="Output"
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.
