---
title: Insights
description: The 26 computed answers in outbound-sdk/insights, what every answer carries, how inputs and capabilities are checked, and how to run one by name, as an agent tool, from the command line, or define your own.
sidebar:
  label: Overview
related:
  - /docs/insights/people
  - /docs/insights/operations
  - /docs/tools
  - /docs/status
---

`outbound-sdk/insights` answers the questions operators ask, computed from the normalized reads. Each insight is one typed method, one agent tool and one CLI command. It calls no AI model, stores nothing and never writes to a vendor. Every number is a count, a rate or a textbook test you can check by hand.

How far each adapter has been checked against a live account is on [what is verified](/docs/status).

## Set it up

Add the plugin. Every insight becomes a method on `outbound.insights`.

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

// Reads INSTANTLY_API_KEY and HEYREACH_API_KEY. One adapter works the same.
export const outbound = createOutbound({
  adapter: combine([instantly(), heyreach()]),
  // The most vendor requests one call may make before it fails.
  maxRequests: 200,
  plugins: [insights()],
});

// Every insight is now a typed method: outbound.insights.personSummary,
// outbound.insights.clientReport and 24 more.
```

The plugin makes no request by itself. An insight reads through the instance, so the vendor's rate limit, your `maxRequests` and every other plugin apply to it. See [rate limits](/docs/rate-limits).

## What every answer carries

Every insight returns the same three parts: its tool name in `insight`, the answer in `data`, and `meta`.

```ts title="answer.ts"
import { outbound } from "../insights-operations/outbound.ts";

// Five campaigns exist. The cap stops the read after two.
const answer = await outbound.insights.campaignRanking({ max_campaigns: 2 });

console.log("insight:", answer.insight);

console.log(
  "data.ranked:",
  answer.data.ranked.map((campaign) => `${campaign.rank}. ${campaign.name}`)
);

console.log("meta:", answer.meta);
```

```text title="Output"
insight: campaign_ranking
data.ranked: [ '1. Agency owners', '2. SaaS founders' ]
meta: {
  complete: false,
  evidence: 'exact',
  notes: [ 'A listing was cut off at its cap; more records exist.' ],
  requests: 0,
  computedAt: '2026-11-14T12:00:00.000Z',
  provider: 'memory'
}
```

| Field | What it holds |
| --- | --- |
| `data` | The answer. Its type is exported under the insight's name: `CampaignRanking`, `PersonSummary` and so on. |
| `meta.requests` | Vendor requests the insight made. The in-memory adapter makes none, so the samples on this site show 0. |
| `meta.complete` | `false` when a cap stopped a read early, so the answer covers part of the data. |
| `meta.evidence` | `exact` only when every read behind the answer was exact. Otherwise the weakest of them: `sampled`, then `derived`. |
| `meta.notes` | What to know before trusting the numbers: which cap was reached, or a part that was skipped because the adapter cannot read it. |
| `meta.computedAt` | When the insight ran, ISO 8601 UTC. |
| `meta.provider` | The adapter's name. |

Pass `meta.notes` on with the numbers. An answer cut short by a cap looks like a whole answer unless you say so.

## Caps

Every read an insight makes has a cap you can set. The defaults keep one call to tens of requests on a real vendor. When a cap stops a read, `meta.complete` is `false` and a note names the cap. The answer is never padded or guessed.

| Cap | Default | Where |
| --- | --- | --- |
| `max_campaigns` | 20 | The [people](/docs/insights/people) insights and `copyReuse` |
| `max_campaigns` | 25 | The [senders](/docs/insights/senders) and [operations](/docs/insights/operations) insights that list campaigns |
| `max_campaigns` | 50 | `bounceWatch` |
| `max_leads` | 500 | `hotLeads`, `personalizationGaps` |
| `max_leads` | 1,000 | `accountSummary` |
| `max_leads` | 2,000 | `pipelineForecast` |
| `max_leads_per_campaign` | 500 | `leadCollisions`, `suppressionCheck`, `clientReport` |
| `max_block_entries` | 2,000 | `suppressionCheck` |
| `max_replies` | 50 | `droppedReplies` |
| `max_messages` | 2,000 | `replyTiming` |
| `max_senders` | 50 | `domainHealth`, `campaignDiagnosis` |
| `max_senders` | 100 | `warmupReadiness` |
| `max_senders` | 200 | `senderCapacity`, `senderLoad` |

The range of each cap is on the insight's own section. Thresholds (a bounce rate that is too high, a warmup score that is too low) are inputs with defaults too.

`maxRequests` on `createOutbound` is a different limit. It is the most vendor requests one call may make, and a call that reaches it fails with `RateLimited` instead of returning part of an answer.

## Counts that are null

A count a vendor does not report is `null`, never `0`, and so is every rate built on it. An insight sets such a record apart and says why. It does not rank it last or count it as clean.

```ts title="nulls.ts"
import { outbound } from "../insights-operations/outbound.ts";

