- LLM: Anthropic Sonnet 5 (effort=low) via OAuth Bearer (Claude Code keychain token, expiry-aware); NVIDIA/LM Studio endpoints kept commented for switch-back. - Entitlements: scripts/import_entitlements.py joins ACL export to accounts by normalized parent name (324/342 matched; ID column Excel-corrupted), maps products->capabilities via data/product_capability_map.json. Raw ACL csv and entitlements.json are gitignored (confidential); accounts.json carries derived currentProducts + ownedCapabilityIds. Owned capabilities tag briefs EXPANSION. - Renewals: local renewal-proximity score boost (window 365d, max +0.10) + 'renewals' report; briefs/digests show RENEWAL WINDOW context. - Batch runs: loop --limit/--rep with persistent cursor (strategic + stale-first). - Digests: per-rep HTML export; timestamped digest_<date>_<time>.md kept forever. - Account narrative memory fed into relevance/synthesis; synthesis maintains it. - Signal quality gate drops PR fluff pre-LLM; SEC EDGAR filings connector. - Citation verification: talking points must cite [S#] signals or are dropped. - Brief filenames use account-name slug; whitespace report + installed-base view. - USAGE.md: full command/flag/env reference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
10 KiB
Usage Reference — Sales Prospecting Agent Loop
All commands run from sales-agent/: python3 run.py <command> [flags]
Commands
loop — run the harvest → reason → deliver cycle
python3 run.py loop # all accounts (ordered, see below)
python3 run.py loop --limit 30 # at most 30 accounts this run
python3 run.py loop --account 300000172847316
python3 run.py loop --tier strategic # strategic | enterprise | growth
python3 run.py loop --rep "Robert Parker" # one rep's accounts (name or id substring)
python3 run.py loop --tier strategic --limit 10
python3 run.py loop --domain-filter # Exa: account's own domain only
python3 run.py loop --no-domain-filter # Exa: broad-web news (default)
| Flag | Effect |
|---|---|
--account ID |
Run a single account by accountId (OEC number) |
--tier T |
Only strategic / enterprise / growth accounts |
--rep NAME |
Only accounts assigned to a rep (case-insensitive name or rep_id substring) |
--limit N |
Process at most N accounts. Order: strategic tier first, then least-recently-scanned (never-scanned first). The run cursor (output/state/run_cursor.json) persists, so successive --limit runs walk the whole territory — e.g. a nightly loop --limit 30 covers 342 accounts in ~12 days. |
--domain-filter |
Scope Exa news to the account's own domain (newsroom/PR only) |
--no-domain-filter |
Broad-web news about the account (default) |
Per-account flow: harvest (Exa news + SEC EDGAR filings + stubs) → dedup →
quality gate (drops awards/CSR/ticker chatter pre-LLM; log:
output/state/quality_gate_log.json) → LLM relevance filter (with rolling
account narrative as context) → LLM synthesis (briefs with cited talking
points [S#] + updated narrative) → citation verification (uncited
points dropped; brief skipped if none survive) → scoring → delivery
(output/briefs/*.md) → memory + cursor update.
On LLM rate-limit (429): waits and retries (no fabricated output). On LLM failure: account skipped, its signals retried next run.
digest — per-rep intelligence digest
python3 run.py digest # text digest, all reps (also -> output/digest.md)
python3 run.py digest --rep rep_robert_parker # filter by rep id
python3 run.py digest --rep "Misty Brew" --html # forwardable HTML -> output/digests/
python3 run.py digest --html # HTML for all reps
| Flag | Effect |
|---|---|
--rep X |
Filter to one rep (text mode: rep id; HTML mode: name substring or id) |
--html |
Write a self-contained HTML file (output/digests/digest_<rep>_<date>.html) — clean enough to paste/forward as an email body |
renewals — upcoming renewals by month (local entitlement data)
python3 run.py renewals # next 6 months
python3 run.py renewals --months 12 --limit 30
| Flag | Effect |
|---|---|
--months N |
Horizon (default 6) |
--limit N |
Max rows per month (default 15) |
Renewal proximity also feeds scoring automatically: a brief whose capability
(or account) has a renewal within RENEWAL_WINDOW_DAYS (default 365) gets a
boost up to RENEWAL_BOOST_MAX (default +0.10; half for account-level
renewals), scaled by closeness — computed locally, no LLM. Boosted briefs show
a 💰 RENEWAL WINDOW block in the brief/digest.
whitespace — territory coverage report
python3 run.py whitespace # prints + writes output/whitespace.md
Shows: scanned vs never-scanned vs stale (>14d) accounts, opportunities by
capability, pipeline status counts, per-account coverage, and the backlog of
never-scanned accounts. (Becomes a true owns-vs-whitespace matrix once
entitlement data populates currentProducts.)
feedback — record a rep's verdict on a brief
python3 run.py feedback --brief <BRIEF_ID> --action act_on
python3 run.py feedback --brief <BRIEF_ID> --action reject --note "already own this"
| Flag | Effect |
|---|---|
--brief ID |
Brief id (see briefs command or the digest) |
--action A |
act_on | snooze | reject | won | lost |
--note "..." |
Optional free-text context, stored with the feedback |
recalibrate — recompute feedback-driven scoring weights
python3 run.py recalibrate # weekly job; needs >=3 samples per combo
accounts / briefs / exa-usage — inspection
python3 run.py accounts # list all target accounts (tier, rep, products)
python3 run.py briefs # all briefs, ranked by score, with status
python3 run.py exa-usage # Exa request budget (monthly cap / remaining)
Account import (repeatable)
python3 scripts/import_accounts.py "/path/to/ISG Customer Sales Assignments.csv"
Rebuilds data/accounts.json from a territory CSV export (see script header
for the field mapping). Backs up nothing — copy data/accounts.json first.
Entitlement import (repeatable, 100% local)
python3 scripts/import_entitlements.py data/SE_ACL_FY26_Q3.csv
Loads the confidential ACL export entirely locally (no LLM, no API):
aggregates active contract lines per parent, joins to accounts by normalized
parent name (the FY26 Q3 export's Parent Account ID column is Excel-corrupted),
maps product families → capability ids via data/product_capability_map.json,
and writes:
data/accounts.json—currentProducts(readable, fed to LLM prompts) +ownedCapabilityIds(used for local anti-signal checks)data/entitlements.json(gitignored) — full per-account detail incl. next renewal datesoutput/state/entitlement_unmapped.json— product families with no capability mapping yet; extendproduct_capability_map.jsonfrom this
During the loop, a brief for an already-owned capability is tagged ⬆ EXPANSION (local set lookup, no LLM call). The ACL CSV and entitlements.json are gitignored and never leave the machine.
Configuration (.env)
LLM provider
| Var | Default | Meaning |
|---|---|---|
LLM_PROVIDER |
auto | anthropic | openai | gemini (auto-detects from keys if blank) |
LLM_MODEL |
claude-sonnet-5 |
Anthropic model id |
ANTHROPIC_EFFORT |
low |
Reasoning effort: low | medium | high | max |
ANTHROPIC_USE_KEYCHAIN |
1 |
Read the Claude Code OAuth token from the macOS keychain (kept fresh by Claude Code; refresh with any claude -p ... call if expired) |
ANTHROPIC_OAUTH_TOKEN |
— | Explicit OAuth token (overrides keychain) |
ANTHROPIC_API_KEY |
— | Classic API key (used only if no OAuth token available) |
OPENAI_BASE_URL |
— | Any OpenAI-compatible /chat/completions endpoint (NVIDIA, LM Studio, ... — commented examples in .env) |
OPENAI_API_KEY / OPENAI_MODEL |
— | Credentials/model for that endpoint |
OPENAI_JSON_MODE |
1 |
Send response_format=json_object (0 if endpoint rejects it) |
OPENAI_TEMPERATURE |
0.5 |
Sampling (OpenAI-compatible only) |
OPENAI_FREQUENCY_PENALTY |
0.4 |
Anti-repetition (OpenAI-compatible only) |
GEMINI_API_KEY / GEMINI_MODEL |
— | Gemini credentials/model |
LLM_RATELIMIT_WAIT_SECONDS |
900 |
Wait per 429 before retry (honors Retry-After) |
LLM_RATELIMIT_MAX_RETRIES |
24 |
Max 429 waits before skipping the account |
LLM_CONTENT_RETRIES |
3 |
Re-samples on degenerate/unparseable model output |
Switching providers is a .env edit only: set LLM_PROVIDER, uncomment the
relevant block (the NVIDIA endpoint is preserved, commented, for switch-back).
Signal sources
| Var | Default | Meaning |
|---|---|---|
EXA_API_KEY |
— | Exa.ai key (news search) |
EXA_ENABLED |
1 |
0 forces fixtures (no API calls) |
EXA_MONTHLY_REQUEST_CAP |
1000 |
Hard monthly ceiling (persistent counter) |
EXA_PER_RUN_REQUEST_CAP |
25 |
Hard per-run ceiling |
EXA_RESULTS_PER_QUERY |
5 |
Articles per account per run |
EXA_LOOKBACK_DAYS |
45 |
News/filings recency window |
EXA_CATEGORY |
news |
Exa category filter ("" disables) — keeps out careers/marketing pages |
EXA_DOMAIN_FILTER |
0 |
1 = first-party newsroom only |
EXA_EXCLUDE_DOMAINS |
— | Comma-separated hosts to always exclude |
SEC_ENABLED |
1 |
SEC EDGAR filings harvester (free, public accounts auto-detected via CIK match) |
SEC_MAX_FILINGS_PER_ACCOUNT |
5 |
Max recent filings per account per run |
Scoring
| Var | Default | Meaning |
|---|---|---|
PRIORITY_THRESHOLD |
0.72 |
Composite score for priority (immediate) delivery |
STANDARD_THRESHOLD |
0.45 |
Composite score for standard (digest) delivery |
CONFIDENCE_FLOOR |
0.35 |
Briefs the LLM itself scores below this are logged, never delivered |
RENEWAL_WINDOW_DAYS |
365 |
Renewal proximity window for the score boost |
RENEWAL_BOOST_MAX |
0.10 |
Max boost at renewal date (same-capability; half for account-level) |
Composite formula: signalStrength*0.30 + llmConfidence*0.20 + accountTier*0.20 + buyerSeniority*0.15 + timingUrgency*0.10 + repFeedback*0.05.
Output layout
output/
├── briefs/ # delivered briefs (Slack-style markdown)
├── digests/ # HTML digests (digest_<rep>_<date>.html)
├── digest.md # latest text digest (convenience copy)
├── digest_<date>_<time>.md # timestamped digests (every run kept)
├── whitespace.md # latest coverage report
└── state/
├── run_cursor.json # per-account last-scanned timestamps (--limit batching)
├── memory_<accountId>.json # per-account memory incl. accountNarrative
├── briefs_index.json # all briefs + status
├── feedback.json # rep feedback records
├── scoring_weights.json # recalibrated weights
├── quality_gate_log.json # last 500 signals dropped pre-LLM (tune the gate here)
├── exa_usage.json # Exa monthly request counter
├── sec_company_tickers.json # SEC company->CIK table (cached 7 days)
└── sec_cik_map.json # accountId->CIK resolution cache (incl. non-public misses)
Typical cadences
# Nightly (cron): walk the territory 30 accounts at a time
python3 run.py loop --limit 30
# Monday morning: coverage review + per-rep digests
python3 run.py whitespace
python3 run.py digest --rep "Misty Brew" --html
# Weekly: fold rep feedback into scoring
python3 run.py recalibrate