---
title: People and account insights
description: Reference for the seven insights about people (personSummary, accountSummary, leadCollisions, suppressionCheck, droppedReplies, hotLeads, personalizationGaps), with every input, what the answer holds and a sample that runs.
sidebar:
  label: People and accounts
related:
  - /docs/insights
  - /guides/person-across-email-and-linkedin
  - /guides/unanswered-replies
  - /guides/hot-leads-call-list
---

Seven insights about the people in your campaigns. Each is a method on `outbound.insights` once the [insights plugin](/docs/insights) is added. What every answer carries (`data`, `meta.complete`, `meta.notes`) and how inputs are checked is on the [overview](/docs/insights).

People are matched by exact identifier only: an email address, lowercased, or a LinkedIn profile. Nothing is matched by name.

## The data the samples run on

Every sample on this page runs on the in-memory adapter with this data: one client on an email tool with three campaigns and on a LinkedIn tool with one. It runs at a fixed clock, 2026-11-14 12:00 UTC. See [how the samples are run](/docs/samples).

```ts title="seed.ts"
// Made-up data for one client, "acme", on two tools: an email tool with three
// campaigns and a LinkedIn tool with one. Ana is in three of the four.
import type { MemoryData } from "outbound-sdk/memory";

const ANA_PROFILE = "https://www.linkedin.com/in/ana-ruiz-example";
const HAL_PROFILE = "https://www.linkedin.com/in/hal-voss-example";

export const MAIL: MemoryData = {
  blocklist: [
    { id: "block_gus", value: "gus@example.com" },
    { id: "block_domain", value: "blocked.example.com" },
    { id: "block_hal", kind: "linkedin", value: HAL_PROFILE },
    { id: "block_company", kind: "company", value: "Initech" },
  ],
  campaigns: [
    {
      id: "founders",
      name: "SaaS founders",
      steps: [
        {
          variants: [
            {
              body: "Hi {{firstName}}, I saw {{company}} is hiring. {{icebreaker}}\n\n{{senderSignature}}",
              subject: "Quick question, {{firstName}}",
            },
          ],
        },
        {
          delayDays: 3,
          variants: [
            { body: "Hi {{firstName|there}}, did my note reach you?" },
          ],
        },
      ],
    },
    { id: "agencies", name: "Agency owners" },
    { id: "summer", name: "Summer push", status: "completed" },
  ],
  leads: [
    {
      campaignId: "founders",
      company: "Northwind",
      custom: { icebreaker: "Congratulations on the funding round." },
      email: "ana@northwind.example.com",
      engagement: {
        clicks: 1,
        lastRepliedAt: "2026-11-12T12:00:00.000Z",
        opens: 6,
        replies: 2,
        stepIndex: 1,
      },
      firstName: "Ana",
      id: "ana_founders",
      interest: "Interested",
      lastName: "Ruiz",
      linkedinUrl: ANA_PROFILE,
      status: "replied",
      title: "CEO",
    },
    {
      campaignId: "founders",
      company: "Northwind",
      email: "ben@northwind.example.com",
      engagement: { clicks: 0, opens: 3, replies: 0 },
      firstName: "Ben",
      id: "ben_founders",
      lastName: "Okafor",
      title: "Head of Sales",
    },
    {
      campaignId: "founders",
      company: "Globex",
      custom: { icebreaker: "" },
      email: "cy@example.com",
      id: "cy_founders",
    },
    {
      campaignId: "founders",
      custom: { icebreaker: "Your talk last month was useful." },
      email: "dee@example.com",
      firstName: "Dee",
      id: "dee_founders",
      status: "unsubscribed",
    },
    {
      campaignId: "agencies",
      email: "ana@northwind.example.com",
      engagement: { opens: 2 },
      id: "ana_agencies",
    },
    { campaignId: "agencies", email: "cy@example.com", id: "cy_agencies" },
    {
      campaignId: "agencies",
      email: "eli@example.com",
      engagement: {
        clicks: 2,
        lastRepliedAt: "2026-11-07T12:00:00.000Z",
        opens: 4,
        replies: 1,
      },
      firstName: "Eli",
      id: "eli_agencies",
      interest: "Meeting Booked",
      lastName: "Stone",
      status: "replied",
    },
    {
      campaignId: "agencies",
      email: "fay@blocked.example.com",
      id: "fay_agencies",
    },
    { campaignId: "agencies", email: "gus@example.com", id: "gus_agencies" },
    {
      campaignId: "summer",
      email: "ben@northwind.example.com",
      id: "ben_summer",
      interest: "Not Interested",
      status: "completed",
    },
  ],
  messages: [
    // Ana, SaaS founders: two steps sent, then two replies nobody answered.
    {
      campaignId: "founders",
      direction: "out",
      id: "m1",
      leadId: "ana_founders",
      sentAt: "2026-11-03T09:00:00.000Z",
      stepIndex: 0,
      threadId: "thread_ana",
    },
    {
      campaignId: "founders",
      direction: "out",
      id: "m2",
      leadId: "ana_founders",
      sentAt: "2026-11-06T09:00:00.000Z",
      stepIndex: 1,
      threadId: "thread_ana",
    },
    {
      campaignId: "founders",
      category: "positive",
      direction: "in",
      id: "m3",
      leadId: "ana_founders",
      sentAt: "2026-11-07T10:00:00.000Z",
      subject: "Re: Quick question, Ana",
      threadId: "thread_ana",
    },
    {
      campaignId: "founders",
      category: "positive",
      direction: "in",
      id: "m4",
      leadId: "ana_founders",
      sentAt: "2026-11-12T12:00:00.000Z",
      subject: "Re: Quick question, Ana",
      threadId: "thread_ana",
    },
    // Ana, Agency owners: one step sent, no reply.
    {
      campaignId: "agencies",
      direction: "out",
      id: "m5",
      leadId: "ana_agencies",
      sentAt: "2026-11-10T09:00:00.000Z",
      stepIndex: 0,
    },
    // Eli: replied, and was answered two hours later.
    {
      campaignId: "agencies",
      direction: "out",
      id: "m6",
      leadId: "eli_agencies",
      sentAt: "2026-11-05T09:00:00.000Z",
      stepIndex: 0,
      threadId: "thread_eli",
    },
    {
      campaignId: "agencies",
      category: "positive",
      direction: "in",
      id: "m7",
      leadId: "eli_agencies",
      sentAt: "2026-11-07T12:00:00.000Z",
      threadId: "thread_eli",
    },
    {
      campaignId: "agencies",
      direction: "out",
      id: "m8",
      leadId: "eli_agencies",
      sentAt: "2026-11-07T14:00:00.000Z",
      threadId: "thread_eli",
    },
    // Gus: replied 16 hours ago. Unanswered, but not for a day yet.
    {
      campaignId: "agencies",
      direction: "out",
      id: "m9",
      leadId: "gus_agencies",
      sentAt: "2026-11-12T09:00:00.000Z",
      stepIndex: 0,
      threadId: "thread_gus",
    },
    {
      campaignId: "agencies",
      category: "neutral",
      direction: "in",
      id: "m10",
      leadId: "gus_agencies",
      sentAt: "2026-11-13T20:00:00.000Z",
      threadId: "thread_gus",
    },
    // Dee unsubscribed, and Cy's mailbox sent an automatic reply.
    {
      campaignId: "founders",
      category: "unsubscribe",
      direction: "in",
      id: "m11",
      leadId: "dee_founders",
      sentAt: "2026-11-08T09:00:00.000Z",
      threadId: "thread_dee",
    },
    {
      campaignId: "founders",
      category: "neutral",
      direction: "in",
      id: "m12",
      isAuto: true,
      leadId: "cy_founders",
      sentAt: "2026-11-09T09:00:00.000Z",
      threadId: "thread_cy",
    },
  ],
};

export const LINKEDIN: MemoryData = {
  campaigns: [
    { channel: "linkedin", id: "li_founders", name: "LinkedIn founders" },
  ],
  leads: [
    { campaignId: "li_founders", id: "ana_linkedin", linkedinUrl: ANA_PROFILE },
    { campaignId: "li_founders", id: "hal_linkedin", linkedinUrl: HAL_PROFILE },
  ],
  messages: [
    // Ana: replied on LinkedIn and was answered the same day.
    {
      campaignId: "li_founders",
      direction: "out",
      id: "d1",
      leadId: "ana_linkedin",
      sentAt: "2026-11-04T09:00:00.000Z",
    },
    {
      campaignId: "li_founders",
      direction: "in",
      id: "d2",
      leadId: "ana_linkedin",
      sentAt: "2026-11-05T09:00:00.000Z",
    },
    {
      campaignId: "li_founders",
      direction: "out",
      id: "d3",
      leadId: "ana_linkedin",
      sentAt: "2026-11-05T15:00:00.000Z",
    },
    // Hal: replied three days ago, and nothing was sent after.
    {
      campaignId: "li_founders",
      direction: "out",
      id: "d4",
      leadId: "hal_linkedin",
      sentAt: "2026-11-10T12:00:00.000Z",
    },
    {
      campaignId: "li_founders",
      direction: "in",
      id: "d5",
      leadId: "hal_linkedin",
      sentAt: "2026-11-11T12:00:00.000Z",
    },
  ],
};
```