// The LinkedIn campaign in this data reports no bounce count.
const { data } = await outbound.insights.campaignRanking({
  metric: "bounce_rate",
});

console.log(
  "ranked:",
  data.ranked.map((campaign) => `${campaign.name} ${campaign.rate}`)
);

for (const campaign of data.unknown) {
  console.log(`unknown: ${campaign.name}. ${campaign.reason}`);
}
```

```text title="Output"
ranked: [
  'Agency owners 0.01',
  'SaaS founders 0.01',
  'Ecommerce ops 0.0321',
  'Pilot test 0'
]
unknown: LinkedIn founders. The vendor does not report the count behind this rate.
```

## The two tests behind the numbers

**The Wilson interval.** A rate from a small sample is a weak claim: 2 replies from 10 sends and 200 from 1,000 are both 20%, but only the second tells you much. Wherever an insight reports a rate that rests on a sample, it also reports the 95% Wilson score interval, the range the true rate plausibly lies in. Rankings sort by the low end of that interval (the high end for a bounce rate), so a lucky small campaign cannot outrank a proven large one.

**The two-proportion test.** When an insight compares two rates (this week against last, variant A against B, email against LinkedIn), it runs a two-proportion z-test and reports the p-value: the chance of a gap at least this large if the two rates were in fact equal. Under `alpha` (an input, default 0.05) the insight calls the difference "more than chance". Over it, the insight says the gap could be chance. It never calls a winner on the raw rates alone.

Both are exported, with the other arithmetic the insights share.

```ts title="statistics.ts"
import { compareRates, rate, wilson } from "outbound-sdk/insights";

// The same 20% reply rate, on 10 sends and on 1,000.
console.log("2 of 10:", rate(2, 10), wilson(2, 10));

console.log("200 of 1,000:", rate(200, 1000), wilson(200, 1000));

// 40 replies from 500 sends against 20 from 500, and 10 from 400 against 6.
console.log(
  "8% against 4%:",
  compareRates({ successes: 40, trials: 500 }, { successes: 20, trials: 500 })
);

console.log(
  "2.5% against 1.5%:",
  compareRates({ successes: 10, trials: 400 }, { successes: 6, trials: 400 })
);

// A count the vendor does not report gives no rate and no interval.
console.log("unknown count:", rate(null, 500), wilson(null, 500));
```

```text title="Output"
2 of 10: 0.2 { high: 0.5098, low: 0.0567 }
200 of 1,000: 0.2 { high: 0.2259, low: 0.1764 }
8% against 4%: { pValue: 0.0077, significant: true, z: 2.6631 }
2.5% against 1.5%: { pValue: 0.3124, significant: false, z: 1.0102 }
unknown count: null null
```

Rates, interval ends and p-values are rounded to four decimal places. A p-value shown as `0` is under 0.00005.

## Inputs are checked

An input is checked twice: by the method's type when you compile, and by the insight's schema when it runs. A key the insight does not take is refused as `Invalid`, with the list of inputs it does take. It is never dropped, because a dropped filter would quietly answer a wider question than the one asked.

An insight that needs a capability the adapter lacks is refused as `Unsupported` before any request is made, and is left out of the tool list, so a model is never offered a tool that cannot work.

```ts title="input-checking.ts"
import { createOutbound, OutboundError } from "outbound-sdk";
import { insights, insightTools } from "outbound-sdk/insights";
import { memory } from "outbound-sdk/memory";

