@vruum/skills 0.6.4 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,7 @@ description: >-
8
8
  ---
9
9
  ## MCP smoke test (run early)
10
10
 
11
- Before triaging, confirm Vruum MCP is reachable. Call `get_marketing_overview` as a lightweight liveness check. On failure, surface this error and stop:
11
+ Before triaging, confirm Vruum MCP is reachable. Call `fetch` with type=marketing, subtype=overview as a lightweight liveness check. On failure, surface this error and stop:
12
12
 
13
13
  > Vruum MCP is not connected. Set up the MCP server (see Vruum docs) and re-invoke. Subagent dispatch needs user-scope MCP — cloud-mode MCP is not inherited.
14
14
 
@@ -20,7 +20,7 @@ You review the user's pending LinkedIn engagement drafts (warming comments, nurt
20
20
 
21
21
  ## Why this is a skill and not just "call the tool"
22
22
 
23
- The backend produces a research dossier + shippable-floor comment for every engagement (the `polished_floor` field — quality SLA for all four front doors per `project_four_front_doors_architecture`). The skill's job is to UPLIFT that floor into a great comment using the operator's Claude subscription, then write the polished result back via `manage_engagement` with `polish_provenance.source="skill"` so the two-stage edit diff is captured.
23
+ The backend produces a research dossier + shippable-floor comment for every engagement (the `polished_floor` field — quality SLA for all four front doors per `project_four_front_doors_architecture`). The skill's job is to UPLIFT that floor into a great comment using the operator's Claude subscription, then write the polished result back via `manage_engagements` with `polish_provenance.source="skill"` so the two-stage edit diff is captured.
24
24
 
25
25
  Reviewing inline burns tokens fast. Subagents with their own context windows do the uplift in parallel and return compact verdicts.
26
26
 
@@ -36,7 +36,7 @@ Falls back to general-purpose subagent with MCP tool names in the prompt if the
36
36
 
37
37
  ### Step 1: Summarize the queue
38
38
 
39
- Call `get_marketing_overview` to see what's pending. Present a one-liner:
39
+ Call `fetch` with type=marketing, subtype=overview to see what's pending. Present a one-liner:
40
40
 
41
41
  "X warming drafts, Y nurture drafts, Z marketing drafts, N content posts pending."
42
42
 
@@ -55,7 +55,7 @@ If the user just says "go", default to A.
55
55
 
56
56
  ### Step 3: Pull sender identity (REQUIRED before dispatch)
57
57
 
58
- Call `get_company_profile` to get the sender's identity, value prop, industry expertise, and background. Subagents need this to validate that drafts sound like the right person.
58
+ Call `fetch` with type=settings, subtype=profile to get the sender's identity, value prop, industry expertise, and background. Subagents need this to validate that drafts sound like the right person.
59
59
 
60
60
  Include a SENDER PROFILE block in every subagent prompt:
61
61
  ```
@@ -71,7 +71,7 @@ Background: {founder_background}
71
71
 
72
72
  For each queue type the user selected, call the appropriate list endpoint, get IDs + lightweight context (no full content yet), then dispatch subagents.
73
73
 
74
- **Warming / Nurture / Marketing engagements** — call `get_engagement_queue` filtered by `type` (`warming` / `nurture` / `marketing`). Batch 3-5 per subagent.
74
+ **Warming / Nurture / Marketing engagements** — call `search` with type=engagements, filtered by source (`warming` / `nurture` / `marketing`). Batch 3-5 per subagent.
75
75
 
76
76
  **Content posts** — call `get_content_review` for drafts awaiting approval. Batch 2-3 per subagent (posts are longer and need more careful voice check).
77
77
 
@@ -130,15 +130,17 @@ For each engagement:
130
130
  - KEEP: polished_floor is already strong. Don't edit.
131
131
  - FLAG: structurally broken (off-topic, wrong stage, prospect bad fit).
132
132
  Recommend skip + plan-stop cascade.
133
- 3. If UPLIFT, call manage_engagement with:
133
+ 3. If UPLIFT, call manage_engagements with:
134
134
  action="edit"
135
- engagement_ids="<id>"
136
- content="<your uplifted comment>"
137
- polish_provenance={
138
- "source": "skill",
139
- "model": "<your model — claude-opus-4-7, claude-sonnet-4-6, etc.>",
140
- "at": "<ISO8601>",
141
- "rewrite_notes": "<one line — what you changed and why>"
135
+ id="<id>"
136
+ payload={
137
+ "content": "<your uplifted comment>",
138
+ "polish_provenance": {
139
+ "source": "skill",
140
+ "model": "<your model — claude-opus-4-7, claude-sonnet-4-6, etc.>",
141
+ "at": "<ISO8601>",
142
+ "rewrite_notes": "<one line — what you changed and why>"
143
+ }
142
144
  }
143
145
 
144
146
  IMPORTANT: Do NOT approve or skip engagements. Return recommendations only.
@@ -36,7 +36,7 @@ If the post makes specific claims (statistics, market sizes), flag as "unverifie
36
36
 
37
37
  ## Step 3: Edit if needed
38
38
 
39
- For structural issues (no line breaks, weak hook), use `manage_content_post` with action="edit". For brand voice and factual issues, FLAG rather than edit.
39
+ For structural issues (no line breaks, weak hook), use `manage_content` with action="edit". For brand voice and factual issues, FLAG rather than edit.
40
40
 
41
41
  ## Step 4: Return structured summary
42
42
 
@@ -67,20 +67,22 @@ Reactions are usually KEEP. Only edit if reaction_type is wrong for the post con
67
67
  ### 2e. Budget check
68
68
  If `budget_status` shows the sender account near daily limits, note it in REASONING (the operator may want to defer some approvals).
69
69
 
70
- ## Step 3: Apply the uplift via manage_engagement
70
+ ## Step 3: Apply the uplift via manage_engagements
71
71
 
72
- When RECOMMENDATION=uplifted, write back via `manage_engagement`:
72
+ When RECOMMENDATION=uplifted, write back via `manage_engagements`:
73
73
 
74
74
  ```
