@vruum/skills 0.6.28 → 0.6.30

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vruum",
3
- "version": "0.6.28",
3
+ "version": "0.6.30",
4
4
  "description": "Vruum AI skills + remote MCP server for B2B GTM teams. Slash commands for outreach triage, engagement triage, pipeline filling, prospect enrichment, and reply diagnosis, paired with the full Vruum MCP tool surface over OAuth 2.1.",
5
5
  "author": {
6
6
  "name": "Vruum AI",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vruum",
3
- "version": "0.6.28",
3
+ "version": "0.6.30",
4
4
  "description": "Vruum AI skills + remote MCP server for B2B GTM teams. Skills for outreach triage, engagement triage, pipeline filling, prospect enrichment, and reply diagnosis, paired with the full Vruum MCP tool surface over OAuth 2.1.",
5
5
  "author": {
6
6
  "name": "Vruum AI",
@@ -100,9 +100,9 @@ Use these tools when:
100
100
  - You're rewriting a message and need a real, specific hook
101
101
  - The prospect's LinkedIn posts field is null and you want to find recent activity
102
102
 
103
- ### MANDATORY web search for follow-ups (T2+)
103
+ ### MANDATORY web search for follow-ups and fallback openers
104
104
 
105
- For ANY follow-up message (sequence_number >= 2), web search is REQUIRED before making a decision. Cached person/company research payloads (`fetch` type=person_research / type=company_research) are often weeks or months old and miss recent signals (acquisitions, role changes, new posts, new reqs, funding, layoffs). You MUST run at least one WebSearch query on the prospect + company before approving or editing a T2+ message.
105
+ For ANY message that is a real follow-up (conversation_state.first_content_touch=false; when the block is absent, sequence_number >= 2) OR a first content touch riding a higher T-number (first_content_touch=true with sequence_number >= 2 — e.g. an email that fell back from a never-accepted LinkedIn connect; these need full T1-style opener research, not dedup review), web search is REQUIRED before making a decision. Cached person/company research payloads (`fetch` type=person_research / type=company_research) are often weeks or months old and miss recent signals (acquisitions, role changes, new posts, new reqs, funding, layoffs). You MUST run at least one WebSearch query on the prospect + company before approving or editing a T2+ message.
106
106
 
107
107
  What to search for:
108
108
  - "{Person Name} {Company}" — surfaces recent LinkedIn posts, interviews, podcast appearances
@@ -111,15 +111,16 @@ What to search for:
111
111
 
112
112
  If web search surfaces nothing useful, note that in RESEARCH_DONE ("web search: no material new signal") so the operator knows you checked. Never skip the search and claim cached context was sufficient.
113
113
 
114
- Exception: T1 initials (blank connection requests or first-touch sends) don't require web search. Fit-rejection calls (where the prospect obviously doesn't match ICP from cached data) don't require web search — but state that explicitly in REASONING.
114
+ Exception: T1 initials (blank connection requests or first-touch sends at sequence_number = 1) don't require web search. Fit-rejection calls (where the prospect obviously doesn't match ICP from cached data) don't require web search — but state that explicitly in REASONING.
115
115
 
116
116
  For T1 structural reviews and other cases, the existing "use when needed" rule applies.
117
117
 
118
118
  ## Step 4: Edit if needed
119
119
 
120
- If the message needs changes, rewrite it, check the rewrite with `check_prose` (`{item_id: <message id>, item_type: "message", content: <rewrite>}` — the `failures[]` are an advisory checklist to consider, not a pass/fail loop: fix what you agree with; a severity `block` channel character cap is the one hard stop, cut to fit), then apply the edit using `manage_messages` with action=edit, the message id, and payload={subject?, content, client_rules_version: <rules_version from check_prose>}. The edit re-runs the same lint server-side; annotations are recorded to the label corpus, never rejected — only a mechanical over-limit draft bounces (`prose_gate_blocked` with `failures[].fix`).
120
+ If the message needs changes, rewrite it, check the rewrite with `check_prose` (`{item_id: <message id>, item_type: "message", content: <rewrite>}` — the `failures[]` are an advisory checklist to consider, not a pass/fail loop: fix what you agree with; severity `block` failures are hard stops channel character caps (cut to fit) and prior-outreach references on a first content touch (rewrite as an opener; the failure `span` names the offending text)), then apply the edit using `manage_messages` with action=edit, the message id, and payload={subject?, content, client_rules_version: <rules_version from check_prose>}. The edit re-runs the same lint server-side; annotations are recorded to the label corpus, never rejected — a `block`-severity draft bounces (`prose_gate_blocked` with `failures[].fix`).
121
121
 
122
122
  When rewriting:
