AI tools
Twelve read-only tools an agent can call to ask about campaigns, senders, replies and leads, what each one needs, and what a tool returns when it cannot answer.
outbound-sdk/tools gives an agent tools shaped like the questions an operator asks: how are my campaigns doing, which step gets replies, which senders need attention. Your model does the reasoning. A tool only reads from the vendor and arranges what it read. The SDK calls no model.
Every tool:
- Takes one JSON object and describes it as JSON Schema, so any model API can call it.
- Returns compact JSON with
data,metaandnext.meta.evidencesays how far to trust the numbers (exact,sampledorderived),meta.requestshow many vendor requests the call made,meta.todayis today’s date in UTC (a model does not know it, and otherwise guesses the date range it asks for), andnextsuggests what to ask after this. - Never throws. Bad input, a missing record and a read the adapter cannot serve all come back as
{ ok: false, error, next }, which a model can read and recover from. - Only reads. No tool sends, pauses or edits anything.
This page uses the in-memory adapter. How far each real adapter has been checked is on what is verified.
Create the tools
createTools(outbound) returns the tools the instance can serve. TOOL_NAMES lists all twelve names.
import { createOutbound } from "outbound-sdk";
import { clients } from "outbound-sdk/clients";
import { identity } from "outbound-sdk/identity";
import { demoData, memory } from "outbound-sdk/memory";
import { createTools, TOOL_NAMES } from "outbound-sdk/tools";
const adapter = () => memory({ data: demoData(), workspaceId: "acme" });
const names = (tools: readonly { name: string }[]) =>
tools.map((tool) => tool.name).join(", ");
// No plugins: the two tools that need one are left out.
const plain = createOutbound({ adapter: adapter() });
console.log(`${createTools(plain).length} of ${TOOL_NAMES.length}:`);
console.log(names(createTools(plain)));
// With the clients and identity plugins, their tools are offered too.
const full = createOutbound({
adapter: adapter(),
plugins: [clients(), identity()],
});
const offered = createTools(full);
console.log(`\n${offered.length} of ${TOOL_NAMES.length}:`);
console.log(names(offered));
console.log(
"left out:",
TOOL_NAMES.filter((name) => !offered.some((tool) => tool.name === name))
);
// `only` narrows the set to the tools you name.
console.log(
"\nonly:",
names(createTools(full, { only: ["campaign_overview", "reply_samples"] }))
);
// Each tool carries its input as JSON Schema.
const [overview] = offered;
console.log(
`\n${overview?.name}:`,
JSON.stringify(overview?.parameters, null, 2)
);
9 of 12:
campaign_overview, campaign_trend, winning_steps, sender_health, reply_samples, lead_runway, campaign_setup, deliverability_check, audience_lists
11 of 12:
campaign_overview, campaign_trend, winning_steps, sender_health, reply_samples, lead_runway, compare_clients, cross_channel_lead, campaign_setup, deliverability_check, audience_lists
left out: [ 'vendor_read' ]
only: campaign_overview, reply_samples
campaign_overview: {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"from": {
"description": "First day, YYYY-MM-DD. Leave out for the whole history. Do not guess the date: every result gives today as meta.today",
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
},
"limit": {
"default": 10,
"description": "How many campaigns to include",
"type": "integer",
"minimum": 1,
"maximum": 50
},
"status": {
"default": "active",
"description": "Which campaigns to include",
"type": "string",
"enum": [
"active",
"archived",
"completed",
"draft",
"paused",
"unknown"
]
},
"to": {
"description": "Last day, YYYY-MM-DD. Leave out for up to today",
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
}
}
}
Each tool has name, description, parameters (JSON Schema), fields (the same input as Zod schemas, one per field), needs and call. To hand the set to a model, use one of the wrappers: OpenAI, AI SDK or Claude Agent SDK. To serve it to an agent client with no code, run outbound mcp.
Call a tool
import { createOutbound } from "outbound-sdk";
import { demoData, memory } from "outbound-sdk/memory";
import { createTools } from "outbound-sdk/tools";
const outbound = createOutbound({
adapter: memory({ data: demoData(), workspaceId: "acme" }),
});
const tools = new Map(createTools(outbound).map((tool) => [tool.name, tool]));
// `call` takes the model's arguments as JSON and returns a result object.
const overview = await tools.get("campaign_overview")?.call({});
console.log(JSON.stringify(overview, null, 2));
const trend = await tools.get("campaign_trend")?.call({
campaign: "cmp_founders",
from: "2026-10-01",
to: "2026-10-31",
});
console.log(JSON.stringify(trend, null, 2));
{
"data": {
"campaigns": [
{
"campaign": "memory:acme:cmp_founders",
"channel": "email",
"name": "Q4 SaaS founders",
"metrics": {
"bounce_rate": 0.0111,
"bounced": 1,
"connections_accepted": null,
"connections_sent": null,
"contacted": 90,
"opened": 52,
"positive": 3,
"positive_reply_rate": 0.0333,
"replied": 7,
"reply_rate": 0.0778,
"reply_rate_basis": "contacted",
"sent": 90
},
"status": "active"
}
]
},
"meta": {
"evidence": "exact",
"provider": "memory",
"requests": 1,
"today": "2026-11-14"
},
"next": "Use campaign_trend for one campaign over time, or winning_steps to see which step drives replies.",
"ok": true
}
{
"data": {
"campaign": "cmp_founders",
"metric": "replied",
"points": [
{
"date": "2026-10-05",
"timezone": "UTC",
"value": 4
},
{
"date": "2026-10-06",
"timezone": "UTC",
"value": 3
}
]
},
"meta": {
"evidence": "exact",
"provider": "memory",
"requests": 0,
"today": "2026-11-14"
},
"next": "Use winning_steps to see which step or variant explains a change.",
"ok": true
}
Rates are computed from the counts beside them. A count the vendor does not report is null, never 0, and a rate built on it is null too. reply_rate_basis names the count the reply rate was divided by.
The twelve tools
| Tool | Answers | Needs |
|---|---|---|
campaign_overview |
How every campaign with a given status is doing | campaigns.status, stats.campaign |
campaign_trend |
How one count moved day by day for a campaign | stats.campaign, stats.byDay |
winning_steps |
Which step or variant drives replies | stats.byStep |
sender_health |
Status, daily limit and warmup per sender, unhealthy first | senders.list |
reply_samples |
Recent replies, optionally by category | replies.list |
lead_runway |
Days until a campaign runs out of leads. An estimate. | leads.byCampaign, stats.campaign, stats.byDay |
compare_clients |
The same counts for every client, side by side | the clients plugin, stats.campaign |
cross_channel_lead |
Every record and message for one person | the identity plugin |
campaign_setup |
How one campaign is set up to send: windows, caps, tracking, stop rules | campaigns.settings |
deliverability_check |
Each sender’s DNS records, sign-in state and warmup spam rate, problems first | senders.health |
audience_lists |
The saved lead lists and their sizes | lists.list |
vendor_read |
Anything else the vendor reports: lists the vendor’s own reads, then runs one | a native catalog |
The last column is the tool’s own needs: the capabilities and plugins it cannot work without. createTools reads it to decide what to offer.
import { createOutbound } from "outbound-sdk";
import { clients } from "outbound-sdk/clients";
import { identity } from "outbound-sdk/identity";
import { demoData, memory } from "outbound-sdk/memory";
import { createTools } from "outbound-sdk/tools";
const full = createOutbound({
adapter: memory({ data: demoData() }),
plugins: [clients(), identity()],
});
// Each tool says what it cannot work without.
for (const tool of createTools(full)) {
console.log(`${tool.name.padEnd(21)} ${tool.needs.join(", ")}`);
}
// Turn one capability off and the tools that need it are gone from the set.
const noDailyStats = createOutbound({
adapter: memory({ capabilities: { "stats.byDay": false }, data: demoData() }),
plugins: [clients(), identity()],
});
const offered = new Set(createTools(noDailyStats).map((tool) => tool.name));
console.log(
"\nwithout stats.byDay, left out:",
createTools(full)
.map((tool) => tool.name)
.filter((name) => !offered.has(name))
.join(", ")
);
campaign_overview campaigns.status, stats.campaign
campaign_trend stats.campaign, stats.byDay
winning_steps stats.byStep
sender_health senders.list
reply_samples replies.list
lead_runway leads.byCampaign, stats.campaign, stats.byDay
compare_clients clients, stats.campaign
cross_channel_lead identity
campaign_setup campaigns.settings
deliverability_check senders.health
audience_lists lists.list
without stats.byDay, left out: campaign_trend, lead_runway
needs lists only what every call makes use of. An input can ask for more: reply_samples with category also needs replies.category, deliverability_check needs senders.list, or with campaign senders.byCampaign, and cross_channel_lead reads a timeline only when the adapter declares leads.timeline.
What is left out, and what is refused
Two different things happen when a tool cannot work.
Left out of the set. createTools does not offer:
- A tool that needs a capability the adapter lacks, or that a plugin turned off.
compare_clientswithout theclientsplugin on the instance.cross_channel_leadwithout theidentityplugin, or while theredactplugin masksemailorlinkedin, becauseoutbound.person()is refused then.vendor_readwhen the adapter has no native reads, or when theredactplugin is on the instance, because a native read returns the vendor’s payload untouched.
A tool’s next hint never names a tool that is not in the set. When the hint a tool wrote would, it is replaced by a general one.
Offered, then refused. A tool in the set can still be asked for a read only its input needs. Calling it then returns { ok: false } with the code Unsupported and a next hint that tells the model to try another tool.
The run below constructs each real adapter to count its tools (constructing one makes no request), then calls every tool on the in-memory adapter limited to what each vendor’s adapter declares. It shows which tools an adapter is offered and whether a plain call is answered. It does not show what a vendor’s API returns.
import { createOutbound } from "outbound-sdk";
import type { Json } from "outbound-sdk";
import { clients } from "outbound-sdk/clients";
import { heyreach } from "outbound-sdk/heyreach";
import { identity } from "outbound-sdk/identity";
import { instantly } from "outbound-sdk/instantly";
import { demoData } from "outbound-sdk/memory";
import { smartlead } from "outbound-sdk/smartlead";
import { createTools, TOOL_NAMES } from "outbound-sdk/tools";
import { asVendor, VENDORS } from "../_shared/as-vendor.ts";
// Constructing a real adapter makes no request. Its tool set includes
// vendor_read, because each vendor has reads of its own.
const REAL = {
heyreach: () => createTools(createOutbound({ adapter: heyreach() })),
instantly: () => createTools(createOutbound({ adapter: instantly() })),
smartlead: () => createTools(createOutbound({ adapter: smartlead() })),
};
for (const vendor of VENDORS) {
const tools = REAL[vendor]();
console.log(
`${vendor}: ${tools.length} tools, the last is ${tools.at(-1)?.name}`
);
}
// Valid input for every tool, to see what each vendor's adapter is offered
// and whether a call with plain input is answered.
const INPUT = new Map<string, Json>([
["campaign_setup", { campaign: "cmp_founders" }],
[
"campaign_trend",
{ campaign: "cmp_founders", from: "2026-10-01", to: "2026-10-31" },
],
["cross_channel_lead", { person: "person1@example.com" }],
["lead_runway", { campaign: "cmp_founders" }],
["winning_steps", { campaign: "cmp_founders" }],
]);
const line = (name: string, cells: readonly string[]) =>
[name.padEnd(21), ...cells.map((cell) => cell.padEnd(12))]
.join(" ")
.trimEnd();
console.log(`\n${line("tool", VENDORS)}`);
const sets = VENDORS.map((vendor) =>
createTools(
createOutbound({
adapter: asVendor(vendor, demoData()),
plugins: [clients(), identity()],
})
)
);
// vendor_read is left out here: the in-memory stand-in has no vendor reads.
for (const name of TOOL_NAMES.filter((tool) => tool !== "vendor_read")) {
const cells: string[] = [];
for (const tools of sets) {
const tool = tools.find((candidate) => candidate.name === name);
// oxlint-disable-next-line no-await-in-loop -- one tool at a time keeps the output in order.
const result = await tool?.call(INPUT.get(name) ?? {});
if (result === undefined) {
cells.push("not offered");
} else {
cells.push(result.ok ? "ok" : result.error.code);
}
}
console.log(line(name, cells));
}
instantly: 10 tools, the last is vendor_read
smartlead: 10 tools, the last is vendor_read
heyreach: 8 tools, the last is vendor_read
tool instantly smartlead heyreach
campaign_overview ok ok ok
campaign_trend ok ok ok
winning_steps ok ok not offered
sender_health ok ok ok
reply_samples ok ok ok
lead_runway ok ok ok
compare_clients ok ok ok
cross_channel_lead ok ok ok
campaign_setup ok ok not offered
deliverability_check ok ok ok
audience_lists ok ok ok
HeyReach’s adapter declares no per-step stats and no campaign settings, so winning_steps and campaign_setup are not offered there. To offer a model fewer tools still, pass only with the names you want.
When a tool fails
import { createOutbound } from "outbound-sdk";
import { demoData, memory } from "outbound-sdk/memory";
import { createTools } from "outbound-sdk/tools";
import type { OutboundTool } from "outbound-sdk/tools";
const find = (tools: readonly OutboundTool[], name: string) =>
tools.find((tool) => tool.name === name);
const tools = createTools(
createOutbound({ adapter: memory({ data: demoData() }) })
);
const show = async (
label: string,
tool: OutboundTool | undefined,
input: Parameters<OutboundTool["call"]>[0]
) => {
console.log(
`${label}\n${JSON.stringify(await tool?.call(input), null, 2)}\n`
);
};
await show(
"An argument the tool does not take",
find(tools, "campaign_overview"),
{
client: "acme",
}
);
await show("A value of the wrong shape", find(tools, "campaign_trend"), {
campaign: "cmp_founders",
from: "yesterday",
to: "2026-10-31",
});
await show("A record that does not exist", find(tools, "campaign_setup"), {
campaign: "no-such-campaign",
});
// An adapter that, like HeyReach, reports no reply categories. reply_samples
// is still offered, because it works without the filter.
const limited = createTools(
createOutbound({
adapter: memory({
capabilities: { "replies.category": false },
data: demoData(),
}),
})
);
await show("A read the adapter cannot serve", find(limited, "reply_samples"), {
category: "positive",
});
An argument the tool does not take
{
"error": {
"code": "Invalid",
"message": "campaign_overview: \"client\" is not an input. It takes: from, limit, status, to"
},
"next": "Check the input and try again.",
"ok": false
}
A value of the wrong shape
{
"error": {
"code": "Invalid",
"message": "campaign_trend: from Use YYYY-MM-DD"
},
"next": "Check the input and try again.",
"ok": false
}
A record that does not exist
{
"error": {
"code": "NotFound",
"message": "Campaign no-such-campaign was not found"
},
"next": "Check the input and try again.",
"ok": false
}
A read the adapter cannot serve
{
"error": {
"code": "Unsupported",
"message": "Filtering replies by category is not available: the memory adapter does not support it (replies.category)"
},
"next": "This tool is not available with the current adapter or plugins. Try another tool.",
"ok": false
}
| What happened | error.code |
What the model is told |
|---|---|---|
| An argument the tool does not take | Invalid |
The stray key and the inputs the tool takes |
| A value of the wrong type or shape | Invalid |
The field and what was wrong with it |
| A record that does not exist | NotFound |
Which record |
| A read the adapter or a plugin does not allow | Unsupported |
Which capability is missing, and to try another tool |
Anything that is not an OutboundError |
Provider |
The error’s message |
Input is strict. An argument a tool does not take is reported as Invalid, never dropped, so a model that invents a filter learns that the filter did nothing. The other codes are the ones on the errors page.
Add the insights
The 26 insights are tools of the same shape. insightTools(outbound) from outbound-sdk/insights returns the ones the adapter can serve, and the extra option of createTools and of every wrapper appends them.
import { createOutbound } from "outbound-sdk";
import { insightTools } from "outbound-sdk/insights";
import { demoData, memory } from "outbound-sdk/memory";
import { createTools } from "outbound-sdk/tools";
const outbound = createOutbound({ adapter: memory({ data: demoData() }) });
// Every insight the adapter can serve, as tools of the same shape.
const insights = insightTools(outbound);
console.log(`${insights.length} insight tools:`);
console.log(insights.map((tool) => tool.name).join(" "));
// `extra` appends them to the built-in set. `only` narrows each side.
const tools = createTools(outbound, {
extra: insightTools(outbound, { only: ["client_report", "hot_leads"] }),
only: ["campaign_overview"],
});
console.log(tools.map((tool) => tool.name));
const report = tools.find((tool) => tool.name === "client_report");
const result = await report?.call({ days: 7 });
if (result?.ok) {
console.log(result.meta, Object.keys(result.data ?? {}));
}
26 insight tools:
person_summary account_summary lead_collisions suppression_check dropped_replies hot_leads personalization_gaps step_funnel variant_test copy_audit copy_reuse reply_timing domain_health sender_capacity bounce_watch warmup_readiness sender_load settings_audit campaign_diagnosis pipeline_forecast anomalies period_comparison campaign_ranking channel_mix tag_rollup client_report
[ 'campaign_overview', 'hot_leads', 'client_report' ]
{ evidence: 'exact', provider: 'memory', requests: 0 } [ 'complete', 'notes', 'result' ]
An insight tool wraps its answer as data.result, with data.complete and data.notes beside it. Like the twelve built-in tools, an insight whose adapter lacks a capability it needs is left out of the list.
Options
| Option | What it does |
|---|---|
only |
Offer only these built-in tools, by name. Defaults to every tool the instance can serve. |
extra |
More tools to offer after the built-in ones. only does not filter them. |
Reply text
reply_samples leaves reply text out unless the model asks for it, and marks it as untrusted when it does. Read untrusted content before you let a model read replies.