---
title: Read every client's outbound data through one instance
description: One instance over every client key, scoped to a single client when you need it, with per-client totals, separate rate budgets and rules for a workspace that holds several clients.
sidebar:
  label: Run many clients
related:
  - /docs/adapters/combine
  - /docs/plugins/clients
  - /docs/rate-limits
  - /guides/weekly-client-report
  - /guides/shared-rate-budget
---

An agency holds one API key per client, sometimes two when a client is on an email tool and a LinkedIn tool, and sometimes one key whose workspace holds several clients' campaigns. `combine` puts any number of adapters behind one instance. The `clients` plugin tags every record with the client it belongs to and adds totals per client.

Every record keeps its own provider and workspace, so two clients' data cannot be confused, and nothing is added together across clients unless you ask for it. The combined instance reads each tool live on each call and keeps nothing.

> **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: one API key per client and tool. Read-only keys are enough. No method of the SDK writes to a vendor.

## What you get

```text title="Output"
Every campaign:
  emailtool:acme:cmp_1  Acme founders
  emailtool:acme:cmp_2  Acme agencies
  linkedintool:acme:cmp_1  Acme on LinkedIn
  emailtool:globex:cmp_1  Globex outreach

acme (combined): 3 campaigns
globex (emailtool): cmp_1 is "Globex outreach"
acme via clients.stats(): 3 campaigns, sent 300, connections sent 40

Workspace stats:
  emailtool:acme  sent 300, connections sent null
  linkedintool:acme  sent null, connections sent 40
  emailtool:globex  sent 50, connections sent null

Per client:
  acme: 3 campaigns, sent 300, replied 10
  globex: 1 campaigns, sent 50, replied 1
```

## One adapter per client key

Give each key its own adapter, and give each adapter a `workspaceId`. That is your label for the client, not anything the vendor knows. It ends up in every ref: `provider:workspace:id`.

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

import { acmeEmail, acmeLinkedin, globexEmail } from "./seed.ts";

// One adapter per client key. `workspaceId` is your own label for the client.
// `name` stands in for the vendor here; a real adapter sets it for you.
export const outbound = createOutbound({
  adapter: combine([
    memory({ data: acmeEmail, name: "emailtool", workspaceId: "acme" }),
    memory({ data: acmeLinkedin, name: "linkedintool", workspaceId: "acme" }),
    memory({ data: globexEmail, name: "emailtool", workspaceId: "globex" }),
  ]),
  // With no rules, one workspace is one client.
  plugins: [clients()],
});
```

- **A client on two tools carries one label on both adapters.** Two adapters may share a label as long as their providers differ.
- **Two adapters with the same provider and the same label are refused.** Their records would have the same refs.
- **Plugins are listed once**, on the combined instance, and apply to every member.

The samples name the in-memory adapters `emailtool` and `linkedintool` so the refs are readable. A real adapter sets its own provider name.

## Try it without a key

```ts title="seed.ts"
// Three accounts. "acme" is a client on two tools, email and LinkedIn.
// "globex" is a client on the email tool only. "agency" is one email
// workspace that holds campaigns for several clients, told apart by tag.
import type { MemoryData } from "outbound-sdk/memory";

export const acmeEmail: MemoryData = {
  campaigns: [
    { id: "cmp_1", name: "Acme founders" },
    { id: "cmp_2", name: "Acme agencies" },
  ],
  stats: [
    {
      campaignId: "cmp_1",
      counts: { replied: 6, sent: 200 },
      date: "2026-11-12",
    },
    {
      campaignId: "cmp_2",
      counts: { replied: 4, sent: 100 },
      date: "2026-11-12",
    },
  ],
};

export const acmeLinkedin: MemoryData = {
  campaigns: [{ channel: "linkedin", id: "cmp_1", name: "Acme on LinkedIn" }],
  stats: [
    {
      campaignId: "cmp_1",
      counts: { connectionsAccepted: 12, connectionsSent: 40 },
      date: "2026-11-12",
    },
  ],
};

export const globexEmail: MemoryData = {
  campaigns: [{ id: "cmp_1", name: "Globex outreach" }],
  stats: [
    {
      campaignId: "cmp_1",
      counts: { replied: 1, sent: 50 },
      date: "2026-11-12",
    },
  ],
};

