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

Campaign operations and reporting insights

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.

Eight insights computed from campaign stats. Each is a method on outbound.insights once the insights plugin is added. What every answer carries and how inputs are checked is on the overview.

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.

// 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",
    },
  ],
};
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.
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);
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.

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));
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.

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}`);
  }
}
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.

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)
);
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.

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);
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.

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"}`
  );
}
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.

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}`);
}
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.

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);
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 guide turns this answer into text a client can read.

Was this page helpful?