Plugins
What a plugin is, how plugins stack around a read, the order to list them in and why, and a table of every plugin the package ships.
A plugin is an object you pass to createOutbound in plugins. It can do five things: wrap every read, turn capabilities off, add methods to the instance, observe calls and HTTP requests, and rewrite webhook events. Each plugin is its own subpath (outbound-sdk/<name>), so you import only the ones you use.
Plugins follow the same boundary as the rest of the SDK. None writes to a vendor, none calls an AI model, and none stores anything on our side. A plugin that keeps state (cache, sync, failover) takes a store you supply.
Two things that look like plugins are documented elsewhere:
combine([...])is an adapter, not a plugin. It goes inadapter, not inplugins. See combine adapters.insights()is a plugin, and has its own section.
How plugins compose
Every read the SDK makes is an operation, such as campaigns.list or stats. Each plugin with a wrap function is a layer around the adapter. plugins[0] is the outermost layer: it sees the operation first and the answer last.
import { createOutbound } from "outbound-sdk";
import type { OutboundPlugin } from "outbound-sdk";
import { memory } from "outbound-sdk/memory";
import { seed } from "./seed.ts";
// A plugin that does nothing but say when an operation passes through it.
const layer = (name: string): OutboundPlugin => ({
name,
wrap: async (operation, next) => {
console.log(`${name}: in (${operation.kind})`);
const output = await next(operation);
console.log(`${name}: out (${output.items.length} records)`);
return output;
},
});
const outbound = createOutbound({
adapter: memory({ data: seed, workspaceId: "agency" }),
plugins: [layer("first"), layer("second"), layer("third")],
});
await outbound.campaigns.list();
console.log(outbound.plugins.map((plugin) => plugin.name));
first: in (campaigns.list)
second: in (campaigns.list)
third: in (campaigns.list)
third: out (3 records)
second: out (3 records)
first: out (3 records)
[ 'first', 'second', 'third' ]
Four more rules:
- A plugin name may appear once. Listing the same plugin twice throws
Invalidwhen the instance is built. - Capabilities only narrow. A plugin can turn a capability off or lower its grade. Anything it tries to widen is ignored. A call that needs a capability a plugin turned off throws
Unsupportedbefore any request, and the message names the plugin. - Methods a plugin adds are typed.
createOutboundreturns the instance with every plugin’s additions, sooutbound.clientsexists in the type only whenclients()is in the list. A plugin that adds a name the instance already has throwsInvalid. outbound.workspace(id)keeps the plugins. The scoped instance has the same plugin objects and the methods they add.
outbound.plugins lists the plugins on an instance, outermost first.
The order that matters
List plugins in this order. Each line says why.
redactfirst. It masks what every layer inside it returns, so nothing inside can hand back an unmasked record.clientsandidentitynext. They add fields to records as they pass. Outside the cache they run on every read, so a cached record is tagged by the rules you have now.cacheafter them, so a repeat read stops there and skips everything inside it.auditandtracinginside the cache. They then record only reads that did real work.budgetlast, next to the request engine, so only requests that reach the vendor count.
import { setImmediate } from "node:timers/promises";
import { createOutbound } from "outbound-sdk";
import { audit } from "outbound-sdk/audit";
import { budget } from "outbound-sdk/budget";
import { cache } from "outbound-sdk/cache";
import { clients } from "outbound-sdk/clients";
import { memory } from "outbound-sdk/memory";
import { redact } from "outbound-sdk/redact";
import { seed } from "./seed.ts";
const audited: string[] = [];
const outbound = createOutbound({
adapter: memory({ data: seed, workspaceId: "agency" }),
plugins: [
// Outermost: masks whatever the layers inside it return.
redact(),
// Tags records.
clients({ rules: [{ campaignTag: "acme", client: "Acme" }] }),
// A repeat read stops here and skips everything below.
cache({ ttl: 60 }),
// So this records only the reads that did real work.
audit({
sink: (entry) => {
audited.push(entry.type === "call" ? entry.operation : entry.type);
},
}),
// Innermost: next to the request engine.
budget({ maxRequests: 50 }),
],
});
const first = await outbound.lead("lead_jane").get();
const second = await outbound.lead("lead_jane").get();
for (const lead of [first, second]) {
console.log(
`email ${lead.email}, client ${"client" in lead ? lead.client : "?"}`
);
}
// The audit sink is not awaited by the read. Give its entries a turn to arrive.
await setImmediate();
console.log("audited:", audited);
email [redacted], client Acme
email [redacted], client Acme
audited: [ 'leads.get', 'campaigns.get' ]
The first read was audited as two operations: the lead, and the lead’s campaign, which clients fetched to check its tag. The second read was served by the cache and never reached the audit layer.
Other positions that matter:
| Plugin | Where | Why |
|---|---|---|
failover |
Outside the layers whose failures it should cover | It stores each successful answer from inside it and catches the vendor’s failures from inside it: Provider errors and a vendor’s 429. A stop by budget or maxRequests is your own cap and is not covered. |
fixtures |
Last when recording | What is saved is then the adapter’s answer, not another plugin’s view of it. |
alerts |
Inside cache |
Rules then run once per real read, not on every cache hit. Outside the cache they run on every read. |
Plugins that only add methods (sync, reconcile, benchmarks, exporter) have no layer, so their position does not matter. Their methods call the instance’s public API, so every layer applies to what they read.
Every plugin
The samples on these pages read one seed on the in-memory adapter: an agency workspace with three campaigns, four leads, three messages, two senders and two days of results. The in-memory adapter makes no HTTP request, so the pages for budget, audit and tracing also run Instantly’s adapter on a fetch that serves the vendor’s documented payloads from the repository.
// Made-up data for one agency workspace, "agency", that runs campaigns for
// two clients. Every sample in the Plugins section reads this.
import type { MemoryData } from "outbound-sdk/memory";
export const seed: MemoryData = {
campaigns: [
{ id: "cmp_founders", name: "Acme: SaaS founders", tags: ["acme"] },
{ id: "cmp_agencies", name: "Globex: agency owners", tags: ["globex"] },
{ id: "cmp_ecom", name: "Ecommerce ops" },
],
leads: [
{
campaignId: "cmp_founders",
company: "Northwind",
createdAt: "2026-11-09T09:00:00.000Z",
custom: { phone: "555-0100" },
email: "jane.doe@example.com",
firstName: "Jane",
id: "lead_jane",
lastName: "Doe",
linkedinUrl: "https://www.linkedin.com/in/jane-doe-example",
},
{
campaignId: "cmp_founders",
company: "Contoso",
createdAt: "2026-11-10T09:00:00.000Z",
email: "sam.lee@example.com",
firstName: "Sam",
id: "lead_sam",
lastName: "Lee",
},
{
campaignId: "cmp_agencies",
company: "Fabrikam",
createdAt: "2026-11-12T09:00:00.000Z",
email: "priya.patel@example.com",
firstName: "Priya",
id: "lead_priya",
lastName: "Patel",
},
{
campaignId: "cmp_ecom",
company: "Tailspin",
createdAt: "2026-11-13T09:00:00.000Z",
email: "omar.haddad@example.com",
firstName: "Omar",
id: "lead_omar",
lastName: "Haddad",
},
],
messages: [
{
body: "Hi Jane, are you still hiring SDRs?",
campaignId: "cmp_founders",
direction: "out",
id: "msg_1",
leadId: "lead_jane",
senderId: "snd_ana",
sentAt: "2026-11-10T10:00:00.000Z",
subject: "Quick question",
},
{
body: "Yes. Send me some times for next week.",
campaignId: "cmp_founders",
category: "positive",
direction: "in",
id: "msg_2",
leadId: "lead_jane",
sentAt: "2026-11-11T08:30:00.000Z",
subject: "Re: Quick question",
},
{
body: "Hi Priya, how do you report to clients today?",
campaignId: "cmp_agencies",
direction: "out",
id: "msg_3",
leadId: "lead_priya",
senderId: "snd_ana",
sentAt: "2026-11-12T10:00:00.000Z",
subject: "Client reporting",
},
],
senders: [
{ address: "ana@agency.example.com", id: "snd_ana", name: "Ana Ruiz" },
{
address: "ben@agency.example.com",
id: "snd_ben",
name: "Ben Cole",
status: "disconnected",
},
],
stats: [
// Acme: 200 sent, 12 replies, 2 bounces over two days.
{
campaignId: "cmp_founders",
counts: { bounced: 1, replied: 6, sent: 100 },
date: "2026-11-12",
},
{
campaignId: "cmp_founders",
counts: { bounced: 1, replied: 6, sent: 100 },
date: "2026-11-13",
},
// Globex: 400 sent, 8 replies, 4 bounces.
{
campaignId: "cmp_agencies",
counts: { bounced: 2, replied: 4, sent: 200 },
date: "2026-11-12",
},
{
campaignId: "cmp_agencies",
counts: { bounced: 2, replied: 4, sent: 200 },
date: "2026-11-13",
},
// No client: nothing on the 11th, then 200 sent, 2 replies, 18 bounces.
{ campaignId: "cmp_ecom", counts: { sent: 0 }, date: "2026-11-11" },
{
campaignId: "cmp_ecom",
counts: { bounced: 9, replied: 1, sent: 100 },
date: "2026-11-12",
},
{
campaignId: "cmp_ecom",
counts: { bounced: 9, replied: 1, sent: 100 },
date: "2026-11-13",
},
],
};
Several tools, several clients
| Plugin | What it does | Adds |
|---|---|---|
clients |
Tags every record with the client it belongs to, by workspace or by rules. | outbound.clients |
identity |
Tags every lead with exact person keys and links one person across tools. | outbound.person() |
redact |
Masks personal fields and drops the vendor’s raw payload. | outbound.redacted, which says what is masked. Turns native off, and messages.body when bodies are masked. |
Cost and visibility
| Plugin | What it does | Adds |
|---|---|---|
cache |
Serves a repeat read from a store for a set time. | Nothing |
budget |
Caps vendor requests per call or per run. | outbound.requestBudget |
audit |
Hands each operation’s duration and request count to a sink. | Nothing |
tracing |
Emits an OpenTelemetry span per operation and per HTTP request. | Nothing |
Keeping a copy in step
| Plugin | What it does | Adds |
|---|---|---|
sync |
Resumable, incremental walks of a collection, with checkpoints in your store. | outbound.sync |
reconcile |
Compares the webhooks and refs you hold with what the vendor lists. | outbound.reconcile |
alerts |
Checks fixed rules on each read and calls your notifier. | outbound.alerts |
Context, files and bad days
| Plugin | What it does | Adds |
|---|---|---|
benchmarks |
A campaign’s rates beside its own workspace’s. | outbound.benchmarks |
fixtures |
Records answers to disk, scrubbed, and replays them. | Nothing |
exporter |
Streams a listing to a CSV or JSON Lines file, or to your writer. | outbound.export() |
failover |
Serves the last good answer, marked stale, when the vendor is down. | Nothing |
To write your own, see writing a plugin.