export const agency: MemoryData = {
  campaigns: [
    { id: "cmp_a", name: "Initech Q4", tags: ["initech"] },
    { id: "cmp_b", name: "Initech webinar", tags: ["initech"] },
    { id: "cmp_c", name: "Umbrella: founders", tags: [] },
    { id: "cmp_d", name: "Internal test", tags: [] },
  ],
  leads: [
    { campaignId: "cmp_a", email: "ana@example.com", id: "lead_1" },
    { campaignId: "cmp_c", email: "ben@example.com", id: "lead_2" },
    { campaignId: "cmp_d", email: "caro@example.com", id: "lead_3" },
  ],
  stats: [
    {
      campaignId: "cmp_a",
      counts: { replied: 5, sent: 100 },
      date: "2026-11-12",
    },
    {
      campaignId: "cmp_b",
      counts: { replied: 2, sent: 60 },
      date: "2026-11-12",
    },
    {
      campaignId: "cmp_c",
      counts: { replied: 3, sent: 90 },
      date: "2026-11-12",
    },
    {
      campaignId: "cmp_d",
      counts: { replied: 0, sent: 4 },
      date: "2026-11-12",
    },
  ],
};
```

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

// Every member's campaigns in one listing. The ref says whose each one is.
console.log("Every campaign:");

for await (const campaign of outbound.campaigns.listAll()) {
  console.log(`  ${campaign.ref}  ${campaign.name}`);
}

// One client. The label is on two adapters, so the scope covers both tools.
const acme = outbound.workspace("acme");
const names = await acme.campaigns.listAll().toArray();

console.log(`\nacme (${acme.provider}): ${names.length} campaigns`);

// A label on one adapter scopes to that adapter, and a bare id works there.
const globex = outbound.workspace("globex");
const campaign = await globex.campaigns.get("cmp_1");

console.log(`globex (${globex.provider}): cmp_1 is "${campaign.name}"`);

// Plugins come along: the scoped instance still has `clients`.
for (const row of await acme.clients.stats()) {
  console.log(
    `acme via clients.stats(): ${row.campaigns} campaigns, sent ${row.counts.sent}, connections sent ${row.counts.connectionsSent}`
  );
}

// Workspace stats: one row per member, side by side. Nothing is added up.
console.log("\nWorkspace stats:");

const workspaceStats = await outbound.stats();

for (const row of workspaceStats.rows) {
  console.log(
    `  ${row.provider}:${row.workspaceId}  sent ${row.sent}, connections sent ${row.connectionsSent}`
  );
}

// The same numbers per client, added up from each client's campaigns.
console.log("\nPer client:");

for (const row of await outbound.clients.stats()) {
  console.log(
    `  ${row.client}: ${row.campaigns} campaigns, sent ${row.counts.sent}, replied ${row.counts.replied}`
  );
}
```

That is the script that printed the output above. Taking it in order:

**One listing covers every member.** The first member is read to its end, then the second, and so on. A cursor records which member the walk is in, so it resumes in the right one.

**`outbound.workspace(id)` is one client.** It is the same instance scoped to the adapters that carry that label. For `acme` that is two adapters, so the scope is itself a combined instance and covers both tools. For `globex` it is one adapter, the scope is that adapter, and a bare id works again.

**The scope keeps the plugins.** `acme.clients.stats()` works because `clients` came along, and the same holds for any plugin's methods, such as `insights`. Code written for one client runs unchanged on a scope: [the weekly report](/guides/weekly-client-report) loops over `outbound.workspaces` this way.

**Workspace stats are one row per member, never added together.** `outbound.stats()` asks each member for its own workspace totals and returns the rows side by side, each with its `provider` and `workspaceId`. The LinkedIn tool's row has `sent: null`, not `0`: it does not count emails.

**`outbound.clients.stats()` adds up, per client.** It lists every campaign, reads each campaign's stats, and sums them by client. For `acme` that is the two email campaigns and the LinkedIn one. A count that only some campaigns report is the sum of those that do.

## A lookup needs a ref

Three members above have a campaign with the id `cmp_1`. On a combined instance a bare id cannot say which, so every lookup takes a ref: `outbound.campaigns.get("emailtool:globex:cmp_1")`, and the same for `outbound.campaign(ref)`, `outbound.lead(ref)`, `outbound.sender(ref)` and a `campaignId` filter. Every record a listing returns has its `ref`, so pass that along and never rebuild it.

