---
title: Campaign operations and reporting insights
description: Reference for the eight insights about campaign results (campaignDiagnosis, pipelineForecast, anomalies, periodComparison, campaignRanking, channelMix, tagRollup, clientReport), with every input, what the answer holds and a sample that runs.
sidebar:
  label: Operations and reporting
related:
  - /docs/insights
  - /guides/weekly-client-report
  - /guides/diagnose-campaign
  - /guides/lead-runway
---

Eight insights computed from campaign stats. Each is a method on `outbound.insights` once the [insights plugin](/docs/insights) is added. What every answer carries and how inputs are checked is on the [overview](/docs/insights).

Three things hold for all of them:

- **A reply rate is replies divided by leads contacted**, or by messages sent when the vendor does not count leads. A bounce rate is bounces divided by messages sent.
- **A rate comes with its count, its base and its 95% interval** as `count`, `of`, `rate`, `low` and `high`.
- **"The last N days" means N complete UTC days ending yesterday.** Today is left out because it is not over.

Five of them (`periodComparison`, `campaignRanking`, `channelMix`, `tagRollup` and `clientReport`) share two inputs:

| Input | Default | Range | What it is |
| --- | --- | --- | --- |
| `statuses` | `["active", "paused", "completed"]` | at least one campaign status | Only campaigns in one of these states are read. |
| `max_campaigns` | 25 | 1 to 200 | The most campaigns to read. |

`campaignRanking`, `channelMix`, `tagRollup` and `campaignDiagnosis` also take a date range:

| Input | Default | Range | What it is |
| --- | --- | --- | --- |
| `from` | `null` | `YYYY-MM-DD` | First day counted. `null` for the whole history. |
| `to` | `null` | `YYYY-MM-DD` | Last day counted. `null` for up to today. |

## The data the samples run on

Three weeks of results for one client: four active campaigns and one paused pilot. The samples run at a fixed clock, 2026-11-14 12:00 UTC, so "the last 7 days" is 2026-11-07 to 2026-11-13.