import { outbound } from "../insights-operations/outbound.ts";
import { seed } from "../insights-operations/seed.ts";

const report = async <Answer>(
  label: string,
  run: () => Promise<Answer>
): Promise<void> => {
  try {
    await run();
  } catch (error) {
    if (!OutboundError.is(error)) {
      throw error;
    }

    console.log(`${label}: ${error.code}: ${error.message}`);
  }
};

// 1. A key the insight does not take. The types refuse it, and so does the
//    call: dropping it would quietly rank every campaign.
await report("unknown key", () =>
  // @ts-expect-error -- `tag` is not an input of campaignRanking
  outbound.insights.campaignRanking({ tag: "saas" })
);

// 2. A value of the wrong type.
await report("wrong type", () =>
  // @ts-expect-error -- `min_sent` is a number
  outbound.insights.campaignRanking({ min_sent: "fifty" })
);

// 3. A value outside its range.
await report("out of range", () =>
  outbound.insights.campaignRanking({ max_campaigns: 500 })
);

// 4. A required input left out.
await report("missing", () =>
  // @ts-expect-error -- `campaign` is required
  outbound.insights.pipelineForecast({})
);

// 5. An adapter that cannot read campaign stats. Refused before any request,
//    and left out of the tool list.
const noStats = createOutbound({
  adapter: memory({ capabilities: { "stats.campaign": false }, data: seed }),
  plugins: [insights()],
});

await report("no capability", () => noStats.insights.campaignRanking({}));

const offered = insightTools(noStats).map((tool) => tool.name);

console.log(
  `tools offered: ${offered.length}, campaign_ranking among them: ${offered.includes("campaign_ranking")}`
);
```

```text title="Output"
unknown key: Invalid: campaign_ranking: "tag" is not an input. It takes: from, max_campaigns, metric, min_sent, statuses, to
wrong type: Invalid: campaign_ranking: min_sent Invalid input: expected number, received string
out of range: Invalid: campaign_ranking: max_campaigns Too big: expected number to be <=200
missing: Invalid: pipeline_forecast: campaign Invalid input: expected string, received undefined
no capability: Unsupported: campaign_ranking is not available: the memory adapter does not support stats.campaign
tools offered: 18, campaign_ranking among them: false
```

Each `@ts-expect-error` marks a line the compiler rejects. Without the comment, the sample would not type-check. Input names are snake case (`max_campaigns`) because the same schema serves the agent tool and the CLI.

## The insights

The tool and CLI name of each is the same words in snake case: `personSummary` is `person_summary`.

### People and accounts

[Reference](/docs/insights/people)

| Method | Answers | Needs |
| --- | --- | --- |
| `personSummary` | One person across campaigns and channels: messages sent, replies, steps replied to, interest | `leads.lookup`, `leads.timeline` |
| `accountSummary` | Everyone at one company domain, across campaigns and tools | `leads.list`, or `leads.byCampaign` when the adapter cannot list every lead |
| `leadCollisions` | People in two or more campaigns at once, inside a tool or across tools | `leads.byCampaign` |
| `suppressionCheck` | Leads in campaigns although they are on the block list | `blocklist`, `leads.byCampaign` |
| `droppedReplies` | Replies nobody answered, longest waiting first | `replies.list` |
| `hotLeads` | Who to call first, scored from interest, replies, clicks, opens and recency | `leads.list`, or `leads.byCampaign` with a campaign given |
| `personalizationGaps` | Leads that will get broken copy because a merge variable has no value | `sequence`, `leads.byCampaign` |

### Copy and sequence

[Reference](/docs/insights/copy)

| Method | Answers | Needs |
| --- | --- | --- |
| `stepFunnel` | Where replies come from, step by step | `sequence`, `stats.byStep` |
| `variantTest` | Whether an A/B leader is ahead by more than chance | `stats.byStep` |
| `copyAudit` | Problems in the copy: length, links, spam words, broken variables | `sequence` |
| `copyReuse` | How the same copy performs in different campaigns | `sequence`, `stats.byStep` |
| `replyTiming` | How fast people reply, and on which days and hours | `messages.list` |

### Senders and deliverability

[Reference](/docs/insights/senders)

| Method | Answers | Needs |
| --- | --- | --- |
| `domainHealth` | DNS, sign-in, block list and warmup spam rate per sending domain | `senders.list`, `senders.health` |
| `senderCapacity` | What senders can send against what campaigns ask and what went out | `senders.list` |
| `bounceWatch` | Campaigns and senders over a bounce threshold | `stats.campaign` |
| `warmupReadiness` | Senders that are not ready to send, with reasons | `senders.list` |
| `senderLoad` | Senders on too many campaigns, and idle ones | `senders.list`, `senders.byCampaign` |
| `settingsAudit` | Risky schedules, tracking and stop rules | `campaigns.settings` |

### Campaign operations and reporting

[Reference](/docs/insights/operations)

| Method | Answers | Needs |
| --- | --- | --- |
| `campaignDiagnosis` | Why one campaign is underperforming, most serious finding first | `stats.campaign` |
| `pipelineForecast` | Leads left, days of sending left, and the replies to expect | `leads.byCampaign`, `stats.campaign` |
| `anomalies` | Days whose sends, replies or bounces broke from trend | `stats.byDay`, and `stats.workspace` for the whole workspace or `stats.campaign` for one campaign |
| `periodComparison` | This period against the one before | `stats.campaign` |
| `campaignRanking` | Campaigns ranked fairly at different volumes | `stats.campaign` |
| `channelMix` | Email against LinkedIn | `stats.campaign` |
| `tagRollup` | Results grouped by campaign tag | `stats.campaign` |
| `clientReport` | One report for a client or workspace, with action items | `stats.campaign` |

"Needs" is what must be there for the insight to run at all. Many insights read more when the adapter can serve it, and say in `meta.notes` what they left out when it cannot. Each section of the reference pages lists both. The [capabilities](/docs/capabilities) page explains the keys.

## Which adapter serves which

An insight is offered when the adapter declares every capability it needs. This sample asks each vendor's adapter what it declares.

```ts title="vendors.ts"
import { createOutbound } from "outbound-sdk";
import { insightTools, listInsights } from "outbound-sdk/insights";

