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,lowandhigh. - “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: thefromandtogiven.totals:sent,contacted,replied,positive,bounced.rates:replyRate,positiveRateandbounceRate, each with its interval.findings: each with acode, aseverity, afindingsentence, thenumbersbehind it andnext, 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 hascampaigns.settings,sequence,senders.byCampaignandstats.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
notCheckedbeside it. It does not read the copy: usecopyAuditandstepFunnelfor 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: alwaystrue. Every figure belowremainingis a projection, not a count. -
leadsRead,leadsComplete(falsewhenmax_leadsstopped the read, soremainingis a minimum),byStatus. -
remaining: leads stillactivein the campaign. -
dailyContactRate: new leads contacted per day over the lastdays.daysLeft:remainingdivided by it. -
replyRate,positiveRate: the campaign’s rates so far, over its whole history. -
expectedReplies,expectedPositive:expected,lowandhigh, 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 needsstats.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, orworkspace. -
period: the days checked.threshold: what was used. -
anomalies: newest first, each withdate,metric,value,baselineMean,baselinePoints,zScore,judgedBy,direction(aboveorbelow) and afindingsentence.judgedByisz_scorefor a daythresholdstandard deviations or more from the baseline mean. It isflat_baselinefor a day that differs from a baseline whose days all had the same count: there is no standard deviation then, sozScoreisnull. -
metrics: for each metric,daysWithData,daysTested,testable, and thereasonwhen no day could be tested. -
Needs:
stats.byDay, andstats.workspacewithcampaign: nullorstats.campaignwith a campaign. On an adapter withoutstats.workspacethe workspace call is refused asUnsupportedbefore 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_pointsdays. Against a flat baselinethresholddoes 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 asfromandto. -
campaigns: one entry per campaign, withcurrentandprevious(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_changeorunknown) and afindingsentence. Largest change in reply rate first. -
tooFewSends: campaigns undermin_sentin either period, withcurrentSentandpreviousSent. -
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, ornullwhen 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
totalorworkspaceleaves 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.
verdictisunknownwhen 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(higherorlower),period. -
ranked: best first, each withrank,sent, and the rate ascount,of,rate,lowandhigh. -
tooFewSends: campaigns undermin_sent, with thereason. -
unknown: campaigns whose count for this metric the vendor does not report, with thereason. -
gap:top(the rate of the campaign ranked first),mediananddifference. -
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
nullrate is inunknown, 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 hascampaigns,sent,contacted,replied,positive,bounced,replyRate,positiveRateandacceptance(connection requests accepted out of those sent;nullfor email). -
comparison:higher(the channel with the higher reply rate),differenceand thetest.nullwhen 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
countandofcan 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, withtag(nullfor campaigns that carry none),campaigns,sent,contacted,replied,positive,bounced, andreplyRate,positiveRateandbounceRatewith their intervals. Best first, by the low end of the reply rate interval. -
tooFewSends: tags undermin_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
countandofcan 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 inperiodComparison:current,previous,change,replyRateTest,verdictandfinding. 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.bottomnever repeats a campaign intop. -
bounceProblems: campaigns overmax_bounce_rate, worst first. -
anomalies: unusual days in the period, workspace-wide.nullwhen the adapter cannot serve them. -
runningOutOfLeads: active campaigns withrunway_daysof leads or fewer, withremaining,dailyContactRateanddaysLeft.nullwhen the adapter cannot serve them. -
actions: what to do, most pressing first. Each has akind(fix_bounces,add_leads,reply_rate_fell,review_campaignorcheck_anomaly), thecampaignandcampaignNameit is about, anactionsentence and thenumberbehind it. -
leftOut: the sections left out because the adapter cannot serve them. -
Needs:
stats.campaign. Unusual days also needstats.byDayand workspace stats. Leads left also needsleads.byCampaignandstats.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
totalsleaves 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_campaignis 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.