---
title: Build a weekly client report an agency can send, from one call
description: One function that turns a client's campaigns into a plain-text weekly report with the change from last week, the best campaigns and a to-do list, plus what it does when a vendor cannot answer.
sidebar:
  label: Weekly client report
related:
  - /docs/insights
  - /docs/adapters/combine
  - /docs/guides/run-many-clients
---

Every Monday an agency writes the same email for each client: what went out, what came back, what changed, what needs doing. `clientReport` computes all of it in one call: this week against the week before, with a test for whether the change is more than chance; the best campaigns; campaigns over a bounce threshold; campaigns about to run out of leads; days that broke from trend; and a list of actions. It calls no AI model. Every number is a count, a rate or a textbook test.

This guide wraps that call in one function that returns the report as text, runs it for every client behind one instance, and shows what happens when it cannot answer.

> **Preview: not published yet**
>
> `outbound-sdk` is not on npm, and its GitHub repository is private, so there
> is no install command that works today. Instantly and HeyReach are `beta`:
> their reads were run, read-only, against one live workspace each. Smartlead is
> `unverified`: written from its public documentation and tested offline only.
> See [what is verified](/docs/status).

## Before you start

- Node 22 or later.
- The code below runs on the in-memory adapter, so it needs no vendor key. Each sample on this page is a file in the repository that the test suite runs; the output shown is what it printed.
- For real data: an Instantly, Smartlead or HeyReach API key. A read-only key is enough. No method of the SDK writes to a vendor.

## What you get

```text title="Output"
Outbound report for acme: 2026-11-07 to 2026-11-13

Sent 1610, replies 70, bounces 77
Reply rate 13.3% (the week before: 9.3%)
Reply rate went from 9.3% to 13.3%; more than chance (p = 0.0409).

Best campaigns:
  1. Agency owners: 24.0% reply rate (42 of 175)
  2. SaaS founders: 15.0% reply rate (21 of 140)
  3. Ecommerce ops: 3.3% reply rate (7 of 210)

To do:
  - Fix the bounces of Ecommerce ops: its bounce rate is 10% (63 of 630 sends).
  - Add leads to Ecommerce ops: 0 leads remain, about 0 days of sending.
  - Add leads to SaaS founders: 40 leads remain, about 2 days of sending.
  - Check what happened on 2026-11-08: replied was 10 on 2026-11-08, against an average of 7.2143 over the 14 days with data before it (z = 3.4744).
  - Check what happened on 2026-11-08: bounced was 11 on 2026-11-08, against an average of 3.5714 over the 14 days with data before it (z = 3.4744).
  - Check what happened on 2026-11-07: replied was 10 on 2026-11-07, against 7 on each of the 14 days with data before it (a flat baseline has no z-score: any other count is flagged).
  - Check what happened on 2026-11-07: bounced was 11 on 2026-11-07, against 3 on each of the 14 days with data before it (a flat baseline has no z-score: any other count is flagged).
```

## Write the report

`outbound.insights.clientReport` takes the length of the period in days and returns `data` and `meta`. The function below formats `data` and passes on anything `meta` says about the answer.

```ts title="report.ts"
import type { Outbound } from "outbound-sdk";
import type { InsightsExtension } from "outbound-sdk/insights";

const percent = (rate: number | null): string =>
  rate === null ? "not reported" : `${(rate * 100).toFixed(1)}%`;

// A count the vendor does not report is null, never 0. Say so.
const count = (value: number | null): string =>
  value === null ? "not reported" : String(value);

/** The week for one client as plain text, ready to paste into an email. */
export const weeklyReport = async (
  outbound: Outbound & InsightsExtension
): Promise<string> => {
  const { data, meta } = await outbound.insights.clientReport({ days: 7 });

  const { current, previous } = data.totals;

  const lines = [
    `Outbound report for ${outbound.workspaceId}: ${data.period.from} to ${data.period.to}`,
    "",
    `Sent ${count(current.sent)}, replies ${count(current.replied)}, bounces ${count(current.bounced)}`,
    `Reply rate ${percent(current.replyRate)} (the week before: ${percent(previous.replyRate)})`,
    data.totals.finding,
    "",
    "Best campaigns:",
    ...data.top.map(
      (campaign) =>
        `  ${campaign.rank}. ${campaign.name}: ${percent(campaign.rate)} reply rate (${campaign.count} of ${campaign.of})`
    ),
    "",
    "To do:",
    ...data.actions.map((item) => `  - ${item.action}`),
  ];

  // An answer cut short by a cap, or built on a count the vendor estimates,
  // says so. Pass that on instead of presenting it as the whole picture.
  if (!meta.complete || meta.notes.length > 0) {
    lines.push(
      "",
      "Read this before relying on the numbers:",
      ...meta.notes.map((note) => `  - ${note}`)
    );
  }

  return lines.join("\n");
};
```

