Initial commit: prospecting agent loop MVP

Closed-loop account-intelligence system: harvest -> relevance filter vs.
Capability Library -> synthesize -> score -> deliver -> feedback.

- 40-capability Broadcom + VMware library
- Live Exa.ai news connector with hard free-plan request ceilings
- Provider-agnostic LLM layer (Gemini/Anthropic) with offline mock fallback
- LLM confidence wired into composite scoring + low-confidence floor
- 7-account pilot list

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 11:39:59 -04:00
commit 39a338990f
35 changed files with 4020 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
# Secrets — never commit
**/.env
.env
# Generated artifacts / local state
**/output/
sales-agent/output/
# Python
**/__pycache__/
*.pyc
*.pyo
# OS
.DS_Store

35
README.md Normal file
View File

@@ -0,0 +1,35 @@
# Prospecting Agent Loop
A closed-loop, account-intelligence system for enterprise sales prospecting: it
monitors a list of target accounts, harvests business signals (news/web today;
LinkedIn, job postings, and filings to come), evaluates them against a structured
**Capability Library** of sellable products, and routes scored, signal-grounded
opportunity briefs to the right rep — with rep feedback flowing back into scoring.
## Contents
- **[`sales-prospecting-agent-loop.md`](sales-prospecting-agent-loop.md)** — the full
design document (architecture, agent loop phases, scoring, feedback loop, roadmap).
- **[`sales-agent/`](sales-agent/)** — the runnable MVP. See
[`sales-agent/README.md`](sales-agent/README.md) for setup and usage.
## Status (MVP)
- End-to-end loop runs: harvest → normalize/dedup → relevance filter → synthesize →
score → deliver → feedback → recalibrate.
- **Capability Library** seeded with 40 real Broadcom + VMware capabilities.
- **News connector**: live Exa.ai with hard request ceilings (free-plan safe).
- **LLM reasoning**: provider-agnostic (Gemini or Anthropic), with an offline mock
fallback so the pipeline runs without a key.
- **Pilot accounts**: 7 real companies wired in (~200 to follow).
Secrets live in `sales-agent/.env` (gitignored). See `sales-agent/.env.example`.
## Quick start
```bash
cd sales-agent
cp .env.example .env # add your keys (Exa, Gemini/Anthropic)
python3 run.py loop # run the loop
python3 run.py digest # per-rep daily digest
```

33
sales-agent/.env.example Normal file
View File

@@ -0,0 +1,33 @@
# Copy to .env and fill in. The loop runs WITHOUT a key using a mock reasoner.
# --- LLM provider ---
# LLM_PROVIDER: gemini | anthropic (auto-detected from whichever key is set,
# Gemini preferred, if left blank). Provider with a missing key => mock reasoner.
LLM_PROVIDER=gemini
GEMINI_API_KEY=
GEMINI_MODEL=gemini-2.5-flash
ANTHROPIC_API_KEY=
LLM_MODEL=claude-sonnet-4-6
# Force the offline mock reasoner even if a key is set:
# USE_MOCK_LLM=1
# Scoring thresholds (design Phase 5)
PRIORITY_THRESHOLD=0.72
STANDARD_THRESHOLD=0.45
# --- Exa.ai news/web harvester ---
# Without a key, the news connector falls back to data/fixtures.json.
EXA_API_KEY=
EXA_ENABLED=1
# Hard ceilings to stay inside the free plan (~20,000 req/month). The connector
# refuses to call Exa once either cap is reached. Monthly count persists in
# output/state/exa_usage.json and resets at the start of each calendar month.
EXA_MONTHLY_REQUEST_CAP=1000
EXA_PER_RUN_REQUEST_CAP=25
EXA_RESULTS_PER_QUERY=5
EXA_LOOKBACK_DAYS=45
# Scope news to each account's own domain (1, default) to cut ticker/analyst
# noise, or search the broad web (0). Override per run: loop --no-domain-filter
EXA_DOMAIN_FILTER=1

4
sales-agent/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
output/
.env
__pycache__/
*.pyc

165
sales-agent/README.md Normal file
View File