123
+ - Frame by conversation state: first_content_touch=true → OPENER (never reference prior outreach); prior_context='note_only' → don't repeat the connection_note_text; prior_context='inbound_only' → continue THEIR conversation; channel_fallback=true → the copy must stand alone on the item's actual channel. Block absent → T-number heuristic. If prior contact happened outside Vruum, flag for the operator instead of resubmitting.
123
124
  - Keep the same strategic intent (don't change a T2 into a T4)
124
125
  - Follow the segment tone instructions exactly
125
126
  - Stay within word/character limits
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vruum/skills",
3
- "version": "0.6.28",
3
+ "version": "0.6.30",
4
4
  "description": "Vruum AI skills for Claude Code, Claude Desktop, Codex CLI, and any AI assistant with a skill directory. Slash commands for outreach triage, engagement triage, pipeline filling, prospect enrichment, and reply diagnosis. Pairs with the Vruum MCP server at https://api.vruum.ai/mcp.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,5 +42,5 @@
42
42
  "outreach",
43
43
  "gtm"
44
44
  ],
45
- "contentHash": "a5a5c3b6c7298d90fbff6995414b71e61d51f0e18167545f43768f37d3eda5da"
45
+ "contentHash": "edb317856c2825036b1d3d8017b14b908386a977b3d0f05efc61e0db0cee7762"
46
46
  }
@@ -39,7 +39,7 @@ Hold the resolved `user_id` (or the decision to omit it) and use it consistently
39
39
 
40
40
  Call `fetch` with `type="seller_signals"`, passing the rough topic as `draft_brief` in `filters`. **If you resolved an author `user_id` in Step 2, pass it as the `id` argument** so the signal is *that person's*, not the caller's. (Omit `id` only in the legacy-fallback case from Step 2.) The brief drives a semantic re-rank, so the more concrete the topic, the more relevant the returned evidence.
41
41
 