```ts title="seed.ts"
// Three weeks of made-up results for one client, "acme": four active
// campaigns and one paused pilot. Replies to "Agency owners" fell in the last
// seven days, and "Ecommerce ops" had a bounce spike on 2026-11-11.
import type { MemoryData, MemoryLead, MemoryStats } from "outbound-sdk/memory";

const DAY = 86_400_000;
const TODAY = Date.parse("2026-11-14T00:00:00.000Z");

const day = (daysAgo: number): string =>
  new Date(TODAY - daysAgo * DAY).toISOString().slice(0, 10);

// One row per day for the 21 days before today.
const daily = (
  campaignId: string,
  perDay: (daysAgo: number) => MemoryStats["counts"]
): MemoryStats[] =>
  Array.from({ length: 21 }, (_, index) => ({
    campaignId,
    counts: perDay(index + 1),
    date: day(index + 1),
  }));

const leads = (
  campaignId: string,
  status: NonNullable<MemoryLead["status"]>,
  count: number
): MemoryLead[] =>
  Array.from({ length: count }, (_, index) => ({
    campaignId,
    email: `${campaignId}.${status}.${index}@example.com`,
    id: `${campaignId}_${status}_${index}`,
    status,
  }));

export const seed: MemoryData = {
  campaigns: [
    { id: "founders", name: "SaaS founders", tags: ["saas"] },
    { id: "agencies", name: "Agency owners", tags: ["agency"] },
    {
      id: "ecom",
      name: "Ecommerce ops",
      settings: {
        dailyLimit: 80,
        schedule: [
          {
            days: [1, 2, 3, 4, 5],
            from: "09:00",
            timezone: "America/Chicago",
            to: "12:00",
          },
        ],
        trackOpens: false,
      },
      steps: [
        { variants: [{ body: "Hi {{firstName}}", subject: "Returns" }] },
        { delayDays: 3, variants: [{ body: "Following up." }] },
      ],
      tags: ["ecommerce"],
    },
    {
      channel: "linkedin",
      id: "linkedin",
      name: "LinkedIn founders",
      tags: ["saas"],
    },
    { id: "pilot", name: "Pilot test", status: "paused" },
  ],
  leads: [
    ...leads("founders", "active", 100),
    ...leads("founders", "replied", 40),
    ...leads("founders", "completed", 45),
    ...leads("founders", "bounced", 10),
    ...leads("founders", "unsubscribed", 5),
    ...leads("agencies", "active", 450),
    ...leads("ecom", "active", 400),
    ...leads("linkedin", "active", 300),
  ],
  senders: [
    {
      address: "ana@send.example.com",
      campaignIds: ["ecom"],
      dailyLimit: 40,
      id: "ana",
    },
    {
      address: "ben@send.example.com",
      campaignIds: ["ecom"],
      dailyLimit: 40,
      id: "ben",
      status: "error",
    },
  ],
  stats: [
    // Steady: 3 replies a day from 50 new leads.
    ...daily("founders", () => ({
      bounced: 1,
      contacted: 50,
      positive: 1,
      replied: 3,
      sent: 100,
    })),
    // Replies fell from 4 a day to 1 a day in the last seven days.
    ...daily("agencies", (daysAgo) => ({
      bounced: 1,
      contacted: 40,
      positive: daysAgo <= 7 ? 0 : 1,
      replied: daysAgo <= 7 ? 1 : 4,
      sent: 100,
    })),
    // Bounces alternate between 1 and 3 a day, then 14 on 2026-11-11.
    ...daily("ecom", (daysAgo) => ({
      bounced: daysAgo === 3 ? 14 : 1 + (daysAgo % 2) * 2,
      contacted: 50,
      positive: 0,
      replied: 1,
      sent: 80,
    })),
    // The LinkedIn tool counts no bounces.
    ...daily("linkedin", () => ({
      connectionsAccepted: 6,
      connectionsSent: 15,
      contacted: 15,
      positive: 1,
      replied: 2,
      sent: 30,
    })),
    // One day in September: 2 replies from 20 leads.
    {
      campaignId: "pilot",
      counts: { bounced: 0, contacted: 20, positive: 1, replied: 2, sent: 60 },
      date: "2026-09-15",
    },
  ],
};
```

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

import { seed } from "./seed.ts";

export const outbound = createOutbound({
  adapter: memory({ data: seed, workspaceId: "acme" }),
  plugins: [insights()],
});
```

## `campaignDiagnosis`

Why one campaign is underperforming: a list of findings, most serious first.

| Input | Default | Range | What it is |
| --- | --- | --- | --- |
| `campaign` | required | a campaign ref | The campaign to diagnose. |
| `from`, `to` | `null` | `YYYY-MM-DD` | The days the totals cover. |
| `min_sent` | 100 | 0 or more, whole | Under this many sends, rates are not judged. |
| `min_reply_rate` | 0.01 | 0 to 1 | A reply rate under this is a finding. |
| `max_bounce_rate` | 0.03 | 0 to 1 | A bounce rate over this is a finding. |
| `min_positive_share` | 0.2 | 0 to 1 | A share of replies that are positive under this is a finding. |
| `recent_days` | 7 | 1 to 30 | The days, ending yesterday, held against the same number before. |
| `max_send_drop` | 0.3 | 0 to 1 | A fall in sends, recent days against the days before, over this share is a finding. |
| `min_weekly_hours` | 20 | 0 to 168 | Sending windows open fewer hours a week than this limit volume. |
| `daily_limit_share` | 0.9 | 0 to 1 | Average daily sends at or over this share of the daily limit mean the limit holds volume back. |
| `max_senders` | 50 | 1 to 500 | The most senders of the campaign to read. |

`data` (`CampaignDiagnosis`):

- `campaign`, `name`, `status`, `channel`, `provider`.
- `period`: the `from` and `to` given.
- `totals`: `sent`, `contacted`, `replied`, `positive`, `bounced`.
- `rates`: `replyRate`, `positiveRate` and `bounceRate`, each with its interval.
- `findings`: each with a `code`, a `severity`, a `finding` sentence, the `numbers` behind it and `next`, what to look at. Empty when nothing was found in what could be checked.
- `notChecked`: what could not be checked, and why.
- `summary`: the count of findings and the most serious one.

The findings:

| Code | Severity | Fires when |
| --- | --- | --- |
| `high_bounce_rate` | high | The bounce rate is over `max_bounce_rate`. |
| `no_recent_sends` | high | The campaign is active but nothing was sent in the last `recent_days`. |
| `inactive_sender` | high | A sender of the campaign is not active. |
| `low_reply_rate` | high or medium | The reply rate is under `min_reply_rate`. High when the whole interval is under it. |
| `too_little_volume` | medium | Fewer sends than `min_sent`. Reply and bounce rates are then not judged. |
| `few_positive_replies` | medium | The positive share of replies is under `min_positive_share`. |
| `falling_sends` | medium | Sends fell by more than `max_send_drop`. |
| `short_sequence` | medium | The sequence has one or two steps. |
| `schedule_limits_volume` | low | The windows are open too few hours a week, or recent days run at the daily limit. |
| `tracking_off` | low | Open tracking is off, so a missing open count is expected. |

- **Needs:** `stats.campaign`. It checks more when the adapter has `campaigns.settings`, `sequence`, `senders.byCampaign` and `stats.byDay`.
- **Costs:** up to 7 requests.
- **Notes it can add:** one per part it could not check, the same sentences as `notChecked`: schedule and tracking, sequence length, sender status, recent sending trend.
- **Does not:** say all is well. An empty list means nothing was found in what could be checked. Read `notChecked` beside it. It does not read the copy: use `copyAudit` and `stepFunnel` for that.

```ts title="campaign-diagnosis.ts"
import { outbound } from "./outbound.ts";