```ts title="outbound.ts"
import { createOutbound } from "outbound-sdk";
import { combine } from "outbound-sdk/combine";
import { insights } from "outbound-sdk/insights";
import { memory } from "outbound-sdk/memory";

import { LINKEDIN, MAIL } from "./seed.ts";

// Two tools behind one instance. `name` is the provider each record carries.
export const outbound = createOutbound({
  adapter: combine([
    memory({ data: MAIL, name: "mailtool", workspaceId: "acme" }),
    memory({ data: LINKEDIN, name: "linkedintool", workspaceId: "acme" }),
  ]),
  plugins: [insights()],
});
```

## `personSummary`

Everything about one person across campaigns and channels: each campaign they are in, how many messages they were sent, whether and how often they replied, which steps they replied to, and their interest label.

| Input | Default | Range | What it is |
| --- | --- | --- | --- |
| `person` | required | at least 3 characters | An email address or a LinkedIn profile URL. Anything else is `Invalid`. |
| `max_campaigns` | 20 | 1 to 50 | The most campaigns to report on. |

`data` (`PersonSummary`):

- `found`: `false` when no record matched.
- `identifiers`: every exact identifier the records carry, as `email:` and `linkedin:` keys.
- `campaigns`: one entry per campaign, with `campaignName`, `campaignStatus`, `channel`, `provider`, `lead`, `leadStatus`, `interest` (the vendor's own label), `messagesSent`, `replies`, `replied`, `repliedSteps` (counted from 0), `lastStep`, `firstContactedAt`, `lastContactedAt` and `lastRepliedAt`.
- `totals`: `campaigns`, `campaignsReplied`, `campaignReplyRate` (campaigns they replied in, divided by campaigns that sent them at least one message), `messagesSent`, `replies` and `byChannel`.

- **Needs:** `leads.lookup`, `leads.timeline`.
- **Costs:** one lookup per identifier, then up to 2 requests per campaign (the lead's timeline and the campaign).
- **Notes it can add:** a thread held fewer sent messages than the lead's last step, so messages sent is counted from the last step.
- **Does not:** match by name, or find a LinkedIn record from an email address unless some tool holds both for that person. `repliedSteps` is empty when the vendor ties a reply to no step.

```ts title="person-summary.ts"
import { outbound } from "./outbound.ts";

const { data } = await outbound.insights.personSummary({
  person: "ana@northwind.example.com",
});

console.log("identifiers:", data.identifiers.join(", "));

for (const entry of data.campaigns) {
  console.log(
    `${entry.campaignName} (${entry.channel}): sent ${entry.messagesSent}, replies ${entry.replies}, replied to steps [${entry.repliedSteps.join(", ")}], interest ${entry.interest}`
  );
}

console.log("by channel:", data.totals.byChannel);

console.log(
  `replied in ${data.totals.campaignsReplied} of ${data.totals.campaigns} campaigns (rate ${data.totals.campaignReplyRate})`
);
```

```text title="Output"
identifiers: email:ana@northwind.example.com, linkedin:ana-ruiz-example
SaaS founders (email): sent 2, replies 2, replied to steps [1], interest Interested
Agency owners (email): sent 1, replies 0, replied to steps [], interest null
LinkedIn founders (linkedin): sent 2, replies 1, replied to steps [], interest null
by channel: {
  email: { messagesSent: 3, replies: 2 },
  linkedin: { messagesSent: 2, replies: 1 }
}
replied in 2 of 3 campaigns (rate 0.6667)
```

The email address found the LinkedIn record because the lead in "SaaS founders" carries both the address and the profile. The LinkedIn messages in this data carry no step, so `repliedSteps` is empty there.

## `accountSummary`

Everyone at one company, across campaigns and tools, by exact email domain.

| Input | Default | Range | What it is |
| --- | --- | --- | --- |
| `domain` | required | at least 3 characters | The company's domain, an email address at it, or a URL on it. |
| `allow_free_domains` | `false` | boolean | Accept an email provider domain such as `gmail.com`, which is not one company. |
| `max_leads` | 1,000 | 1 to 20,000 | The most leads to read in all. |
| `max_campaigns` | 20 | 1 to 200 | The most campaigns to read leads from, when the adapter cannot list every lead at once. |
| `positive_labels` | `Interested`, `Meeting Booked`, `Meeting Completed` | at least 1 character each | Vendor interest labels that count as interested, matched without regard to case. |

`data` (`AccountSummary`):

- `domain`: the domain that was matched.
- `people`: one entry per email address, with `name`, `title`, `company`, `campaigns` (refs), `replied`, `interested` (`null` when they carry no label), `opens`, `clicks` and `replies` added up over their records, `lastRepliedAt`, and `records` (one per campaign). Sorted by most recent reply.
- `totals`: `people`, `records`, `campaigns`, `replied`, `interested`, `leadsRead` and `leadsWithoutEmail`.
- `lastReply`: the most recent reply with a known time, or `null`.

- **Needs:** `leads.list`. Without it, `leads.byCampaign`, and the insight reads campaign by campaign. With neither it is `Unsupported`.
- **Costs:** one request per page of leads read. `max_leads` sets the ceiling.
- **Notes it can add:** the tool reports no per-lead open, click or reply counts, so those are `null`; a cap was reached. On a combined instance the note names only the tools whose records carry no counts, and is left out when every tool's records carry them: records from a tool that reports counts keep them.
- **Does not:** find a lead that has only a LinkedIn profile (they are counted in `leadsWithoutEmail`), or treat a subdomain as the same company. It reads leads and filters them here, so it costs the same whether the company has one lead or fifty.

```ts title="account-summary.ts"
import { outbound } from "./outbound.ts";

const { data } = await outbound.insights.accountSummary({
  domain: "northwind.example.com",
});

for (const person of data.people) {
  console.log(
    `${person.name} (${person.title}): ${person.campaigns.length} campaigns, replied ${person.replied}, interested ${person.interested}, opens ${person.opens}, replies ${person.replies}`
  );
}

console.log("totals:", data.totals);

console.log("last reply:", data.lastReply?.email, "at", data.lastReply?.at);
```

```text title="Output"
Ana Ruiz (CEO): 2 campaigns, replied true, interested true, opens 8, replies 2
Ben Okafor (Head of Sales): 2 campaigns, replied false, interested false, opens 3, replies 0
totals: {
  campaigns: 3,
  interested: 1,
  leadsRead: 12,
  leadsWithoutEmail: 2,
  people: 2,
  records: 4,
  replied: 1
}
last reply: ana@northwind.example.com at 2026-11-12T12:00:00.000Z
```

Ben is `interested: false` because his only label, `Not Interested`, is not a positive one. A person with no label at all is `null`.

## `leadCollisions`

People who are in two or more campaigns at once, so they get overlapping outreach.

| Input | Default | Range | What it is |
| --- | --- | --- | --- |
| `statuses` | `["active"]` | at least one of `active`, `paused`, `draft`, `completed`, `archived`, `unknown` | Campaign statuses to consider. |
| `max_campaigns` | 20 | 1 to 200 | The most campaigns to read. |
| `max_leads_per_campaign` | 500 | 1 to 20,000 | The most leads to read from each campaign. |

`data` (`LeadCollisions`):

- `collisions`: one entry per person, with `identifiers`, `campaignCount`, `stillActive` (campaigns in which the lead's own status is still `active`), `scope` (`across_tools` or `within_tool`), `providers`, `channels`, `campaigns` and a `finding` sentence. Most campaigns first.
- `totals`: `collisions`, `acrossTools`, `withinTool`, `campaignsRead`, `leadsRead` and `leadsWithoutIdentifier`.

- **Needs:** `leads.byCampaign`.
- **Costs:** one request per page of campaigns, plus one per page of each campaign's leads.
- **Notes it can add:** a cap was reached.
- **Does not:** count the same person twice in one campaign as a collision, or look at campaigns outside `statuses`. A lead with neither an email address nor a profile cannot be matched and is counted in `leadsWithoutIdentifier`.

```ts title="lead-collisions.ts"
import { outbound } from "./outbound.ts";

const { data } = await outbound.insights.leadCollisions({});

for (const collision of data.collisions) {
  console.log(`${collision.scope}: ${collision.finding}`);
  console.log(
    `  still active in ${collision.stillActive} of ${collision.campaignCount}:`,
    collision.campaigns.map((each) => each.campaignName).join(", ")
  );
}

console.log("totals:", data.totals);
```

```text title="Output"
across_tools: email:ana@northwind.example.com is in 3 campaigns across mailtool and linkedintool (email and linkedin).
  still active in 2 of 3: SaaS founders, Agency owners, LinkedIn founders
within_tool: email:cy@example.com is in 2 mailtool campaigns at once.
  still active in 2 of 2: SaaS founders, Agency owners
totals: {
  acrossTools: 1,
  campaignsRead: 3,
  collisions: 2,
  leadsRead: 11,
  leadsWithoutIdentifier: 0,
  withinTool: 1
}
```

Ben is also in "Summer push", but that campaign is completed, so it is not read.

## `suppressionCheck`

Leads sitting in campaigns although they are on the block list.

| Input | Default | Range | What it is |
| --- | --- | --- | --- |
| `statuses` | `["active"]` | at least one campaign status | Campaign statuses to check. |
| `max_block_entries` | 2,000 | 1 to 100,000 | The most block list entries to read. |
| `max_campaigns` | 20 | 1 to 200 | The most campaigns to check. |
| `max_leads_per_campaign` | 500 | 1 to 20,000 | The most leads to read from each campaign. |

`data` (`SuppressionCheck`):

- `violations`: one entry per lead, with `lead`, `email`, `linkedinUrl`, `leadStatus`, `campaign`, `campaignName`, `entry`, `entryValue`, `matchedBy` (`email`, `domain` or `linkedin`), `sameTool` and a `finding` sentence. Leads still `active` first.
- `byCampaign`: every campaign checked with `leadsRead` and `violations`, most violations first.
- `totals`: `violations`, `byMatch`, `campaignsRead`, `leadsRead`, `blockEntriesRead` and `blockEntriesNotChecked`.

- **Needs:** `blocklist`, `leads.byCampaign`.
- **Costs:** one request per page of block entries, plus one per page of each campaign's leads.
- **Notes it can add:** a cap was reached.
- **Does not:** match a company name or an entry of the vendor's kind `other`. Those are counted in `blockEntriesNotChecked`. It reports a lead once, by its most specific match: the address, then the profile, then the domain. It removes nobody from a campaign.

```ts title="suppression-check.ts"
import { outbound } from "./outbound.ts";

const { data } = await outbound.insights.suppressionCheck({});

for (const violation of data.violations) {
  console.log(
    `${violation.finding} (matched by ${violation.matchedBy}, same tool: ${violation.sameTool})`
  );
}

console.log("totals:", data.totals);
```

```text title="Output"
https://www.linkedin.com/in/hal-voss-example is in "LinkedIn founders" but the block list holds the linkedin https://www.linkedin.com/in/hal-voss-example. (matched by linkedin, same tool: false)
fay@blocked.example.com is in "Agency owners" but the block list holds the domain blocked.example.com. (matched by domain, same tool: true)
gus@example.com is in "Agency owners" but the block list holds the email gus@example.com. (matched by email, same tool: true)
totals: {
  blockEntriesNotChecked: 1,
  blockEntriesRead: 4,
  byMatch: { domain: 1, email: 1, linkedin: 1 },
  campaignsRead: 3,
  leadsRead: 11,
  violations: 3
}
```

The block list is the email tool's. On a combined instance an entry on one tool's list is also matched against the other tools' leads, and marked `sameTool: false`: that tool does not know about the entry and will keep sending.

## `droppedReplies`

Replies from prospects that nobody answered, longest waiting first.

| Input | Default | Range | What it is |
| --- | --- | --- | --- |
| `since` | `null` | an ISO 8601 date, or date and time with offset | Only replies received at or after this. `null` means 14 days before now. |
| `older_than_hours` | 24 | 0 or more | A reply counts as dropped only after waiting this long without an answer. |
| `categories` | `["positive", "neutral", "unknown"]` | at least one reply category | Reply categories that need an answer. |
| `include_auto` | `false` | boolean | Also check replies the vendor marks as automatic. |
| `max_replies` | 50 | 1 to 1,000 | The most replies to read. |

`data` (`DroppedReplies`):

- `dropped`: one entry per conversation, with `reply` (the oldest unanswered reply), `lead`, `campaign`, `channel`, `provider`, `category`, `subject`, `receivedAt`, `hoursWaiting`, `unansweredReplies`, `checkedBy` (`thread` or `timeline`) and a `finding` sentence.
- `totals`: `repliesRead`, `skippedByCategory`, `skippedAuto`, `conversations`, `answered`, `dropped`, `tooRecent` and `unchecked`.
- `since` and `olderThanHours`: what was used.

- **Needs:** `replies.list`. To check a conversation it also needs `messages.list` with `messages.byThread` or `leads.timeline`.
- **Costs:** one request per page of replies, plus one per conversation checked. At most about `max_replies` requests.
- **Notes it can add:** the adapter cannot filter replies by date, so the newest were read and filtered here; the adapter can read neither a conversation nor a timeline, so no reply could be checked; a cap was reached.
- **Does not:** report a reply it could not check as dropped. Those are counted in `totals.unchecked`. "Answered" means a message was sent after the reply in the same conversation. It does not read what the message said.

```ts title="dropped-replies.ts"
import { outbound } from "./outbound.ts";

const { data } = await outbound.insights.droppedReplies({});

for (const reply of data.dropped) {
  console.log(
    `${reply.lead}: ${reply.finding} Unanswered replies: ${reply.unansweredReplies}. Checked by ${reply.checkedBy}.`
  );
}

console.log("since:", data.since);

console.log("totals:", data.totals);
```

```text title="Output"
mailtool:acme:ana_founders: A positive reply has waited 170 hours with nothing sent after it. Unanswered replies: 2. Checked by timeline.
linkedintool:acme:hal_linkedin: An unknown reply has waited 72 hours with nothing sent after it. Unanswered replies: 1. Checked by timeline.
since: 2026-10-31T12:00:00.000Z
totals: {
  answered: 2,
  conversations: 5,
  dropped: 2,
  repliesRead: 8,
  skippedAuto: 1,
  skippedByCategory: 1,
  tooRecent: 1,
  unchecked: 0
}
```

Eight replies were read. One was an unsubscribe and one was automatic. Of the five conversations left, two were answered, one is 16 hours old and so too recent, and two are dropped. Both were checked by timeline because a [combined](/docs/adapters/combine) instance cannot read a thread by its id.

## `hotLeads`

Who to call first: leads ranked by a score built from what the vendor reports.

| Input | Default | Range | What it is |
| --- | --- | --- | --- |
| `campaign` | `null` | a campaign ref | Rank one campaign's leads. `null` ranks every lead the adapter lists. |
| `limit` | 20 | 1 to 500 | How many leads to return. |
| `max_leads` | 500 | 1 to 20,000 | The most leads to read and score. |
| `positive_labels` | `Interested`, `Meeting Booked`, `Meeting Completed` | at least 1 character each | Interest labels that count as positive. |
| `weight_interest` | 50 | 0 or more | Points for carrying a positive interest label. |
| `weight_reply` | 10 | 0 or more | Points for each reply, up to `count_cap`. |
| `weight_click` | 5 | 0 or more | Points for each click, up to `count_cap`. |
| `weight_open` | 1 | 0 or more | Points for each open, up to `count_cap`. |
| `weight_recency` | 20 | 0 or more | Points for a reply received right now. |
| `recency_days` | 14 | 1 or more | The recency points fade evenly to none over this many days. |
| `count_cap` | 5 | 1 or more, whole | The most replies, clicks or opens that earn points, so 40 opens do not outrank a reply. |

`data` (`HotLeads`):

- `leads`: highest score first, only leads that scored above 0. Each has `lead`, `email`, `linkedinUrl`, `name`, `title`, `company`, `campaign`, `status`, `interest`, `score`, `parts` (the points per signal: `interest`, `replies`, `clicks`, `opens`, `recency`), the raw `replies`, `clicks` and `opens`, `lastRepliedAt` and a `reason` sentence.
- `totals`: `leadsRead`, `scored`, `hot`, `withoutData` and `excluded`.

- **Needs:** `leads.list`, or `leads.byCampaign` when `campaign` is given. Without `campaign` on an adapter that cannot list every lead it is `Unsupported`.
- **Costs:** one request per page of leads read. `max_leads` sets the ceiling.
- **Notes it can add:** the tool reports no per-lead open, click or reply counts, so scores rest on the interest label and the lead's status; a cap was reached. On a combined instance the note names only the tools whose leads carry no counts.
- **Does not:** predict anything. The score is a sum you can recompute from `parts`. A part that is `null` means the vendor does not report that signal. A lead with no label and no engagement data is left out and counted in `withoutData`, never scored 0. Unsubscribed and bounced leads are left out and counted in `excluded`.

```ts title="hot-leads.ts"
import { outbound } from "./outbound.ts";

const { data } = await outbound.insights.hotLeads({ limit: 3 });

for (const lead of data.leads) {
  console.log(`${lead.score} ${lead.email}: ${lead.reason}`);
  console.log("  parts:", lead.parts);
}

console.log("totals:", data.totals);
```

```text title="Output"
97.1429 ana@northwind.example.com: marked Interested, replied 2 time(s), last reply 2 day(s) ago, 1 click(s), 6 open(s).
  parts: { clicks: 5, interest: 50, opens: 5, recency: 17.1429, replies: 20 }
84 eli@example.com: marked Meeting Booked, replied 1 time(s), last reply 7 day(s) ago, 2 click(s), 4 open(s).
  parts: { clicks: 10, interest: 50, opens: 4, recency: 10, replies: 10 }
3 ben@northwind.example.com: 3 open(s).
  parts: { clicks: 0, interest: null, opens: 3, recency: null, replies: 0 }
totals: { excluded: 1, hot: 4, leadsRead: 12, scored: 5, withoutData: 6 }
```

Ana's 6 opens earn 5 points, the cap. Her reply two days ago earns 20 x (1 - 2/14) = 17.1429 recency points.

## `personalizationGaps`

Leads in one campaign who will get broken copy because a merge variable has no value for them.

| Input | Default | Range | What it is |
| --- | --- | --- | --- |
| `campaign` | required | a campaign ref | The campaign to check. |
| `limit` | 100 | 1 to 5,000 | How many affected leads to list. The counts cover all of them. |
| `max_leads` | 500 | 1 to 20,000 | The most leads to check. |

`data` (`PersonalizationGaps`):

- `campaign`: the ref that was checked.
- `variables`: one entry per variable in the copy, with `name`, `source` (`builtin`, `custom` or `unknown`), `gap`, `steps`, `uses` (step, variant, field, and whether that use has a fallback), `leadsWithoutValue` and `leadsAffected`. Most leads affected first.
- `leads`: the leads missing at least one variable, with `lead`, `email`, `linkedinUrl`, `status` and `missing`. Most missing first, up to `limit`.
- `unknownVariables`: variables no lead has a value for and that are not built in.
- `totals`: `steps`, `variables`, `leadsRead`, `leadsAffected` and `affectedRate`.

- **Needs:** `sequence`, `leads.byCampaign`.
- **Costs:** one request for the sequence, plus one per page of leads.
- **Notes it can add:** the sequence uses no variables, so no lead was read; a cap was reached.
- **Does not:** render the copy. It finds `{{variable}}`, `{variable}` and `%variable%` in subjects and bodies, and checks each lead for a value: first name, last name, company, title, email, or a custom field of the same name without regard to case, spaces, underscores or hyphens. A variable with a fallback in every use, such as `{{firstName|there}}`, is not a gap. A variable no lead has, such as a sender signature the tool fills in, is listed as `unknown` and not counted against leads: check those by hand.

```ts title="personalization-gaps.ts"
import { outbound } from "./outbound.ts";

const { data } = await outbound.insights.personalizationGaps({
  campaign: "mailtool:acme:founders",
});

for (const variable of data.variables) {
  console.log(
    `${variable.name} (${variable.source}): steps [${variable.steps.join(", ")}], gap ${variable.gap}, leads affected ${variable.leadsAffected}`
  );
}

for (const lead of data.leads) {
  console.log(`${lead.email} is missing ${lead.missing.join(", ")}`);
}

console.log("unknown:", data.unknownVariables);

console.log("totals:", data.totals);
```

```text title="Output"
icebreaker (custom): steps [0], gap true, leads affected 2
company (builtin): steps [0], gap true, leads affected 1
firstName (builtin): steps [0, 1], gap true, leads affected 1
senderSignature (unknown): steps [0], gap false, leads affected null
cy@example.com is missing firstName, icebreaker
ben@northwind.example.com is missing icebreaker
dee@example.com is missing company
unknown: [ 'senderSignature' ]
totals: {
  affectedRate: 0.75,
  leadsAffected: 3,
  leadsRead: 4,
  steps: 2,
  variables: 4
}
```

`firstName` is a gap although step 1 writes `{{firstName|there}}`, because step 0 uses it with no fallback. Cy has an `icebreaker` field, but it is empty, which counts as no value.