import { asVendor, VENDORS } from "../_shared/as-vendor.ts";

const all = listInsights().map((insight) => insight.name);

// What each adapter declares, not what its API returned.
for (const vendor of VENDORS) {
  const outbound = createOutbound({ adapter: asVendor(vendor, {}) });
  const offered = new Set(insightTools(outbound).map((tool) => tool.name));
  const refused = all.filter((name) => !offered.has(name));

  console.log(
    `${vendor}: ${offered.size} of ${all.length}. Refuses: ${refused.join(", ") || "none"}`
  );
}
```

```text title="Output"
instantly: 26 of 26. Refuses: none
smartlead: 26 of 26. Refuses: none
heyreach: 22 of 26. Refuses: step_funnel, variant_test, copy_reuse, settings_audit
```

HeyReach has no per-step stats, so it cannot serve `stepFunnel`, `variantTest` or `copyReuse`, and its API cannot read campaign settings, so it cannot serve `settingsAudit`.

A declaration is what the adapter's code says it can do. It is not a live run. Which insights were run against a live account, on which vendor and when, is on [what is verified](/docs/status).

## Run one by name

`runInsightByName` takes the tool name or the method name and JSON input. It is what the CLI and an agent's tool call go through. The answer's `data` is typed as JSON, so use the method when you know the insight at compile time. An unknown name throws `Invalid` and lists the known names.

`listInsights` needs no instance. It returns each insight's `name`, `key`, `description`, `needs` and its input as JSON Schema in `parameters`.

`insightTools(outbound)` returns the insights this instance can serve as agent tools, in the form every wrapper in [`outbound-sdk/tools`](/docs/tools) accepts through `extra`. Pass `only` to offer some of them.

```ts title="by-name.ts"
import {
  insightTools,
  listInsights,
  runInsightByName,
} from "outbound-sdk/insights";
import { openaiTools } from "outbound-sdk/tools";