const { data } = await outbound.insights.campaignDiagnosis({
  campaign: "memory:acme:ecom",
});

console.log(data.summary);

for (const finding of data.findings) {
  console.log(`${finding.severity} ${finding.code}: ${finding.finding}`);
}

console.log("not checked:", data.notChecked);
```

```text title="Output"
6 findings. Most serious: Bounce rate is 3.2% (54 of 1680 sends), over the 3% limit.
high high_bounce_rate: Bounce rate is 3.2% (54 of 1680 sends), over the 3% limit.
high inactive_sender: 1 of 2 senders are not active: ben@send.example.com (error).
medium few_positive_replies: Only 0 of 21 replies (0%) are positive, under the 20% expected. With 21 replies the 95% interval is 0% to 15.5%.
medium short_sequence: The sequence has 2 steps; follow-ups usually bring a large share of the replies.
low schedule_limits_volume: The sending windows are open 15 hours a week, under 20. Recent sending days average 80 sends against a daily limit of 80, so the limit is what holds volume back.
low tracking_off: Open tracking is off, so the missing open count is expected and not a fault.
not checked: []
```

## `pipelineForecast`

The leads one campaign has left, and what they should produce. It is an estimate and says so.

| Input | Default | Range | What it is |
| --- | --- | --- | --- |
| `campaign` | required | a campaign ref | The campaign to forecast. |
| `days` | 7 | 1 to 60 | How many complete days, ending yesterday, set the daily pace. |
| `max_leads` | 2,000 | 1 to 50,000 | The most leads to read. |

`data` (`PipelineForecast`):

- `campaign`, `name`, `status`, `channel`, `provider`.
- `estimate`: always `true`. Every figure below `remaining` is a projection, not a count.
- `leadsRead`, `leadsComplete` (`false` when `max_leads` stopped the read, so `remaining` is a minimum), `byStatus`.
- `remaining`: leads still `active` in the campaign.
- `dailyContactRate`: new leads contacted per day over the last `days`. `daysLeft`: `remaining` divided by it.
- `replyRate`, `positiveRate`: the campaign's rates so far, over its whole history.
- `expectedReplies`, `expectedPositive`: `expected`, `low` and `high`, from the rate and the ends of its interval.
- `assumptions`: what the estimate assumes, as sentences.
- `finding`: the answer in a sentence.

- **Needs:** `leads.byCampaign`, `stats.campaign`. Days left also needs `stats.byDay`.
- **Costs:** one request per page of leads, plus two stats requests.
- **Notes it can add:** days of sending left were not estimated because the adapter has no stats by day; a cap was reached.
- **Does not:** know the future. It assumes every remaining lead is contacted, that they reply at the rate the campaign has had so far, and that the daily pace stays what it was. Leads partway through the sequence may reply less. A day with no report counts as no sends.

```ts title="pipeline-forecast.ts"
import { outbound } from "./outbound.ts";

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

