identity
Tag every lead with person keys built from exact identifiers, and look one person up across tools with every record and one timeline.
identity() from outbound-sdk/identity links the same person across tools and campaigns. It adds personKey and personKeys to every lead, and outbound.person(identifier), which returns every record for that person and one timeline of their messages. A key comes from an exact identifier only: a lowercased email address or a cleaned LinkedIn profile URL. There is no matching on names or companies. Records are linked, never merged: each keeps its own provider, workspaceId and fields.
Sample
Two tools behind one combined instance. The email tool knows Jane by address and profile. The LinkedIn tool knows her only by profile, written differently.
import { createOutbound } from "outbound-sdk";
import { combine } from "outbound-sdk/combine";
import { identity } from "outbound-sdk/identity";
import { memory } from "outbound-sdk/memory";
import { seed } from "../plugins-index/seed.ts";
// A second tool that knows Jane only by her LinkedIn profile.
const linkedinTool = memory({
data: {
campaigns: [{ channel: "linkedin", id: "li_founders", name: "Founders" }],
leads: [
{
campaignId: "li_founders",
firstName: "Jane",
id: "li_jane",
linkedinUrl: "https://linkedin.com/in/Jane-Doe-Example/?utm_source=x",
},
],
messages: [
{
body: "Thanks for connecting, Jane.",
campaignId: "li_founders",
direction: "out",
id: "li_msg_1",
leadId: "li_jane",
sentAt: "2026-11-10T15:00:00.000Z",
},
],
},
name: "linkedintool",
workspaceId: "agency",
});
const outbound = createOutbound({
adapter: combine([
memory({ data: seed, workspaceId: "agency" }),
linkedinTool,
]),
plugins: [identity()],
});
// Every lead is tagged as it is read.
for await (const lead of outbound.leads.listAll()) {
console.log(
lead.ref,
"personKey" in lead ? lead.personKey : "?",
"personKeys" in lead ? lead.personKeys : "?"
);
}
// Asked by email, found in both tools through the profile the email record carries.
const person = await outbound.person("Jane.Doe@example.com");
console.log("\nfound:", person.found);
console.log("keys:", person.keys);
console.log(
"records:",
person.records.map((record) => record.ref)
);
for (const message of person.timeline) {
console.log(message.sentAt, message.provider, message.direction);
}
// No record carries this address. The link is unknown, not an error.
const nobody = await outbound.person("nobody@example.com");
console.log("\nnobody:", nobody.found, nobody.records.length);
memory:agency:lead_jane email:jane.doe@example.com [ 'email:jane.doe@example.com', 'linkedin:jane-doe-example' ]
memory:agency:lead_sam email:sam.lee@example.com [ 'email:sam.lee@example.com' ]
memory:agency:lead_priya email:priya.patel@example.com [ 'email:priya.patel@example.com' ]
memory:agency:lead_omar email:omar.haddad@example.com [ 'email:omar.haddad@example.com' ]
linkedintool:agency:li_jane linkedin:jane-doe-example [ 'linkedin:jane-doe-example' ]
found: true
keys: [ 'email:jane.doe@example.com', 'linkedin:jane-doe-example' ]
records: [ 'memory:agency:lead_jane', 'linkedintool:agency:li_jane' ]
2026-11-10T10:00:00.000Z memory out
2026-11-10T15:00:00.000Z linkedintool out
2026-11-11T08:30:00.000Z memory in
nobody: false 0
The lookup started from an email address in mixed case. The email record carried a profile URL, which led to the LinkedIn record. The timeline is the three messages to and from Jane in both tools, oldest first.
Options
identity() takes no options.
outbound.person(identifier, options?) takes:
| Option | Type | Default | Meaning |
|---|---|---|---|
campaignIds |
string[] |
none | Look only in these campaigns (refs). Each campaign’s leads are walked in full. |
timeline |
boolean |
true |
false leaves the timeline out, which saves one read per matching record. |
include |
("body")[] |
none | ["body"] asks for message text on the timeline. Without it bodyText is null. Needs the messages.body capability. |
identifier is a person key (email:... or linkedin:...), an email address or a LinkedIn profile URL. Anything else throws Invalid.
How a key is built
| Source | Key | Rule |
|---|---|---|
email |
email:jane.doe@example.com |
Trimmed and lowercased. A value with no @ gives no key. |
linkedinUrl |
linkedin:jane-doe-example |
The profile slug from the URL, as profileSlug from outbound-sdk returns it. A URL with no slug gives no key. |
personKeys holds every key a lead has, email first. personKey is the first of them, or null for a lead with neither identifier. The subpath also exports the functions themselves: emailKey, linkedinKey, keysOf and toKey.
What it adds
| Member | Returns |
|---|---|
| Every lead | personKey: string | null and personKeys: string[], added at run time. The exported PersonLead type describes the result, but Lead does not declare the fields, so read them with an in check. |
outbound.person(identifier, options?) |
A Person. |
A Person has:
| Field | Meaning |
|---|---|
key |
The key that was looked up. |
keys |
Every key found on the matching records, sorted. |
found |
false when no record carries the key. The link is unknown, not proven absent. |
records |
One lead record per tool or campaign the person is in. |
timeline |
Every message to and from the person across those records, oldest first. Empty when timeline is false or the adapter does not declare leads.timeline. |
How the lookup reads
person() picks one of three paths from the instance’s capabilities:
- The vendor can find a lead by identifier (
leads.lookup), andcampaignIdsis not set. It asks for the person by each key. A record that carries a second key is looked up under that one too, so an email can lead to a profile and back. The vendor’s own matching is not trusted: only a record whose exact key matches is kept. - Otherwise, the adapter lists all leads (
leads.list), andcampaignIdsis not set. Every lead is walked. - Otherwise, every campaign is listed and each campaign’s leads are walked. With
campaignIds, only those campaigns.
On paths 2 and 3, a record that links a new key causes one more full walk, to catch records seen earlier under the other key.
Paths 2 and 3 read every lead they cover. On a large account that is many requests: pass campaignIds, or set maxRequests on the instance.
The timeline is one lead(ref).timeline() read per matching record, all started together.
What it changes
It changes no capability and nothing in meta.
With other plugins
List it after redact and before cache.
With redact on the same instance:
personKeyandpersonKeysare masked with the field they came from. A key keeps itsemail:orlinkedin:prefix, and withstrategy: "hash"the same person still gets the same key.person()is refused asUnsupportedwhileredactmasksemailorlinkedin. It links records by those identifiers, and on masked records it would link nothing. It never answersfound: falsefor that reason.
person() reads through the instance’s public API, so cache, budget, audit and failover apply to every read it makes.
When it fails
import { createOutbound, OutboundError } from "outbound-sdk";
import { identity } from "outbound-sdk/identity";
import { memory } from "outbound-sdk/memory";
import { seed } from "../plugins-index/seed.ts";
const outbound = createOutbound({
adapter: memory({ data: seed, workspaceId: "agency" }),
plugins: [identity()],
});
// 1. A name is not an exact identifier, so it is refused.
try {
await outbound.person("Jane Doe");
} catch (error) {
if (OutboundError.is(error)) {
console.log("a name:", error.code, "-", error.message);
}
}
// 2. Bounded to one campaign, and without the timeline.
const bounded = await outbound.person("email:priya.patel@example.com", {
campaignIds: ["memory:agency:cmp_founders"],
timeline: false,
});
console.log("wrong campaign:", bounded.found);
// 3. An adapter that cannot read a lead's timeline: the records still link.
const noTimeline = createOutbound({
adapter: memory({
capabilities: { "leads.timeline": false },
data: seed,
workspaceId: "agency",
}),
plugins: [identity()],
});
const jane = await noTimeline.person("jane.doe@example.com");
console.log("no timeline:", jane.records.length, jane.timeline.length);
a name: Invalid - person() needs a person key, an email address or a LinkedIn profile URL
wrong campaign: false
no timeline: 1 0
| What happened | What you get | What to do |
|---|---|---|
| The identifier is a name or other text | Invalid, before any read |
Pass an email address, a profile URL or a key. |
| No record carries the key | found: false, empty records and timeline |
Treat it as unknown. The person may exist under an identifier the vendor did not give you. |
redact on the instance masks email or linkedin |
Unsupported, before any read. The message names the plugin. |
Use an instance without redact, or leave both fields out of its fields. |
| The adapter cannot read a lead’s timeline | Records link, timeline is empty |
Check outbound.capabilities["leads.timeline"] before relying on the timeline. |
| A read fails part way | person() throws that error. No partial answer is returned. |
Retry, or bound the lookup with campaignIds. |