## One workspace that holds several clients

When one key covers several clients, the workspace cannot tell them apart. Pass `rules` to `clients`. A rule can match on `provider`, `workspaceId`, `campaignTag`, `campaignName` (a regular expression) or `campaignIds` (refs). The first rule that matches decides. A record that matches no rule is `unassigned`: nothing is guessed.

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

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

// One key, several clients. The first rule that matches decides; a record
// that matches no rule is "unassigned", never guessed into a client.
const outbound = createOutbound({
  adapter: memory({ data: agency, name: "emailtool", workspaceId: "agency" }),
  plugins: [
    clients({
      rules: [
        { campaignTag: "initech", client: "Initech" },
        { campaignName: /^Umbrella:/u, client: "Umbrella" },
      ],
    }),
  ],
});

console.log("clients:", outbound.clients.names());

for (const row of await outbound.clients.stats()) {
  console.log(
    `${row.client}: ${row.campaigns} campaigns, sent ${row.counts.sent}, replied ${row.counts.replied}`
  );
}

// Every record is tagged, not only campaigns. A lead gets its campaign's
// client. The plugin adds `client` at run time; it is not on the Lead type.
for (const lead of await outbound.leads.listAll().toArray()) {
  const client = "client" in lead ? lead.client : undefined;

  console.log(`${lead.email} -> ${String(client)}`);
}
```

```text title="Output"
clients: [ 'Initech', 'Umbrella' ]
Initech: 2 campaigns, sent 160, replied 7
Umbrella: 1 campaigns, sent 90, replied 3
unassigned: 1 campaigns, sent 4, replied 0
ana@example.com -> Initech
ben@example.com -> Umbrella
caro@example.com -> unassigned
```

- **Every record gets a `client`**, not only campaigns. A lead, a message or a reply is assigned through its campaign. A sender belongs to no campaign, so a tag or name rule cannot place it and it is `unassigned`.
- **The tag is added at run time.** It is not on the `Lead` or `Campaign` type, so read it with an `in` check, as the sample does.
- **Check `unassigned` first.** It is where a campaign that someone forgot to tag ends up, and its numbers are missing from every client.
- Rules and members combine: a rule with `workspaceId: "acme"` assigns a whole member, and tag rules split another.

## Each member keeps its own rate budget

Each adapter's requests are counted against that adapter's own credential. A heavy read for one client does not slow another client's reads. The in-memory adapter makes no HTTP request, so this sample uses two real Instantly adapters with made-up keys and a `fetch` that answers from Instantly's documented payloads in the repository's `fixtures/instantly/` folder. Those are documented payloads, not live responses, and nothing leaves the process.

```ts title="budgets.ts"
// Two real Instantly adapters with made-up keys and no network: `fetch` is
// replaced with one that answers from Instantly's documented payloads in the
// repository's fixtures/instantly/ folder. Those are not live responses.
import { readFileSync } from "node:fs";

import { createOutbound } from "outbound-sdk";
import { combine } from "outbound-sdk/combine";
import { instantly } from "outbound-sdk/instantly";

const FIXTURES = new URL("../../../../fixtures/instantly/", import.meta.url);

const DOCUMENTED = new Map([
  ["/api/v2/campaigns", "listCampaigns.page2.json"],
  ["/api/v2/custom-tag-mappings", "listCustomTagMappings.json"],
  ["/api/v2/custom-tags", "listCustomTags.page2.json"],
]);

const keysUsed: string[] = [];

const documented: typeof fetch = (input, init) => {
  const request = new Request(input, init);
  const file = DOCUMENTED.get(new URL(request.url).pathname);

  keysUsed.push(request.headers.get("authorization") ?? "none");

  return Promise.resolve(
    file === undefined
      ? new Response("{}", { status: 404 })
      : new Response(readFileSync(new URL(file, FIXTURES), "utf-8"))
  );
};

const outbound = createOutbound({
  adapter: combine([
    instantly({ apiKey: "example-key-acme", workspaceId: "acme" }),
    instantly({ apiKey: "example-key-globex", workspaceId: "globex" }),
  ]),
  fetch: documented,
});

// One listing across both clients, then one more read for acme alone.
await outbound.campaigns.listAll().toArray();

await outbound.workspace("acme").campaigns.list();

for (const snapshot of await outbound.limits()) {
  const [perSecond, perMinute] = snapshot.windows;

  console.log(
    `${snapshot.workspaceId}: used ${perMinute?.used} of ${perMinute?.requests} a minute, ${perSecond?.requests} a second allowed`
  );
}

console.log("keys used:", [...new Set(keysUsed)]);
```

```text title="Output"
acme: used 6 of 3000 a minute, 50 a second allowed
globex: used 3 of 3000 a minute, 50 a second allowed
keys used: [ 'Bearer example-key-acme', 'Bearer example-key-globex' ]
```

`outbound.limits()` returns one snapshot per member. `acme` was read twice and `globex` once, at three requests a read (Instantly's campaign list also reads tag assignments and tag names). The allowances shown are half of what Instantly publishes, because an instance uses a `share` of 0.5 of each limit unless you set another.

Budgets are counted in this process. Several processes on the same keys need a shared store: see [sharing a rate budget](/guides/shared-rate-budget).

## Point it at your vendor

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

// Your own list of clients and the variable each key is kept in.
const CLIENTS = [
  {
    heyreach: "ACME_HEYREACH_KEY",
    id: "acme",
    instantly: "ACME_INSTANTLY_KEY",
  },
  { id: "globex", instantly: "GLOBEX_INSTANTLY_KEY" },
];

// Always pass `apiKey`. An adapter given none falls back to its vendor's own
// variable (INSTANTLY_API_KEY), which would be some other client's key.
const key = (name: string): string => {
  const value = process.env[name];

  if (value === undefined || value === "") {
    throw new Error(`${name} is not set`);
  }

  return value;
};

/** Built on first use, so a missing key fails the call and not the import. */
export const createAgency = () =>
  createOutbound({
    adapter: combine(
      CLIENTS.flatMap((client) => [
        instantly({ apiKey: key(client.instantly), workspaceId: client.id }),
        ...(client.heyreach === undefined
          ? []
          : [
              heyreach({
                apiKey: key(client.heyreach),
                workspaceId: client.id,
              }),
            ]),
      ])
    ),
    plugins: [clients()],
  });
```

Pass `apiKey` to every adapter. An adapter given none reads its vendor's own environment variable (`INSTANTLY_API_KEY`, `HEYREACH_API_KEY`), so a client whose variable is missing would be read with whatever key that holds. The sample refuses to build the instance instead.

## What each vendor can serve

A combined instance promises only what every member can do. This is the in-memory adapter limited to what the Instantly and HeyReach adapters declare, alone and combined. It shows which calls would be refused. It does not show what either API returns.

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

import { asVendor } from "../_shared/as-vendor.ts";
import { acmeEmail, acmeLinkedin } from "./seed.ts";

const email = createOutbound({
  adapter: asVendor("instantly", acmeEmail, "acme"),
});

const linkedin = createOutbound({
  adapter: asVendor("heyreach", acmeLinkedin, "acme"),
});

const both = createOutbound({
  adapter: combine([
    asVendor("instantly", acmeEmail, "acme"),
    asVendor("heyreach", acmeLinkedin, "acme"),
  ]),
});

