outreach: follow-up radar — deterministic "needs attention" + one-click draft (v0.1.0:69)

The Outreach page now opens with a "Needs attention" list. A deterministic scan
(outreach_agent.follow_up_radar) surfaces investors per the email history: tier 0 "you
owe a reply" (their email is the most recent, unanswered, >=3d), tier 1 flagged + quiet,
tier 2 warm lead gone quiet (no contact in >=45d). Most urgent first; every reason is
verifiable from the data (no LLM in the surfacing — the deliberate fix for the trust
problem that sank objection-grounding). Excludes graveyard; needs email history. One
click sets the investor + suggested type (follow-up/nurture) and runs the existing
outreach drafter. Route GET /api/outreach/radar. Test mcp/test_outreach.py extended
(owe-reply/warm-quiet/recent/graveyard/order). Verified live in preview.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Keysat
2026-06-08 21:31:52 -05:00
parent b5619d61e1
commit 787d580550
7 changed files with 181 additions and 7 deletions
+37 -3
View File
@@ -9956,6 +9956,7 @@
const [drafting, setDrafting] = useState(false);
const [result, setResult] = useState(null);
const [draftText, setDraftText] = useState('');
const [radar, setRadar] = useState([]);
const TYPES = [
['intro', 'Intro'],
['follow_up', 'Warm follow-up'],
@@ -9977,19 +9978,25 @@
const r = await api('/api/outreach/investors', {}, token);
if (!cancelled) setInvestors(Array.isArray(r?.investors) ? r.investors : []);
} catch (_) { /* none */ }
try {
const rr = await api('/api/outreach/radar', {}, token);
if (!cancelled) setRadar(Array.isArray(rr?.items) ? rr.items : []);
} catch (_) { /* none */ }
})();
return () => { cancelled = true; };
}, [token]);
const draft = async () => {
const draft = async (ovInvestor, ovType) => {
if (drafting) return;
if (!investorId) { onShowToast('Pick an investor first', 'error'); return; }
const inv = ovInvestor || investorId;
const t = ovType || type;
if (!inv) { onShowToast('Pick an investor first', 'error'); return; }
try {
setDrafting(true);
setResult(null);
const res = await api('/api/outreach/draft', {
method: 'POST',
body: JSON.stringify({ investor_id: investorId, outreach_type: type, guidance }),
body: JSON.stringify({ investor_id: inv, outreach_type: t, guidance }),
}, token);
const data = res.data || res;
setResult(data);
@@ -10013,6 +10020,33 @@
return (
<div className="page-container">
<h2 className="section-title" style={{ marginBottom: '20px' }}>Outreach</h2>
{radar.length > 0 && (
<div className="section">
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
Needs attention
<span className="approval-pill">{radar.length}</span>
</div>
<div className="index-action-hint" style={{ marginTop: 0, marginBottom: '12px' }}>
Investors who are waiting on a reply or have gone quiet, most urgent first. Every reason is verifiable from your email history — no guesswork. Click Draft to compose in your voice.
</div>
{radar.map((it) => (
<div key={it.investor_id} className="merge-candidate-card"
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
<div>
<div style={{ fontWeight: 600 }}>{it.name}</div>
<div className="kpi-subtitle">{it.reason}</div>
</div>
<button
onClick={() => { setInvestorId(it.investor_id); setType(it.suggested_type); draft(it.investor_id, it.suggested_type); }}
disabled={drafting}>
{drafting ? '…' : `Draft ${it.suggested_type === 'nurture' ? 'nurture' : 'follow-up'}`}
</button>
</div>
))}
</div>
)}
<div className="section">
<div className="index-action-hint" style={{ marginTop: 0, marginBottom: '12px' }}>
Drafts a tailored LP email in Ten31's voice, grounded in the thesis and that investor's CRM notes + email history. The investor's details are de-identified before Claude sees them and restored locally, so the LP list never leaves Ten31. Drafts only — you review, edit, and send.