Three things in it are worth copying:

- **A count can be `null`.** A vendor that does not report a number gets `null`, never `0`, and a rate built on it is `null` too. Print "not reported", not a zero.
- **`data.totals.finding` is a sentence.** It already says whether the reply rate moved by more than chance, from a two-proportion test at `alpha` (default 0.05).
- **`meta.complete` and `meta.notes` travel with the report.** When a cap stopped a read early, the report covers part of the data and a note says which part.

"The last 7 days" means seven complete UTC days ending yesterday, so a half-finished today cannot read as a drop.

## Try it without a key

The in-memory adapter takes plain data. This seed is three weeks of results for one client, with one campaign whose bounces jumped last week:

```ts title="seed.ts"
// Three weeks of made-up results for one client, "acme": two email campaigns
// that are doing fine and one whose bounces jumped last week.
import type { MemoryData, 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);

const daily = (
  campaignId: string,
  perDay: (daysAgo: number) => MemoryStats["counts"]
): MemoryStats[] =>
  Array.from({ length: 21 }, (_, index) => ({
    campaignId,
    counts: perDay(index + 1),
    date: day(index + 1),
  }));

export const seed: MemoryData = {
  campaigns: [
    { id: "cmp_founders", name: "SaaS founders", tags: ["acme"] },
    { id: "cmp_agencies", name: "Agency owners", tags: ["acme"] },
    { id: "cmp_ecom", name: "Ecommerce ops", tags: ["acme"] },
  ],
  leads: [
    ...Array.from({ length: 40 }, (_, index) => ({
      campaignId: "cmp_founders",
      email: `founder${index}@example.com`,
      id: `lead_f${index}`,
      status: "active" as const,
    })),
    ...Array.from({ length: 400 }, (_, index) => ({
      campaignId: "cmp_agencies",
      email: `owner${index}@example.com`,
      id: `lead_a${index}`,
      status: "active" as const,
    })),
  ],
  stats: [
    // Steady: 60 sends a day, 3 replies.
    ...daily("cmp_founders", () => ({
      bounced: 1,
      contacted: 20,
      replied: 3,
      sent: 60,
    })),
    // Replies doubled in the last seven days.
    ...daily("cmp_agencies", (daysAgo) => ({
      bounced: 1,
      contacted: 25,
      replied: daysAgo <= 7 ? 6 : 3,
      sent: 80,
    })),
    // Bounces went from 1 a day to 9 a day in the last seven days.
    ...daily("cmp_ecom", (daysAgo) => ({
      bounced: daysAgo <= 7 ? 9 : 1,
      contacted: 30,
      replied: 1,
      sent: 90,
    })),
  ],
};
```

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

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

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

console.log(await weeklyReport(outbound));
```

That is the script that printed the report above.

## Point it at your vendor

Swap the adapter. Nothing else changes.

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

// Reads INSTANTLY_API_KEY. Swap the import for smartlead() or heyreach().
export const outbound = createOutbound({
  adapter: instantly({ workspaceId: "acme" }),
  // One report may spend at most 120 vendor requests.
  maxRequests: 120,
  plugins: [insights()],
});
```

`maxRequests` caps what one call may spend. On a real vendor the report costs two stats requests per campaign, and for each active campaign three more plus a read of its leads (up to `max_leads_per_campaign`, default 500). The default `max_campaigns` is 25. If the cap is reached the call fails with `RateLimited` instead of spending your vendor quota; raise `maxRequests` or lower `max_campaigns`.

## One report per client

Give each client's key its own adapter and a `workspaceId` that is your label for the client. `outbound.workspace(id)` is the same instance scoped to that client, with the same plugins, so the function above runs unchanged. A client on two tools keeps one label on both adapters, and its scope covers both.

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

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

// One adapter per client key. `workspaceId` is your own label for the client.
const outbound = createOutbound({
  adapter: combine([
    memory({ data: seed, workspaceId: "acme" }),
    memory({ data: { campaigns: [] }, workspaceId: "globex" }),
  ]),
  plugins: [insights()],
});

