Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/api/src/autogtm-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,27 @@ export const OPERATIONS: readonly Operation[] = [
},
},
},
{
method: 'get',
path: '/autogtm/campaigns/{campaign_id}/accounts',
id: 'getAccounts',
tag: 'Campaigns',
summary: 'Account expansion: who at each company was reached, who is next',
description:
'Contacts are classified from their job title as budget holder, pain feeler (end user), ' +
'blocker (legal, security, IT, finance) or champion (manager). One contact per company is ' +
'written to at a time: a colleague emailed in the last 21 days, or on an active cadence, ' +
'holds the company until they answer or the window closes. Queued colleagues go in persona ' +
'order, each drafted with an angle for their role. `missing` lists personas nobody covers yet.',
params: [idParam('campaign_id', 'The campaign.')],
response: {
type: 'object',
properties: {
gap_days: { type: 'integer' },
accounts: { type: 'array', items: { type: 'object' } },
},
},
},
{
method: 'get',
path: '/autogtm/campaigns/{campaign_id}/list-health',
Expand Down
33 changes: 33 additions & 0 deletions apps/api/src/autogtm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
autogtmStatus,
CONTACT_PRICE_USD,
isConsumerMailDomain,
EXPANSION_GAP_DAYS,
mapHeaders,
MAX_BOUNCE_RATE,
newId,
Expand All @@ -43,6 +44,7 @@ import { now, queryAll, queryOne, type Client } from '@outreachgraph/db';
import {
applyProjectBudget,
budgetStatus,
campaignAccounts,
campaignBudgetFrom,
countScreenedHeld,
crawlDedupeKey,
Expand Down Expand Up @@ -996,6 +998,37 @@ export function autogtmRoutes(deps: AutogtmDeps): Hono<AppEnv> {
});
});

/**
* Account expansion: each company in the campaign with who has been reached,
* who is next, and which of the four personas nobody covers yet.
*/
r.get('/campaigns/:id/accounts', async (c) => {
const actor = c.get('actor');
const db = c.get('db');
const campaign = await ownedCampaign(db, actor.workspaceId, c.req.param('id'));
const accounts = await campaignAccounts(db, {
workspaceId: actor.workspaceId,
campaignId: campaign.id,
});
return c.json({
campaign_id: campaign.id,
gap_days: EXPANSION_GAP_DAYS,
accounts: accounts.map((account) => ({
company_id: account.companyId,
company: account.company,
missing: account.missing,
contacts: account.contacts.map((contact) => ({
person_id: contact.personId,
name: contact.name,
title: contact.title,
persona: contact.persona,
state: contact.state,
last_contacted_at: contact.lastContactedAt,
})),
})),
});
});