console.log(data.finding);

console.log("by status:", data.byStatus);

console.log(
  `${data.remaining} leads at ${data.dailyContactRate} a day: ${data.daysLeft} days left`
);

console.log("expected positive replies:", data.expectedPositive);

console.log(data.assumptions.at(-1));
```

```text title="Output"
100 leads remain; expect about 6 replies (between 4.72 and 7.6). This is an estimate.
by status: {
  active: 100,
  bounced: 10,
  completed: 45,
  paused: 0,
  replied: 40,
  unknown: 0,
  unsubscribed: 5
}
100 leads at 50 a day: 2 days left
expected positive replies: { expected: 2, high: 3.04, low: 1.31 }
The reply rate so far is 6%, from 63 replies over 1050.
```

The expected 6 replies are 100 leads at the 6% rate so far. The range, 4.72 to 7.6, is 100 leads at the two ends of that rate's interval.

## `anomalies`

Days that broke from trend, for one campaign or the whole workspace.

| Input | Default | Range | What it is |
| --- | --- | --- | --- |
| `campaign` | `null` | a campaign ref | `null` checks the whole workspace. |
| `days` | 30 | 1 to 90 | How many complete days, ending yesterday, are checked. |
| `baseline_days` | 14 | 5 to 60 | How many days before a day form the baseline it is held to. |
| `metrics` | `["sent", "replied", "bounced"]` | at least one of `sent`, `contacted`, `replied`, `positive`, `bounced`, `opened`, `clicked`, `unsubscribed` | The daily counts to check. |
| `threshold` | 2.5 | above 0 | How many standard deviations from the baseline mean is unusual. |
| `min_baseline_points` | 5 | 2 or more, whole | The fewest baseline days with data a day needs to be tested. |

`data` (`Anomalies`):

- `scope`: the campaign's ref, or `workspace`.
- `period`: the days checked. `threshold`: what was used.
- `anomalies`: newest first, each with `date`, `metric`, `value`, `baselineMean`, `baselinePoints`, `zScore`, `judgedBy`, `direction` (`above` or `below`) and a `finding` sentence. `judgedBy` is `z_score` for a day `threshold` standard deviations or more from the baseline mean. It is `flat_baseline` for a day that differs from a baseline whose days all had the same count: there is no standard deviation then, so `zScore` is `null`.
- `metrics`: for each metric, `daysWithData`, `daysTested`, `testable`, and the `reason` when no day could be tested.

- **Needs:** `stats.byDay`, and `stats.workspace` with `campaign: null` or `stats.campaign` with a campaign. On an adapter without `stats.workspace` the workspace call is refused as `Unsupported` before any request, with "not available for this input" in the message: pass a campaign there.
- **Costs:** one stats request.
- **Notes it can add:** none of its own.
- **Does not:** read a day the vendor has no count for as zero. Such days are skipped. A day is tested only when its baseline has at least `min_baseline_points` days. Against a flat baseline `threshold` does not apply: any other count is flagged, however small the difference. A break that lasts becomes part of its own baseline, so only its first days are flagged.

```ts title="anomalies.ts"
import { outbound } from "./outbound.ts";

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

console.log(`checked ${data.period.from} to ${data.period.to}`);

for (const anomaly of data.anomalies) {
  console.log(`${anomaly.direction}: ${anomaly.finding}`);
}