import { outbound } from "../insights-operations/outbound.ts";

// By tool name or by method name, with JSON input. This is what the CLI and
// an agent's tool call go through.
const result = await runInsightByName(outbound, "tag_rollup", {
  min_sent: 100,
});

console.log(result.insight, "complete:", result.meta.complete);

// What exists, without an instance: names, descriptions, needs, JSON Schema.
const listed = listInsights();
const entry = listed.find((insight) => insight.key === "bounceWatch");

console.log("insights:", listed.length);

console.log(entry?.name, "needs", entry?.needs);

// The insights this instance can serve, as agent tools.
const tools = insightTools(outbound, { only: ["hot_leads", "client_report"] });

console.log(
  "tools:",
  tools.map((tool) => tool.name)
);

// `only: []` leaves out the built-in tools, so the count is the insights alone.
const { tools: forOpenAi } = openaiTools(outbound, {
  extra: insightTools(outbound),
  only: [],
});

console.log("handed to the model:", forOpenAi.length);
```

```text title="Output"
tag_rollup complete: true
insights: 26
bounce_watch needs [ 'stats.campaign' ]
tools: [ 'hot_leads', 'client_report' ]
handed to the model: 26
```

A tool call never throws. It returns `ok: true` with `data.result`, `data.complete` and `data.notes`, or `ok: false` with an error `code` and `message`. Both carry `next`, one sentence on what to ask after this.

## From the command line

`outbound insight` with no name lists every insight as JSON. With a name it runs one and prints the answer. `--input` takes the same inputs as the method, as JSON.

```sh
outbound insight
outbound insight person_summary --input '{"person":"jane@example.com"}'
outbound insight client_report --input '{}'
```

`outbound mcp` serves the insights with the other tools. See [CLI commands](/docs/cli/commands) and [the MCP server](/docs/cli/mcp). These commands are not run by this site's tests.

## Define your own

`defineInsight` takes a name, a description, a Zod object for the input, the capabilities it needs and a `run` function. `runInsight` runs it with the same input checking, capability check and `meta` as the built-in ones, and keeps its typed answer. A definition's `execute` method is the untyped form the tool list uses.

```ts title="define.ts"
import { createOutbound } from "outbound-sdk";
import {
  compareRates,
  defineInsight,
  rate,
  runInsight,
  wilson,
} from "outbound-sdk/insights";
import { memory } from "outbound-sdk/memory";
import { z } from "zod";

import { seed } from "../insights-operations/seed.ts";

// Is one campaign's reply rate above another's by more than chance?
const headToHead = defineInsight({
  description:
    "Compares the reply rate of two campaigns with a two-proportion test. Costs 2 requests.",
  inputSchema: z.object({
    first: z.string().describe("A campaign ref"),
    second: z.string().describe("The campaign ref to compare it with"),
  }),
  key: "headToHead",
  name: "head_to_head",
  // Checked before any request. Without it the call is `Unsupported`.
  needs: ["stats.campaign"],
  next: "Use campaign_diagnosis on the campaign that is behind.",
  run: async (context, { first, second }) => {
    const read = async (campaign: string) => {
      // `context.rows` records what the read cost and how exact it was.
      const [row] = context.rows(
        await context.outbound.campaign(campaign).stats()
      );

      const replied = row?.replied ?? null;
      const contacted = row?.contacted ?? null;

      if (rate(replied, contacted) === null) {
        // A note travels with the answer in `meta.notes`.
        context.note(`${campaign} reports no reply or lead count.`);
      }

      return {
        campaign,
        interval: wilson(replied, contacted),
        rate: rate(replied, contacted),
        sample: { successes: replied, trials: contacted },
      };
    };

    const left = await read(first);
    const right = await read(second);

    return {
      sides: [left, right],
      test: compareRates(left.sample, right.sample),
    };
  },
});

const outbound = createOutbound({
  adapter: memory({ data: seed, workspaceId: "acme" }),
});

const { data, meta } = await runInsight(outbound, headToHead, {
  first: "memory:acme:agencies",
  second: "memory:acme:founders",
});

