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>
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>
The summarize-historical-email grounding produced generic, boilerplate objections
with no quotes and no source traceability (the minimize step abstracts away the
actual email text; the newest-N corpus carries little real objection signal, so the
model pattern-completes). Pulled the page (ObjectionsPage component + nav + dispatch).
The redaction boundary is kept (reusable for proactive outreach); the dormant
/api/architect/ground route is left in place but has no UI trigger. Pivoting to
proactive outreach / messaging.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New admin "LP Objections" page (frontend ObjectionsPage + nav). Pick a segment (or
All LPs) and Run grounding: the Architect mines matched LP emails + notes on the local
model, scrubs every identifier through the redaction boundary, and asks Claude for the
recurring objections + honest rebuttals (substantiated/hand-wavy flagged). Renders the
de-identified draft + an "N identifiers protected" badge; fail-closed statuses
(local_model_unavailable / scrub_unavailable / claude_not_configured / rehydrate_failed)
show a clear message. Uses the existing /api/architect/ground route. Verified in preview:
page + segment selector + Run; the local minimize/scrub legs actually ran against real
Spark on synthetic input and fail-closed correctly at the (key-less) Claude step;
success rendering verified with a mocked ok response.
NOT yet deployed — start-cli RPC to the box hit a transient transport error post a
StartOS hiccup (curl works, start-cli doesn't); CRM healthy at v0.1.0:65 meanwhile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/api/email/accounts now returns captured + matched per account (from the per-mailbox
sighting table email_account_messages joined to emails; emails dedupe globally so an
email seen by two mailboxes counts for each). Each mailbox card on the Email Capture
page shows "<N> captured · <M> matched" so per-user coverage is visible, not just the
aggregate. Verified in preview with two seeded mailboxes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a sent/received email is matched to an investor, a local-model agent drafts a
one-line dated note and queues it as a PENDING proposal (it never writes the grid
itself). On the Email Capture page a partner sees "Proposed grid notes", can edit the
text, and Approve (appends to that investor's grid notes cell, newest at bottom,
stamped with the approver) or Dismiss. Going-forward only: a cutoff (app_settings
email_activity_since, set on first run) means email dated before the feature was
enabled is never summarized, so the historical backfill makes no noise. Sovereign:
summaries run entirely on the local model (no redaction needed). Gmail sync interval
tightened 180 -> 15 min so outgoing email surfaces quickly.
Backend: migration 0002 (email_activity_proposals); propose_email_activity_notes()
runs via a new scheduler post_sync hook; list/decide functions + routes
GET /api/activity/proposals, POST .../{id}/approve|dismiss. Grid append stamps the
approving user (fundraising_state.updated_by has a FK to users). Test
test_email_activity.py (propose cutoff/idempotency, approve appends + edited note,
dismiss, already-decided guard) under FK enforcement.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/api/system/status now returns a best-effort storage block: database file size
(crm.db + WAL + SHM), the email_attachments dir, the backups dir, and disk
total/used/free via shutil.disk_usage(DATA_DIR). System Status renders a Storage
section with human-readable sizes so growth can be watched over time.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The first Gmail backfill leaves the account at "pending · never synced" until it
fully completes (the sync_runs row only finalizes at the end), so there was no
feedback. /api/email/status now also returns captured_emails (total, which climbs
page-by-page during backfill), the latest sync run, and a backfilling flag. The
panel shows a "Backfilling… N captured so far" banner + an Emails Captured count
and auto-refreshes every 5s while a backfill is in progress. Verified live in
preview with seeded data (count auto-climbed 37 -> 50 without manual refresh).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a "Test with a single mailbox first" input (pre-filled with the admin's own
address) + Enroll this mailbox button calling the enroll-one endpoint, so capture
can be tried on one mailbox before enrolling the whole domain. runAction now sends
an optional JSON body. Enroll-all stays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an admin-only "Email Capture" page so Gmail capture can be turned on and
monitored from the UI instead of an API call: shows whether the integration is
enabled, how many mailboxes are enrolled, how many emails are matched to investors,
and last sync; with Enroll Ten31 mailboxes / Sync now / Re-match buttons and a hint
that domain-wide delegation must be authorized in Google Workspace first. Disabled
state renders cleanly (no scary error) when the integration is off. Bundles the
email-into-grounding corpus wiring (bf829b7).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses Grant's feedback that the Workshop was confusing and underbuilt (no delete,
no approve, redundant generate-vs-feedback panels, and a stray "0" on segment lines).
Backend (architect_tools.py + server.py routes/handlers):
- retire_node: soft-delete a node + its subtree (reversible). DELETE /api/thesis/nodes/{id}.
- choose_variant: 'Use this' — keep this option, soft-delete the others in its group,
mark it approved. POST /api/thesis/nodes/{id}/choose.
- upsert_thesis_node gains actor_type so a manual human edit is recorded as 'human'.
PUT /api/thesis/nodes/{id} edits a part's text directly.
- handle_approve_line: one-click 'approve as current' — records this admin's approval on
the line's in-review version (creating + submitting one from the live tree if none),
promoting to canonical at the required distinct-approval count. POST /api/thesis/lines/{key}/approve.
Frontend (ThesisWorkshop redesign):
- Merged the redundant "Generate options" + "Give feedback" panels into one "Ask the
Architect for options" box (revise was just generate-with-guidance).
- Per option: Use this / Edit (inline) / Delete. Per part: edit + delete via the same.
- "Approve as current" bar with dual-sign-off state + a "Current ✓" badge, and a one-line
"how it works" hint. Refreshes the tree after every action.
- Fixed the stray "0": `{line.is_core && <badge>}` rendered 0 for non-core lines (SQLite
integer 0); now `{!!line.is_core && ...}`.
Verified: backend test_thesis_actions.py (choose/edit/retire-subtree/dual-approval->canonical),
and a live in-browser smoke test (JSX compiles, Workshop renders, options show Use/Edit/Delete,
approve returns 1-of-2, no runtime errors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After the grid/contacts unification (v0.1.0:52) the Contacts page's "Add New Contact"
modal was made unreachable (new people are added from the grid). This removes the now-dead
showForm/formData/formError state, handleAddContact, and the modal JSX. Other components'
forms are untouched; html parses clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The fundraising grid's per-contact editor now has a LinkedIn URL field next to
name, email, title, and location. It threads through the grid contact object and
sanitize (which preserves contact-object fields), and _upsert_contact_from_fundraising
now reads and persists linkedin_url on both the update and insert paths — so a
LinkedIn entered in the grid lands on the linked contact record.
Test: test_grid_contact_link.py extended to assert LinkedIn entered in the grid
persists to the contact (idempotent). Frontend html.parser clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Structural fix for the duplicate-people class of bug: instead of matching a grid
contact "pill" to a contacts row heuristically by name/email (which drifted and
caused the 1406 double-count), link them by id.
Backend:
- Migration 0004: fundraising_contacts.contact_id (additive, nullable, logical FK
to contacts(id)) + index. Paired down migration.
- sync_fundraising_relational now stores the id that _upsert_contact_from_fundraising
already returns, so every grid contact carries its contacts-table id.
- _backfill_grid_contact_ids: one-time, idempotent backfill on startup (re-runs the
grid sync once if any row lacks contact_id), so existing data links immediately.
- entity_resolution: grid pass prefers the explicit contact_id link (match_kind
'grid_link') over heuristic email / name+investor, guarded by a PRAGMA check so
older DBs without the column still work.
Frontend:
- Fundraising grid "+ Row" -> "+ Investor" (clear, single investor entry point).
- Contacts page: the "+ Add Contact" trigger is replaced by a pointer to the grid;
the page is now a read/search/edit view (ContactDetailPanel still edits all
fields). New people are added from the grid. No contact data is removed.
Tests: backend/ingest/test_entity_resolution.py extended (explicit-link case, 11/11)
and a new backend/test_grid_contact_link.py integration test (init_db applies 0004,
sync populates contact_id to the right contact, re-sync is idempotent). py_compile +
frontend html.parser clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Frontend: ThesisWorkshopPage / ThesisWorkshopNode / ThesisWorkshopOptions —
the collaborative iteration screen where partners generate a variable number
of competing thesis options (1, 2, 3, A1/A2/A3 ...) for any node, give
feedback, and regenerate. Reuses the shared api() helper; flexible option
count is the core UX constraint.
Backend Architect agent (architect_agent.py) + routes shipped in dd25bbc;
this completes the user-facing surface and bumps the StartOS package to
0.1.0:49 (anthropic dep already in the image, key loaded from
/data/secrets/anthropic-api-key — self-disabling until present).
Also lands thesis seed iterations v3 and v5 (voice/messaging corrections).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per Grant's clarification of the real data model:
- Investor entities come from the fundraising grid, one per row, all labeled
"investor" (drops the confusing lp/organization split). Grid is source of truth.
- People come ONLY from the contacts table. The grid's contacts (fundraising_
contacts) are matched to a contact-person and recorded as member_of links to
their investor, instead of creating duplicate person entities. This fixes the
~doubled people count (people now ≈ contacts, not contacts + grid contacts).
- System Status cards: Investors / People (resolved) / Contacts in CRM / Grid
contacts, so resolved-vs-source is visible at a glance.
Verified on synthetic: people == contacts count (no double-count); multi-contact
investors preserved via member_of.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- frontend: System Status page extended with one-click index actions
(update/rebuild/find-duplicates, with live job status) and a human-in-the-loop
duplicate-review queue (approve=merge / reject=keep-separate per candidate).
- StartOS version 0.1.0:45 (image-only; schema via the in-app migration runner).
Backend + new routes verified end-to-end via the running HTTP server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two React-via-Babel views in the CRM SPA, reusing the existing api() helper and
conventions:
- Thesis: lists thesis lines + the in-review queue with approvals/required pills;
version detail renders throughline/pillars/claims/objections + the reviews
timeline; admin review form (approve/request-changes/comment + feedback) ->
POST /api/thesis/versions/{id}/review (the dual-approval feedback loop).
- System Status: entity counts, last index sync, thesis counts, recent activity
from the interaction log — index health visible in-app, no shell.
Backend + full approve flow verified end-to-end via the running HTTP server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>