Give your agent tools that answer questions about campaigns
A set of read-only tools your own agent can call, in the shapes the OpenAI SDK, the AI SDK and the Claude Agent SDK take, with recorded tool calls and the failures a model will see.
You are building an agent and want it to answer “which step gets the replies?” or “which campaign is behind?” from live data. outbound-sdk/tools gives it tools shaped like those questions, not like vendor endpoints. Each tool has a description written for a model, its input as JSON Schema, and a function that reads, arranges and returns compact JSON.
The SDK calls no model. Your model decides which tool to call and what the answer means. A tool only reads: there is no tool that sends, pauses or edits. This guide lists the tools, records real calls to them, and shows the wiring for three agent SDKs. The AI SDK wiring was run once against a real model, and that transcript is shown; the other two were not.
Before you start
- Node 22 or later.
- The code runs on the in-memory adapter, so it needs no vendor key and no model key. Each sample is a file the test suite runs; the output shown is what it printed.
outbound-sdk/toolsneeds no other package.outbound-sdk/tools/ai-sdkneedsai(version 6 or 7) andoutbound-sdk/tools/claude-agentneeds@anthropic-ai/claude-agent-sdk. Both are optional peers.- For real data: an Instantly, Smartlead or HeyReach API key. A read-only key is enough.
What you get
A model’s function call is a tool name and an arguments string. handle takes both and returns the string to send back. These are two real calls on seeded data.
13 tools; the first one:
{"description":"How every campaign is doing right now: sends, replies, positive replies, bounces and reply rate for each campaign with the given status. One vendor request per campaign. Rates are computed from counts; a null means the vendor does not report that count.","name":"campaign_overview","parameters":"(JSON Schema)","strict":false,"type":"function"}
function, type
winning_steps {"campaign":"cmp_founders"}
{
"data": {
"campaign": "cmp_founders",
"steps": [
{
"metrics": {
"bounce_rate": null,
"bounced": null,
"connections_accepted": null,
"connections_sent": null,
"contacted": null,
"opened": null,
"positive": null,
"positive_reply_rate": null,
"replied": 10,
"reply_rate": 0.0476,
"reply_rate_basis": "sent",
"sent": 210
},
"step": 1,
"variant": null
},
{
"metrics": {
"bounce_rate": null,
"bounced": null,
"connections_accepted": null,
"connections_sent": null,
"contacted": null,
"opened": null,
"positive": null,
"positive_reply_rate": null,
"replied": 4,
"reply_rate": 0.0286,
"reply_rate_basis": "sent",
"sent": 140
},
"step": 0,
"variant": null
}
]
},
"meta": {
"evidence": "exact",
"provider": "memory",
"requests": 0,
"today": "2026-11-14"
},
"next": "Use reply_samples to read what people said in reply.",
"ok": true
}
campaign_ranking {"metric":"reply_rate"}
{
"data": {
"complete": true,
"notes": [],
"result": {
"gap": {
"difference": 0,
"median": 0.1,
"top": 0.1
},
"ranked": [
{
"campaign": "memory:acme:cmp_founders",
"channel": "email",
"name": "SaaS founders",
"provider": "memory",
"status": "active",
"count": 14,
"high": 0.1609,
"low": 0.0605,
"of": 140,
"rate": 0.1,
"sent": 350,
"rank": 1
},
{
"campaign": "memory:acme:cmp_agencies",
"channel": "email",
"name": "Agency owners",
"provider": "memory",
"status": "active",
"count": 7,
"high": 0.1923,
"low": 0.0493,
"of": 70,
"rate": 0.1,
"sent": 210,
"rank": 2
}
],
"tooFewSends": [],
"unknown": [],
"better": "higher",
"metric": "reply_rate",
"period": {
"from": null,
"to": null
}
}
},
"meta": {
"evidence": "exact",
"provider": "memory",
"requests": 0
},
"next": "Use campaign_diagnosis on the campaign ranked last to see why it is behind.",
"ok": true
}
Every result has the same four parts:
okistrueorfalse. A tool never throws.datais the answer. A count the vendor does not report isnull, never 0, and a rate built on it isnulltoo.meta.evidenceisexact,sampledorderived, so the model can say how far to trust a number.meta.requestsis the vendor requests the call made.nextsuggests the tool to call after this one.
In the seed, step 2 got 10 replies from 210 sends and step 1 got 4 from 140, so winning_steps lists step index 1 first at 0.0476. No contacted count is seeded per step, so the rate is on sent and reply_rate_basis says so.
Build the instance and choose the tools
import { createOutbound } from "outbound-sdk";
import { identity } from "outbound-sdk/identity";
import { insightTools } from "outbound-sdk/insights";
import { memory } from "outbound-sdk/memory";
import { seed } from "./seed.ts";
// Swap memory() for instantly(), smartlead() or heyreach().
export const outbound = createOutbound({
adapter: memory({ data: seed, workspaceId: "acme" }),
// The identity plugin adds outbound.person, which cross_channel_lead needs.
plugins: [identity()],
});
// Three of the 26 insights, offered as tools beside the built-in ones.
export const extra = insightTools(outbound, {
only: ["campaign_ranking", "hot_leads", "client_report"],
});
createTools returns the built-in tools the instance can serve. A tool that needs a capability the adapter lacks is left out. Two of the twelve need a plugin and are left out without it: compare_clients needs clients, cross_channel_lead needs identity. vendor_read is offered only when the adapter has vendor reads of its own and no plugin turned them off, so it is never in the list on the in-memory adapter.
insightTools from outbound-sdk/insights turns the 26 insights into tools of the same form. Pass them as extra. Offering all of them costs tokens on every model call, so name the ones you want with only.
import { createOutbound } from "outbound-sdk";
import { insightTools } from "outbound-sdk/insights";
import { memory } from "outbound-sdk/memory";
import { createTools } from "outbound-sdk/tools";
import { extra, outbound } from "./outbound.ts";
import { seed } from "./seed.ts";
const names = (tools: readonly { readonly name: string }[]): string =>
tools.map((tool) => tool.name).join(", ");
// An instance with no plugins: the tools that need one are left out.
const plain = createOutbound({ adapter: memory({ data: seed }) });
console.log("no plugins:", names(createTools(plain)));
// The identity plugin is on `outbound`, so one more tool is offered.
const tools = createTools(outbound, { extra });
console.log("\nwith identity and three insights:", names(tools));
// `only` narrows the built-in tools to the ones you name.
console.log(
"\nonly:",
names(createTools(outbound, { only: ["campaign_overview", "reply_samples"] }))
);
console.log("\nevery insight as a tool:", insightTools(outbound).length);
// A tool is a name, a description written for a model, its input as JSON
// Schema, and `call`.
const [overview] = tools;
console.log(`\n${overview?.name}: ${overview?.description}`);
console.log(JSON.stringify(overview?.parameters, null, 2));
no plugins: campaign_overview, campaign_trend, winning_steps, sender_health, reply_samples, lead_runway, campaign_setup, deliverability_check, audience_lists
with identity and three insights: campaign_overview, campaign_trend, winning_steps, sender_health, reply_samples, lead_runway, cross_channel_lead, campaign_setup, deliverability_check, audience_lists, hot_leads, campaign_ranking, client_report
only: campaign_overview, reply_samples
every insight as a tool: 26
campaign_overview: How every campaign is doing right now: sends, replies, positive replies, bounces and reply rate for each campaign with the given status. One vendor request per campaign. Rates are computed from counts; a null means the vendor does not report that count.
{
"$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}$"
}
}
}
Run a call from the model
openaiTools returns the definitions in the two shapes the OpenAI SDK takes, and handle. It imports nothing from openai.
import { openaiTools } from "outbound-sdk/tools";
import { extra, outbound } from "./outbound.ts";
const { chatTools, handle, tools } = openaiTools(outbound, { extra });
// `tools` goes to the Responses API, `chatTools` to Chat Completions.
console.log(tools.length, "tools;", "the first one:");
console.log(JSON.stringify({ ...tools[0], parameters: "(JSON Schema)" }));
console.log(Object.keys(chatTools[0] ?? {}).join(", "));
// What your loop does with a function call from the model: pass the name and
// the arguments string to `handle`, and send the string it returns back.
const show = async (name: string, argumentsJson: string): Promise<void> => {
const output = await handle(name, argumentsJson);
console.log(`\n${name} ${argumentsJson}`);
console.log(JSON.stringify(JSON.parse(output), null, 2));
};
await show("winning_steps", '{"campaign":"cmp_founders"}');
await show("campaign_ranking", '{"metric":"reply_rate"}');
Your loop is: send tools with the request; for each function call the model returns, pass its name and its arguments string to handle; send the returned string back as that call’s output; repeat until the model answers in text. That loop is not shown as a sample here because the openai package is not a dependency of this repository, so a sample could not be type-checked or run. An empty arguments string is treated as no arguments.
Wire it to the AI SDK
aiSdkTools returns one AI SDK tool per outbound tool, each with execute, so the AI SDK runs the calls and the loop for you.
import { generateText, stepCountIs } from "ai";
import type { LanguageModel } from "ai";
import { aiSdkTools } from "outbound-sdk/tools/ai-sdk";
import { extra, outbound } from "./outbound.ts";
// One AI SDK tool per outbound tool, keyed by name. Each has `execute`, so
// the AI SDK runs the calls itself.
const tools = aiSdkTools(outbound, { extra });
/**
* Wiring only: this function is not called by the sample, because calling it
* needs a model and a key. `model` is whatever your provider package gives.
*/
export const ask = (model: LanguageModel, prompt: string) =>
generateText({ model, prompt, stopWhen: stepCountIs(8), tools });
This one was run against a real model on 2026-11-14: deepseek/deepseek-v4-flash through the Vercel AI Gateway, on this guide’s seeded in-memory data. The model called campaign_ranking, one of the insight tools, and answered from its result:
{
"answer": "Here are the campaigns ranked by reply rate:\n\n| Rank | Campaign | Reply rate | Replies | Sends | Lower bound |\n|------|----------|-----------|---------|-------|-------------|\n| 1 | **SaaS founders** | **10%** | 14 | 350 | 6.05% |\n| 2 | Agency owners | 10% | 7 | 210 | 4.93% |\n\n**Best campaign: \"SaaS founders\"** with a **reply rate of 10%** (14 replies from 350 sends).\n\nA note on the tie: both campaigns nominally show a 10% reply rate, but the ranking uses the low end of the 95% Wilson confidence interval to be fair. \"SaaS founders\" ranks #1 because it has far more sends (350 vs 210), so its 10% is more statistically reliable (lower bound ≈ 6.1%) than \"Agency owners\" (lower bound ≈ 4.9%, from a smaller sample).\n\nWant me to dig into what's driving the replies on \"SaaS founders\" (e.g., which step or variant converts best)?",
"model": "deepseek/deepseek-v4-flash",
"prompt": "Rank the campaigns by reply rate. Which one is best, and what is its rate?",
"ranAt": "2026-11-14",
"steps": 2,
"toolCalls": [
{
"input": {},
"tool": "campaign_ranking"
}
],
"via": "Vercel AI Gateway"
}
A model does not answer the same way twice, so this is an example run and not an output the test suite reproduces. npm run site:live in the repository repeats it with an AI Gateway credential.
Wire it to the Claude Agent SDK
claudeAgentTools returns the tools as an in-process MCP server, with the list of names to allow. Each tool is annotated as read-only. This file is type-checked and loaded by the test suite; the function in it was not run against a model.
import { query } from "@anthropic-ai/claude-agent-sdk";
import { claudeAgentTools } from "outbound-sdk/tools/claude-agent";
import { extra, outbound } from "./outbound.ts";
// An in-process MCP server holding the tools, and their names in the form
// the Claude Agent SDK allows by: mcp__outbound__campaign_overview.
const tools = claudeAgentTools(outbound, { extra });
/**
* Wiring only: this function is not called by the sample, because calling it
* starts an agent that needs a model and a key.
*/
export const ask = (prompt: string) =>
query({
options: {
// Every tool only reads, so all of them can be allowed up front.
allowedTools: tools.allowedTools,
mcpServers: { [tools.serverName]: tools.server },
},
prompt,
});
For a client that starts a command instead of importing code, such as Claude Code or Cursor, use the local MCP server. It serves the same tools.
Try it without a key
// Made-up data for one workspace, "acme": two email campaigns with a week of
// daily results, per-step results for one of them, one sender and one reply.
import type { MemoryData, MemoryStats } from "outbound-sdk/memory";
const DAY = 86_400_000;
const TODAY = Date.parse("2026-11-14T00:00:00.000Z");
const day = (daysAgo: number): string =>
new Date(TODAY - daysAgo * DAY).toISOString().slice(0, 10);
const week = (
campaignId: string,
counts: MemoryStats["counts"]
): MemoryStats[] =>
Array.from({ length: 7 }, (_, index) => ({
campaignId,
counts,
date: day(index + 1),
}));
export const seed: MemoryData = {
campaigns: [
{ id: "cmp_founders", name: "SaaS founders" },
{ id: "cmp_agencies", name: "Agency owners" },
],
leads: [
{
campaignId: "cmp_founders",
email: "dana@example.com",
firstName: "Dana",
id: "lead_dana",
status: "replied",
},
],
messages: [
{
body: "Yes, send me the deck. Thursday works.",
campaignId: "cmp_founders",
category: "positive",
direction: "in",
id: "msg_dana",
leadId: "lead_dana",
sentAt: "2026-11-12T09:30:00.000Z",
},
],
senders: [
{
address: "ava@example.com",
dailyLimit: 40,
id: "snd_ava",
warmup: { enabled: true, score: 96 },
},
],
stats: [
// 50 sends a day to 20 new people, 2 replies a day.
...week("cmp_founders", { contacted: 20, replied: 2, sent: 50 }),
// 30 sends a day to 10 new people, 1 reply a day.
...week("cmp_agencies", { contacted: 10, replied: 1, sent: 30 }),
// The same week for SaaS founders, by step: the follow-up gets the replies.
{
campaignId: "cmp_founders",
counts: { replied: 4, sent: 140 },
date: day(1),
stepIndex: 0,
},
{
campaignId: "cmp_founders",
counts: { replied: 10, sent: 210 },
date: day(1),
stepIndex: 1,
},
],
};
Every sample on this page reads this seed through outbound.ts.
Point it at your vendor
In outbound.ts, replace memory({ ... }) with instantly(), smartlead() or heyreach(). Each reads its key from the environment. Nothing else changes. On a real vendor, also set maxRequests on createOutbound: a model chooses how often to call a tool, and the cap is what stops one question from spending your vendor quota.
What each vendor can serve
The same five calls on the in-memory adapter limited to what each vendor’s adapter declares, with the identity plugin on. This shows which calls a vendor’s adapter would refuse. It does not show what that vendor’s API returns.
import { createOutbound } from "outbound-sdk";
import { identity } from "outbound-sdk/identity";
import { insightTools } from "outbound-sdk/insights";
import { createTools } from "outbound-sdk/tools";
import { asVendor, VENDORS } from "../_shared/as-vendor.ts";
import { seed } from "./seed.ts";
const QUESTIONS = [
["campaign_overview", {}],
["winning_steps", { campaign: "cmp_founders" }],
["campaign_setup", { campaign: "cmp_founders" }],
["reply_samples", { category: "positive" }],
["cross_channel_lead", { person: "dana@example.com" }],
] as const;
for (const vendor of VENDORS) {
const outbound = createOutbound({
adapter: asVendor(vendor, seed, "acme"),
plugins: [identity()],
});
const tools = createTools(outbound, { extra: insightTools(outbound) });
console.log(`${vendor}: ${tools.length} tools`);
for (const [name, input] of QUESTIONS) {
const tool = tools.find((candidate) => candidate.name === name);
// oxlint-disable-next-line no-await-in-loop -- one call at a time keeps the output in order.
const result = await tool?.call(input);
if (result === undefined) {
console.log(` ${name}: not offered`);
} else if (!result.ok) {
console.log(` ${name}: ${result.error.code}: ${result.error.message}`);
}
}
}
instantly: 36 tools
smartlead: 36 tools
heyreach: 30 tools
winning_steps: not offered
campaign_setup: not offered
reply_samples: Unsupported: Filtering replies by category is not available: the heyreach adapter does not support it (replies.category)
HeyReach reports no per-step stats and no readable campaign settings, so winning_steps, campaign_setup and four insights are not offered there. It reports no reply categories either: reply_samples is offered, because it works without the filter, and the call that asks for a category comes back Unsupported. Its real adapter also offers vendor_read, as do the other two; the in-memory stand-in has no vendor reads, so the counts above are one short of a real instance.
What it costs in vendor requests
A tool spends vendor requests only when called. The descriptions tell the model what a call costs, and meta.requests reports what it spent.
campaign_overview: the campaign list, then one stats read per campaign, up tolimit(default 10, at most 50).deliverability_check: one or more requests per sender, up tolimit(default 10, at most 25).compare_clients: one request per campaign.vendor_read: one request per call, none to list the reads on offer.- An insight tool reports its own cost in its description, for example
campaign_rankingreads one stats request per campaign up tomax_campaigns.
When it cannot answer
import { createOutbound } from "outbound-sdk";
import { memory } from "outbound-sdk/memory";
import { openaiTools } from "outbound-sdk/tools";
import { outbound } from "./outbound.ts";
import { seed } from "./seed.ts";
const { handle } = openaiTools(outbound);
// `handle` never throws. Every failure is a string to send back to the model.
const show = async (label: string, output: Promise<string>): Promise<void> => {
console.log(`${label}: ${await output}`);
};
// 1. A tool that does not exist. There is no tool that writes.
await show("unknown tool", handle("pause_campaign", '{"campaign":"x"}'));
// 2. Arguments that are not JSON.
await show("not JSON", handle("sender_health", "{limit: 5"));
// 3. A value the input schema refuses.
await show(
"bad value",
handle("campaign_trend", '{"campaign":"cmp_founders","from":"last week"}')
);
// 4. An argument the tool does not take is reported, never dropped.
await show(
"unknown argument",
handle("campaign_overview", '{"client":"acme"}')
);
// 5. A record that does not exist.
await show("not found", handle("campaign_setup", '{"campaign":"cmp_nope"}'));
// 6. A read the adapter cannot serve. A tool that could never answer is not
// in the list at all, so calling it is the same as calling an unknown tool.
const noSteps = openaiTools(
createOutbound({
adapter: memory({ capabilities: { "stats.byStep": false }, data: seed }),
})
);
await show(
"not offered",
noSteps.handle("winning_steps", '{"campaign":"cmp_founders"}')
);
// 7. A tool that is offered can still be asked for something the adapter
// cannot do. Here the vendor, like HeyReach, has no reply categories.
const noCategories = openaiTools(
createOutbound({
adapter: memory({
capabilities: { "replies.category": false },
data: seed,
}),
})
);
await show(
"unsupported",
noCategories.handle("reply_samples", '{"category":"positive"}')
);
unknown tool: {"error":{"code":"Invalid","message":"Unknown tool pause_campaign"},"next":"Call one of the listed tools with a JSON object.","ok":false}
not JSON: {"error":{"code":"Invalid","message":"The arguments are not valid JSON"},"next":"Call one of the listed tools with a JSON object.","ok":false}
bad value: {"error":{"code":"Invalid","message":"campaign_trend: from Use YYYY-MM-DD"},"next":"Check the input and try again.","ok":false}
unknown argument: {"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}
not found: {"error":{"code":"NotFound","message":"Campaign cmp_nope was not found"},"next":"Check the input and try again.","ok":false}
not offered: {"error":{"code":"Invalid","message":"Unknown tool winning_steps"},"next":"Call one of the listed tools with a JSON object.","ok":false}
unsupported: {"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 | What you get | What to do |
|---|---|---|
| The model named a tool that does not exist | ok: false, Invalid, “Unknown tool” |
Send it back. The next hint tells the model to use a listed tool. |
| The arguments string is not JSON | ok: false, Invalid |
Send it back. |
| A value the schema refuses | ok: false, Invalid, naming the field |
Send it back. The model can correct the value. |
| An argument the tool does not take | ok: false, Invalid, listing the inputs |
Send it back. It is never dropped silently. |
| A record that does not exist | ok: false, NotFound |
Send it back. |
| The adapter cannot serve the read | ok: false, Unsupported |
Send it back. next tells the model to try another tool. |
| The vendor rejects the key, or rate limits past your budget | ok: false with Unauthorized or RateLimited |
Fix the key, or raise maxRequests or deadline. |
Limits
- One live run with one model is shown. Whether another model picks the right tool is not tested by this repository. The OpenAI and Claude Agent SDK wiring was never run against a model.
- Built-in tools and insight tools are both filtered by what the adapter declares. A tool that is offered can still be refused for a read only its input asks for, such as a reply category on HeyReach.
cross_channel_leadreturns message text only withinclude_bodies: true, wrapped and cut like reply text. Its timeline also holds your own outgoing messages.- Reply text is left out unless asked for, cut to 500 characters, and marked untrusted. That marking is a convention a model can ignore. See hand reply text to a model.
- Tool definitions are sent with
strict: falsein the OpenAI shape.