Playbook new in role skill

Produces a copy-ready clause about someone who recently started or changed into their current job title.

by growthenginenowoslawski·MIT license·★ 736 Stars on the repo·GitHub ↗

Use now

Files of Playbook new in role

growthenginenowoslawski/main1 file shown
SKILL.md
Show the full text350 lines

Playbook: New in Role

All rules here are best practice, not law. Override any of them when the campaign calls for it; note the best practice once and move on.

Use when: the campaign angle depends on the buyer being new in the seat, because a new leader is rebuilding their stack, has budget to reallocate, and has not yet formed a vendor preference.

Do not use when: you want people who moved to a new company regardless of title, or you want the company-level hiring story. Use playbook-hiring-surge for headcount growth and playbook-fundraising for the "they just raised, so they are buying" angle.

One-line output: new_in_role_line = "you stepped into the COO seat at Northwind in April"

1. Trigger and scope

This playbook answers one question: which people in my target market took their current job title in the last N months, and what one sentence can I open an email with?

It is a filter-at-source signal, not an enrichment. You do not buy a list and then ask a vendor "is this person new?". You ask the people database to only return people whose current role started recently, and the answer comes back with the start date already attached. That is why it costs essentially nothing and why the false-positive rate on tenure is near zero.

Two things it explicitly does not do. It does not tell you whether the move was a step up, only that the person changed titles — a President who becomes COO trips the same signal, and the locked prompt is written so the copy stays true either way. It does not find the person's email; run your email waterfall first so you never spend enrichment on rows that will fail the email gate.

Pick your recency window

Default: 90 days, expressed as person_time_in_current_role: {min:0, max:3} (the filter is month-level, so 3 months is the month-level expression of 90 days).

Know the volume cost before you build the lane, because it is steep. Measured on one base (US, VP of Operations, headcount 50 to 1000):

Window Rows
unfiltered 1,662
0 to 6 months 22
0 to 3 months 2

Tightening from 6 months to 90 days cost about 90% of the rows on that base. A single title at 90 days is a handful of people, not a campaign.

Widen when the list is too thin, in this order:

  1. Widen the title family. A measured five-title operations family returned 1,925 rows at 6 months versus 22 for one title on a comparable base. Titles multiply volume far harder than the window does. This is almost always the right fix.
  2. Drop the headcount floor or widen the geography.
  3. Widen the window to 6 months, and only then. Above 9 months the "just started" premise stops being credible and the copy starts lying, so 9 months is the ceiling regardless of how thin the list is.

Record the widening and the reason in the campaign brief.

Cheap pass or robust pass

Decide before you build. These are not the same product.

Cheap pass (default) Robust pass (opt in)
Source Prospeo /search-person a LinkedIn Sales Navigator scraper on Apify
Cost effectively $0 for the data, $0.053 per 1,000 rows for the model metered: roughly $3.20 per 1,000 profiles discovered plus $4 per 1,000 hydrated to full profiles
Tenure precision month-level. {min:0, max:6} is an exact month range coarse. A "changed jobs in the last 90 days" boolean, and a bucket that bottoms out at "less than 1 year". You cannot ask for 0 to 6 months
Freshness database snapshot, can lead a public announcement by about a month LinkedIn-live, but LinkedIn keeps stale results, so a cleanup pass is mandatory
Use it when almost always the client is high value, the vertical is one the database is thin on, or the campaign's whole premise is the job change

Worth internalizing, because it generalizes past this playbook: the expensive LinkedIn-live source has a coarser tenure filter than the free database. You buy freshness and coverage, and you pay for it in precision plus a mandatory cleanup pass. Never run the robust pass silently because the cheap pass came back thin. Say what it will cost, get a yes, then run it.

2. Output contract

Inputs required per row
Field Type Source Required?
target job titles string[] campaign brief / ICP yes, this is the search input
headcount range {min,max} int ICP yes
geography string[] ICP, as location strings like "United States #US" yes
recency window (months) int operator, default 3, ceiling 9 yes
company_domain (bare, lowercase, no www) string returned by the search produced, not supplied

Rows are produced by this playbook, not fed into it. If you already have a list and want to know which of those people are new in role, restrict the same call with company: { websites: { include: [ ...up to 500 domains... ] } }.

Output fields
Field Type Example Max length Null allowed?
new_in_role_line string you stepped into the COO seat at Northwind in April 90 chars yes, empty string
role_change_type enum promotion or new_hire 9 no
role_start_month string April 2026 20 no
months_in_role int 4 n/a no
prior_title string Chief Business Officer 120 yes, empty string

role_change_type, role_start_month, months_in_role and prior_title are computed deterministically from the source's job_history array. The model never decides them. Only new_in_role_line is written by the model.

Abstain value: "" (empty string). Never "N/A", never "null", never a guess.

Coverage expectation

Read these as three separate numbers. They are not the same thing, and campaign sizing depends on telling them apart. Measured on 10 rows:

Metric Measured What it means
Produced rate (non-empty line) 10/10 Of the rows that clear the title gate, essentially all get a line. This is not a rate over the raw pull — the title-gate drop rate was not recorded, so size the campaign off rows that survive the gate
Factual-error rate 1/10 About 1 in 10 prospects could receive a confident but wrong statement about their own job title. This is the real exposure
Empty rate (model abstained) 0/10 The abstain path exists but did not fire
Usable rate (produced and correct) 9/10 The headline number

