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
+44
View File
@@ -60,10 +60,12 @@ try:
import architect_tools as _architect_tools # type: ignore
import architect_agent as _architect_agent # type: ignore
import architect_grounding as _architect_grounding # type: ignore
import outreach_agent as _outreach_agent # type: ignore
except Exception:
_architect_tools = None
_architect_agent = None
_architect_grounding = None
_outreach_agent = None
# ─── Configuration ────────────────────────────────────────────────────────────
@@ -1804,6 +1806,8 @@ class CRMHandler(BaseHTTPRequestHandler):
return self.handle_system_status(user)
if path == '/api/activity/proposals':
return self.handle_list_activity_proposals(user)
if path == '/api/outreach/investors':
return self.handle_list_outreach_investors(user)
# Users
if path == '/api/users':
@@ -1907,6 +1911,8 @@ class CRMHandler(BaseHTTPRequestHandler):
return self.handle_node_feedback(user, path.split('/')[-2], body)
if path == '/api/architect/ground':
return self.handle_architect_ground(user, body)
if path == '/api/outreach/draft':
return self.handle_outreach_draft(user, body)
if re.match(r'^/api/activity/proposals/[^/]+/approve$', path):
return self.handle_decide_activity_proposal(user, path.split('/')[-2], 'approve', body)
if re.match(r'^/api/activity/proposals/[^/]+/dismiss$', path):
@@ -3905,6 +3911,44 @@ class CRMHandler(BaseHTTPRequestHandler):
conn.close()
return self.send_json({"data": res})
def handle_list_outreach_investors(self, user):
conn = get_db()
try:
rows = conn.execute("SELECT id, investor_name FROM fundraising_investors "
"ORDER BY investor_name LIMIT 2000").fetchall()
return self.send_json({"investors": [{"id": r["id"], "name": r["investor_name"]} for r in rows]})
finally:
conn.close()
def handle_outreach_draft(self, user, body):
"""Draft tailored LP outreach through the redaction boundary (draft-only —
a human reviews/edits/sends; guardrails #4, #6)."""
if _outreach_agent is None:
return self.send_error_json("Outreach agent unavailable", 503)
body = body or {}
inv = body.get('investor_id')
if not inv:
return self.send_error_json("investor_id required", 400)
conn = get_db()
try:
res = _outreach_agent.draft_outreach(conn, inv, body.get('outreach_type', 'follow_up'),
body.get('guidance', '') or '', DB_PATH)
try:
conn.execute(
"INSERT INTO interaction_log (id, ts, actor_type, actor_id, action, target_type, target_id, payload, source, created_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
(generate_id(), now(), "human", user.get('user_id'), "outreach.drafted",
"fundraising_investor", inv,
json.dumps({"type": body.get('outreach_type'), "status": res.get('status')}), "crm_ui", now()))
conn.commit()
except Exception:
pass
except Exception as exc:
return self.send_error_json(str(exc), 502)
finally:
conn.close()
return self.send_json({"data": res})
# ─── Architect thesis (Phase 1) ───
def handle_list_thesis_lines(self, user):
if thesis_review is None: