clients
Tag every record with the client it belongs to, by workspace or by rules on provider, workspace, campaign tag, name or ref, and add up results per client.
clients() from outbound-sdk/clients adds a client field to every record an instance returns. A key and a client do not always match one to one: one key can hold several clients as campaigns, and one client can span several keys. Without rules, one workspace is one client. With rules, the first rule that matches a record decides, and a record that matches no rule is unassigned. Nothing is assigned by guessing.
Sample
One workspace holds campaigns for two clients, told apart by a tag and by a name prefix. It runs on the shared seed.
import { createOutbound } from "outbound-sdk";
import { clients } from "outbound-sdk/clients";
import { memory } from "outbound-sdk/memory";
import { seed } from "../plugins-index/seed.ts";
const outbound = createOutbound({
adapter: memory({ data: seed, workspaceId: "agency" }),
plugins: [
clients({
// The first rule that matches decides.
rules: [
{ campaignTag: "acme", client: "Acme" },
{ campaignName: /^Globex:/u, client: "Globex" },
],
}),
],
});
console.log("clients:", outbound.clients.names());
for await (const campaign of outbound.campaigns.listAll()) {
// The plugin adds `client` at run time. The Campaign type does not have it.
console.log(
`${campaign.name} -> ${"client" in campaign ? campaign.client : "?"}`
);
}
// A lead has no tags of its own. It is assigned through its campaign.
for await (const lead of outbound.leads.listAll()) {
console.log(`${lead.email} -> ${"client" in lead ? lead.client : "?"}`);
}
const stats = await outbound.clients.stats({
from: "2026-11-12",
to: "2026-11-13",
});
for (const row of stats) {
console.log(
`${row.client}: ${row.campaigns} campaign(s), sent ${row.counts.sent}, replied ${row.counts.replied}, bounced ${row.counts.bounced}`
);
}
clients: [ 'Acme', 'Globex' ]
Acme: SaaS founders -> Acme
Globex: agency owners -> Globex
Ecommerce ops -> unassigned
jane.doe@example.com -> Acme
sam.lee@example.com -> Acme
priya.patel@example.com -> Globex
omar.haddad@example.com -> unassigned
Acme: 1 campaign(s), sent 200, replied 12, bounced 2
Globex: 1 campaign(s), sent 400, replied 8, bounced 4
unassigned: 1 campaign(s), sent 200, replied 2, bounced 18
The totals are the seed’s: 200 sent for Acme’s one campaign over the two days, 400 for Globex, and 200 for the campaign no rule matched.
Options
| Option | Type | Default | Meaning |
|---|---|---|---|
rules |
ClientRule[] |
[] |
Checked in order. The first rule whose every condition holds assigns its client. With no rules, a record’s client is its workspaceId. |
A ClientRule has a client and any of these conditions. All the conditions on a rule must hold.
| Field | Type | Matches |
|---|---|---|
client |
string |
Required. The label to assign. |
provider |
string |
Records whose provider is exactly this. |
workspaceId |
string |
Records whose workspaceId is exactly this. |
campaignTag |
string |
Records of a campaign that carries this tag. |
campaignIds |
string[] |
Records of a campaign whose ref is in the list. Use refs, not bare vendor ids. |
campaignName |
RegExp |
Records of a campaign whose name the pattern matches. |
A record’s campaign is the campaign itself, the campaignRef a lead, message or reply carries, or the scopeRef of a stats row that is not scoped to a workspace or a sender. A record with no campaign (a sender, a workspace, a workspace stats row) can only match provider and workspaceId conditions.
What it adds
| Member | Returns |
|---|---|
| Every record | client: string, added at run time. The record types do not declare it, so read it with an in check, as the sample does. |
outbound.clients.names() |
The clients named by rules, in rule order, without repeats. Empty when there are no rules. unassigned is not listed. |
outbound.clients.stats(options?) |
One row per client: client, campaigns and counts, sorted by client name. Takes from and to (YYYY-MM-DD) and the per-call options (signal, timeout, retries, deadline, maxRequests). |
clients.stats() lists every campaign, reads each campaign’s stats one at a time, and adds the rows up per client. It costs one stats request per campaign on top of the listing. campaigns is the number of stats rows added together. The exported constant UNASSIGNED is the string "unassigned".
Without rules
import { createOutbound } from "outbound-sdk";
import { clients } from "outbound-sdk/clients";
import { combine } from "outbound-sdk/combine";
import { memory } from "outbound-sdk/memory";
import { seed } from "../plugins-index/seed.ts";
// No rules: one workspace is one client.
const outbound = createOutbound({
adapter: combine([
memory({ data: seed, workspaceId: "acme" }),
memory({
data: { campaigns: [{ id: "cmp_1", name: "Globex launch" }] },
workspaceId: "globex",
}),
]),
plugins: [clients()],
});
console.log("names:", outbound.clients.names());
for await (const campaign of outbound.campaigns.listAll()) {
console.log(
`${campaign.ref} -> ${"client" in campaign ? campaign.client : "?"}`
);
}
// The scoped instance keeps the plugin and what it adds.
const acme = outbound.workspace("acme");
const stats = await acme.clients.stats({
from: "2026-11-12",
to: "2026-11-13",
});
console.log(stats.map((row) => `${row.client}: sent ${row.counts.sent}`));
names: []
memory:acme:cmp_founders -> acme
memory:acme:cmp_agencies -> acme
memory:acme:cmp_ecom -> acme
memory:globex:cmp_1 -> globex
[ 'acme: sent 800' ]
outbound.workspace("acme") reads only that member, so its stats hold one client. The 800 sends are the seed’s three campaigns over the two days.
What it changes
It changes no capability and nothing in meta. It adds one field to each record.
A rule on campaignTag or campaignName needs the campaign itself. When a record’s campaign has not been seen yet, the plugin reads it with one campaigns.get and remembers it for the life of the plugin object. Campaigns that pass through any read are remembered too, so listing campaigns first costs nothing extra later. A rule on campaignIds alone needs no lookup. Remembered campaigns are not refreshed: a tag changed at the vendor is seen only by a new plugin object.
With other plugins
- List it after
redactand beforecache. Inside the cache, the cached records would keep the client they were given when they were stored. - Its campaign lookup goes inward, through
cache,auditandbudgetwhen they are listed after it. The ordering sample shows the extracampaigns.getin the audit log. redactleavesclientalone. It is your label, not the vendor’s data.- On a combined instance with no rules, each member’s records carry that member’s
workspaceId.
When it fails
| What happened | What you get | What to do |
|---|---|---|
| A record matches no rule | client is unassigned |
Add a rule, or treat unassigned as a list to review. |
| The campaign lookup for a tag or name rule fails | The read that triggered it fails with that error | Handle it as you would the read’s own failure. |
| The adapter cannot read campaign stats | clients.stats() throws Unsupported |
Use an adapter that declares stats.campaign. |