The single error was not a missing value and not a tenure error. The database asserted a title the company's own site contradicts, and the model faithfully turned that wrong fact into polished copy. No downstream verifier over your own output can catch this, because the output is a correct rewrite of a wrong input. See §6 for the optional cross-check.

The "zero false positives" result below refers only to tenure. It is not a statement about title accuracy.

Because the signal is a source-side filter, coverage on the rows it returns is high by construction. The number that varies is volume, not hit rate.

Copy-fit rules
  • Slots into: Saw {{new_in_role_line}}. as the first sentence of email 1.
  • Starts lowercase. No trailing punctuation — the frame supplies the period.
  • Maximum 90 characters, 5th-grade reading level, no em dashes.
  • Company name must be the short spoken form. "ATG", not "ATG (Auction Technology Group)".
Downstream gate

If new_in_role_line is empty: drop the personalized clause via spintax and keep the row — the row still matches the ICP. Do not leave a blank gap in the email. If the campaign's entire premise is the job change (a "congrats on the new seat" campaign), exclude the row instead and say so in the brief.

The empty case is the cheap case. The expensive case is the ~10% wrong-title case above, which arrives as a non-empty, well-formed line and therefore passes every structural QC.

There are two controls and they address different failures. Do not confuse them:

  1. Title gate (free, mandatory) — controls loose-contains noise only. person_job_title is a loose contains match that can hit a past role, so the pull returns people whose current title is unrelated to the search (measured: a Chief Operating Officer query returned an "Adult Basic Education Instructor"). The gate drops those. It cannot catch a wrong source title — the one observed factual error had a job_history current title that did contain the target keyword, so the gate passed it and the wrong line shipped. Running the gate does not reduce the ~10% factual-error rate at all.
  2. Leadership-page cross-check (cheap, optional) — the only control for a wrong source title. Cross-check the title against the company's own leadership page (see playbook-google-site-search). Turn this on for any client sensitive to being wrong about a prospect's title. If you skip it, budget for roughly 1 in 10 prospects receiving a confident but wrong statement about their own job.

3. Source chain (cost-tagged)

# Source Cost Exact call Stop rule
1 Prospeo /search-person FREE to CHEAP POST https://api.prospeo.io/search-person, header X-KEY: $PROSPEO_API_KEY, body below always start here
2 Clay Find people source, built in a new workbook through the UI FREE to METERED set job title, location, headcount and the recent-role-change filter in the UI, run, then export or webhook the rows out only if step 1 returned fewer rows than the campaign needs
3 Apify LinkedIn Sales Navigator scraper (the robust pass) EXPENSIVE actor with recentlyChangedJobs: true, currentJobTitles, locations, companyHeadcount, profileScraperMode: "Full" only when the operator chose the robust pass and knows the cost
4 Model line writer CHEAP ($0.053 / 1,000 rows) POST https://api.openai.com/v1/chat/completions, params in §6 always runs, on whichever source produced the facts

Step 1's body:

{ "page": 1,
  "filters": {
    "person_location_search": { "include": ["United States #US"] },
    "person_job_title": { "include": ["Chief Operating Officer"] },
    "company_headcount_custom": { "min": 50, "max": 2000 },
    "person_time_in_current_role": { "min": 0, "max": 3 }
  } }

max:6 is what was graded; max:3 is the default. The hit rate does not move with the window, only the volume does.

Rejected alternatives, and why
  • ⛔ A CLI "find people" substitution. It returns only the latest experience, no job history, so role_change_type, prior_title and prior_company cannot be derived at all and the promotion-versus-new-hire branch disappears. Worth generalizing: "needs no browser" is not automatically the better path. When a substitution changes what comes back, that is a contract change, not a convenience.
  • ⛔ Generic LinkedIn search APIs with no tenure filter. Common ones expose title, company, industry, location and school filters and no tenure, months-in-role, or start-date filter, so they cannot express this signal at any price. No subscription tier fixes that.
  • Per-person "enrich" endpoints that would make a clean tenure check are frequently gated off standard plans. Check before designing around one.
  • Scraping LinkedIn for tenure. Career history is already a structured field on the people database, so scraping is pure cost.
  • person_time_in_current_company as the primary. It answers "how long at the company", which misses every internal promotion — and internal promotions were 8 of the 10 rows in the live test. Use it only as a secondary filter to separate new hires from promotions.

4. Verification

VERDICT: PASS 9/10 (90%) | best call = Prospeo /search-person with person_time_in_current_role → model line writer at minimal reasoning effort | p50 latency 1.1s/row | ~$0.05 per 1,000 rows | tested on 10 graded rows.

A separate true-negative test confirmed 3 of 3 long-tenure incumbents (30, 43 and 145 months in seat) are present in the database and correctly excluded by the filter. Zero false positives on tenure.

This verdict covers exactly one path: the script path calling Prospeo /search-person and then the model at minimal reasoning effort, graded end to end against a second source per row.