for (const metric of data.metrics) {
  console.log(
    `${metric.metric}: ${metric.daysTested} of ${metric.daysWithData} days tested`
  );

  if (metric.reason !== null) {
    console.log(`  ${metric.reason}`);
  }
}
```

```text title="Output"
checked 2026-10-15 to 2026-11-13
above: bounced was 16 on 2026-11-11, against an average of 4 over the 14 days with data before it (z = 11.5635).
below: replied was 7 on 2026-11-08, against an average of 9.7857 over the 14 days with data before it (z = -3.4744).
below: replied was 7 on 2026-11-07, against 10 on each of the 14 days with data before it (a flat baseline has no z-score: any other count is flagged).
sent: 16 of 21 days tested
replied: 16 of 21 days tested
bounced: 16 of 21 days tested
```

Workspace replies fell from 10 a day to 7 a day on 2026-11-07. That day is flagged against a flat baseline: the 14 days before it were all 10. The next day is flagged by its z-score, 3.47 standard deviations below a baseline that now holds one lower day. The days after are not, because the baseline by then holds more of the lower days. The first 5 of the 21 days with data have fewer than five earlier days, so 16 are tested. Sends are 310 every day: each day is tested and none differs. Real data is rarely this flat.

## `periodComparison`

The last `days` complete days against the `days` before, per campaign and in total.

| Input | Default | Range | What it is |
| --- | --- | --- | --- |
| `days` | 7 | 1 to 90 | The length of each period. |
| `alpha` | 0.05 | above 0, under 1 | The p-value under which a change counts as more than chance. |
| `min_sent` | 50 | 0 or more, whole | A campaign under this many sends in either period is listed apart. |
| `statuses`, `max_campaigns` | see above |  |  |

`data` (`PeriodComparison`):

- `days`, `current`, `previous`: the two periods as `from` and `to`.
- `campaigns`: one entry per campaign, with `current` and `previous` (counts and the three rates), `change` (current minus previous; rates change in points, so 0.02 is two points), `replyRateTest` (`z`, `pValue`, `significant`), `verdict` (`up`, `down`, `no_real_change` or `unknown`) and a `finding` sentence. Largest change in reply rate first.
- `tooFewSends`: campaigns under `min_sent` in either period, with `currentSent` and `previousSent`.
- `total`: the same comparison for every campaign read, added together. Each rate counts only the campaigns that report both its count and what it is divided by, so the bounces of the campaigns that report them are never divided by every campaign's sends.
- `workspace`: the same for the vendor's own workspace totals, or `null` when the adapter has none. On a combined instance the tools' rows are added up by the same rule.

- **Needs:** `stats.campaign`.
- **Costs:** two stats requests per campaign, plus two for the workspace.
- **Notes it can add:** workspace totals were left out because the adapter has no workspace stats; a rate of `total` or `workspace` leaves out campaigns (or tools) that do not report one side of it, naming the rate and how many are counted; a cap was reached.
- **Does not:** test anything but the reply rate. The other changes are plain differences. `verdict` is `unknown` when a period has no reply or volume counts, or neither period has a reply.

```ts title="period-comparison.ts"
import { outbound } from "./outbound.ts";

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

console.log(
  `${data.current.from} to ${data.current.to}, against ${data.previous.from} to ${data.previous.to}`
);

for (const campaign of data.campaigns) {
  console.log(`${campaign.name} (${campaign.verdict}): ${campaign.finding}`);
}

console.log(`all campaigns (${data.total.verdict}): ${data.total.finding}`);

