See every campaign one person is in, across an email tool and a LinkedIn tool
One function that takes an email address or a LinkedIn profile URL and returns each campaign the person is in on every tool, with messages sent and replies per channel. Records are linked by exact identifier only.
Before you answer a reply, you want to know what else this person was sent: which campaigns, on which channel, and whether they already replied somewhere else. personSummary takes an email address or a LinkedIn profile URL and returns one entry per campaign the person is in, on every tool behind the instance, with messages sent, replies, the steps replied to, the vendor’s interest label and totals by channel.
It links records by exact identifier only: the same lowercased email address, or the same LinkedIn profile. It never matches on a name or a company. So an email address reaches a LinkedIn record only when some tool holds both the address and the profile for that person. When no record holds both, you get the email side only, and nothing tells you a LinkedIn side exists.
Before you start
- Node 22 or later.
- The code below runs on the in-memory adapter, so it needs no vendor key. Each sample on this page is a file in the repository that the test suite runs; the output shown is what it printed.
- For real data: an API key for each tool. A read-only key is enough. No method of the SDK writes to a vendor.
What you get
Three lookups against two tools. Dana’s address finds her LinkedIn campaign too. Omar’s address does not find his, and his profile does not find his email campaign.
dana.whitfield@example.com: 3 campaigns, 5 messages sent, 2 replies
Known as: email:dana.whitfield@example.com, linkedin:dana-whitfield-example
email via mailtool: Founders Q4 (active)
sent 2, replies 1, last contacted 2026-11-06, last reply 2026-11-07, marked Interested
email via mailtool: Webinar follow-up (completed)
sent 1, replies 0, last contacted 2026-10-12, last reply never
linkedin via lntool: LinkedIn founders (active)
sent 2, replies 1, last contacted 2026-11-10, last reply 2026-11-08
email: 3 sent, 1 replies
linkedin: 2 sent, 1 replies
omar.haddad@example.com: 1 campaigns, 1 messages sent, 0 replies
Known as: email:omar.haddad@example.com
email via mailtool: Founders Q4 (active)
sent 1, replies 0, last contacted 2026-11-03, last reply never
email: 1 sent, 0 replies
https://www.linkedin.com/in/omar-haddad-example: 1 campaigns, 1 messages sent, 1 replies
Known as: linkedin:omar-haddad-example
linkedin via lntool: LinkedIn founders (active)
sent 1, replies 1, last contacted 2026-11-04, last reply 2026-11-09
linkedin: 1 sent, 1 replies
Put two tools behind one instance
combine takes one adapter per tool. Every record keeps its own provider, so the email tool’s campaigns and the LinkedIn tool’s campaigns are never confused.
import { createOutbound } from "outbound-sdk";
import { combine } from "outbound-sdk/combine";
import { identity } from "outbound-sdk/identity";
import { insights } from "outbound-sdk/insights";
import { memory } from "outbound-sdk/memory";
import { emailTool, linkedinTool } from "./seed.ts";
// Two tools behind one instance. `name` stands in for the vendor: every
// record keeps it as `provider`.
export const outbound = createOutbound({
adapter: combine([
memory({ data: emailTool, name: "mailtool", workspaceId: "agency" }),
memory({ data: linkedinTool, name: "lntool", workspaceId: "agency" }),
]),
plugins: [insights(), identity()],
});
Write the lookup
import type { Outbound } from "outbound-sdk";
import type { InsightsExtension } from "outbound-sdk/insights";
const day = (time: string | null): string =>
time === null ? "never" : time.slice(0, 10);
/** One person across every campaign and tool the instance reads, as text. */
export const personBrief = async (
outbound: Outbound & InsightsExtension,
person: string
): Promise<string> => {
const { data, meta } = await outbound.insights.personSummary({ person });
if (!data.found) {
return `${person}: no record carries this identifier.`;
}
const lines = [
`${person}: ${data.totals.campaigns} campaigns, ${data.totals.messagesSent} messages sent, ${data.totals.replies} replies`,
`Known as: ${data.identifiers.join(", ")}`,
"",
...data.campaigns.map(
(campaign) =>
` ${campaign.channel} via ${campaign.provider}: ${campaign.campaignName ?? "no campaign"} (${campaign.campaignStatus ?? "unknown"})\n` +
` sent ${campaign.messagesSent}, replies ${campaign.replies}, last contacted ${day(campaign.lastContactedAt)}, last reply ${day(campaign.lastRepliedAt)}${
campaign.interest === null ? "" : `, marked ${campaign.interest}`
}`
),
"",
...Object.entries(data.totals.byChannel).map(
([channel, counts]) =>
`${channel}: ${counts.messagesSent} sent, ${counts.replies} replies`
),
];
// A cap that stopped the read, or a count taken from the lead's last step,
// is said here. Pass it on.
if (!meta.complete || meta.notes.length > 0) {
lines.push("", ...meta.notes.map((note) => `Note: ${note}`));
}
return lines.join("\n");
};
What the insight does with the identifier:
- It asks every tool for leads with that exact address or profile.
- Each record it gets back is checked again: a record counts only when it carries the identifier asked for. The vendor’s own matching is not trusted.
- If a matching record carries a second identifier, it asks for that one too. This is the only way an address reaches a LinkedIn record.
- For each record it reads the lead’s messages and the campaign.
Three things in the answer are worth knowing:
data.foundisfalsewhen no record matches. That is an answer, not an error. It means no record carries the identifier, not that the person was never contacted.data.identifierslists every identifier the matching records carry, asemail:andlinkedin:keys. One key means nothing linked the two channels.messagesSentcan come from the lead’s last step. When a vendor’s thread holds fewer sent messages than the lead’s last step number implies, the step number is used and a note says so.
Try it without a key
Two made-up tools. The LinkedIn tool has one record that carries both of Dana’s identifiers, which is the only way an Instantly and HeyReach pairing can link a person, because an Instantly lead carries no profile. Nothing carries both of Omar’s.
// Two made-up tools for one agency. The email tool knows Dana and Omar by
// address only, as Instantly would. The LinkedIn tool knows Omar by profile
// only, and Dana by profile and by address, as a HeyReach lead can.
import type { MemoryData } from "outbound-sdk/memory";
export const DANA = "dana.whitfield@example.com";
export const DANA_PROFILE =
"https://www.linkedin.com/in/dana-whitfield-example";
export const OMAR = "omar.haddad@example.com";
export const OMAR_PROFILE = "https://www.linkedin.com/in/omar-haddad-example";
export const emailTool: MemoryData = {
campaigns: [
{ id: "em_q4", name: "Founders Q4" },
{ id: "em_webinar", name: "Webinar follow-up", status: "completed" },
],
leads: [
{
campaignId: "em_q4",
email: DANA,
engagement: { replies: 1, stepIndex: 1 },
firstName: "Dana",
id: "lead_dana_q4",
interest: "Interested",
lastName: "Whitfield",
status: "replied",
},
{
campaignId: "em_webinar",
email: DANA,
id: "lead_dana_webinar",
status: "completed",
},
// No profile on Omar's record, so nothing ties it to the LinkedIn tool.
{ campaignId: "em_q4", email: OMAR, id: "lead_omar_q4" },
],
messages: [
{
campaignId: "em_q4",
direction: "out",
id: "msg_1",
leadId: "lead_dana_q4",
sentAt: "2026-11-03T09:00:00.000Z",
stepIndex: 0,
},
{
campaignId: "em_q4",
direction: "out",
id: "msg_2",
leadId: "lead_dana_q4",
sentAt: "2026-11-06T09:00:00.000Z",
stepIndex: 1,
},
{
campaignId: "em_q4",
category: "positive",
direction: "in",
id: "msg_3",
leadId: "lead_dana_q4",
sentAt: "2026-11-07T14:30:00.000Z",
},
{
campaignId: "em_webinar",
direction: "out",
id: "msg_4",
leadId: "lead_dana_webinar",
sentAt: "2026-10-12T09:00:00.000Z",
stepIndex: 0,
},
{
campaignId: "em_q4",
direction: "out",
id: "msg_5",
leadId: "lead_omar_q4",
sentAt: "2026-11-03T09:05:00.000Z",
stepIndex: 0,
},
],
};
export const linkedinTool: MemoryData = {
campaigns: [
{ channel: "linkedin", id: "li_founders", name: "LinkedIn founders" },
],
leads: [
// The one record that carries both identifiers for Dana.
{
campaignId: "li_founders",
email: DANA,
id: "prof_dana",
linkedinUrl: DANA_PROFILE,
},
{
campaignId: "li_founders",
id: "prof_omar",
linkedinUrl: OMAR_PROFILE,
},
],
messages: [
{
campaignId: "li_founders",
direction: "out",
id: "dm_1",
leadId: "prof_dana",
sentAt: "2026-11-04T10:00:00.000Z",
},
{
campaignId: "li_founders",
direction: "in",
id: "dm_2",
leadId: "prof_dana",
sentAt: "2026-11-08T08:15:00.000Z",
},
{
campaignId: "li_founders",
direction: "out",
id: "dm_3",
leadId: "prof_dana",
sentAt: "2026-11-10T10:00:00.000Z",
},
{
campaignId: "li_founders",
direction: "out",
id: "dm_4",
leadId: "prof_omar",
sentAt: "2026-11-04T10:05:00.000Z",
},
{
campaignId: "li_founders",
direction: "in",
id: "dm_5",
leadId: "prof_omar",
sentAt: "2026-11-09T16:00:00.000Z",
},
],
};
import { outbound } from "./agency.ts";
import { personBrief } from "./person.ts";
import { DANA, OMAR, OMAR_PROFILE } from "./seed.ts";
// The email tool holds Dana's address and her profile on one record, so her
// address reaches the LinkedIn campaign too.
console.log(await personBrief(outbound, DANA));
// No record holds both of Omar's identifiers. His address finds the email
// campaign only, and his profile finds the LinkedIn campaign only.
console.log(`\n${await personBrief(outbound, OMAR)}`);
console.log(`\n${await personBrief(outbound, OMAR_PROFILE)}`);
That is the script that printed the output above.
Read the messages in one timeline
personSummary returns counts. The identity plugin adds outbound.person(key), which returns the lead records themselves and every message to and from the person across tools, oldest first. It uses the same exact-identifier rule.
import { outbound } from "./agency.ts";
import { DANA } from "./seed.ts";
// The identity plugin returns the records themselves and every message to
// and from the person, across tools, oldest first.
const person = await outbound.person(DANA);
console.log(`${person.key}: ${person.records.length} records`);
for (const message of person.timeline) {
console.log(
`${message.sentAt?.slice(0, 16)} ${message.channel.padEnd(8)} ${message.provider.padEnd(8)} ${message.direction === "in" ? "they replied" : "sent"}`
);
}
email:dana.whitfield@example.com: 3 records
2026-10-12T09:00 email mailtool sent
2026-11-03T09:00 email mailtool sent
2026-11-04T10:00 linkedin lntool sent
2026-11-06T09:00 email mailtool sent
2026-11-07T14:30 email mailtool they replied
2026-11-08T08:15 linkedin lntool they replied
2026-11-10T10:00 linkedin lntool sent
bodyText is null on the timeline unless you ask for it: outbound.person(identifier, { include: ["body"] }) reads message text for every record, where the adapter declares messages.body. For one record, outbound.lead(ref).timeline({ include: ["body"] }) does the same, which is covered in handles.
Point it at your vendor
Swap the adapters. Nothing else changes.
import { createOutbound } from "outbound-sdk";
import { combine } from "outbound-sdk/combine";
import { heyreach } from "outbound-sdk/heyreach";
import { insights } from "outbound-sdk/insights";
import { instantly } from "outbound-sdk/instantly";
// Reads INSTANTLY_API_KEY and HEYREACH_API_KEY.
export const outbound = createOutbound({
adapter: combine([
instantly({ workspaceId: "agency" }),
heyreach({ workspaceId: "agency" }),
]),
// One call may spend at most 60 vendor requests.
maxRequests: 60,
plugins: [insights()],
});
Whether an address reaches the LinkedIn side depends on what each tool stores. These come from the adapters’ own notes, not from this guide’s data:
- Instantly leads carry no LinkedIn profile. A lookup by profile returns nothing from Instantly, and an Instantly record never links an address to a profile.
- HeyReach leads can carry an email address. Then the address finds the HeyReach record, and its profile finds the person’s other HeyReach campaigns. HeyReach has no read that returns a profile for an address, so the adapter goes through a saved list the address is in: an address that is in no saved list is not found although HeyReach holds it.
- Smartlead has no read that finds a lead by LinkedIn profile. A lookup by profile returns nothing and sends no request.
What each vendor can serve
The same function, run on the in-memory adapter limited to what each vendor’s adapter declares. This shows which calls a vendor’s adapter would refuse. It does not show what that vendor’s API returns.
import { createOutbound, OutboundError } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";
import { asVendor, VENDORS } from "../_shared/as-vendor.ts";
import { personBrief } from "./person.ts";
import { DANA, emailTool } from "./seed.ts";
for (const vendor of VENDORS) {
const outbound = createOutbound({
adapter: asVendor(vendor, emailTool, "agency"),
plugins: [insights()],
});
try {
// oxlint-disable-next-line no-await-in-loop -- one vendor at a time.
const brief = await personBrief(outbound, DANA);
console.log(`${vendor}: ok, ${brief.split("\n").length} lines`);
} catch (error) {
if (!OutboundError.is(error)) {
throw error;
}
console.log(`${vendor}: ${error.code}: ${error.message}`);
}
}
instantly: ok, 9 lines
smartlead: ok, 9 lines
heyreach: ok, 9 lines
personSummary needs leads.lookup and leads.timeline. All three adapters declare both, so none refuses it.
What it costs
One lookup per identifier, on each tool, then up to two requests per campaign the person is in: the lead’s messages (read once per lead) and the campaign. max_campaigns (default 20, at most 50) bounds the second part. A HeyReach lookup by email address is four requests in the usual case. maxRequests on the instance caps the whole call; past it the call fails with RateLimited instead of spending your vendor quota.
When it cannot answer
import { createOutbound, OutboundError } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";
import { memory } from "outbound-sdk/memory";
import { outbound } from "./agency.ts";
import { DANA, emailTool } from "./seed.ts";
// 1. A name is not an identifier. Nothing is matched by name.
try {
await outbound.insights.personSummary({ person: "Dana Whitfield" });
} catch (error) {
if (OutboundError.is(error)) {
console.log("a name:", error.code, "-", error.message);
}
}
// 2. Nobody carries the identifier. That is an answer, not an error.
const nobody = await outbound.insights.personSummary({
person: "nobody@example.com",
});
console.log("nobody:", nobody.data.found, nobody.data.totals.campaigns);
// 3. More campaigns than the cap: the answer covers part of them and says so.
const capped = await outbound.insights.personSummary({
max_campaigns: 1,
person: DANA,
});
console.log(
"capped:",
capped.data.campaigns.length,
capped.meta.complete,
capped.meta.notes
);
// 4. An adapter that cannot look a lead up by identifier refuses the insight.
const noLookup = createOutbound({
adapter: memory({ capabilities: { "leads.lookup": false }, data: emailTool }),
plugins: [insights()],
});
try {
await noLookup.insights.personSummary({ person: DANA });
} catch (error) {
if (OutboundError.is(error)) {
console.log("no lookup:", error.code, "-", error.message);
}
}
a name: Invalid - Give an email address or a LinkedIn profile URL
nobody: false 0
capped: 1 false [ 'Stopped at 1 records of the person; more exist.' ]
no lookup: Unsupported - person_summary is not available: the memory adapter does not support leads.lookup
| What happened | What you get | What to do |
|---|---|---|
| A name, or other text that is neither an address nor a profile URL | Invalid, before any request |
Pass an email address or a LinkedIn profile URL. |
| No record carries the identifier | data.found is false, campaigns is empty |
Try the person’s other identifier. The two sides may not be linked. |
The person is in more campaigns than max_campaigns |
meta.complete is false and a note says where it stopped: “Stopped at 1 records of the person; more exist.” |
Raise max_campaigns. |
| The adapter cannot look a lead up by identifier | Unsupported, before any request |
Use an adapter that declares leads.lookup. |
| A rejected key | Unauthorized. It is not retried. |
Check the key and its scope. |
Limits
- No fuzzy matching. Two records with the same name and company, one with an address and one with a profile, stay two people.
- An address in one tool and a profile in another are linked only through a record that holds both. Look the person up by each identifier when you have both.
repliedStepsis empty when the vendor does not tie a reply to a step.campaignReplyRateis campaigns replied in, divided by campaigns that sent at least one message. It isnullwhen none did.- The numbers are only as good as the vendor’s. On an
unverifiedadapter, check a summary against the vendor’s own lead page before relying on it.