Skip to content
Preview: outbound-sdk is not on npm yet and its repository is private. Instantly and HeyReach are beta, Smartlead is unverified.What is verified
outbound-sdk
Esc
↑↓navigate↵open⌘Jpreview
On this page

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, meta and next. meta.evidence says how far to trust the numbers (exact, sampled or derived), meta.requests how many vendor requests the call made, meta.today is today’s date in UTC (a model does not know it, and otherwise guesses the date range it asks for), and next suggests 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_clients without the clients plugin on the instance.
  • cross_channel_lead without the identity plugin, or while the redact plugin masks email or linkedin, because outbound.person() is refused then.
  • vendor_read when the adapter has no native reads, or when the redact plugin 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.

Was this page helpful?