---
title: AI tools
description: 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.
sidebar:
  label: Overview
related:
  - /docs/tools/untrusted-content
  - /docs/cli/mcp
  - /docs/insights
  - /guides/agent-tools
---

`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](/docs/adapters/memory). How far each real adapter has been checked is on [what is verified](/docs/status).

## Create the tools

`createTools(outbound)` returns the tools the instance can serve. `TOOL_NAMES` lists all twelve names.

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

```text title="Output"
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](/docs/tools/openai), [AI SDK](/docs/tools/ai-sdk) or [Claude Agent SDK](/docs/tools/claude-agent). To serve it to an agent client with no code, run [`outbound mcp`](/docs/cli/mcp).

## Call a tool

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

```text title="Output"
{
  "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](/docs/plugins/clients), `stats.campaign` |
| `cross_channel_lead` | Every record and message for one person | the [`identity` plugin](/docs/plugins/identity) |
| `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](/docs/native) catalog |

The last column is the tool's own `needs`: the [capabilities](/docs/capabilities) and plugins it cannot work without. `createTools` reads it to decide what to offer.

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

```text title="Output"
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](/docs/plugins/redact) 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.

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

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

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

```text title="Output"
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](/docs/api/errors) page.

## Add the insights

The 26 [insights](/docs/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.

```ts title="extra.ts"
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 ?? {}));
}
```

```text title="Output"
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](/docs/tools/untrusted-content) before you let a model read replies.