console.log(
  "too few sends:",
  data.tooFewSends.map((campaign) => campaign.name)
);
```

```text title="Output"
2026-11-07 to 2026-11-13, against 2026-10-31 to 2026-11-06
Agency owners (down): Reply rate went from 10% to 2.5%; more than chance (p = 0.0002).
Ecommerce ops (no_real_change): Reply rate went from 2% to 2%; within what chance would give (p = 1).
LinkedIn founders (no_real_change): Reply rate went from 13.3% to 13.3%; within what chance would give (p = 1).
SaaS founders (no_real_change): Reply rate went from 6% to 6%; within what chance would give (p = 1).
all campaigns (down): Reply rate went from 6.5% to 4.5%; more than chance (p = 0.0477).
too few sends: [ 'Pilot test' ]
```

"Pilot test" sent nothing in either week, so it is listed apart.

## `campaignRanking`

Which campaigns are best, ranked so a small sample cannot win on luck.

| Input | Default | Range | What it is |
| --- | --- | --- | --- |
| `metric` | `reply_rate` | `reply_rate`, `positive_reply_rate`, `bounce_rate` | The rate to rank by. For `bounce_rate`, lower is better. |
| `min_sent` | 50 | 0 or more, whole | Campaigns with fewer sends than this are left out of the rank. |
| `from`, `to`, `statuses`, `max_campaigns` | see above |  |  |

`data` (`CampaignRanking`):

- `metric`, `better` (`higher` or `lower`), `period`.
- `ranked`: best first, each with `rank`, `sent`, and the rate as `count`, `of`, `rate`, `low` and `high`.
- `tooFewSends`: campaigns under `min_sent`, with the `reason`.
- `unknown`: campaigns whose count for this metric the vendor does not report, with the `reason`.
- `gap`: `top` (the rate of the campaign ranked first), `median` and `difference`.

- **Needs:** `stats.campaign`.
- **Costs:** one listing per status, plus one stats request per campaign.
- **Notes it can add:** a cap was reached.
- **Does not:** rank by the raw rate. It ranks by the low end of the 95% interval, or for a bounce rate by the high end, lowest first. A campaign with a `null` rate is in `unknown`, never ranked last.

```ts title="campaign-ranking.ts"
import { outbound } from "./outbound.ts";

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

for (const campaign of data.ranked) {
  console.log(
    `${campaign.rank}. ${campaign.name}: ${campaign.count} of ${campaign.of} = ${campaign.rate}, interval ${campaign.low} to ${campaign.high}`
  );
}

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

```text title="Output"
1. LinkedIn founders: 42 of 315 = 0.1333, interval 0.1002 to 0.1753
2. Agency owners: 63 of 840 = 0.075, interval 0.0591 to 0.0948
3. SaaS founders: 63 of 1050 = 0.06, interval 0.0472 to 0.076
4. Pilot test: 2 of 20 = 0.1, interval 0.0279 to 0.301
5. Ecommerce ops: 21 of 1050 = 0.02, interval 0.0131 to 0.0304
gap: { difference: 0.0583, median: 0.075, top: 0.1333 }
```

"Pilot test" has the second highest raw rate, 10%, but from 20 leads. Its interval reaches down to 2.79%, so it ranks fourth, under two campaigns whose 6% and 7.5% rest on about a thousand leads each.

## `channelMix`

Email against LinkedIn, from campaign totals added up per channel.

| Input | Default | Range | What it is |
| --- | --- | --- | --- |
| `alpha` | 0.05 | above 0, under 1 | The p-value under which a difference counts as more than chance. |
| `from`, `to`, `statuses`, `max_campaigns` | see above |  |  |

`data` (`ChannelMix`):

- `period`.
- `channels`: email first, then LinkedIn, only the channels that have campaigns. Each has `campaigns`, `sent`, `contacted`, `replied`, `positive`, `bounced`, `replyRate`, `positiveRate` and `acceptance` (connection requests accepted out of those sent; `null` for email).
- `comparison`: `higher` (the channel with the higher reply rate), `difference` and the `test`. `null` when there is one channel, or a channel has no counts to compare.
- `finding`: the answer in a sentence.
- `byProvider`: the same numbers for each tool and channel.

- **Needs:** `stats.campaign`.
- **Costs:** one listing per status, plus one stats request per campaign.
- **Notes it can add:** a channel's rate leaves out campaigns that do not report one side of it; a cap was reached.
- **Does not:** make the two channels comparable. A rate counts only the campaigns that report both its count and what it is divided by, so its `count` and `of` can be smaller than the channel's totals beside it. A LinkedIn reply and an email reply are counted the same way, but the audiences usually differ. With one channel present it returns that channel and says there is nothing to compare.