// A combined instance promises only what every member can do.
for (const key of CAPABILITY_KEYS) {
  if (both.capabilities[key] === false) {
    const had = [email, linkedin].flatMap((member) =>
      member.capabilities[key] === false ? [] : [member.provider]
    );

    if (had.length > 0) {
      console.log(`${key}: off when combined, on for ${had.join(" and ")}`);
    }
  }
}
```

```text title="Output"
campaigns.settings: off when combined, on for instantly
leads.engagement: off when combined, on for instantly
leads.list: off when combined, on for instantly
messages.byThread: off when combined, on for instantly and heyreach
native: off when combined, on for instantly and heyreach
replies.category: off when combined, on for instantly
senders.warmup: off when combined, on for instantly
sequence.subsequences: off when combined, on for instantly
stats.byStep: off when combined, on for instantly
tags: off when combined, on for instantly
```

Combining an email tool with HeyReach turns off what HeyReach lacks, for every member: reply categories, per-step stats, the list of every lead. Two capabilities are off on every combined instance whatever the members: `messages.byThread`, because a thread id is the vendor's own and names no member, and `native`, because a native read belongs to one vendor.

To get a capability back, scope to one member. A label that is on one adapter does that. For a client on two tools, `outbound.workspace("acme")` still covers both and is still limited to what both can do; name the member as `provider:label`, for example `outbound.workspace("emailtool:acme")`.

## What it costs in vendor requests

- A listing across members costs each member's listing, on that member's budget.
- `outbound.stats()` at workspace scope is one stats read per member.
- `outbound.clients.stats()` is one campaign listing per member plus one stats read per campaign. Pass `from` and `to` to bound the dates, and `maxRequests` to cap the call.
- A tag or name rule needs the campaign of each record. It is fetched once per campaign and remembered for the life of the instance.
- `outbound.workspaces.list()` on a combined instance makes no request: the workspaces are its members.

## When it cannot answer

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

import { outbound } from "./instance.ts";
import { acmeEmail } from "./seed.ts";

const attempt = async (
  label: string,
  read: () => object | Promise<object>
): Promise<void> => {
  try {
    await read();
    console.log(`${label}: ok`);
  } catch (error) {
    if (!OutboundError.is(error)) {
      throw error;
    }

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

// 1. Three members have a campaign "cmp_1". A bare id cannot say which.
await attempt("bare id", () => outbound.campaigns.get("cmp_1"));

await attempt("ref", () => outbound.campaigns.get("emailtool:globex:cmp_1"));

// 2. A label no adapter carries.
await attempt("unknown client", () => outbound.workspace("initech"));

// 3. Two adapters that would produce the same refs.
await attempt("same label twice", () =>
  combine([memory({ data: acmeEmail }), memory({ data: acmeEmail })])
);

// 4. A read by the vendor's own thread id names no member, so it is off on
//    every combined instance. Scope to one member to use it.
await attempt("thread on combined", () =>
  outbound.thread("thread_1").messages()
);

await attempt("thread on one workspace", () =>
  outbound.workspace("globex").thread("thread_1").messages()
);

// acme is on two tools. `provider:label` names one of them.
await attempt("thread on one member", () =>
  outbound.workspace("emailtool:acme").thread("thread_1").messages()
);

// 5. A failing member fails the combined read. Nothing is returned for the
//    members that answered.
const base = memory({ data: acmeEmail, name: "emailtool", workspaceId: "b" });

const oneDown = createOutbound({
  adapter: combine([
    memory({ data: acmeEmail, name: "emailtool", workspaceId: "a" }),
    {
      ...base,
      stats: () =>
        Promise.reject(
          new OutboundError("Unauthorized", "emailtool responded 401", {
            provider: "emailtool",
            status: 401,
          })
        ),
    },
  ]),
});

await attempt("one key rejected", () => oneDown.stats());

await attempt("the other client alone", () => oneDown.workspace("a").stats());
```

```text title="Output"
bare id: Invalid - Campaign "cmp_1" must be a ref (provider:workspace:id) of an adapter on this combined instance
ref: ok
unknown client: NotFound - Workspace initech is not on this instance
same label twice: Invalid - Two adapters are both memory:default. Give each one its own workspaceId so their records stay apart
thread on combined: Unsupported - Reading one conversation is not available: the combined adapter does not support it (messages.byThread)
thread on one workspace: ok
thread on one member: ok
one key rejected: Unauthorized - emailtool responded 401
the other client alone: ok
```

| What happened | What you get | What to do |
| --- | --- | --- |
| A bare id on a combined instance | `Invalid`, asking for a ref | Pass the record's `ref`, or scope to the workspace first. |
| A label no adapter carries | `NotFound`, from `workspace(id)` itself | Check the label against `outbound.workspaces`. |
| Two adapters with the same provider and label | `Invalid`, when `combine` is called | Give each its own `workspaceId`. |
| A capability one member lacks | `Unsupported`, before any request | Scope to a member that has it. |
| One member's key is rejected | The whole combined read fails with that member's error. No partial answer is returned. | Read per workspace and catch per client, so one bad key costs one client. |

## Limits

- A combined instance cannot be nested inside another.
- Rows from different members are never merged. The same person in two tools is two leads; the `identity` plugin links them without merging.
- `clients.stats()` reads every campaign's stats one at a time. On many clients it is the most expensive call on this page.
- One failing member fails a combined read. There is no partial result.
- The numbers are only as good as each vendor's. The combined instance's maturity is that of its least verified member.
