Writing a plugin
The plugin contract (wrap, capabilities, extend, hooks and event), a small working plugin with typed methods, and the rules a plugin has to follow.
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, callnextmore 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,sampledorderived), and optionallystaleandfetchedAt, which becomemeta.staleandmeta.fetchedAt. infohasprovider,workspaceId, theadapter, andstate: what the SDK call has used so far (requests,throttledMs) and its limits. A plugin may lowerstate.maxRequests, asbudgetdoes.
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. 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.
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;
},
};
};
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());
{ '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
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);
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
- Fail closed. A plugin that protects data must refuse what it does not understand. Throw an
OutboundErrorwith codeUnsupportedrather than pass a record through untouched.redactdoes this for a record it does not recognise: see its failures. A read that would defeat the plugin should be turned off incapabilities, so it is refused before any request. - Narrow, never widen. A plugin cannot add a capability the adapter did not declare. The sample tries to turn
replies.categoryon and it staysfalse. - Name clashes are errors. A property from
extendthat already exists on the instance, built in or from an earlier plugin, throwsInvalidwhen the instance is built. So does the samenametwice. Pick one namespace for what you add, such asoutbound.usage, instead of several top-level names. - Stay read-only and store nothing on your own. A plugin that keeps state should take a store from the caller, as
cache,syncandfailoverdo. It should not call an AI model. - Throw
OutboundError. An error of another class thrown fromwrapis passed to the adapter’s error mapper and reaches the caller as anOutboundErrorwhose code the mapper chose. - Do not fail a read for a side effect. A sink, a notifier or a logger should be fire and forget, as
auditandalertsdo it. An awaited store is different:cacheandfailoverlet a store error fail the read. - 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.