```ts title="channel-mix.ts"
import { outbound } from "./outbound.ts";

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

console.log(data.finding);

for (const channel of data.channels) {
  console.log(
    `${channel.channel}, campaigns ${channel.campaigns}: ${channel.replied} replies from ${channel.contacted} leads (${channel.replyRate.rate}), acceptance ${channel.acceptance?.rate ?? "not applicable"}`
  );
}
```

```text title="Output"
The reply rate is 13.3% on LinkedIn against 5% on email; the difference is more than chance (p < 0.0001).
email, campaigns 4: 149 replies from 2960 leads (0.0503), acceptance not applicable
linkedin, campaigns 1: 42 replies from 315 leads (0.1333), acceptance 0.4
```

`p < 0.0001` is how a finding writes a p-value that rounds to 0 at four places. The `pValue` field beside it is `0`.

## `tagRollup`

Results grouped by campaign tag, for example one tag per client, offer or audience.

| Input | Default | Range | What it is |
| --- | --- | --- | --- |
| `min_sent` | 50 | 0 or more, whole | Tags with fewer sends than this are listed apart. |
| `from`, `to`, `statuses`, `max_campaigns` | see above |  |  |

`data` (`TagRollup`):

- `period`, `campaigns` (campaigns read).
- `tags`: one entry per tag, with `tag` (`null` for campaigns that carry none), `campaigns`, `sent`, `contacted`, `replied`, `positive`, `bounced`, and `replyRate`, `positiveRate` and `bounceRate` with their intervals. Best first, by the low end of the reply rate interval.
- `tooFewSends`: tags under `min_sent`.

- **Needs:** `stats.campaign`.
- **Costs:** one listing per status, plus one stats request per campaign.
- **Notes it can add:** a tag's rate leaves out campaigns that do not report one side of it, naming the rate and how many campaigns are counted; a cap was reached.
- **Does not:** split a campaign between its tags. A campaign with two tags counts in full in both, so the tags do not add up to the workspace. A rate counts only the campaigns that report both its count and what it is divided by, so its `count` and `of` can be smaller than the tag's totals beside it.

```ts title="tag-rollup.ts"
import { outbound } from "./outbound.ts";

const { data, meta } = await outbound.insights.tagRollup({});

for (const tag of data.tags) {
  console.log(
    `${tag.tag ?? "(no tag)"}: campaigns ${tag.campaigns}, sent ${tag.sent}, reply rate ${tag.replyRate.rate} (low end ${tag.replyRate.low}), bounce rate ${tag.bounceRate.rate}`
  );
}

for (const note of meta.notes) {
  console.log(`note: ${note}`);
}
```

```text title="Output"
saas: campaigns 2, sent 2730, reply rate 0.0769 (low end 0.0639), bounce rate 0.01
agency: campaigns 1, sent 2100, reply rate 0.075 (low end 0.0591), bounce rate 0.01
(no tag): campaigns 1, sent 60, reply rate 0.1 (low end 0.0279), bounce rate 0
ecommerce: campaigns 1, sent 1680, reply rate 0.02 (low end 0.0131), bounce rate 0.0321
note: Tag saas: bounce rate (1 of 2) counts only the campaigns that report both the count and what it is divided by. The others are left out of the rate, not counted as 0.
```

The `saas` tag shows that last rule. It sent 2,730 messages, but 630 of them are the LinkedIn campaign's, which reports no bounces. Its bounce rate is the email campaign's 21 bounces over that campaign's 2,100 sends, or 0.01, and the note says one of the two campaigns is counted.

## `clientReport`

One report for one client or workspace over the last `days` complete days, composed from what the other insights on this page compute.

| Input | Default | Range | What it is |
| --- | --- | --- | --- |
| `days` | 7 | 1 to 90 | The length of the period. |
| `top` | 3 | 1 to 20 | How many best and worst campaigns to show. |
| `alpha` | 0.05 | above 0, under 1 | The p-value under which a change counts as more than chance. |
| `min_sent` | 50 | 0 or more, whole | Campaigns under this many sends in the period are not judged. |
| `max_bounce_rate` | 0.03 | 0 to 1 | A campaign bounce rate over this is a bounce problem. |
| `runway_days` | 7 | 0 or more | A campaign with this many days of leads left or fewer is flagged. |
| `max_leads_per_campaign` | 500 | 1 to 50,000 | The most leads to read per active campaign, for leads left. |
| `anomaly_threshold` | 2.5 | above 0 | Standard deviations from the baseline that make a day unusual. |
| `baseline_days` | 14 | 5 to 60 | Days before the period that form the anomaly baseline. |
| `min_baseline_points` | 5 | 2 or more, whole | The fewest baseline days with data a day needs to be tested. |
| `statuses`, `max_campaigns` | see above |  |  |

