code.deepline.com包含需要注意的行为
SKILL DETAIL
deepline-ads-audiences
code.deepline.com/deepline-ads-audiences
此技能用于将第一方客户或潜在客户列表转化为高质量的 ABM 付费广告受众,并上传至 Google Customer Match、Meta/Facebook 自定义受众或 LinkedIn 匹配受众。它涵盖受众构建、丰富、审计和上传的完整流程,包括处理个人邮箱哈希、提高匹配率、规范化 LinkedIn URL 以及管理排除列表。 该技能提供多种工作流,包括默认的成本效益模式、最大覆盖率模式以及针对特定平台(如 Google 或 Facebook)的上传流程。它还包括可复制的剧本模板,用于可重复或可共享的工作流。使用前,请确保已安装 Deepline CLI,并运行工具搜索以确认最新的工具名称和负载格式。
安装量 · 126查看来源
Installation
npx skills add https://github.com/code.deepline.com --skill deepline-ads-audiences
技能文件
SKILL.md
最近同步 · 2026年8月29日
plays/audit-no-double-hash.play.ts›
import { definePlay } from 'deepline';
import {
auditHashRows,
normalizeSha256,
sha256Hex,
} from './shared/audience-hash';
type HashRow = Record<string, string | undefined>;
function collectHashes(row: HashRow, columns: string[]): string[] {
const hashes: string[] = [];
for (const column of columns) {
const hash = normalizeSha256(row[column]);
if (hash) hashes.push(hash);
}
return hashes;
}
export default definePlay(
'ads-audience-audit-no-double-hash',
async (
ctx,
input: {
payloadFile: string;
providerHashFile: string;
providerHashColumns?: string[];
},
) => {
const payloadDataset = await ctx.csv<HashRow>(input.payloadFile);
const providerDataset = await ctx.csv<HashRow>(input.providerHashFile);
const payloadRows = await payloadDataset.materialize();
const providerRows = await providerDataset.materialize();
const providerHashColumns = input.providerHashColumns ?? [
'email_sha256',
'personal_email_sha256',
'hashed_personal_email_sha256',
'aviato_hash',
'limadata_hash',
];
const payloadHashes = new Set(
payloadRows
.map((row) => normalizeSha256(row.email_sha256))
.filter((hash): hash is string => Boolean(hash)),
);
const providerHashes = new Set<string>();
providerRows.forEach((row) => {
collectHashes(row, providerHashColumns).forEach((hash) =>
providerHashes.add(hash),
);
});
const missingProviderHashes = Array.from(providerHashes).filter(
(hash) => !payloadHashes.has(hash),
);
const doubleHashedProviderHashes = Array.from(providerHashes)
.map((hash) => sha256Hex(hash))
.filter((hash) => payloadHashes.has(hash));
const audit = auditHashRows(payloadRows);
const ok =
audit.malformedHashes === 0 &&
audit.duplicateHashes === 0 &&
audit.rawEmailFieldsPresent === false &&
missingProviderHashes.length === 0 &&
doubleHashedProviderHashes.length === 0;
if (!ok) {
throw new Error(
JSON.stringify({
message: 'Audience hash audit failed.',
audit,
provider_hashes: providerHashes.size,
missing_provider_hashes: missingProviderHashes.length,
double_hashed_provider_hashes: doubleHashedProviderHashes.length,
missing_provider_hash_examples: missingProviderHashes.slice(0, 5),
double_hashed_provider_hash_examples:
doubleHashedProviderHashes.slice(0, 5),
}),
);
}
return {
ok: true,
audit,
provider_hashes: providerHashes.size,
missing_provider_hashes: 0,
double_hashed_provider_hashes: 0,
};
},
{
description:
'Verify a paid-ads upload payload is hash-only, deduped, and free of hash-of-hash mistakes before it reaches a platform.',
},
);
plays/build-contactout-hash-pool.play.ts›
import { definePlay } from 'deepline';
import { normalizeSha256 } from './shared/audience-hash';
/**
* ContactOut's hashed identifiers endpoint returns an unattributed pool of
* email hashes for a batch of LinkedIn URLs. It does not say which hash belongs
* to which profile, so the output is an audience-level hash pool rather than
* per-row enrichment.
*
* Contract details that drive this play:
* - 5-100 unique LinkedIn URLs per call; fewer than 5 is rejected with HTTP 400
* - one matched profile can return several hashes, so the hash count overstates
* matches; `matches_found` is the exact matched-profile count
* - a chunk where nothing matches returns HTTP 404 "No hashed emails found",
* which is a normal empty result rather than a failure
*/
const CONTACTOUT_MAX_BATCH = 100;
const CONTACTOUT_MIN_BATCH = 5;
type SourceRow = {
linkedin_url?: string;
person_linkedin_url?: string;
// Any column an earlier hash layer may have written. aviato_hash and
// limadata_hash are the names the other plays in this skill use.
existing_hash?: string;
email_sha256?: string;
personal_email_sha256?: string;
hashed_personal_email_sha256?: string;
aviato_hash?: string;
limadata_hash?: string;
};
type ChunkResult = {
chunk_index: number;
profiles_sent: number;
matches_found: number;
hashes_returned: number;
status: 'matched' | 'no_match';
};
function readLinkedInUrl(row: SourceRow): string | null {
const raw = row.person_linkedin_url ?? row.linkedin_url;
if (typeof raw !== 'string') return null;
const trimmed = raw.trim().replace(/\/+$/, '').split('?')[0];
if (!trimmed) return null;
return /linkedin\.com\/(in|pub)\//i.test(trimmed) ? trimmed : null;
}
/**
* Read any personal-email hash an earlier layer already wrote for this row.
*
* The column names matter. The hash providers in this skill write into named
* per-provider columns (`aviato_hash`, `limadata_hash`), which is also what the
* sibling build-hash-only and audit plays read. A filter that only checked
* `existing_hash`/`email_sha256` would find nothing on a real CSV from this
* workflow and would silently send every already-covered row to ContactOut.
*/
function readExistingHash(row: SourceRow): string | null {
const candidates: Array<unknown> = [
row.existing_hash,
row.email_sha256,
row.personal_email_sha256,
row.hashed_personal_email_sha256,
row.aviato_hash,
row.limadata_hash,
];
for (const candidate of candidates) {
const hash = normalizeSha256(candidate);
if (hash) return hash;
}
return null;
}
function chunkProfiles(profiles: string[]): string[][] {
const chunks: string[][] = [];
for (let i = 0; i < profiles.length; i += CONTACTOUT_MAX_BATCH) {
chunks.push(profiles.slice(i, i + CONTACTOUT_MAX_BATCH));
}
// ContactOut rejects a call with fewer than 5 profiles. If the final chunk is
// short, pull profiles back from the previous chunk so both stay valid.
if (chunks.length > 1) {
const last = chunks[chunks.length - 1];
if (last.length < CONTACTOUT_MIN_BATCH) {
const previous = chunks[chunks.length - 2];
const needed = CONTACTOUT_MIN_BATCH - last.length;
chunks[chunks.length - 1] = [...previous.splice(previous.length - needed, needed), ...last];
}
}
return chunks;
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object'
? (value as Record<string, unknown>)
: {};
}
/**
* ctx.tools.execute returns a wrapper, not the provider body. The provider
* response sits under toolResponse.raw. Reading the wrapper root returns
* nothing, which would report zero hashes after a call that already spent
* credits.
*/
function raw(result: unknown): Record<string, unknown> {
const wrapped = asRecord(result);
return asRecord(
asRecord(wrapped.toolResponse).raw ??
asRecord(wrapped.tool_response).raw ??
asRecord(wrapped.toolOutput).raw ??
wrapped.raw ??
wrapped.data ??
result,
);
}
function readHashes(result: unknown): string[] {
const emails = asRecord(raw(result).matches).emails;
if (!Array.isArray(emails)) return [];
return emails
.map((value) => normalizeSha256(value))
.filter((value): value is string => Boolean(value));
}
function readMatchesFound(result: unknown): number {
const count = raw(result).matches_found;
return typeof count === 'number' && Number.isInteger(count) && count > 0
? count
: 0;
}
export default definePlay(
'ads-audience-contactout-hash-pool',
async (
ctx,
input: {
file: string;
limit?: number;
/**
* Send every row with a LinkedIn URL, including rows that already carry a
* hash from an earlier provider.
*
* Off by default, which suits a cost-effective pass: those rows often
* resolve to hashes the pool already has, and ContactOut bills per
* matched profile whether or not the hash is new.
*
* Turn it on for max-coverage runs. Roughly a third of what ContactOut
* returns is a second or third address for a person it already matched
* (measured live: 228 matched profiles returned 313 hashes). Excluding a
* row because it has an Aviato or LimaData hash forfeits any different
* address ContactOut would have found for that same person, and more
* addresses per person means more chances a platform matches them.
*/
includeRowsWithExistingHash?: boolean;
},
) => {
const dataset = await ctx.csv<SourceRow>(input.file);
const allRows = await dataset.materialize();
const existingPool = new Set<string>();
const profiles: string[] = [];
const seenProfiles = new Set<string>();
let skippedAlreadyHashed = 0;
let skippedNoLinkedIn = 0;
for (const row of allRows) {
const existing = readExistingHash(row);
if (existing) existingPool.add(existing);
const url = readLinkedInUrl(row);
if (!url) {
skippedNoLinkedIn += 1;
continue;
}
// The response is an unattributed pool, so coverage cannot be reconciled
// per row after the call. Filtering the send set on what we already know
// is the only saving available.
if (existing && !input.includeRowsWithExistingHash) {
skippedAlreadyHashed += 1;
continue;
}
const key = url.toLowerCase();
if (seenProfiles.has(key)) continue;
seenProfiles.add(key);
profiles.push(url);
}
const limited =
typeof input.limit === 'number' && input.limit > 0
? profiles.slice(0, input.limit)
: profiles;
// Too few profiles to call ContactOut. This is a normal outcome, not a
// failure: it usually means the earlier hash layers already covered the
// list, which is the result you wanted. Throwing here would fail an
// audience build for succeeding. Return an empty contribution and let the
// caller see why.
if (limited.length < CONTACTOUT_MIN_BATCH) {
return {
source_rows: allRows.length,
unique_profiles_sent: 0,
skipped_already_hashed: skippedAlreadyHashed,
skipped_no_linkedin_url: skippedNoLinkedIn,
excluded_rows_may_hold_additional_addresses: skippedAlreadyHashed > 0,
chunks: 0,
matched_profiles: 0,
hashes_returned: 0,
existing_pool_hashes: existingPool.size,
net_new_hashes: 0,
chunk_results: [],
net_new_hash_pool: [],
skipped_reason: `Only ${limited.length} eligible profiles after filtering, below the ${CONTACTOUT_MIN_BATCH}-profile minimum. Nothing was sent and nothing was billed.`,
};
}
const chunks = chunkProfiles(limited);
const chunkResults: ChunkResult[] = [];
const contactoutHashes = new Set<string>();
let matchedProfiles = 0;
for (const [index, chunk] of chunks.entries()) {
const result = await ctx.tools.execute({
id: 'contactout_hashes',
tool: 'contactout_get_hashed_email_identifiers',
input: { profiles: chunk } as never,
description: 'Hash a batch of LinkedIn profiles into email identifiers',
});
const hashes = readHashes(result);
const found = readMatchesFound(result);
for (const hash of hashes) contactoutHashes.add(hash);
matchedProfiles += found;
chunkResults.push({
chunk_index: index,
profiles_sent: chunk.length,
matches_found: found,
hashes_returned: hashes.length,
status: hashes.length > 0 ? 'matched' : 'no_match',
});
}
const netNew = [...contactoutHashes].filter(
(hash) => !existingPool.has(hash),
);
return {
source_rows: allRows.length,
unique_profiles_sent: limited.length,
skipped_already_hashed: skippedAlreadyHashed,
skipped_no_linkedin_url: skippedNoLinkedIn,
// Those skipped rows may still have had a second personal address that
// ContactOut would have found. Re-run with includeRowsWithExistingHash
// when the goal is maximum coverage rather than lowest cost.
excluded_rows_may_hold_additional_addresses: skippedAlreadyHashed > 0,
chunks: chunkResults.length,
matched_profiles: matchedProfiles,
hashes_returned: contactoutHashes.size,
existing_pool_hashes: existingPool.size,
net_new_hashes: netNew.length,
// matched_profiles is the billing signal. hashes_returned is larger
// whenever a profile resolves to more than one hashed address, so it must
// not be used to report cost.
chunk_results: chunkResults,
net_new_hash_pool: netNew,
skipped_reason: null,
};
},
{
description:
'Resolve LinkedIn URLs into a deduped ContactOut email hash pool for paid ads audiences, reporting net-new hashes and matched-profile cost.',
},
);
plays/build-hash-only-audience.play.ts›
import { definePlay } from 'deepline';
import {
auditHashRows,
normalizeEmail,
normalizeSha256,
sha256Hex,
} from './shared/audience-hash';
type AudienceInputRow = {
source_row?: string;
person_id?: string;
work_email?: string;
email?: string;
personal_email?: string;
personal_emails?: string;
email_sha256?: string;
personal_email_sha256?: string;
hashed_personal_email_sha256?: string;
personal_email_hashes_sha256?: string;
email_hashes_sha256?: string;
aviato_hash?: string;
limadata_hash?: string;
first_name?: string;
last_name?: string;
company?: string;
company_name?: string;
domain?: string;
company_domain?: string;
linkedin_url?: string;
person_linkedin_url?: string;
};
type HashOnlyRow = {
email_sha256: string;
source_row?: string;
provider_used: string;
identifier_type: 'work_email' | 'provider_hash' | 'personal_email';
};
function firstHash(
row: AudienceInputRow,
): { hash: string; provider: string } | null {
const candidates: Array<[string, unknown]> = [
['email_sha256', row.email_sha256],
['personal_email_sha256', row.personal_email_sha256],
['hashed_personal_email_sha256', row.hashed_personal_email_sha256],
['aviato_hash', row.aviato_hash],
['limadata_hash', row.limadata_hash],
];
for (const [provider, value] of candidates) {
const hash = normalizeSha256(value);
if (hash) return { hash, provider };
}
return null;
}
function splitMultiValue(value: unknown): string[] {
if (Array.isArray(value)) return value.flatMap(splitMultiValue);
if (typeof value !== 'string') return [];
const trimmed = value.trim();
if (!trimmed) return [];
try {
const parsed = JSON.parse(trimmed) as unknown;
if (Array.isArray(parsed)) return parsed.flatMap(splitMultiValue);
} catch {
// Not JSON; fall through to delimiter split.
}
return trimmed
.split(/[;,|\n]/)
.map((item) => item.trim())
.filter(Boolean);
}
function allProviderHashes(row: AudienceInputRow): Array<{
hash: string;
provider: string;
}> {
const scalar = firstHash(row);
const output = scalar ? [scalar] : [];
const multiCandidates: Array<[string, unknown]> = [
['personal_email_hashes_sha256', row.personal_email_hashes_sha256],
['email_hashes_sha256', row.email_hashes_sha256],
];
for (const [provider, value] of multiCandidates) {
for (const item of splitMultiValue(value)) {
const hash = normalizeSha256(item);
if (hash) output.push({ hash, provider });
}
}
const seen = new Set<string>();
return output.filter(({ hash }) => {
if (seen.has(hash)) return false;
seen.add(hash);
return true;
});
}
function personalEmails(row: AudienceInputRow): string[] {
const output = [
row.personal_email,
...splitMultiValue(row.personal_emails),
].flatMap((value) => {
const normalized = normalizeEmail(value);
return normalized ? [normalized] : [];
});
return [...new Set(output)];
}
function sourceRow(row: AudienceInputRow, index: number): string {
return String(row.source_row || row.person_id || index);
}
function dedupe(rows: HashOnlyRow[]): HashOnlyRow[] {
const seen = new Set<string>();
const output: HashOnlyRow[] = [];
for (const row of rows) {
if (seen.has(row.email_sha256)) continue;
seen.add(row.email_sha256);
output.push(row);
}
return output;
}
export default definePlay(
'ads-audience-build-hash-only',
async (ctx, input: { file: string }) => {
const dataset = await ctx.csv<AudienceInputRow>(input.file);
const rows = await dataset.materialize();
const baseline: HashOnlyRow[] = [];
const enriched: HashOnlyRow[] = [];
rows.forEach((row, index) => {
const rowId = sourceRow(row, index);
const workEmail = normalizeEmail(row.work_email || row.email);
if (workEmail) {
const hash = sha256Hex(workEmail);
baseline.push({
email_sha256: hash,
source_row: rowId,
provider_used: 'work_email',
identifier_type: 'work_email',
});
enriched.push({
email_sha256: hash,
source_row: rowId,
provider_used: 'work_email',
identifier_type: 'work_email',
});
}
for (const providerHash of allProviderHashes(row)) {
enriched.push({
email_sha256: providerHash.hash,
source_row: rowId,
provider_used: providerHash.provider,
identifier_type: 'provider_hash',
});
}
for (const personalEmail of personalEmails(row)) {
enriched.push({
email_sha256: sha256Hex(personalEmail),
source_row: rowId,
provider_used: 'personal_email',
identifier_type: 'personal_email',
});
}
});
const baselineRows = dedupe(baseline);
const enrichedRows = dedupe(enriched);
const baselineAudit = auditHashRows(baselineRows);
const enrichedAudit = auditHashRows(enrichedRows);
const baselineDataset = await ctx
.dataset('baseline_hash_only_audience', baselineRows)
.run({ description: 'Baseline work-email hash-only audience rows.' });
const enrichedDataset = await ctx
.dataset('enriched_hash_only_audience', enrichedRows)
.run({ description: 'Enriched hash-only audience rows.' });
return {
baseline_rows: baselineDataset,
enriched_rows: enrichedDataset,
baseline_audit: baselineAudit,
enriched_audit: enrichedAudit,
unique_hash_lift: enrichedAudit.outputRows - baselineAudit.outputRows,
};
},
{
description:
'Build a validated hash-only paid-ads upload file from contact rows, normalizing and hashing identifiers exactly once.',
},
);
plays/enrich-audience-waterfall.play.ts›
import { definePlay } from 'deepline';
import { normalizeEmail, normalizeSha256, sha256Hex } from './shared/audience-hash';
/**
* Personal-identifier waterfall for paid-ads audiences.
*
* The rule that makes this a waterfall rather than a fan-out: each layer runs
* only on rows that still have no usable hash. A row covered by an earlier,
* cheaper layer is never sent to a later, dearer one.
*
* Skipping that rule is expensive in a way that looks fine in the logs. Every
* call still returns 200, so the run reads as healthy while paying several
* providers for the same person.
*
* ContactOut is the deliberate exception. Its response is an unattributed pool
* of hashes with no mapping back to input rows, so it cannot skip rows another
* provider covered and later providers cannot skip rows it covered. It runs as
* a bulk pass over everyone with a LinkedIn URL. See
* shared/contactout-hash-pool.md.
*/
const CONTACTOUT_MAX_BATCH = 100;
const CONTACTOUT_MIN_BATCH = 5;
type SourceRow = {
external_id?: string;
first_name?: string;
last_name?: string;
company_name?: string;
company_domain?: string;
work_email?: string;
person_linkedin_url?: string;
linkedin_url?: string;
// Any hash an earlier layer already wrote. These are the column names the
// other plays in this skill read and write.
email_sha256?: string;
personal_email_sha256?: string;
hashed_personal_email_sha256?: string;
personal_email_hashes_sha256?: string;
email_hashes_sha256?: string;
aviato_hash?: string;
limadata_hash?: string;
};
type EnrichedRow = SourceRow & {
personal_hash: string | null;
hash_source: string | null;
};
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object'
? (value as Record<string, unknown>)
: {};
}
/** ctx.tools.execute returns a wrapper; the provider body sits under toolResponse.raw. */
function raw(result: unknown): Record<string, unknown> {
const wrapped = asRecord(result);
return asRecord(
asRecord(wrapped.toolResponse).raw ??
asRecord(wrapped.tool_response).raw ??
asRecord(wrapped.toolOutput).raw ??
wrapped.raw ??
wrapped.data ??
result,
);
}
/**
* Any personal hash this row already carries, from any earlier layer.
*
* This list has to match what the sibling plays write, or a covered row reads
* as uncovered and gets sent through paid providers a second time. See
* firstHash in build-hash-only-audience.play.ts.
*/
function existingHash(row: SourceRow): string | null {
for (const candidate of [
row.email_sha256,
row.personal_email_sha256,
row.hashed_personal_email_sha256,
row.aviato_hash,
row.limadata_hash,
row.personal_email_hashes_sha256,
row.email_hashes_sha256,
]) {
const hash = normalizeSha256(candidate);
if (hash) return hash;
}
return null;
}
/**
* Validate on the parsed hostname, not a substring.
*
* A substring check accepts `https://evil.example/linkedin.com/in/person`.
* LimaData and ContactOut would reject that later, but LeadMagic's schema only
* requires a string, so a malformed row becomes a paid miss.
*/
function readLinkedInUrl(row: SourceRow): string | null {
const value = row.person_linkedin_url ?? row.linkedin_url;
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
let parsed: URL;
try {
parsed = new URL(trimmed.startsWith('http') ? trimmed : `https://${trimmed}`);
} catch {
return null;
}
const host = parsed.hostname.toLowerCase();
if (host !== 'linkedin.com' && !host.endsWith('.linkedin.com')) return null;
if (!/^\/(in|pub)\/[^/]+/i.test(parsed.pathname)) return null;
return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`;
}
/**
* Every hash a provider returned, not just the first.
*
* Aviato returns `hashedEmails[]` and LimaData returns `hashed_emails[]`, one
* entry per address it knows for that person. The call is already paid for, so
* taking only the first discards coverage that costs nothing more to keep. A
* person with several personal addresses gets several chances to match.
*/
function readProviderHashes(result: unknown): string[] {
const body = raw(result);
const data = asRecord(body.data ?? body);
const found: string[] = [];
const push = (value: unknown) => {
const hash = normalizeSha256(value);
if (hash && !found.includes(hash)) found.push(hash);
};
for (const candidate of [
data.hashed_email,
data.normalized_hash,
data.hash,
data.sha256,
asRecord(data.matched_result).hash,
]) {
push(candidate);
}
if (Array.isArray(data.hashedEmails)) {
for (const entry of data.hashedEmails) push(entry);
}
if (Array.isArray(data.hashed_emails)) {
for (const entry of data.hashed_emails) {
const record = asRecord(entry);
push(record.normalized_hash ?? record.hash ?? entry);
}
}
return found;
}
/** First hash only, for deciding whether a layer covered a row. */
function readProviderHash(result: unknown): string | null {
return readProviderHashes(result)[0] ?? null;
}
/**
* Pull a raw personal email and hash it once.
*
* Only fields explicitly typed as personal count. An untyped trailing email can
* be a work address, and uploading one as a personal hash quietly pollutes the
* audience with an identifier the platform already failed to match.
*/
function readPersonalEmailHashes(result: unknown): string[] {
const body = raw(result);
const data = asRecord(body.data ?? body);
const found: string[] = [];
const push = (value: unknown) => {
const email = normalizeEmail(value);
if (!email) return;
const hash = sha256Hex(email);
if (!found.includes(hash)) found.push(hash);
};
push(data.personal_email);
push(data.first_personal_email);
if (Array.isArray(data.personal_emails)) {
for (const entry of data.personal_emails) push(entry);
}
return found;
}
function readPersonalEmailHash(result: unknown): string | null {
return readPersonalEmailHashes(result)[0] ?? null;
}
function chunkProfiles(profiles: string[]): string[][] {
const chunks: string[][] = [];
for (let i = 0; i < profiles.length; i += CONTACTOUT_MAX_BATCH) {
chunks.push(profiles.slice(i, i + CONTACTOUT_MAX_BATCH));
}
// ContactOut rejects a call with fewer than 5 profiles, so a short tail chunk
// borrows from the one before it rather than failing the batch.
if (chunks.length > 1) {
const last = chunks[chunks.length - 1];
if (last.length < CONTACTOUT_MIN_BATCH) {
const previous = chunks[chunks.length - 2];
const needed = CONTACTOUT_MIN_BATCH - last.length;
chunks[chunks.length - 1] = [
...previous.splice(previous.length - needed, needed),
...last,
];
}
}
return chunks;
}
export default definePlay(
'ads-audience-enrich-waterfall',
async (
ctx,
input: {
file: string;
/** Buy raw personal emails for rows the hash layers missed. Costs more per row. */
includeRawEmailFallback?: boolean;
/** Run the ContactOut bulk pass. Needs LinkedIn URLs. */
includeContactOut?: boolean;
/** Send already-covered rows to ContactOut too, for max coverage. */
contactOutIncludeCoveredRows?: boolean;
},
) => {
const dataset = await ctx.csv<SourceRow>(input.file);
const sourceRows = await dataset.materialize();
const rows: EnrichedRow[] = sourceRows.map((row) => {
const hash = existingHash(row);
return {
...row,
personal_hash: hash,
hash_source: hash ? 'source_csv' : null,
};
});
const stats: Array<{
layer: string;
attempted: number;
hits: number;
skipped_already_covered: number;
}> = [];
// Each layer is a dataset column with runIf, so the runtime maps rows in
// parallel instead of a serial per-row loop, and runIf is what makes this a
// waterfall: a row already carrying a hash is skipped without a provider
// call. A serial loop over a few thousand rows would also be thousands of
// sequential round trips.
const enriched = await ctx
.dataset('audience_rows', rows)
.withColumn('aviato_result', {
// Aviato takes identifier fields only and is additionalProperties:false,
// so name and domain cannot be sent. Seed with a LinkedIn URL or an email.
runIf: (row) =>
!row.personal_hash &&
Boolean(readLinkedInUrl(row) || normalizeEmail(row.work_email)),
run: ({ row, ctx: rowCtx }) => {
const linkedinUrl = readLinkedInUrl(row);
const workEmail = normalizeEmail(row.work_email);
return rowCtx.tools.execute({
id: 'aviato_hash',
tool: 'aviato_pull_email_hash',
input: (linkedinUrl
? { linkedinURL: linkedinUrl }
: { email: workEmail }) as never,
description: 'Pull a hashed personal email for one contact',
});
},
})
.withColumn('limadata_result', {
// LimaData accepts linkedin_url and work_email only.
runIf: (row) =>
!row.personal_hash &&
!readProviderHash((row as Record<string, unknown>).aviato_result) &&
Boolean(readLinkedInUrl(row) || normalizeEmail(row.work_email)),
run: ({ row, ctx: rowCtx }) => {
const linkedinUrl = readLinkedInUrl(row);
const workEmail = normalizeEmail(row.work_email);
return rowCtx.tools.execute({
id: 'limadata_hash',
tool: 'limadata_find_audience_identifiers',
input: {
...(linkedinUrl ? { linkedin_url: linkedinUrl } : {}),
...(workEmail ? { work_email: workEmail } : {}),
} as never,
description: 'Find hashed audience identifiers for one contact',
});
},
})
.withColumn('leadmagic_result', {
// The dearest layer, so it is opt-in and only sees rows the hash layers
// missed. LeadMagic takes exactly one required field, profile_url.
runIf: (row) =>
Boolean(input.includeRawEmailFallback) &&
!row.personal_hash &&
!readProviderHash((row as Record<string, unknown>).aviato_result) &&
!readProviderHash((row as Record<string, unknown>).limadata_result) &&
Boolean(readLinkedInUrl(row)),
run: ({ row, ctx: rowCtx }) =>
rowCtx.tools.execute({
id: 'leadmagic_personal_email',
tool: 'leadmagic_personal_email_finder',
input: { profile_url: readLinkedInUrl(row) } as never,
description: 'Find a raw personal email for one contact',
}),
})
.run({ description: 'Personal-identifier waterfall, cheapest layer first' });
const enrichedRows = await enriched.materialize();
// Fold each layer's result into the row, cheapest source winning.
for (const row of enrichedRows as unknown as Array<
EnrichedRow & Record<string, unknown>
>) {
if (row.personal_hash) continue;
const aviato = readProviderHash(row.aviato_result);
if (aviato) {
row.personal_hash = aviato;
row.hash_source = 'aviato';
continue;
}
const lima = readProviderHash(row.limadata_result);
if (lima) {
row.personal_hash = lima;
row.hash_source = 'limadata';
continue;
}
const leadmagic = readPersonalEmailHash(row.leadmagic_result);
if (leadmagic) {
row.personal_hash = leadmagic;
row.hash_source = 'leadmagic_personal_email';
}
}
const layerRows = enrichedRows as unknown as Array<
EnrichedRow & Record<string, unknown>
>;
for (const [layer, column] of [
['aviato_hash', 'aviato_result'],
['limadata_hash', 'limadata_result'],
['leadmagic_personal_email', 'leadmagic_result'],
] as const) {
const attempted = layerRows.filter(
(row) => row[column] !== null && row[column] !== undefined,
).length;
const hits = layerRows.filter(
(row) => row.hash_source === layer.replace('_hash', ''),
).length;
stats.push({
layer,
attempted,
hits,
skipped_already_covered: layerRows.length - attempted,
});
}
rows.length = 0;
rows.push(...(layerRows as unknown as EnrichedRow[]));
// --- ContactOut: bulk pass, not a waterfall step -----------------------
// Deliberately not filtered on coverage by default. The response is an
// unattributed pool, so its hashes join the audience-level pool rather than
// per-row cells, and it is recommended for everyone with a LinkedIn URL.
const contactOutHashes = new Set<string>();
let contactOutMatched = 0;
let contactOutSent = 0;
if (input.includeContactOut) {
const seen = new Set<string>();
const profiles: string[] = [];
for (const row of rows) {
const url = readLinkedInUrl(row);
if (!url) continue;
if (!input.contactOutIncludeCoveredRows && row.hash_source === 'source_csv') {
// Rows that arrived already hashed are the one cheap exclusion: they
// were covered before this run started. Rows covered by a layer above
// still go, because ContactOut often returns a second address for a
// person another provider already matched.
continue;
}
const key = url.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
profiles.push(url);
}
if (profiles.length >= CONTACTOUT_MIN_BATCH) {
contactOutSent = profiles.length;
for (const chunk of chunkProfiles(profiles)) {
const result = await ctx.tools.execute({
id: 'contactout_hashes',
tool: 'contactout_get_hashed_email_identifiers',
input: { profiles: chunk } as never,
description: 'Hash a batch of LinkedIn profiles into email identifiers',
});
const body = raw(result);
const emails = asRecord(body.matches).emails;
if (Array.isArray(emails)) {
for (const value of emails) {
const hash = normalizeSha256(value);
if (hash) contactOutHashes.add(hash);
}
}
const found = asRecord(body).matches_found;
if (typeof found === 'number' && Number.isInteger(found) && found > 0) {
contactOutMatched += found;
}
}
}
}
const perRowHashes = rows
.map((row) => row.personal_hash)
.filter((hash): hash is string => Boolean(hash));
const pool = new Set(perRowHashes);
// Providers often return several addresses for one person. The row keeps
// one hash for lineage, but every hash bought belongs in the audience pool:
// more addresses per person means more chances a platform matches them, and
// these cost nothing beyond the call already made.
for (const row of layerRows) {
for (const extra of [
...readProviderHashes(row.aviato_result),
...readProviderHashes(row.limadata_result),
...readPersonalEmailHashes(row.leadmagic_result),
]) {
pool.add(extra);
}
}
const contactOutNetNew = [...contactOutHashes].filter(
(hash) => !pool.has(hash),
);
for (const hash of contactOutHashes) pool.add(hash);
return {
source_rows: rows.length,
rows_with_hash: perRowHashes.length,
rows_still_missing: rows.length - perRowHashes.length,
layers: stats,
contactout: {
profiles_sent: contactOutSent,
matched_profiles: contactOutMatched,
hashes_returned: contactOutHashes.size,
net_new_hashes: contactOutNetNew.length,
},
audience_hash_pool_size: pool.size,
// Slim, hash-only rows. The dataset carries a full tool envelope per
// provider per row, and the LeadMagic envelope holds the raw personal
// email this play otherwise only ever hashes. Returning those rows
// verbatim would leak purchased personal emails into the play output and
// could also cross the output-size ceiling on a few thousand rows.
enriched_rows: rows.map((row) => ({
external_id: row.external_id ?? null,
company_domain: row.company_domain ?? null,
person_linkedin_url: readLinkedInUrl(row),
personal_hash: row.personal_hash,
hash_source: row.hash_source,
})),
audience_hash_pool: [...pool],
};
},
{
description:
'Enrich a contact list for paid ads audiences: cheap hash layers first, each running only on rows still missing a hash, with ContactOut as a bulk pass over everyone with a LinkedIn URL.',
},
);
plays/evaluate-leadmagic-personal-email.play.ts›
import { definePlay } from 'deepline';
import { normalizeEmail } from './shared/audience-hash';
type SourceRow = {
contact_id: string;
first_name?: string;
last_name?: string;
linkedin_url?: string;
account_name?: string;
domain?: string;
title?: string;
lead_score?: string;
};
type EvaluatedRow = SourceRow & {
provider: 'leadmagic_personal_email_finder';
personal_emails: string;
personal_email_count: number;
accepted: boolean;
};
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object'
? (value as Record<string, unknown>)
: {};
}
function raw(result: unknown): Record<string, unknown> {
const wrapped = asRecord(result);
return asRecord(asRecord(wrapped.toolResponse).raw ?? wrapped);
}
function extractPersonalEmails(result: unknown): string[] {
const data = raw(result);
const candidates = [
data.personal_email,
data.first_personal_email,
...(Array.isArray(data.personal_emails) ? data.personal_emails : []),
];
return [
...new Set(
candidates
.map((value) => normalizeEmail(value))
.filter((email): email is string => Boolean(email)),
),
];
}
export default definePlay(
'ads-audience-leadmagic-eval',
async (
ctx,
input: {
file: string;
limit?: number;
},
) => {
const rows = (await ctx.csv<SourceRow>(input.file))
.filter((row) => Boolean(row.contact_id && row.linkedin_url))
.slice(0, Math.max(1, Math.min(input.limit ?? 25, 500)));
const rowCount = await rows.count();
const evaluated = await ctx
.dataset('leadmagic_eval', rows)
.withColumn('leadmagic_result', (row, rowCtx) =>
rowCtx.tools.execute({
id: 'leadmagic_personal_email_finder',
tool: 'leadmagic_personal_email_finder',
input: { profile_url: String(row.linkedin_url) },
description:
'Evaluate LeadMagic personal-email finder for one remaining ads-audience miss.',
}),
)
.withColumn('evaluated', (row) => {
const emails = extractPersonalEmails(row.leadmagic_result);
return {
...row,
provider: 'leadmagic_personal_email_finder',
personal_emails: emails.join(';'),
personal_email_count: emails.length,
accepted: emails.length > 0,
} satisfies EvaluatedRow;
})
.run({
key: 'contact_id',
description:
'Evaluate LeadMagic personal-email finder on remaining paid-audience misses.',
});
const previewRows = (await evaluated.materialize(500)) as Array<{
evaluated?: EvaluatedRow;
}>;
const rowsOut = previewRows
.map((row) => row.evaluated)
.filter((row): row is EvaluatedRow => Boolean(row));
const hitRows = rowsOut.filter((row) => row.accepted);
return {
provider: 'leadmagic_personal_email_finder',
attempted_rows: rowCount,
validated_contacts: hitRows.length,
validated_personal_emails: hitRows.reduce(
(sum, row) => sum + row.personal_email_count,
0,
),
hit_rate_pct: rowCount === 0 ? 0 : (hitRows.length * 100) / rowCount,
deepline_usd_per_result: 0.068,
estimated_deepline_cost_per_incremental_validated_contact: 0.068,
evaluated_rows: evaluated,
};
},
{
description:
'Measure LeadMagic personal-email coverage and cost per hit on a sample of contacts before committing to a full run.',
},
);
plays/evaluate-personal-email-provider.play.ts›
import { definePlay } from 'deepline';
import { normalizeEmail, sha256Hex } from './shared/audience-hash';
type SourceRow = {
contact_id: string;
first_name?: string;
last_name?: string;
full_name?: string;
linkedin_url?: string;
work_email?: string;
account_name?: string;
domain?: string;
title?: string;
lead_score?: string;
};
type ProviderName =
| 'leadmagic_personal_email_finder'
| 'crustdata_v2_enrich_person'
| 'contactout_enrich_person'
| 'datagma_full_enrichment'
| 'enformion_person_search'
| 'fullenrich_bulk_enrich'
| 'limadata_find_personal_email'
| 'lusha_enrich_person'
| 'wiza_reveal_person';
type EvaluatedRow = SourceRow & {
provider: ProviderName;
validated_personal_email_hashes_sha256: string;
validated_hash_count: number;
candidate_email_count: number;
accepted: boolean;
rejection_reason?: string;
};
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object'
? (value as Record<string, unknown>)
: {};
}
function raw(result: unknown): Record<string, unknown> {
const wrapped = asRecord(result);
return asRecord(
asRecord(wrapped.toolResponse).raw ??
asRecord(wrapped.tool_response).raw ??
wrapped.raw ??
result,
);
}
function stringValue(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function arrayValue(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function hashEmails(emails: string[]): string[] {
return [...new Set(emails.map((email) => sha256Hex(email)))];
}
function emailsFromCandidates(candidates: unknown[]): string[] {
return [
...new Set(
candidates
.map((value) => normalizeEmail(stringValue(value)))
.filter((email): email is string => Boolean(email)),
),
];
}
function collectValuesByKey(value: unknown, keyMatcher: RegExp): unknown[] {
if (Array.isArray(value)) {
return value.flatMap((item) => collectValuesByKey(item, keyMatcher));
}
if (!value || typeof value !== 'object') return [];
const output: unknown[] = [];
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
if (keyMatcher.test(key)) output.push(child);
output.push(...collectValuesByKey(child, keyMatcher));
}
return output;
}
function flattenValues(values: unknown[]): unknown[] {
const output: unknown[] = [];
for (const value of values) {
if (Array.isArray(value)) output.push(...flattenValues(value));
else output.push(value);
}
return output;
}
function leadmagicEmails(result: unknown): string[] {
const data = raw(result);
return emailsFromCandidates([
data.personal_email,
data.first_personal_email,
...arrayValue(data.personal_emails),
]);
}
function crustdataPersonalEmails(result: unknown): string[] {
const data = raw(result);
const rows = Array.isArray(data) ? data : [data, objectValue(data.data)];
const candidates = rows.flatMap((row) => {
const contact = objectValue(objectValue(row).personal_contact_info);
return arrayValue(contact.personal_emails);
});
return emailsFromCandidates(candidates);
}
function contactoutPersonalEmails(result: unknown): string[] {
const data = raw(result);
return emailsFromCandidates([
data.personal_email,
...arrayValue(data.personal_email),
]);
}
function datagmaPersonalEmails(result: unknown): string[] {
const data = raw(result);
return emailsFromCandidates(
flattenValues(collectValuesByKey(data, /personal.*email|email.*personal/i)),
);
}
function enformionPersonalEmails(result: unknown): string[] {
const data = raw(result);
const candidates: unknown[] = [];
for (const person of arrayValue(data.persons)) {
for (const email of arrayValue(objectValue(person).emailAddresses)) {
const row = objectValue(email);
if (row.nonBusiness === 1 || row.nonBusiness === true) {
candidates.push(row.emailAddress);
}
}
}
return emailsFromCandidates(candidates);
}
function fullenrichPersonalEmails(result: unknown): string[] {
const data = raw(result);
return emailsFromCandidates(
flattenValues(collectValuesByKey(data, /personal.*email/i)),
);
}
function lushaPersonalEmails(result: unknown): string[] {
const data = raw(result);
const output: string[] = [];
for (const item of [
...arrayValue(data.emails),
...arrayValue(data.emailAddresses),
]) {
const row = asRecord(item);
const type = String(row.type ?? row.emailType ?? '').toLowerCase();
const email = normalizeEmail(stringValue(row.email));
if (email && /personal|private|home/.test(type)) output.push(email);
}
return [...new Set(output)];
}
function wizaPersonalEmails(result: unknown): string[] {
const data = raw(result);
return emailsFromCandidates([
data.personal_email,
data.personal_email1,
data.personal_email2,
data.personal_email3,
...arrayValue(data.personal_emails),
]);
}
function providerInput(provider: ProviderName, row: SourceRow): Record<string, unknown> {
if (provider === 'leadmagic_personal_email_finder') {
return { profile_url: row.linkedin_url };
}
if (provider === 'crustdata_v2_enrich_person') {
return {
linkedin_profile_url: row.linkedin_url,
fields: 'personal_contact_info.personal_emails',
};
}
if (provider === 'contactout_enrich_person') {
return {
linkedin_url: row.linkedin_url,
include: ['personal_email'],
};
}
if (provider === 'datagma_full_enrichment') {
return {
data: row.linkedin_url || row.work_email,
fullName:
row.full_name ||
[row.first_name, row.last_name].filter(Boolean).join(' '),
company: row.account_name || row.domain,
};
}
if (provider === 'enformion_person_search') {
return {
first_name: row.first_name,
last_name: row.last_name,
};
}
if (provider === 'fullenrich_bulk_enrich') {
return {
name: `ads-audience-personal-email-${row.contact_id}`,
wait_for_completion: true,
max_wait_ms: 120000,
data: [
{
first_name: row.first_name,
last_name: row.last_name,
domain: row.domain,
company_name: row.account_name,
linkedin_url: row.linkedin_url,
enrich_fields: ['contact.personal_emails'],
custom: { contact_id: row.contact_id },
},
],
};
}
if (provider === 'limadata_find_personal_email') {
return {
linkedin_url: row.linkedin_url,
work_email: row.work_email,
};
}
if (provider === 'lusha_enrich_person') {
return {
linkedin_url: row.linkedin_url,
first_name: row.first_name,
last_name: row.last_name,
company_name: row.account_name,
company_domain: row.domain,
reveal_emails: true,
reveal_phones: false,
};
}
return {
linkedin_url: row.linkedin_url,
full_name:
row.full_name ||
[row.first_name, row.last_name].filter(Boolean).join(' '),
company_name: row.account_name,
company_domain: row.domain,
enrichment_level: 'partial',
};
}
function providerDescription(provider: ProviderName): string {
return `Evaluate ${provider} personal emails for one remaining ads-audience miss.`;
}
function extractEmails(provider: ProviderName, result: unknown): string[] {
if (provider === 'leadmagic_personal_email_finder') {
return leadmagicEmails(result);
}
if (provider === 'crustdata_v2_enrich_person') {
return crustdataPersonalEmails(result);
}
if (provider === 'contactout_enrich_person') {
return contactoutPersonalEmails(result);
}
if (provider === 'datagma_full_enrichment') {
return datagmaPersonalEmails(result);
}
if (provider === 'enformion_person_search') {
return enformionPersonalEmails(result);
}
if (provider === 'fullenrich_bulk_enrich') {
return fullenrichPersonalEmails(result);
}
if (provider === 'limadata_find_personal_email') {
return emailsFromCandidates(arrayValue(raw(result).emails));
}
if (provider === 'lusha_enrich_person') {
return lushaPersonalEmails(result);
}
return wizaPersonalEmails(result);
}
export default definePlay(
'ads-audience-provider-eval',
async (
ctx,
input: {
file: string;
provider: ProviderName;
limit?: number;
},
) => {
const rows = (await ctx.csv<SourceRow>(input.file))
.filter((row) => Boolean(row.contact_id && row.linkedin_url))
.slice(0, Math.max(1, Math.min(input.limit ?? 25, 500)));
const rowCount = await rows.count();
const evaluated = await ctx
.dataset('eval_rows', rows)
.withColumn('provider_result', (row, rowCtx) => {
if (input.provider === 'leadmagic_personal_email_finder') {
return rowCtx.tools.execute({
id: 'leadmagic_personal_email_finder',
tool: 'leadmagic_personal_email_finder',
input: providerInput(input.provider, row) as never,
description: providerDescription(input.provider),
}) as Promise<unknown>;
}
if (input.provider === 'crustdata_v2_enrich_person') {
return rowCtx.tools.execute({
id: 'crustdata_v2_enrich_person',
tool: 'crustdata_v2_enrich_person',
input: providerInput(input.provider, row) as never,
description: providerDescription(input.provider),
}) as Promise<unknown>;
}
if (input.provider === 'contactout_enrich_person') {
return rowCtx.tools.execute({
id: 'contactout_enrich_person',
tool: 'contactout_enrich_person',
input: providerInput(input.provider, row) as never,
description: providerDescription(input.provider),
}) as Promise<unknown>;
}
if (input.provider === 'datagma_full_enrichment') {
return rowCtx.tools.execute({
id: 'datagma_full_enrichment',
tool: 'datagma_full_enrichment',
input: providerInput(input.provider, row) as never,
description: providerDescription(input.provider),
}) as Promise<unknown>;
}
if (input.provider === 'enformion_person_search') {
return rowCtx.tools.execute({
id: 'enformion_person_search',
tool: 'enformion_person_search',
input: providerInput(input.provider, row) as never,
description: providerDescription(input.provider),
}) as Promise<unknown>;
}
if (input.provider === 'fullenrich_bulk_enrich') {
return rowCtx.tools.execute({
id: 'fullenrich_bulk_enrich',
tool: 'fullenrich_bulk_enrich',
input: providerInput(input.provider, row) as never,
description: providerDescription(input.provider),
}) as Promise<unknown>;
}
if (input.provider === 'limadata_find_personal_email') {
return rowCtx.tools.execute({
id: 'limadata_find_personal_email',
tool: 'limadata_find_personal_email',
input: providerInput(input.provider, row) as never,
description: providerDescription(input.provider),
}) as Promise<unknown>;
}
if (input.provider === 'lusha_enrich_person') {
return rowCtx.tools.execute({
id: 'lusha_enrich_person',
tool: 'lusha_enrich_person',
input: providerInput(input.provider, row) as never,
description: providerDescription(input.provider),
}) as Promise<unknown>;
}
return rowCtx.tools.execute({
id: 'wiza_reveal_person',
tool: 'wiza_reveal_person',
input: providerInput(input.provider, row) as never,
description: providerDescription(input.provider),
}) as Promise<unknown>;
})
.withColumn('evaluated', (row) => {
const emails = extractEmails(input.provider, row.provider_result);
const hashes = hashEmails(emails);
return {
...row,
provider: input.provider,
validated_personal_email_hashes_sha256: hashes.join(';'),
validated_hash_count: hashes.length,
candidate_email_count: emails.length,
accepted: hashes.length > 0,
rejection_reason: hashes.length
? undefined
: 'no_valid_personal_email',
} satisfies EvaluatedRow;
})
.run({
key: 'contact_id',
description:
'Evaluate one personal-email provider on remaining paid-audience misses.',
});
const outputRows = (await evaluated.materialize(500)) as Array<{
evaluated?: EvaluatedRow;
}>;
const rowsOut = outputRows
.map((row) => row.evaluated)
.filter((row): row is EvaluatedRow => Boolean(row));
const hitRows = rowsOut.filter((row) => row.accepted);
const hashes = new Set(
hitRows.flatMap((row) =>
row.validated_personal_email_hashes_sha256
.split(';')
.filter(Boolean),
),
);
return {
provider: input.provider,
attempted_rows: rowCount,
validated_contacts: hitRows.length,
validated_hashes: hashes.size,
hit_rate_pct:
rowCount === 0 ? 0 : (hitRows.length * 100) / rowCount,
evaluated_rows: evaluated,
};
},
{
description:
'Compare personal-email providers on the same contact sample, reporting hit rate, unique hashes added, and Deepline spend.',
},
);
plays/report-google-coverage-lift.play.ts›
import { definePlay } from 'deepline';
type AudienceMetric = {
key?: string;
label: string;
audience_id: string;
match_rate_pct: number;
uploaded_rows?: number;
source_coverage_pct?: number;
deepline_spend_usd?: number;
notes?: string;
};
type SpendBreakdown = {
low_cost_hash_usd?: number;
contact_fallback_usd?: number;
linkedin_refresh_usd?: number;
total_usd?: number;
};
type Input = {
recipient_name?: string;
account_name: string;
account_id: string;
segment_name: string;
source_rows?: number;
baseline: AudienceMetric;
comparisons: AudienceMetric[];
spend?: SpendBreakdown;
recommendation_label?: string;
};
type ReportRow = {
label: string;
audience_id: string;
match_rate_pct: number;
delta_points_vs_baseline: number;
relative_lift_vs_baseline_pct: number;
uploaded_rows?: number;
estimated_matched_identifiers?: number;
incremental_matched_identifiers_vs_baseline?: number;
deepline_spend_usd?: number;
cost_per_incremental_matched_identifier_usd?: number;
notes?: string;
};
function round(value: number, places = 2): number {
const factor = 10 ** places;
return Math.round(value * factor) / factor;
}
function formatPct(value: number): string {
return `${round(value, 1)}%`;
}
function formatPoints(value: number): string {
return `${round(value, 1)} point${Math.abs(round(value, 1)) === 1 ? '' : 's'}`;
}
function formatMoney(value: number | undefined): string {
if (value === undefined || !Number.isFinite(value)) return 'not specified';
return `$${round(value, 2).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
}
function estimatedMatched(audience: AudienceMetric): number | undefined {
if (audience.uploaded_rows === undefined) return undefined;
return Math.round(audience.uploaded_rows * (audience.match_rate_pct / 100));
}
function totalSpend(spend: SpendBreakdown | undefined): number | undefined {
if (!spend) return undefined;
if (spend.total_usd !== undefined) return spend.total_usd;
const parts = [
spend.low_cost_hash_usd,
spend.contact_fallback_usd,
spend.linkedin_refresh_usd,
].filter((value): value is number => value !== undefined);
if (parts.length === 0) return undefined;
return parts.reduce((sum, value) => sum + value, 0);
}
function buildReportRow(
baseline: AudienceMetric,
audience: AudienceMetric,
): ReportRow {
const baselineMatched = estimatedMatched(baseline);
const matched = estimatedMatched(audience);
const incrementalMatched =
baselineMatched !== undefined && matched !== undefined
? matched - baselineMatched
: undefined;
const deltaPoints = audience.match_rate_pct - baseline.match_rate_pct;
const relativeLift =
baseline.match_rate_pct > 0
? (deltaPoints / baseline.match_rate_pct) * 100
: 0;
const spend = audience.deepline_spend_usd;
const costPerIncremental =
spend !== undefined &&
incrementalMatched !== undefined &&
incrementalMatched > 0
? spend / incrementalMatched
: undefined;
return {
label: audience.label,
audience_id: audience.audience_id,
match_rate_pct: audience.match_rate_pct,
delta_points_vs_baseline: round(deltaPoints, 2),
relative_lift_vs_baseline_pct: round(relativeLift, 2),
uploaded_rows: audience.uploaded_rows,
estimated_matched_identifiers: matched,
incremental_matched_identifiers_vs_baseline: incrementalMatched,
deepline_spend_usd: spend,
cost_per_incremental_matched_identifier_usd:
costPerIncremental === undefined ? undefined : round(costPerIncremental),
notes: audience.notes,
};
}
function bestRow(rows: ReportRow[]): ReportRow {
return rows.reduce((best, row) =>
row.match_rate_pct > best.match_rate_pct ? row : best,
);
}
function spendSentence(spend: SpendBreakdown | undefined): string {
const total = totalSpend(spend);
if (!spend || total === undefined) {
return 'I do not have a complete Deepline spend total attached to this report.';
}
const parts: string[] = [];
if (spend.low_cost_hash_usd !== undefined) {
parts.push(`low-cost hash pass: ${formatMoney(spend.low_cost_hash_usd)}`);
}
if (spend.contact_fallback_usd !== undefined) {
parts.push(
`expanded contact fallback: ${formatMoney(spend.contact_fallback_usd)}`,
);
}
if (spend.linkedin_refresh_usd !== undefined) {
parts.push(`LinkedIn refresh: ${formatMoney(spend.linkedin_refresh_usd)}`);
}
return `Deepline spend recorded for the workflow was ${formatMoney(total)}${
parts.length ? ` (${parts.join('; ')})` : ''
}.`;
}
function buildMessage(input: Input, rows: ReportRow[]): string {
const baseline = input.baseline;
const best = bestRow(rows);
const recommendation =
input.recommendation_label || best.label.replace(/^L\d+\s*/i, '').trim();
const baselineMatched = estimatedMatched(baseline);
const bestIncremental = best.incremental_matched_identifiers_vs_baseline;
const spendTotal = totalSpend(input.spend);
const costPerIncremental =
spendTotal !== undefined &&
bestIncremental !== undefined &&
bestIncremental > 0
? spendTotal / bestIncremental
: undefined;
const lines = [
`${input.recipient_name ?? 'Team'} - quick follow-up on the Google Ads audience coverage test for ${input.segment_name}.`,
'',
`We uploaded the QA audiences into ${input.account_name} (${input.account_id}). Baseline work-email hashes matched at ${formatPct(
baseline.match_rate_pct,
)}${baseline.uploaded_rows ? ` across ${baseline.uploaded_rows.toLocaleString()} uploaded rows` : ''}.`,
`The best enriched audience was ${best.label} (${best.audience_id}) at ${formatPct(
best.match_rate_pct,
)}, a +${formatPoints(best.delta_points_vs_baseline)} lift versus baseline.`,
];
if (baselineMatched !== undefined && best.estimated_matched_identifiers) {
lines.push(
`That is roughly ${best.estimated_matched_identifiers.toLocaleString()} matched identifiers versus about ${baselineMatched.toLocaleString()} on the baseline, or about ${(
bestIncremental ?? 0
).toLocaleString()} incremental matched identifiers.`,
);
}
lines.push(spendSentence(input.spend));
if (costPerIncremental !== undefined) {
lines.push(
`Using the full recorded spend, the blended cost was about ${formatMoney(
costPerIncremental,
)} per incremental matched identifier versus the baseline.`,
);
}
lines.push(
'',
'The important takeaway: work-email-only upload was materially underpowered. The cheap LimaData/Aviato hash pass gave the first large jump, and the LinkedIn-refresh + Lima/Aviato variant produced the strongest Google result. Source details did not improve the Google match rate in this test, so I would keep the production default hash-only unless we need those fields for QA.',
'',
`Recommendation: use ${recommendation} for the first campaign test and keep the work-hash-only list as the holdout/baseline comparison.`,
'',
'Audience readout:',
...rows.map(
(row) =>
`- ${row.label}: ${row.audience_id}, ${formatPct(
row.match_rate_pct,
)} match, ${row.delta_points_vs_baseline >= 0 ? '+' : ''}${formatPoints(
row.delta_points_vs_baseline,
)} vs baseline${
row.uploaded_rows
? `, ${row.uploaded_rows.toLocaleString()} uploaded rows`
: ''
}`,
),
);
return lines.join('\n');
}
export default definePlay(
'ads-audience-report-google-coverage-lift',
async (ctx, input: Input) => {
if (!input.comparisons.length) {
throw new Error('At least one comparison audience is required.');
}
const rows = [
buildReportRow(input.baseline, input.baseline),
...input.comparisons.map((audience) =>
buildReportRow(input.baseline, audience),
),
];
const best = bestRow(rows);
const spendTotal = totalSpend(input.spend);
const reportRows = await ctx
.dataset('coverage_lift', rows)
.run({ description: 'Google Ads audience match-rate lift report.' });
return {
account_name: input.account_name,
account_id: input.account_id,
segment_name: input.segment_name,
source_rows: input.source_rows,
baseline_match_rate_pct: input.baseline.match_rate_pct,
best_audience_label: best.label,
best_audience_id: best.audience_id,
best_match_rate_pct: best.match_rate_pct,
best_delta_points_vs_baseline: best.delta_points_vs_baseline,
deepline_spend_usd: spendTotal,
report_rows: reportRows,
follow_up_message: buildMessage(input, rows),
};
},
{
description:
'Calculate Google Customer Match coverage lift, estimated matched identifiers, and spend efficiency against a baseline audience.',
},
);
plays/shared/audience-hash.ts›
export type HashAudit = {
inputRows: number;
outputRows: number;
malformedHashes: number;
duplicateHashes: number;
rawEmailFieldsPresent: boolean;
};
export type HashOnlyUploadRow = {
email_sha256: string;
};
type HashInputRow = {
email_sha256?: unknown;
email?: unknown;
};
const SHA256_K = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
];
declare const TextEncoder: {
new (): { encode(value: string): ArrayLike<number> };
};
function rightRotate(value: number, shift: number): number {
return (value >>> shift) | (value << (32 - shift));
}
function utf8Bytes(value: string): number[] {
return Array.from(new TextEncoder().encode(value));
}
export function sha256Hex(value: string): string {
const bytes = utf8Bytes(value);
const bitLength = bytes.length * 8;
bytes.push(0x80);
while (bytes.length % 64 !== 56) bytes.push(0);
for (let shift = 56; shift >= 0; shift -= 8) {
bytes.push(Math.floor(bitLength / 2 ** shift) & 0xff);
}
let h0 = 0x6a09e667;
let h1 = 0xbb67ae85;
let h2 = 0x3c6ef372;
let h3 = 0xa54ff53a;
let h4 = 0x510e527f;
let h5 = 0x9b05688c;
let h6 = 0x1f83d9ab;
let h7 = 0x5be0cd19;
for (let offset = 0; offset < bytes.length; offset += 64) {
const words = Array.from({ length: 64 }, () => 0);
for (let index = 0; index < 16; index += 1) {
const i = offset + index * 4;
words[index] =
((bytes[i] ?? 0) << 24) |
((bytes[i + 1] ?? 0) << 16) |
((bytes[i + 2] ?? 0) << 8) |
(bytes[i + 3] ?? 0);
}
for (let index = 16; index < 64; index += 1) {
const s0 =
rightRotate(words[index - 15]!, 7) ^
rightRotate(words[index - 15]!, 18) ^
(words[index - 15]! >>> 3);
const s1 =
rightRotate(words[index - 2]!, 17) ^
rightRotate(words[index - 2]!, 19) ^
(words[index - 2]! >>> 10);
words[index] = (words[index - 16]! + s0 + words[index - 7]! + s1) >>> 0;
}
let a = h0;
let b = h1;
let c = h2;
let d = h3;
let e = h4;
let f = h5;
let g = h6;
let h = h7;
for (let index = 0; index < 64; index += 1) {
const s1 = rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25);
const ch = (e & f) ^ (~e & g);
const temp1 = (h + s1 + ch + SHA256_K[index]! + words[index]!) >>> 0;
const s0 = rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const temp2 = (s0 + maj) >>> 0;
h = g;
g = f;
f = e;
e = (d + temp1) >>> 0;
d = c;
c = b;
b = a;
a = (temp1 + temp2) >>> 0;
}
h0 = (h0 + a) >>> 0;
h1 = (h1 + b) >>> 0;
h2 = (h2 + c) >>> 0;
h3 = (h3 + d) >>> 0;
h4 = (h4 + e) >>> 0;
h5 = (h5 + f) >>> 0;
h6 = (h6 + g) >>> 0;
h7 = (h7 + h) >>> 0;
}
return [h0, h1, h2, h3, h4, h5, h6, h7].map(wordHex).join('');
}
function wordHex(value: number): string {
const alphabet = '0123456789abcdef';
let output = '';
for (let shift = 28; shift >= 0; shift -= 4) {
output += alphabet[(value >>> shift) & 0xf];
}
return output;
}
export function normalizeEmail(value: unknown): string | null {
if (typeof value !== 'string') return null;
const normalized = value.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) return null;
return normalized;
}
export function normalizeSha256(value: unknown): string | null {
if (typeof value !== 'string') return null;
const normalized = value.trim().toLowerCase();
if (!/^[a-f0-9]{64}$/.test(normalized)) return null;
return normalized;
}
export function auditHashRows(rows: HashInputRow[]): HashAudit {
const hashes = rows
.map((row) => normalizeSha256(row.email_sha256))
.filter((hash): hash is string => Boolean(hash));
return {
inputRows: rows.length,
outputRows: hashes.length,
malformedHashes: rows.length - hashes.length,
duplicateHashes: hashes.length - new Set(hashes).size,
rawEmailFieldsPresent: rows.some((row) =>
Object.prototype.hasOwnProperty.call(row, 'email'),
),
};
}
export function prepareHashOnlyUploadRows(
rows: HashInputRow[],
): { rows: HashOnlyUploadRow[]; audit: HashAudit } {
const audit = auditHashRows(rows);
const output: HashOnlyUploadRow[] = [];
const seen = new Set<string>();
for (const row of rows) {
const hash = normalizeSha256(row.email_sha256);
if (!hash || seen.has(hash)) continue;
seen.add(hash);
output.push({ email_sha256: hash });
}
return { rows: output, audit };
}
plays/upload-facebook-google-hash-only-audience.play.ts›
import { definePlay } from 'deepline';
import { prepareHashOnlyUploadRows } from './shared/audience-hash';
type HashOnlyRow = {
email_sha256?: string;
email?: string;
};
export default definePlay(
'ads-audience-upload-facebook-google-hash-only',
async (
ctx,
input: {
file: string;
audience_name: string;
google_account_id?: string;
google_login_account_id?: string;
meta_ad_account_id?: string;
meta_audience_id?: string;
description?: string;
validate_only?: boolean;
},
) => {
const sourceDataset = await ctx.csv<HashOnlyRow>(input.file);
const sourceRows = (await sourceDataset.materialize()) as HashOnlyRow[];
const { rows, audit } = prepareHashOnlyUploadRows(sourceRows);
// Narrow the optional ids once. Each platform branch only runs when its id
// is present, but the tool payloads require a string, not string|undefined.
const googleAccountId = input.google_account_id ?? '';
const metaAdAccountId = input.meta_ad_account_id ?? '';
const metaAudienceId = input.meta_audience_id ?? '';
if (
rows.length === 0 ||
audit.malformedHashes > 0 ||
audit.duplicateHashes > 0 ||
audit.rawEmailFieldsPresent
) {
throw new Error(
JSON.stringify({
message: 'Hash-only audience payload did not pass validation.',
audit,
}),
);
}
if (!input.google_account_id && !input.meta_ad_account_id) {
throw new Error(
'Provide google_account_id, meta_ad_account_id, or both for upload.',
);
}
const google =
input.google_account_id !== undefined
? await (async () => {
const createResult = await ctx.tools.execute({
id: 'create_google_audience',
tool: 'google_ads_audiences_create_audience',
input: {
account_id: googleAccountId,
...(input.google_login_account_id
? { login_account_id: input.google_login_account_id }
: {}),
name: input.audience_name,
membership_life_span_days: 540,
membership_status: 'OPEN',
upload_key_types: ['CONTACT_ID'],
validate_only: input.validate_only === true,
},
description: 'Create a Google Customer Match audience.',
});
const audienceId = String(
(createResult as { data?: { audience?: { id?: unknown } } }).data
?.audience?.id ?? '',
);
if (!audienceId) {
throw new Error(
'Google audience create did not return an audience id.',
);
}
const syncResult = await ctx.tools.execute({
id: 'sync_google_audience_members',
tool: 'google_ads_audiences_sync_audience_members',
input: {
account_id: googleAccountId,
...(input.google_login_account_id
? { login_account_id: input.google_login_account_id }
: {}),
audience_id: audienceId,
mode: 'append',
rows,
consent: {
ad_user_data: 'GRANTED',
ad_personalization: 'GRANTED',
},
encoding: 'HEX',
validate_only: input.validate_only === true,
terms_of_service_accepted: true,
},
description: 'Upload hash-only audience members to Google.',
});
const statusResult = await ctx.tools.execute({
id: 'get_google_audience_status',
tool: 'google_ads_audiences_get_audience_status',
input: {
account_id: googleAccountId,
...(input.google_login_account_id
? { login_account_id: input.google_login_account_id }
: {}),
audience_id: audienceId,
},
description: 'Read back Google audience status after upload.',
});
return {
audience_id: audienceId,
create_result: createResult,
sync_result: syncResult,
status_result: statusResult,
};
})()
: null;
const meta =
input.meta_ad_account_id !== undefined
? await (async () => {
if (!input.meta_audience_id) {
throw new Error(
'Meta upload requires meta_audience_id for an existing Custom Audience.',
);
}
const syncResult = await ctx.tools.execute({
id: 'sync_meta_audience_members',
tool: 'meta_audiences_sync_audience_members',
input: {
ad_account_id: metaAdAccountId,
audience_id: metaAudienceId,
mode: 'replace',
rows,
},
description:
'Upload hash-only audience members to an existing Meta Custom Audience.',
});
return {
audience_id: metaAudienceId,
sync_result: syncResult,
};
})()
: null;
return {
uploaded_rows: rows.length,
audit,
google,
meta,
};
},
{
description:
'Upload the same validated hash-only rows to a Google Customer Match audience and an existing Meta Custom Audience.',
},
);
plays/upload-google-hash-only-audience.play.ts›
import { definePlay } from 'deepline';
import { prepareHashOnlyUploadRows } from './shared/audience-hash';
type HashOnlyRow = {
email_sha256?: string;
email?: string;
};
export default definePlay(
'ads-audience-upload-google-hash-only',
async (
ctx,
input: {
file: string;
account_id: string;
audience_name: string;
login_account_id?: string;
description?: string;
validate_only?: boolean;
},
) => {
const sourceDataset = await ctx.csv<HashOnlyRow>(input.file);
const sourceRows = (await sourceDataset.materialize()) as HashOnlyRow[];
const { rows, audit } = prepareHashOnlyUploadRows(sourceRows);
if (
rows.length === 0 ||
audit.malformedHashes > 0 ||
audit.duplicateHashes > 0 ||
audit.rawEmailFieldsPresent
) {
throw new Error(
JSON.stringify({
message: 'Hash-only audience payload did not pass validation.',
audit,
}),
);
}
const createResult = await ctx.tools.execute({
id: 'create_google_audience',
tool: 'google_ads_audiences_create_audience',
input: {
account_id: input.account_id,
// Only send login_account_id when the caller supplied one; the tool
// rejects an explicit undefined, and it is only needed for MCC access.
...(input.login_account_id
? { login_account_id: input.login_account_id }
: {}),
name: input.audience_name,
membership_life_span_days: 540,
membership_status: 'OPEN',
upload_key_types: ['CONTACT_ID'],
validate_only: input.validate_only === true,
},
description: 'Create a Google Customer Match audience.',
});
const audienceId = String(
(createResult as { data?: { audience?: { id?: unknown } } }).data
?.audience?.id ?? '',
);
if (!audienceId) {
throw new Error('Google audience create did not return an audience id.');
}
const syncResult = await ctx.tools.execute({
id: 'sync_google_audience_members',
tool: 'google_ads_audiences_sync_audience_members',
input: {
account_id: input.account_id,
...(input.login_account_id
? { login_account_id: input.login_account_id }
: {}),
audience_id: audienceId,
mode: 'append',
rows,
consent: {
ad_user_data: 'GRANTED',
ad_personalization: 'GRANTED',
},
encoding: 'HEX',
validate_only: input.validate_only === true,
terms_of_service_accepted: true,
},
description: 'Upload hash-only audience members to Google.',
});
const statusResult = await ctx.tools.execute({
id: 'get_google_audience_status',
tool: 'google_ads_audiences_get_audience_status',
input: {
account_id: input.account_id,
...(input.login_account_id
? { login_account_id: input.login_account_id }
: {}),
audience_id: audienceId,
},
description: 'Read back Google audience status after upload.',
});
return {
audience_id: audienceId,
uploaded_rows: rows.length,
audit,
create_result: createResult,
sync_result: syncResult,
status_result: statusResult,
};
},
{
description:
'Create a Google Customer Match audience, upload hash-only rows, and read the resulting status back.',
},
);
recipes/enrich-and-upload-facebook-google.md›
# Enrich And Upload Paid Ads Audiences
Use this recipe when the user wants a B2B audience enriched with personal identifiers and uploaded to Meta/Facebook and Google. The output is paid ads audiences, not outbound contacts.
## When To Use
Use this recipe for:
- ABM audiences from CRM, Snowflake, Salesforce, HubSpot, or customer uploads.
- "Increase match rate on Facebook/Google" requests.
- "Use personal emails for ads, not outbound" requests.
- Enriched versus unenriched audience tests.
Skip this recipe for:
- Cold outbound sequences.
- One-off consumer targeting without source-list rights.
- Mobile-phone buying unless the user explicitly asks and approves cost.
## Inputs
Start with a project-local working directory because enrichment outputs are paid artifacts:
```bash
WORKDIR="deepline/data/<customer>-ads-audience" && mkdir -p "$WORKDIR" && echo "$WORKDIR"
```
The source CSV should preserve lineage fields:
| Field | Why |
| --------------------------- | ------------------------------------------------------------- |
| `source_row` or `person_id` | Reconcile rows after enrichment and upload. |
| `work_email` | Baseline Google and Meta hash input. |
| `linkedin_url` | Personal-email fallback providers usually need it. |
| `first_name`, `last_name` | Identity checks and fallback providers. |
| `company`, `domain` | ABM review and provider context. |
| `country_code` | Keep US-only default unless the user confirms broader rights. |
## Waterfall
Start with the cost-effective hash-provider pass:
1. Work-email baseline, normalized and SHA-256 hashed.
2. Aviato hash provider on all eligible rows with LinkedIn/profile context, including rows that already have work emails. Pass through 64-character personal email hashes as-is.
3. LimaData audience identifier hashes on rows still missing a personal hash after Aviato, or on all eligible rows first when that is more cost-effective.
Keep work-email rows in the enrichment pool until they receive a personal hash or the provider ladder is exhausted.
Stop there by default. Report coverage lift, unique hashes added, contacts still missing personal hashes, and Deepline spend.
Then ask the user whether they want to spend more per contact on broader raw personal-email providers. Quote the current rate from `deepline tools describe <tool_id> --json` rather than a figure written here: rates change, and a stale number in an approval gate makes users agree to a price that no longer holds. Only after explicit approval, run the expanded pass:
4. LeadMagic personal email on rows still missing personal hashes, then normalize and hash once.
5. ContactOut free personal-email pre-check, then paid reveal only for true rows.
6. Optional later fallbacks: Wiza, Datagma, Crustdata, Prospeo, FullEnrich, PDL.
Every provider layer should report:
- attempted rows
- row hits
- hashes seen
- unique hashes added
- failures
- estimated and actual cost
If the user asks for "max coverage", "highest match rate", "keep increasing coverage", or a target such as "get closer to 75% coverage", switch to `recipes/max-coverage-audience.md` before spending beyond Aviato/LimaData. The max-coverage recipe adds explicit stop conditions, LinkedIn repair, broader personal-email fallback, optional phone hashes, and platform-readback economics.
## Build Hash-Only Payload
Confirm the play surface before building files:
```bash
deepline --help
deepline plays --help
```
If `deepline plays` is unavailable, stop and ask for the Deepline SDK CLI to be installed or updated. Do not build upload payloads through older command paths.
Use the bundled play when the CSV already contains first-party emails plus provider hash or personal email columns:
```bash
deepline plays run --file .skills/deepline-ads-audiences/plays/build-hash-only-audience.play.ts --input '{"file":"'"$WORKDIR"'/source.csv"}' --watch
```
Export both datasets after the run:
```bash
deepline runs export <run-id> --dataset baseline_hash_only_audience --out "$WORKDIR/baseline_hash_only.csv"
deepline runs export <run-id> --dataset enriched_hash_only_audience --out "$WORKDIR/enriched_hash_only.csv"
```
## Audit Before Upload
Run the no-double-hash audit. This catches the most expensive silent failure: hashing a provider hash again and uploading a valid-looking but useless identifier.
```bash
deepline plays run --file .skills/deepline-ads-audiences/plays/audit-no-double-hash.play.ts --input '{"payloadFile":"'"$WORKDIR"'/enriched_hash_only.csv","providerHashFile":"'"$WORKDIR"'/provider_hashes.csv","providerHashColumns":["aviato_hash","limadata_hash","email_sha256","hashed_personal_email_sha256"]}' --watch
```
Pass criteria:
- malformed hashes: 0
- duplicate hashes: 0 after dedupe
- raw email fields in upload payload: false
- provider hashes missing from payload: 0
- double-hashed provider hashes present: 0
A live upload without a no-double-hash audit is not acceptable for this workflow.
## Upload To Facebook And Google
Discover accounts first. Show account name and ID to the user before upload.
```bash
deepline tools search "google ads audiences accounts" --json
deepline tools search "meta audiences custom audience upload" --json
```
Use the combined play when both channel IDs are ready:
```bash
deepline plays run --file .skills/deepline-ads-audiences/plays/upload-facebook-google-hash-only-audience.play.ts --input '{"file":"'"$WORKDIR"'/enriched_hash_only.csv","audience_name":"<segment> enriched hash-only <date>","google_account_id":"<google_customer_id_without_dashes>","meta_ad_account_id":"act_<meta_ad_account_id>","meta_audience_id":"<existing_meta_custom_audience_id>"}' --watch
```
Notes:
- Google play path creates a new Customer Match audience, uploads rows, and reads status back.
- Meta path syncs rows into an existing Custom Audience because the live tool surface may expose sync without create. If a create tool exists in the current catalog, create the Meta audience first, then pass its ID to the play.
- Use `append` for Google on a newly created list.
- Use `replace` for Meta so the custom audience mirrors the hash-only payload.
Use the same create/sync/readback pattern for Meta with the current `meta_audiences` tools. If Meta create is not exposed, ask for an existing Custom Audience ID and sync into that audience only after the user confirms the account name and ID.
## Report
Final report should include:
- Source list name and row count.
- Baseline hash count.
- Enriched hash count.
- Provider lift by layer.
- Audit pass/fail values.
- Google account name and ID, audience ID, uploaded count, invalid count, readback status.
- Meta ad account name and ID, audience ID, uploaded count, invalid count, readback status if available.
- Match-rate caveat: Meta may take about 1 hour to populate; Google may take 24 to 48 hours.
Do not claim a match rate until the platform returns it. Report `null` as processing, not as zero.
recipes/max-coverage-audience.md›
# Max Coverage Paid Ads Audience
Use this recipe when the user asks for maximum B2B paid-audience coverage, highest match rate, or to keep increasing coverage. The output is still paid ads audience upload data, not outbound contact data.
This recipe extends the default cost-effective pass. It should not skip validation, account discovery, consent checks, or holdouts.
## Success Metric
Optimize for incremental matched audience coverage, not raw enrichment coverage.
Primary metric:
```text
cost per incremental matched person =
Deepline spend / additional matched people versus baseline
```
Report these values for every coverage layer:
- attempted rows
- row hits
- unique contacts with at least one uploadable hash
- unique hashes added
- remaining no-personal-hash contacts
- Deepline spend
- estimated incremental matched people after platform readback
- cost per incremental matched person after platform readback
## Required Preflight
Before enrichment spend:
1. Confirm the list can be used for paid ads audience creation.
2. Confirm enriched identifiers can be used for paid ads matching.
3. Confirm geography. Default to US-only for personal-identifier enrichment unless the user confirms broader rights.
4. Discover uploadable ad accounts and show `Account Name (Account ID)`.
5. Confirm platform credentials can create, sync, and read back audiences.
6. Create a holdout/control group before enrichment.
7. Set a budget cap for fallback beyond Aviato/LimaData.
Do not proceed to paid fallback if the ad account cannot be discovered or validated. Fix the activation path first, otherwise enrichment spend may create a list that cannot be uploaded.
## Coverage Ladder
Run the ladder in this order, but do not stop personal-email hash enrichment just because a contact already has a work email. Work-email hashes are the baseline and holdout; personal email hashes are additive identifiers that should be collected for as many eligible contacts as possible.
Use these scopes:
- Baseline scope: all rows with first-party uploadable identifiers.
- Personal-hash scope: all eligible rows with enough identity context for the provider, including rows that already have work-email hashes.
- Provider-dedup scope: for a given paid provider, skip only rows that already have a usable personal email hash from an earlier personal-hash provider, unless the user explicitly asks to test overlap.
- Expanded fallback scope: rows still missing a personal email hash after Aviato/LimaData, not rows missing any uploadable identifier.
### L0: Source And Baseline
Build a baseline object from first-party identifiers:
- work email normalized and SHA-256 hashed once
- phone SHA-256 only if already present and permitted
- first name, last name, country, postal code where supported
- LinkedIn URL, company domain, company name, title for lineage and QA
Upload baseline separately. This is the denominator for lift.
### L1: Existing Hashes And Warehouse Edges
Use any existing paid-ads-safe hashes already present in the source system or warehouse.
Validation:
- accept only lowercase 64-character SHA-256 hex hashes;
- reject placeholders and malformed hashes;
- do not hash a provided hash again;
- record source columns used.
### L2: LinkedIn Repair
For rows missing verified LinkedIn profile URLs, run the LinkedIn URL lookup ladder before LinkedIn-dependent hash providers. Include rows that already have work emails; LinkedIn repair is meant to unlock personal-hash providers and improve match coverage beyond the work-email baseline.
Use cleaned account/company aliases and validate identity before merging:
```text
"{{full_name}}" ("{{account_name}}" OR "{{linkedin_company_name}}") site:linkedin.com/in -inurl:dir -inurl:pub
"{{full_name}}" "{{title}}" "{{linkedin_company_name}}" site:linkedin.com/in -inurl:dir -inurl:pub
"{{full_name}}" "{{account_name}}" site:linkedin.com/in -inurl:dir -inurl:pub
```
Accept a candidate only when:
- URL is `linkedin.com/in/`;
- first and last name match, allowing common nicknames;
- company/title evidence supports the match;
- ambiguous results are profile-scraped and revalidated before merge.
Do not spend heavily on LinkedIn repair when coverage is already high. Report attempted rows and recovered verified URLs.
### L3: Cost-Effective Hash Providers
Run Aviato and LimaData before broader fallback.
Use provider hashes as-is when they are valid SHA-256:
- Aviato hash provider on all eligible rows with LinkedIn/profile context, including rows that already have work-email hashes.
- LimaData audience identifier hashes on rows still missing a personal hash after Aviato, or as first provider when that is more cost-effective for the available inputs.
Extract hashes only from explicit hash fields such as:
- `matched_result`
- `result.data.hashedEmails[]`
- `result.data.hashed_email`
- `result.data.hashed_emails[].normalized_hash`
- `hash`
- `sha256`
Never upload a JSON object string as a hash value.
#### ContactOut hashed identifiers (batch, pool-level)
`contactout_get_hashed_email_identifiers` converts LinkedIn profile URLs
directly into hashed email identifiers. It runs beside this ladder rather than
as a step in it, because it cannot waterfall: the response is an unattributed
pool, so it can neither skip rows an earlier provider covered nor let a later
provider skip rows it covered. Run it after L2 LinkedIn repair, on rows that
have a verified LinkedIn URL and still lack a personal hash.
Filtering the send set that way is the only saving available. In one production
run where ContactOut went last against the full set, 979 of its 1,691 returned
hashes were already in the pool, so about 58% of the spend bought nothing new.
In this recipe, though, prefer to include rows that already have a hash. About a
third of what ContactOut returns is a second or third address for a person it
already matched, measured live at 228 matched profiles returning 313 hashes.
Extra addresses per person raise the odds a platform matches that person at all,
which is the whole point of a max-coverage run. Pass
`includeRowsWithExistingHash` to the hash-pool play. Drop back to the default
exclusion only when the budget cap bites.
It behaves differently from the per-row hash providers, so treat it separately:
- **Batch only.** Send 5–100 unique LinkedIn URLs per call. Fewer than 5 is
rejected with HTTP 400. Chunk the eligible rows and keep chunks at 100.
- **The result is an unattributed hash pool.** ContactOut returns a flat
`matches.emails` list with no mapping back to the input profiles. You cannot
tell which hash belongs to which person, so do not write these hashes into
per-row `email_sha256` cells. Merge them into the audience-level hash pool.
- **Billing is per matched profile.** The response includes `matches_found`,
the exact number of profiles matched. One matched profile can return several
hashes, so `matches.emails.length` overstates it. Report cost from
`matches_found`, never from the hash count.
- **A zero-match chunk returns HTTP 404** with `No hashed emails found`. That
is a normal empty result, not a failure. Keep processing the other chunks.
Because the output is pool-level, measure its contribution as net-new unique
hashes added to the pool after deduping against hashes you already had:
```text
contactout_net_new = |contactout_hashes - existing_pool|
```
Report that net-new count and the summed `matches_found`. Do not report
ContactOut lift as a per-row hit rate.
### L4: Raw Personal-Email Waterfall
Run this only when max coverage is requested and the budget cap covers it.
Try raw personal-email providers on rows still missing a personal email hash after the cost-effective hash pass. Do not exclude a row merely because it has a work email hash.
1. LeadMagic personal email
2. ContactOut pre-check, then paid reveal only for true rows
3. Wiza
4. Datagma
5. Crustdata
6. Prospeo
7. FullEnrich
8. People Data Labs or Deepline native personal-email waterfall
Provider output rules:
- include only fields explicitly typed as personal email;
- reject untyped scalar emails when they may be work emails;
- normalize raw personal emails with trim + lowercase;
- validate email syntax;
- SHA-256 hash exactly once;
- upload only `email_sha256` by default.
### L5: Phone Hashes
Do not buy mobile phones by default.
Use phone hashes only when:
- phones are already present and permitted; or
- the user explicitly approves mobile-phone enrichment and the budget cap.
Normalize phones consistently before hashing. Report phone-derived lift separately from email-derived lift.
### L6: Upload Variants
Create separate upload objects:
- baseline work identifiers
- baseline plus existing personal hashes
- baseline plus Aviato/LimaData personal hashes
- baseline plus max-coverage personal-email fallback
- phone-hash variant if used
- geo or account-tier cuts when relevant
Do not overwrite variants during experimentation. Separate audiences are required to calculate lift.
### L7: Platform Readback
After upload, poll platform status:
```bash
deepline tools execute google_ads_audiences_get_audience_status --payload '{"account_id":"<google_customer_id>","audience_id":"<audience_id>"}' --json
deepline tools execute meta_audiences_get_audience_status --payload '{"ad_account_id":"act_<meta_ad_account_id>","audience_id":"<audience_id>"}' --json
deepline tools execute linkedin_ads_audiences_get_audience_status --payload '{"account_id":"<linkedin_account_id>","audience_id":"<audience_id>"}' --json
```
Do not claim success from upload receipt alone. Success requires create, sync, and readback.
## Stop Conditions
Stop expanding coverage when any of these are true:
- budget cap is reached;
- remaining rows lack enough identity context for reliable lookup;
- provider returns are mostly work emails or invalid hashes;
- marginal unique-hash lift is below the user-defined threshold;
- platform match-rate lift does not justify another fallback layer;
- geography/rights constraints block further enrichment;
- account credential preflight fails.
When stopping, report the exact blocker and the next highest-value option.
## Reliability Checks
Before upload:
- `malformed_hashes = 0`
- `raw_email_fields_in_hash_only_payload = false`
- `double_hashed_provider_hashes = 0`
- `holdout_leaks = 0`
- `duplicate_hashes` are deduped before upload
- account name and ID are confirmed
- upload permission is confirmed
After upload:
- audience exists in the right account
- membership status is open or processing
- upload key type is correct
- invalid rows are reported
- match rate is reported only after platform returns it
## Final Report Shape
Use this structure:
```text
Coverage mode: max_coverage
Source rows:
Holdout rows:
Uploadable baseline rows:
Final uploadable rows:
Unique hashes by layer:
Remaining no-personal-hash rows:
Deepline spend:
Audience IDs by platform:
Current platform match rates:
Incremental matched people:
Cost per incremental matched person:
Recommended activation audience:
Remaining blockers:
```
The recommendation should be based on platform match-rate readback and economics, not on the file with the most enriched columns.
recipes/sample-abm-segment-example.md›
# Sample ABM Segment Ads Audience Example
Use this recipe for a concrete ABM audience pattern from a high-priority title or account segment. It is intentionally written as a reusable example, not as a customer-specific runbook.
## Scenario
The source audience is a high-priority B2B segment from CRM or Salesforce title segmentation:
- about 2,000 source people.
- most rows have LinkedIn URLs.
- about half of rows have uploadable work emails.
- US-only by default for personal identifier enrichment.
- Mobile phones skipped because they are too expensive for this audience test.
Observed provider planning numbers from the example run:
| Layer | Example result |
| ----------------------------------- | ------------------------------- |
| LimaData hashed personal-email hits | low double-digit row lift |
| Aviato hashed personal-email hits | low-to-mid double-digit lift |
| Raw personal-email fallback | run only after explicit budget approval |
Do not treat these as Deepline benchmarks. They are example-run planning numbers that help agents preserve the intended shape of the workflow.
## Goal
Create separate enriched and unenriched hash-only audiences for Google Customer Match and Meta Custom Audiences so the user can compare actual platform match behavior.
The output should include:
- `unenriched_hash_only.csv`, built from first-party work emails and allowed source identifiers.
- `enriched_hash_only.csv`, built from source identifiers plus provider hashes and raw personal-email fallbacks hashed once.
- `provider_coverage.csv`, showing attempted rows, hits, unique hashes added, failures, and cost by provider layer.
- `no_double_hash_audit.md`, proving provider hashes were not hashed again.
- `upload_report.md`, listing platform account name and ID, audience name and ID, uploaded count, invalid count, request IDs, and readback status.
## Rights Gate
Before enrichment or upload, confirm:
- The CRM or Salesforce segment can be used for paid ads audience creation.
- Enriched personal identifiers can be used for paid ads matching.
- The audience is US-only, or the user has confirmed broader geography rights.
- The workflow is for ABM paid ads only, not outbound.
- Google and Meta account selection has been confirmed by account name and ID.
## Play Path
Confirm the play surface first:
```bash
deepline --help
deepline plays --help
```
If `deepline plays` is unavailable, stop and ask for the Deepline SDK CLI to be installed or updated. Do not replace this recipe with older enrichment or tool-execution command paths.
Build baseline and enriched objects:
```bash
deepline plays check .skills/deepline-ads-audiences/plays/build-hash-only-audience.play.ts
deepline plays run --file .skills/deepline-ads-audiences/plays/build-hash-only-audience.play.ts --input '{"file":"'"$WORKDIR"'/source.csv"}' --watch
```
Export the datasets from the build run:
```bash
deepline runs export <build-run-id> --dataset baseline_hash_only_audience --out "$WORKDIR/unenriched_hash_only.csv"
deepline runs export <build-run-id> --dataset enriched_hash_only_audience --out "$WORKDIR/enriched_hash_only.csv"
```
Audit the enriched payload:
```bash
deepline plays check .skills/deepline-ads-audiences/plays/audit-no-double-hash.play.ts
deepline plays run --file .skills/deepline-ads-audiences/plays/audit-no-double-hash.play.ts --input '{"payloadFile":"'"$WORKDIR"'/enriched_hash_only.csv","providerHashFile":"'"$WORKDIR"'/provider_hashes.csv","providerHashColumns":["aviato_hash","limadata_hash","email_sha256","hashed_personal_email_sha256"]}' --watch
```
Upload enriched and unenriched as separate objects. Use the Google-only play for the Google comparison and the combined play when a Meta Custom Audience ID is available:
```bash
deepline plays check .skills/deepline-ads-audiences/plays/upload-google-hash-only-audience.play.ts
deepline plays run --file .skills/deepline-ads-audiences/plays/upload-google-hash-only-audience.play.ts --input '{"file":"'"$WORKDIR"'/unenriched_hash_only.csv","account_id":"<google_customer_id_without_dashes>","audience_name":"High-priority segment unenriched hash-only <date>"}' --watch
deepline plays run --file .skills/deepline-ads-audiences/plays/upload-google-hash-only-audience.play.ts --input '{"file":"'"$WORKDIR"'/enriched_hash_only.csv","account_id":"<google_customer_id_without_dashes>","audience_name":"High-priority segment enriched hash-only <date>"}' --watch
```
## Acceptance Criteria
The run is complete only when:
- The play run IDs and exported files are reported.
- The source count, LinkedIn URL count, baseline work-email count, and provider coverage counts are reported.
- Provider hashes are passed through as lowercase 64-character SHA-256 values.
- Raw personal emails, if any, are normalized and hashed exactly once.
- The no-double-hash audit passes before live upload.
- Enriched and unenriched objects are uploaded separately.
- Google and Meta account names and IDs are shown in the final report.
- Match-rate fields are reported only when the platforms return them.
shared/audience-basics.md›
# Audience Basics
Background for users who have not built a paid ads audience before. Read this when the user asks what a hash is, why their match rate is low, or why the workflow has so many steps. Everything here is explanation, not procedure.
## Contents
- [What an upload actually does](#what-an-upload-actually-does)
- [Why B2B lists match badly](#why-b2b-lists-match-badly)
- [What a hash is](#what-a-hash-is)
- [Hash or raw personal email](#hash-or-raw-personal-email)
- [Why match rate misleads](#why-match-rate-misleads)
- [Platform formatting differences](#platform-formatting-differences)
## What an upload actually does
Uploading a customer list does not buy an audience. It asks the platform which of these people already have an account there. Rows that match become reachable; rows that do not reach nobody, and any enrichment spend on them is wasted.
So the whole workflow optimizes one thing: how many rows carry an identifier the platform recognizes.
## Why B2B lists match badly
CRMs store work emails. People register personal social accounts with personal addresses and mobile numbers. You hold one identifier, the platform holds another, and the same person never gets connected.
Nothing rejects work emails. A Google Workspace address is a real Google account, and Meta accepts any address at signup. The constraint is registration behavior, not policy. That distinction matters when a user asks whether they can "just fix" their CRM data: the fix is acquiring the identifier the person actually used, not cleaning the one you have.
## What a hash is
A hash is a fixed-length code generated from text. SHA-256 turns any input into 64 characters:
```text
[email protected]
a2327573224b6c023cc60a440a85830a8894f467ea13f33f36290059e2e8193f
```
Three properties matter:
1. **Same input, same output, always.** This is what makes matching possible.
2. **Any change to the input changes the whole output.** `[email protected]` and `[email protected]` produce entirely unrelated codes. There is no partial match, which is why normalization rules are not optional.
3. **You cannot work backwards.** The recipe discards information, so the code cannot be turned back into the address.
That third property is why both sides can compare lists without exchanging addresses. You hash your copy, the platform hashes its copy, and only the codes are compared. For people who do not match, the platform holds a code it cannot reverse for a person it cannot identify.
## Hash or raw personal email
Users often ask which is better for matching. Neither, because they are the same thing: a hash is a personal email that has been through the recipe. Upload a raw address and the platform hashes it on arrival.
The decision is about whether anyone needs to read the address:
| Use | Buy | Why |
| --- | --- | --- |
| Ads only | The hash | Costs a fraction of a readable address, because you are not paying for contactability |
| Ads plus email or calling | The raw address, hashed locally | You need the readable form too |
## Why match rate misleads
Google reports a match rate percentage; Meta reports a Match score out of ten in half-point increments. Both divide by rows uploaded.
Two consequences worth telling the user:
- Junk rows count against the denominator, so cleaning the file before upload raises the score without buying anything.
- Adding a layer that contributes many rows can lower the percentage while raising the count of reachable people. Judge a run on matched people, not on the ratio. Google states outright that match rate is not an indicator of list performance.
## Platform formatting differences
The two platforms disagree on phone format, so one shared normalizer across both is a bug:
| Field | Google Customer Match | Meta Custom Audiences |
| --- | --- | --- |
| Phone | E.164 with a leading `+` | Digits only, symbols and leading zeroes stripped, country code prefixed |
| Gmail dots and `+suffix` | Strip both, for `gmail.com` and `googlemail.com` only | No equivalent rule |
| Hash | SHA-256, hex | SHA-256 only, hex, lowercase A-F |
Applying Google's gmail rule to other domains breaks matching, because dots are significant everywhere else.
Google needs 100 active users in the last 30 days before a list serves, for lists uploaded or refreshed after February 2024, and recommends at least 5,000. Meta guides toward at least 1,000 per customer list, and separately requires 100 people in a source audience used for a Lookalike. Both take up to a day or two to process, so an empty size right after upload means processing rather than failure.
shared/contactout-hash-pool.md›
# ContactOut hashed identifiers
Read this before planning or running a ContactOut hashed-identifier pass. It behaves unlike every other hash provider in this skill, and the differences cost money.
## Contents
- [Why it cannot waterfall](#why-it-cannot-waterfall)
- [Expect overlap](#expect-overlap)
- [The exclusion trade-off](#the-exclusion-trade-off)
- [What it contributes on Meta](#what-it-contributes-on-meta)
- [Choosing the send set](#choosing-the-send-set)
- [Contract details](#contract-details)
## Why it cannot waterfall
Every other layer runs row by row, so each can skip rows an earlier layer covered. ContactOut cannot, and slotting it into the ladder as another step quietly overspends.
- **The output is unattributed.** You send up to 100 LinkedIn URLs and get back a pool of hashes plus a `matches_found` count. Nothing maps a hash to a person, so after the call you still do not know which of those people were covered.
- **Skipping does not help downstream either.** Because the pool is unattributed, providers running after ContactOut cannot skip anyone. Reordering the ladder recovers nothing.
- **Attribution cannot be bought back.** Re-sending in smaller batches fails twice over: ContactOut bills per matched profile on every call, so a second pass pays for the same people again, and the 5-profile floor means even the smallest legal batch cannot isolate one person. A live 5-profile probe returned 7 hashes for 4 matched profiles, identifying neither which four matched nor whose hash is whose.
So it runs as a bulk pass beside the ladder. The only levers are which rows you send and whether to run it at all.
## What it costs
Look the price up before quoting it. Rates change, and a number written here will be wrong eventually:
```bash
deepline tools describe contactout_get_hashed_email_identifiers --json
```
What does not change is the billing shape, and it is what makes cost comparisons across these providers misleading:
| Billed on | Providers | Consequence |
| --- | --- | --- |
| Each matched profile | ContactOut hashed identifiers | Misses in a batch cost nothing, so the effective cost per row sent falls as match rate falls |
| Each call, hit or miss | The per-row hash providers such as Aviato and LimaData | Every row costs the same whether or not it returns a hash |
So a per-row price comparison flatters ContactOut on a low-match list and penalises it on a high-match one. To compare like for like, multiply ContactOut's per-match price by the match rate you actually observe, then compare that against a per-call provider's price.
When quoting cost to a user, quote per matched profile and say so. At the roughly 75% match rate measured on a real B2B list, a 100-profile batch bills about 75 profiles, so the per-row figure is around three quarters of the per-match price. Presenting that lower number as the price understates the bill.
## Expect overlap
In one production audience run, ContactOut returned 1,691 unique hashes. 979 were already in the pool from other providers and 712 were net-new, so roughly 58% of that spend bought hashes the audience already had. That is a property of the endpoint rather than a mistake in the run, and it is the reason the send set matters.
## The exclusion trade-off
Excluding rows that already carry a hash is the only pre-call saving. On pool size it costs nothing, because the hashes it drops were already in the pool. It is not free in every mode though.
Roughly a quarter to a third of what ContactOut returns is a second or third address for a person it already matched. Measured on two independent samples: 228 matched profiles returned 313 hashes (27% extra), and a separate 100-profile batch returned 111 hashes for 84 matched profiles (24% extra). Excluding a row because it holds an Aviato or LimaData hash forfeits any different address ContactOut would have found for that same person.
| Mode | Send already-hashed rows? | Why |
| --- | --- | --- |
| `cost_effective` | No | A second address for an already-covered person is the least valuable hash available |
| `max_coverage` | Yes | More addresses per person means more chances a platform matches them, and the spend is already approved |
The hash-pool play defaults to excluding and takes `includeRowsWithExistingHash` for the max-coverage case.
Read the coverage columns the other layers actually write. The hash providers here populate named per-provider columns such as `aviato_hash` and `limadata_hash`. A check that only reads `email_sha256` finds nothing on a real workflow CSV and sends every already-covered row while reporting that it excluded them.
## What it contributes on Meta
From the same production run, two audiences differing only by ContactOut's contribution:
| Audience | Rows | Matched | Match rate |
| --- | --- | --- | --- |
| Baseline only | 9,670 | 2,800-3,300 | 31.5% |
| Baseline plus ContactOut | 10,382 | 3,000-3,600 | 31.8% |
The 712 rows it added brought roughly 250 matched people, an incremental match rate near 35% against an audience averaging 31.5%. The rows it contributes match better than the list as a whole.
Note that the overall percentage moved only 31.5% to 31.8% while 250 more real people became reachable. Judge this on matched people, not the ratio.
## Choosing the send set
Decide on inputs known before the call:
| Situation | Do |
| --- | --- |
| Many rows have a verified LinkedIn URL and no personal hash | Send those rows. Best case, lowest overlap. |
| Most rows already carry a hash, `cost_effective` | Send only the rows still missing one. |
| Most rows already carry a hash, `max_coverage` | Send them anyway, for the additional addresses. |
| Few rows have a verified LinkedIn URL | Skip it, or run LinkedIn repair first. A LinkedIn URL is the only accepted input. |
| Strict cost control on a small list | Skip it. Per-row providers give attributable spend; this one does not. |
## Contract details
- Batch of 5 to 100 unique LinkedIn URLs. Fewer than 5 is rejected with HTTP 400.
- A chunk where nothing matches returns HTTP 404 `No hashed emails found`. That is a normal empty result, not a failure, and it is not billed. Keep processing the other chunks.
- Billing reads `matches_found`, never the length of `matches.emails`. One matched profile can return several hashes, so the hash list overstates the charge.
- Merge results into the audience-level hash pool, never into per-row `email_sha256` cells.
- Report contribution as net-new hashes added to the pool plus the summed `matches_found`. A per-row hit rate is not computable here, so quoting one would be invented.
shared/upload-failure-modes.md›
# Upload failure modes
Failures observed on live Google Customer Match and Meta Custom Audience runs.
Each one uploads cleanly and reports success, so nothing downstream surfaces it.
## Contents
- [Mixed identifier types in one column](#mixed-identifier-types-in-one-column)
- [Hash-of-hash audit](#hash-of-hash-audit)
- [The connector forwards hashes verbatim](#the-connector-forwards-hashes-verbatim)
- [Meta locks an audience while it ingests](#meta-locks-an-audience-while-it-ingests)
- [Blank string fields are rejected](#blank-string-fields-are-rejected)
- [Phone normalization](#phone-normalization)
- [Match rate lives in contactIdInfo](#match-rate-lives-in-contactidinfo)
- [Acceptance count is not a match rate](#acceptance-count-is-not-a-match-rate)
- [Sheets destroys all-digit hashes](#sheets-destroys-all-digit-hashes)
## Mixed identifier types in one column
Meta applies one normalization rule per column. It trims, lowercases and hashes
whatever it finds. That is correct for a raw address and destructive for a value
that is already hashed: the result is `sha256(sha256(email))`, which matches
nothing and raises no error.
A customer file failed this way. Its `email_1` column held 3,725 raw addresses
and 945 SHA-256 hashes, so 951 contacts became unmatchable while the upload
reported success. The hashing was correct. Only the column placement was wrong.
The broken file also looked better on the number people check: 11,251 distinct
identifiers against 9,244 in the clean rebuild. A larger list that matches worse
is how this survives review.
Assert per column that every populated value is the same kind: all raw, or all
64-character lowercase hex. A column holding both is a blocker.
## Hash-of-hash audit
Take every hash in the payload. Check whether its own SHA-256 also appears in the
payload. A hit means a value was hashed twice.
Run this as a gate before upload, not as a report afterwards. The entire failure
mode is that nothing else surfaces it.
## The connector forwards hashes verbatim
`email_sha256` and `phone_sha256` reach the platform exactly as supplied.
Deepline hashes only `external_id`.
A correct hash arrives intact. A double-hashed one arrives intact too. No layer
repairs it, so the audit above is the only check between a mistake and a dead
audience.
## Meta locks an audience while it ingests
Meta sets `operation_status: 414` when it accepts a write, then rejects further
writes with `UPSTREAM_BAD_INPUT` / HTTP 422 until ingestion finishes. Observed
holding for over four hours.
Batching cannot work against this. The first batch lands, later batches bounce,
and the audience keeps part of the list while the run appears to progress.
- Send the whole audience in one `sync_audience_members` call. The tool accepts
`maxItems: 300000`. Verified: 10,664 rows in one call completed in 16 seconds;
the same list in 2,000-row batches wedged on the second batch. Google accepted
the same list in one call with no lock, so one call is correct on both.
- Pass large payloads as `--payload @file.json`. Inline JSON for a real audience
exceeds the shell argument limit and fails with
`OSError: [Errno 7] Argument list too long` before reaching Deepline.
- Do not resume a half-landed batched run with `append`. The source file may have
changed. Create a new audience and `replace` once.
- `approximate_count: 1000` during a lock is a placeholder. A 5,000-row and a
13,236-row upload both reported exactly 1000 while locked.
- A locked audience still reports `delivery_status: 200`. That describes the
previously ingested audience. Only `operation_status: 200` means settled.
## Blank string fields are rejected
Every string field in the row schema declares `minLength: 1`. Sending
`first_name: ""` fails the whole batch with 422. Omit the key instead.
This applies most often to ContactOut-pool rows, which carry no name or country.
## Phone normalization
Meta's phone rule differs from its email rule: *"Remove symbols, letters, and any
leading zeroes. You should prefix the country code if the COUNTRY field is not
specified."* Digits only, country code included, no leading `+`.
Verify the implementation against Meta's published example instead of the prose.
Meta documents `15559876543` hashing to
`1ef970831d7963307784fa8688e8fce101a15685d62aa765fed23f3a2c576a4e`. A pipeline
that reproduces that digest handles phones correctly.
Both platforms accept `phone_sha256` beside `email_sha256` on one row. Verified:
13,236 rows carrying 10,664 email and 6,982 phone hashes uploaded to Google with
`invalid_count: 0` and to Meta with `status=completed`.
Hashing phones the customer already supplied costs nothing. Buying mobile numbers
costs far more per contact than hashed emails, so treat that as a separate
decision.
## Match rate lives in contactIdInfo
Google reports the match rate as a percentage at
`ingestedUserListInfo.contactIdInfo.matchRatePercentage`. The `matchRateRange`
enum beside it stays unset, so a caller reading only the enum reports `null` for
an audience with a real rate. Deepline fixed this in `deepline-api` #4311, which
adds `match_rate_percentage`. On an older build, read the raw path.
Measured on one account and one source list:
| Audience | Rows | Match rate |
| --- | --- | --- |
| Email hashes only | 10,664 | 79% |
| Email + phone hashes | 13,236 | 84% |
Treat `null` as not yet computed, never as zero. Google can take hours. A report
that renders `null` as 0% turns a successful upload into an apparent failure.
Meta exposes no per-audience match rate. It returns
`approximate_count_lower_bound` and `upper_bound` only.
## Acceptance count is not a match rate
`sync_audience_members` returns `uploaded_count` and `invalid_count`. These state
whether rows parsed, not whether people were found. An upload reports
`invalid_count: 0` even when every hash is double-hashed.
Report the two separately. Acceptance is available immediately and proves the
file is well formed. Match rate arrives hours later and decides whether the
enrichment spend paid off. Meta returns no per-row counts.
## Sheets destroys all-digit hashes
Google Sheets converts all-digit strings to numbers. A hash of `0000...0001`
becomes `1`. Most SHA-256 digests contain a letter and survive; an all-digit
digest does not.
Check for all-digit values before publishing, and import the column as text.
`google_workspace_export_dataset` takes a Play `run_id`, not a local path. Run a
play that emits the rows as a dataset, then export that run. Two constraints:
`materialize()` refuses more than 10,000 rows, so page the file and export one
tab per page; and the export appends a run-id suffix to the tab name, so a later
`values.get` must quote the full title.
Verify published tabs by row count, not by export status. One tab returned
`status: completed` and did not appear in the spreadsheet.
skill-metadata.json›
{
"documents": {
"SKILL.md": {
"kind": "entrypoint",
"title": "Deepline Ads Audiences",
"tags": [
"ads",
"audiences",
"google-ads",
"meta",
"linkedin",
"customer-match"
],
"providers": [
"google_ads_audiences",
"meta_audiences",
"linkedin_ads_audiences",
"aviato",
"limadata",
"crustdata",
"contactout"
]
},
"plays/build-hash-only-audience.play.ts": {
"kind": "example",
"title": "Build hash-only audience play",
"tags": [
"play",
"audience",
"hashing",
"customer-match"
]
},
"plays/audit-no-double-hash.play.ts": {
"kind": "example",
"title": "Audit no double hash play",
"tags": [
"play",
"audit",
"hashing",
"validation"
]
},
"plays/upload-google-hash-only-audience.play.ts": {
"kind": "example",
"title": "Upload Google hash-only audience play",
"tags": [
"play",
"google-ads",
"customer-match",
"upload"
]
},
"plays/upload-facebook-google-hash-only-audience.play.ts": {
"kind": "example",
"title": "Upload Facebook and Google hash-only audience play",
"tags": [
"play",
"facebook",
"meta",
"google-ads",
"customer-match",
"upload"
]
},
"plays/report-google-coverage-lift.play.ts": {
"kind": "example",
"title": "Report Google coverage lift play",
"tags": [
"play",
"google-ads",
"customer-match",
"reporting",
"coverage-lift"
]
},
"recipes/enrich-and-upload-facebook-google.md": {
"kind": "recipe",
"title": "Enrich and upload paid ads audiences to Facebook and Google",
"tags": [
"recipe",
"facebook",
"meta",
"google-ads",
"audience-enrichment"
]
},
"recipes/max-coverage-audience.md": {
"kind": "recipe",
"title": "Max coverage paid ads audience",
"tags": [
"recipe",
"max-coverage",
"ads",
"audience-enrichment",
"customer-match"
]
},
"recipes/sample-abm-segment-example.md": {
"kind": "recipe",
"title": "Sample ABM segment ads audience example",
"tags": [
"recipe",
"example",
"abm",
"ads",
"audience-enrichment"
]
},
"plays/build-contactout-hash-pool.play.ts": {
"kind": "example",
"title": "Build ContactOut hash pool play",
"tags": [
"play",
"audience",
"hashing",
"contactout",
"batch"
],
"providers": [
"contactout"
]
},
"plays/enrich-audience-waterfall.play.ts": {
"kind": "example",
"title": "Enrich audience waterfall play",
"tags": [
"play",
"audience",
"hashing",
"waterfall",
"enrichment"
],
"providers": [
"aviato",
"limadata",
"leadmagic",
"contactout"
]
}
},
"prefixes": {
"shared/": {
"kind": "guide",
"title": "Shared Guide",
"tags": [
"ads",
"audiences",
"background"
],
"providers": []
}
}
}
SKILL.md›
---
name: deepline-ads-audiences
description: "Use this skill when building, enriching, auditing, or uploading B2B paid ads audiences to Google Customer Match, Meta/Facebook Custom Audiences, or LinkedIn Matched Audiences. Triggers on phrases like '/deepline-ads-audience', '/deepline-ads-audiences', 'upload this audience', 'create custom audiences', 'personal email hashes', 'increase Facebook match rate', 'Google ads audience', 'Meta audience', 'FB audience', or any workflow that turns CRM/customer/contact data into paid ads upload lists. Skip for outbound prospecting sequences, cold email, or pure campaign copywriting."
---
# Deepline Ads Audiences
## Quick Start
```bash
npm install -g deepline
# Fallback for secure sandboxes: mkdir -p "$HOME/.local" && npm config set prefix "$HOME/.local" && export PATH="$HOME/.local/bin:$PATH" && npm install -g deepline --registry https://code.deepline.com/api/v2/npm/
deepline auth register --wait auto
deepline auth wait --timeout 120 # completes Cowork/browser approval; no-op if already connected
deepline auth status
deepline -h
```
## CLI resolution
Run `deepline` when it is available. If the shell reports that command is missing, use `<workspace-root>/.deepline/runtime/bin/deepline` (or the npm-created `.cmd` shim on Windows). If neither exists, follow `https://code.deepline.com/INSTALL.md` to set up Deepline.
Build high-quality ABM paid ads audiences from first-party customer or prospect lists. This skill is for paid ads audience upload and evaluation, not outbound.
Names in this skill are starting hints. Run `deepline tools search audience --json` and `deepline tools describe <tool_id> --json` before executing because tool names and payload shapes can change. Tool search accepts an intent query or, for structured filtering, `--categories` and/or `--search_terms`; a filter-only search needs at least one of those flags. Use commas for multiple filter values, and put provider names in the query rather than using a `--prefix` flag.
## Before You Start
Use the full recipe when the user asks to enrich and upload audiences to Facebook/Meta and Google:
→ Read `recipes/enrich-and-upload-facebook-google.md`.
Use the max-coverage recipe when the user asks for "max coverage", "maximum match rate", "keep increasing coverage", "get to 75% coverage", or asks to exhaust LinkedIn/personal-email/hash options:
→ Read `recipes/max-coverage-audience.md`.
This skill is not for cold outbound, sequencing, or copywriting. Personal emails here are used to improve paid ads matching, not to contact people directly.
## Decision Matrix
| User says | Do this | Read |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------ | --------------------------------------------------------- |
| "max coverage", "highest match rate", "keep increasing coverage" | Run the explicit max-coverage ladder with budget gates. | `recipes/max-coverage-audience.md` |
| `/deepline-ads-audience`, "enrich and upload to FB/Google" | Run the full paid ads audience recipe. | `recipes/enrich-and-upload-facebook-google.md` |
| "sample ABM segment", "do the example workflow" | Follow the reusable high-priority ABM segment recipe. | `recipes/sample-abm-segment-example.md` |
| "use ContactOut hashes", "hashed identifiers", "LinkedIn URLs to hashes" | Plan a bulk pass beside the ladder, not a waterfall step. | `shared/contactout-hash-pool.md` |
| "what is a hash", "why is my match rate low", first-time user | Explain the mechanic before quoting a plan. | `shared/audience-basics.md` |
| encoded/internal-identifier LinkedIn URLs (`/in/ACwAA…`), "API rejected my LinkedIn URLs", "convert LinkedIn URLs" | Normalize `person_linkedin_url` before upload: drop encoded, recover vanity. | Step 4 → "Normalize LinkedIn URLs" (this file) |
| "Make sure hashes are not double hashed" | Run the no-double-hash audit play before upload. | `plays/audit-no-double-hash.play.ts` |
| "enrich this list", "buy personal emails/hashes", "run the ladder" | Run the waterfall. Each layer only sees rows still missing a hash. | `plays/enrich-audience-waterfall.play.ts` |
| "Compare enriched versus unenriched" | Build both hash-only datasets and report lift. | `plays/enrich-audience-waterfall.play.ts` |
| "include phone numbers", "add phones" | Hash existing phones digits-only with country code. | `shared/upload-failure-modes.md` |
| "what was the match rate", "did it match" | Read `contactIdInfo.matchRatePercentage`, not the range enum. | `shared/upload-failure-modes.md` |
| "put it in a sheet", "customer will upload" | Publish the validated file to Sheets; verify by row count. | `shared/upload-failure-modes.md` |
| "upload keeps failing", "422", "audience is locked" | Meta locks on write. Send the audience in one call. | `shared/upload-failure-modes.md` |
| "Upload to Google" | Validate hash-only rows, create Google audience, sync, readback. | `plays/upload-google-hash-only-audience.play.ts` |
| "Upload to Facebook and Google", "upload to FB/Google", "Meta + GAds" | Validate once, then upload to Google and Meta. | `plays/upload-facebook-google-hash-only-audience.play.ts` |
## Default Workflow
1. Confirm rights, use case, and geography.
2. Discover uploadable ad accounts.
3. Build baseline and enriched audience objects.
4. Validate identifiers and expected match-rate lift.
5. Create separate platform audiences.
6. Upload rows.
7. Check status and report IDs, uploaded counts, invalid rows, and current build state.
## Ask about suppression before targeting
Every audience run has a second list hiding in it: the people who should never see the ad. Current customers, closed-lost accounts, active opportunities, employees, and recent converters.
Ask for it explicitly, because users rarely volunteer it and the failure is invisible. A suppression list that was never built, or that silently failed to sync, spends budget advertising to people who already bought.
The risk is asymmetric, which decides how to handle edge cases: an extra person on a suppression list costs a few unserved impressions, while a missing one costs real money and can annoy a customer. When you are unsure whether someone belongs on it, include them.
Suppression lists match on the same identifiers as targeting lists, so a work-email-only suppression list suppresses almost nobody. Enrich it with the same ladder, or it will not do its job.
## Expect different lift per platform
Enrichment does not pay off evenly. Meta gains the most from personal-email enrichment, because personal addresses are what people register with there. Google gains less, since a Workspace address is already a Google account and often matches from the baseline.
Set that expectation before spending. A user who was promised uniform lift reads a modest Google result as a failed run, when it is the expected shape.
## Explain the shape before you spend
Users new to paid ads usually have not met hashed identifiers before, and a plan that opens with provider names reads as an opaque menu. Before running the ladder, state the mechanic in one or two sentences so the user can judge the plan rather than approve it blindly:
> Ad platforms can only match people on identifiers those people gave the platform. Your CRM holds work emails; almost nobody signs up to Meta with a work address. These layers buy the personal identifiers that do match, cheapest first.
Say what each layer costs in Deepline credits and what it is expected to add, then ask for approval before the first paid layer. A user who understands the mechanic will make a better call on where to stop, which is the only decision that controls spend here.
`shared/audience-basics.md` holds the longer explanation, including what a hash is and why it is safe to send. Point the user there when they ask, or when the run is their first.
## Coverage Modes
Choose the coverage mode before spending credits. Record it in the run notes.
| Mode | Use when | Waterfall | Stop condition |
| ---------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `cost_effective` | User asks for the default, low-cost, or first-pass enrichment. | Work-email baseline → Aviato personal hashes on all eligible rows → LimaData personal hashes on remaining personal-hash misses. Optionally a ContactOut bulk pass over rows that still lack a personal hash and have a LinkedIn URL, which runs beside the ladder rather than inside it. | Stop after the hash providers, report contacts still missing personal hashes, then ask before expanded fallback. |
| `max_coverage` | User asks for highest match rate, max coverage, or to keep increasing coverage. | Work-email baseline → phone hashes already present → LinkedIn repair → Aviato personal hashes for all eligible rows → LimaData personal hashes → ContactOut bulk pass beside the ladder → raw personal-email waterfall → platform upload variants. | Stop when no approved provider remains, budget cap is hit, marginal lift is below threshold, or rights/geo constraints block more enrichment. |
Never silently downgrade a `max_coverage` request to `cost_effective`. If a provider or credential is unavailable, report the gap and continue with the next approved provider rather than stopping early.
## Shareable Plays
This skill includes copyable play templates under `plays/`. Use them when the user asks for a repeatable or shareable workflow, not just a one-off CLI run.
Before running a template, check the installed surface when it is unclear:
```bash
deepline --help
deepline plays --help
```
Use `deepline plays` for the bundled templates. If `deepline plays` is unavailable, stop and ask for the Deepline SDK CLI to be installed or updated instead of approximating the upload through older command paths.
| Play | Purpose | Input |
| --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `plays/build-hash-only-audience.play.ts` | Build baseline and enriched hash-only datasets from source CSV rows. Raw emails are normalized and hashed once. Provider hashes pass through as lowercase hex. | `{ "file": "input.csv" }` |
| `plays/audit-no-double-hash.play.ts` | Verify the final upload payload is hash-only, deduped, populated, includes provider hashes as-is, and does not contain hash-of-hash mistakes. | `{ "payloadFile": "upload.csv", "providerHashFile": "provider-hashes.csv", "providerHashColumns": ["aviato_hash", "limadata_hash"] }` |
| `plays/build-contactout-hash-pool.play.ts` | Batch LinkedIn URLs through ContactOut hashed identifiers into a deduped hash pool. Reports matched profiles, net-new hashes, and per-chunk results. | `{ "file": "contacts.csv", "limit": 100 }` |
| `plays/upload-google-hash-only-audience.play.ts` | Create a Google Customer Match list, upload hash-only rows, and read status back. | `{ "file": "upload.csv", "account_id": "1234567890", "audience_name": "Segment enriched 2026-06-09" }` |
| `plays/upload-facebook-google-hash-only-audience.play.ts` | Upload the same validated hash-only rows to Google and an existing Meta/Facebook Custom Audience. | `{ "file": "upload.csv", "google_account_id": "1234567890", "meta_ad_account_id": "act_123", "meta_audience_id": "456", "audience_name": "Segment enriched 2026-06-09" }` |
| `plays/report-google-coverage-lift.play.ts` | After Google match rates populate, calculate coverage lift, estimated matched identifiers, spend efficiency, and a follow-up note. | `{ "account_name": "Customer Google Ads", "account_id": "1234567890", "baseline": {...}, "comparisons": [...] }` |
Recommended sequence:
```bash
deepline plays check ./plays/build-hash-only-audience.play.ts
deepline plays run --file ./plays/build-hash-only-audience.play.ts --input '{"file":"source.csv"}' --watch
deepline plays check ./plays/audit-no-double-hash.play.ts
deepline plays run --file ./plays/audit-no-double-hash.play.ts --input '{"payloadFile":"enriched_hash_only.csv","providerHashFile":"provider_hashes.csv","providerHashColumns":["aviato_hash","limadata_hash"]}' --watch
deepline plays check ./plays/upload-google-hash-only-audience.play.ts
deepline plays run --file ./plays/upload-google-hash-only-audience.play.ts --input '{"file":"enriched_hash_only.csv","account_id":"1234567890","audience_name":"ABM enriched hash-only 2026-06-09"}' --watch
deepline plays check ./plays/upload-facebook-google-hash-only-audience.play.ts
deepline plays run --file ./plays/upload-facebook-google-hash-only-audience.play.ts --input '{"file":"enriched_hash_only.csv","audience_name":"ABM enriched hash-only 2026-06-09","google_account_id":"1234567890","meta_ad_account_id":"act_123","meta_audience_id":"456"}' --watch
deepline plays check ./plays/report-google-coverage-lift.play.ts
deepline plays run --file ./plays/report-google-coverage-lift.play.ts --input '{"account_name":"Customer Google Ads","account_id":"1234567890","segment_name":"High-priority target-account audience","source_rows":20000,"baseline":{"label":"L1 work hash-only","audience_id":"1111111111","match_rate_pct":23,"uploaded_rows":13935},"comparisons":[{"label":"L2 Lima+Aviato hash-only","audience_id":"2222222222","match_rate_pct":35,"uploaded_rows":18386,"deepline_spend_usd":51.47},{"label":"L3 all hashes only","audience_id":"3333333333","match_rate_pct":43,"uploaded_rows":24787},{"label":"L4 all hashes + details","audience_id":"4444444444","match_rate_pct":42,"uploaded_rows":24787},{"label":"L5 LeadMagic top100 fallback","audience_id":"5555555555","match_rate_pct":44,"uploaded_rows":17016},{"label":"L6 GTM LinkedIn + Lima/Aviato","audience_id":"6666666666","match_rate_pct":45,"uploaded_rows":17064}],"spend":{"low_cost_hash_usd":51.47,"contact_fallback_usd":218.37,"total_usd":269.84},"recommendation_label":"L6 GTM LinkedIn + Lima/Aviato"}' --watch
```
Export dataset outputs after a run with:
```bash
deepline runs export <run-id> --out audience-output.csv
```
Before using the upload play, run account discovery from Step 2 and confirm the selected Google Ads account name and ID with the user.
## Step 1: Confirm Rights
Ask for explicit confirmation when the source data belongs to a customer workspace or third party. The minimum confirmation is:
- The source list can be used for paid ads audience creation.
- Any enrichment identifiers can be used for paid ads matching.
- The requested platforms are allowed for this use case.
- Geography is in scope. Default to US-only when personal identifiers are being enriched unless the user specifies otherwise and confirms compliance.
Do not use this skill for outbound email, phone, or sequencing. The output is audience upload data and platform audience IDs.
## Step 2: Discover Uploadable Accounts
Run account discovery before every live upload. Agents often guess account IDs from prior context, app IDs, or UI labels. That creates audiences in the wrong account or fails after enrichment spend has already happened.
Use this discovery ladder:
1. Search for live account tools:
```bash
deepline tools search "ads audience account discovery google meta linkedin" --json
deepline tools list | grep -Ei "account|audience"
```
2. If a platform exposes a direct account discovery tool or endpoint, use it first. Record account name, account ID, platform, permission status, and whether customer list upload is supported.
3. If no direct discovery tool is exposed, ask the user for the account ID and name, then validate it before upload:
```bash
deepline tools execute google_ads_audiences_list_audiences --payload '{"account_id":"1234567890","page_size":10}' --json
deepline tools execute meta_audiences_list_audiences --payload '{"ad_account_id":"1234567890"}' --json
deepline tools execute linkedin_ads_audiences_list_audiences --payload '{"account_id":"urn:li:sponsoredAccount:123456789"}' --json
```
4. Show the discovered choices back to the user as `Account Name (Account ID)`, grouped by platform. If there is more than one plausible account, ask which one to use before creating audiences.
5. Keep the selected account IDs in the run notes and final answer. A Meta app ID is not an ad account ID; the two look similar enough that agents substitute one for the other, and the upload then fails or lands in the wrong account after enrichment has already been paid for. Meta upload IDs look like `act_123...` or a numeric ad account ID that Deepline can prefix.
## Step 3: Build Baseline and Enriched Objects
Create two separate objects when evaluating lift:
- `unenriched`: first-party source identifiers only, usually work email plus name, company, country, postal code, and LinkedIn URL context.
- `enriched`: source identifiers plus paid-ads-safe enrichment. Prefer hashed personal email providers first, then raw personal-email providers that can be normalized and hashed locally.
Run this as a waterfall, not a fan-out. Every layer below runs only on rows that still have no usable hash. Sending the same row to several providers costs several times over for one identifier, and it hides: every call returns 200, so the run reads as healthy while the bill multiplies. `plays/enrich-audience-waterfall.play.ts` enforces the skipping and reports attempted, hits, and skipped per layer, so a fan-out is visible in the output.
Default personal-email waterfall for B2B paid ads:
1. Baseline first-party identifiers: valid work emails, names, company, country, postal code, LinkedIn URLs, and stable external IDs.
2. Aviato `aviato_pull_email_hash`: run on all eligible rows with enough identity context, including rows that already have work emails. Use it when the goal is ad upload and the provider returns paid-ads-ready personal email hashes. If the output cell is a JSON object, extract the scalar hash from `matched_result`, `result.data.hashedEmails[0]`, `result.data.hashed_email`, or equivalent hash fields. Do not treat the JSON object string as the upload value.
3. LimaData `limadata_find_audience_identifiers`: run on rows still missing a personal hash after Aviato, or run it first when the user asks for the most cost-effective expansion pass. Extract only normalized 64-character SHA-256 hashes from `matched_result`, `result.data.hashed_emails[].normalized_hash`, `hash`, or `sha256` fields.
ContactOut hashed identifiers do not belong in this numbered list, because they cannot waterfall. Run them as a separate bulk pass. See the section below.
### ContactOut hashed identifiers (quick reference)
ContactOut converts LinkedIn URLs straight into hashed emails, but it does not waterfall: the response is an unattributed pool, so it cannot skip rows another provider covered and later providers cannot skip rows it covered. Run it as a bulk pass beside the ladder.
- Send set: rows with a verified LinkedIn URL. Exclude rows that already have a hash for `cost_effective`, include them for `max_coverage`.
- Batch 5 to 100 per call. A zero-match chunk returns HTTP 404 and is not billed.
- Bill and report from `matches_found`, never the hash-list length.
- Merge into the audience-level hash pool, not per-row `email_sha256` cells.
→ Read `shared/contactout-hash-pool.md` before planning or running a pass. It covers why attribution cannot be recovered, the measured overlap and multi-address rates, and the Meta result.
### A work email is not a personal hash
Scope the waterfall on whether a row has a usable personal identifier, not on
whether it has any email. A work-email-only contact must run through every layer;
a work email is the L0 baseline.
The same mistake hides inside a `personal_email` column. In one run 151 rows held
a corporate address there, and each one exempted a contact from enrichment.
Re-running only those rows hit 64.9%, against 27.5% on the main pass, for 4.23
USD. Check the domain, not the column name.
### Order the ladder by what a miss costs
Two providers at similar prices are not equivalent, because they bill differently
on a miss:
| Billing | Providers | Consequence |
| --------------------- | --------------------- | ---------------------------------------------- |
| Per call, hit or miss | LimaData, Aviato | Every attempted row costs the same |
| Per result or match | LeadMagic, ContactOut | Misses are free, so they suit a thin remainder |
Measured on one 5,549-row list, cheapest first:
| Layer | Attempted | Hit rate | Spend |
| --------------------------------- | --------- | -------- | --------- |
| LimaData | 1,775 | 27.5% | 49.70 USD |
| LimaData, corporate-personal redo | 151 | 64.9% | 4.23 USD |
| ContactOut bulk | 1,772 | 53.0% | 52.64 USD |
| LeadMagic | 1,285 | 5.4% | 4.76 USD |
Run the cheapest per-call provider first so it absorbs the easy hits. A low hit
rate on the remainder means the pool is exhausted: LeadMagic cost 4.76 USD to
establish that, where a per-call provider bills the same for the same answer.
Stop after the hash providers by default. Report attempted rows, row hits, unique hashes added, contacts still missing personal hashes, and Deepline spend. Then ask whether the user wants to spend more on broader raw personal-email providers, quoting the current per-contact cost from `deepline tools describe <tool_id> --json` rather than a remembered figure. Rates change, and a stale quote in an approval gate is how users end up agreeing to a number that no longer holds.
Only run the expanded coverage pass after explicit approval. In that pass, try providers such as LeadMagic, ContactOut, Wiza, Datagma, Crustdata, Prospeo, FullEnrich, PDL, or Deepline native personal-email waterfalls on rows still missing personal hashes. Normalize and SHA-256 hash raw personal emails exactly once, record provider-level lift and Deepline spend, and keep the default upload payload hash-only.
Leave mobile phones out unless the user explicitly asks for them. They cost considerably more per contact than hashed emails, and a phone layer can consume most of a test budget before the cheap email layers have finished proving what the list can reach. Phones are worth adding as a second identifier alongside email, not as a substitute for it.
For `max_coverage`, the user has already approved the goal but not unlimited spend. Ask for or infer a budget cap before any paid fallback past the hash layer. If the user gave a cap, run the expanded pass until the cap or marginal-lift stop condition is reached.
For native waterfall outputs, include only provider-specific fields that are confirmed personal-email responses, such as `first_personal_email`, `personal_email`, `personal_emails[]`, or Wiza email values where `email_type` is personal. Do not include an untyped final scalar just because it contains an email address. Untyped final values can be work emails.
Keep row lineage in both objects:
- `external_id`
- `source_row_number`
- `person_linkedin_url`
- `company_name`
- `company_domain`
- `provider_used`
- `identifier_type`
This lets the user evaluate whether enrichment improved upload coverage without losing the source list.
### LinkedIn URL Backfill for Audience Enrichment
When LinkedIn URLs are needed before personal-email/hash enrichment, use a measured query ladder instead of one exact-company query. In a high-priority ABM eval sample, exact account-name search recovered the known URL in the top five for `51.7%` of rows, while the account-or-LinkedIn-company query recovered `65.8%`. Quoted domain search was much worse (`5.8%`) and should not be a first-pass default.
Start with the native `person-to-linkedin` play when available. It cleans company anchors with the same helper used by the API:
- `RTX Corporation` → `RTX`
- `Lockheed Martin Corporation` → `Lockheed Martin`
- `NASA - National Aeronautics and Space Administration` → `NASA` plus a secondary long-form alias
- `L3Harris (formerly Aerojet Rocketdyne)` → `L3Harris`
- `Siemens Energy Global GmbH & Co. KG` → `Siemens Energy`
- `Airbus EMEA` → `Airbus`
The first Serper query should use the cleaned account/LinkedIn company aliases:
```text
"{{full_name}}" ("{{account_name}}" OR "{{linkedin_company_name}}") site:linkedin.com/in -inurl:dir -inurl:pub
```
Then validate candidates before using them:
1. Keep only `linkedin.com/in/` URLs and strip query params/trailing slashes.
2. Reject search results where the profile title does not contain a first-name match and a last-name match. Allow common nicknames and meaningful first-name prefixes; do not accept single-letter last-name initials as validated.
3. Use company/title evidence as supporting evidence, not as identity proof.
4. For ambiguous candidates, retrieve the profile with `harvestapi_get_profile` and validate `element.firstName`, `element.lastName`, current company, and headline before merging. This catches snippet false positives such as a search result mentioning the target name in another person's experience section.
Use follow-up queries only after the first pass misses:
```text
"{{full_name}}" "{{title}}" "{{linkedin_company_name}}" site:linkedin.com/in -inurl:dir -inurl:pub
"{{full_name}}" "{{account_name}}" site:linkedin.com/in -inurl:dir -inurl:pub
```
Do not make quoted domain (`"{{domain}}"`) the first query. It can help occasional rows, but in the eval sample it reduced recall and caused more provider failures.
## Step 4: Validate Identifiers
Before upload, remove empty strings and malformed hashes from the payload. Deepline platform validators reject empty `email` fields and non-64-character `email_sha256` values.
Valid upload row fields include:
- `email`
- `email_sha256`
- `phone`
- `phone_sha256`
- `first_name`
- `last_name`
- `country_code`
- `postal_code`
- `company_name`
- `title`
- `company_domain`
- `person_linkedin_url`
- `external_id`
Prefer `email_sha256` when a provider returns a paid-ads-ready hash. Provider hashes must pass through exactly as lowercase 64-character hex. Do not double-hash provider hashes.
### Standard upload file shape
Use one shape for every run, so the audit, the Sheets export and both platform
uploads read the same file:
```
email,phone,fn,ln,country
```
- `email` and `phone` hold 64-character lowercase SHA-256 digests, or nothing.
- Every row carries at least one of the two. Drop rows with neither.
- `fn` and `ln` are lowercase `a-z` only. `country` is a two-letter code.
- Keep a parallel `..._lineage.csv` with a `source` column naming the layer that
produced each hash.
Blank cells are deliberate: a ContactOut-pool row has no name, a phone-only row
has no email.
Before uploading, assert that each column holds one identifier type and that no
value is the SHA-256 of another value in the file. Both failures upload cleanly
and match nothing.
→ Read `shared/upload-failure-modes.md`.
When a provider returns raw personal email:
1. Trim whitespace.
2. Lowercase the email.
3. Validate it with a normal email pattern.
4. Hash the normalized email with SHA-256 exactly once.
5. Upload only `email_sha256` unless the user explicitly asked to upload raw email fields.
### Normalize LinkedIn URLs (drop encoded internal-identifier URLs)
The Ad Audiences APIs (batch and live) accept only **vanity** LinkedIn URLs —
`linkedin.com/in/<username>` (e.g. `linkedin.com/in/scott`). They do **not**
accept **encoded internal-identifier** URLs, where the slug is an opaque
member-identity token — e.g.
`https://www.linkedin.com/in/ACwAAA3jFR0B90FE7rqSIhnof5R9h57ZrfgYR7w`. These come
from Sales Navigator exports and some enrichment providers. The endpoint tolerates
them today but **will return `400` for that input soon**, so treat an encoded URL
as invalid `person_linkedin_url` before upload.
Detect and handle every `person_linkedin_url` cell:
1. **Detect encoded** — the slug after `/in/` starts with `ACwAA` / `ACoAA` (or is
a long single-token Base64URL-ish string, ~30+ chars of `[A-Za-z0-9_-]` with no
hyphenated name and no digits-as-suffix). That is an internal identifier, not a
username.
2. **Do not upload it as-is.** Drop the encoded value from `person_linkedin_url`
for that row so the platform validator does not reject the whole payload.
3. **Recover the vanity URL when the row still needs LinkedIn coverage** — run the
LinkedIn URL Backfill ladder from Step 3 (native `person-to-linkedin`, then the
Serper name + company query) to resolve the real `linkedin.com/in/<username>`.
This is the same helper that produces upload-ready vanity URLs elsewhere in this
skill.
4. **Keep vanity URLs as-is** — `linkedin.com/in/<username>`: strip query params and
trailing slashes, lowercase the host, and upload.
Report, in the Step 4 audit, how many `person_linkedin_url` rows were encoded
(dropped), how many were recovered to a vanity URL, and how many were already
valid — an all-encoded input silently uploading zero LinkedIn identifiers is the
failure this step exists to prevent.
Before live upload, write a small audit with:
- source row count
- valid baseline work-email count
- provider row hits
- hashes seen by provider
- unique hashes added by provider
- invalid hash rows
- whether any raw `email` field remains in the upload payload
- LinkedIn URLs: encoded (dropped), recovered to vanity, already valid
## Step 5: Create and Upload
Create one audience per platform per object. Name them so the account UI makes the test clear:
- `<customer or segment> enriched <date>`
- `<customer or segment> unenriched <date>`
Then upload each object separately.
Current starting hints:
```bash
deepline tools execute google_ads_audiences_create_audience --payload '{"account_id":"1234567890","name":"Example enriched hash-only","membership_life_span_days":540,"upload_key_types":["CONTACT_ID"]}' --json
deepline tools execute google_ads_audiences_sync_audience_members --payload '{"account_id":"1234567890","audience_id":"1111111111","mode":"append","terms_of_service_accepted":true,"consent":{"ad_user_data":"GRANTED","ad_personalization":"GRANTED"},"rows":[{"email_sha256":"973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b"}]}' --json
deepline tools execute meta_audiences_create_audience --payload '{"ad_account_id":"1234567890","name":"Example enriched hash-only","customer_file_source":"BOTH_USER_AND_PARTNER_PROVIDED"}' --json
deepline tools execute meta_audiences_sync_audience_members --payload '{"ad_account_id":"1234567890","audience_id":"1111111111","mode":"replace","rows":[{"email_sha256":"973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b"}]}' --json
deepline tools execute linkedin_ads_audiences_create_audience --payload '{"account_id":"urn:li:sponsoredAccount:123456789","name":"Example enriched hash-only","audience_kind":"contacts"}' --json
deepline tools execute linkedin_ads_audiences_sync_audience_members --payload '{"account_id":"urn:li:sponsoredAccount:123456789","audience_id":"1111111111","mode":"replace","rows":[{"email_sha256":"973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b"}]}' --json
```
Use `append` for Google when uploading into a newly created empty audience if `replace` returns provider-side Data Manager payload errors. Report that clearly because it indicates connector behavior that should be fixed.
### Upload the whole audience in one call
Meta locks an audience on write and rejects later writes with HTTP 422 until
ingestion finishes, so a batched loop leaves the audience holding part of the
list. Send every row in one `sync_audience_members` call, and pass the payload as
`--payload @file.json` because inline JSON exceeds the shell argument limit.
Omit blank string fields rather than sending `""`; the schema declares
`minLength: 1` and an empty value fails the whole batch.
→ Read `shared/upload-failure-modes.md`.
## Step 6: Verify and Report
Read Google's match rate from
`ingestedUserListInfo.contactIdInfo.matchRatePercentage`. The `matchRateRange`
enum beside it stays unset, so reading the enum alone reports `null` for an
audience with a real rate, and a report that renders `null` as 0% turns a good
run into an apparent failure. Meta exposes no per-audience match rate.
`uploaded_count` and `invalid_count` describe whether rows parsed, not whether
people matched. Report acceptance and match rate separately.
→ Read `shared/upload-failure-modes.md`.
Run status checks after upload:
```bash
deepline tools execute google_ads_audiences_get_audience_status --payload '{"account_id":"1234567890","audience_id":"1111111111"}' --json
deepline tools execute meta_audiences_get_audience_status --payload '{"ad_account_id":"1234567890","audience_id":"1111111111"}' --json
deepline tools execute linkedin_ads_audiences_get_audience_status --payload '{"account_id":"urn:li:sponsoredAccount:123456789","audience_id":"1111111111"}' --json
```
Final answer format:
- Platform and account name plus ID.
- Audience name and audience ID.
- Object type: enriched or unenriched.
- Uploaded count.
- Invalid count.
- Request IDs or session IDs.
- Current status. Note that match size and match-rate ranges may stay null while platforms process the audience.
If upload fails, report the provider error category and request ID. Do not say the audience worked unless create, sync, and readback status all succeeded.
### Google Coverage Follow-Up Reporting
After Google match rates populate, use `plays/report-google-coverage-lift.play.ts` to prevent hand-calculation drift. The play should be run from the same match-rate readback that listed the Google account name and ID. It calculates:
- percentage-point lift versus baseline
- relative lift versus baseline
- estimated matched identifiers from uploaded rows and match rate
- incremental matched identifiers versus baseline
- Deepline spend and blended cost per incremental matched identifier when spend is provided
- a ready-to-send follow-up note
Use anonymized or customer-approved values in customer-facing follow-up notes. Keep account IDs, audience IDs, match rates, spend, and provider-layer labels tied to the current run artifact rather than copying old example values.
Do not expose provider-side unit costs in customer-facing messages. Report Deepline spend only.
## Reading guide
| If you're about to… | Read |
| ------------------------------------------------------------------- | ---------------------------------------------- |
| Explain hashing, match rate, or platform rules to a first-time user | `shared/audience-basics.md` |
| Plan or run a ContactOut hashed-identifier pass | `shared/contactout-hash-pool.md` |
| Enrich and upload to Meta and Google end to end | `recipes/enrich-and-upload-facebook-google.md` |
| Push a list as far as the budget allows | `recipes/max-coverage-audience.md` |
| Follow the worked ABM example | `recipes/sample-abm-segment-example.md` |