| name | investor-update |
| description | Draft the monthly LP update from the team WhatsApp group, enriched with facts from email, deduped against prior sends. Use when the user asks to write the investor update, LP update, monthly update, or says "time for the LP update". Produces a Superhuman/Gmail draft plus a local markdown archive. Never sends. |
Monthly LP Update
Assemble the periodic LP update from whatever sources a fund actually uses, and hand back a
draft. Assembly is the job; the writing is the easy part.
Two pluggable things, both defined in config.md:
- Roster sources — who is in the portfolio. A hand-written list, a CRM, Airtable,
Notion, a spreadsheet, a public portfolio page, or your own prior updates.
- Signal sources — where news about them shows up. WhatsApp groups, Slack channels,
founder update emails, forwarded updates, a running doc.
Plus one fixed pair:
updates/ + ledger.jsonl — the format and the memory. What the update looks
like, and what you already told LPs.
Every source normalizes to the same record before triage, so nothing downstream cares
where a fact came from:
{ source_type, source_id, fund, trust, author, date, text, permalink }
Trust tiers
trust is set per source in config and decides how a fact may be used.
| Tier | Meaning | Example |
|---|
primary | The company's own written words | A founder's monthly investor update email |
team | A partner stated it | A GP pasting Acme at $12.3M ARR into the group |
mention | Someone referred to it | "heard Acme closed 🎉" |
primary and team figures are quotable. mention never is — it only tells you where to
go looking. When two tiers disagree, primary wins and you note the discrepancy in Stage 5.
THE INVARIANT
No number reaches the draft unless it was explicitly stated by a human — in the chat,
in an email thread, or in a Stage 5 answer. Never inferred.
The distinction is stated vs. inferred, and it cuts across every source:
- Usable (
primary). A founder's own update email says the round closed at $14M. Best
possible source. Cite the thread.
- Usable (
team). A GP pastes Acme at $12.3M ARR, 110% NRR into the group. Use it,
and record who posted it and when.
- NOT usable (
mention). "Acme closed the round 🎉" — this licenses you to go look
for the round size. It never licenses you to state one.
- NOT usable. Anything you derived, estimated, annualized, or computed. If the number
isn't written somewhere verbatim, it doesn't exist.
Violating this is the only way this skill can actually damage the user. Everything else is
a bad draft they'll edit.
Hard rules
- ⚠️ The compose tool can deliver mail. Superhuman's
create_or_update_draft has been
observed delivering a type: "new" message that was never passed to send_draft. Do not
trust "it's only a draft." Before calling it, confirm with the user in that moment, and
only ever pass content that would be acceptable if it left immediately.
- Never send deliberately. Stage 7 aims to produce a draft. Any intentional send is a
separate explicit confirmation on that specific message.
- Never invent a fund-level number. Deployment, reserves, new investments, portfolio
marks — these come from Stage 5 questions only.
- Cap Stage 5 at 6 questions. If you have more, drop the lowest-confidence items and
say in the draft what you dropped.
- Never silently drop a candidate. Uncertain → it goes in the question round.
- Read
config.md first. Everything fund-specific lives there. Do not hardcode names,
companies, or recipients into this file.
Tool resolution
MCP prefixes vary by install. Resolve tools with ToolSearch before Stage 0:
select:mcp__twang__search_threads,mcp__twang__get_thread,mcp__twang__contact_brief,mcp__twang__deal_status
WhatsApp tools come from verygoodplugins/whatsapp-mcp — search whatsapp list_messages
if the prefix is unknown. Superhuman drafting: search create_or_update_draft.
Needed: list_chats, list_messages, get_message_context (WhatsApp);
search_threads / deal_status / contact_brief (Twang) or query_email_and_calendar /
get_thread (Superhuman); create_or_update_draft (Superhuman).
Stage 0 — Preflight
- Read
config.md. If it has unfilled TODO markers, stop and ask for them.
- Read
state.json for last_run. Absent → default to one period back from today.
- Resolve the roster from
roster_sources, in the order listed. Merge: a company
named by any source is in the roster; per field, the first source that has a value wins.
Report the merge (47 companies: 40 from HubSpot, 5 from manual, 2 from prior updates).
- Resolve every signal source. For each, confirm it's reachable and note its
fund
and trust.
- Report what is and isn't available before doing any work:
Roster: 47 companies (hubspot 40, manual 5, prior_updates 2)
Sources: whatsapp:Fund I ✓ whatsapp:Fund II ✓ email:founder-updates ✓ slack:#portfolio ✗
Window: 2026-07-01 → 2026-07-31
A source that fails is never skipped silently. Say which one, and ask whether to
proceed without it. A missing source is a missing slice of the portfolio, and it looks
identical to a quiet month.
Stage 1 — Harvest
Pull from every configured signal source over the window and normalize each item to the
common record. Tag fund and trust from the source config; carry both through every
later stage.
Source adapters
type | How to harvest | Notes |
|---|
whatsapp | list_messages on the group JID | Match on JID, never name. Bridge must be running |
slack | conversations.history on the channel via a Slack MCP | Threads count — pull replies too |
email_query | Mail search over a subject/sender pattern | e.g. subject:"Investor Update" |
email_label | Mail search scoped to a label/folder | For funds that file founder updates |
email_forwards | Mail search for forwards from a teammate | from:colleague subject:Fwd + the pattern |
gdoc | Read the doc, diff against last run | Treat each new bullet as one item |
Founder update emails are the highest-value source most funds already have and don't
use. They arrive on a cadence with subjects like {Company} Investor Update - {Month}
or {Company} Monthly Update | {Month}, and they contain the founder's own numbers. Set
them trust: primary. Where a teammate forwards them, harvest the forward — the original
body is intact inside it.
If volume strains context, harvest one source at a time and triage each (Stage 2) before
fetching the next. Carry only the candidate list forward, not the raw text.
Stage 2 — Triage
Classify every message into exactly one bucket:
- portfolio-signal — something happened at a company on the roster (round, hire, launch,
customer, metric, press, trouble)
- fund-signal — something about the fund itself (a new investment, an exit, an LP matter)
- noise — everything else
Match portfolio signals against company_roster in config, including the aliases listed
there. A signal you can't attribute to a roster company is not noise — it may be a new
investment. Keep it, tag it unattributed.
Emit a candidate list:
{ company, fund, trust, what_happened, source_type, source_date, excerpt, confidence }
fund comes from the source, never inferred from the company. If a company shows up under
two different fund labels in one window, that is a follow-on: keep both, flag it, and say
so in the draft rather than merging them.
Merge across sources per company before Stage 3. A single company will often appear in
three places in one window — its own update email (primary), a partner's summary in chat
(team), and a passing Slack mention (mention). Collapse them into one candidate holding
all three, ordered by trust. Where they conflict on a number, keep both values and send the
conflict to Stage 5; never quietly average or pick the newer one.
confidence is high / medium / low: how sure you are that this is a reportable event
rather than chatter about one.
If the group is an editorial thread, treat it as one. Many funds run the update as a
round-robin: someone posts a kickoff like "who has updates for the LP email?" and the
team replies with submissions. Where kickoff_pattern is set in config, treat the matching
message as the start of the reporting window and every reply to it as a submission rather
than a passing mention.
Expect real figures pasted inline. Capture them verbatim with the poster and date; they
are usable per THE INVARIANT.
Also capture the team's own editorial calls — they are instructions, not noise:
- "we did X last time" → already reported, skip
- "we can close the loop there" → a follow-up to a prior entry, not a new one
- "still confidential" → hold, do not report
- "save it for next month when we really know how it went" → defer
Cross-check these against ledger.jsonl. Where the team's memory and the ledger disagree,
surface the conflict in Stage 5 rather than picking a side.
Stage 3 — Enrich the gaps
Stage 1 already harvested the configured sources. This stage is a targeted search for
candidates that arrived without quotable numbers — typically the mention-tier ones.
Skip any candidate that already has a primary fact. Don't spend searches confirming what
a founder already wrote.
For the rest, in order:
- Twang —
deal_status, then search_threads scoped to the window and the
company/founder name. contact_brief if the thread set is ambiguous.
- Superhuman —
query_email_and_calendar with the company name and window;
get_thread on anything promising.
- CRM — if a roster source is a CRM, check for a recent-activity or notes field.
Search on the company's domain, not just its name. Two unrelated companies sharing a
word is common, and a name-only search will confidently return the wrong firm's threads —
with a real citation, so the invariant won't catch it.
Extract only facts an LP would care about, each with a source and its trust tier:
{ company, fact, trust, source: {type, ref, date, from} }
Anything still without a quotable figure:
{ company, claim, trust: "mention", source: {...} } # UNSOURCED
Do not resolve UNSOURCED items yourself. They go to Stage 5.
Stage 4 — Continuity
Read ledger.jsonl and the two most recent files in updates/.
For each enriched fact, check whether the same milestone was already reported:
- Already reported, no change → drop it. Say so in your Stage 6 summary line.
- Already reported, materially progressed → keep it, framed as a delta
("Acme's Series A, mentioned in July, closed at $14M with X leading").
- New → keep it.
At a monthly cadence this is the feature. LPs read last month's. Repeating yourself is
worse than omitting.
Stage 5 — Ask (max 6 questions)
One round. Use AskUserQuestion. Priority order, cut from the bottom if over six:
- Fund-level, always ask — deployed this month; new investments; any exit, markup or
writedown; the ask to LPs this month.
Ask each of these once, covering both funds in the same question ("Deployed this
month — Fund I and Fund II?"). Asking them per fund would be eight questions and blow
the cap.
- UNSOURCED items, highest confidence first — "The chat says Acme closed. I found no
email confirming terms. Report it without numbers, with numbers you give me, or hold it?"
- Unattributed signals — "Bolt Logistics came up twice and isn't on the roster. New
investment, or skip?"
Batch related fund-level items into one question. Never ask about something you already
have a sourced answer for.
If you had to cut questions, note it in Stage 6 output.
Stage 6 — Draft
Read the last 2–3 files in updates/ and copy their shape, not their content:
section order, section headings, typical length, how companies are named, whether metrics
appear inline or in a list, sign-off, tone.
Write updates/YYYY-MM.md — one combined document covering both funds, with the
portfolio news split into a Fund I section and a Fund II section. Each company goes under
the fund its source group indicates.
A company that appeared in both groups is a follow-on: one entry, placed under the fund
that made the new investment, noting the prior position.
Pick the subject pattern from what the month actually contains (see config.md):
Fund I news present → Fund I & II: {Month} Update. Fund II only → Fund II: {Month} Update. Quarter close for Fund I (January covers Q4, April covers Q1, and so on) → the
Fund I section becomes a quarterly roundup and the subject is Fund I Q{n} & Fund II {Month} Updates. Fund I reports quarterly; Fund II reports monthly. Do not force a Fund I
section into a month that has no Fund I news.
Every portfolio fact traces to an email source or a Stage 5 answer. Nothing else.
Then append each newly reported milestone to ledger.jsonl:
{"company":"Acme","fund":"II","milestone":"Series A closed, $14M, led by X","reported_in":"2026-08","date":"2026-07-28"}
Update state.json: {"last_run":"<today>"}.
Print a short summary: how many companies covered, how many facts sourced from email, how
many from the user, what was dropped as already-reported, and any questions you had to cut.
Stage 7 — Deliver
Most funds send the real LP update from a mailing platform (Mailchimp, Customer.io) run
by one person. This skill never touches that. What you produce is a draft for whoever
runs the send, per deliver_to in config.
Before calling the compose tool, confirm with the user. It has been observed delivering
mail that was never explicitly sent. Show them the recipient and subject and get a yes.
create_or_update_draft with:
- to —
deliver_to from config. Never an LP address. If you find yourself
assembling a list of LPs, stop — that list lives in the mailing platform, not here.
- acting_email AND from — both set to
from_account. Setting only from is not
enough; the tool falls back to the primary linked account.
- subject —
draft_subject from config
- body — the Stage 6 markdown, plus a short lead-in noting which facts came from email
sources and which came from the user's answers
Report:
Draft created for <deliver_to>: "<subject>"
Archive: updates/YYYY-MM.md
Verify in your mail client whether this is a draft or was delivered.
If no email MCP is reachable, skip this stage, say so, and hand back the markdown path.
Markdown-only is a perfectly good outcome.
Failure handling
| Situation | What you do |
|---|
| WhatsApp bridge not running | Stop, print the start command |
| Group / channel unresolved | List candidates, ask |
| One signal source unreachable | Name it, ask whether to proceed without it. Never skip silently |
| All signal sources unreachable | Stop |
| A roster source unreachable | Fall back to the remaining sources, report the smaller roster |
| Roster is empty | Stop. Nothing downstream works without it |
| Window is empty | Say so. Ask whether to widen it or skip the period |
| Two sources disagree on a number | Keep both, primary provisionally wins, conflict → Stage 5 |
| No quotable figure for a company | Mark UNSOURCED → Stage 5 |
| Company not on roster | Tag unattributed → Stage 5 |
| More than 6 questions needed | Cut lowest-confidence, disclose the cut |
No prior updates in updates/ | Ask for the format, or ask to paste one in |
| Email MCP unavailable | Markdown only, more Stage 5 questions, say what degraded |
What this skill is bad at
Metrics tables. The sources are narrative, not structured — there is no clean ARR series
hiding in a WhatsApp group. Write prose. If the user wants a metrics table, that data
comes from somewhere else.