---
title: Writing a plugin
description: The plugin contract (wrap, capabilities, extend, hooks and event), a small working plugin with typed methods, and the rules a plugin has to follow.
sidebar:
  label: Writing a plugin
related:
  - /docs/plugins
  - /docs/api/hooks
  - /docs/capabilities
  - /docs/webhooks
---

A plugin is a plain object with a `name` and any of five optional members. There is no base class and nothing to register. The type is `OutboundPlugin` from `outbound-sdk`.

## The contract

| Member | Signature | What it is for |
| --- | --- | --- |
| `name` | `string` | Required. Names the plugin in errors. It must be unique on an instance. |
| `wrap` | `(operation, next, info) => Promise<OperationOutput>` | Intercept every read. Call `next(operation)` to continue inward, toward the adapter. |
| `capabilities` | `(capabilities) => Capabilities` | Return what the instance should advertise. It can only take away. |
| `extend` | `(outbound) => Extension` | Return properties to add to the instance. |
| `hooks` | `OutboundHooks` | Observe calls, errors, HTTP requests, retries and throttle waits. |
| `event` | `(event) => OutboundEvent \| null` | Rewrite a webhook event, or return `null` to drop it. |

### wrap

Every read the SDK makes is one operation: an object with a `kind` (`campaigns.list`, `leads.get`, `stats` and so on) and either a `query` or an `id`. `wrap` receives it before the adapter does.