Path State
Script: Prospeo → model API GRADED, PASS 9/10
Clay table recipe (clay-table.md) ⚠️ specification, never built
Clay workflow recipe (clay-workflow.md) ⚠️ specification, never built
Clay "Find people" source (#2) ⚠️ never built — row shape and tenure granularity unknown
Apify robust pass (#3) ⚠️ never run — filter grammar read from the actor schema only

Re-test if the usable rate drops below 60% for two consecutive campaigns, if the filter schema changes, or if a campaign reports a "you just started" line landing on a long-tenured incumbent.

5. Clay implementation

Two ways to run this continuously:

  • clay-table.md — build it as columns on a table. Read clay-playbooks/clay-table-harness.md first.
  • clay-workflow.md — build it as a workflow from the CLI. Read clay-playbooks/clay-cli-harness.md first.

Both are unbuilt specifications. The script path in §4 is the verified one.

6. Locked prompt

Model: a small reasoning model (gpt-5-nano class). On a measured 732 input and 42 output tokens per row it is roughly 3x cheaper than a mini-tier model with no quality gap on a pure rewrite.

Params: max_completion_tokens=2000, reasoning_effort="minimal", no temperature, and a flex/batch service tier for overnight runs.

You write one short opening clause for a cold email, about a person who recently changed jobs.

You will be given verified facts about one person. The facts are already true. Your only job is to turn them into one natural clause.

Return JSON only, exactly these keys:
{"new_in_role_line": "...", "role_change_type": "promotion|new_hire", "confidence": "high|low"}

Rules for new_in_role_line:
- It must read correctly inside this sentence: "Saw <new_in_role_line>."
- Start with a lowercase letter. No period at the end. No quotation marks.
- Maximum 90 characters.
- Say the seat and the company and roughly when. Use the month name given, or say "earlier this year" if the month is more than 4 months ago.
- Only say "earlier this year" if the start year given is the CURRENT year. If the start year is any earlier year, say the month and the year, for example "in September 2026". Never say "earlier this year" about a date in a previous year.
- If role_change_type is promotion, say they stepped into or took over the seat. Do not say they joined the company. Never say "moved up", "got promoted", or "was promoted": an internal move is not always a step up and we cannot prove it was.
- If role_change_type is new_hire, you may say they joined.
- Use the shortest natural form of the company name. Drop anything inside parentheses, drop legal suffixes like LLC, Inc, PLC, Ltd, and drop trailing descriptive phrases after a comma. "ATG (Auction Technology Group)" becomes "ATG". "A.Y. Strauss, LLC" becomes "A.Y. Strauss".
- 5th-grade reading level. Short words.
- No em dashes. No en dashes. Hyphens are fine only inside a number range.
- Never invent a fact. Only use the facts given. Do not mention headcount, industry, funding, or anything not in the facts.
- If the facts are missing the title, the company, or the start month, return "" for new_in_role_line and "low" for confidence.

Examples:
Facts: first_name=Dana | current_title=VP of Operations | company_name=Gymshark | role_start_month=March 2026 | months_in_role=3 | role_change_type=new_hire | prior_title=Director of Supply Chain | prior_company=Represent
Output: {"new_in_role_line": "you joined Gymshark as VP of Operations back in March", "role_change_type": "new_hire", "confidence": "high"}
Facts: first_name=Marcus | current_title=Chief Operating Officer | company_name=Irby Utilities, LLC | role_start_month=February 2026 | months_in_role=6 | role_change_type=promotion | prior_title=Senior Vice President | prior_company=Irby Utilities, LLC
Output: {"new_in_role_line": "you stepped into the COO seat at Irby earlier this year", "role_change_type": "promotion", "confidence": "high"}
Facts: first_name=Priya | current_title= | company_name=Northwind Labs | role_start_month= | months_in_role= | role_change_type=new_hire | prior_title= | prior_company=
Output: {"new_in_role_line": "", "role_change_type": "new_hire", "confidence": "low"}

PER-ROW DATA (appended last, as the user message, never merged into the block above)
Facts: first_name={{First Name}} | current_title={{Current Title (from job_history)}} | company_name={{Company Name Clean}} | role_start_month={{Role Start Month Label}} | months_in_role={{Months In Role}} | role_change_type={{Role Change Type}} | prior_title={{Prior Title}} | prior_company={{Prior Company}}

The cross-year guard is load-bearing. The "earlier this year" rule was originally unqualified, which generates factually false copy for any role that started in the previous calendar year. With a 6-month window, every run between January and May hits those rows: a run in February sees roles that started in September, five months earlier, and the model would write "earlier this year" about last year. Both months_in_role and the year are already in the facts, so the guard is free. This case is unmeasured — the graded run happened in August, so no graded row had a previous-year start. Re-grade 5 rows the first time you run a window that crosses a year boundary.

Date format, one rule: role_start_month is always the month-name form (April 2026), never 2026-04. The prompt says "use the month name given", so a numeric month would force the model to convert it silently, which is exactly the drift the locked prompt exists to prevent.

The model's role_change_type is discarded. The prompt still asks for it — that is the exact prompt that was graded, so it stays byte-identical — but it is an echo, not a decision. The real value is computed deterministically from job_history. If the model's value were ever allowed through, a flipped promotion to new_hire would make the copy claim someone "joined" a company they have worked at for years.

Everything above the PER-ROW DATA marker is the static prefix and stays static.

⚠️ No prompt-cache discount applies here, and the cost math must not assume one. Automatic prompt caching engages at 1,024+ prompt tokens. This prompt is 732, so it never qualifies and the effective floor stays $0.053 per 1,000 rows. Padding the prefix past 1,024 tokens costs more input tokens than the discount returns at this size. Keep the static-prefix-first structure anyway — it starts paying the moment the prompt grows.

Verifier pass: not needed, by design. The model is never given the open web and is never asked to establish a fact. Every fact in its input was already proven by the structured job_history, and the deterministic fields are computed in code before the call. The residual risk is that the source is wrong about the title, which no verifier over your own output would catch. If a client is sensitive to that, add the leadership-page cross-check rather than a verifier over the generated line.

Truncation guard: finish_reason=length means retry, never abstain. This fired on 10/10 rows at default reasoning effort, returning empty content while burning the full budget on reasoning. reasoning_effort:"minimal" is the fix and is not optional.

7. Edge cases and failure modes

Symptom Cause Fix
Every call returns HTTP 400 INVALID_FILTERS You used person_time_in_current_position The real key is person_time_in_current_role, value {min,max} integer months, 0 to 600. There is no string range form; "0-6" is rejected
Filter silently does nothing, list is full of 10-year incumbents A script swallowed the 400 above and shipped the unfiltered result Assert that pagination.total_count dropped versus the unfiltered baseline, and fail the run on any non-200
A made-up filter name returns the same error as a real one INVALID_FILTERS does not distinguish "unknown key" from "bad value" Do not discover keys by probing. Read the filter docs
error_code: NO_RESULTS kills the whole run An empty result set comes back as HTTP 400 with {"error":true,"error_code":"NO_RESULTS"}, so if (j.error) throw crashes on a normal empty pull — common on the narrow windows this playbook recommends Branch on error_code === "NO_RESULTS" before the generic error branch and return zero rows. Do not retry it
company_domain filter returns INVALID_FILTERS Wrong key shape Use company: { websites: { include: ["acme.com"] } }, max 500 domains
Result has a current title unrelated to the search person_job_title is a loose contains match and can hit a past role Gate in code on job_history[current].title, never on person.current_job_title. The gate list must include abbreviations ("COO", "VP Ops"), not just the searched titles
Line says "moved up" but the prior title was more senior positions_at_company > 1 proves an internal move, never its direction The locked prompt bans "moved up" and always says "stepped into". Do not relax this
Line contains a legal suffix or a parenthetical Raw company name reached the prompt Run playbook-company-name-cleaning first. The prompt strips them as a second line of defence
Copy says a month one off the press release Start month can precede the public announcement by about a month Acceptable. The prompt may say "earlier this year" for anything over 4 months old
LinkedIn URL does not resolve, or resolves to a different person Stale slug (measured on 1 of 10 rows) Never key the email waterfall solely on the returned LinkedIn URL; work from name plus domain too
Model returns empty strings for every row finish_reason=length, reasoning ate the budget reasoning_effort:"minimal". Retry on length, never record it as an abstain
Volume collapses to almost nothing Expected at the 90-day default Widen the title list first, then headcount or geography, then the window to 6 months. Never past 9
The robust pass returns people who changed jobs a year ago LinkedIn keeps stale results. The 90-day flag is LinkedIn's own and it is not aggressively retired Cleanup is mandatory on every robust-pass run, budget for it before you quote the job. Hydrate to full profiles, read the current experience start date, drop every row outside your window. Treat the filter as a cheap pre-filter, not the gate
Hard rules
  • Prospeo pacing: 2 to 2.5 requests per second is the vendor ceiling. 25 results per page, 1,000 pages maximum, so 25,000 results per filter combination — split by US state to go past that. Credits are charged per request that returns at least 1 result, and repeating identical filters plus page within 30 days returns free.
  • Many data vendors sit behind Cloudflare and return 403 to a default Python or Node user agent while allowing curl. Send a browser User-Agent on every call, or your batch silently 403s while your manual test passes.
  • A provider's own email_status: "VERIFIED" is not send-ready. Everything still goes through your own validation waterfall.
1---
2name: playbook-new-in-role
3description: Produces a copy-ready clause about someone who recently started or changed into their current job title. Triggers on "who just started", "new in role", "recently promoted", "new VP of X", "job change signal", "people who just took the seat". Outputs new_in_role_line, a lowercase clause that completes "Saw <line>."
4---
5 
6# Playbook: New in Role
7 
8> All rules here are best practice, not law. Override any of them when the campaign calls for it; note the best practice once and move on.
9 
10**Use when:** the campaign angle depends on the buyer being new in the seat, because a new leader
11is rebuilding their stack, has budget to reallocate, and has not yet formed a vendor preference.
12 
13**Do not use when:** you want people who moved to a **new company** regardless of title, or you
14want the company-level hiring story. Use `playbook-hiring-surge` for headcount growth and
15`playbook-fundraising` for the "they just raised, so they are buying" angle.
16 
17**One-line output:** `new_in_role_line = "you stepped into the COO seat at Northwind in April"`
18 
19## 1. Trigger and scope
20 
21This playbook answers one question: **which people in my target market took their current job
22title in the last N months, and what one sentence can I open an email with?**
23 
24It is a **filter-at-source** signal, not an enrichment. You do not buy a list and then ask a
25vendor "is this person new?". You ask the people database to only return people whose current
26role started recently, and the answer comes back with the start date already attached. That is
27why it costs essentially nothing and why the false-positive rate on tenure is near zero.
28 
29Two things it explicitly does not do. It does not tell you whether the move was a step **up**,
30only that the person changed titles — a President who becomes COO trips the same signal, and the
31locked prompt is written so the copy stays true either way. It does not find the person's email;
32run your email waterfall first so you never spend enrichment on rows that will fail the email gate.
33 
34### Pick your recency window
35 
36**Default: 90 days**, expressed as `person_time_in_current_role: {min:0, max:3}` (the filter is
37month-level, so 3 months is the month-level expression of 90 days).
38 
39**Know the volume cost before you build the lane, because it is steep.** Measured on one base
40(US, `VP of Operations`, headcount 50 to 1000):
41 
42| Window | Rows |
43|---|---|
44| unfiltered | 1,662 |
45| 0 to 6 months | 22 |
46| 0 to 3 months | 2 |
47 
48Tightening from 6 months to 90 days cost about **90% of the rows** on that base. A single title at
4990 days is a handful of people, not a campaign.
50 
51**Widen when the list is too thin, in this order:**
52 
531. **Widen the title family.** A measured five-title operations family returned 1,925 rows at 6
54 months versus 22 for one title on a comparable base. Titles multiply volume far harder than the
55 window does. This is almost always the right fix.
562. **Drop the headcount floor or widen the geography.**
573. **Widen the window to 6 months**, and only then. Above 9 months the "just started" premise
58 stops being credible and the copy starts lying, so **9 months is the ceiling** regardless of
59 how thin the list is.
60 
61Record the widening and the reason in the campaign brief.
62 
63### Cheap pass or robust pass
64 
65Decide before you build. These are not the same product.
66 
67| | **Cheap pass (default)** | **Robust pass (opt in)** |
68|---|---|---|
69| Source | Prospeo `/search-person` | a LinkedIn Sales Navigator scraper on Apify |
70| Cost | effectively $0 for the data, $0.053 per 1,000 rows for the model | metered: roughly **$3.20 per 1,000 profiles** discovered plus **$4 per 1,000** hydrated to full profiles |
71| Tenure precision | **month-level.** `{min:0, max:6}` is an exact month range | **coarse.** A "changed jobs in the last 90 days" boolean, and a bucket that bottoms out at "less than 1 year". You cannot ask for 0 to 6 months |
72| Freshness | database snapshot, can lead a public announcement by about a month | LinkedIn-live, but **LinkedIn keeps stale results**, so a cleanup pass is mandatory |
73| Use it when | almost always | the client is high value, the vertical is one the database is thin on, or the campaign's whole premise is the job change |
74 
75Worth internalizing, because it generalizes past this playbook: **the expensive LinkedIn-live
76source has a coarser tenure filter than the free database.** You buy freshness and coverage, and
77you pay for it in precision plus a mandatory cleanup pass. Never run the robust pass silently
78because the cheap pass came back thin. Say what it will cost, get a yes, then run it.
79 
80## 2. Output contract
81 
82### Inputs required per row
83 
84| Field | Type | Source | Required? |
85|---|---|---|---|
86| target job titles | string[] | campaign brief / ICP | yes, this is the search input |
87| headcount range | {min,max} int | ICP | yes |
88| geography | string[] | ICP, as location strings like `"United States #US"` | yes |
89| recency window (months) | int | operator, default 3, ceiling 9 | yes |
90| `company_domain` (bare, lowercase, no `www`) | string | returned by the search | produced, not supplied |
91 
92Rows are **produced by** this playbook, not fed into it. If you already have a list and want to
93know which of those people are new in role, restrict the same call with
94`company: { websites: { include: [ ...up to 500 domains... ] } }`.
95 
96### Output fields
97 
98| Field | Type | Example | Max length | Null allowed? |
99|---|---|---|---|---|
100| `new_in_role_line` | string | `you stepped into the COO seat at Northwind in April` | 90 chars | yes, empty string |
101| `role_change_type` | enum | `promotion` or `new_hire` | 9 | no |
102| `role_start_month` | string | `April 2026` | 20 | no |
103| `months_in_role` | int | `4` | n/a | no |
104| `prior_title` | string | `Chief Business Officer` | 120 | yes, empty string |
105 
106`role_change_type`, `role_start_month`, `months_in_role` and `prior_title` are computed
107**deterministically** from the source's `job_history` array. The model never decides them. Only
108`new_in_role_line` is written by the model.
109 
110**Abstain value:** `""` (empty string). Never "N/A", never "null", never a guess.
111 
112### Coverage expectation
113 
114Read these as **three separate numbers**. They are not the same thing, and campaign sizing depends
115on telling them apart. Measured on 10 rows:
116 
117| Metric | Measured | What it means |
118|---|---|---|
119| **Produced rate** (non-empty line) | **10/10** | Of the rows that clear the title gate, essentially all get a line. This is **not** a rate over the raw pull — the title-gate drop rate was not recorded, so size the campaign off rows that survive the gate |
120| **Factual-error rate** | **1/10** | About 1 in 10 prospects could receive a confident but wrong statement about their own job title. This is the real exposure |
121| **Empty rate** (model abstained) | **0/10** | The abstain path exists but did not fire |
122| **Usable rate** (produced and correct) | **9/10** | The headline number |
123 
124The single error was **not** a missing value and **not** a tenure error. The database asserted a
125title the company's own site contradicts, and the model faithfully turned that wrong fact into
126polished copy. **No downstream verifier over your own output can catch this**, because the output
127is a correct rewrite of a wrong input. See §6 for the optional cross-check.
128 
129The "zero false positives" result below refers **only to tenure**. It is not a statement about
130title accuracy.
131 
132Because the signal is a source-side filter, coverage on the rows it returns is high by
133construction. The number that varies is **volume**, not hit rate.
134 
135### Copy-fit rules
136 
137- Slots into: `Saw {{new_in_role_line}}.` as the first sentence of email 1.
138- **Starts lowercase.** No trailing punctuation — the frame supplies the period.
139- Maximum 90 characters, 5th-grade reading level, no em dashes.
140- Company name must be the short spoken form. "ATG", not "ATG (Auction Technology Group)".
141 
142### Downstream gate
143 
144If `new_in_role_line` is empty: **drop the personalized clause via spintax and keep the row** — the
145row still matches the ICP. Do not leave a blank gap in the email. If the campaign's entire premise
146is the job change (a "congrats on the new seat" campaign), exclude the row instead and say so in
147the brief.
148 
149The empty case is the *cheap* case. The expensive case is the ~10% wrong-title case above, which
150arrives as a **non-empty, well-formed line** and therefore passes every structural QC.
151 
152There are two controls and **they address different failures. Do not confuse them:**
153 
1541. **Title gate (free, mandatory) — controls loose-contains noise only.** `person_job_title` is a
155 loose contains match that can hit a **past** role, so the pull returns people whose current
156 title is unrelated to the search (measured: a `Chief Operating Officer` query returned an
157 "Adult Basic Education Instructor"). The gate drops those. **It cannot catch a wrong source
158 title** — the one observed factual error had a `job_history` current title that *did* contain
159 the target keyword, so the gate passed it and the wrong line shipped. Running the gate does not
160 reduce the ~10% factual-error rate at all.
1612. **Leadership-page cross-check (cheap, optional) — the only control for a wrong source title.**
162 Cross-check the title against the company's own leadership page (see
163 `playbook-google-site-search`). Turn this on for any client sensitive to being wrong about a
164 prospect's title. If you skip it, budget for roughly 1 in 10 prospects receiving a confident but
165 wrong statement about their own job.
166 
167## 3. Source chain (cost-tagged)
168 
169| # | Source | Cost | Exact call | Stop rule |
170|---|---|---|---|---|
171| 1 | Prospeo `/search-person` | **FREE** to CHEAP | `POST https://api.prospeo.io/search-person`, header `X-KEY: $PROSPEO_API_KEY`, body below | always start here |
172| 2 | Clay **Find people** source, built in a new workbook through the UI | FREE to METERED | set job title, location, headcount and the recent-role-change filter in the UI, run, then export or webhook the rows out | only if step 1 returned fewer rows than the campaign needs |
173| 3 | Apify LinkedIn Sales Navigator scraper (the robust pass) | **EXPENSIVE** | actor with `recentlyChangedJobs: true`, `currentJobTitles`, `locations`, `companyHeadcount`, `profileScraperMode: "Full"` | only when the operator chose the robust pass and knows the cost |
174| 4 | Model line writer | CHEAP ($0.053 / 1,000 rows) | `POST https://api.openai.com/v1/chat/completions`, params in §6 | always runs, on whichever source produced the facts |
175 
176Step 1's body:
177 
178```json
179{ "page": 1,
180 "filters": {
181 "person_location_search": { "include": ["United States #US"] },
182 "person_job_title": { "include": ["Chief Operating Officer"] },
183 "company_headcount_custom": { "min": 50, "max": 2000 },
184 "person_time_in_current_role": { "min": 0, "max": 3 }
185 } }
186```
187 
188`max:6` is what was graded; `max:3` is the default. The hit rate does not move with the window,
189only the volume does.
190 
191### Rejected alternatives, and why
192 
193- ⛔ **A CLI "find people" substitution.** It returns only the latest experience, **no job
194 history**, so `role_change_type`, `prior_title` and `prior_company` cannot be derived at all and
195 the promotion-versus-new-hire branch disappears. Worth generalizing: **"needs no browser" is not
196 automatically the better path.** When a substitution changes what comes back, that is a contract
197 change, not a convenience.
198- ⛔ **Generic LinkedIn search APIs with no tenure filter.** Common ones expose title, company,
199 industry, location and school filters and **no tenure, months-in-role, or start-date filter**, so
200 they cannot express this signal at any price. No subscription tier fixes that.
201- **Per-person "enrich" endpoints** that would make a clean tenure check are frequently gated off
202 standard plans. Check before designing around one.
203- **Scraping LinkedIn for tenure.** Career history is already a structured field on the people
204 database, so scraping is pure cost.
205- **`person_time_in_current_company` as the primary.** It answers "how long at the company", which
206 misses every internal promotion — and internal promotions were **8 of the 10** rows in the live
207 test. Use it only as a secondary filter to separate new hires from promotions.
208 
209## 4. Verification
210 
211**VERDICT: PASS 9/10 (90%)** | best call = Prospeo `/search-person` with
212`person_time_in_current_role` → model line writer at minimal reasoning effort | p50 latency
2131.1s/row | ~$0.05 per 1,000 rows | tested on 10 graded rows.
214 
215A separate true-negative test confirmed 3 of 3 long-tenure incumbents (30, 43 and 145 months in
216seat) are present in the database and correctly excluded by the filter. Zero false positives on
217tenure.
218 
219**This verdict covers exactly one path:** the script path calling Prospeo `/search-person` and then
220the model at minimal reasoning effort, graded end to end against a second source per row.
221 
222| Path | State |
223|---|---|
224| Script: Prospeo → model API | **GRADED, PASS 9/10** |
225| Clay table recipe (`clay-table.md`) | ⚠️ **specification, never built** |
226| Clay workflow recipe (`clay-workflow.md`) | ⚠️ **specification, never built** |
227| Clay "Find people" source (#2) | ⚠️ never built — row shape and tenure granularity unknown |
228| Apify robust pass (#3) | ⚠️ never run — filter grammar read from the actor schema only |
229 
230Re-test if the usable rate drops below 60% for two consecutive campaigns, if the filter schema
231changes, or if a campaign reports a "you just started" line landing on a long-tenured incumbent.
232 
233## 5. Clay implementation
234 
235Two ways to run this continuously:
236 
237- **`clay-table.md`** — build it as columns on a table. Read `clay-playbooks/clay-table-harness.md` first.
238- **`clay-workflow.md`** — build it as a workflow from the CLI. Read `clay-playbooks/clay-cli-harness.md` first.
239 
240Both are unbuilt specifications. The script path in §4 is the verified one.
241 
242## 6. Locked prompt
243 
244Model: a small reasoning model (`gpt-5-nano` class). On a measured 732 input and 42 output tokens
245per row it is roughly 3x cheaper than a mini-tier model with no quality gap on a pure rewrite.
246 
247Params: `max_completion_tokens=2000`, **`reasoning_effort="minimal"`**, no `temperature`, and a
248flex/batch service tier for overnight runs.
249 
250```text
251You write one short opening clause for a cold email, about a person who recently changed jobs.
252 
253You will be given verified facts about one person. The facts are already true. Your only job is to turn them into one natural clause.
254 
255Return JSON only, exactly these keys:
256{"new_in_role_line": "...", "role_change_type": "promotion|new_hire", "confidence": "high|low"}
257 
258Rules for new_in_role_line:
259- It must read correctly inside this sentence: "Saw <new_in_role_line>."
260- Start with a lowercase letter. No period at the end. No quotation marks.
261- Maximum 90 characters.
262- Say the seat and the company and roughly when. Use the month name given, or say "earlier this year" if the month is more than 4 months ago.
263- Only say "earlier this year" if the start year given is the CURRENT year. If the start year is any earlier year, say the month and the year, for example "in September 2026". Never say "earlier this year" about a date in a previous year.
264- If role_change_type is promotion, say they stepped into or took over the seat. Do not say they joined the company. Never say "moved up", "got promoted", or "was promoted": an internal move is not always a step up and we cannot prove it was.
265- If role_change_type is new_hire, you may say they joined.
266- Use the shortest natural form of the company name. Drop anything inside parentheses, drop legal suffixes like LLC, Inc, PLC, Ltd, and drop trailing descriptive phrases after a comma. "ATG (Auction Technology Group)" becomes "ATG". "A.Y. Strauss, LLC" becomes "A.Y. Strauss".
267- 5th-grade reading level. Short words.
268- No em dashes. No en dashes. Hyphens are fine only inside a number range.
269- Never invent a fact. Only use the facts given. Do not mention headcount, industry, funding, or anything not in the facts.
270- If the facts are missing the title, the company, or the start month, return "" for new_in_role_line and "low" for confidence.
271 
272Examples:
273Facts: first_name=Dana | current_title=VP of Operations | company_name=Gymshark | role_start_month=March 2026 | months_in_role=3 | role_change_type=new_hire | prior_title=Director of Supply Chain | prior_company=Represent
274Output: {"new_in_role_line": "you joined Gymshark as VP of Operations back in March", "role_change_type": "new_hire", "confidence": "high"}
275Facts: first_name=Marcus | current_title=Chief Operating Officer | company_name=Irby Utilities, LLC | role_start_month=February 2026 | months_in_role=6 | role_change_type=promotion | prior_title=Senior Vice President | prior_company=Irby Utilities, LLC
276Output: {"new_in_role_line": "you stepped into the COO seat at Irby earlier this year", "role_change_type": "promotion", "confidence": "high"}
277Facts: first_name=Priya | current_title= | company_name=Northwind Labs | role_start_month= | months_in_role= | role_change_type=new_hire | prior_title= | prior_company=
278Output: {"new_in_role_line": "", "role_change_type": "new_hire", "confidence": "low"}
279 
280PER-ROW DATA (appended last, as the user message, never merged into the block above)
281Facts: first_name={{First Name}} | current_title={{Current Title (from job_history)}} | company_name={{Company Name Clean}} | role_start_month={{Role Start Month Label}} | months_in_role={{Months In Role}} | role_change_type={{Role Change Type}} | prior_title={{Prior Title}} | prior_company={{Prior Company}}
282```
283 
284**The cross-year guard is load-bearing.** The "earlier this year" rule was originally unqualified,
285which generates **factually false copy** for any role that started in the previous calendar year.
286With a 6-month window, every run between January and May hits those rows: a run in February sees
287roles that started in September, five months earlier, and the model would write "earlier this
288year" about last year. Both `months_in_role` and the year are already in the facts, so the guard
289is free. This case is **unmeasured** — the graded run happened in August, so no graded row had a
290previous-year start. Re-grade 5 rows the first time you run a window that crosses a year boundary.
291 
292**Date format, one rule:** `role_start_month` is always the **month-name form** (`April 2026`),
293never `2026-04`. The prompt says "use the month name given", so a numeric month would force the
294model to convert it silently, which is exactly the drift the locked prompt exists to prevent.
295 
296**The model's `role_change_type` is discarded.** The prompt still asks for it — that is the exact
297prompt that was graded, so it stays byte-identical — but it is an echo, not a decision. The real
298value is computed deterministically from `job_history`. If the model's value were ever allowed
299through, a flipped `promotion` to `new_hire` would make the copy claim someone "joined" a company
300they have worked at for years.
301 
302Everything above the `PER-ROW DATA` marker is the static prefix and stays static.
303 
304⚠️ **No prompt-cache discount applies here, and the cost math must not assume one.** Automatic
305prompt caching engages at 1,024+ prompt tokens. This prompt is 732, so it never qualifies and the
306effective floor stays **$0.053 per 1,000 rows**. Padding the prefix past 1,024 tokens costs more
307input tokens than the discount returns at this size. Keep the static-prefix-first structure
308anyway — it starts paying the moment the prompt grows.
309 
310**Verifier pass: not needed, by design.** The model is never given the open web and is never asked
311to establish a fact. Every fact in its input was already proven by the structured `job_history`,
312and the deterministic fields are computed in code before the call. The residual risk is that the
313**source is wrong about the title**, which no verifier over your own output would catch. If a
314client is sensitive to that, add the leadership-page cross-check rather than a verifier over the
315generated line.
316 
317**Truncation guard:** `finish_reason=length` means retry, never abstain. This fired on **10/10
318rows** at default reasoning effort, returning empty content while burning the full budget on
319reasoning. `reasoning_effort:"minimal"` is the fix and is not optional.
320 
321## 7. Edge cases and failure modes
322 
323| Symptom | Cause | Fix |
324|---|---|---|
325| Every call returns HTTP 400 `INVALID_FILTERS` | You used `person_time_in_current_position` | The real key is **`person_time_in_current_role`**, value `{min,max}` integer **months**, 0 to 600. There is no string range form; `"0-6"` is rejected |
326| Filter silently does nothing, list is full of 10-year incumbents | A script swallowed the 400 above and shipped the unfiltered result | Assert that `pagination.total_count` dropped versus the unfiltered baseline, and fail the run on any non-200 |
327| A made-up filter name returns the same error as a real one | `INVALID_FILTERS` does not distinguish "unknown key" from "bad value" | Do not discover keys by probing. Read the filter docs |
328| `error_code: NO_RESULTS` kills the whole run | An empty result set comes back as **HTTP 400** with `{"error":true,"error_code":"NO_RESULTS"}`, so `if (j.error) throw` crashes on a normal empty pull — common on the narrow windows this playbook recommends | Branch on `error_code === "NO_RESULTS"` **before** the generic error branch and return zero rows. Do not retry it |
329| `company_domain` filter returns `INVALID_FILTERS` | Wrong key shape | Use `company: { websites: { include: ["acme.com"] } }`, max 500 domains |
330| Result has a current title unrelated to the search | `person_job_title` is a loose contains match and can hit a **past** role | Gate in code on `job_history[current].title`, never on `person.current_job_title`. **The gate list must include abbreviations** ("COO", "VP Ops"), not just the searched titles |
331| Line says "moved up" but the prior title was more senior | `positions_at_company > 1` proves an internal move, never its direction | The locked prompt bans "moved up" and always says "stepped into". Do not relax this |
332| Line contains a legal suffix or a parenthetical | Raw company name reached the prompt | Run `playbook-company-name-cleaning` first. The prompt strips them as a second line of defence |
333| Copy says a month one off the press release | Start month can precede the public announcement by about a month | Acceptable. The prompt may say "earlier this year" for anything over 4 months old |
334| LinkedIn URL does not resolve, or resolves to a different person | Stale slug (measured on 1 of 10 rows) | Never key the email waterfall solely on the returned LinkedIn URL; work from name plus domain too |
335| Model returns empty strings for every row | `finish_reason=length`, reasoning ate the budget | `reasoning_effort:"minimal"`. Retry on `length`, never record it as an abstain |
336| Volume collapses to almost nothing | Expected at the 90-day default | Widen the **title list** first, then headcount or geography, then the window to 6 months. Never past 9 |
337| The robust pass returns people who changed jobs a year ago | **LinkedIn keeps stale results.** The 90-day flag is LinkedIn's own and it is not aggressively retired | **Cleanup is mandatory on every robust-pass run, budget for it before you quote the job.** Hydrate to full profiles, read the current experience start date, drop every row outside your window. Treat the filter as a cheap pre-filter, not the gate |
338 
339### Hard rules
340 
341- **Prospeo pacing:** 2 to 2.5 requests per second is the vendor ceiling. 25 results per page,
342 1,000 pages maximum, so **25,000 results per filter combination** — split by US state to go past
343 that. Credits are charged per request that returns at least 1 result, and repeating identical
344 filters plus page within 30 days returns free.
345- **Many data vendors sit behind Cloudflare** and return 403 to a default Python or Node user
346 agent while allowing `curl`. Send a browser User-Agent on every call, or your batch silently
347 403s while your manual test passes.
348- **A provider's own `email_status: "VERIFIED"` is not send-ready.** Everything still goes through
349 your own validation waterfall.
350 

Discussion

Alternatives