for (const workspace of await outbound.workspaces.listAll().toArray()) {
  const client = outbound.workspace(workspace.workspaceId);
  const report = await weeklyReport(client);

  console.log(report.split("\n").slice(0, 3).join("\n"), "\n");
}
```

```text title="Output"
Outbound report for acme: 2026-11-07 to 2026-11-13

Sent 1610, replies 70, bounces 77 

Outbound report for globex: 2026-11-07 to 2026-11-13

Sent not reported, replies not reported, bounces not reported 

```

The second client has no campaigns, so its counts are "not reported" and not zero.

To send it weekly, call the function from whatever scheduler you already run (cron, a GitHub Actions schedule, a Vercel cron route) and hand the text to your email tool. The SDK stores nothing and schedules nothing.

## What each vendor can serve

The same function, run on the in-memory adapter limited to what each vendor's adapter declares. This shows which calls a vendor's adapter would refuse. It does not show what that vendor's API returns.

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

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

for (const vendor of VENDORS) {
  const outbound = createOutbound({
    adapter: asVendor(vendor, seed, "acme"),
    plugins: [insights()],
  });

  try {
    const report = await weeklyReport(outbound);
    console.log(`${vendor}: ok, ${report.split("\n").length} lines`);
  } catch (error) {
    if (!OutboundError.is(error)) {
      throw error;
    }

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

```text title="Output"
instantly: ok, 19 lines
smartlead: ok, 19 lines
heyreach: ok, 19 lines
```

`clientReport` needs only `stats.campaign`, which all three declare. Parts of the report that need more (`stats.byDay` for unusual days, `leads.byCampaign` for leads left) come back `null` with a note when an adapter cannot read them.

## When it cannot answer

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

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

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

// 1. An input the insight does not take is refused. It is never dropped,
//    which would quietly report on every campaign.
try {
  // @ts-expect-error -- `tag` is not an input of clientReport
  await outbound.insights.clientReport({ tag: "acme" });
} catch (error) {
  if (OutboundError.is(error)) {
    console.log("unknown input:", error.code, "-", error.message);
  }
}

// 2. A cap that stops a read early marks the answer incomplete and says why.
const capped = await outbound.insights.clientReport({
  max_campaigns: 2,
});

console.log("capped:", capped.meta.complete, capped.meta.notes);

// 3. Input outside its range is refused before any request.
try {
  await outbound.insights.clientReport({ days: 400 });
} catch (error) {
  if (OutboundError.is(error)) {
    console.log("bad input:", error.code);
  }
}

// 4. An adapter that cannot read campaign stats refuses the whole insight.
const noStats = createOutbound({
  adapter: memory({ capabilities: { "stats.campaign": false }, data: seed }),
  plugins: [insights()],
});

try {
  await noStats.insights.clientReport({});
} catch (error) {
  if (OutboundError.is(error)) {
    console.log("no stats:", error.code, "-", error.message);
  }
}
```

```text title="Output"
unknown input: Invalid - client_report: "tag" is not an input. It takes: alpha, anomaly_threshold, baseline_days, days, max_bounce_rate, max_campaigns, max_leads_per_campaign, min_baseline_points, min_sent, runway_days, statuses, top
capped: false [ 'A listing was cut off at its cap; more records exist.' ]
bad input: Invalid
no stats: Unsupported - client_report is not available: the memory adapter does not support stats.campaign
```

| What happened | What you get | What to do |
| --- | --- | --- |
| An input the insight does not take | `Invalid`, naming the key and listing the inputs | Fix the key. An unknown filter is never dropped silently. |
| A cap stopped a read | `meta.complete` is `false` and a note says why | Raise the cap, or send the report with the note. |
| Input out of range | `Invalid`, before any request | Fix the value. |
| The adapter cannot read campaign stats | `Unsupported`, before any request | Use an adapter that declares `stats.campaign`. |
| A rejected key | `Unauthorized`. It is not retried. | Check the key and its scope. |
| The vendor is rate limiting | Waited out and retried for you. `RateLimited` only once `deadline` or `maxRequests` is passed. | Raise the budget, or run the report later. |

## Limits

- The report is per instance or per workspace. There is no filter by campaign tag on `clientReport`; use `tagRollup` to group results by tag.
- Anomalies need at least five days of history before the period (`min_baseline_points`).
- "Leads left" reads up to `max_leads_per_campaign` leads per active campaign. Past that, the report says it is incomplete.
- The numbers are only as good as the vendor's. On an `unverified` adapter, check a report against the vendor's own dashboard before sending it to a client.