`data` (`ClientReport`):

- `days`, `period`, `previousPeriod`, `campaigns` (campaigns read).
- `totals`: every campaign added together, as in `periodComparison`: `current`, `previous`, `change`, `replyRateTest`, `verdict` and `finding`. A rate counts only the campaigns that report both sides of it.
- `top`, `bottom`: the best and worst campaigns by the low end of the reply rate interval. `bottom` never repeats a campaign in `top`.
- `bounceProblems`: campaigns over `max_bounce_rate`, worst first.
- `anomalies`: unusual days in the period, workspace-wide. `null` when the adapter cannot serve them.
- `runningOutOfLeads`: active campaigns with `runway_days` of leads or fewer, with `remaining`, `dailyContactRate` and `daysLeft`. `null` when the adapter cannot serve them.
- `actions`: what to do, most pressing first. Each has a `kind` (`fix_bounces`, `add_leads`, `reply_rate_fell`, `review_campaign` or `check_anomaly`), the `campaign` and `campaignName` it is about, an `action` sentence and the `number` behind it.
- `leftOut`: the sections left out because the adapter cannot serve them.

- **Needs:** `stats.campaign`. Unusual days also need `stats.byDay` and workspace stats. Leads left also needs `leads.byCampaign` and `stats.byDay`.
- **Costs:** two stats requests per campaign, one for the workspace, and for each active campaign two more plus its pages of leads.
- **Notes it can add:** one per section left out, starting "Left out."; a rate of `totals` leaves out campaigns that do not report one side of it; a cap was reached.
- **Does not:** filter by tag or by campaign. It reports on everything the instance holds, or the one workspace it is scoped to. A campaign whose leads were cut off at `max_leads_per_campaign` is never flagged as running out, because its count is a minimum. It writes no email and sends nothing.

```ts title="client-report.ts"
import { outbound } from "./outbound.ts";

const { data, meta } = await outbound.insights.clientReport({});

console.log(data.totals.finding);

console.log("top:", data.top.map((campaign) => campaign.name).join(", "));

console.log("bottom:", data.bottom.map((campaign) => campaign.name).join(", "));

for (const item of data.actions) {
  console.log(`${item.kind}: ${item.action}`);
}

console.log("complete:", meta.complete, "left out:", data.leftOut);
```

```text title="Output"
Reply rate went from 6.5% to 4.5%; more than chance (p = 0.0477).
top: LinkedIn founders, SaaS founders, Agency owners
bottom: Ecommerce ops
fix_bounces: Fix the bounces of Ecommerce ops: its bounce rate is 4.6% (26 of 560 sends).
add_leads: Add leads to SaaS founders: 100 leads remain, about 2 days of sending.
reply_rate_fell: Find out what changed: the reply rate fell from 6.5% to 4.5%, more than chance would give.
review_campaign: Review the copy and the audience of Ecommerce ops: it has the weakest reply rate, 2% (7 of 350).
check_anomaly: Check what happened on 2026-11-11: bounced was 16 on 2026-11-11, against an average of 4 over the 14 days with data before it (z = 11.5635).
check_anomaly: Check what happened on 2026-11-08: replied was 7 on 2026-11-08, against an average of 9.7857 over the 14 days with data before it (z = -3.4744).
check_anomaly: Check what happened on 2026-11-07: replied was 7 on 2026-11-07, against 10 on each of the 14 days with data before it (a flat baseline has no z-score: any other count is flagged).
complete: true left out: []
```

The [weekly client report](/guides/weekly-client-report) guide turns this answer into text a client can read.