for (const side of data.sides) {
  console.log(side.campaign, side.rate, side.interval);
}

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

console.log("evidence:", meta.evidence, "complete:", meta.complete);
```

```text title="Output"
memory:acme:agencies 0.075 { high: 0.0948, low: 0.0591 }
memory:acme:founders 0.06 { high: 0.076, low: 0.0472 }
test: { pValue: 0.1939, significant: false, z: 1.299 }
evidence: exact complete: true
```

`run` receives a context. Read through its helpers so the cost and the evidence of every read reach `meta`.

| On the context | What it does |
| --- | --- |
| `outbound` | The instance to read through. |
| `now` | The time the insight runs at, in milliseconds. Use it, not `Date.now()`, so a test can fix the clock with the `now` option of `runInsight`. |
| `rows(stats)` | Records a stats read and returns its rows. |
| `one(item)` | Records one lookup and returns the record. |
| `page(page)` | Records one page and returns its items. An unfinished page marks the answer incomplete. |
| `all(walk, cap)` | Walks a `listAll` to its end or its `max`, and marks the answer incomplete when `max` cut it short. |
| `note(text)` | Adds a note. The same note is kept once. |
| `partial(reason)` | Marks the answer incomplete, with the reason as a note. |

The arithmetic is exported from `outbound-sdk/insights` as plain functions:

| Function | Returns |
| --- | --- |
| `rate(part, whole)` | `part / whole`, or `null` when either is unknown or the whole is zero |
| `wilson(successes, trials)` | The 95% Wilson interval as `low` and `high`, or `null` |
| `compareRates(first, second, alpha)` | The two-proportion test: `z`, `pValue`, `significant`. `null` when a side has no trials or both rates are 0 or 1 |
| `total(values)` | The known values added up. `null` when none is known |
| `mean`, `median`, `percentile(values, fraction)` | `null` for an empty list. Percentiles use linear interpolation |
| `deviation(values)` | The sample standard deviation. `null` with fewer than two values |
| `zScore(value, history)` | How many standard deviations `value` sits from the mean of `history`. `null` when the history has no spread |
| `round(value)` | Four decimal places. `null` stays `null` |
| `tally(values)` | How often each value occurs, as a `Map` in first-seen order |

`defineInsight` does not add your insight to `outbound.insights`, `listInsights` or `insightTools`. Those hold the built-in 26.

## Things to know

- **"The last N days" means N complete UTC days ending yesterday**, so a half-finished today cannot read as a drop.
- **Records are linked by exact identifier, never by name.** An email address finds a LinkedIn record only when a tool holds both for that person.
- **Finding a person costs a different number of requests on each vendor:** 1 on Instantly, 2 by profile or about 4 by email on HeyReach, and 1 to 2 on Smartlead.
- **A vendor's thread can be shorter than its sequence.** Instantly's email list does not hold every email a campaign sent, so `personSummary` counts messages sent from the lead's last step when the thread is shorter, and says so in a note.
- **A combined instance cannot read a conversation by thread id**, because a thread id is one vendor's own. `droppedReplies` reads the lead's timeline there.
- **`bounceWatch` judges a sender on everything it sent in the period**, not only in the offending campaign: no vendor reports one sender within one campaign.
- **`variantTest` makes no correction for comparing three or more variants**, and its "sends still needed" assumes both rates stay as they are.
- **`anomalies` judges a day against a flat baseline without a z-score.** If every earlier day had the same count, there is no standard deviation to measure against. A day with another count is flagged with `judgedBy: "flat_baseline"` and a `null` `zScore`, whatever `threshold` is.
- **A rate over several campaigns counts only the campaigns that report both sides of it.** `tagRollup`, `channelMix`, `periodComparison` and `clientReport` add counts up across campaigns. A campaign that reports sends but no bounces adds to the sends shown and is left out of the bounce rate, and a note says so.
- **`hotLeads` is a ranking, not a prediction.** Its score is the sum of weighted parts that are returned with it.
- **The findings are sentences built from the numbers by fixed rules.** No model writes them, so the same data always gives the same sentence.