42
- **Trust boundary — handle 403 honestly.** Authoring as another person is permission-guarded server-side (a non-owner can't author as a teammate). If this call (or the draft call in Step 5) returns **403**, STOP: tell the operator plainly that they aren't entitled to author as that person, and ask them to pick a permitted author or have an owner do it. Do **not** retry with the author param omitted — that would silently fall back to caller/company-scoped generation under a different identity than was asked for.
42
+ **Trust boundary — handle 403 honestly.** Authoring as another person is permission-guarded server-side (a non-owner can't author as a teammate). If this call (or the draft call in Step 5) returns **403**, STOP: tell the operator plainly that they aren't entitled to author as that person, and ask them to pick a permitted author or have an owner do it. Do **not** retry with the author param omitted — that would silently fall back to caller/company-scoped signal under a different identity than was asked for.
43
43
 
44
44
  **Ground on `formatted_evidence`.** It is the EVIDENCE-wrapped rendering prepared for drafting — the surface you should read, quote, and reason over. The backend scrubs prospect names, emails, phone numbers, and URLs out of *everything* it returns (both `formatted_evidence` and the raw `bundle` text), so you don't have to police that yourself — there is no un-redacted surface on the response. Still prefer `formatted_evidence`: it is the prepared, sectioned grounding surface, where `bundle` is just the structured raw material behind it.
45
45
 
@@ -72,23 +72,31 @@ Which of these do you want to anchor the post on? And what's the angle — a les
72
72
 
73
73
  Let the seller refine. Iterate in conversation until the brief is **settled** — you and the seller agree on the angle, the anchor evidence, and the tone. Do not generate a draft while the brief is still moving.
74
74
 
75
- ## Step 5: Draft on-voice — once
75
+ ## Step 5: Author on-voice in the harness then save once
76
76
 
77
- **Only once the brief is settled**, call `manage_content` with `action="draft_post"`, passing the settled brief as `topic` in the payload. **If you resolved an author `user_id` in Step 2, pass the same value as `author_user_id` in the payload** so the draft is written from that person's voice/signal and the draft row is stamped with their `author_user_id` (this is what later carries the author through schedule/publish). Pass the *same* `user_id` you used for the signal pull — don't let signal and draft disagree. Omit `author_user_id` only in the legacy-fallback case. The same **403** trust-boundary rule from Step 3 applies here: on 403, STOP and ask for a permitted author — never retry with the param omitted.
77
+ **The backend never writes content prose (VRU-676, permanently).** There is no post generator behind `manage_content` `action="draft_post"` only returns authoring guidance. YOU author the post, here in the conversation, and this is a blank page, not a rewrite:
78
78
 
79
- The backend writes the draft in the seller's voice and grounds it on their seller signal internally — you do not pass the evidence yourself.
79
+ **Only once the brief is settled**, write the post yourself in the seller's voice:
80
80
 
81
- The `draft_post` action creates a new draft row every time it runs. Call it **once** per post. Show the seller the returned draft.
81
+ - Ground it on the anchor evidence the seller chose from `formatted_evidence` in Step 4 (or the voice profile alone in the profile-only fallback). Quote their real signal; never invent specifics.
82
+ - Write like the seller talks — their phrasing, their stance from the settled brief — not like a content bot. The angle and tone you agreed in Step 4 are the spec.
83
+ - Keep it inside LinkedIn's 3000-char cap.
82
84
 
83
- ## Step 6: Iterate by editingnever regenerate
85
+ **Pre-check before saving.** Run the draft through `check_prose` with `{surface: "content_post", content: <post>}` and treat the `failures[]` as an advisory checklist: fix what you agree with, keep what the seller deliberately wants the annotations are hypotheses recorded for learning, not a pass/fail loop, and the seller's voice wins. Hold on to the returned `rules_version`.
84
86
 
85
- When the seller wants changes (tighten the hook, change the CTA, fix a line), revise the **existing** draft with `manage_content` using `action="edit"`, passing the updated `content`.
87
+ Then save it **once** with `manage_content` `action="draft"`, payload `{content, topic_tags?, client_rules_version?}`. **If you resolved an author `user_id` in Step 2, pass the same value as `author_user_id` in the payload** so the draft row is stamped with their `author_user_id` (this is what later carries the author through schedule/publish). Pass the *same* `user_id` you used for the signal pull — don't let signal and draft disagree. Omit `author_user_id` only in the legacy-fallback case. The same **403** trust-boundary rule from Step 3 applies here: on 403, STOP and ask for a permitted author — never retry with the param omitted.
86
88
 
87
- **Check every revision before submitting it.** Run the revised text through `check_prose` with `{surface: "content_post", content: <revised post>}` first, and treat the `failures[]` as an advisory checklist: fix what you agree with, keep what the seller deliberately wants — the annotations are hypotheses recorded for learning, not a pass/fail loop, and the seller's voice wins. Pass the returned `rules_version` as `client_rules_version` on the edit. `manage_content` create and edit re-run the same deterministic lint server-side; annotations are recorded, never rejected. The one hard constraint is mechanical: keep the post inside LinkedIn's 3000-char cap, because an over-cap post fails at publish time.
89
+ `action="draft"` creates a new draft row every time it runs. Save **once** per post, then show the seller the saved draft and iterate by editing.
88
90
 
89
- Never call `manage_content` with `action="draft_post"` again for a revision that spawns a duplicate draft row and loses the thread. One post = one draft row, edited in place.
91
+ ## Step 6: Iterate by editingnever re-save
90
92
 
91
- `manage_content` operates on the existing draft row, which already carries the `author_user_id` you stamped at generation. You do **not** re-pass the author here schedule/publish inherit it from the row.
93
+ When the seller wants changes (tighten the hook, change the CTA, fix a line), revise the text yourself and update the **existing** draft with `manage_content` using `action="edit"`, passing the updated `content`.
94
+
95
+ **Check every revision before submitting it**, the same way as the initial draft: run the revised text through `check_prose` with `{surface: "content_post", content: <revised post>}`, weigh the `failures[]` as advisory, and pass the returned `rules_version` as `client_rules_version` on the edit. `manage_content` draft and edit re-run the same deterministic lint server-side; annotations are recorded, never rejected. The one hard constraint is mechanical: keep the post inside LinkedIn's 3000-char cap, because an over-cap post fails at publish time.
96
+
97
+ Never call `manage_content` with `action="draft"` again for a revision — that spawns a duplicate draft row and loses the thread. One post = one draft row, edited in place.
98
+
99
+ `manage_content` operates on the existing draft row, which already carries the `author_user_id` you stamped at save time. You do **not** re-pass the author here — schedule/publish inherit it from the row.
92
100
 
93
101
  ## Step 7: Save — draft, schedule, or publish
94
102
 
@@ -106,7 +114,7 @@ When the seller is happy with the draft, ask how they want to land it. **Default
106
114
  Because that hard failure lands at publish time — which for a scheduled post can be minutes or hours after you draft it — surface it **early** rather than letting the operator discover a dead, `failed` post later. So before you schedule or publish a post you authored as a specific person, **call the channel-status `fetch` (type=settings, subtype=channel_status) again — fresh, right now, immediately before the schedule/publish call.** Do **not** trust the Step 2 snapshot: an account can disconnect, change `status`, or exhaust its `quota` during drafting and refinement. Re-read `channels.linkedin_accounts` from this *new* response and find the author's account by the `user_id` you stamped on the draft, then:
107
115
 
108
116
  - If their account is present, `connected`, `status` is healthy, and `quota` is not exhausted **in the fresh response** → proceed with schedule/publish as normal (still behind the explicit "publish now" confirmation above).
109
- - If their account is **missing, not `connected`, shows a bad `status`, or has an exhausted `quota` in the fresh response** (or the fresh channel-status `fetch` call fails / omits `linkedin_accounts`, so you can't confirm the author's account is healthy) → **STOP. Do not schedule or publish.** The backend would reject this author-scoped publish as `Author account unavailable` anyway; tell the operator plainly so they don't end up with a `failed` post. Offer the safe paths: keep it as a draft, reschedule for after that person's account is reconnected / their quota resets, or pick a different permitted author and regenerate. There is no "publish under a different identity" escape hatch for an author-scoped post — the server will not do it; to post from another account the operator must deliberately regenerate the draft under that author (or with no author).
117
+ - If their account is **missing, not `connected`, shows a bad `status`, or has an exhausted `quota` in the fresh response** (or the fresh channel-status `fetch` call fails / omits `linkedin_accounts`, so you can't confirm the author's account is healthy) → **STOP. Do not schedule or publish.** The backend would reject this author-scoped publish as `Author account unavailable` anyway; tell the operator plainly so they don't end up with a `failed` post. Offer the safe paths: keep it as a draft, reschedule for after that person's account is reconnected / their quota resets, or pick a different permitted author and save a fresh draft as them. There is no "publish under a different identity" escape hatch for an author-scoped post — the server will not do it; to post from another account the operator must deliberately re-author and save the draft under that author (or with no author).
110
118
 
111
119
  For a legacy / no-author post (you omitted `author_user_id` in Step 2) there is no specific author identity to protect, so the standard publish confirmation above is sufficient.
112
120
 
@@ -114,7 +122,7 @@ If the seller asks to reschedule a post that is **already scheduled**, be aware
114
122
 
115
123
  ## Notes
116
124
 
117
- - Stay conversational. The value of this skill is the steer→draft→schedule loop, not a single generated blob. Surface evidence, let the seller choose the angle, and draft only when the brief is settled.
125
+ - Stay conversational. The value of this skill is the steer→author→schedule loop, not a one-shot blob. Surface evidence, let the seller choose the angle, and author only when the brief is settled.
118
126
  - The seller's voice is the product. Ground the post in their real signal (`formatted_evidence`) whenever it's available; only fall back to a profile-only draft when there's genuinely no evidence to draw on.
119
127
  - Be honest about gaps. If there was no signal to retrieve, say "I didn't find recent signal on this — here's a draft from your voice profile" rather than inventing specifics.
120
128
  - This skill never deletes posts. If the seller wants to discard a draft, point them to `/marketing/content` rather than removing rows on their behalf.
@@ -30,21 +30,41 @@ For small queues (5 or fewer) or when subagents can't access MCP, review directl
30
30
 
31
31
  Call `fetch` with type=stats and subtype=outreach to see the pending queue shape. The response carries `needs_draft_count` (unauthored touches awaiting authoring) alongside `draft_count` (authored, awaiting approval) — surface both so the authoring backlog is visible up front. Present a quick summary:
32
32
 
33
- "You have N to author (needs_draft), 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?"
33
+ "You have N to author (needs_draft, W of them WARM accepted-connection follow-ups), 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
+
35
+ **Warm follow-ups outrank everything except replies.** The stats payload carries `needs_draft_warm_count`: unauthored `linkedin_message` touches to people who ACCEPTED the connection request. These are the highest-EV rows in the queue — a person who said yes to the invite is waiting on a first real message. When non-zero, lead the summary with it and default the triage order to: inbound replies → warm follow-ups → everything else.
34
36
 
35
37
  Keep it short. The user knows their queue — they just need the numbers to decide what to prioritize.
36
38
 
39
+ **needs_draft items EXPIRE.** A nightly backend sweep (03:20 UTC) rejects any `needs_draft` row older than 14 days from creation — intended garbage collection, not an operator action. An expired touch is not lost forever (the plan reschedules and mints a fresh row about a week later), but the authoring work is deferred a cycle and the queue silently shrinks. The tools tell you: the stats payload carries `needs_draft_expiring_soon_count` (rows within 3 days of the sweep — surface it in the summary when non-zero), and each needs_draft item carries `expires_at` (its sweep deadline). Consequence for triage: author oldest-first, and if the queue is too big to clear in one session, clear the items closest to `expires_at` rather than sampling the freshest.
40
+
37
41
  ### Step 2: Build the dispatch list and categorize
38
42
 
39
- Once the user says go (or picks a focus area), pull the lightweight message queue via `search` with type=messages, `fields=compact` and limit=100 — make TWO cheap calls: `status=needs_draft` (the authoring lane) and `status=draft` (the review lane). `fields=compact` returns message_id, person_name, category, sequence_number, channel, status, match_score, touches_completed, and campaign_id WITHOUT message content — very cheap on tokens. Tag each item with its status so dispatch routes it to the right mode: `needs_draft` → authoring, `draft` → review. (Omitting the status filter returns the default actionable set — needs_draft + draft + approved — but pull the two lanes explicitly so already-approved messages awaiting send don't enter triage.)
43
+ Once the user says go (or picks a focus area), pull the lightweight message queue via `search` with type=messages, `fields=compact` and limit=100 — make THREE cheap calls: `warm_only=true, status=needs_draft, sort_by=expiring` (the WARM authoring lane — LinkedIn follow-ups to accepted connections, nearest deadline first), `status=needs_draft` (the full authoring lane; warm rows appear here too — dedupe by message_id, warm lane wins), and `status=draft` (the review lane). `fields=compact` returns message_id, person_name, category, sequence_number, channel, status, match_score, touches_completed, campaign_id, first_content_touch, and channel_rewrite_reason WITHOUT message content — very cheap on tokens. Tag each item with its status so dispatch routes it to the right mode: `needs_draft` → authoring, `draft` → review. (Omitting the status filter returns the default actionable set — needs_draft + draft + approved — but pull the two lanes explicitly so already-approved messages awaiting send don't enter triage.)
44
+
45
+ **Frame by conversation state, not sequence number.** `first_content_touch=true` means this person has NEVER received a content-bearing message — their history is only connection requests (blank or noted). That item is an OPENER no matter its T-number (a T3 email that fell back from a never-accepted LinkedIn connect is still the first thing this person reads from the sender). `first_content_touch=false` means real prior contact exists — follow-up framing is legitimate. If the field is null/absent (rows drafted before this signal shipped, or an older backend), fall back to the sequence_number heuristic: seq 1 = opener, seq ≥2 = follow-up. The full block lives on the `get_outreach_review` item as `conversation_state` (`{v, first_content_touch, prior_context: none|note_only|inbound_only|content, connection_note_text, last_delivered_channel, designed_channel, channel_rewrite_reason, channel_fallback}`) — same absent-block fallback applies when `v` is unrecognized.
46
+
47
+ **Authoring mode (needs_draft items).** The backend no longer writes outreach prose — touches arrive as `needs_draft` items carrying the decision context (channel, touch number, signals) and no content. These are not rewrites; they are blank pages. For each needs_draft item the subagent AUTHORS the message: check the person's research freshness from the review item itself — `person_researched_at` / `company_researched_at` / `research_status` are on the payload, no extra fetch needed (older than ~14 days or missing → research first with WebSearch + the research reads, and persist what you learn via `research` action=save_person, plus action=save_company when you learned something about the company, so it compounds), then write the touch from scratch in the seller's voice against the same quality standards as any review, then check it — call `check_prose` with `{item_id: <message id>, item_type: "message", content: <draft>}` and treat the `failures[]` as an advisory checklist: fix what you agree with; severity `block` failures are hard stops that bounce at post time — today: channel character caps (cut to fit) and prior-outreach references on a first content touch (rewrite as an opener) — then submit it via `manage_messages` action=edit with the content and the returned `rules_version` as `client_rules_version` — that transitions the item to a normal draft — and approve only what the user's standing instructions allow. (The edit re-runs the same lint server-side; annotations are recorded to the label corpus, never rejected — a `block`-severity draft bounces with `prose_gate_blocked` + `failures[].fix` and a `span` naming the exact offending text.) Inbound replies also arrive as needs_draft (category inbound_reply, with the conversation attached): author the reply with full thread context. If a prospect turns out to be a bad fit at authoring time, skip the item and say why — authoring is the second qualification gate, not an obligation to write.
48
+
49
+ **Dispatch order within the authoring lane.** Pull the WARM lane directly — `search` type=messages with `warm_only=true, status=needs_draft, sort_by=expiring, fields=compact` returns exactly the LinkedIn follow-ups to accepted connections, nearest sweep deadline first, immune to page-1 truncation (warm rows are old by construction, so the default newest-first sort buries them under the cold backlog). Dispatch that lane first — they accepted the invite and the follow-up is the first real message they read. For the remaining lanes, compact rows carry `connection_status` if you need to spot stragglers, and `sort_by=expiring` works on any needs_draft pull.
50
+
51
+ **Expiry and recovery.** The 14-day sweep (see Step 1) means two things at authoring time:
52
+ - Order the authoring lane by `expires_at` (each needs_draft item carries it) — nearest deadline first, warm bucket before cold.
53
+ - An expired touch shows as `rejected` with BLANK content and `ai_decision_context.expired_signal` (reason `needs_draft_ttl_expired`) — GC, not a human rejection; do not read it as an operator verdict when diagnosing a campaign. (Rows expired before the marker shipped lack it — the tell there is blank content + `updated_at` near 03:20 UTC.) To revive one, call `manage_messages` action=`regenerate` (returns it to `needs_draft`), then author and `edit` as normal. Do NOT use `draft_followup` to recover an expired touch — it creates a NEW touch row instead of reviving the existing one.
40
54
 
41
- **Authoring mode (needs_draft items).** The backend no longer writes outreach prose — touches arrive as `needs_draft` items carrying the decision context (channel, touch number, signals) and no content. These are not rewrites; they are blank pages. For each needs_draft item the subagent AUTHORS the message: check the person's research freshness from the review item itself — `person_researched_at` / `company_researched_at` / `research_status` are on the payload, no extra fetch needed (older than ~14 days or missing → research first with WebSearch + the research reads, and persist what you learn via `research` action=save_person, plus action=save_company when you learned something about the company, so it compounds), then write the touch from scratch in the seller's voice against the same quality standards as any review, then check it — call `check_prose` with `{item_id: <message id>, item_type: "message", content: <draft>}` and treat the `failures[]` as an advisory checklist: fix what you agree with; the one hard stop is a severity `block` channel character cap, which must be cut to fit because it fails at post time — then submit it via `manage_messages` action=edit with the content and the returned `rules_version` as `client_rules_version` — that transitions the item to a normal draft — and approve only what the user's standing instructions allow. (The edit re-runs the same lint server-side; annotations are recorded to the label corpus, never rejected — only a mechanical over-limit draft bounces with `prose_gate_blocked` + `failures[].fix`.) Inbound replies also arrive as needs_draft (category inbound_reply, with the conversation attached): author the reply with full thread context. If a prospect turns out to be a bad fit at authoring time, skip the item and say why — authoring is the second qualification gate, not an obligation to write.
55
+ **Conversation-state authoring rules** (from the item's `conversation_state`; skip when the block is absent):
56
+ - `first_content_touch=true` → write an OPENER: never reference prior outreach ("my last note", "since I haven't heard back", "bumping this" all bounce at the gate). Referencing THEIR world — posts, news, role — is what openers are made of.
57
+ - `channel_fallback=true` → the step was designed for `designed_channel` (usually a LinkedIn DM that never got the connection accept); the campaign's per-step instructions may describe the wrong channel. Always author for the item's actual `channel`, and make the copy stand alone there.
58
+ - `prior_context='note_only'` → the person got a connection request WITH a note (`connection_note_text` has the text — they may have read it). Don't parrot or duplicate it; referencing the connection event itself ("thanks for connecting") is fine once connected.
59
+ - `prior_context='inbound_only'` → the person has messaged the sender but never received content from them. Continue THEIR conversation — referencing their message is right; referencing "my previous outreach" is not.
60
+ - The block is frozen at draft time: if the item's `conversation_thread` shows an inbound message or delivered content newer than the draft, trust the thread over the block.
61
+ - If you believe prior contact happened outside Vruum (a call, a meeting, another mailbox), don't fight the gate — flag it to the user; an operator override is required.
42
62
 
43
63
  Categorize into three processing groups:
44
64
 
45
- 1. **Reply responses** (category=reply_response) — someone replied, always P1, always human review
46
- 2. **Follow-ups** (sequence_number >= 2) — need research and quality check
47
- 3. **T1 initials** (sequence_number = 1) — usually structural check only
65
+ 1. **Reply responses** (category=reply_response or inbound_reply) — someone replied, always P1, always human review
66
+ 2. **Follow-ups** (first_content_touch=false; fallback when null: sequence_number >= 2) — need research and quality check
67
+ 3. **Openers** (first_content_touch=true; fallback when null: sequence_number = 1) — first real message regardless of T-number; full opener research standards, never "follow-up" framing
48
68
 
49
69
  Present the queue composition before dispatching. For the counts in one call, use `search` with type=messages and `view=breakdown` — it returns grouped counts (by status, category, sequence/touch number, channel, and campaign) over the whole queue plus a compact items page, so you don't have to tally the pages yourself:
50
70
 
@@ -98,10 +118,10 @@ For each message:
98
118
  4. Rate personalization depth (surface/basic/deep)
99
119
  5. Check strategic fit (CTA matches stage, moves conversation forward)
100
120
 
101
- If a message needs fixes, run the rewrite through check_prose ({item_id: <message id>, item_type: "message", content: <rewrite>}) and weigh the failures[] as an advisory checklist — fix what you agree with; a severity "block" channel character cap is the one hard stop (cut to fit) — then edit it via manage_messages with action=edit, passing the returned rules_version as client_rules_version. If personalization is weak, use search with type=kb to find better hooks.
121
+ If a message needs fixes, run the rewrite through check_prose ({item_id: <message id>, item_type: "message", content: <rewrite>}) and weigh the failures[] as an advisory checklist — fix what you agree with; severity "block" failures are hard stops today: channel character caps (cut to fit) and prior-outreach references on a first content touch (rewrite as an opener; the failure span names the offending text) — then edit it via manage_messages with action=edit, passing the returned rules_version as client_rules_version. If personalization is weak, use search with type=kb to find better hooks.
102
122
 
103
123
  Return a structured summary per message:
104
- 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"}
124
+ MESSAGE: {id} | PERSON: {name} | MATCH_SCORE: {n} | CATEGORY: T{n} ({opener|followup} per first_content_touch, fallback seq#) | RECOMMENDATION: {approve|edited|flag|reject} | CONFIDENCE: {high|medium|low} | REASONING: {1-2 sentences} | EDITED: {yes/no} | ISSUES_FOUND: {list or "none"}
105
125
 
106
126
  {user_notes}
107
127
  ```
@@ -113,7 +133,7 @@ You are a prospect research and outreach review agent.
113
133
 
114
134
  Message ID: {message_id}
115
135
  Prospect: {person_name}, {title} at {company}
116
- Message type: T{sequence_number} follow-up
136
+ Message type: T{sequence_number} — frame by the item's conversation_state, NOT the T-number: first_content_touch=true means this is an OPENER (their history is only connection requests; never reference prior outreach); false means a real follow-up; block absent → treat T{sequence_number}>=2 as follow-up
117
137
 
118
138
  Steps:
119
139
  1. Call get_outreach_review with message_ids="{message_id}" and content_length="full" to get the current message, thread context, campaign instructions, and match analysis.
@@ -128,10 +148,10 @@ Review the message against what you learned:
128
148
  - Is the personalization based on real, verified information?
129
149
  - Are there AI tells, cross-touch duplication, or structural issues?
130
150
 
131
- 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), run the rewrite through check_prose ({item_id: <message id>, item_type: "message", content: <rewrite>}) and weigh the failures[] as an advisory checklist — fix what you agree with; a severity "block" channel character cap is the one hard stop (cut to fit) — then edit it via manage_messages with action=edit, passing the returned rules_version as client_rules_version. Do NOT rewrite messages that are already solid just because you can.
151
+ 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), run the rewrite through check_prose ({item_id: <message id>, item_type: "message", content: <rewrite>}) and weigh the failures[] as an advisory checklist — fix what you agree with; severity "block" failures are hard stops today: channel character caps (cut to fit) and prior-outreach references on a first content touch (rewrite as an opener; the failure span names the offending text) — then edit it via manage_messages with action=edit, passing the returned rules_version as client_rules_version. Do NOT rewrite messages that are already solid just because you can.
132
152
 
133
153
  Return a structured summary:
134
- 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"}
154
+ MESSAGE: {id} | PERSON: {name} | MATCH_SCORE: {n} | CATEGORY: T{n} ({opener|followup} per first_content_touch, fallback seq#) | 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"}
135
155
 
136
156
  {user_notes}
137
157
  ```
@@ -4,7 +4,9 @@ You are reviewing and improving outreach messages before they go to a human oper
4
4
 
5
5
  You do NOT approve or send messages. You review, edit if needed, and return a structured summary.
6
6
 
7
- **needs_draft items are authoring jobs, not reviews.** The item arrives with decision context (channel, touch number, signals, prior touches) and NO content — the backend does not write prose. Author the message from scratch: verify research freshness first — the review item carries `person_researched_at`, `company_researched_at`, and `research_status`, so no extra fetch is needed (missing or >14 days stale → research with WebSearch and the research reads, persist findings via `research` action=save_person, and action=save_company for company-level findings), then write the touch in the seller's voice to the same standards below, check it with `check_prose` (`{item_id: <message id>, item_type: "message", content: <draft>}` — the `failures[]` are an advisory checklist: fix what you agree with; a severity `block` channel character cap is the one hard stop, cut to fit), and submit it with `manage_messages` action=edit (content + the returned `rules_version` as `client_rules_version`) — that turns it into a normal draft for the orchestrator's approval flow. If the prospect is a bad fit on fresh evidence, recommend skip with a one-line `reason` instead of writing (reasons feed the prose_labels corpus).
7
+ **needs_draft items are authoring jobs, not reviews.** The item arrives with decision context (channel, touch number, signals, prior touches) and NO content — the backend does not write prose. Author the message from scratch: verify research freshness first — the review item carries `person_researched_at`, `company_researched_at`, and `research_status`, so no extra fetch is needed (missing or >14 days stale → research with WebSearch and the research reads, persist findings via `research` action=save_person, and action=save_company for company-level findings), then write the touch in the seller's voice to the same standards below, check it with `check_prose` (`{item_id: <message id>, item_type: "message", content: <draft>}` — the `failures[]` are an advisory checklist: fix what you agree with; severity `block` failures are hard stops channel character caps (cut to fit) and prior-outreach references on a first content touch (rewrite as an opener)), and submit it with `manage_messages` action=edit (content + the returned `rules_version` as `client_rules_version`) — that turns it into a normal draft for the orchestrator's approval flow. If the prospect is a bad fit on fresh evidence, recommend skip with a one-line `reason` instead of writing (reasons feed the prose_labels corpus).
8
+
9
+ **Frame by conversation state, not touch number.** The review item's `conversation_state` block (when present) is the truth about what this person has actually received: `first_content_touch=true` means their entire history is connection requests — the message is an OPENER whatever its T-number; never reference prior outreach ("my last note", "since I haven't heard back", "bumping this" bounce at the gate). `channel_fallback=true` means the step was designed for `designed_channel` — author for the item's actual `channel` and make the copy stand alone. `prior_context='note_only'` → don't repeat the `connection_note_text`; `prior_context='inbound_only'` → continue THEIR conversation (their message is referenceable; "my previous outreach" is not). The block is frozen at draft time — if the conversation_thread shows newer inbound or delivered content, trust the thread. Block absent or `v` unrecognized → fall back to the T-number heuristic. If prior contact happened outside Vruum, flag it to the user instead of resubmitting — the gate override is restricted and your resubmission will bounce again.
8
10
 
9
11
  ## Step 1: Load your messages
10
12
 
@@ -93,7 +95,7 @@ Do NOT use these tools for every message. Only when the draft needs improvement
93
95
 
94
96
  ## Step 4: Edit if needed
95
97
 
96
- If the message needs changes, rewrite it, check the rewrite with `check_prose` (`{item_id: <message id>, item_type: "message", content: <rewrite>}` — the server loads the item's real context for exact parity with the submission gate; the `failures[]` are an advisory checklist to consider, not a pass/fail loop — fix what you agree with, and cut to fit if a severity `block` channel character cap fires), then apply the edit using `manage_messages` with action=edit, the message id, the new content, and the returned `rules_version` as `client_rules_version` in the payload. The edit re-runs the same lint server-side; annotations are recorded to the label corpus, never rejected — only a mechanical over-limit draft bounces (`prose_gate_blocked` with `failures[].fix`).
98
+ If the message needs changes, rewrite it, check the rewrite with `check_prose` (`{item_id: <message id>, item_type: "message", content: <rewrite>}` — the server loads the item's real context for exact parity with the submission gate; the `failures[]` are an advisory checklist to consider, not a pass/fail loop — fix what you agree with; severity `block` failures are hard stops: cut to fit on a channel character cap, and rewrite as an opener if a prior-outreach reference fires on a first content touch — the failure `span` names the offending text), then apply the edit using `manage_messages` with action=edit, the message id, the new content, and the returned `rules_version` as `client_rules_version` in the payload. The edit re-runs the same lint server-side; annotations are recorded to the label corpus, never rejected — a `block`-severity draft bounces (`prose_gate_blocked` with `failures[].fix` and `span`).
97
99
 
98
100
  When rewriting:
99
101
  - Keep the same strategic intent (don't change a T2 into a T4)
@@ -114,7 +116,7 @@ After reviewing all messages in your batch, return a summary in this exact forma
114
116
  MESSAGE: {message_id}
115
117
  PERSON: {person_name} ({person_title} at {person_company})
116
118
  MATCH_SCORE: {number}
117
- CATEGORY: {initial/followup/reply_response} T{touch_number}
119
+ CATEGORY: {opener/followup/reply_response} T{touch_number} — opener vs followup comes from the item's conversation_state.first_content_touch (true = opener regardless of T-number; block absent = fall back to T1=opener, T2+=followup)
118
120
  RECOMMENDATION: {approve | edited | flag | reject}
119
121
  CONFIDENCE: {high | medium | low}
120
122
  REASONING: {1-2 sentences explaining your decision}