@@ -0,0 +1,165 @@
# Sales Prospecting Agent Loop — MVP (v1)
A runnable first cut of the closed-loop account-intelligence system described in
[`../sales-prospecting-agent-loop.md`](../sales-prospecting-agent-loop.md). It
harvests signals per account, evaluates them against a capability library, scores
and synthesizes opportunity briefs, delivers them, and feeds rep feedback back into
scoring — the full loop, end to end.
## ⚠️ What's real vs. placeholder in this version
This MVP is built to **run today with zero setup** so we can review output quality
and iterate. Deliberate v1 simplifications (we'll revisit each tomorrow):
| Design calls for | v1 MVP uses | Why |
|---|---|---|
| TypeScript / Node + BullMQ | **Python 3, stdlib only** | Node isn't installed on this machine; concepts map 1:1. Easy to port. |
| Proxycurl/Apify, JSearch, SEC EDGAR | **Fixture file** (`data/fixtures.json`) | No API keys needed; swap each connector for the real API. |
| Exa.ai news/web search | **LIVE** when `EXA_API_KEY` set (else fixtures) | Real connector with hard request ceilings — see below. |
| Salesforce/HubSpot | **`data/accounts.json`** + console write-back | Read context + log-activity stub. |
| Postgres + Redis + pgvector | **JSON files** under `output/state/` | Hash dedup works; semantic dedup is a documented v2 hook. |
| Vector ANN capability pre-filter | **Keyword-overlap pre-filter** | Same interface, no embedding service. |
| Slack Bolt + SendGrid | **Console + markdown files** | Briefs render Slack-style; digest writes to `output/digest.md`. |
| LLM relevance + synthesis | **Gemini or Anthropic when a key is set, else a deterministic mock reasoner** | Provider-agnostic (`LLM_PROVIDER`); loop runs offline without a key. |
## Run it
```bash
cd sales-agent
# 1. Run the loop over all target accounts (harvest -> deliver)
python3 run.py loop
# Single account or one tier:
python3 run.py loop --account acct_meridian
python3 run.py loop --tier strategic
# 2. See the per-rep daily digest
python3 run.py digest
python3 run.py digest --rep rep_sarah
# 3. Record rep feedback (closes the loop)
python3 run.py feedback --brief <BRIEF_ID> --action act_on
# actions: act_on | snooze | reject | won | lost (reject also suppresses 14d)
# 4. Recompute scoring weights from feedback (the weekly recalibration job)
python3 run.py recalibrate
# Helpers
python3 run.py accounts # list target accounts
python3 run.py briefs # list all generated briefs + status
```
### LLM reasoning (provider-agnostic)
The relevance filter and synthesizer call whichever provider is configured. Set in
`.env`:
```bash
# Gemini (current default)
LLM_PROVIDER=gemini
GEMINI_API_KEY=...
GEMINI_MODEL=gemini-2.5-flash
# or Anthropic
LLM_PROVIDER=anthropic
ANTHROPIC_API_KEY=...
LLM_MODEL=claude-sonnet-4-6
```
If `LLM_PROVIDER` is blank it auto-detects from whichever key is present (Gemini
preferred). With no key, a deterministic mock reasoner produces grounded briefs so
the full pipeline still runs offline. Both providers use plain `urllib` — no SDKs.
## Exa.ai news connector & free-plan protection
The news harvester ([`src/connectors/exa.py`](src/connectors/exa.py)) calls the real
Exa search API (`POST https://api.exa.ai/search`) when `EXA_API_KEY` is set, and
falls back to fixtures otherwise. **Two hard ceilings keep you inside the free plan**
(Exa's free tier is ~20,000 requests/month; we default far below that):
- `EXA_MONTHLY_REQUEST_CAP` (default **1000**) — a persistent counter in
`output/state/exa_usage.json` that resets at the start of each calendar month.
Once hit, the connector refuses to call Exa and falls back to fixtures.
- `EXA_PER_RUN_REQUEST_CAP` (default **25**) — a per-process cap so a single loop
can't burn through the budget.
A request is only counted when a call actually reaches Exa (a billable call);
network failures that never hit Exa are not counted. Each account uses **one** Exa
request per loop run (so the default 3 sample accounts = 3 requests/run).
```bash
python3 run.py exa-usage # show remaining monthly budget
```
### Domain scoping (default on)
By default, news search is **scoped to each account's own `domain`** (via Exa
`includeDomains`) — newsroom, press releases, investor-relations — which cuts out
stock-ticker and analyst noise from a bare company-name search. Toggle it:
```bash
python3 run.py loop --no-domain-filter # widen to broad third-party web coverage
# or set EXA_DOMAIN_FILTER=0 in .env to default to broad
```
Note: domain scoping only returns results when the account's real domain has indexed
content. The fictional sample accounts return 0 domain-scoped articles by design —
real target-account domains will return their first-party news.
Tune all limits in `.env` (`EXA_MONTHLY_REQUEST_CAP`, `EXA_PER_RUN_REQUEST_CAP`,
`EXA_RESULTS_PER_QUERY`, `EXA_LOOKBACK_DAYS`). Set `EXA_ENABLED=0` to force fixtures.
## How the loop maps to the design
```
run.py loop
└─ src/pipeline.run_account_cycle (design §7.3)
1. agents/harvester Phase 1 signal harvest (connectors/*)
2. agents/normalizer Phase 2 normalize + hash dedup
3. agents/relevance Phase 3 LLM vs. capability library
4. agents/synthesizer Phase 4 opportunity briefs
5. agents/scorer Phase 5 composite score + priority
6. delivery/render Phase 7 deliver (console/md) + CRM log
7. memory/store Phase 6 account memory + suppression
feedback ─ memory/feedback §6 feedback store + recalibration
```
## Project layout
```
sales-agent/
├── run.py CLI entrypoint
├── data/
│ ├── accounts.json target accounts (stand-in for CRM)
│ ├── capabilities.json the Capability Library (the "brain")
│ └── fixtures.json placeholder harvested signals
├── src/
│ ├── config.py paths, thresholds, .env loader
│ ├── types.py RawSignal / NormalizedSignal / Brief dataclasses
│ ├── llm.py Anthropic call (urllib) + mock fallback
│ ├── capabilities.py library loader + keyword pre-filter
│ ├── connectors/ linkedin, news, jobs, crm (fixture-backed)
│ ├── agents/ harvester, normalizer, relevance, synthesizer, scorer
│ ├── memory/ store (dedup/memory/briefs) + feedback (recalibration)
│ ├── delivery/ render (briefs) + digest
│ └── pipeline.py the per-account orchestration
└── output/ generated briefs, digest, and JSON state (created on run)
```
## Suggested review focus for tomorrow
1. **Capability Library** (`data/capabilities.json`) — now seeded with 40 real
Broadcom/VMware capabilities across Mainframe (13), Enterprise Software (9),
Identity & Access Management (6), and VMware by Broadcom (12). Review wording of
buying-signals/anti-signals and add any missing SKUs. The prior placeholder library
is kept at `data/capabilities.placeholder.json`.
2. **Accounts** (`data/accounts.json`) — sample BSG target accounts; swap for a real
target-account list + CRM pull.
3. **Scoring weights** — the composite formula in `agents/scorer.py` uses the design's
weights; tune the tier/seniority/timing tables.
4. **Connectors** — Exa.ai news is now **live** (with request caps). Improve query
precision (a bare company name pulls in stock-ticker noise — add domain filtering
via the account `domain`), then wire the next source (jobs/LinkedIn).
5. **Reasoning quality** — set an API key and compare Claude briefs vs. the mock.
```

View File

@@ -0,0 +1,82 @@
{
"_note": "Pilot target list (7 accounts; ~200 total to come). REAL fields: accountName, domain, industry. PLACEHOLDER fields to confirm from CRM/install-base: accountTier, assignedRep, currentProducts (drives anti-signal suppression — empty here means nothing is suppressed yet), targetContacts (empty; the LLM/signal authors identify the ideal contact, CRM enrichment fills the rest).",
"accounts": [
{
"accountId": "acct_lowes",
"accountName": "Lowe's Companies",
"domain": "lowes.com",
"industry": "Retail (Home Improvement)",
"accountTier": "strategic",
"assignedRep": { "id": "rep_sarah", "name": "Sarah K.", "email": "sarah.k@yourco.com" },
"currentProducts": [],
"targetContacts": [],
"lastRunAt": null
},
{
"accountId": "acct_duke_energy",
"accountName": "Duke Energy",
"domain": "duke-energy.com",
"industry": "Energy & Utilities",
"accountTier": "strategic",
"assignedRep": { "id": "rep_priya", "name": "Priya R.", "email": "priya.r@yourco.com" },
"currentProducts": [],
"targetContacts": [],
"lastRunAt": null
},
{
"accountId": "acct_fedex",
"accountName": "FedEx",
"domain": "fedex.com",
"industry": "Transportation & Logistics",
"accountTier": "strategic",
"assignedRep": { "id": "rep_sarah", "name": "Sarah K.", "email": "sarah.k@yourco.com" },
"currentProducts": [],
"targetContacts": [],
"lastRunAt": null
},
{
"accountId": "acct_walmart",
"accountName": "Walmart",
"domain": "walmart.com",
"industry": "Retail",
"accountTier": "strategic",
"assignedRep": { "id": "rep_sarah", "name": "Sarah K.", "email": "sarah.k@yourco.com" },
"currentProducts": [],
"targetContacts": [],
"lastRunAt": null
},
{
"accountId": "acct_hca",
"accountName": "HCA Healthcare",
"domain": "hcahealthcare.com",
"industry": "Healthcare",
"accountTier": "strategic",
"assignedRep": { "id": "rep_marcus", "name": "Marcus L.", "email": "marcus.l@yourco.com" },
"currentProducts": [],
"targetContacts": [],
"lastRunAt": null
},
{
"accountId": "acct_chs",
"accountName": "Community Health Systems",
"domain": "chs.net",
"industry": "Healthcare",
"accountTier": "enterprise",
"assignedRep": { "id": "rep_marcus", "name": "Marcus L.", "email": "marcus.l@yourco.com" },
"currentProducts": [],
"targetContacts": [],
"lastRunAt": null
},
{
"accountId": "acct_acxiom",
"accountName": "Acxiom",
"domain": "acxiom.com",
"industry": "Marketing & Data Services",
"accountTier": "enterprise",
"assignedRep": { "id": "rep_priya", "name": "Priya R.", "email": "priya.r@yourco.com" },
"currentProducts": [],
"targetContacts": [],
"lastRunAt": null
}
]
}

View File

@@ -0,0 +1,858 @@
{
"version": "0.2.0-broadcom",
"lastUpdated": "2026-06-23",
"_source": "Broadcom Mainframe Software, Enterprise Software, and Identity & Access Management (Cybersecurity) groupings + VMware by Broadcom product line. Built from broadcom.com/products and vmware.com/products.",
"_note": "Each capability maps to a real Broadcom/VMware product or a tight cluster of related SKUs. Anti-signals reference current-product ownership from CRM to suppress what the account already owns.",
"capabilities": [
{
"id": "cap_mf_endevor",
"grouping": "Mainframe Software",
"category": "DevOps / Application Development",
"name": "Endevor — Mainframe Source & Change Management",
"description": "Automates and governs mainframe application change management (SCM), securing and standardizing how mainframe software assets are built and released.",
"businessProblems": [
"Manual, error-prone mainframe release processes",
"Lack of audit trail and governance over mainframe code changes",
"Mainframe teams excluded from enterprise CI/CD pipelines",
"Knowledge loss as veteran mainframe developers retire"
],
"buyingSignals": [
"Hiring mainframe developers, z/OS engineers, or DevOps roles touching the mainframe",
"Executive posts about mainframe modernization or DevOps on Z",
"Mention of integrating mainframe into Git/CI-CD or pipeline automation",
"Audit findings on change control for core systems"
],
"antiSignals": [
"Already owns Endevor per CRM",
"Recently standardized on a competing mainframe SCM"
],
"targetBuyer": { "titles": ["VP Application Development", "Director Mainframe", "Head of DevOps"], "departments": ["IT", "Application Development"] },
"keywords": ["mainframe", "endevor", "scm", "change management", "devops", "ci/cd", "pipeline", "z/os", "release", "source control"]
},
{
"id": "cap_mf_brightside",
"grouping": "Mainframe Software",
"category": "DevOps / Application Development",
"name": "Brightside — Zowe-based Mainframe DevOps",
"description": "Open, Zowe-based framework that gives developers modern, self-service tooling (CLI, APIs, VS Code) to work with z/OS the way they work with the cloud.",
"businessProblems": [
"Younger developers reluctant to use green-screen mainframe tools",
"Mainframe siloed from modern developer toolchains",
"Slow onboarding of new mainframe talent",
"Manual, repetitive z/OS operational tasks"
],
"buyingSignals": [
"Job postings mentioning Zowe, VS Code for mainframe, or modern mainframe tooling",
"Executive posts about attracting next-gen mainframe talent",
"Initiatives to standardize developer experience across platforms",
"Mentions of automating z/OS tasks or self-service"
],
"antiSignals": ["Already owns Brightside per CRM"],
"targetBuyer": { "titles": ["Director Mainframe", "VP Engineering", "Head of Platform"], "departments": ["IT", "Application Development"] },
"keywords": ["zowe", "brightside", "mainframe", "developer experience", "vs code", "cli", "self-service", "modernization", "automation"]
},
{
"id": "cap_mf_idms",
"grouping": "Mainframe Software",
"category": "Databases",
"name": "IDMS — Mainframe Database Management System",
"description": "High-performance network/relational mainframe DBMS for mission-critical, high-volume transaction workloads, web-enabled for modern access.",
"businessProblems": [
"Core transactional systems require extreme reliability and throughput",
"Need to web/API-enable legacy mainframe data",
"Scaling transaction volume without re-platforming"
],
"buyingSignals": [
"Hiring for mainframe DBA or core systems roles",
"Mentions of high-volume transaction growth on legacy systems",
"Initiatives to expose mainframe data via APIs"
],
"antiSignals": ["Already owns IDMS per CRM", "Migrating off the mainframe DBMS entirely"],
"targetBuyer": { "titles": ["VP Infrastructure", "Director Database", "Mainframe DBA Lead"], "departments": ["IT", "Data"] },
"keywords": ["idms", "mainframe", "database", "dbms", "transaction", "rdbms", "core systems", "throughput"]
},
{
"id": "cap_mf_datacom",
"grouping": "Mainframe Software",
"category": "Databases",
"name": "Datacom — High-Performance Mainframe Database",
"description": "Fault-tolerant, high-volume mainframe database repository for enterprise-wide workloads with strong availability characteristics.",
"businessProblems": [
"Need fault tolerance and continuous availability for core data",
"High-volume enterprise workloads outgrowing current repository",
"Pressure to modernize data access on the mainframe"
],
"buyingSignals": [
"Hiring mainframe DBAs or reliability engineers",
"Mentions of availability/uptime targets for core systems",
"Workload growth or consolidation onto the mainframe"
],
"antiSignals": ["Already owns Datacom per CRM"],
"targetBuyer": { "titles": ["VP Infrastructure", "Director Database"], "departments": ["IT", "Data"] },
"keywords": ["datacom", "mainframe", "database", "availability", "fault tolerant", "high volume", "repository"]
},
{
"id": "cap_mf_db2tools",
"grouping": "Mainframe Software",
"category": "Databases",
"name": "Database Management for Db2 for z/OS",
"description": "Suite of tools to administer, tune, back up, and recover Db2 for z/OS — reducing cost and improving performance of Db2 environments.",
"businessProblems": [
"Db2 performance bottlenecks and rising MIPS cost",
"Manual, slow Db2 backup/recovery and reorg processes",
"DBA productivity limits as Db2 estate grows"
],
"buyingSignals": [
"Hiring Db2 DBAs or performance engineers",
"Mentions of MIPS/MSU cost reduction initiatives",
"Complaints about Db2 performance or batch windows"
],
"antiSignals": ["Already owns Broadcom Db2 tools per CRM", "Standardized on a competing Db2 tools vendor"],
"targetBuyer": { "titles": ["Director Database", "VP Infrastructure", "Db2 DBA Lead"], "departments": ["IT", "Data"] },
"keywords": ["db2", "mainframe", "database tools", "performance", "tuning", "backup", "recovery", "reorg", "mips", "cost"]
},
{
"id": "cap_mf_security",
"grouping": "Mainframe Software",
"category": "Security",
"name": "Mainframe Security — Top Secret & ACF2",
"description": "External security managers (ESM) for z/OS providing access control, authentication, and protection of mainframe resources at scale.",
"businessProblems": [
"Protecting core mainframe systems from insider and external threats",
"Demonstrating access controls for audit and regulatory exams",
"Managing mainframe identities and entitlements consistently"
],
"buyingSignals": [
"Hiring mainframe security or z/OS security admins",
"Regulatory exam, audit finding, or breach involving core systems",
"Mentions of zero-trust or access governance on the mainframe",
"M&A requiring consolidation of mainframe security domains"
],
"antiSignals": ["Already owns Top Secret or ACF2 per CRM", "Standardized on RACF"],
"targetBuyer": { "titles": ["CISO", "Director Mainframe Security", "VP Infrastructure"], "departments": ["Security", "IT"] },
"keywords": ["mainframe", "security", "top secret", "acf2", "esm", "access control", "z/os", "audit", "zero trust", "authentication"]
},
{
"id": "cap_mf_compliance",
"grouping": "Mainframe Software",
"category": "Security / Compliance",
"name": "Mainframe Compliance & Audit (Compliance Event Manager, Cleanup, Data Content Discovery)",
"description": "Continuous compliance monitoring, security database cleanup, and sensitive-data discovery for z/OS to reduce audit effort and risk.",
"businessProblems": [
"Manual, spreadsheet-driven mainframe audit preparation",
"Stale or excessive access rights in the security database",
"Unknown sensitive data residing on the mainframe"
],
"buyingSignals": [
"Audit findings or material weakness involving core systems",
"Hiring GRC, internal audit, or compliance roles touching IT",
"New regulatory scope (PCI, SOX, GDPR) affecting mainframe data",
"Mentions of continuous compliance or access recertification"
],
"antiSignals": ["Already owns Compliance Event Manager per CRM"],
"targetBuyer": { "titles": ["CISO", "VP Compliance", "Head of Internal Audit"], "departments": ["Security", "Compliance", "Risk"] },
"keywords": ["mainframe", "compliance", "audit", "cleanup", "data discovery", "sox", "pci", "gdpr", "recertification", "controls"]
},
{
"id": "cap_mf_automation",
"grouping": "Mainframe Software",
"category": "Operations / Automation",
"name": "OPS/MVS — Mainframe Event Management & Automation",
"description": "Automates z/OS operations, event detection, and response to reduce outages, manual intervention, and operator workload.",
"businessProblems": [
"Manual mainframe operations and console firefighting",
"Outages from delayed or missed event response",
"Operations headcount strain as veterans retire"
],
"buyingSignals": [
"Hiring mainframe operations / automation engineers",
"Mentions of reducing manual intervention or improving uptime on Z",
"Initiatives to automate IT operations or NOC modernization"
],
"antiSignals": ["Already owns OPS/MVS per CRM"],
"targetBuyer": { "titles": ["Director Mainframe Operations", "VP Infrastructure", "Head of IT Ops"], "departments": ["IT", "Operations"] },
"keywords": ["mainframe", "automation", "ops/mvs", "operations", "event management", "console", "uptime", "outage", "z/os"]
},
{
"id": "cap_mf_sysview",
"grouping": "Mainframe Software",
"category": "Operations / Performance",
"name": "SYSVIEW — Mainframe Performance Management",
"description": "Real-time z/OS performance monitoring and management to optimize system health, reduce MIPS consumption, and prevent performance incidents.",
"businessProblems": [
"Rising mainframe (MIPS/MSU) cost pressure",
"Performance incidents impacting core services",
"Limited real-time visibility into z/OS health"
],
"buyingSignals": [
"Mentions of mainframe cost optimization or MIPS reduction",
"Hiring performance / capacity engineers",
"Complaints about batch window or response time"
],
"antiSignals": ["Already owns SYSVIEW per CRM"],
"targetBuyer": { "titles": ["Director Mainframe Operations", "VP Infrastructure"], "departments": ["IT", "Operations"] },
"keywords": ["mainframe", "sysview", "performance", "monitoring", "mips", "msu", "capacity", "z/os", "cost"]
},
{
"id": "cap_mf_aiops",
"grouping": "Mainframe Software",
"category": "Operations / AIOps",
"name": "Mainframe Operational Intelligence (AIOps for Z)",
"description": "Applies machine learning to mainframe operational data to predict anomalies and surface insights across the z/OS estate.",
"businessProblems": [
"Reactive, alert-driven mainframe operations",
"Too much operational data, too little insight",
"Difficulty predicting incidents before they impact service"
],
"buyingSignals": [
"Executive posts about AIOps, predictive operations, or observability",
"Hiring data/observability roles spanning the mainframe",
"Initiatives to unify ops visibility across platforms"
],
"antiSignals": ["Already owns Mainframe Operational Intelligence per CRM"],
"targetBuyer": { "titles": ["VP IT Operations", "Director Mainframe", "Head of Observability"], "departments": ["IT", "Operations"] },
"keywords": ["mainframe", "aiops", "operational intelligence", "machine learning", "predictive", "anomaly", "observability", "insights"]
},
{
"id": "cap_mf_workload_automation",
"grouping": "Mainframe Software",
"category": "Workload Automation",
"name": "Workload Automation CA 7 — Mainframe Job Scheduling",
"description": "Enterprise job scheduling and workload automation for the mainframe, ensuring reliable, on-time batch processing at scale.",
"businessProblems": [
"Batch SLAs missed or fragile job schedules",
"Manual coordination of complex cross-platform batch",
"Limited visibility and recovery for failed jobs"
],
"buyingSignals": [
"Hiring batch/scheduling or workload automation engineers",
"Mentions of batch modernization or SLA reliability",
"M&A requiring consolidation of scheduling environments"
],
"antiSignals": ["Already owns CA 7 per CRM", "Standardized on a competing scheduler"],
"targetBuyer": { "titles": ["Director Mainframe Operations", "VP Infrastructure"], "departments": ["IT", "Operations"] },
"keywords": ["mainframe", "workload automation", "ca 7", "scheduling", "batch", "jobs", "sla", "orchestration"]
},
{
"id": "cap_mf_network",
"grouping": "Mainframe Software",
"category": "Network Management",
"name": "NetMaster — Mainframe Network Management & Automation",
"description": "Monitors and automates mainframe network (SNA/IP) operations to maximize availability and performance of mission-critical connectivity.",
"businessProblems": [
"Mainframe network outages impacting core services",
"Manual network troubleshooting on z/OS",
"Limited visibility across SNA and TCP/IP on the mainframe"
],
"buyingSignals": [
"Hiring mainframe network engineers",
"Mentions of network reliability or automation initiatives",
"Connectivity incidents affecting core transactions"
],
"antiSignals": ["Already owns NetMaster per CRM"],
"targetBuyer": { "titles": ["Director Network", "VP Infrastructure"], "departments": ["IT", "Network"] },
"keywords": ["mainframe", "netmaster", "network", "sna", "tcp/ip", "connectivity", "availability", "automation"]
},
{
"id": "cap_mf_storage",
"grouping": "Mainframe Software",
"category": "Storage & Output Management",
"name": "Mainframe Storage Management (Vantage, Disk Backup & Restore)",
"description": "Optimizes, monitors, and protects growing mainframe storage — managing capacity, performance, and backup/recovery of z/OS storage.",
"businessProblems": [
"Growing mainframe storage cost and capacity pressure",
"Manual storage administration and reporting",
"Backup/recovery risk for critical datasets"
],
"buyingSignals": [
"Mentions of storage cost optimization on the mainframe",
"Hiring storage administrators",
"Data growth or retention/compliance initiatives"
],
"antiSignals": ["Already owns Broadcom mainframe storage tools per CRM"],
"targetBuyer": { "titles": ["Director Storage", "VP Infrastructure"], "departments": ["IT", "Operations"] },
"keywords": ["mainframe", "storage", "vantage", "backup", "restore", "capacity", "disk", "dataset", "cost"]
},
{
"id": "cap_es_rally",
"grouping": "Enterprise Software",
"category": "ValueOps — Value Stream Management",
"name": "Rally — Enterprise Agile Management",
"description": "Enterprise agile planning and management platform that aligns teams, programs, and portfolios to accelerate business agility.",
"businessProblems": [
"Scaling agile beyond individual teams",
"Poor visibility into delivery progress across programs",
"Disconnect between strategy and what teams build"
],
"buyingSignals": [
"Executive posts about agile transformation or business agility",
"Hiring agile coaches, RTEs, or program managers",
"Mentions of SAFe adoption or scaling agile",
"Reorg toward product-/value-stream operating model"
],
"antiSignals": ["Already owns Rally per CRM", "Standardized on a competing agile platform"],
"targetBuyer": { "titles": ["VP Engineering", "Head of Agile / Transformation", "CTO"], "departments": ["Engineering", "Transformation"] },
"keywords": ["rally", "agile", "safe", "transformation", "agile management", "program increment", "business agility", "value stream", "scrum"]
},
{
"id": "cap_es_clarity",
"grouping": "Enterprise Software",
"category": "ValueOps — Value Stream Management",
"name": "Clarity — Strategic Portfolio Management",
"description": "Strategic portfolio, investment, and product management platform that connects funding and strategy to execution.",
"businessProblems": [
"Investment decisions disconnected from delivery outcomes",
"Manual, spreadsheet-driven portfolio and capacity planning",
"Inability to reprioritize spend quickly against strategy"
],
"buyingSignals": [
"Executive posts about portfolio prioritization or cost discipline",
"Hiring PMO, portfolio, or strategic finance roles",
"Mentions of zero-based budgeting or investment realignment",
"Transformation or restructuring program announced"
],
"antiSignals": ["Already owns Clarity per CRM", "Standardized on a competing PPM/SPM tool"],
"targetBuyer": { "titles": ["CIO", "Head of PMO", "VP Strategy"], "departments": ["IT", "PMO", "Strategy"] },
"keywords": ["clarity", "portfolio", "spm", "ppm", "investment", "pmo", "budgeting", "prioritization", "strategy", "capacity planning"]
},
{
"id": "cap_es_valueops_insights",
"grouping": "Enterprise Software",
"category": "ValueOps — Value Stream Management",
"name": "ValueOps Insights & ConnectALL — Value Stream Management",
"description": "Measures and improves digital value-stream performance and integrates the toolchain (Jira, ADO, ServiceNow) end to end.",
"businessProblems": [
"No unified view of flow from idea to value",
"Fragmented, disconnected delivery toolchains",
"Difficulty proving ROI of transformation investments"
],
"buyingSignals": [
"Executive posts about value stream management or flow metrics",
"Mentions of toolchain integration (Jira/ADO/ServiceNow)",
"Hiring value-stream or transformation measurement roles"
],
"antiSignals": ["Already owns ValueOps Insights per CRM"],
"targetBuyer": { "titles": ["Head of Transformation", "VP Engineering", "CIO"], "departments": ["Transformation", "Engineering"] },
"keywords": ["valueops", "value stream", "vsm", "insights", "connectall", "flow", "toolchain integration", "jira", "metrics", "roi"]
},
{
"id": "cap_es_dx_apm",
"grouping": "Enterprise Software",
"category": "DX — Observability & AIOps",
"name": "DX Application Performance Management (DX APM)",
"description": "Full-stack application performance monitoring with AI-driven analytics to detect, triage, and resolve performance issues fast.",
"businessProblems": [
"Application performance incidents hurting customer experience",
"Slow root-cause analysis across complex stacks",
"Siloed monitoring tools with no end-to-end view"
],
"buyingSignals": [
"Executive posts about reliability, uptime, or customer experience",
"Hiring SRE, observability, or performance engineers",
"Mentions of major outage or digital experience initiatives",
"Cloud migration increasing monitoring complexity"
],
"antiSignals": ["Already owns DX APM per CRM", "Standardized on a competing APM/observability vendor"],
"targetBuyer": { "titles": ["VP IT Operations", "Head of SRE", "CIO"], "departments": ["IT", "Operations"] },
"keywords": ["apm", "dx apm", "observability", "application performance", "monitoring", "sre", "reliability", "root cause", "uptime"]
},
{
"id": "cap_es_dx_aiops",
"grouping": "Enterprise Software",
"category": "DX — Observability & AIOps",
"name": "DX Operational Observability & AIOps",
"description": "Unifies metrics, events, logs, and traces with AIOps to reduce noise, predict issues, and automate IT operations.",
"businessProblems": [
"Alert storms and tool sprawl overwhelming ops teams",
"Reactive operations with slow MTTR",
"No correlated view across hybrid infrastructure"
],
"buyingSignals": [
"Executive posts about AIOps, noise reduction, or MTTR",
"Hiring observability/AIOps roles",
"Mentions of consolidating monitoring tools",
"Major incident driving operations modernization"
],
"antiSignals": ["Already owns DX OI/AIOps per CRM"],
"targetBuyer": { "titles": ["VP IT Operations", "Head of Observability", "CIO"], "departments": ["IT", "Operations"] },
"keywords": ["aiops", "observability", "operational intelligence", "mttr", "noise reduction", "events", "logs", "traces", "automation"]
},
{
"id": "cap_es_dx_netops",
"grouping": "Enterprise Software",
"category": "DX — Network Operations",
"name": "DX NetOps — Network Operations Management",
"description": "Unified network monitoring and analytics for modern, multi-vendor, hybrid networks — from data center to SD-WAN and cloud.",
"businessProblems": [
"Blind spots across multi-vendor, hybrid networks",
"Slow network fault isolation and outage resolution",
"Tool sprawl across network monitoring"
],
"buyingSignals": [
"Hiring network operations or NetOps engineers",
"Mentions of SD-WAN, network modernization, or multi-cloud connectivity",
"Network outage or performance incident",
"Merger combining disparate network estates"
],
"antiSignals": ["Already owns DX NetOps per CRM", "Standardized on a competing NPM vendor"],
"targetBuyer": { "titles": ["Director Network", "VP Infrastructure", "Head of NetOps"], "departments": ["IT", "Network"] },
"keywords": ["netops", "network monitoring", "network operations", "sd-wan", "multi-vendor", "fault", "hybrid network", "npm"]
},
{
"id": "cap_es_appneta",
"grouping": "Enterprise Software",
"category": "DX — Network Operations",
"name": "AppNeta — Network Performance Monitoring (Active/Synthetic)",
"description": "End-user experience and active path monitoring across the internet and SaaS, ideal for distributed/remote workforces.",
"businessProblems": [
"Poor SaaS/app experience for remote and branch users",
"No visibility into networks you don't own (ISP, SaaS paths)",
"Difficulty proving where experience problems originate"
],
"buyingSignals": [
"Mentions of remote work, branch, or SaaS experience problems",
"Hiring digital-experience or network roles",
"Office/branch expansion or hybrid-work initiatives"
],
"antiSignals": ["Already owns AppNeta per CRM"],
"targetBuyer": { "titles": ["Director Network", "Head of Digital Experience", "VP IT"], "departments": ["IT", "Network"] },
"keywords": ["appneta", "network performance", "synthetic monitoring", "end user experience", "saas", "remote work", "branch", "path"]
},
{
"id": "cap_es_automation",
"grouping": "Enterprise Software",
"category": "Automation",
"name": "Automic / AutoSys — Workload Automation & Orchestration",
"description": "Enterprise automation and orchestration of business and IT workloads across hybrid environments, replacing brittle scripting and siloed schedulers.",
"businessProblems": [
"Fragmented schedulers and manual scripting across platforms",
"Batch/process SLAs at risk as complexity grows",
"Cloud migration requiring orchestration across hybrid estate"
],
"buyingSignals": [
"Hiring automation, orchestration, or RevOps/ITOps roles",
"Mentions of process automation or eliminating manual handoffs",
"Cloud migration or DevOps pipeline expansion",
"M&A requiring consolidation of automation tooling"
],
"antiSignals": ["Already owns Automic or AutoSys per CRM", "Standardized on a competing orchestration platform"],
"targetBuyer": { "titles": ["VP IT Operations", "Director Automation", "Head of DevOps"], "departments": ["IT", "Operations"] },
"keywords": ["automic", "autosys", "workload automation", "orchestration", "scheduling", "automation", "batch", "hybrid", "process"]
},
{
"id": "cap_es_layer7",
"grouping": "Enterprise Software",
"category": "API Management",
"name": "Layer7 — API Management",
"description": "Secure, govern, and scale APIs across the enterprise with an API gateway, developer portal, and lifecycle management.",
"businessProblems": [
"Ungoverned API sprawl and inconsistent security",
"Slow partner/developer onboarding to APIs",
"Need to expose legacy systems safely via APIs"
],
"buyingSignals": [
"Hiring API, integration, or platform engineers",
"Executive posts about API-first, open banking, or ecosystems",
"Mentions of digital platform or partner integration initiatives",
"Legacy/mainframe modernization exposing services via APIs"
],
"antiSignals": ["Already owns Layer7 per CRM", "Standardized on a competing API gateway"],
"targetBuyer": { "titles": ["Head of Integration", "VP Platform", "Chief Architect"], "departments": ["IT", "Engineering"] },
"keywords": ["layer7", "api management", "api gateway", "developer portal", "integration", "api-first", "open banking", "microservices"]
},
{
"id": "cap_iam_siteminder",
"grouping": "Cybersecurity — Identity & Access Management",
"category": "Access Management",
"name": "Symantec SiteMinder — Access Management & SSO",
"description": "Web/enterprise access management providing single sign-on, federation, and centralized authentication/authorization at scale.",
"businessProblems": [
"Inconsistent authentication across many applications",
"Poor user experience from multiple logins",
"Federation gaps with partners and SaaS"
],
"buyingSignals": [
"Hiring IAM, SSO, or identity engineers",
"Mentions of zero-trust, SSO, or access modernization",
"M&A requiring federation across identity domains",
"Audit findings on access governance"
],
"antiSignals": ["Already owns SiteMinder per CRM", "Standardized on a competing access-management platform"],
"targetBuyer": { "titles": ["CISO", "Director IAM", "VP Security"], "departments": ["Security", "IT"] },
"keywords": ["siteminder", "access management", "sso", "single sign-on", "federation", "authentication", "zero trust", "iam"]
},
{
"id": "cap_iam_vip",
"grouping": "Cybersecurity — Identity & Access Management",
"category": "Authentication / MFA",
"name": "Symantec VIP — Multi-Factor & Risk-Based Authentication",
"description": "Cloud-based MFA and risk-based authentication to secure user access with strong, adaptive verification.",
"businessProblems": [
"Account takeover and credential-based attacks",
"Weak or password-only authentication",
"Friction between security and user experience"
],
"buyingSignals": [
"Mentions of MFA mandates, phishing, or account takeover",
"Cyber-insurance or regulatory MFA requirements",
"Breach or credential-stuffing incident disclosed",
"Hiring identity/security engineers"
],
"antiSignals": ["Already owns VIP per CRM", "Standardized on a competing MFA provider"],
"targetBuyer": { "titles": ["CISO", "Director IAM", "VP Security"], "departments": ["Security", "IT"] },
"keywords": ["vip", "mfa", "multi-factor", "authentication", "risk-based", "adaptive", "account takeover", "phishing", "passwordless"]
},
{
"id": "cap_iam_pam",
"grouping": "Cybersecurity — Identity & Access Management",
"category": "Privileged Access",
"name": "Symantec PAM — Privileged Access Management",
"description": "Secures, controls, and records privileged/administrative access across cloud, virtual, and physical environments.",
"businessProblems": [
"Unmanaged privileged credentials and standing access",
"No recording/audit of administrator activity",
"Insider-threat and lateral-movement risk"
],
"buyingSignals": [
"Audit finding or breach involving privileged accounts",
"Cyber-insurance requiring PAM controls",
"Mentions of zero-trust, least privilege, or secrets management",
"Hiring security engineers focused on access"
],
"antiSignals": ["Already owns Symantec PAM per CRM", "Standardized on a competing PAM vendor"],
"targetBuyer": { "titles": ["CISO", "Director IAM", "Head of Security Operations"], "departments": ["Security", "IT"] },
"keywords": ["pam", "privileged access", "credentials", "least privilege", "secrets", "session recording", "zero trust", "insider threat"]
},
{
"id": "cap_iam_iga",
"grouping": "Cybersecurity — Identity & Access Management",
"category": "Identity Governance",
"name": "Symantec Identity Governance & Administration (Identity Suite)",
"description": "Automates identity lifecycle, access requests, and certification/recertification to enforce least privilege and pass audits.",
"businessProblems": [
"Manual joiner/mover/leaver and access provisioning",
"Access-recertification campaigns done in spreadsheets",
"Excessive entitlements failing audit"
],
"buyingSignals": [
"Audit findings on access governance or segregation of duties",
"Hiring IAM/IGA or compliance roles",
"Mentions of identity lifecycle automation or recertification",
"M&A requiring identity consolidation"
],
"antiSignals": ["Already owns Symantec Identity Suite per CRM", "Standardized on a competing IGA vendor"],
"targetBuyer": { "titles": ["CISO", "Director IAM", "VP Compliance"], "departments": ["Security", "Compliance"] },
"keywords": ["identity governance", "iga", "identity suite", "provisioning", "recertification", "least privilege", "segregation of duties", "lifecycle", "entitlements"]
},
{
"id": "cap_iam_directory",
"grouping": "Cybersecurity — Identity & Access Management",
"category": "Directory",
"name": "Symantec Directory — High-Scale Identity Directory",
"description": "High-performance, scalable directory service underpinning large identity deployments with strong availability.",
"businessProblems": [
"Directory scale/performance limits for large user bases",
"Fragmented directories after mergers",
"Availability requirements for identity infrastructure"
],
"buyingSignals": [
"Mentions of identity infrastructure modernization",
"Large-scale customer identity (CIAM) initiatives",
"M&A requiring directory consolidation"
],
"antiSignals": ["Already owns Symantec Directory per CRM"],
"targetBuyer": { "titles": ["Director IAM", "Chief Architect", "VP Security"], "departments": ["Security", "IT"] },
"keywords": ["directory", "ldap", "identity", "ciam", "scale", "availability", "consolidation"]
},
{
"id": "cap_iam_advauth",
"grouping": "Cybersecurity — Identity & Access Management",
"category": "Authentication / CIAM",
"name": "Advanced Authentication & VIP Authentication Hub (CIAM)",
"description": "Centralized, modern authentication services (incl. passwordless and orchestration) for workforce and customer identity.",
"businessProblems": [
"Inconsistent authentication across customer and workforce apps",
"Need for passwordless / modern auth standards",
"Customer-facing login friction and abandonment"
],
"buyingSignals": [
"Mentions of CIAM, passwordless, or login experience",
"Customer-facing digital initiatives or app launches",
"Hiring identity/CIAM engineers"
],
"antiSignals": ["Already owns Advanced Authentication / VIP Auth Hub per CRM"],
"targetBuyer": { "titles": ["CISO", "Director IAM", "Head of Digital"], "departments": ["Security", "Digital"] },
"keywords": ["advanced authentication", "ciam", "passwordless", "authentication hub", "login", "customer identity", "orchestration"]
},
{
"id": "cap_vmw_vcf",
"grouping": "VMware by Broadcom",
"category": "Cloud Platform",
"name": "VMware Cloud Foundation (VCF) — Private Cloud Platform",
"description": "Integrated full-stack private cloud (compute, storage, network, management, automation) for running and modernizing workloads on-prem and at the edge.",
"businessProblems": [
"Public-cloud cost overruns driving repatriation",
"Fragmented, hard-to-operate virtualization estate",
"Need a consistent private-cloud operating model",
"Data sovereignty / control requirements"
],
"buyingSignals": [
"Executive posts about cloud cost, repatriation, or sovereignty",
"Mentions of data center modernization or private cloud",
"Hiring cloud platform / virtualization architects",
"Renewal/transformation of data center strategy"
],
"antiSignals": ["Already owns VCF per CRM", "Committed to all-public-cloud with no on-prem"],
"targetBuyer": { "titles": ["CIO", "VP Infrastructure", "Chief Architect"], "departments": ["IT", "Infrastructure"] },
"keywords": ["vcf", "cloud foundation", "private cloud", "virtualization", "data center", "repatriation", "sovereignty", "sddc", "modernization"]
},
{
"id": "cap_vmw_vsphere",
"grouping": "VMware by Broadcom",
"category": "Compute",
"name": "VMware vSphere Foundation — Compute Virtualization",
"description": "Industry-standard server virtualization and the compute foundation for enterprise workloads, with integrated management.",
"businessProblems": [
"Aging or unsupported virtualization infrastructure",
"Underutilized hardware and server sprawl",
"Need a reliable base for app modernization"
],
"buyingSignals": [
"Hardware refresh or data center consolidation initiatives",
"Hiring virtualization / infrastructure engineers",
"Mentions of hypervisor or platform standardization"
],
"antiSignals": ["Already owns vSphere Foundation/VCF per CRM"],
"targetBuyer": { "titles": ["VP Infrastructure", "Director IT", "Chief Architect"], "departments": ["IT", "Infrastructure"] },
"keywords": ["vsphere", "virtualization", "hypervisor", "compute", "esxi", "vcenter", "consolidation", "data center"]
},
{
"id": "cap_vmw_vsan",
"grouping": "VMware by Broadcom",
"category": "Storage",
"name": "VMware vSAN — Hyperconverged Storage",
"description": "Software-defined, hyperconverged storage integrated with vSphere to simplify and scale storage without dedicated SAN.",
"businessProblems": [
"Costly, complex traditional SAN storage",
"Storage scaling and refresh challenges",
"Desire to simplify with hyperconverged infrastructure"
],
"buyingSignals": [
"Storage refresh or HCI initiatives",
"Mentions of reducing storage cost/complexity",
"Data center modernization or edge buildout"
],
"antiSignals": ["Already owns vSAN per CRM", "Standardized on a competing HCI/storage platform"],
"targetBuyer": { "titles": ["Director Storage", "VP Infrastructure"], "departments": ["IT", "Infrastructure"] },
"keywords": ["vsan", "hci", "hyperconverged", "storage", "software-defined storage", "san", "modernization"]
},
{
"id": "cap_vmw_nsx",
"grouping": "VMware by Broadcom",
"category": "Networking & Security",
"name": "VMware NSX & vDefend — Network Virtualization & Lateral Security",
"description": "Software-defined networking with micro-segmentation, distributed firewall, and advanced threat prevention for zero-trust lateral security.",
"businessProblems": [
"Flat networks enabling lateral attacker movement",
"Manual, slow network provisioning",
"Need micro-segmentation for zero-trust"
],
"buyingSignals": [
"Executive posts about zero-trust or ransomware resilience",
"Mentions of micro-segmentation or network security modernization",
"Breach or ransomware incident disclosed",
"Hiring network security engineers"
],
"antiSignals": ["Already owns NSX/vDefend per CRM"],
"targetBuyer": { "titles": ["CISO", "Director Network", "VP Infrastructure"], "departments": ["Security", "Network"] },
"keywords": ["nsx", "vdefend", "network virtualization", "micro-segmentation", "distributed firewall", "zero trust", "ransomware", "lateral security", "sdn"]
},
{
"id": "cap_vmw_avi",
"grouping": "VMware by Broadcom",
"category": "Networking",
"name": "VMware Avi Load Balancer — Software Load Balancing & ADC",
"description": "Software-defined, elastic load balancing and application delivery with analytics, across data center and cloud.",
"businessProblems": [
"Costly, rigid hardware load balancers",
"No application traffic analytics",
"Scaling app delivery across hybrid/multi-cloud"
],
"buyingSignals": [
"Hardware load-balancer refresh or F5 migration mentions",
"Mentions of multi-cloud app delivery or autoscaling",
"Hiring platform/network engineers"
],
"antiSignals": ["Already owns Avi per CRM"],
"targetBuyer": { "titles": ["Director Network", "VP Infrastructure", "Chief Architect"], "departments": ["IT", "Network"] },
"keywords": ["avi", "load balancer", "adc", "application delivery", "load balancing", "multi-cloud", "autoscaling", "f5"]
},
{
"id": "cap_vmw_aria",
"grouping": "VMware by Broadcom",
"category": "Cloud Management",
"name": "VCF Operations & Automation (Aria) — Cloud Management",
"description": "Cloud operations, automation, and self-service (formerly Aria/vRealize) for performance, cost, capacity, and provisioning across the private cloud.",
"businessProblems": [
"Manual provisioning and slow infrastructure delivery",
"No unified visibility into cost, capacity, performance",
"Cloud sprawl without governance"
],
"buyingSignals": [
"Mentions of self-service infrastructure or cloud cost governance",
"Hiring cloud ops / platform engineering roles",
"Private-cloud or VCF adoption initiatives"
],
"antiSignals": ["Already owns Aria/VCF Operations per CRM"],
"targetBuyer": { "titles": ["VP Infrastructure", "Head of Cloud Platform", "CIO"], "departments": ["IT", "Infrastructure"] },
"keywords": ["aria", "vcf operations", "vcf automation", "cloud management", "self-service", "provisioning", "cost", "capacity", "vrealize"]
},
{
"id": "cap_vmw_tanzu",
"grouping": "VMware by Broadcom",
"category": "Application Platform",
"name": "VMware Tanzu — Kubernetes & Application Platform",
"description": "Kubernetes runtime (VKS) and application platform for building, running, and managing modern containerized apps on VCF.",
"businessProblems": [
"Container/Kubernetes complexity and inconsistent platforms",
"Slow path from code to production for cloud-native apps",
"Need consistent app platform across clouds"
],
"buyingSignals": [
"Hiring Kubernetes, platform engineering, or cloud-native developers",
"Mentions of containerization, microservices, or developer platform",
"App modernization or replatforming initiatives"
],
"antiSignals": ["Already owns Tanzu per CRM", "Standardized on a competing Kubernetes platform"],
"targetBuyer": { "titles": ["VP Engineering", "Head of Platform", "CTO"], "departments": ["Engineering", "Platform"] },
"keywords": ["tanzu", "kubernetes", "vks", "containers", "cloud native", "platform engineering", "microservices", "app modernization"]
},
{
"id": "cap_vmw_workspaceone",
"grouping": "VMware by Broadcom",
"category": "Digital Workspace",
"name": "VMware Workspace ONE — Unified Endpoint Management & Digital Workspace",
"description": "Manages and secures any device with unified endpoint management and a digital workspace for anywhere work.",
"businessProblems": [
"Securing and managing a growing fleet of devices",
"Poor employee experience accessing apps across devices",
"Hybrid/remote work device sprawl"
],
"buyingSignals": [
"Mentions of remote/hybrid work, BYOD, or endpoint security",
"Hiring endpoint/EUC or device management roles",
"Workforce growth or device fleet expansion"
],
"antiSignals": ["Already owns Workspace ONE per CRM", "Standardized on a competing UEM"],
"targetBuyer": { "titles": ["Director EUC", "VP IT", "CISO"], "departments": ["IT", "Security"] },
"keywords": ["workspace one", "uem", "endpoint management", "digital workspace", "byod", "mdm", "remote work", "device"]
},
{
"id": "cap_vmw_horizon",
"grouping": "VMware by Broadcom",
"category": "Digital Workspace",
"name": "VMware Horizon — Virtual Desktops & Apps (VDI)",
"description": "Delivers virtual desktops and applications to any device, enabling secure remote work and simplified desktop management.",
"businessProblems": [
"Securing remote access to desktops and apps",
"High cost/complexity of managing physical desktops",
"Need to rapidly onboard contractors or remote staff"
],
"buyingSignals": [
"Mentions of remote work, contractor onboarding, or VDI",
"Hiring EUC/VDI engineers",
"M&A or rapid headcount growth requiring fast provisioning"
],
"antiSignals": ["Already owns Horizon per CRM", "Standardized on a competing VDI platform"],
"targetBuyer": { "titles": ["Director EUC", "VP IT"], "departments": ["IT"] },
"keywords": ["horizon", "vdi", "virtual desktop", "daas", "remote work", "euc", "desktop virtualization", "contractor"]
},
{
"id": "cap_vmw_live_recovery",
"grouping": "VMware by Broadcom",
"category": "Resilience / DR",
"name": "VMware Live Recovery — Disaster & Ransomware Recovery",
"description": "Disaster recovery and ransomware recovery (Site Recovery + Live Site Recovery) to protect and rapidly restore workloads.",
"businessProblems": [
"Inadequate or untested disaster recovery",
"Ransomware exposure with slow recovery",
"Regulatory/board pressure on resilience"
],
"buyingSignals": [
"Executive posts about resilience, ransomware, or business continuity",
"Ransomware or outage incident disclosed",
"Regulatory resilience requirements (e.g., DORA) mentioned",
"Hiring resilience / DR roles"
],
"antiSignals": ["Already owns Live Recovery/SRM per CRM"],
"targetBuyer": { "titles": ["CISO", "VP Infrastructure", "Head of Resilience"], "departments": ["Security", "IT"] },
"keywords": ["live recovery", "disaster recovery", "ransomware", "business continuity", "srm", "resilience", "dora", "backup"]
},
{
"id": "cap_vmw_hcx",
"grouping": "VMware by Broadcom",
"category": "Migration",
"name": "VMware HCX — Workload Migration & Mobility",
"description": "Large-scale application mobility and migration across data centers and clouds with minimal downtime.",
"businessProblems": [
"Risky, slow data center migrations",
"Need to evacuate or consolidate data centers",
"Cloud migration with downtime constraints"
],
"buyingSignals": [
"Mentions of data center exit, consolidation, or cloud migration",
"M&A requiring workload consolidation",
"Lease expiry or facility move driving migration"
],
"antiSignals": ["Already owns HCX per CRM"],
"targetBuyer": { "titles": ["VP Infrastructure", "Chief Architect", "Director IT"], "departments": ["IT", "Infrastructure"] },
"keywords": ["hcx", "migration", "workload mobility", "data center exit", "consolidation", "cloud migration", "lift and shift"]
},
{
"id": "cap_vmw_private_ai",
"grouping": "VMware by Broadcom",
"category": "AI Infrastructure",
"name": "VMware Private AI Foundation (with NVIDIA)",
"description": "Run and govern generative AI and ML workloads on-prem with GPU optimization, keeping data private and controlled.",
"businessProblems": [
"Need to run AI on private data without exposing it to public clouds",
"GPU cost and utilization challenges",
"Governance and control over AI infrastructure"
],
"buyingSignals": [
"Executive posts about generative AI, data privacy, or GPU strategy",
"Hiring ML/AI platform or MLOps engineers",
"Mentions of on-prem AI, RAG, or private LLM initiatives"
],
"antiSignals": ["Already owns Private AI Foundation per CRM"],
"targetBuyer": { "titles": ["CTO", "Head of AI/ML Platform", "VP Infrastructure"], "departments": ["Engineering", "IT"] },
"keywords": ["private ai", "generative ai", "gpu", "nvidia", "mlops", "llm", "on-prem ai", "data privacy", "machine learning"]
}
],
"bundleSignals": [
{
"capabilityIds": ["cap_vmw_vcf", "cap_vmw_nsx", "cap_vmw_aria"],
"description": "Data center modernization / cloud repatriation signals → pitch full VCF stack (compute + network security + cloud management)."
},
{
"capabilityIds": ["cap_iam_siteminder", "cap_iam_vip", "cap_iam_pam"],
"description": "Zero-trust or audit-finding signals → pitch the Symantec identity bundle (access mgmt + MFA + privileged access)."
},
{
"capabilityIds": ["cap_mf_endevor", "cap_mf_brightside"],
"description": "Mainframe modernization + next-gen talent signals → pitch Endevor + Brightside DevOps modernization together."
},
{
"capabilityIds": ["cap_es_rally", "cap_es_clarity", "cap_es_valueops_insights"],
"description": "Agile/transformation signals → pitch the ValueOps VSM platform (Rally + Clarity + Insights)."
},
{
"capabilityIds": ["cap_vmw_live_recovery", "cap_vmw_nsx"],
"description": "Ransomware/resilience signals → pitch Live Recovery + NSX/vDefend lateral security together."
}
],
"globalAntiSignals": [
"Competitor displacement win logged in CRM within the last 90 days",
"Account flagged as actively divesting the relevant business unit"
]
}

View File

@@ -0,0 +1,144 @@
{
"version": "0.1.0-mvp",
"lastUpdated": "2026-06-22",
"capabilities": [
{
"id": "cap_financial_close",
"name": "Automated Financial Close Module",
"description": "Automates month-end and quarter-end close, reducing cycle time and manual reconciliation errors across multiple entities.",
"businessProblems": [
"Manual financial close taking 10+ days",
"Audit findings related to reconciliation errors",
"Controller or CFO unable to scale without adding headcount",
"Recent ERP migration or acquisition creating reporting gaps"
],
"buyingSignals": [
"Hiring for financial reporting or accounting manager roles",
"CFO or Controller posts about process improvement or modernization",
"Press release about acquisition (new entity to consolidate)",
"Mention of close process or reconciliation challenges in earnings calls",
"Job descriptions mentioning reduce close cycle time or multi-entity consolidation"
],
"antiSignals": [
"Already purchased this module per CRM",
"Recent competitor win logged in CRM"
],
"targetBuyer": {
"titles": ["CFO", "Controller", "VP Finance", "Head of Accounting"],
"departments": ["Finance", "Accounting"]
},
"keywords": ["close", "reconciliation", "consolidation", "month-end", "audit", "ERP", "controller", "multi-entity", "acquisition"]
},
{
"id": "cap_workflow_automation",
"name": "Workflow Automation Suite",
"description": "Eliminates manual handoffs across operational processes, automating repetitive review-and-approve workflows end to end.",
"businessProblems": [
"Manual handoffs between departments slowing throughput",
"Headcount growth required just to keep up with volume",
"Inconsistent process execution and error rates",
"Post-merger process fragmentation"
],
"buyingSignals": [
"Executive posts about eliminating manual work or automation",
"Hiring operations or automation roles",
"Merger or rapid growth increasing transaction volume",
"Job descriptions mentioning reduce manual intervention or automate workflows"
],
"antiSignals": [
"Already purchased automation suite per CRM",
"Just signed with a competing automation platform"
],
"targetBuyer": {
"titles": ["COO", "VP Operations", "Director of Operations", "Head of RevOps"],
"departments": ["Operations", "Revenue Operations"]
},
"keywords": ["automation", "manual", "handoff", "workflow", "operations", "throughput", "prior authorization", "claims", "process"]
},
{
"id": "cap_analytics_platform",
"name": "Unified Analytics & Reporting Platform",
"description": "Consolidates fragmented data sources into a single reporting layer with self-serve dashboards and governed metrics.",
"businessProblems": [
"Data scattered across systems with no single source of truth",
"Leadership lacks real-time visibility into KPIs",
"Analysts spend more time wrangling data than analyzing it",
"Reporting gaps after a system migration or merger"
],
"buyingSignals": [
"Hiring data analysts, BI, or analytics engineering roles",
"Executive posts about data-driven decisions or visibility",
"Mentions of building a data warehouse or single source of truth",
"Job descriptions mentioning consolidate reporting or dashboards"
],
"antiSignals": [
"Already purchased analytics platform per CRM",
"Standardized on a competing BI tool recently"
],
"targetBuyer": {
"titles": ["CDO", "VP Data", "Head of Analytics", "CIO"],
"departments": ["Data", "IT", "Analytics"]
},
"keywords": ["analytics", "reporting", "dashboard", "data warehouse", "BI", "visibility", "single source of truth", "KPI", "metrics"]
},
{
"id": "cap_compliance_suite",
"name": "Compliance & Audit Management Suite",
"description": "Centralizes controls, evidence collection, and audit workflows to reduce compliance risk and audit prep effort.",
"businessProblems": [
"Manual, spreadsheet-driven audit preparation",
"Expanding regulatory scope after entering new markets",
"Audit findings or material weaknesses disclosed",
"Compliance team cannot scale with the business"
],
"buyingSignals": [
"Hiring compliance, internal audit, or GRC roles",
"Regulatory filing disclosing control weaknesses",
"Press release about entering regulated markets",
"Executive posts about risk, controls, or governance"
],
"antiSignals": [
"Already purchased compliance suite per CRM"
],
"targetBuyer": {
"titles": ["Chief Risk Officer", "VP Compliance", "Head of Internal Audit", "General Counsel"],
"departments": ["Risk", "Compliance", "Legal"]
},
"keywords": ["compliance", "audit", "controls", "risk", "regulatory", "governance", "GRC", "material weakness", "SOX"]
},
{
"id": "cap_revenue_intelligence",
"name": "Revenue Intelligence & Forecasting",
"description": "Applies AI to pipeline and activity data to improve forecast accuracy and surface at-risk deals.",
"businessProblems": [
"Forecast accuracy is poor and reactive",
"Sales leaders lack visibility into deal health",
"Rapid sales-team growth straining forecasting process",
"CRM data quality undermining planning"
],
"buyingSignals": [
"Hiring RevOps, sales operations, or sales enablement roles",
"CRO or VP Sales posts about forecast accuracy or pipeline",
"Funding round driving aggressive sales scaling",
"Job descriptions mentioning forecasting or pipeline management"
],
"antiSignals": [
"Already purchased revenue intelligence per CRM"
],
"targetBuyer": {
"titles": ["CRO", "VP Sales", "Head of RevOps", "VP Sales Operations"],
"departments": ["Sales", "Revenue Operations"]
},
"keywords": ["forecast", "pipeline", "revenue", "sales", "RevOps", "quota", "deal", "funding", "scaling"]
}
],
"bundleSignals": [
{
"capabilityIds": ["cap_financial_close", "cap_workflow_automation"],
"description": "Post-acquisition consolidation + manual handoff pain often pairs close automation with workflow automation."
}
],
"globalAntiSignals": [
"Competitor X displacement win logged in CRM within 90 days"
]
}

View File

@@ -0,0 +1,97 @@
{
"_comment": "PLACEHOLDER harvested signals (v1). Production: Proxycurl/Apify (linkedin), Exa.ai/NewsAPI (news), JSearch (jobs), SEC EDGAR (filings). Signals here are written to exercise the Broadcom/VMware capability library.",
"acct_atlas_bank": {
"linkedin": [
{
"author": { "name": "Diane Okafor", "title": "CIO", "seniority": "C-suite" },
"content": "Our public cloud bill has nearly doubled in two years. We're taking a hard look at repatriating core workloads to a modern private cloud and standardizing our data center operating model. Sovereignty and cost are both driving this.",
"engagement": { "likes": 274, "comments": 53 },
"publishedAt": "2026-06-20T14:22:00Z",
"url": "https://linkedin.com/posts/dokafor-atlas-1"
},
{
"author": { "name": "Raj Patel", "title": "CISO", "seniority": "C-suite" },
"content": "Zero-trust is no longer optional for us. After our last audit we're prioritizing least-privilege access, MFA everywhere, and getting privileged accounts under control across the enterprise.",
"engagement": { "likes": 119, "comments": 27 },
"publishedAt": "2026-06-18T10:00:00Z",
"url": "https://linkedin.com/posts/rpatel-atlas-1"
}
],
"news": [
{
"title": "Atlas Financial Group to modernize mainframe and adopt API-first core",
"content": "Atlas Financial announced a multi-year initiative to modernize its mainframe applications, exposing core banking services via APIs and bringing next-generation developer tooling to its z/OS teams.",
"publishedAt": "2026-06-12T09:00:00Z",
"url": "https://finextra.com/atlas-mainframe-modernization"
}
],
"jobs": [
{
"title": "Director, Identity & Access Management",
"content": "Lead our zero-trust identity program: SSO, MFA, and privileged access management across cloud and on-prem. Drive access governance and recertification to close audit findings.",
"count": 2,
"publishedAt": "2026-06-15T00:00:00Z",
"url": "https://jobs.atlasfinancial.com/iam-director"
}
],
"sec_filing": []
},
"acct_summit_insure": {
"linkedin": [
{
"author": { "name": "Tom Reyes", "title": "VP Infrastructure", "seniority": "VP" },
"content": "The ransomware wave hitting our industry has the board's full attention. We're investing in resilience: tested disaster recovery, rapid ransomware recovery, and micro-segmentation to stop lateral movement. DORA timelines aren't helping the stress levels.",
"engagement": { "likes": 88, "comments": 19 },
"publishedAt": "2026-06-21T11:00:00Z",
"url": "https://linkedin.com/posts/treyes-summit-1"
}
],
"news": [
{
"title": "Summit Mutual launches enterprise agile transformation",
"content": "Summit Mutual Insurance kicked off an enterprise-wide agile transformation, adopting SAFe and reorganizing IT around product value streams to speed delivery of digital insurance products.",
"publishedAt": "2026-06-17T09:00:00Z",
"url": "https://insurancejournal.com/summit-agile"
}
],
"jobs": [
{
"title": "Release Train Engineer / Agile Coach",
"content": "Scale agile across the enterprise. Help teams adopt SAFe, improve flow, and connect strategy to delivery as part of our transformation.",
"count": 3,
"publishedAt": "2026-06-14T00:00:00Z",
"url": "https://jobs.summitmutual.com/rte"
}
],
"sec_filing": []
},
"acct_brightpath": {
"linkedin": [
{
"author": { "name": "Alex Chen", "title": "VP Engineering", "seniority": "VP" },
"content": "Scaling pains are real. Our last outage cost us dearly and root-cause took hours across a dozen disconnected monitoring tools. We need real observability and SRE discipline before the next one. Also drowning in ungoverned APIs as we add partners.",
"engagement": { "likes": 201, "comments": 64 },
"publishedAt": "2026-06-21T16:00:00Z",
"url": "https://linkedin.com/posts/achen-brightpath-1"
}
],
"news": [],
"jobs": [
{
"title": "Site Reliability Engineer (Observability)",
"content": "Build our observability practice: full-stack application performance monitoring, reduce MTTR, and tame alert noise as we scale.",
"count": 2,
"publishedAt": "2026-06-20T00:00:00Z",
"url": "https://jobs.brightpath.io/sre"
},
{
"title": "Platform Engineer, API & Integration",
"content": "Own our API gateway and developer portal. Govern API sprawl and onboard partners securely as our ecosystem grows.",
"count": 1,
"publishedAt": "2026-06-19T00:00:00Z",
"url": "https://jobs.brightpath.io/api-platform"
}
],
"sec_filing": []
}
}

115
sales-agent/run.py Normal file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""CLI entrypoint for the Sales Prospecting Agent Loop (MVP).
Commands:
python run.py loop [--account ID] [--tier strategic|enterprise|growth]
python run.py digest [--rep rep_sarah]
python run.py feedback --brief ID --action act_on|snooze|reject|won|lost [--note "..."]
python run.py recalibrate
python run.py accounts
python run.py briefs
"""
from __future__ import annotations
import argparse
import sys
from src import config, pipeline
from src.connectors import crm, exa
from src.delivery import digest as digest_mod
from src.memory import store, feedback
def cmd_loop(args):
if args.no_domain_filter:
config.EXA_DOMAIN_FILTER = False # per-run override
pipeline.run_all(tier=args.tier, account_id=args.account)
print("\n--- digest preview ---")
print(digest_mod.write_digest())
def cmd_digest(args):
print(digest_mod.write_digest(rep_id=args.rep))
def cmd_feedback(args):
entry = feedback.record_feedback(args.brief, args.action, args.note or "")
print(f"Recorded '{args.action}' on brief {args.brief}. "
f"New status: {store.get_brief(args.brief)['status']}")
def cmd_recalibrate(args):
weights = feedback.recalibrate()
if not weights:
print("Not enough feedback yet (need >=3 samples per capability/tier/signal combo).")
return
print("Updated scoring weights:")
for k, v in weights.items():
print(f" {k}: weight={v['weight']} (n={v['samples']})")
def cmd_accounts(args):
for a in crm.load_accounts():
print(f" {a['accountId']:18} {a['accountTier']:10} {a['accountName']:28} "
f"rep={a['assignedRep']['name']} has={a.get('currentProducts') or '[]'}")
def cmd_briefs(args):
briefs = sorted(store.load_briefs(), key=lambda b: b["composite_score"], reverse=True)
if not briefs:
print("No briefs yet. Run: python run.py loop")
return
for b in briefs:
print(f" {b['id']} [{b['priority']:8}] score={b['composite_score']:<5} "
f"status={b['status']:9} {b['account_name']}{b['capability_name']}")
def cmd_exa_usage(args):
st = exa.usage_status()
print("Exa.ai request budget")
print(f" enabled: {st['enabled']}")
print(f" month: {st['month']}")
print(f" used this month: {st['used_this_month']}")
print(f" monthly cap: {st['monthly_cap']}")
print(f" monthly remaining: {st['monthly_remaining']}")
print(f" per-run cap: {st['per_run_cap']}")
if not st["enabled"]:
print(" (Exa disabled — set EXA_API_KEY in .env to enable; using fixtures)")
def main(argv=None):
parser = argparse.ArgumentParser(description="Sales Prospecting Agent Loop (MVP)")
sub = parser.add_subparsers(dest="command", required=True)
p_loop = sub.add_parser("loop", help="Run the harvest->deliver loop")
p_loop.add_argument("--account", help="single account id")
p_loop.add_argument("--tier", choices=["strategic", "enterprise", "growth"])
p_loop.add_argument("--no-domain-filter", action="store_true",
help="include broad third-party news instead of scoping Exa to the account domain")
p_loop.set_defaults(func=cmd_loop)
p_dig = sub.add_parser("digest", help="Print the daily intelligence digest")
p_dig.add_argument("--rep", help="filter to a rep id")
p_dig.set_defaults(func=cmd_digest)
p_fb = sub.add_parser("feedback", help="Record rep feedback on a brief")
p_fb.add_argument("--brief", required=True)
p_fb.add_argument("--action", required=True,
choices=["act_on", "snooze", "reject", "won", "lost"])
p_fb.add_argument("--note", default="")
p_fb.set_defaults(func=cmd_feedback)
p_re = sub.add_parser("recalibrate", help="Recompute feedback weights (weekly job)")
p_re.set_defaults(func=cmd_recalibrate)
sub.add_parser("accounts", help="List target accounts").set_defaults(func=cmd_accounts)
sub.add_parser("briefs", help="List all generated briefs").set_defaults(func=cmd_briefs)
sub.add_parser("exa-usage", help="Show Exa.ai request budget").set_defaults(func=cmd_exa_usage)
args = parser.parse_args(argv)
config.ensure_dirs()
args.func(args)
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1 @@
"""Enterprise Sales Prospecting Agent Loop — MVP (v1, placeholder assumptions)."""

View File

View File

@@ -0,0 +1,16 @@
"""Phase 1: Signal Harvesting. Fans out across all source connectors per account."""
from __future__ import annotations
from ..connectors import linkedin, news, jobs
from ..types import RawSignal
def run_harvesters(account: dict) -> list[RawSignal]:
"""Equivalent of Promise.allSettled across source harvesters."""
raw: list[RawSignal] = []
for harvester in (linkedin.harvest, news.harvest, jobs.harvest):
try:
raw.extend(harvester(account))
except Exception as e: # one bad source shouldn't kill the run
print(f" [harvest] {harvester.__module__} failed: {e}")
return raw

View File

@@ -0,0 +1,22 @@
"""Phase 2: Normalization + deduplication.
Hash dedup (exact) is implemented. Semantic dedup (embeddings/cosine) is noted
as a v2 hook — the MVP relies on content-fingerprint hashing only.
"""
from __future__ import annotations
from ..types import RawSignal, NormalizedSignal
from ..memory import store
def normalize_and_dedup(raw_signals: list[RawSignal], account: dict, mem: dict) -> list[NormalizedSignal]:
seen = store.seen_hashes(mem)
out: list[NormalizedSignal] = []
fresh_ids: set[str] = set()
for raw in raw_signals:
norm = NormalizedSignal.from_raw(raw)
if norm.id in seen or norm.id in fresh_ids:
continue # exact duplicate, already processed in a prior run
fresh_ids.add(norm.id)
out.append(norm)
return out

View File

@@ -0,0 +1,115 @@
"""Phase 3: Relevance filtering against the Capability Library (LLM reasoning).
Each signal is pre-filtered to candidate capabilities (keyword ANN stand-in),
then evaluated by the LLM. A deterministic mock path runs when no API key is set.
"""
from __future__ import annotations
from .. import capabilities, llm
from ..types import NormalizedSignal, RelevanceResult
SYSTEM = """You are an account intelligence analyst for an enterprise software company.
Your job: evaluate whether a business signal indicates a genuine cross-sell opportunity
for one of our product capabilities. Be skeptical — only flag signals grounded in a real,
specific business need that a listed capability directly addresses. Return JSON only."""
USER_TMPL = """ACCOUNT CONTEXT
Company: {account_name}
Current Products: {current_products}
Account Tier: {account_tier}
SIGNAL
{content}
Source: {source} | Type: {signal_type} | Date: {published_at}
CANDIDATE CAPABILITIES
{capability_summary}
INSTRUCTIONS
1. Determine if this signal indicates a business need one or more candidate capabilities directly addresses.
2. If yes, list the specific capability id(s) and explain WHY (cite the signal).
3. If the account ALREADY has that capability (see Current Products), set "isExpansion": true.
4. Assign signalStrength: strong | moderate | weak.
5. Identify the ideal contact to approach (title + department).
6. If no genuine relevance exists, set "relevant": false.
Return ONLY this JSON:
{{"relevant": bool, "capabilityIds": [str], "rationale": str, "signalStrength": "strong|moderate|weak",
"isExpansion": bool, "idealContact": {{"title": str, "department": str}}, "suggestedAngle": str}}"""
def _mock_evaluate(signal: NormalizedSignal, candidates: list[dict], account: dict) -> dict:
"""Deterministic stand-in for the LLM: keyword overlap + seniority heuristics."""
if not candidates:
return {"relevant": False, "capabilityIds": [], "rationale": "No candidate capability matched.",
"signalStrength": "weak", "isExpansion": False,
"idealContact": {"title": "", "department": ""}, "suggestedAngle": ""}
top = candidates[0]
seniority = (signal.author or {}).get("seniority", "")
strength = "strong" if (seniority == "C-suite" or "[" in signal.content[:3]) else "moderate"
if signal.source == "jobs" and signal.signal_type == "hiring_surge":
strength = "strong"
is_expansion = top["id"] in account.get("currentProducts", [])
return {
"relevant": True,
"capabilityIds": [top["id"]],
"rationale": (
f"Signal references themes central to {top['name']} "
f"(e.g. {', '.join(top['keywords'][:3])}). Source={signal.source}, "
f"author seniority={seniority or 'n/a'}."
),
"signalStrength": strength,
"isExpansion": is_expansion,
"idealContact": {
"title": top["targetBuyer"]["titles"][0],
"department": top["targetBuyer"]["departments"][0],
},
"suggestedAngle": top["businessProblems"][0],
}
def evaluate_signal(signal: NormalizedSignal, account: dict) -> RelevanceResult | None:
candidates = capabilities.prefilter(signal.content)
if not candidates:
return None
if llm.is_mock():
data = _mock_evaluate(signal, candidates, account)
else:
user = USER_TMPL.format(
account_name=account["accountName"],
current_products=", ".join(account.get("currentProducts", [])) or "none",
account_tier=account["accountTier"],
content=signal.content,
source=signal.source,
signal_type=signal.signal_type,
published_at=signal.published_at,
capability_summary=capabilities.library_summary_for_prompt(candidates),
)
try:
data = llm.call_json(SYSTEM, user)
except llm.LLMError as e:
print(f" [relevance] LLM error, falling back to mock: {e}")
data = _mock_evaluate(signal, candidates, account)
if not data.get("relevant"):
return None
return RelevanceResult(
signal_id=signal.id,
relevant=True,
capability_ids=data.get("capabilityIds", []),
rationale=data.get("rationale", ""),
signal_strength=data.get("signalStrength", "weak"),
is_expansion=bool(data.get("isExpansion", False)),
ideal_contact=data.get("idealContact", {}),
suggested_angle=data.get("suggestedAngle", ""),
)
def filter_for_relevance(signals: list[NormalizedSignal], account: dict) -> list[tuple[NormalizedSignal, RelevanceResult]]:
out = []
for s in signals:
res = evaluate_signal(s, account)
if res:
out.append((s, res))
return out

View File

@@ -0,0 +1,95 @@
"""Phase 5: Composite scoring & prioritization.
compositeScore = signalStrength*0.30 + llmConfidence*0.20 + accountTier*0.20
+ buyerSeniority*0.15 + timingUrgency*0.10 + repFeedbackWeight*0.05
All sub-scores are normalized to 0..1. (The design's base formula is extended
here with an llmConfidence term so the reasoning model's own confidence shapes
ranking.) A separate hard floor (CONFIDENCE_FLOOR) demotes briefs the model
doubts, regardless of composite score.
"""
from __future__ import annotations
from .. import config
from ..types import OpportunityBrief, now_utc, parse_dt
from ..memory import feedback
_TIER = {"strategic": 1.0, "enterprise": 0.66, "growth": 0.33}
_SENIORITY = {"c-suite": 1.0, "evp": 1.0, "svp": 0.85, "vp": 0.7,
"director": 0.55, "manager": 0.35}
def _signal_strength_score(brief: OpportunityBrief) -> float:
strengths = [s.get("strength") for s in brief.signals]
strong = strengths.count("strong")
moderate = strengths.count("moderate")
if strong >= 2:
return 1.0
if strong == 1 or moderate >= 2:
return 0.7
return 0.4
def _seniority_score(brief: OpportunityBrief) -> float:
best = 0.0
for s in brief.signals:
author = s.get("author") or {}
sen = (author.get("seniority") or "").lower()
best = max(best, _SENIORITY.get(sen, 0.0))
title = (author.get("title") or "").lower()
for key, val in _SENIORITY.items():
if key in title:
best = max(best, val)
return best or 0.4
def _timing_score(brief: OpportunityBrief) -> float:
newest = None
for s in brief.signals:
try:
dt = parse_dt(s["published_at"])
except Exception:
continue
if newest is None or dt > newest:
newest = dt
if newest is None:
return 0.5
age = (now_utc() - newest).days
if age < 7:
return 1.0
if age <= 21:
return 0.66
return 0.33
def score_and_prioritize(briefs: list[OpportunityBrief], account: dict) -> list[OpportunityBrief]:
tier = account["accountTier"]
for b in briefs:
s_strength = _signal_strength_score(b)
s_confidence = max(0.0, min(1.0, b.confidence_score))
s_tier = _TIER.get(tier, 0.33)
s_seniority = _seniority_score(b)
s_timing = _timing_score(b)
s_feedback = feedback.feedback_weight(
b.capability_id, tier, [s.get("signal_type") for s in b.signals]
)
composite = (
s_strength * 0.30
+ s_confidence * 0.20
+ s_tier * 0.20
+ s_seniority * 0.15
+ s_timing * 0.10
+ s_feedback * 0.05
)
b.composite_score = round(composite, 3)
# Hard gate: the model's own low confidence keeps a brief out of delivery.
if s_confidence < config.CONFIDENCE_FLOOR:
b.priority = "low"
elif composite >= config.PRIORITY_THRESHOLD:
b.priority = "priority"
elif composite >= config.STANDARD_THRESHOLD:
b.priority = "standard"
else:
b.priority = "low"
briefs.sort(key=lambda x: x.composite_score, reverse=True)
return briefs

View File

@@ -0,0 +1,161 @@
"""Phase 4: Opportunity synthesis.
Groups relevant signals by capability, then produces one Opportunity Brief per
(account, capability) via a second LLM pass. Mock path builds a grounded brief
deterministically from the contributing signals.
"""
from __future__ import annotations
from datetime import timedelta
from .. import capabilities, llm, config
from ..types import (
NormalizedSignal, RelevanceResult, OpportunityBrief,
now_utc, iso, fingerprint,
)
SYSTEM = """You are synthesizing validated account-intelligence signals into a single,
actionable sales opportunity brief. Stay grounded — never over-promise beyond what the
signals say. Lead with the most compelling signal. Return JSON only."""
USER_TMPL = """ACCOUNT
{account_name} (tier: {account_tier}); current products: {current_products}
TARGET CAPABILITY
{capability_name}: {capability_desc}
VALIDATED SIGNALS
{signals_block}
Create a brief. Return ONLY this JSON:
{{"headline": str (one sentence),
"executiveSummary": str (2-3 sentences for the rep),
"talkingPoints": [str, str, str],
"outreachHook": str (one opener for email/LinkedIn DM),
"urgencyLevel": "high|medium|low",
"confidenceScore": float 0..1}}"""
def _signals_block(items: list[tuple[NormalizedSignal, RelevanceResult]]) -> str:
lines = []
for i, (s, r) in enumerate(items, 1):
author = ""
if s.author:
author = f"{s.author.get('name')} ({s.author.get('title')})"
lines.append(
f"[{i}] {s.source}/{s.signal_type}{author} ({s.published_at[:10]})\n"
f' "{s.content[:400]}"\n'
f" relevance: {r.signal_strength}{r.rationale}"
)
return "\n".join(lines)
def _target_contacts(account: dict, ideal: dict) -> list[dict]:
contacts = list(account.get("targetContacts", []))
# Surface the ideal-contact title even if not yet in CRM.
if ideal.get("title") and not any(
ideal["title"].lower() in (c.get("title", "").lower()) for c in contacts
):
contacts.insert(0, {"name": "(prospect)", "title": ideal["title"],
"department": ideal.get("department", ""), "inCrm": False})
return contacts
def _mock_brief_body(cap: dict, items, account) -> dict:
strong = [r for _, r in items if r.signal_strength == "strong"]
urgency = "high" if strong else ("medium" if len(items) > 1 else "low")
confidence = min(0.95, 0.45 + 0.18 * len(strong) + 0.08 * len(items))
lead = items[0][0]
tps = [
f"{cap['name']} directly addresses: {cap['businessProblems'][0]}.",
f"Signal evidence: {items[0][1].rationale}",
]
if len(items) > 1:
tps.append(f"Reinforced by {len(items)-1} additional signal(s) across "
f"{', '.join(sorted({s.source for s, _ in items}))}.")
hook_who = (lead.author or {}).get("name", "there")
return {
"headline": f"{account['accountName']}: {len(items)} signal(s) point to {cap['name']}.",
"executiveSummary": (
f"{account['accountName']} is showing intent around {cap['name'].lower()}. "
f"Lead signal: {lead.signal_type} via {lead.source}. "
f"{len(strong)} strong / {len(items)} total contributing signals."
),
"talkingPoints": tps,
"outreachHook": (
f"{hook_who} — saw the recent signal around "
f"{cap['keywords'][0]}. We've helped similar {account.get('industry','')} "
f"teams here. Worth 20 minutes?"
),
"urgencyLevel": urgency,
"confidenceScore": round(confidence, 2),
}
def synthesize(
relevant: list[tuple[NormalizedSignal, RelevanceResult]], account: dict
) -> list[OpportunityBrief]:
# Group by capability id.
by_cap: dict[str, list[tuple[NormalizedSignal, RelevanceResult]]] = {}
for s, r in relevant:
for cap_id in r.capability_ids:
by_cap.setdefault(cap_id, []).append((s, r))
briefs: list[OpportunityBrief] = []
for cap_id, items in by_cap.items():
cap = capabilities.capability_by_id(cap_id)
if not cap:
continue
# Strongest signal first.
order = {"strong": 0, "moderate": 1, "weak": 2}
items.sort(key=lambda x: order.get(x[1].signal_strength, 3))
if llm.is_mock():
body = _mock_brief_body(cap, items, account)
else:
user = USER_TMPL.format(
account_name=account["accountName"],
account_tier=account["accountTier"],
current_products=", ".join(account.get("currentProducts", [])) or "none",
capability_name=cap["name"],
capability_desc=cap["description"],
signals_block=_signals_block(items),
)
try:
body = llm.call_json(SYSTEM, user, max_tokens=1200)
except llm.LLMError as e:
print(f" [synthesize] LLM error, falling back to mock: {e}")
body = _mock_brief_body(cap, items, account)
created = now_utc()
ideal = items[0][1].ideal_contact
brief = OpportunityBrief(
id=fingerprint(account["accountId"], cap_id, iso(created)),
account_id=account["accountId"],
account_name=account["accountName"],
capability_id=cap_id,
capability_name=cap["name"],
headline=body.get("headline", ""),
executive_summary=body.get("executiveSummary", ""),
signal_ids=[s.id for s, _ in items],
signals=[
{
"id": s.id, "source": s.source, "signal_type": s.signal_type,
"content": s.content, "published_at": s.published_at, "url": s.url,
"author": s.author, "strength": r.signal_strength,
}
for s, r in items
],
talking_points=body.get("talkingPoints", []),
outreach_hook=body.get("outreachHook", ""),
target_contacts=_target_contacts(account, ideal),
urgency_level=body.get("urgencyLevel", "medium"),
confidence_score=float(body.get("confidenceScore", 0.5)),
composite_score=0.0, # filled by scorer
priority="", # filled by scorer
assigned_rep=account["assignedRep"],
created_at=iso(created),
expires_at=iso(created + timedelta(days=config.BRIEF_TTL_DAYS)),
)
briefs.append(brief)
return briefs

View File

@@ -0,0 +1,61 @@
"""Capability Library loader + lightweight keyword pre-filter.
The design uses a vector ANN pre-filter to cut LLM calls ~60%. For the MVP we
approximate that with keyword overlap scoring (no embedding service required).
The interface is the same: given a signal, return candidate capabilities.
"""
from __future__ import annotations
import json
import re
from functools import lru_cache
from . import config
@lru_cache(maxsize=1)
def load_library() -> dict:
return json.loads((config.DATA_DIR / "capabilities.json").read_text())
def capability_by_id(cap_id: str) -> dict | None:
for cap in load_library()["capabilities"]:
if cap["id"] == cap_id:
return cap
return None
def _tokens(text: str) -> set[str]:
return set(re.findall(r"[a-z]{3,}", text.lower()))
def prefilter(signal_content: str, top_k: int = 3) -> list[dict]:
"""Return capabilities whose keywords overlap the signal, best first.
Stand-in for the vector ANN pre-filter in the design (section 5.2).
"""
sig_tokens = _tokens(signal_content)
scored: list[tuple[float, dict]] = []
for cap in load_library()["capabilities"]:
kw = set(k.lower() for k in cap.get("keywords", []))
kw_tokens: set[str] = set()
for k in kw:
kw_tokens |= _tokens(k)
overlap = len(sig_tokens & kw_tokens)
if overlap:
scored.append((overlap, cap))
scored.sort(key=lambda x: x[0], reverse=True)
return [cap for _, cap in scored[:top_k]]
def library_summary_for_prompt(candidates: list[dict]) -> str:
"""Compact capability descriptions for the relevance-filter prompt."""
lines = []
for c in candidates:
lines.append(
f"- {c['id']} | {c['name']}: {c['description']}\n"
f" Buying signals: {'; '.join(c['buyingSignals'])}\n"
f" Anti-signals: {'; '.join(c['antiSignals'])}\n"
f" Target buyer: {', '.join(c['targetBuyer']['titles'])}"
)
return "\n".join(lines)

103
sales-agent/src/config.py Normal file
View File

@@ -0,0 +1,103 @@
"""Central config & paths. Loads a .env file if present (no external deps)."""
from __future__ import annotations
import os
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DATA_DIR = ROOT / "data"
OUTPUT_DIR = ROOT / "output"
STATE_DIR = OUTPUT_DIR / "state"
BRIEFS_DIR = OUTPUT_DIR / "briefs"
def _load_dotenv() -> None:
"""Minimal .env loader so the user can keep keys in sales-agent/.env."""
env_path = ROOT / ".env"
if not env_path.exists():
return
for line in env_path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
key, val = key.strip(), val.strip().strip('"').strip("'")
os.environ.setdefault(key, val)
_load_dotenv()
# --- LLM ---
# Provider abstraction: gemini | anthropic | mock. If LLM_PROVIDER is unset we
# auto-detect from whichever key is present (Gemini preferred), falling back to
# a deterministic mock reasoner so the loop still runs offline.
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "").strip()
LLM_MODEL = os.environ.get("LLM_MODEL", "claude-sonnet-4-6")
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "").strip()
GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash")
_provider = os.environ.get("LLM_PROVIDER", "").strip().lower()
if not _provider:
if GEMINI_API_KEY:
_provider = "gemini"
elif ANTHROPIC_API_KEY:
_provider = "anthropic"
else:
_provider = "mock"
# A forced USE_MOCK_LLM=1, or selecting a provider whose key is missing, => mock.
if os.environ.get("USE_MOCK_LLM", "").strip() == "1":
_provider = "mock"
elif _provider == "gemini" and not GEMINI_API_KEY:
_provider = "mock"
elif _provider == "anthropic" and not ANTHROPIC_API_KEY:
_provider = "mock"
LLM_PROVIDER = _provider
USE_MOCK_LLM = LLM_PROVIDER == "mock"
def llm_label() -> str:
if LLM_PROVIDER == "mock":
return "MOCK reasoner (no LLM key)"
if LLM_PROVIDER == "gemini":
return f"Gemini ({GEMINI_MODEL})"
return f"Anthropic ({LLM_MODEL})"
# --- Scoring thresholds (Phase 5 of the design) ---
PRIORITY_THRESHOLD = float(os.environ.get("PRIORITY_THRESHOLD", "0.72"))
STANDARD_THRESHOLD = float(os.environ.get("STANDARD_THRESHOLD", "0.45"))
# Briefs whose LLM confidence falls below this floor are demoted to "low"
# (logged, not delivered) regardless of composite score — keeps weak matches
# the model itself doubts out of the rep's queue.
CONFIDENCE_FLOOR = float(os.environ.get("CONFIDENCE_FLOOR", "0.35"))
# --- Exa.ai news/web harvester ---
EXA_API_KEY = os.environ.get("EXA_API_KEY", "").strip()
# Enabled only when a key is present AND not explicitly turned off.
EXA_ENABLED = (os.environ.get("EXA_ENABLED", "1").strip() == "1") and bool(EXA_API_KEY)
# Hard request ceilings to protect the free plan. The connector will NOT call
# Exa once either cap is reached; the monthly counter persists across runs.
EXA_MONTHLY_REQUEST_CAP = int(os.environ.get("EXA_MONTHLY_REQUEST_CAP", "1000"))
EXA_PER_RUN_REQUEST_CAP = int(os.environ.get("EXA_PER_RUN_REQUEST_CAP", "25"))
EXA_RESULTS_PER_QUERY = int(os.environ.get("EXA_RESULTS_PER_QUERY", "5"))
EXA_LOOKBACK_DAYS = int(os.environ.get("EXA_LOOKBACK_DAYS", "45"))
# Scope news search to each account's own domain (newsroom/press/IR) to cut
# third-party ticker/analyst noise. Toggle off in .env or per-run with
# `loop --no-domain-filter` to include broad third-party coverage.
EXA_DOMAIN_FILTER = os.environ.get("EXA_DOMAIN_FILTER", "1").strip() == "1"
# --- Dedup / suppression windows ---
SEMANTIC_DEDUP_DAYS = 90
SAME_TYPE_SUPPRESS_DAYS = 14
# Per-tier harvest cadence (informational for the scheduler; the MVP runs on demand)
TIER_CADENCE_HOURS = {"strategic": 24, "enterprise": 60, "growth": 168}
# Brief expiry (opportunities decay if not acted on)
BRIEF_TTL_DAYS = 21
def ensure_dirs() -> None:
for d in (OUTPUT_DIR, STATE_DIR, BRIEFS_DIR):
d.mkdir(parents=True, exist_ok=True)

View File

@@ -0,0 +1,21 @@
"""Source connectors. v1 reads placeholder fixtures; swap for real APIs later.
Production mapping (from the design):
linkedin -> Proxycurl + Apify
news -> Exa.ai + NewsAPI
jobs -> JSearch (RapidAPI) / Adzuna
crm -> Salesforce / HubSpot REST
sec -> SEC EDGAR
"""
import json
from functools import lru_cache
from .. import config
@lru_cache(maxsize=1)
def _fixtures() -> dict:
return json.loads((config.DATA_DIR / "fixtures.json").read_text())
def account_fixture(account_id: str) -> dict:
return _fixtures().get(account_id, {})

View File

@@ -0,0 +1,39 @@
"""CRM connector (v1 reads data/accounts.json -> Salesforce/HubSpot in production).
Provides read (account context) and write-back (activity logging) stubs.
"""
from __future__ import annotations
import json
from functools import lru_cache
from .. import config
@lru_cache(maxsize=1)
def _accounts() -> list[dict]:
return json.loads((config.DATA_DIR / "accounts.json").read_text())["accounts"]
def load_accounts(tier: str | None = None, account_id: str | None = None) -> list[dict]:
accts = _accounts()
if account_id:
accts = [a for a in accts if a["accountId"] == account_id]
if tier:
accts = [a for a in accts if a["accountTier"] == tier]
return accts
def get_account(account_id: str) -> dict | None:
for a in _accounts():
if a["accountId"] == account_id:
return a
return None
def log_activity(account_id: str, brief: dict) -> None:
"""Write-back stub. Production: create a Salesforce/HubSpot Task + activity."""
print(
f" [CRM] logged activity on {account_id}: "
f"\"{brief['headline']}\" -> task assigned to {brief['assigned_rep']['name']}"
)

View File

@@ -0,0 +1,168 @@
"""Exa.ai news/web harvester (real API) with hard request ceilings.
Free-plan protection (the whole point of this module):
* A persistent MONTHLY counter in output/state/exa_usage.json, reset each
calendar month. Once EXA_MONTHLY_REQUEST_CAP is hit, no more calls are made.
* A PER-RUN counter (process-local) capped at EXA_PER_RUN_REQUEST_CAP.
A request is only counted when an HTTP response actually comes back from Exa
(i.e. a billable call). Network failures that never reach Exa are not counted.
Returns:
list[RawSignal] on a successful call (may be empty if Exa found no news)
None when Exa is disabled, a cap is reached, or the call errored
-> signals the news harvester to fall back to fixtures.
"""
from __future__ import annotations
import json
import urllib.request
import urllib.error
from datetime import timedelta
from .. import config
from ..types import RawSignal, now_utc, iso
_ENDPOINT = "https://api.exa.ai/search"
# Process-local counter -> enforces the per-run cap (resets every CLI invocation).
_run_count = 0
def _usage_path():
return config.STATE_DIR / "exa_usage.json"
def _current_month() -> str:
return now_utc().strftime("%Y-%m")
def _load_usage() -> dict:
config.ensure_dirs()
p = _usage_path()
if p.exists():
data = json.loads(p.read_text())
if data.get("month") == _current_month():
return data
# New file or a new month -> reset the counter.
return {"month": _current_month(), "count": 0}
def _save_usage(data: dict) -> None:
config.ensure_dirs()
_usage_path().write_text(json.dumps(data, indent=2))
def usage_status() -> dict:
"""Snapshot of current budget, for the CLI / startup banner."""
u = _load_usage()
return {
"enabled": config.EXA_ENABLED,
"month": u["month"],
"used_this_month": u["count"],
"monthly_cap": config.EXA_MONTHLY_REQUEST_CAP,
"monthly_remaining": max(0, config.EXA_MONTHLY_REQUEST_CAP - u["count"]),
"per_run_used": _run_count,
"per_run_cap": config.EXA_PER_RUN_REQUEST_CAP,
}
def _within_caps() -> tuple[bool, str]:
if _run_count >= config.EXA_PER_RUN_REQUEST_CAP:
return False, f"per-run cap reached ({config.EXA_PER_RUN_REQUEST_CAP})"
u = _load_usage()
if u["count"] >= config.EXA_MONTHLY_REQUEST_CAP:
return False, f"monthly cap reached ({config.EXA_MONTHLY_REQUEST_CAP})"
return True, ""
def _count_request() -> None:
global _run_count
_run_count += 1
u = _load_usage()
u["count"] += 1
_save_usage(u)
def _build_query(account: dict, domain_scoped: bool) -> str:
name = account["accountName"]
if domain_scoped:
# Domain is already restricting results, so keep the query topical/broad.
return (
f"{name} announcements: funding, acquisition, merger, leadership change, "
f"strategy, restructuring, product launch, hiring, earnings, partnership"
)
# No domain restriction -> anchor harder on the company name to limit drift.
return (
f'"{name}" company news: funding, acquisition, merger, leadership change, '
f"strategy, restructuring, product launch, hiring, earnings, partnership"
)
def harvest_news(account: dict) -> list[RawSignal] | None:
if not config.EXA_ENABLED:
return None
ok, reason = _within_caps()
if not ok:
print(f" [exa] skipped — {reason}; staying within free plan (using fixtures)")
return None
start = iso(now_utc() - timedelta(days=config.EXA_LOOKBACK_DAYS))
domain = (account.get("domain") or "").strip()
domain_scoped = config.EXA_DOMAIN_FILTER and bool(domain)
payload = {
"query": _build_query(account, domain_scoped),
"type": "auto",
"numResults": config.EXA_RESULTS_PER_QUERY,
"startPublishedDate": start,
"contents": {"text": {"maxCharacters": 1000}},
}
if domain_scoped:
payload["includeDomains"] = [domain]
req = urllib.request.Request(
_ENDPOINT,
data=json.dumps(payload).encode("utf-8"),
headers={"content-type": "application/json", "x-api-key": config.EXA_API_KEY},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
# The call reached Exa (billable) but returned an error status.
_count_request()
detail = e.read().decode("utf-8")[:200] if hasattr(e, "read") else ""
print(f" [exa] HTTP {e.code}: {detail} — falling back to fixtures")
return None
except urllib.error.URLError as e:
# Never reached Exa -> not counted.
print(f" [exa] network error ({e.reason}) — falling back to fixtures")
return None
_count_request() # successful billable request
results = body.get("results", []) or []
signals: list[RawSignal] = []
for r in results:
title = (r.get("title") or "").strip()
text = (r.get("text") or "").strip()
if not (title or text):
continue
content = f"{title}. {text}" if title else text
signals.append(
RawSignal(
account_id=account["accountId"],
source="news",
signal_type="news_article",
content=content[:1200],
published_at=r.get("publishedDate") or iso(now_utc()),
url=r.get("url", ""),
extra={"title": title, "exa_score": r.get("score"), "via": "exa",
"domain_scoped": domain_scoped},
)
)
remaining = usage_status()["monthly_remaining"]
scope = f"domain={domain}" if domain_scoped else "broad web"
print(f" [exa] {len(signals)} article(s) for {account['accountName']} ({scope}) "
f"(monthly budget left: {remaining}/{config.EXA_MONTHLY_REQUEST_CAP})")
return signals

View File

@@ -0,0 +1,24 @@
"""Job posting harvester (v1 placeholder -> JSearch/Adzuna in production)."""
from __future__ import annotations
from ..types import RawSignal
from . import account_fixture
def harvest(account: dict) -> list[RawSignal]:
out: list[RawSignal] = []
for item in account_fixture(account["accountId"]).get("jobs", []):
count = item.get("count", 1)
prefix = f"[{count} openings] " if count > 1 else ""
out.append(
RawSignal(
account_id=account["accountId"],
source="jobs",
signal_type="hiring_surge" if count > 1 else "job_posting",
content=f"{prefix}{item['title']}: {item['content']}",
published_at=item["publishedAt"],
url=item["url"],
extra={"title": item["title"], "count": count},
)
)
return out

View File

@@ -0,0 +1,23 @@
"""LinkedIn harvester (v1 placeholder -> Proxycurl/Apify in production)."""
from __future__ import annotations
from ..types import RawSignal
from . import account_fixture
def harvest(account: dict) -> list[RawSignal]:
out: list[RawSignal] = []
for item in account_fixture(account["accountId"]).get("linkedin", []):
out.append(
RawSignal(
account_id=account["accountId"],
source="linkedin",
signal_type="executive_post",
content=item["content"],
published_at=item["publishedAt"],
url=item["url"],
author=item.get("author"),
engagement=item.get("engagement"),
)
)
return out

View File

@@ -0,0 +1,35 @@
"""News/Web harvester.
Uses the real Exa.ai connector when enabled (and within its request ceiling);
otherwise falls back to placeholder fixtures so the loop still runs offline.
"""
from __future__ import annotations
from ..types import RawSignal
from . import account_fixture
from . import exa
def _from_fixtures(account: dict) -> list[RawSignal]:
out: list[RawSignal] = []
for item in account_fixture(account["accountId"]).get("news", []):
out.append(
RawSignal(
account_id=account["accountId"],
source="news",
signal_type="press_release",
content=f"{item.get('title', '')}. {item['content']}",
published_at=item["publishedAt"],
url=item["url"],
extra={"title": item.get("title", ""), "via": "fixture"},
)
)
return out
def harvest(account: dict) -> list[RawSignal]:
# Real source first; None means disabled/capped/errored -> use fixtures.
via_exa = exa.harvest_news(account)
if via_exa is not None:
return via_exa
return _from_fixtures(account)

View File

View File

@@ -0,0 +1,60 @@
"""Daily Intelligence Digest (email/Slack stand-in -> console + markdown).
Consolidated, per-rep, ordered by composite score, standard priority and above.
"""
from __future__ import annotations
from collections import defaultdict
from .. import config
from ..memory import store
from ..types import now_utc, parse_dt
def build_digest(rep_id: str | None = None) -> str:
briefs = [b for b in store.load_briefs() if b.get("priority") in ("priority", "standard")]
# Only active (not expired, still pending) opportunities.
active = []
for b in briefs:
if b.get("status") not in (None, "pending"):
continue
try:
if parse_dt(b["expires_at"]) < now_utc():
continue
except Exception:
pass
if rep_id and b["assigned_rep"]["id"] != rep_id:
continue
active.append(b)
by_rep: dict[str, list[dict]] = defaultdict(list)
for b in active:
by_rep[b["assigned_rep"]["id"]].append(b)
out = ["═══════════════════════════════════════════════",
f"📬 DAILY INTELLIGENCE DIGEST — {now_utc().strftime('%Y-%m-%d')}",
"═══════════════════════════════════════════════", ""]
if not active:
out.append("No active priority/standard opportunities in the queue.")
return "\n".join(out)
for rid, items in by_rep.items():
items.sort(key=lambda x: x["composite_score"], reverse=True)
rep_name = items[0]["assigned_rep"]["name"]
out.append(f"{rep_name} ({rid}) — {len(items)} opportunit"
f"{'y' if len(items)==1 else 'ies'}")
for b in items:
flag = "🚨" if b["priority"] == "priority" else "📊"
out.append(f" {flag} [{b['composite_score']}] {b['account_name']}{b['capability_name']}")
out.append(f" {b['headline']}")
out.append(f" brief: {b['id']} | confidence {round(b['confidence_score']*100)}%")
out.append("")
return "\n".join(out)
def write_digest(rep_id: str | None = None) -> str:
config.ensure_dirs()
text = build_digest(rep_id)
path = config.OUTPUT_DIR / "digest.md"
path.write_text(text)
return text

View File

@@ -0,0 +1,93 @@
"""Phase 7: Delivery rendering.
v1 delivery channels:
- console (priority briefs printed inline, Slack-style)
- markdown file per brief under output/briefs/ (stand-in for Slack/CRM/UI)
Production: Slack Bolt message with action buttons + CRM activity write-back.
"""
from __future__ import annotations
from .. import config
_PRIORITY_EMOJI = {"priority": "🚨", "standard": "📊", "low": "🗒️"}
def render_brief_md(brief: dict) -> str:
pct = round(brief["confidence_score"] * 100)
lines = [
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
"🎯 OPPORTUNITY BRIEF",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
f"Account: {brief['account_name']}",
f"Capability: {brief['capability_name']}",
f"Assigned: {brief['assigned_rep']['name']} ({brief['assigned_rep']['email']})",
f"Brief ID: {brief['id']}",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
"",
f"HEADLINE",
f" {brief['headline']}",
"",
f"CONFIDENCE: {pct}% URGENCY: {brief['urgency_level'].upper()} "
f"SCORE: {brief['composite_score']} PRIORITY: {brief['priority'].upper()}",
"",
"EXECUTIVE SUMMARY",
f" {brief['executive_summary']}",
"",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
"CONTRIBUTING SIGNALS",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
]
for i, s in enumerate(brief["signals"], 1):
author = ""
if s.get("author"):
author = f"{s['author'].get('name')} ({s['author'].get('title')})"
lines.append(f"[{i}] {s['source'].upper()}/{s['signal_type']}{author} "
f"({s['published_at'][:10]}) [{s['strength']}]")
lines.append(f' "{s["content"][:300]}"')
lines.append(f" {s['url']}")
lines.append("")
lines += [
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
"TALKING POINTS",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
]
for i, tp in enumerate(brief["talking_points"], 1):
lines.append(f"{i}. {tp}")
lines += [
"",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
"SUGGESTED OUTREACH",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
"Target:",
]
for c in brief["target_contacts"]:
crm = "in CRM" if c.get("inCrm") else "not yet in CRM"
last = f", last contact {c['lastContactDays']}d ago" if c.get("lastContactDays") else ""
lines.append(f" {c.get('name')}{c.get('title')} ({crm}{last})")
lines += [
"",
"Opener:",
f" {brief['outreach_hook']}",
"",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
f"Act: python run.py feedback --brief {brief['id']} --action act_on",
f"Snooze: python run.py feedback --brief {brief['id']} --action snooze",
f"Reject: python run.py feedback --brief {brief['id']} --action reject",
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
]
return "\n".join(lines)
def deliver(brief: dict) -> None:
"""Write the brief to a file; print priority briefs to console (Slack stand-in)."""
config.ensure_dirs()
path = config.BRIEFS_DIR / f"{brief['priority']}_{brief['account_id']}_{brief['id']}.md"
path.write_text(render_brief_md(brief))
emoji = _PRIORITY_EMOJI.get(brief["priority"], "")
if brief["priority"] == "priority":
print()
print(render_brief_md(brief))
print()
else:
print(f" {emoji} [{brief['priority']}] {brief['account_name']}"
f"{brief['capability_name']} (score {brief['composite_score']}) -> {path.name}")

105
sales-agent/src/llm.py Normal file
View File

@@ -0,0 +1,105 @@
"""LLM access layer (provider-agnostic).
Dispatches to a configured provider — Gemini or Anthropic — via plain urllib
(no SDKs). When no key is configured it falls back to a deterministic mock
reasoner so the full loop runs offline. All paths return parsed JSON dicts.
"""
from __future__ import annotations
import json
import re
import urllib.request
import urllib.error
from . import config
class LLMError(RuntimeError):
pass
def _extract_json(text: str) -> dict:
"""Pull the first JSON object out of a model response."""
text = text.strip()
# Strip ```json fences if present.
fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
if fence:
text = fence.group(1)
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1:
raise LLMError(f"No JSON object found in LLM output: {text[:200]}")
return json.loads(text[start : end + 1])
def _post(url: str, payload: dict, headers: dict, provider: str) -> dict:
req = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={"content-type": "application/json", **headers},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8")[:400] if hasattr(e, "read") else ""
raise LLMError(f"{provider} API error {e.code}: {detail}")
except urllib.error.URLError as e:
raise LLMError(f"Network error calling {provider} API: {e}")
def _call_gemini(system: str, user: str, max_tokens: int) -> dict:
url = (
"https://generativelanguage.googleapis.com/v1beta/models/"
f"{config.GEMINI_MODEL}:generateContent"
)
payload = {
"system_instruction": {"parts": [{"text": system}]},
"contents": [{"role": "user", "parts": [{"text": user}]}],
"generationConfig": {
"maxOutputTokens": max_tokens,
"temperature": 0.2,
"responseMimeType": "application/json",
},
}
body = _post(url, payload, {"x-goog-api-key": config.GEMINI_API_KEY}, "Gemini")
candidates = body.get("candidates", [])
if not candidates:
raise LLMError(f"Gemini returned no candidates: {json.dumps(body)[:300]}")
parts = candidates[0].get("content", {}).get("parts", [])
text = "".join(p.get("text", "") for p in parts)
return _extract_json(text)
def _call_anthropic(system: str, user: str, max_tokens: int) -> dict:
payload = {
"model": config.LLM_MODEL,
"max_tokens": max_tokens,
"system": system,
"messages": [{"role": "user", "content": user}],
}
headers = {
"x-api-key": config.ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
}
body = _post("https://api.anthropic.com/v1/messages", payload, headers, "Anthropic")
text = "".join(b.get("text", "") for b in body.get("content", []))
return _extract_json(text)
def call_json(system: str, user: str, max_tokens: int = 1500) -> dict:
"""Single-turn LLM call that returns parsed JSON."""
if config.USE_MOCK_LLM:
raise _MockSignal() # callers catch this and run their mock path
if config.LLM_PROVIDER == "gemini":
return _call_gemini(system, user, max_tokens)
return _call_anthropic(system, user, max_tokens)
class _MockSignal(Exception):
"""Internal sentinel: tells the caller to use its deterministic mock branch."""
def is_mock() -> bool:
return config.USE_MOCK_LLM

View File

View File

@@ -0,0 +1,121 @@
"""Feedback ingestion + weight recalibration (Phase 6 of the design)."""
from __future__ import annotations
import json
from pathlib import Path
from .. import config
from ..types import now_utc, iso, fingerprint
from . import store
VALID_ACTIONS = {"act_on", "snooze", "reject", "won", "lost"}
_STATUS_MAP = {
"act_on": "accepted",
"snooze": "snoozed",
"reject": "rejected",
"won": "won",
"lost": "lost",
}
def _feedback_path() -> Path:
return config.STATE_DIR / "feedback.json"
def _weights_path() -> Path:
return config.STATE_DIR / "scoring_weights.json"
def load_feedback() -> list[dict]:
p = _feedback_path()
return json.loads(p.read_text()) if p.exists() else []
def record_feedback(brief_id: str, action: str, note: str = "") -> dict:
if action not in VALID_ACTIONS:
raise ValueError(f"action must be one of {sorted(VALID_ACTIONS)}")
brief = store.get_brief(brief_id)
if not brief:
raise ValueError(f"brief {brief_id} not found")
entry = {
"id": fingerprint(brief_id, action, iso(now_utc())),
"briefId": brief_id,
"accountId": brief["account_id"],
"capabilityId": brief["capability_id"],
"repId": brief["assigned_rep"]["id"],
"action": action,
"feedbackText": note,
"signalTypes": [s.get("signal_type") for s in brief.get("signals", [])],
"accountTier": brief.get("metadata", {}).get("tier")
or brief.get("account_tier", "unknown"),
"createdAt": iso(now_utc()),
}
config.ensure_dirs()
all_fb = load_feedback()
all_fb.append(entry)
_feedback_path().write_text(json.dumps(all_fb, indent=2))
# Reflect the action on the brief + account memory.
brief["status"] = _STATUS_MAP[action]
store.save_brief(brief)
mem = store.load_memory(brief["account_id"])
for opp in mem["surfacedOpportunities"]:
if opp["briefId"] == brief_id:
opp["status"] = brief["status"]
# A 'reject' adds a 14-day suppression for that capability on that account.
if action == "reject":
mem["suppressions"].append(
{
"capabilityId": brief["capability_id"],
"expiresAt": iso(now_utc().replace(microsecond=0)),
"reason": note or "rep marked not relevant",
}
)
store.save_memory(mem)
return entry
def load_weights() -> dict:
p = _weights_path()
return json.loads(p.read_text()) if p.exists() else {}
def feedback_weight(capability_id: str, account_tier: str, signal_types: list[str]) -> float:
"""Look up the learned rep-feedback weight for a (capability, tier, signal) combo.
Returns 0.5 (neutral) when there isn't enough history. Range 0..1.
"""
weights = load_weights()
vals = []
for st in signal_types:
key = f"{capability_id}|{account_tier}|{st}"
if key in weights:
vals.append(weights[key]["weight"])
if not vals:
return 0.5
return sum(vals) / len(vals)
def recalibrate() -> dict:
"""Weekly job: acceptance rate by capability x tier x signal_type (min 3 samples)."""
fb = load_feedback()
buckets: dict[str, dict[str, int]] = {}
for e in fb:
for st in e.get("signalTypes", []) or ["unknown"]:
key = f"{e['capabilityId']}|{e['accountTier']}|{st}"
b = buckets.setdefault(key, {"positive": 0, "total": 0})
b["total"] += 1
if e["action"] in ("act_on", "won"):
b["positive"] += 1
weights = {}
for key, b in buckets.items():
if b["total"] >= 3:
weights[key] = {
"weight": round(b["positive"] / b["total"], 3),
"samples": b["total"],
}
config.ensure_dirs()
_weights_path().write_text(json.dumps(weights, indent=2))
return weights

View File

@@ -0,0 +1,121 @@
"""File-based account memory + brief store.
v1 stand-in for Redis (hash dedup) + pgvector (semantic dedup) + Postgres
(opportunity history). Everything persists as JSON under output/state/.
"""
from __future__ import annotations
import json
from pathlib import Path
from .. import config
from ..types import now_utc, iso, parse_dt
def _account_path(account_id: str) -> Path:
return config.STATE_DIR / f"memory_{account_id}.json"
def load_memory(account_id: str) -> dict:
config.ensure_dirs()
p = _account_path(account_id)
if p.exists():
return json.loads(p.read_text())
return {
"accountId": account_id,
"signalHashes": [], # [{hash, discoveredAt, signalType}]
"surfacedOpportunities": [], # [{briefId, capabilityId, surfacedAt, status}]
"suppressions": [], # [{capabilityId?, signalType?, expiresAt?}]
"accountNarrative": "",
"lastUpdated": None,
}
def save_memory(mem: dict) -> None:
config.ensure_dirs()
mem["lastUpdated"] = iso(now_utc())
_account_path(mem["accountId"]).write_text(json.dumps(mem, indent=2))
def seen_hashes(mem: dict) -> set[str]:
"""Return signal hashes seen within the dedup window."""
cutoff = now_utc()
out = set()
for entry in mem.get("signalHashes", []):
try:
age_days = (cutoff - parse_dt(entry["discoveredAt"])).days
except Exception:
age_days = 0
if age_days <= config.SEMANTIC_DEDUP_DAYS:
out.add(entry["hash"])
return out
def record_signals(mem: dict, signals: list) -> None:
for s in signals:
mem["signalHashes"].append(
{"hash": s.id, "discoveredAt": s.discovered_at, "signalType": s.signal_type}
)
def is_suppressed(mem: dict, capability_id: str, signal_type: str) -> bool:
now = now_utc()
for rule in mem.get("suppressions", []):
exp = rule.get("expiresAt")
if exp and parse_dt(exp) < now:
continue
if rule.get("capabilityId") and rule["capabilityId"] != capability_id:
continue
if rule.get("signalType") and rule["signalType"] != signal_type:
continue
return True
return False
def record_brief(mem: dict, brief: dict) -> None:
mem["surfacedOpportunities"].append(
{
"briefId": brief["id"],
"capabilityId": brief["capability_id"],
"surfacedAt": brief["created_at"],
"status": brief.get("status", "pending"),
}
)
def recent_capability_surfaced(mem: dict, capability_id: str, days: int) -> bool:
now = now_utc()
for opp in mem.get("surfacedOpportunities", []):
if opp["capabilityId"] != capability_id:
continue
if (now - parse_dt(opp["surfacedAt"])).days <= days:
return True
return False
# --- Brief persistence (the "primary DB" of opportunity history) ---
def briefs_index_path() -> Path:
return config.STATE_DIR / "briefs_index.json"
def save_brief(brief: dict) -> None:
config.ensure_dirs()
idx = load_briefs()
idx = [b for b in idx if b["id"] != brief["id"]]
idx.append(brief)
briefs_index_path().write_text(json.dumps(idx, indent=2))
def load_briefs() -> list[dict]:
p = briefs_index_path()
if p.exists():
return json.loads(p.read_text())
return []
def get_brief(brief_id: str) -> dict | None:
for b in load_briefs():
if b["id"] == brief_id:
return b
return None

View File

@@ -0,0 +1,95 @@
"""The account cycle — the closed loop, run per account (design section 7.3)."""
from __future__ import annotations
from . import config
from .agents import harvester, normalizer, relevance, synthesizer, scorer
from .delivery import render
from .connectors import crm, exa
from .memory import store
def run_account_cycle(account: dict, verbose: bool = True) -> list[dict]:
aid = account["accountId"]
if verbose:
print(f"\n=== {account['accountName']} ({account['accountTier']}) [{aid}] ===")
mem = store.load_memory(aid)
# 1. Harvest
raw = harvester.run_harvesters(account)
if verbose:
print(f" harvested {len(raw)} raw signal(s)")
# 2. Normalize + dedup
signals = normalizer.normalize_and_dedup(raw, account, mem)
if verbose:
print(f" {len(signals)} new signal(s) after dedup")
if not signals:
store.save_memory(mem)
return []
# 3. Relevance filter (LLM reasoning vs. capability library)
relevant = relevance.filter_for_relevance(signals, account)
if verbose:
print(f" {len(relevant)} relevant signal(s)")
# Record all processed signals so we don't re-evaluate them next run.
store.record_signals(mem, signals)
if not relevant:
store.save_memory(mem)
return []
# 4. Synthesize into opportunity briefs
briefs = synthesizer.synthesize(relevant, account)
# 5. Score + prioritize
briefs = scorer.score_and_prioritize(briefs, account)
# Apply suppression / re-surface guard, then deliver.
delivered: list[dict] = []
for b in briefs:
if store.is_suppressed(mem, b.capability_id, ""):
if verbose:
print(f" suppressed: {b.capability_name}")
continue
if store.recent_capability_surfaced(mem, b.capability_id, config.SAME_TYPE_SUPPRESS_DAYS):
if verbose:
print(f" skipped (surfaced < {config.SAME_TYPE_SUPPRESS_DAYS}d ago): {b.capability_name}")
continue
if b.priority == "low":
if verbose:
print(f" logged (below delivery threshold): {b.capability_name} "
f"(score {b.composite_score})")
store.save_brief(b.to_dict())
continue
bd = b.to_dict()
store.save_brief(bd)
store.record_brief(mem, bd)
render.deliver(bd) # 6. Deliver
crm.log_activity(aid, bd) # + CRM write-back stub
delivered.append(bd)
# 7. Update memory
store.save_memory(mem)
return delivered
def run_all(tier: str | None = None, account_id: str | None = None) -> list[dict]:
config.ensure_dirs()
accounts = crm.load_accounts(tier=tier, account_id=account_id)
if not accounts:
print("No matching accounts.")
return []
print(f"Running prospecting loop over {len(accounts)} account(s) — LLM: {config.llm_label()}")
if config.EXA_ENABLED:
st = exa.usage_status()
scope = "domain-scoped" if config.EXA_DOMAIN_FILTER else "broad web"
print(f"News source: Exa.ai ({scope}) — monthly budget {st['used_this_month']}/{st['monthly_cap']} "
f"used, {st['monthly_remaining']} left (per-run cap {st['per_run_cap']})")
else:
print("News source: fixtures (Exa disabled — set EXA_API_KEY to enable)")
all_delivered: list[dict] = []
for acct in accounts:
all_delivered.extend(run_account_cycle(acct))
print(f"\nDone. Delivered {len(all_delivered)} brief(s). "
f"Files in {config.BRIEFS_DIR}")
return all_delivered

109
sales-agent/src/types.py Normal file
View File

@@ -0,0 +1,109 @@
"""Core data structures for the prospecting loop (mirrors the design's interfaces)."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from typing import Any, Optional
def now_utc() -> datetime:
return datetime.now(timezone.utc)
def iso(dt: datetime) -> str:
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def parse_dt(value: str) -> datetime:
if value.endswith("Z"):
value = value[:-1] + "+00:00"
return datetime.fromisoformat(value)
def fingerprint(*parts: str) -> str:
h = hashlib.sha256("||".join(parts).encode("utf-8")).hexdigest()
return h[:16]
@dataclass
class RawSignal:
account_id: str
source: str # linkedin | news | jobs | sec_filing
signal_type: str
content: str
published_at: str
url: str
author: Optional[dict] = None
engagement: Optional[dict] = None
extra: dict = field(default_factory=dict)
@dataclass
class NormalizedSignal:
id: str
account_id: str
source: str
signal_type: str
content: str
published_at: str
discovered_at: str
url: str
author: Optional[dict] = None
metadata: dict = field(default_factory=dict)
@staticmethod
def from_raw(raw: RawSignal) -> "NormalizedSignal":
sid = fingerprint(raw.source, raw.url, raw.content[:120])
return NormalizedSignal(
id=sid,
account_id=raw.account_id,
source=raw.source,
signal_type=raw.signal_type,
content=raw.content.strip(),
published_at=raw.published_at,
discovered_at=iso(now_utc()),
url=raw.url,
author=raw.author,
metadata={"engagement": raw.engagement or {}, **raw.extra},
)
@dataclass
class RelevanceResult:
"""Output of the LLM relevance filter for a single signal."""
signal_id: str
relevant: bool
capability_ids: list[str]
rationale: str
signal_strength: str # strong | moderate | weak
is_expansion: bool
ideal_contact: dict # {title, department}
suggested_angle: str
@dataclass
class OpportunityBrief:
id: str
account_id: str
account_name: str
capability_id: str
capability_name: str
headline: str
executive_summary: str
signal_ids: list[str]
signals: list[dict]
talking_points: list[str]
outreach_hook: str
target_contacts: list[dict]
urgency_level: str # high | medium | low
confidence_score: float # 0..1 from the LLM
composite_score: float # weighted score from the scorer
priority: str # priority | standard | low
assigned_rep: dict
created_at: str
expires_at: str
status: str = "pending" # pending | accepted | rejected | snoozed | won | lost
def to_dict(self) -> dict[str, Any]:
return asdict(self)

View File

@@ -0,0 +1,884 @@
# Enterprise Sales Prospecting Agent Loop
## Account Intelligence System for Cross-Sell Opportunity Detection
---
## 1. Overview & Vision
The goal is a continuously running, closed-loop intelligence system that monitors a defined list of target accounts and surfaces high-confidence cross-sell opportunities to your sales team before they have to go looking. Rather than relying on reps to manually track news, LinkedIn, and account signals, the system does this automatically — ingesting raw signals, running them through an AI reasoning layer that understands your product capabilities, scoring them, and routing actionable briefs to the right person at the right time.
The system operates on three core principles:
- **Signal-first:** Every output is grounded in a real, verifiable business signal (a LinkedIn post, a job posting, a press release) — never speculation.
- **Capability-mapped:** Every signal is evaluated against a structured library of your product capabilities, so the output is always tied to something you can actually sell.
- **Closed-loop:** Sales rep feedback flows back into the system, continuously improving signal relevance, scoring weights, and messaging quality.
---
## 2. System Architecture
```
┌────────────────────────────────────────────────────────────────────────┐
│ AGENT ORCHESTRATOR │
│ (Scheduler + Job Queue + State) │
└────────────────────────┬───────────────────────────────────────────────┘
│ dispatches per-account jobs
┌──────────────┼──────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌──────────────┐ ┌──────────────────┐
│ SIGNAL │ │ SIGNAL │ │ SIGNAL │
│ HARVESTER │ │ HARVESTER │ │ HARVESTER │
│ (LinkedIn) │ │ (News/Web) │ │ (Job Postings) │
└───────┬───────┘ └──────┬───────┘ └──────────┬───────┘
└─────────────────┼──────────────────────┘
│ raw signals
┌─────────────────────┐
│ SIGNAL NORMALIZER │
│ (dedup + enrich) │
└──────────┬──────────┘
│ structured signal objects
┌─────────────────────┐
│ RELEVANCE FILTER │◄─── Capability Library
│ (LLM reasoning) │
└──────────┬──────────┘
│ relevant signals only
┌─────────────────────┐
│ OPPORTUNITY │◄─── Account Context (CRM)
│ SYNTHESIZER │◄─── Historical Feedback
│ (LLM + scoring) │
└──────────┬──────────┘
│ scored opportunity briefs
┌─────────────────────┐
│ DEDUP & MEMORY │
│ (Redis + pgvector) │
└──────────┬──────────┘
│ new, non-duplicate opportunities
┌─────────────────────┐
│ DELIVERY LAYER │
│ (Slack / CRM / UI) │
└──────────┬──────────┘
│ feedback
┌─────────────────────┐
│ FEEDBACK ENGINE │
│ (scoring updates) │
└─────────────────────┘
```
---
## 3. Data Sources & Signal Harvesting
### 3.1 LinkedIn Intelligence
LinkedIn is the highest-signal source for executive intent, strategic direction, and organizational change.
**What to harvest:**
- Posts from C-suite, VP-level, and Director-level contacts at target accounts
- Company page updates and announcements
- Job postings (particularly in functions that correlate with buying decisions)
- Headcount change patterns (rapid hiring vs. contraction in specific departments)
- Executive profile changes (new roles, new certifications, new responsibilities)
**How to harvest:**
- **Proxycurl API** — enriches LinkedIn profiles and company pages programmatically, GDPR-compliant
- **Apify LinkedIn scrapers** — for post content and activity feeds at scale
- **LinkedIn Sales Navigator API** (if licensed) — provides official access to account activity feeds and alerts
- **RocketReach / Apollo.io API** — supplemental contact and org chart data
**Signal types extracted:**
```json
{
"type": "linkedin_post",
"account_id": "acct_123",
"author": { "name": "Jane Smith", "title": "CTO", "seniority": "C-suite" },
"content": "We're accelerating our digital transformation journey...",
"engagement": { "likes": 142, "comments": 38 },
"published_at": "2026-06-18T14:22:00Z",
"raw_url": "https://linkedin.com/posts/..."
}
```
### 3.2 News & Web Intelligence
**What to harvest:**
- Press releases (funding rounds, acquisitions, partnerships, new product launches)
- Earnings call transcripts (public companies) — rich in strategic intent signals
- Industry publication coverage
- Executive interviews and keynote summaries
- Regulatory filings (10-K, 10-Q for public companies via SEC EDGAR)
**How to harvest:**
- **Exa.ai** — semantic web search with date-filtering, excellent for "find me news about Acme Corp in the last 30 days"
- **NewsAPI / GDELT** — broad news aggregation with keyword and entity filtering
- **SEC EDGAR API** — free, official access to all public company filings
- **Perplexity API** — for summarizing and reasoning over web content
- **RSS feeds** — many company blogs and investor relations pages expose RSS
### 3.3 Job Posting Intelligence
Job postings are one of the most underutilized signals in B2B sales. A company posting for a "VP of Financial Operations" or "Director of Revenue Operations" is telling you they're scaling a function — and likely buying new tools for it.
**What to harvest:**
- Job titles in departments relevant to your product capabilities
- Technology stack mentions in job descriptions ("must have experience with Salesforce, looking to migrate from legacy ERP...")
- Volume of hiring in specific functions (trend data)
- Keywords like "build vs. buy," "digital transformation," "consolidate," "modernize"
**How to harvest:**
- **JSearch API (via RapidAPI)** — aggregates LinkedIn Jobs, Indeed, Glassdoor
- **Adzuna API** — free tier available, covers major job boards
- **Coresignal** — provides bulk job posting data with company mapping
### 3.4 CRM Context (Internal)
Every signal is enriched with internal CRM context to prevent surfacing noise:
- Current products the account has purchased
- Open opportunities and their stages
- Last activity date with the account
- Account tier / strategic priority
- Assigned rep and territory
- Known stakeholders and their roles
- Prior AI-surfaced signals and their outcomes
**Integration:** Salesforce REST API, HubSpot API, or direct DB query if self-hosted.
### 3.5 Product Usage Signals (If Available)
If your enterprise software has usage telemetry:
- Features actively used vs. features licensed but dormant
- Usage trends (increasing engagement in Module A could signal readiness for Module B)
- Login frequency and user adoption by department
- Support ticket patterns (frustration with a gap your cross-sell product fills)
---
## 4. The Agent Loop — Step-by-Step
### Phase 1: Signal Harvesting (Per Account, Per Run)
```typescript
interface AccountJob {
accountId: string;
accountName: string;
domain: string;
targetContacts: Contact[]; // from CRM
currentProducts: string[]; // what they already have
accountTier: 'strategic' | 'enterprise' | 'growth';
lastRunAt: Date;
}
async function runAccountHarvestCycle(job: AccountJob): Promise<RawSignal[]> {
const signals = await Promise.allSettled([
harvestLinkedInSignals(job),
harvestNewsSignals(job),
harvestJobPostingSignals(job),
harvestFilingsSignals(job),
]);
return signals
.filter(r => r.status === 'fulfilled')
.flatMap(r => (r as PromiseFulfilledResult<RawSignal[]>).value);
}
```
The harvester runs on a per-account cadence:
- **Strategic accounts:** Every 24 hours
- **Enterprise accounts:** Every 4872 hours
- **Growth accounts:** Weekly
### Phase 2: Signal Normalization & Deduplication
All signals from different sources are normalized into a common schema, then deduplicated against the memory store.
```typescript
interface NormalizedSignal {
id: string; // hash of source + content fingerprint
accountId: string;
source: 'linkedin' | 'news' | 'jobs' | 'sec_filing' | 'usage';
signalType: string; // 'executive_post' | 'hiring_surge' | 'press_release' | etc.
content: string; // normalized text content
metadata: Record<string, unknown>;
publishedAt: Date;
discoveredAt: Date;
author?: { name: string; title: string; seniority: string };
embedding?: number[]; // vector embedding for semantic dedup
}
```
**Deduplication strategy:**
1. **Hash-based:** Exact match on `source + url` — catches literal duplicates
2. **Semantic dedup:** Cosine similarity on embeddings against signals seen in the last 90 days — catches rephrased versions of the same story
3. **Window-based suppression:** If a signal of type `executive_post` for account X with similar themes was surfaced < 14 days ago, suppress unless engagement is 3x higher
### Phase 3: Relevance Filtering (LLM Reasoning Layer)
This is where raw signals are evaluated against your **Capability Library** a structured description of every product/module you sell, what business problems it solves, and what signals suggest a buying opportunity.
```typescript
interface Capability {
id: string;
name: string;
description: string; // plain language
businessProblems: string[]; // problems this solves
buyingSignals: string[]; // what to look for in the market
antiSignals: string[]; // signals that suggest they already have this
targetBuyer: { titles: string[]; departments: string[] };
}
```
**Example Capability entry:**
```typescript
{
id: "cap_financial_close",
name: "Automated Financial Close Module",
description: "Automates the month-end and quarter-end close process, reducing cycle time and manual reconciliation errors.",
businessProblems: [
"Manual financial close taking 10+ days",
"Audit findings related to reconciliation errors",
"Controller or CFO unable to scale without adding headcount",
"Recent ERP migration creating reporting gaps"
],
buyingSignals: [
"Hiring for financial reporting or accounting manager roles",
"CFO/Controller posts about process improvement or modernization",
"Press release about acquisition (new entity to consolidate)",
"Mention of 'close process' or 'reconciliation' challenges in earnings calls",
"Job descriptions mentioning 'reduce close cycle time'"
],
antiSignals: [
"Already purchased this module per CRM",
"Recent competitor win logged in CRM"
],
targetBuyer: {
titles: ["CFO", "Controller", "VP Finance", "Head of Accounting"],
departments: ["Finance", "Accounting"]
}
}
```
The LLM relevance filter receives:
- The normalized signal
- The account's current product footprint (to apply anti-signals)
- The full Capability Library
**System prompt pattern:**
```
You are an account intelligence analyst for an enterprise software company.
Your job: evaluate whether a business signal indicates a genuine cross-sell opportunity for one of our product capabilities.
ACCOUNT CONTEXT:
- Company: {{accountName}}
- Current Products: {{currentProducts}}
- Account Tier: {{accountTier}}
SIGNAL:
{{signal.content}}
Source: {{signal.source}} | Type: {{signal.signalType}} | Date: {{signal.publishedAt}}
CAPABILITY LIBRARY:
{{capabilityLibrary}}
INSTRUCTIONS:
1. Determine if this signal indicates a business need that one or more of our capabilities directly addresses.
2. If yes, identify the specific capability ID(s) and explain WHY this signal is relevant.
3. If the account already has that capability, mark it as an expansion signal instead.
4. Assign a signal strength: strong | moderate | weak
5. Identify the ideal contact to approach (by title/function) based on the signal.
6. If no relevance exists, return null.
Return JSON only. No preamble.
```
**Output:**
```json
{
"relevant": true,
"capabilityIds": ["cap_financial_close"],
"rationale": "The CFO's post explicitly mentions 'closing the books faster' as a Q3 priority following their recent acquisition of MidWest Holdings. The acquisition creates a new consolidation need our module directly addresses.",
"signalStrength": "strong",
"signalType": "executive_intent + strategic_event",
"idealContact": { "title": "CFO", "department": "Finance" },
"suggestedAngle": "Post-acquisition consolidation complexity as the entry point conversation"
}
```
### Phase 4: Opportunity Synthesis
Relevant signals are grouped per account and synthesized into a single **Opportunity Brief** using a second LLM pass. This aggregates multiple weak signals into a coherent narrative, or elevates a single strong signal into a full sales brief.
```typescript
interface OpportunityBrief {
id: string;
accountId: string;
accountName: string;
capabilityId: string;
capabilityName: string;
headline: string; // 1-sentence summary
executiveSummary: string; // 2-3 sentence brief for the rep
signals: EnrichedSignal[]; // all contributing signals
suggestedTalkingPoints: string[]; // 3-5 specific points grounded in signals
suggestedOutreachHook: string; // opening line for email or LinkedIn DM
targetContacts: Contact[]; // from CRM + signal author enrichment
urgencyLevel: 'high' | 'medium' | 'low';
confidenceScore: number; // 0.01.0
createdAt: Date;
expiresAt: Date; // opportunities decay if not acted on
assignedRep: string;
}
```
**Second-pass synthesis prompt** (abbreviated):
```
You are synthesizing multiple account intelligence signals into a single, actionable sales opportunity brief.
SIGNALS (already validated as relevant):
{{signals}}
ACCOUNT CONTEXT:
{{accountContext}}
TARGET CAPABILITY:
{{capability}}
Create a brief that:
- Opens with the single most compelling signal
- Builds a coherent narrative connecting all signals
- Proposes 3-5 specific talking points tied directly to signals
- Suggests an opening hook for outreach (LinkedIn DM or email opener)
- Does not over-promise — stay grounded in what the signals actually say
Return JSON matching the OpportunityBrief schema.
```
### Phase 5: Scoring & Prioritization
Each brief receives a composite score used to determine delivery urgency and rep queue position.
```
compositeScore =
(signalStrength × 0.35)
+ (accountTier × 0.25)
+ (buyerSeniority × 0.20)
+ (timingUrgency × 0.15)
+ (repFeedbackWeight × 0.05)
```
**Scoring dimensions:**
| Dimension | High | Medium | Low |
|---|---|---|---|
| Signal Strength | Multiple strong signals | 1 strong or 2 moderate | Weak signals only |
| Account Tier | Strategic | Enterprise | Growth |
| Buyer Seniority | C-suite or EVP | VP/Director | Manager |
| Timing Urgency | <7 days since signal | 721 days | 21+ days |
| Rep Feedback Weight | Rep has marked similar opps as useful | Neutral history | Rep has marked similar as noise |
Briefs scoring above **0.72** are routed as **Priority** (immediate Slack notification). Briefs between **0.450.72** are queued as **Standard** (daily digest). Below **0.45** are logged but not delivered unless the rep opts into low-confidence signals.
### Phase 6: Memory & State Management
```typescript
// What gets persisted per account
interface AccountMemory {
accountId: string;
// Signal fingerprints seen in last 90 days (for dedup)
signalHashes: string[];
signalEmbeddings: { hash: string; embedding: number[] }[];
// Opportunities surfaced (to avoid re-surfacing same opp)
surfacedOpportunities: {
briefId: string;
capabilityId: string;
surfacedAt: Date;
repAction: 'accepted' | 'rejected' | 'snoozed' | 'pending';
repFeedback?: string;
}[];
// Suppression rules (rep-defined)
suppressions: {
capabilityId?: string; // "don't show me fin-close opps for this account"
signalType?: string;
expiresAt?: Date;
}[];
// Rep-observed context (from CRM notes, enriched by AI parsing)
accountNarrative: string; // running AI-maintained summary of account status
lastUpdated: Date;
}
```
**Memory store implementation:**
- **Redis** signal hash dedup (fast O(1) lookups, TTL-based expiry)
- **pgvector (PostgreSQL)** embedding similarity search for semantic dedup and "find similar opportunities"
- **Primary DB** full opportunity history, account memory, feedback records
### Phase 7: Delivery Layer
#### Slack Integration
Real-time delivery for high-priority opportunities:
```
🚨 *New Cross-Sell Signal — Acme Corp* | Priority: HIGH
*Opportunity:* Automated Financial Close Module
*Confidence:* 87% | *Urgency:* High
*Why Now:*
CFO Jane Smith posted yesterday about their post-acquisition integration
challenges, specifically calling out "reconciliation across three new
entities" as a Q3 blocker. They acquired MidWest Holdings 6 weeks ago.
*Supporting Signals:*
• LinkedIn post by CFO (Jun 18) — 142 likes, 38 comments
• 3 open Finance Manager roles (posted Jun 1014) referencing
"multi-entity consolidation"
• Earnings call transcript (May 15) — CFO mentioned "streamlining
financial operations" as top priority
*Talking Points:*
1. Multi-entity consolidation is a known friction point post-acquisition
2. Our module reduces average close cycle by 60% in consolidation scenarios
3. Other [industry] clients scaled from 2 → 8 entities without adding headcount
*Suggested Opener:*
"Jane — saw your post about the MidWest integration. We've helped three
other [industry] CFOs close the multi-entity gap without adding staff.
Worth 20 minutes?"
*Best Contact:* Jane Smith (CFO) | Mike Torres (Controller — already in CRM)
[✅ Act on This] [⏰ Snooze 2 Weeks] [❌ Not Relevant]
```
#### CRM Auto-Logging
Every surfaced brief is auto-logged as an activity against the account record, with:
- Signal summary
- Full opportunity brief
- Links to source signals
- Suggested next step task created and assigned to rep
#### Daily Intelligence Digest (Email)
A consolidated morning email for each rep covering all active accounts, ordered by composite score, showing only standard-priority and above. Each entry is a 3-line summary with a link to the full brief in the web UI.
#### Web Dashboard (Optional)
A purpose-built UI showing:
- Active opportunity queue per rep / per account
- Signal feed with filtering by capability, signal type, account tier
- Account 360 view showing all signals + opportunity history
- Capability coverage heatmap (which accounts have white space for which products)
- System health (run logs, signal volume, delivery rates)
---
## 5. The Capability Mapping Engine
The capability library is the brain of the system. It needs to be maintained by a product or sales enablement team and treated as a living document.
### 5.1 Capability Library Structure
```typescript
interface CapabilityLibrary {
version: string;
lastUpdated: Date;
capabilities: Capability[];
// Cross-capability rules
bundleSignals: {
capabilityIds: string[];
description: string; // "When we see X + Y, pitch Z bundle"
}[];
// Account-level exclusion rules
globalAntiSignals: string[]; // "Competitor X win" → suppress all opps
}
```
### 5.2 Capability Embedding Index
Every `buyingSignal` string in the library is embedded and stored in a vector index. When a new signal comes in, a fast ANN (approximate nearest neighbor) search can pre-filter which capabilities are candidates before sending to the LLM. This reduces LLM calls by ~60% and dramatically improves latency on high-volume runs.
```typescript
// Pre-filter step before LLM evaluation
async function preFilterCapabilities(
signal: NormalizedSignal,
library: CapabilityLibrary
): Promise<Capability[]> {
const signalEmbedding = await embed(signal.content);
const similar = await vectorDB.query({
vector: signalEmbedding,
topK: 5,
threshold: 0.75,
index: 'capability_buying_signals'
});
// Return only capabilities that have at least one similar buying signal
const candidateCapabilityIds = [...new Set(similar.map(r => r.capabilityId))];
return library.capabilities.filter(c => candidateCapabilityIds.includes(c.id));
}
```
---
## 6. Feedback Loop & Continuous Improvement
The closed-loop aspect is what separates this from a simple alert system. Every rep action is a training signal.
### 6.1 Feedback Actions
- **✅ Act on This** High-quality signal; boosts weight for this signal type + capability combination for this account tier
- **⏰ Snooze 2 Weeks** Timing is off; opportunity is real but surfaced too early
- **❌ Not Relevant** Low-quality signal; penalizes weight; rep can optionally specify why
- **💬 Custom Feedback** Free-text comment, parsed by LLM to extract structured feedback
- **🏆 Won Deal** Maximum positive signal; retrospectively boosts all signals that contributed
- **❌ Lost Deal** Retrospective analysis what signals did we have, what did we miss?
### 6.2 Feedback Storage Schema
```sql
CREATE TABLE opportunity_feedback (
id UUID PRIMARY KEY,
brief_id UUID NOT NULL,
account_id TEXT NOT NULL,
capability_id TEXT NOT NULL,
rep_id TEXT NOT NULL,
action VARCHAR(20) NOT NULL, -- act_on | snooze | reject | won | lost
feedback_text TEXT,
feedback_parsed JSONB, -- structured extraction of free text
signal_types_present TEXT[], -- which signal types contributed
account_tier VARCHAR(20),
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
### 6.3 Weight Recalibration (Weekly Job)
A weekly background job analyzes feedback patterns and adjusts scoring weights:
```typescript
async function recalibrateScoring(): Promise<void> {
// Calculate acceptance rate by signal_type × capability × account_tier
const acceptanceRates = await db.query(`
SELECT
capability_id,
account_tier,
unnest(signal_types_present) AS signal_type,
COUNT(*) FILTER (WHERE action IN ('act_on', 'won')) AS positive,
COUNT(*) AS total
FROM opportunity_feedback
WHERE created_at > NOW() - INTERVAL '90 days'
GROUP BY 1, 2, 3
HAVING COUNT(*) >= 5
`);
// Update scoring weights in config
for (const row of acceptanceRates.rows) {
const rate = row.positive / row.total;
await updateScoringWeight({
capabilityId: row.capability_id,
accountTier: row.account_tier,
signalType: row.signal_type,
weight: rate
});
}
}
```
### 6.4 System-Level Prompt Refinement
Monthly: run an LLM analysis over all rejected briefs to identify patterns in false positives, and use those to refine the relevance filter system prompt. This is a manual step reviewed by a product/sales ops owner before being deployed.
---
## 7. Technical Implementation
### 7.1 Technology Stack
| Layer | Technology | Rationale |
|---|---|---|
| Language | TypeScript / Node.js | Matches existing stack; excellent async primitives |
| Orchestration | BullMQ (Redis-backed job queue) | Reliable job scheduling, retries, rate limiting |
| LLM | Anthropic Claude API (claude-sonnet-4-6) | Best-in-class reasoning for signal evaluation |
| Embeddings | `text-embedding-3-large` (OpenAI) or `voyage-3` (Anthropic) | High-quality semantic similarity |
| Vector DB | pgvector (PostgreSQL extension) | Avoids a separate vector DB if you're already on Postgres |
| Primary DB | PostgreSQL | Opportunity history, feedback, account memory |
| Cache / Dedup | Redis (Upstash or self-hosted) | Signal hash dedup, rate limiting, BullMQ |
| LinkedIn Data | Proxycurl + Apify | Compliant enrichment + post scraping |
| News/Web | Exa.ai | Semantic search over web, excellent B2B signal quality |
| Job Postings | JSearch API | Aggregates all major boards |
| CRM Integration | Salesforce / HubSpot REST API | Read account context, write back activity logs |
| Delivery | Slack API (Bolt SDK) + SendGrid | Notifications + digests |
| Hosting | Railway / Render / EC2 | Depends on scale; start with Railway for simplicity |
### 7.2 Project Structure
```
/sales-agent
├── /src
│ ├── /agents
│ │ ├── harvester.ts # Signal collection per-source
│ │ ├── normalizer.ts # Signal standardization
│ │ ├── relevanceFilter.ts # LLM relevance evaluation
│ │ ├── synthesizer.ts # Opportunity brief generation
│ │ └── scorer.ts # Composite scoring
│ ├── /capabilities
│ │ ├── library.ts # Capability library loader
│ │ ├── embedder.ts # Build + query capability embeddings
│ │ └── capabilities.json # The actual capability definitions
│ ├── /connectors
│ │ ├── linkedin.ts # Proxycurl + Apify
│ │ ├── news.ts # Exa.ai + NewsAPI
│ │ ├── jobPostings.ts # JSearch
│ │ ├── crm.ts # Salesforce / HubSpot
│ │ └── secEdgar.ts # SEC filing fetcher
│ ├── /delivery
│ │ ├── slack.ts # Slack Bolt app + message formatting
│ │ ├── email.ts # Daily digest via SendGrid
│ │ └── crmLogger.ts # Write-back to CRM
│ ├── /memory
│ │ ├── dedup.ts # Hash + semantic dedup
│ │ ├── accountMemory.ts # Per-account state management
│ │ └── feedback.ts # Feedback ingestion + storage
│ ├── /jobs
│ │ ├── accountCycle.ts # Main per-account orchestration job
│ │ ├── digest.ts # Daily digest job
│ │ └── recalibrate.ts # Weekly scoring recalibration
│ ├── /prompts
│ │ ├── relevanceFilter.ts # Prompt templates
│ │ └── synthesizer.ts
│ └── /api
│ ├── webhooks.ts # Slack feedback webhooks
│ └── dashboard.ts # REST API for web UI (optional)
├── /migrations # DB migrations
├── docker-compose.yml # Local dev: Postgres + Redis
└── .env
```
### 7.3 Main Orchestration Job
```typescript
// jobs/accountCycle.ts
import { Queue, Worker } from 'bullmq';
import { redis } from '../lib/redis';
import { runHarvesters } from '../agents/harvester';
import { normalizeAndDedup } from '../agents/normalizer';
import { filterForRelevance } from '../agents/relevanceFilter';
import { synthesizeOpportunities } from '../agents/synthesizer';
import { scoreAndPrioritize } from '../agents/scorer';
import { deliverBriefs } from '../delivery';
import { updateAccountMemory } from '../memory/accountMemory';
const accountQueue = new Queue('account-cycle', { connection: redis });
// Schedule all accounts
export async function scheduleAllAccounts(accounts: Account[]): Promise<void> {
for (const account of accounts) {
const delay = getDelayForTier(account.tier); // 24h / 48h / 7d
await accountQueue.add(
'run',
{ accountId: account.id },
{ repeat: { every: delay }, jobId: `cycle-${account.id}` }
);
}
}
// Worker
const worker = new Worker('account-cycle', async (job) => {
const account = await loadAccountContext(job.data.accountId);
// 1. Harvest
const rawSignals = await runHarvesters(account);
// 2. Normalize + dedup
const signals = await normalizeAndDedup(rawSignals, account);
if (signals.length === 0) return; // Nothing new
// 3. Relevance filter
const relevantSignals = await filterForRelevance(signals, account);
if (relevantSignals.length === 0) return;
// 4. Synthesize into opportunity briefs
const briefs = await synthesizeOpportunities(relevantSignals, account);
// 5. Score + prioritize
const scoredBriefs = await scoreAndPrioritize(briefs, account);
// 6. Deliver
await deliverBriefs(scoredBriefs, account);
// 7. Update memory
await updateAccountMemory(account, signals, scoredBriefs);
}, { connection: redis, concurrency: 5 });
```
### 7.4 Rate Limiting & API Cost Management
LLM calls are the primary cost driver. Key mitigations:
- **Capability pre-filter (vector search):** Reduces LLM calls by ~60% by only sending signals to the LLM when vector similarity suggests relevance
- **Signal batching:** Send up to 5 related signals in a single LLM call rather than one per call
- **Caching:** Cache LLM responses for identical signal content (rare but happens with news reposts)
- **Tier-based depth:** Strategic accounts get full LLM analysis; growth accounts get lighter-weight summarization
- **Estimated cost at scale:** ~$0.080.15 per account per daily run at full depth with Claude Sonnet
---
## 8. Implementation Roadmap
### Phase 1 — Foundation (Weeks 13)
- [ ] Set up PostgreSQL + Redis + pgvector
- [ ] Build Capability Library (start with top 510 capabilities)
- [ ] Implement Exa.ai news harvester (lowest integration friction)
- [ ] Build basic LLM relevance filter + synthesizer
- [ ] Slack delivery for a single test account
- [ ] Manual run (no scheduler yet) validate output quality
### Phase 2 — Core Loop (Weeks 46)
- [ ] Add BullMQ orchestrator + account scheduler
- [ ] Add LinkedIn harvester (Proxycurl for profile enrichment, Apify for posts)
- [ ] Add job posting harvester
- [ ] Build dedup layer (hash + semantic)
- [ ] Add CRM read integration (pull account context)
- [ ] Wire up feedback buttons in Slack
### Phase 3 — Intelligence Layer (Weeks 79)
- [ ] Capability embedding index + pre-filter
- [ ] Composite scoring engine
- [ ] Account memory + suppression rules
- [ ] CRM write-back (log opportunities as activities)
- [ ] Daily digest email via SendGrid
- [ ] Feedback storage + rep preference tracking
### Phase 4 — Closed Loop (Weeks 1012)
- [ ] Weekly scoring recalibration job
- [ ] Feedback-driven prompt refinement workflow
- [ ] Account narrative maintenance (rolling AI-generated account summary)
- [ ] Web dashboard (opportunity queue UI, signal feed)
- [ ] System health monitoring + alerting
- [ ] Win/loss retrospective analysis
---
## 9. Full Capability Delivery Summary
| Capability | Description |
|---|---|
| **Signal-Grounded Intelligence** | Every opportunity is tied to a real, datable source no speculation |
| **Multi-Source Signal Fusion** | Combines LinkedIn, news, job postings, filings, and CRM into a unified account view |
| **Continuous Monitoring** | Runs automatically on configurable cadences reps don't have to go looking |
| **Capability-Mapped Relevance** | Signals are evaluated against your specific product capabilities, not generic firmographic data |
| **Anti-Signal Filtering** | Knows what the account already has and excludes opportunities they've closed or that don't apply |
| **Executive-Level Targeting** | Identifies the right contact based on who posted, what role is relevant, and who's in the CRM |
| **Urgency-Aware Scoring** | Prioritizes by signal recency, account tier, buyer seniority, and rep feedback history |
| **Semantic Deduplication** | Avoids re-surfacing the same story across different sources or with different wording |
| **CRM Bidirectional Sync** | Reads context from CRM; writes opportunities back as activities with tasks and owners |
| **Slack-Native Delivery** | One-click action buttons surface priority briefs in the rep's existing workflow |
| **Daily Intelligence Digest** | Morning email/Slack summary of the full account queue ranked by priority |
| **Rep Feedback Loop** | Every rep action refines scoring weights the system gets smarter over time |
| **Suppression & Snooze** | Reps control the noise level; suppression rules prevent irrelevant signals from recurring |
| **Win/Loss Retrospective** | Closed deals are traced back to their contributing signals for model improvement |
| **Account Narrative** | AI-maintained running summary of each account's strategic context, updated with each run |
| **Capability Coverage Heatmap** | View-level insight into which accounts have white space for which products |
| **Prompt + Weight Versioning** | All changes to scoring and prompts are versioned, so output regressions can be diagnosed |
| **Cost-Controlled LLM Usage** | Vector pre-filtering + batching keeps per-account LLM cost under $0.15/day |
---
## 10. Example Output Brief
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🎯 OPPORTUNITY BRIEF
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Account: Meridian Health Systems
Tier: Strategic
Assigned: Sarah K. (sarah.k@yourco.com)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
OPPORTUNITY
Workflow Automation Suite — Revenue Cycle Expansion
HEADLINE
CFO signaling process automation push following system merger;
3 new operations director hires suggest accelerated build-out.
CONFIDENCE: 91% URGENCY: HIGH SCORE: 0.87
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CONTRIBUTING SIGNALS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[1] LinkedIn Post — CFO Robert Vasquez (Jun 19)
"Post-merger, our biggest unlock is eliminating the manual
handoffs between clinical and revenue cycle. We're building
toward a fully automated claim-to-cash process by Q4."
Engagement: 218 likes, 61 comments
[2] Job Postings — 3 postings (Jun 1017)
"Director of Revenue Cycle Operations" × 2
"VP, Claims Automation" × 1
All three mention "reduce manual intervention" and
"automate prior authorization workflows"
[3] Press Release (Jun 5)
Meridian completed merger with NorthStar Medical Group,
adding 4 hospitals and ~$800M in annual revenue. Integration
expected to take 18 months.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
TALKING POINTS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. Post-merger claim-to-cash automation is exactly where our
Workflow Automation Suite drives the fastest ROI — clients
avg 40% reduction in manual touchpoints within 90 days.
2. The NorthStar merger likely doubled their prior auth volume
overnight. Our PA automation module has handled 10M+
authorizations monthly for comparable health systems.
3. They're hiring for roles that will own this decision —
now is the time to shape the requirements before vendors
are evaluated.
4. We already have an integration with their current EHR
(Epic) — no rip-and-replace risk.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SUGGESTED OUTREACH
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Target: Robert Vasquez, CFO (LinkedIn; not yet in CRM)
Diana Torres, SVP Revenue Cycle (in CRM — last contact 4mo ago)
Opener:
"Robert — your post about claim-to-cash automation resonated.
We helped [Comparable Health System] cut manual handoffs by
half after their own merger. Given the NorthStar integration
timing, worth a 20-minute call before you finalize your
vendor evaluation?"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[✅ Act on This] [⏰ Snooze 2 Weeks] [❌ Not Relevant]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
---
*Document Version 1.0 — Enterprise Sales Prospecting Agent Loop*