outreach: Outreach Draft Assistant — tailored LP drafts (v0.1.0:68)

First proactive-messaging build. New "Outreach" page (all authenticated users): pick an
investor + type (intro / follow-up / fund update / meeting follow-up / nurture) + optional
guidance; the agent drafts a tailored LP email in Ten31's voice, grounded in the thesis +
that investor's CRM notes and matched email history. The draft is editable + copyable;
nothing is sent (draft-only — guardrails #4, #6).

Sovereignty: the thesis is Ten31's own non-sensitive messaging (to Claude as-is); the LP
context is scrubbed through the redaction boundary before Claude, drafted with placeholders,
and re-hydrated locally — the LP list never reaches the API. Fails closed (scrub_unavailable /
claude_not_configured / rehydrate_failed quarantines a hallucinated-token draft).

Backend: mcp/outreach_agent.py (context assembly + scrub + Claude + rehydrate, reusing
architect_agent's client/thesis/voice + the Boundary); routes GET /api/outreach/investors,
POST /api/outreach/draft; logged. Test mcp/test_outreach.py (context assembly). Verified in
preview: page/selector/types/guidance render, fail-closed at the key-less Claude step (scrub
ran locally first), success rendering verified with a mocked ok draft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Keysat
2026-06-08 20:06:46 -05:00
parent 0943aeb2df
commit b5619d61e1
7 changed files with 378 additions and 4 deletions
+131
View File
@@ -9948,6 +9948,132 @@
);
};
const OutreachPage = ({ token, user, onShowToast }) => {
const [investors, setInvestors] = useState([]);
const [investorId, setInvestorId] = useState('');
const [type, setType] = useState('follow_up');
const [guidance, setGuidance] = useState('');
const [drafting, setDrafting] = useState(false);
const [result, setResult] = useState(null);
const [draftText, setDraftText] = useState('');
const TYPES = [
['intro', 'Intro'],
['follow_up', 'Warm follow-up'],
['fund_update', 'Fund update'],
['meeting_follow_up', 'Meeting follow-up'],
['nurture', 'Nurture / stay in touch'],
];
const FAIL = {
not_found: 'That investor was not found.',
scrub_unavailable: 'The redaction boundary could not be prepared, so nothing was sent to Claude.',
claude_not_configured: 'The Architect (Claude) is not configured on the server.',
rehydrate_failed: 'The draft could not be safely personalized (an unexpected placeholder). Try again.',
};
useEffect(() => {
let cancelled = false;
(async () => {
try {
const r = await api('/api/outreach/investors', {}, token);
if (!cancelled) setInvestors(Array.isArray(r?.investors) ? r.investors : []);
} catch (_) { /* none */ }
})();
return () => { cancelled = true; };
}, [token]);
const draft = async () => {
if (drafting) return;
if (!investorId) { 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 }),
}, token);
const data = res.data || res;
setResult(data);
if (data.status === 'ok') setDraftText(data.draft || '');
} catch (err) {
const msg = getErrorMessage(err, 'Drafting failed');
setResult({ status: 'error', reason: msg });
onShowToast(msg, 'error');
} finally {
setDrafting(false);
}
};
const copy = async () => {
try { await navigator.clipboard.writeText(draftText); onShowToast('Draft copied', 'success'); }
catch (_) { onShowToast('Could not copy', 'error'); }
};
const ok = result && result.status === 'ok';
return (
<div className="page-container">
<h2 className="section-title" style={{ marginBottom: '20px' }}>Outreach</h2>
<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.
</div>
<div className="form-group" style={{ marginBottom: '12px', maxWidth: '420px' }}>
<label className="form-label">Investor</label>
<select className="select-input" value={investorId} onChange={(e) => setInvestorId(e.target.value)}>
<option value="">Select an investor…</option>
{investors.map((iv) => <option key={iv.id} value={iv.id}>{iv.name}</option>)}
</select>
</div>
<div className="form-group" style={{ marginBottom: '12px', maxWidth: '420px' }}>
<label className="form-label">Type</label>
<select className="select-input" value={type} onChange={(e) => setType(e.target.value)}>
{TYPES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</div>
<div className="form-group" style={{ marginBottom: '12px' }}>
<label className="form-label">Guidance (optional)</label>
<textarea className="text-input" style={{ width: '100%', minHeight: '54px' }}
placeholder="e.g. mention the new Giga deal; they asked about lock-up terms"
value={guidance} onChange={(e) => setGuidance(e.target.value)} />
</div>
<div className="index-action-buttons">
<button onClick={draft} disabled={drafting || !investorId}>
{drafting ? 'Drafting… (this can take a moment)' : 'Draft outreach'}
</button>
</div>
</div>
{drafting && <div className="section"><SkeletonBlock lines={6} /></div>}
{result && !drafting && (
<div className="section">
{ok ? (
<>
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
Draft for {result.investor_name}
{result.scrub_stats && result.scrub_stats.tokens != null
? <span className="approval-pill">{result.scrub_stats.tokens} identifiers protected</span> : null}
</div>
<textarea className="text-input" style={{ width: '100%', minHeight: '260px', lineHeight: 1.5 }}
value={draftText} onChange={(e) => setDraftText(e.target.value)} />
<div className="index-action-buttons" style={{ marginTop: '10px' }}>
<button onClick={copy}>Copy draft</button>
</div>
<div className="index-action-hint" style={{ marginTop: '10px' }}>
Review and edit before sending. Nothing is sent automatically.
</div>
</>
) : (
<div className="toast error" style={{ position: 'static' }}>
{FAIL[result.status] || result.reason || 'Drafting did not complete.'}
</div>
)}
</div>
)}
</div>
);
};
const EmailCapturePage = ({ token, user, onShowToast }) => {
const isAdmin = user?.role === 'admin';
const [status, setStatus] = useState(null);
@@ -10751,6 +10877,9 @@
<button className={`nav-item ${page === 'thesis-workshop' ? 'active' : ''}`} onClick={() => setPage('thesis-workshop')}>
<span className="nav-item-icon"></span> Thesis Workshop
</button>
<button className={`nav-item ${page === 'outreach' ? 'active' : ''}`} onClick={() => setPage('outreach')}>
<span className="nav-item-icon"></span> Outreach
</button>
<button className={`nav-item ${page === 'system-status' ? 'active' : ''}`} onClick={() => setPage('system-status')}>
<span className="nav-item-icon"></span> System Status
</button>
@@ -10786,6 +10915,7 @@
{page === 'communications' && 'Communications'}
{page === 'thesis' && 'Thesis'}
{page === 'thesis-workshop' && 'Thesis Workshop'}
{page === 'outreach' && 'Outreach'}
{page === 'system-status' && 'System Status'}
{page === 'email-capture' && 'Email Capture'}
{page === 'feature-requests' && 'Feature Requests'}
@@ -10818,6 +10948,7 @@
{page === 'communications' && <CommunicationsPage token={token} onShowToast={showToast} />}
{page === 'thesis' && <ThesisPage token={token} user={user} onShowToast={showToast} />}
{page === 'thesis-workshop' && <ThesisWorkshopPage token={token} user={user} onShowToast={showToast} />}
{page === 'outreach' && <OutreachPage token={token} user={user} onShowToast={showToast} />}
{page === 'system-status' && <SystemStatusPage token={token} user={user} onShowToast={showToast} />}
{page === 'email-capture' && <EmailCapturePage token={token} user={user} onShowToast={showToast} />}
{page === 'feature-requests' && <FeatureRequestsPage token={token} onShowToast={showToast} user={user} />}