---
title: Plugins
description: 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.
sidebar:
  label: Overview
related:
  - /docs/plugins/writing-a-plugin
  - /docs/adapters/combine
  - /docs/insights
  - /docs/api/hooks
---

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 in `adapter`, not in `plugins`. See [combine adapters](/docs/adapters/combine).
- `insights()` is a plugin, and has [its own section](/docs/insights).

## 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.

```ts title="compose.ts"
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));
```

```text title="Output"
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 `Invalid` when 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 `Unsupported` before any request, and the message names the plugin.
- **Methods a plugin adds are typed.** `createOutbound` returns the instance with every plugin's additions, so `outbound.clients` exists in the type only when `clients()` is in the list. A plugin that adds a name the instance already has throws `Invalid`.
- **`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.

1. `redact` first. It masks what every layer inside it returns, so nothing inside can hand back an unmasked record.
2. `clients` and `identity` next. 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.
3. `cache` after them, so a repeat read stops there and skips everything inside it.
4. `audit` and `tracing` inside the cache. They then record only reads that did real work.
5. `budget` last, next to the request engine, so only requests that reach the vendor count.

```ts title="order.ts"
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);
```

```text title="Output"
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.

> **redact masks the fields it knows**
>
> A field another plugin adds passes through as it is, with one exception:
> `identity`'s `personKey` and `personKeys` are masked, because they hold the
> email address and the LinkedIn profile slug. `outbound.person()` is refused
> while those identifiers are masked. See
> [redact](/docs/plugins/redact#with-other-plugins).

## Every plugin

The samples on these pages read one seed on the [in-memory adapter](/docs/adapters/memory): 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.

```ts title="seed.ts"
// 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`](/docs/plugins/clients) | Tags every record with the client it belongs to, by workspace or by rules. | `outbound.clients` |
| [`identity`](/docs/plugins/identity) | Tags every lead with exact person keys and links one person across tools. | `outbound.person()` |
| [`redact`](/docs/plugins/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`](/docs/plugins/cache) | Serves a repeat read from a store for a set time. | Nothing |
| [`budget`](/docs/plugins/budget) | Caps vendor requests per call or per run. | `outbound.requestBudget` |
| [`audit`](/docs/plugins/audit) | Hands each operation's duration and request count to a sink. | Nothing |
| [`tracing`](/docs/plugins/tracing) | Emits an OpenTelemetry span per operation and per HTTP request. | Nothing |

### Keeping a copy in step

| Plugin | What it does | Adds |
| --- | --- | --- |
| [`sync`](/docs/plugins/sync) | Resumable, incremental walks of a collection, with checkpoints in your store. | `outbound.sync` |
| [`reconcile`](/docs/plugins/reconcile) | Compares the webhooks and refs you hold with what the vendor lists. | `outbound.reconcile` |
| [`alerts`](/docs/plugins/alerts) | Checks fixed rules on each read and calls your notifier. | `outbound.alerts` |

### Context, files and bad days

| Plugin | What it does | Adds |
| --- | --- | --- |
| [`benchmarks`](/docs/plugins/benchmarks) | A campaign's rates beside its own workspace's. | `outbound.benchmarks` |
| [`fixtures`](/docs/plugins/fixtures) | Records answers to disk, scrubbed, and replays them. | Nothing |
| [`exporter`](/docs/plugins/exporter) | Streams a listing to a CSV or JSON Lines file, or to your writer. | `outbound.export()` |
| [`failover`](/docs/plugins/failover) | Serves the last good answer, marked stale, when the vendor is down. | Nothing |

To write your own, see [writing a plugin](/docs/plugins/writing-a-plugin).