75
- manage_engagement(
76
- engagement_ids="<id>",
75
+ manage_engagements(
77
76
  action="edit",
78
- content="<your uplifted comment>",
79
- polish_provenance={
80
- "source": "skill",
81
- "model": "<your model — claude-opus-4-7 / claude-sonnet-4-6 / etc.>",
82
- "at": "<ISO8601 timestamp>",
83
- "rewrite_notes": "<one line: what changed and why>"
77
+ id="<id>",
78
+ payload={
79
+ "content": "<your uplifted comment>",
80
+ "polish_provenance": {
81
+ "source": "skill",
82
+ "model": "<your model claude-opus-4-7 / claude-sonnet-4-6 / etc.>",
83
+ "at": "<ISO8601 timestamp>",
84
+ "rewrite_notes": "<one line: what changed and why>"
85
+ }
84
86
  }
85
87
  )
86
88
  ```
@@ -116,6 +118,6 @@ VALIDATOR_FAILURES_FIXED: {comma-separated codes you cleared, or "none"}
116
118
  ## Error handling
117
119
 
118
120
  - **MCP call failure**: retry once. If it still fails, set RECOMMENDATION=flag with REASONING="MCP error: <message>" and continue with the next item. Don't abort the whole batch.
119
- - **manage_engagement write fails (idempotency)**: report failure in REASONING; the operator can re-run. The skill keeps partial state in `/tmp/engagement-triage-$$/pending.jsonl` (parent skill responsibility).
121
+ - **manage_engagements write fails (idempotency)**: report failure in REASONING; the operator can re-run. The skill keeps partial state in `/tmp/engagement-triage-$$/pending.jsonl` (parent skill responsibility).
120
122
  - **No polished_floor AND no first_draft AND no content**: the queue row is malformed; flag with REASONING="malformed_queue_row, no draft to uplift".
121
123
  - **Empty dossier + empty post text**: flag with REASONING="no grounding available".
@@ -16,13 +16,13 @@ This is not a database lookup. This is an analyst's brief.
16
16
 
17
17
  Call these in parallel:
18
18
  - `get_person_360` — profile, match analysis, research, activity, outreach plan, deal
19
- - `get_person_research` — structured research data (if exists)
20
- - `get_company_research` — company intelligence
19
+ - `fetch` type=person_research — structured research data (if exists)
20
+ - `fetch` type=company_research — company intelligence
21
21
 
22
22
  If research is thin (no person_research, or match_analysis is null):
23
- - `fetch_linkedin_data` — pull their recent posts and profile
23
+ - `research` action=linkedin_fetch — pull their recent posts and profile
24
24
  - WebSearch for "[person name] [company name]" — recent news, talks, publications
25
- - `search_knowledge_base` — relevant sales docs
25
+ - `search` type=kb — relevant sales docs
26
26
 
27
27
  ## Step 2: Diarize
28
28
 
@@ -69,7 +69,7 @@ No embedding search finds these gaps. No keyword filter finds them. You have to
69
69
 
70
70
  "Append this diarization as a note? It will appear on the person card timeline and be visible to the outreach agent when writing messages."
71
71
 
72
- If approved: call `add_person_note` with a condensed version of the diarization (the SAYS/ACTUALLY gap, key signals, and recommended approach). Each call appends a new row to the person's notes timeline — no overwrite of prior notes.
72
+ If approved: call `manage_person` action=note (payload={body}) with a condensed version of the diarization (the SAYS/ACTUALLY gap, key signals, and recommended approach). Each call appends a new row to the person's notes timeline — no overwrite of prior notes.
73
73
 
74
74
  ## Notes
75
75
 
@@ -12,29 +12,29 @@ exposes.
12
12
 
13
13
  ```
14
14
  # Step 1: pull closed-won deals (results are scoped to your session's tenant)
15
- won = get_deals(outcome="won", limit=200)
15
+ won = search(type="deals", filters={"outcome": "won"}, limit=200)
16
16
 
17
17
  # Step 2: keep deals whose actual_close_date is more than 60 days old
18
18
  cutoff = now() - 60 days
19
19
  won_old = [d for d in won.deals if parse(d.actual_close_date) < cutoff]
20
20
 
21
21
  # Step 3: drop people who currently have an OPEN deal (i.e., a deal with
22
- # outcome=None on the same person). If you can call get_deals with no outcome
23
- # filter, post-filter; otherwise rely on Step 3 of the skill workflow where
24
- # get_person_360 surfaces each person's full deal history.
25
- open_deals = get_deals(outcome=None, limit=500) # NULL outcome = still open
22
+ # outcome=None on the same person). If you can call search type=deals with no
23
+ # outcome filter, post-filter; otherwise rely on Step 3 of the skill workflow
24
+ # where get_person_360 surfaces each person's full deal history.
25
+ open_deals = search(type="deals", filters={"outcome": None}, limit=500) # NULL outcome = still open
26
26
  open_person_ids = { d.person_id for d in open_deals.deals }
27
27
  candidates = [d for d in won_old if d.person_id not in open_person_ids]
28
28
  ```
29
29
 
30
30
  If you have more than 200 won deals, paginate with the `offset` parameter on
31
- `get_deals`. Cap the candidate list to the most recently won 50 (sort by
31
+ `search`. Cap the candidate list to the most recently won 50 (sort by
32
32
  `actual_close_date DESC`) before per-account enrichment.
33
33
 
34
34
  Why this works without a DB query:
35
35
 
36
- - `get_deals` is tenant-scoped server-side; you don't (and can't) choose the
37
- tenant.
36
+ - `search` type=deals is tenant-scoped server-side; you don't (and can't)
37
+ choose the tenant.
38
38
  - Account-to-person mapping (the old `company_people` JOIN) is resolved by
39
39
  `get_person_360` in Step 3 of the skill workflow — call it per surfaced
40
40
  person and read `current_positions[0].company_id`.
@@ -42,8 +42,8 @@ Why this works without a DB query:
42
42
  ## Optional refinement: account stage is tagged
43
43
 
44
44
  If your team has been tagging account lifecycle stages on the Vruum
45
- dashboard, call `get_account_state(company_id=<id>)` for each candidate in
46
- the skill's Step 3 enrichment and:
45
+ dashboard, call `fetch(type="account_state", id=<company_id>)` for each
46
+ candidate in the skill's Step 3 enrichment and:
47
47
 
48
48
  - Up-rank rows where `account_stage` is `adopting` or `expansion_ready`.
49
49
  - Down-rank or skip rows where `account_stage` is `dormant` or `churned` —
@@ -57,20 +57,21 @@ Vruum dashboard by whoever owns the account lifecycle.
57
57
  ## Scoring inputs (consumed by skill Step 4)
58
58
 
59
59
  Per-account features the skill computes from `get_person_360` +
60
- `get_company_research`:
60
+ `fetch` type=company_research:
61
61
 
62
- - `accounts.renewal_at` (from `get_account_state`) — proximity weight
62
+ - `accounts.renewal_at` (from `fetch` type=account_state) — proximity weight
63
63
  (60-180d sweet spot)
64
64
  - `accounts.health_score` — gate (>70 only)
65
65
  - `accounts.account_stage` — boost (`adopting`, `expansion_ready`) or skip
66
66
  (`dormant`, `churned`)
67
67
  - Most recent `practice='adoption'` activity in last 60d — engagement signal
68
68
  - Recent touch sent + reply received → champion present
69
- - New hire signals on company LinkedIn (via `fetch_linkedin_data`) — fresh
70
- stakeholder = hook
71
- - New dept created (via `fetch_linkedin_data`) — multi-team expansion play
72
- - Recent product/news events (via `fetch_company_website` or research) —
73
- timing hook
69
+ - New hire signals on company LinkedIn (via `research` action=linkedin_fetch)
70
+ — fresh stakeholder = hook
71
+ - New dept created (via `research` action=linkedin_fetch) — multi-team
72
+ expansion play
73
+ - Recent product/news events (via `research` action=enrich_company or saved
74
+ research) — timing hook
74
75
 
75
76
  ## Hook generation
76
77
 
@@ -16,31 +16,31 @@ The post-sale side (Onboarding → Adoption → Expansion → Win-back) is where
16
16
 
17
17
  ## Where heavy logic lives
18
18
 
19
- [`COHORT-QUERIES.md`](./COHORT-QUERIES.md) — the cohort recipes (Vruum-MCP harness flow + scoring rationale). Read it before running the workflow if you need to understand or tweak the cohort definition. All recipes use `get_deals` + post-filter; tenant scope is automatic from your session.
19
+ [`COHORT-QUERIES.md`](./COHORT-QUERIES.md) — the cohort recipes (Vruum-MCP harness flow + scoring rationale). Read it before running the workflow if you need to understand or tweak the cohort definition. All recipes use `search` type=deals + post-filter; tenant scope is automatic from your session.
20
20
 
21
21
  ## Workflow
22
22
 
23
23
  Step 1 — Read the impact scoreboard.
24
24
  ```
25
- get_account_impact_scoreboard(window_days=90)
25
+ fetch(type="scoreboard", subtype="impact", filters={"window_days": 90})
26
26
  ```
27
27
  - If `state == "no_events_yet"`: no impact data yet — fall through to the cohort SQL below to use the day-1 heuristic. Mention that the scoreboard is empty so you understand why this is running off deal data not impact events.
28
28
  - Otherwise: surface the practice rollups so you see current state.
29
29
 
30
30
  Step 2 — Build the cohort.
31
31
  Follow the **primary cohort** recipe in `COHORT-QUERIES.md`. The recipe runs
32
- `get_deals(outcome="won")` and post-filters in-memory; tenant scope is
33
- automatic from your authenticated session. Cohort criteria:
32
+ `search(type="deals", filters={"outcome": "won"})` and post-filters in-memory;
33
+ tenant scope is automatic from your authenticated session. Cohort criteria:
34
34
  - A deal closed `won` more than 60 days ago AND
35
35
  - No open deal exists on the same person now (post-filter against
36
- `get_deals(outcome=None)`) AND
36
+ `search` type=deals filters={outcome: None}) AND
37
37
  - The person is still surfaceable via `get_person_360` (Step 3 enrichment).
38
38
 
39
39
  Limit 50. Order by `actual_close_date DESC` (most recently won first — freshest relationship).
40
40
 
41
41
  Step 3 — Per-account enrichment. For each surfaced (person, company):
42
42
  - `get_person_360(person_id=<id>)` — pulls current title, recent activity, last touch, deal history
43
- - `get_company_research(company_id=<id>)` — pulls firmographics, recent news/triggers
43
+ - `fetch(type="company_research", id=<company domain>)` — pulls firmographics, recent news/triggers
44
44
 
45
45
  Step 4 — Score and rank. Within the cohort, rank by:
46
46
  - (a) **Renewal pressure** — `accounts.renewal_at` within 60-180 days → up-rank
@@ -60,24 +60,27 @@ Step 5 — Surface a ranked table:
60
60
  | 1 | Acme | Tyler T | adopt | 85 | 47d | 3 new dept LinkedIns past 30d |
61
61
  ```
62
62
 
63
- For each row, include a one-line "hook" — a specific recent observation from `get_person_360` or `get_company_research` (new hire, new dept, news event, product usage signal) that frames the expansion conversation. **Never push expansion without a hook** — generic "checking in" outreach kills relationships.
63
+ For each row, include a one-line "hook" — a specific recent observation from `get_person_360` or `fetch` type=company_research (new hire, new dept, news event, product usage signal) that frames the expansion conversation. **Never push expansion without a hook** — generic "checking in" outreach kills relationships.
64
64
 
65
65
  Step 6 — Hand off to outreach. Two options:
66
66
  - **Option A (recommended)**: Approve the ranked list, then for each account: run `/pipeline-fill` with that prospect_list — same Sales Nav harness flow, just sourced from the expansion cohort instead of cold. Tag the resulting `outreach_plans.tag` with `bowtie_pilot:expansion` so success-tracking finds them.
67
- - **Option B**: Direct `start_outreach` with an expansion-flavored segment (pre-create an `expansion_<your-tenant>` segment with the right tone — formal, ROI-focused, no opener-hooks since the customer already knows you).
67
+ - **Option B**: Direct `manage_outreach` action=start with an expansion-flavored segment (pre-create an `expansion_<your-tenant>` segment with the right tone — formal, ROI-focused, no opener-hooks since the customer already knows you).
68
68
 
69
- Step 7 — Success tracking (auto). When a calendar webhook fires a `meeting_booked` event on an outreach plan tagged `bowtie_pilot:expansion`, the webhook handler in `backend/app/domains/calendar/` auto-fires:
69
+ Step 7 — Success tracking (auto). When a calendar webhook fires a `meeting_booked` event on an outreach plan tagged `bowtie_pilot:expansion`, the webhook handler in `backend/app/domains/calendar/` auto-records the impact event, equivalent to:
70
70
  ```
71
- record_impact_event(
72
- practice='expansion',
73
- event_type='expansion_meeting_booked',
74
- value_delivered_numeric=deal.estimated_value,
75
- ...
71
+ manage_account(
72
+ action="record_impact",
73
+ payload={
74
+ practice: 'expansion',
75
+ event_type: 'expansion_meeting_booked',
76
+ value_delivered_numeric: deal.estimated_value,
77
+ ...
78
+ }
76
79
  )
77
80
  ```
78
- You do NOT manually invoke `record_impact_event` for tagged plans. If a meeting is booked outside Vruum (manual scheduling, calendar tool not connected), manually invoke `record_impact_event` from the person 360 Activity tab.
81
+ You do NOT manually record the impact event for tagged plans. If a meeting is booked outside Vruum (manual scheduling, calendar tool not connected), record it manually via `manage_account` action=record_impact from the person 360 Activity tab.
79
82
 
80
- After 30 days, run `get_account_impact_scoreboard` to measure cohort uplift: expansion `event_count` should be > 0 with `impact_sum` matching booked deal values.
83
+ After 30 days, run `fetch` type=scoreboard subtype=impact to measure cohort uplift: expansion `event_count` should be > 0 with `impact_sum` matching booked deal values.
81
84
 
82
85
  ## When NOT to use this skill
83
86
 
@@ -92,4 +95,4 @@ See `docs/ACCOUNT-LIFECYCLE-VOCABULARY.md` for the 8 canonical account-lifecycle
92
95
 
93
96
  ## Backend authoritative gate
94
97
 
95
- This skill is harness-mode: the harness picks the cohort, scores, and ranks. The backend's authoritative gate is `record_impact_event` itself — the dedupe key `(user_company_id, company_id, activity_type, source_type, source_id)` prevents double-write. The harness is uplift, not the SLA floor.
98
+ This skill is harness-mode: the harness picks the cohort, scores, and ranks. The backend's authoritative gate is the impact-event write itself (`manage_account` action=record_impact) — the dedupe key `(user_company_id, company_id, activity_type, source_type, source_id)` prevents double-write. The harness is uplift, not the SLA floor.
@@ -16,7 +16,7 @@ Reviewing outreach messages is context-expensive. Each message with full context
16
16
  ## Subagent architecture
17
17
 
18
18
  This skill uses the custom agent `vruum-outreach-reviewer` (bundled at `agents/vruum-outreach-reviewer.md`). That agent has:
19
- - Full Vruum MCP access (can call get_outreach_review, edit_message, search_knowledge_base, etc.)
19
+ - Full Vruum MCP access (can call get_outreach_review, manage_messages, search, etc.)
20
20
  - Web search for prospect research
21
21
  - Complete review instructions baked into its system prompt
22
22
 
@@ -28,7 +28,7 @@ For small queues (5 or fewer) or when subagents can't access MCP, review directl
28
28
 
29
29
  ### Step 1: Get the lay of the land
30
30
 
31
- Call `get_outreach_stats` to see the pending queue shape. Present a quick summary:
31
+ Call `fetch` with type=stats and subtype=outreach to see the pending queue shape. Present a quick summary:
32
32
 
33
33
  "You have X reply responses, Y pending T1s, Z T2+ follow-ups. [Any critical alerts.] Want me to run full triage or focus on a specific category?"
34
34
 
@@ -36,7 +36,7 @@ Keep it short. The user knows their queue — they just need the numbers to deci
36
36
 
37
37
  ### Step 2: Build the dispatch list and categorize
38
38
 
39
- Once the user says go (or picks a focus area), pull the lightweight message queue via `get_message_queue` with `status=draft` and `limit=100`. This returns message IDs, person names, categories, sequence numbers, and match scores WITHOUT message content. Very cheap on tokens.
39
+ Once the user says go (or picks a focus area), pull the lightweight message queue via `search` with type=messages, a status=draft filter, and limit=100. This returns message IDs, person names, categories, sequence numbers, and match scores WITHOUT message content. Very cheap on tokens.
40
40
 
41
41
  Categorize into three processing groups:
42
42
 
@@ -96,7 +96,7 @@ For each message:
96
96
  4. Rate personalization depth (surface/basic/deep)
97
97
  5. Check strategic fit (CTA matches stage, moves conversation forward)
98
98
 
99
- If a message needs fixes, edit it via edit_message. If personalization is weak, use search_knowledge_base to find better hooks.
99
+ If a message needs fixes, edit it via manage_messages with action=edit. If personalization is weak, use search with type=kb to find better hooks.
100
100
 
101
101
  Return a structured summary per message:
102
102
  MESSAGE: {id} | PERSON: {name} | MATCH_SCORE: {n} | CATEGORY: T{n} | RECOMMENDATION: {approve|edited|flag|reject} | CONFIDENCE: {high|medium|low} | REASONING: {1-2 sentences} | EDITED: {yes/no} | ISSUES_FOUND: {list or "none"}
@@ -115,10 +115,10 @@ Message type: T{sequence_number} follow-up
115
115
 
116
116
  Steps:
117
117
  1. Call get_outreach_review with message_ids="{message_id}" and content_length="full" to get the current message, thread context, segment instructions, and match analysis.
118
- 2. Call get_person_research and get_person_360 for this person to get everything we know.
119
- 3. Call get_company_research to understand the company's product, positioning, and what problems it solves.
118
+ 2. Call fetch with type=person_research plus get_person_360 for this person to get everything we know.
119
+ 3. Call fetch with type=company_research to understand the company's product, positioning, and what problems it solves.
120
120
  4. Search the web for this prospect and their company to understand what they actually do, what challenges they face, what they post about.
121
- 5. Call search_knowledge_base for any relevant intel.
121
+ 5. Call search with type=kb for any relevant intel.
122
122
 
123
123
  Review the message against what you learned:
124
124
  - Does the message accurately reflect what this prospect's company does?
@@ -126,7 +126,7 @@ Review the message against what you learned:
126
126
  - Is the personalization based on real, verified information?
127
127
  - Are there AI tells, cross-touch duplication, or structural issues?
128
128
 
129
- If the message is good as-is, approve it. If there is clear opportunity to improve (weak personalization when rich signals exist, fabricated references, wrong framing), edit it via edit_message. Do NOT rewrite messages that are already solid just because you can.
129
+ If the message is good as-is, approve it. If there is clear opportunity to improve (weak personalization when rich signals exist, fabricated references, wrong framing), edit it via manage_messages with action=edit. Do NOT rewrite messages that are already solid just because you can.
130
130
 
131
131
  Return a structured summary:
132
132
  MESSAGE: {id} | PERSON: {name} | MATCH_SCORE: {n} | CATEGORY: T{n} | RECOMMENDATION: {approve|edited|flag|reject} | CONFIDENCE: {high|medium|low} | REASONING: {1-2 sentences} | EDITED: {yes/no} | ISSUES_FOUND: {list or "none"} | RESEARCH_SUMMARY: {2-3 sentences on what you found} | PROBLEM_IDENTIFIED: {yes/no/speculative} | REWRITE_REASON: {why you edited, or "n/a"}
@@ -175,7 +175,7 @@ After outreach messages are processed, ask if the user wants to review the engag
175
175
 
176
176
  - **Small queue (5 or fewer total):** skip subagent dispatch, pull `get_outreach_review` directly and review inline.
177
177
  - **Small queue of follow-ups (5 or fewer T2+) with many T1s:** still use subagents for T1 structural review, review follow-ups inline.
178
- - **User wants to review a specific person:** pull that person's conversation with `get_conversation` and review directly. No batch workflow.
178
+ - **User wants to review a specific person:** pull that person's conversation with `fetch` (type=conversation) and review directly. No batch workflow.
179
179
  - **Subagent can't reach MCP tools:** fall back to inline review.
180
- - **Homogeneous T1 pattern:** if the first T1 batch all had the identical issue, fix the remaining in bulk with `bulk_manage_messages`. Confirm first.
180
+ - **Homogeneous T1 pattern:** if the first T1 batch all had the identical issue, fix the remaining in bulk with a single `manage_messages` call passing an id array (same action applied to every id, max 50 per call). Confirm first.
181
181
 
@@ -74,11 +74,11 @@ If personalization is surface level or basic, and you have the tools to go deepe
74
74
 
75
75
  If a message needs better personalization or you need to verify something, you have access to:
76
76
 
77
- - **Knowledge base** (`search_knowledge_base`): Search the company's uploaded sales docs — positioning, case studies, battlecards, objection handling, process docs. Browse without filters first to see what's available, then narrow with `doc_type` or `query`. Use `document_id` or `include_content: true` to read full content. This is your FIRST stop for company-specific messaging guidance, proof points, and competitive positioning.
77
+ - **Knowledge base** (`search` with type=kb): Search the company's uploaded sales docs — positioning, case studies, battlecards, objection handling, process docs. Browse with no extra filters first to see what's available, then narrow with the `doc_type` or `query` filters. Use the `document_id` or `include_content: true` filters to read full content. This is your FIRST stop for company-specific messaging guidance, proof points, and competitive positioning.
78
78
  - **Web search**: Search for recent news about the prospect's company, their recent activity, industry trends relevant to them
79
- - **LinkedIn data** (`fetch_linkedin_data`): Pull the prospect's recent posts if not already in the review data
79
+ - **LinkedIn data** (`research` with action=linkedin_fetch): Pull the prospect's recent posts if not already in the review data
80
80
  - **Obsidian vault** (Read/Grep on `/sessions/amazing-lucid-shannon/mnt/Jon's Neural Net/`): Search for notes on this vertical, company, or prospect. The vault contains pricing frameworks, competitive intel, and vertical playbooks.
81
- - **Company research** (`get_company_research`, `fetch_company_website`): Get deeper company context if the match analysis feels thin
81
+ - **Company research** (`fetch` with type=company_research; `research` with action=enrich_company): Get deeper company context if the match analysis feels thin
82
82
 
83
83
  Use these tools when:
84
84
  - The draft's personalization is surface-level and you can find something better
@@ -91,7 +91,7 @@ Do NOT use these tools for every message. Only when the draft needs improvement
91
91
 
92
92
  ## Step 4: Edit if needed
93
93
 
94
- If the message needs changes, rewrite it and apply the edit using `edit_message` with the message_id and new content.
94
+ If the message needs changes, rewrite it and apply the edit using `manage_messages` with action=edit, the message id, and the new content in the payload.
95
95
 
96
96
  When rewriting:
97
97
  - Keep the same strategic intent (don't change a T2 into a T4)
@@ -156,7 +156,7 @@ RESEARCH_DONE: {list of extra research you did, or "none"}
156
156
 
157
157
  **EDITED** when:
158
158
  - Message had fixable issues (AI tells, weak personalization, minor repetition, wrong CTA format)
159
- - You've applied the fix via edit_message
159
+ - You've applied the fix via manage_messages (action=edit)
160
160
  - The rewritten version passes all checks
161
161
 
162
162
  **FLAG** when:
@@ -30,14 +30,14 @@ All harness source skills produce candidate lists matching this shape exactly. T
30
30
 
31
31
  **Rules:**
32
32
  - At minimum, each candidate needs **either** `linkedin_url` **or** (`name`-fields + `company`). Candidates with neither are skipped at Step 3.
33
- - `full_name` is a convenience for sources that don't pre-split. Engine's Step 7 splits via last-space heuristic (`Jane van der Merwe` → first=`Jane`, last=`van der Merwe`). Multi-token surnames like `Maria Del Carmen Garcia` may split imperfectly — Phase B's `fetch_linkedin_data` returns canonical first/last when `linkedin_url` is present and overrides the heuristic.
33
+ - `full_name` is a convenience for sources that don't pre-split. Engine's Step 7 splits via last-space heuristic (`Jane van der Merwe` → first=`Jane`, last=`van der Merwe`). Multi-token surnames like `Maria Del Carmen Garcia` may split imperfectly — Phase B's linkedin_fetch call (`research` action=linkedin_fetch) returns canonical first/last when `linkedin_url` is present and overrides the heuristic.
34
34
  - Field additions are additive only. Removing a field is a breaking change for source skills.
35
35
 
36
36
  ---
37
37
 
38
38
  ## MCP-availability precheck (load-bearing — runs before Step 3)
39
39
 
40
- Before any other Step 3 work, call `get_research_playbook(segment_id=<id>)`. If this fails with "tool not found" / 404 / connection error, abort the run with this exact message:
40
+ Before any other Step 3 work, call `fetch(type="research_playbook", id=<segment_id>)`. If this fails with "tool not found" / 404 / connection error, abort the run with this exact message:
41
41
 
42
42
  > Vruum MCP not configured as a user-scoped server. Run:
43
43
  >
@@ -47,7 +47,7 @@ Before any other Step 3 work, call `get_research_playbook(segment_id=<id>)`. If
47
47
 
48
48
  This catches the common silent-failure mode: deep-research subagents dispatch, all return `STATUS: failed` because they can't reach MCP, and the operator gets a confusing "0 enrolled, no errors" report. One MCP call upfront vs an hour of debugging.
49
49
 
50
- The `get_research_playbook` call also doubles as the ICP load — capture target_titles, target_industries, value_proposition, positioning_angle, ACV floor, signals_to_watch, exclusions for use in subagent dispatch prompts.
50
+ The research_playbook fetch also doubles as the ICP load — capture target_titles, target_industries, value_proposition, positioning_angle, ACV floor, signals_to_watch, exclusions for use in subagent dispatch prompts.
51
51
 
52
52
  ---
53
53
 
@@ -56,8 +56,8 @@ The `get_research_playbook` call also doubles as the ICP load — capture target
56
56
  Per segment's candidate list:
57
57
 
58
58
  1. **MCP precheck + ICP load** (above) — abort run on failure.
59
- 2. **Batch dedup against existing pipeline.** Call `batch_search_existing_people(queries=[{name, company, linkedin_url} for each candidate])`. Returns one match record per candidate (in input order). Drop candidates with non-null `match` — they're already in pipeline.
60
- 3. **Batch company-cache check.** Collect unique company domains from surviving candidates. Call `batch_get_company_research(domains=[...])`. Returns `[{domain, cached_research, age_days}]`.
59
+ 2. **Batch dedup against existing pipeline.** Call `search(type="people", query=[{name, company, linkedin_url} for each candidate])`. Returns one match record per candidate (in input order). Drop candidates with non-null `match` — they're already in pipeline.
60
+ 3. **Batch company-cache check.** Collect unique company domains from surviving candidates. Call `fetch(type="company_research", id=[the domains])`. Returns `[{domain, cached_research, age_days}]`.
61
61
  - Cache hit (`cached_research != null` AND `age_days <= 90`) → company skips Phase A; the cached research carries forward.
62
62
  - Cache miss or stale (`age_days > 90`) → company joins the Phase A research queue.
63
63
  4. **Operator confirmation gate (CSV / large lists only).** If the original candidate list was >200 (CSV) or >100 (manual list), confirm count to process before continuing.
@@ -68,7 +68,7 @@ Per segment's candidate list:
68
68
 
69
69
  ## Step 4 — Phase A: company research
70
70
 
71
- **Concurrency cap: 10 parallel.** Phase A subagents don't call `fetch_linkedin_data` — they hit `get_company_research`, `fetch_company_website`, `WebFetch`, `WebSearch`. No Unipile rate-limit pressure.
71
+ **Concurrency cap: 10 parallel.** Phase A subagents don't call `research` with action=linkedin_fetch — they hit `fetch` (type=company_research), `research` (action=enrich_company), `WebFetch`, `WebSearch`. No Unipile rate-limit pressure.
72
72
 
73
73
  Dispatch one `vruum-company-deep-researcher` per unique uncached company. Subagent file at `.claude/agents/vruum-company-deep-researcher.md` defines the workflow + tools.
74
74
 
@@ -79,7 +79,7 @@ You are vruum-company-deep-researcher. Research this company against segment "{s
79
79
 
80
80
  company_name: {name}
81
81
  domain: {domain}
82
- segment_icp_summary: {one paragraph from get_research_playbook}
82
+ segment_icp_summary: {one paragraph from the research_playbook fetch}
83
83
  acv_floor: {dollars or default $10K}
84
84
 
85
85
  Run your workflow (a–i) and return the structured output block.
@@ -89,7 +89,7 @@ Each subagent returns: `company_id`, `funding_data`, `growth_metrics`, `current_
89
89
 
90
90
  **Wait for the wave to complete before Phase B.** Phase B inputs depend on Phase A's signals (or null if failed).
91
91
 
92
- **Subagent timeout cascade (load-bearing):** when STATUS=failed for a company, the orchestrator does NOT skip the prospects from that company. Phase B still runs for them with `null` company signals. The harness pre-filter gate then tags them `harness_gate_status: gate_inconclusive` (a fourth status alongside pass/warming/low_priority/dismiss). `save_discovered_person` is still called — the backend's `MatchAnalysisAgent` may have cached company research from earlier runs and gates them appropriately. Surface gate-inconclusive prospects in the final report so the operator can re-run the failed companies later.
92
+ **Subagent timeout cascade (load-bearing):** when STATUS=failed for a company, the orchestrator does NOT skip the prospects from that company. Phase B still runs for them with `null` company signals. The harness pre-filter gate then tags them `harness_gate_status: gate_inconclusive` (a fourth status alongside pass/warming/low_priority/dismiss). `manage_person` action=save_discovered is still called — the backend's `MatchAnalysisAgent` may have cached company research from earlier runs and gates them appropriately. Surface gate-inconclusive prospects in the final report so the operator can re-run the failed companies later.
93
93
 
94
94
  **Inter-wave progress line.** After each wave (5–10 subagents):
95
95
  ```
@@ -101,7 +101,7 @@ Helps operators distinguish "still working" from "stuck."
101
101
 
102
102
  ## Step 5 — Phase B: prospect research
103
103
 
104
- **Concurrency cap: 5 parallel** (lowered from Phase A's 10 because Phase B subagents call `fetch_linkedin_data` and the Unipile rate limiter throws over cap — see `backend/app/domains/channels/services/unipile/rate_limiter.py:36`. Lower concurrency keeps us under the per-account window.)
104
+ **Concurrency cap: 5 parallel** (lowered from Phase A's 10 because Phase B subagents call `research` action=linkedin_fetch and the Unipile rate limiter throws over cap — see `backend/app/domains/channels/services/unipile/rate_limiter.py:36`. Lower concurrency keeps us under the per-account window.)
105
105
 
106
106
  Dispatch one `vruum-prospect-deep-researcher` per surviving candidate. Subagent file at `.claude/agents/vruum-prospect-deep-researcher.md`.
107
107
 
@@ -122,10 +122,10 @@ phase_a_signals:
122
122
  outbound_motion_score: {0|1|2 or null}
123
123
  triggers: [list or null]
124
124
 
125
- segment_icp_summary: {one paragraph from get_research_playbook}
125
+ segment_icp_summary: {one paragraph from the research_playbook fetch}
126
126
  acv_floor: {dollars}
127
127
 
128
- Run your workflow (a–k) and return the structured output block. Note: do NOT call save_discovered_person or start_outreach — those are orchestrator-only and not in your tools list.
128
+ Run your workflow (a–k) and return the structured output block. Note: do NOT call manage_person action=save_discovered or manage_outreach action=start — those are orchestrator-only and not in your tools list.
129
129
  ```
130
130
 
131
131
  Each subagent returns: `topics_of_interest`, `recent_posts`, `opening_hooks[]` (2–3, with source URLs), `decision_maker_level` (junior/mid/senior), `email_status` (found/pending), `role_start_date`, per-prospect `triggers[]`, `STATUS`. Note: `person_id` is NOT returned here — identity resolution happens in Step 7.
@@ -139,17 +139,17 @@ Each subagent returns: `topics_of_interest`, `recent_posts`, `opening_hooks[]` (
139
139
 
140
140
  ## Step 6 — Harness pre-filter gate (orchestrator-side, pre-save)
141
141
 
142
- This is a **coarse pre-filter** — its job is to avoid wasted backend `save_discovered_person` calls on obvious dismisses. The **authoritative** gate is server-side `MatchAnalysisAgent.match_score >= 70` and runs inside `save_discovered_person`. The harness gate cannot override the backend gate; it can only dismiss before reaching it.
142
+ This is a **coarse pre-filter** — its job is to avoid wasted backend save calls (`manage_person` action=save_discovered) on obvious dismisses. The **authoritative** gate is server-side `MatchAnalysisAgent.match_score >= 70` and runs inside that save call. The harness gate cannot override the backend gate; it can only dismiss before reaching it.
143
143
 
144
144
  Per surviving prospect, evaluate four criteria using the segment's playbook ICP and the Phase A + Phase B signals:
145
145
 
146
146
  ### 1. ACV class meets segment threshold?
147
147
  - `acv_class >= acv_floor_class` → pass this criterion (smb=$5K, mid=$5–50K, ent=$50K+; segment's `acv_floor` from playbook maps to a class)
148
- - If no → dismiss `acv_too_low`. Don't call `save_discovered_person`.
148
+ - If no → dismiss `acv_too_low`. Don't call `manage_person` action=save_discovered.
149
149
 
150
150
  ### 2. Outbound motion or hiring signal?
151
151
  - `outbound_motion_score > 0` OR explicit hiring trigger present → pass
152
- - If no → flag `warming_candidate` (still call `save_discovered_person` — operator may want to warm-track them; backend match analysis tells us if the segment fit is real)
152
+ - If no → flag `warming_candidate` (still call `manage_person` action=save_discovered — operator may want to warm-track them; backend match analysis tells us if the segment fit is real)
153
153
 
154
154
  ### 3. Decision-maker level senior?
155
155
  - `decision_maker_level == senior` → pass
@@ -158,7 +158,7 @@ Per surviving prospect, evaluate four criteria using the segment's playbook ICP
158
158
 
159
159
  ### 4. Trigger event in last 90d?
160
160
  - 1+ trigger from Phase A (`funding`, `exec_hire`, `launch`, `m_and_a`, `partnership`) OR Phase B (`new_role`, `topical_post`, `press_mention`, `promotion`) → pass
161
- - If no → flag `low_priority` (still call `save_discovered_person`)
161
+ - If no → flag `low_priority` (still call `manage_person` action=save_discovered)
162
162
 
163
163
  ### Tag each prospect:
164
164
  - `harness_gate_status: pass` — all four criteria passed
@@ -178,43 +178,46 @@ For non-dismiss outcomes, also set `dismiss_reason` to null and `flag` to the re
178
178
  Per surviving prospect:
179
179
 
180
180
  ### a. Save company research (once per company)
181
- If the prospect's company isn't already cached and Phase A produced fresh research, call `save_company_research(company_name=..., domain=..., funding_data=..., growth_metrics=..., current_priorities=...)`. Skip if `CACHE_HIT: true` for that company.
181
+ If the prospect's company isn't already cached and Phase A produced fresh research, call `research(action="save_company", payload={company_name, domain, funding_data, growth_metrics, current_priorities})`. Skip if `CACHE_HIT: true` for that company.
182
182
 
183
183
  ### b. Identity resolution + person research (load-bearing — corrects Codex Finding #6)
184
184
 
185
- `save_person_research` requires `first_name` + `last_name`, NOT `name`. `save_discovered_person` requires `person_id` from a prior save step. So Step 7 is a 2-step backend dance:
185
+ `research` action=save_person requires `first_name` + `last_name` in the payload, NOT `name`. `manage_person` action=save_discovered requires `person_id` from a prior save step. So Step 7 is a 2-step backend dance:
186
186
 
187
187
  1. **Split full_name** if `first_name`/`last_name` aren't already set:
188
188
  - Last-space heuristic: split on the last space. `Jane Smith` → first=`Jane`, last=`Smith`. `Jane van der Merwe` → first=`Jane`, last=`van der Merwe`.
189
- - **Override with Phase B canonical names** if `fetch_linkedin_data` returned them. LinkedIn's `first_name`/`last_name` fields are authoritative; the heuristic is a fallback for candidates without `linkedin_url`.
189
+ - **Override with Phase B canonical names** if the linkedin_fetch research call returned them. LinkedIn's `first_name`/`last_name` fields are authoritative; the heuristic is a fallback for candidates without `linkedin_url`.
190
190
 
191
- 2. **Call `save_person_research(...)`.** The backend now requires you to identify the company unambiguously — pick ONE of these two paths:
191
+ 2. **Call `research` with action=save_person.** The backend now requires you to identify the company unambiguously — pick ONE of these two paths:
192
192
 
193
- **Path A (preferred): pass `company_id`.** Run `save_company_research(...)` first, capture the returned `company_id`, then pass it here.
193
+ **Path A (preferred): pass `company_id`.** Run the save_company call (`research` action=save_company) first, capture the returned `company_id`, then pass it in the payload here.
194
194
 
195
- **Path B (when Path A isn't done yet): pass `company_name` + at least one anchor.** Required anchors are any of `company_domain`, `company_website`, or `company_linkedin_url`. The data is in the LinkedIn payload you already fetched. The prospect's CURRENT employer is the entry in `work_experience[]` with `end_date: null` — that entry has `company_linkedin_url` (e.g. `https://linkedin.com/company/microsoft`). If you ran `fetch_linkedin_data(include_company=true)`, the separate company response carries `website` and `industry`. Domain can be derived from website (e.g. `microsoft.com` from `https://microsoft.com`) or from the prospect's verified work email.
195
+ **Path B (when Path A isn't done yet): pass `company_name` + at least one anchor.** Required anchors are any of `company_domain`, `company_website`, or `company_linkedin_url`. The data is in the LinkedIn payload you already fetched. The prospect's CURRENT employer is the entry in `work_experience[]` with `end_date: null` — that entry has `company_linkedin_url` (e.g. `https://linkedin.com/company/microsoft`). If you ran linkedin_fetch with `include_company: true` in the payload, the separate company response carries `website` and `industry`. Domain can be derived from website (e.g. `microsoft.com` from `https://microsoft.com`) or from the prospect's verified work email.
196
196
 
197
197
  **Anchor-less name-only saves are rejected with HTTP 422.** This was hardened to stop orphan stub creation in the companies table — name-only saves were silently producing duplicate rows for common names like Microsoft.
198
198
 
199
199
  Example call:
200
200
  ```
201
- save_person_research(
202
- first_name=..., last_name=...,
203
- email=..., linkedin_url=...,
204
- # ONE of:
205
- company_id=<from save_company_research>
206
- # OR:
207
- company_name=..., company_linkedin_url=..., # at least one anchor
208
- # ...rest of research fields
201
+ research(
202
+ action="save_person",
203
+ payload={
204
+ first_name=..., last_name=...,
205
+ email=..., linkedin_url=...,
206
+ # ONE of:
207
+ company_id=<from the save_company call>
208
+ # OR:
209
+ company_name=..., company_linkedin_url=..., # at least one anchor
210
+ # ...rest of research fields
211
+ }
209
212
  )
210
213
  ```
211
214
 
212
- - If the prospect already had `person_id` set on the candidate (e.g. operator pasted a Vruum person UUID), pass it explicitly: `save_person_research(person_id=..., ...)` — backend updates rather than creating a new record.
215
+ - If the prospect already had `person_id` set on the candidate (e.g. operator pasted a Vruum person UUID), pass it explicitly in the payload: `research(action="save_person", payload={person_id: ..., ...})` — backend updates rather than creating a new record.
213
216
  - The response includes the `person_id`. Capture it for step c.
214
217
 
215
218
  ### c. Save discovered person (the backend authoritative gate runs here)
216
219
 
217
- Call `save_discovered_person(person_id=<from b>, segment_id=...)`. This:
220
+ Call `manage_person(action="save_discovered", payload={person_id: <from b>, segment_id: ...})`. This:
218
221
  - Runs server-side `analyze_person_match` + signal eval
219
222
  - Returns `match_score` (0–100) and `quality_gate_pass` (bool, true iff `match_score >= 70`)
220
223
  - Writes the `company_people` row that puts the prospect into the segment
@@ -225,7 +228,7 @@ Call `save_discovered_person(person_id=<from b>, segment_id=...)`. This:
225
228
 
226
229
  ### d. Bulk enrollment (only after all prospects saved)
227
230
 
228
- Collect all `person_id`s where `harness_gate_status == pass` AND backend `quality_gate_pass == true` AND `mode == save-and-enroll`. Then call `bulk_start_outreach(person_ids=[...], segment_id=...)` ONCE at the end of Step 7.
231
+ Collect all `person_id`s where `harness_gate_status == pass` AND backend `quality_gate_pass == true` AND `mode == save-and-enroll`. Then call `manage_outreach(action="start", id=[those person_ids], payload={segment_id: ...})` ONCE at the end of Step 7.
229
232
 
230
233
  - Per-prospect outcomes are returned (enrolled | skipped | failed). Surface per-prospect failures in the report.
231
234
  - If `harness_gate_status` is `warming` or `low_priority`, exclude from the bulk enroll list. Operator decides on review.
@@ -285,7 +288,7 @@ For multi-segment runs, group the report by segment and include a totals summary
285
288
  - **Manual-list cap** — if >100 lines pasted, orchestrator asks "{N} prospects pasted — process all, or first M? (a/N)".
286
289
  - **CSV >200 rows** — same prompt at Step 5 of csv-pipeline-fill.
287
290
  - **LinkedIn rate-limit (Unipile 429)** — Phase B subagent dismisses with `linkedin_data_unavailable`; orchestrator surfaces in report; operator reruns later.
288
- - **Unicode multi-token surnames** — `Maria Del Carmen Garcia`: heuristic splits to first=`Maria Del Carmen`, last=`Garcia` (last space wins). When `linkedin_url` is present, `fetch_linkedin_data` overrides with canonical names. Imperfect for candidates without LinkedIn URL — operator can edit via `update_person_contact` post-import.
291
+ - **Unicode multi-token surnames** — `Maria Del Carmen Garcia`: heuristic splits to first=`Maria Del Carmen`, last=`Garcia` (last space wins). When `linkedin_url` is present, the linkedin_fetch research call overrides with canonical names. Imperfect for candidates without LinkedIn URL — operator can edit via `manage_person` action=update_contact post-import.
289
292
 
290
293
  ---
291
294