- `next(operation)` runs the layers inside this one and then the adapter. Pass the operation on unchanged, pass a changed one, call `next` more than once, or do not call it and answer yourself.
- The result is an `OperationOutput`: `items` (the records), `next` (the adapter's continuation to the following page, when there is one), `evidence` (`exact`, `sampled` or `derived`), and optionally `stale` and `fetchedAt`, which become `meta.stale` and `meta.fetchedAt`.
- `info` has `provider`, `workspaceId`, the `adapter`, and `state`: what the SDK call has used so far (`requests`, `throttledMs`) and its limits. A plugin may lower `state.maxRequests`, as `budget` does.

One SDK call can be several operations. A `listAll` walk is one operation per page, and all of them share one `info.state`.

What `wrap` returns is checked. Each record must match the schema for the read, or the call fails with `Provider`. Extra fields on a record are kept, which is how `clients` adds `client`.

A capability check and input validation happen before any `wrap` runs. A call refused there never reaches a plugin.

### capabilities

The function receives the capabilities as the plugins before it left them and returns the set to advertise. The SDK intersects the result with what came in, so turning something off or lowering a grade works, and anything widened is ignored. A call that needs a capability a plugin turned off throws `Unsupported`, and the message names the plugin.

Turning a capability off is how a plugin refuses a whole kind of read up front. The adapter itself still sees its own declared capabilities, so a plugin's own reads through `next` are not blocked by another plugin's narrowing.

### extend

`extend` runs once when the instance is built, and once more for each scoped instance `outbound.workspace(id)` creates, with that instance as its argument. Each property of the object it returns is defined on the instance. Declare the shape as the type argument, `OutboundPlugin<MyExtension>`, and `createOutbound` adds it to the instance's type.

Reads made inside `extend`'s methods through `outbound` go through the public API, so every layer applies to them, your own `wrap` included.

Keep state in the closure of the factory function, not in `extend`, so the instance and its scoped copies share it.

### hooks

The same hooks as the `hooks` option on `createOutbound`: `onAction`, `onError`, `onRequest`, `onRetry` and `onThrottle`. See [hooks](/docs/api/hooks). A hook is fire and forget. It cannot change a result, and a hook that throws is ignored. Hooks do not depend on the plugin's position.

### event

`event` is not called by the instance. It runs when you pass plugins to `parse()` from `outbound-sdk/webhooks`, usually as `plugins: outbound.plugins`. Hooks run innermost first, so `plugins[0]` sees an event last, the same way it sees a read's answer last. Returning `null` drops the event, and the remaining plugins are not called.

## A working plugin

This plugin counts what an instance read. It uses `wrap`, `extend` and `hooks`.

```ts title="usage.ts"
import type { OutboundPlugin } from "outbound-sdk";

/** What the plugin adds to the instance. */
export interface UsageExtension {
  readonly usage: {
    /** Operations that reached this layer, by kind. */
    readonly operations: () => Record<string, number>;
    /** Records those operations returned. */
    readonly records: () => number;
    /** HTTP requests the instance made, retries included. */
    readonly httpRequests: () => number;
  };
}

/** Counts what an instance read. It keeps the counts in this process. */
export const usage = (): OutboundPlugin<UsageExtension> => {
  const operations = new Map<string, number>();
  let records = 0;
  let httpRequests = 0;

  return {
    // `extend` returns the properties to add. The type parameter above is
    // what makes `outbound.usage` typed at the call site.
    extend: () => ({
      usage: {
        httpRequests: () => httpRequests,
        operations: () => Object.fromEntries(operations),
        records: () => records,
      },
    }),
    // Hooks observe. A hook that throws is ignored and cannot fail a read.
    hooks: {
      onRequest: () => {
        httpRequests += 1;
      },
    },
    name: "usage",
    // `wrap` sees every operation. Call `next` to continue inward, and
    // return what it returns, changed or not.
    wrap: async (operation, next) => {
      operations.set(operation.kind, (operations.get(operation.kind) ?? 0) + 1);

      const output = await next(operation);

      records += output.items.length;

      return output;
    },
  };
};
```

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

import { seed } from "../plugins-index/seed.ts";
import { usage } from "./usage.ts";

const outbound = createOutbound({
  adapter: memory({ data: seed, pageSize: 2, workspaceId: "agency" }),
  // Inside the cache, so a cache hit is not counted as a read.
  plugins: [cache({ ttl: 60 }), usage()],
});

await outbound.leads.listAll().toArray();

await outbound.leads.listAll().toArray();

await outbound.campaign("cmp_founders").stats();

console.log(outbound.usage.operations());

console.log("records:", outbound.usage.records());

console.log("http requests:", outbound.usage.httpRequests());

// A scoped instance has the same plugin and the same counts.
console.log(outbound.workspace("agency").usage.records());
```

```text title="Output"
{ 'leads.list': 2, stats: 1 }
records: 5
http requests: 0
5
```

Four leads at two per page are two `leads.list` operations. The second walk was served by the cache and never reached the plugin. The in-memory adapter makes no HTTP request, so the hook never fired.

## The rules

```ts title="rules.ts"
import { createOutbound, OutboundError } from "outbound-sdk";
import type { OutboundPlugin } from "outbound-sdk";
import { memory } from "outbound-sdk/memory";
import { parse } from "outbound-sdk/webhooks";

import { seed } from "../plugins-index/seed.ts";

const describe = (error: OutboundError): string =>
  `${error.code} - ${error.message}`;

// Narrow, never widen. This plugin turns bodies off and tries to turn on
// something the adapter said it cannot do.
const noBodies: OutboundPlugin = {
  capabilities: (capabilities) => ({
    ...capabilities,
    "messages.body": false,
    "replies.category": "vendor",
  }),
  // Webhook events pass through `event`. Returning null drops one.
  event: (event) => (event.type === "unknown" ? null : event),
  name: "no-bodies",
};

const outbound = createOutbound({
  adapter: memory({
    capabilities: { "replies.category": false },
    data: seed,
  }),
  plugins: [noBodies],
});

console.log("messages.body:", outbound.capabilities["messages.body"]);

console.log("replies.category:", outbound.capabilities["replies.category"]);

// The SDK refuses the call before the plugin's wrap or the adapter runs,
// and the error names the plugin.
try {
  await outbound.messages.list({ include: ["body"] });
} catch (error) {
  if (OutboundError.is(error)) {
    console.log("turned off:", describe(error));
  }
}

// A name the instance already has is an error when the instance is built.
const clash: OutboundPlugin<{ readonly campaigns: number }> = {
  extend: () => ({ campaigns: 1 }),
  name: "clash",
};

try {
  createOutbound({ adapter: memory(), plugins: [clash] });
} catch (error) {
  if (OutboundError.is(error)) {
    console.log("name clash:", describe(error));
  }
}

// So is the same plugin name twice.
try {
  createOutbound({ adapter: memory(), plugins: [noBodies, noBodies] });
} catch (error) {
  if (OutboundError.is(error)) {
    console.log("listed twice:", describe(error));
  }
}

// `event` runs when you hand the plugins to parse(). This payload matches
// no vendor, so it parses to one `unknown` event, which the plugin drops.
const request = new Request("https://example.com/hooks", {
  body: JSON.stringify({ hello: "world" }),
  method: "POST",
});

const kept = await parse(request.clone());
const filtered = await parse(request, { plugins: outbound.plugins });

console.log("events without the plugin:", kept.length);

console.log("events with it:", filtered.length);
```

```text title="Output"
messages.body: false
replies.category: false
turned off: Unsupported - Reading message bodies is not available: the no-bodies plugin turned it off (messages.body)
name clash: Invalid - The clash plugin adds "campaigns", which already exists on the instance
listed twice: Invalid - The no-bodies plugin is listed twice
events without the plugin: 1
events with it: 0
```

1. **Fail closed.** A plugin that protects data must refuse what it does not understand. Throw an `OutboundError` with code `Unsupported` rather than pass a record through untouched. `redact` does this for a record it does not recognise: see [its failures](/docs/plugins/redact#when-it-fails). A read that would defeat the plugin should be turned off in `capabilities`, so it is refused before any request.
2. **Narrow, never widen.** A plugin cannot add a capability the adapter did not declare. The sample tries to turn `replies.category` on and it stays `false`.
3. **Name clashes are errors.** A property from `extend` that already exists on the instance, built in or from an earlier plugin, throws `Invalid` when the instance is built. So does the same `name` twice. Pick one namespace for what you add, such as `outbound.usage`, instead of several top-level names.
4. **Stay read-only and store nothing on your own.** A plugin that keeps state should take a store from the caller, as `cache`, `sync` and `failover` do. It should not call an AI model.
5. **Throw `OutboundError`.** An error of another class thrown from `wrap` is passed to the adapter's error mapper and reaches the caller as an `OutboundError` whose code the mapper chose.
6. **Do not fail a read for a side effect.** A sink, a notifier or a logger should be fire and forget, as `audit` and `alerts` do it. An awaited store is different: `cache` and `failover` let a store error fail the read.
7. **Test on the in-memory adapter**, with `memory({ capabilities })` to exercise an adapter that declares less, and in each plugin order that matters to yours.