r.get('/campaigns/:id/enrichment', async (c) => {
const actor = c.get('actor');
const db = c.get('db');
Expand Down
27 changes: 25 additions & 2 deletions apps/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,29 @@ async function runLeads({ client, args, flags }: CommandContext): Promise<string
.join('\n');
}

if (verb === 'accounts') {
if (!target) throw new Error('usage: og leads accounts <campaignId>');
const result = (await client.get(
`/autogtm/campaigns/${encodeURIComponent(target)}/accounts`,
)) as Record<string, unknown>;
const accounts = (result.accounts ?? []) as Record<string, unknown>[];
if (accounts.length === 0) return 'No companies in this campaign yet.';
return accounts
.map((account) => {
const contacts = (account.contacts ?? []) as Record<string, unknown>[];
const missing = (account.missing ?? []) as string[];
return [
`${text(account, 'company')}${missing.length ? ` (missing: ${missing.join(', ')})` : ''}`,
...contacts.map(
(contact) =>
` ${text(contact, 'state').padEnd(10)} ${text(contact, 'persona').padEnd(13)} ` +
`${text(contact, 'name')}${contact.title ? `, ${text(contact, 'title')}` : ''}`,
),
].join('\n');
})
.join('\n');
}

if (verb === 'health') {
if (!target) throw new Error('usage: og leads health <campaignId>');
const health = (await client.get(
Expand Down Expand Up @@ -336,7 +359,7 @@ async function runLeads({ client, args, flags }: CommandContext): Promise<string
}

throw new Error(
'usage: og leads add|report|screened|enrich|enrichment|health|allow|hold … (og help)',
'usage: og leads add|report|screened|enrich|enrichment|health|accounts|allow|hold … (og help)',
);
}

Expand Down Expand Up @@ -1716,7 +1739,7 @@ export const COMMANDS: readonly Command[] = [
{
name: 'leads',
usage:
'og leads add <campaignId> <file.csv> --consent-source "<where>" [--allow-flagged] [--keep-project-duplicates] [--report out.csv] | report <taskId> [--csv] | screened <campaignId> [--all] | enrich <campaignId> [--max <searches>] | enrichment <campaignId> | health <campaignId> | allow <personId> | hold <personId>',
'og leads add <campaignId> <file.csv> --consent-source "<where>" [--allow-flagged] [--keep-project-duplicates] [--report out.csv] | report <taskId> [--csv] | screened <campaignId> [--all] | enrich <campaignId> [--max <searches>] | enrichment <campaignId> | health <campaignId> | accounts <campaignId> | allow <personId> | hold <personId>',
summary: 'Add a CSV of leads to a running campaign, with a per-row report and screening.',
run: runLeads,
},
Expand Down
16 changes: 16 additions & 0 deletions apps/mcp/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,22 @@ export const TOOLS: readonly ToolDefinition[] = [
`/autogtm/campaigns/${encodeURIComponent(require(args, 'campaignId'))}/enrichment`,
),
},
{
name: 'get_campaign_accounts',
title: 'Account expansion per company',
description:
'Each company in a campaign with its contacts classified as budget holder, pain feeler, ' +
'blocker or champion; who was contacted, who replied, who is next in line (one contact per ' +
'company at a time, 21 days apart), and which personas nobody covers yet.',
readOnly: true,
inputSchema: {
type: 'object',
properties: { campaignId: { type: 'string' } },
required: ['campaignId'],
},
run: (client, args) =>
client.get(`/autogtm/campaigns/${encodeURIComponent(require(args, 'campaignId'))}/accounts`),
},
{
name: 'get_list_health',
title: 'A campaign’s bounce rate and address verdicts',
Expand Down
22 changes: 20 additions & 2 deletions packages/ai/src/draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@
* fabricated one.
*/

import { newId, type ActionKind, type Network, type OutreachStyle } from '@outreachgraph/domain';
import {
classifyPersona,
newId,
PERSONA_ANGLES,
type ActionKind,
type Network,
type OutreachStyle,
} from '@outreachgraph/domain';
import { now, queryAll, queryOne, type Client } from '@outreachgraph/db';
import { composeDraft, type ComposeResult, type TextModel } from '@outreachgraph/ai';
import { draftReplyForRecommendation } from './draft-reply';
Expand Down Expand Up @@ -66,6 +73,17 @@ export async function draftForRecommendation(
return draftReplyForRecommendation(db, model, recommendation.id);
}

// A fresh angle per role (account expansion): the budget holder hears the
// outcome, the end user the friction, the reviewer the objection answered,
// the manager the proof to forward. Added to whatever the step asked for.
const titled = await queryOne<{ current_title: string | null }>(
db,
'SELECT current_title FROM people WHERE id = ?',
[recommendation.person_id],
);
const angle = PERSONA_ANGLES[classifyPersona(titled?.current_title)];
const guidance = [recommendation.guidance?.trim(), angle].filter(Boolean).join(' ') || undefined;

// No trigger means nothing to quote, and §14.1 forbids personalising
// without evidence.
if (!recommendation.trigger_signal_id) return { ok: false, reason: 'no_trigger_signal' };
Expand Down Expand Up @@ -189,7 +207,7 @@ export async function draftForRecommendation(
: {}),
minIdentityConfidence: workspace?.min_outreach_confidence ?? 0.85,
priorDraftHashes: priorHashes.map((r) => r.similarity_hash),
...(recommendation.guidance ? { guidance: recommendation.guidance } : {}),
...(guidance ? { guidance } : {}),
});

if (!result.ok) {
Expand Down
1 change: 1 addition & 0 deletions packages/domain/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,5 @@ export * from './replies';
export * from './webhooks';
export * from './mailbox-health';
export * from './list-quality';
export * from './personas';
export * from './warmup';
58 changes: 58 additions & 0 deletions packages/domain/src/personas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, test } from 'bun:test';
import { classifyPersona, orderForExpansion, personaRank } from './personas';

describe('classifyPersona', () => {
test.each([
['CEO', 'budget_holder'],
['Co-founder & CTO', 'budget_holder'],
['VP of Sales', 'budget_holder'],
['Head of Growth', 'budget_holder'],
['Director, Marketing', 'budget_holder'],
['General Counsel', 'blocker'],
['VP Legal', 'blocker'],
['Chief Information Security Officer', 'blocker'],
['CFO', 'blocker'],
['Procurement Manager', 'blocker'],
['IT Manager', 'blocker'],
['Engineering Manager', 'champion'],
['Team Lead, Support Operations', 'champion'],
['Senior Software Engineer', 'pain_feeler'],
['Marketing Specialist', 'pain_feeler'],
['Customer Success Associate', 'pain_feeler'],
['', 'unknown'],
[null, 'unknown'],
['Wizard', 'unknown'],
] as const)('%s → %s', (title, persona) => {
expect(classifyPersona(title)).toBe(persona);
});

test('ranks in planner order, unknown last', () => {
expect(personaRank('budget_holder')).toBe(0);
expect(personaRank('pain_feeler')).toBe(1);
expect(personaRank('blocker')).toBe(2);
expect(personaRank('champion')).toBe(3);
expect(personaRank('unknown')).toBe(4);
});
});

describe('orderForExpansion', () => {
test('a company opens with its budget holder, other companies keep their places', () => {
const queue = [
{ id: 'acme-engineer', company: 'acme', title: 'Software Engineer' },
{ id: 'solo', company: null, title: 'CEO' },
{ id: 'acme-ceo', company: 'acme', title: 'CEO' },
{ id: 'beta-cfo', company: 'beta', title: 'CFO' },
];
const ordered = orderForExpansion(
queue,
(item) => item.company,
(item) => classifyPersona(item.title),
);
expect(ordered.map((item) => item.id)).toEqual([
'acme-ceo',
'acme-engineer',
'solo',
'beta-cfo',
]);
});
});
114 changes: 114 additions & 0 deletions packages/domain/src/personas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Buyer personas and account expansion (Hunter's outreach planner, run by the
* worker).
*
* Every target company has four people worth reaching, and the planner reaches
* them one at a time, a month apart, each with its own angle:
*
* 1. **Budget holder** — signs off. Reached first, while budgets are fresh.
* 2. **Pain feeler** — the end user who lives with the problem.
* 3. **Blocker** — legal, security, IT, finance, procurement: the people who
* can say no, brought in before they do.
* 4. **Champion** — the manager who carries it internally.
*
* Two rules come with it, and both are enforced at send time:
*
* - **Never two contacts at one company at once.** While anyone at a company
* is mid-sequence (`EXPANSION_GAP_DAYS` since their last message), every
* colleague waits.
* - **In order.** When several people at one company are queued, the one
* whose persona comes earliest goes first, so a company is opened by the
* person who can buy and widened from there.
*
* Classification is a deterministic reading of the job title: the policy path
* never asks a model, and a title is what we reliably have.
*/

export const PERSONAS = ['budget_holder', 'pain_feeler', 'blocker', 'champion'] as const;
export type Persona = (typeof PERSONAS)[number];
export type PersonaOrUnknown = Persona | 'unknown';

/** Days after one contact's last message before a colleague may be written to. */
export const EXPANSION_GAP_DAYS = 21;

export const PERSONA_LABELS: Readonly<Record<PersonaOrUnknown, string>> = {
budget_holder: 'budget holder',
pain_feeler: 'pain feeler',
blocker: 'blocker',
champion: 'champion',
unknown: 'unclassified',
};

/** What to say to each, so the composer writes a fresh angle per role. */
export const PERSONA_ANGLES: Readonly<Record<PersonaOrUnknown, string>> = {
budget_holder:
'Write to the person who owns the budget: lead with the business outcome (pipeline, revenue, cost, risk) and ask one question answerable in ten seconds.',
pain_feeler:
'Write to the person who does the work day to day: name the specific friction they live with and how it goes away. No executive framing.',
blocker:
'Write to the legal, security, IT or finance reviewer: answer their objection before they raise it (compliance, data handling, integration effort, total cost).',
champion:
'Write to the manager who would carry this internally: give them the proof they would forward (a result, a number, a short case) and reference the colleague already in touch.',
unknown: '',
};

// Order matters: a "VP of Legal" is a blocker before a budget holder, and a
// "Chief Security Officer" likewise. Finance, legal and security titles are
// checked before seniority.
const BLOCKER =
/\b(legal|counsel|attorney|lawyer|compliance|privacy|security|infosec|ciso|risk|procurement|purchasing|sourcing|vendor management|finance|financial|cfo|controller|comptroller|accounting|accountant|treasury|audit|it manager|it director|head of it|it operations|sysadmin|system administrator|information technology|data protection|dpo|gdpr)\b/i;
const BUDGET =
/\b(ceo|coo|cto|cmo|cro|cpo|cio|chief|founder|co-?founder|owner|president|vp|vice president|svp|evp|head of|director|general manager|gm|managing director|partner|principal owner|proprietor|board)\b/i;
const CHAMPION =
/\b(manager|lead|team lead|principal|staff|senior manager|supervisor|coordinator lead|architect)\b/i;
const PAIN =
/\b(engineer|developer|programmer|designer|analyst|specialist|associate|coordinator|representative|rep|executive assistant|assistant|administrator|operator|technician|marketer|writer|editor|consultant|scientist|researcher|agent|recruiter|sdr|bdr|ae|account executive|support|success|intern)\b/i;

export function classifyPersona(title: string | null | undefined): PersonaOrUnknown {
const text = (title ?? '').trim();
if (!text) return 'unknown';
if (BLOCKER.test(text)) return 'blocker';
if (BUDGET.test(text)) return 'budget_holder';
if (CHAMPION.test(text)) return 'champion';
if (PAIN.test(text)) return 'pain_feeler';
return 'unknown';
}

/** Position in the expansion order; unclassified people go last. */
export function personaRank(persona: PersonaOrUnknown): number {
const index = (PERSONAS as readonly string[]).indexOf(persona);
return index === -1 ? PERSONAS.length : index;
}

/**
* Orders a send queue so each company's earliest persona comes first, without
* disturbing the order between companies.
*
* A company takes the position of its best-ranked card in the original order
* (priority already decided that), and its cards follow one another in persona
* order. Cards with no company keep their own position.
*/
export function orderForExpansion<T>(
items: readonly T[],
companyOf: (item: T) => string | null | undefined,
personaOf: (item: T) => PersonaOrUnknown,
): T[] {
const firstSeen = new Map<string, number>();
items.forEach((item, index) => {
const company = companyOf(item);
if (company && !firstSeen.has(company)) firstSeen.set(company, index);
});

return items
.map((item, index) => {
const company = companyOf(item);
return {
item,
index,
group: company ? (firstSeen.get(company) as number) : index,
rank: company ? personaRank(personaOf(item)) : 0,
};
})
.sort((a, b) => a.group - b.group || a.rank - b.rank || a.index - b.index)
.map((entry) => entry.item);
}
Loading
Loading