Playbook hiring surge skill

Produces a copy-ready clause about a company adding people to one specific department in the last 6 months, plus the department-level headcount counts that drive the segment gate.

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

Use now

Files of Playbook hiring surge

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

Playbook: Hiring Surge

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 angle is "you are scaling this team right now".

Do not use when: you want the job ad itself (playbook-job-posting-language), the person who just started (playbook-new-in-role), or money rather than people (playbook-fundraising).

One-line output: hiring_surge_line = "your sales team has 8 people who started new roles in the last six months"

The three things you cannot get wrong

  1. It is role starts, not hires. Measured 28.3% internal moves. The filter counts people whose current role started in the window — promotions, re-titles and lateral moves included. Ship started new roles; never hired, added, brought on.
  2. Never buy a LinkedIn URL with enrichment credits to feed this. The company-enrichment action that returns one costs ~8 credits per company and was, by three orders of magnitude, the largest cost line here. Use a free LinkedIn URL off your intake payload where you have one, and the bare normalized domain everywhere else. Both are free.
  3. The gate is a ratio PLUS absolute floors: role_starts >= 2 && dept_total >= 4. This cuts the qualifying segment from 14.6% to 5.2%, so plan roughly 96,000 source rows per 5,000-lead campaign. Do not loosen it to hit volume; source more rows.

1. Trigger and scope

A hiring surge is a department-level fact, not a company-level one. "This company grew from 200 to 260 people" is almost useless in copy, because the reader cannot tell whether it means anything to them. "Your sales team has 8 people who started new roles in the last six months" is specific, checkable, and implies a budget owner. This playbook produces the second kind.

The whole signal comes from one Clay table action, Find Employee Headcount by Criteria, which is free on every Clay plan. It counts how many people currently at a company match a filter set, and it accepts a recency filter (current_role_max_months_since_start_date), so running it twice per department gives you the department total and the department's last-6-months intake in one pass. No other vendor does department-sliced recency counts at zero marginal cost. That is why this playbook has exactly one source and no expensive fallback.

Four things it does not do
  • It does not tell you who. Counts only. Names are a different, non-free action.
  • It does not date the starts. The copy can only ever say "in the last six months", never "in March".
  • It does not pick your departments. Sales and marketing are the two shown here; the same pattern extends to engineering, RevOps, customer success or finance.
  • It does not prove anyone was HIRED. Read this one before you write copy.
The promotion confound, measured

The filter counts people whose current role started in the last 6 months. That set includes external hires, internal promotions, title changes and lateral moves. LinkedIn also generates a new role start date when someone merely edits or re-titles an existing position.

  • Supported by the data: your sales team has 8 people who started new roles in the last six months
  • Not supported, do not ship: you added 8 people to the sales team in the last six months

The second sentence is a factual claim about the reader's own team that they can disprove instantly if three of those eight were promotions, and cold copy does not survive that.

The over-count rate is measured: 28.3%. Of 276 people whose current role started inside a six-month window across 18 companies, 78 were already working at that company before the window opened. Sliced by title: sales-titled 24.4%, marketing-titled 34.6%. And that is a lower bound — the detector only sees a prior role that started 7+ months ago, so a February joiner promoted in July counts as a joiner. Real examples from the audit: a Business Development Representative who became a Mid-Market Account Executive; a Manager of Customer Success who became a Senior Manager; a Project Manager II who became a Project Manager III.

On one company in the audit, 5 of the 7 recent sales starters were internal moves — "you hired 3 people for sales" would have been wrong about most of the evidence behind it.

Treat every count as an upper bound on hires. Roughly 1 reply in 4 has a right to say "nobody joined my team, I was promoted", and the locked prompt is written so that reply contradicts nothing you sent. If a client insists on "you hired", show them the 28.3% first. That is an operator sign-off, not a default.

Re-measuring the confound on a new ICP is free (about 40 searches). Per company, run two people searches: one for current_role_max_months_since_start_date: 6 (the population the gate counts), one for everyone already there before the window (include_past_experiences: true with a role_range_end_month of YYYY-MM). Intersect on the LinkedIn profile URL: anyone in both started a new role at a company they already worked at.

2. Output contract

Inputs required per row
Field Type Source Required?
company_linkedin_url string your intake payload, as a free formula column yes when present — this is the preferred and free identifier
company_domain (fallback) string, bare lowercase, no www your list, via Normalize a Domain yes when no LinkedIn URL, which on a measured intake is 83.6% of rows

The action prefers a LinkedIn company URL — it is the identifier the underlying people dataset is keyed on. A bare domain forces a resolution step that can silently land on a different company, so the name-collision guard in §7 is mandatory on those rows.

The identifier waterfall, in order:

  1. Free LinkedIn URL from your intake payload. Measured populated on 16.35% of rows.
  2. Bare company domain on the other 83.6%. Free. Accept the collision risk, apply the guard.
  3. If a client genuinely needs LinkedIn URLs resolved at scale, resolve them outside Clay with a domain-to-LinkedIn enrichment endpoint and webhook them in. Never buy them with Clay credits.

Stated plainly: on the 83.6% bare-domain rows the identifier is weaker and some counts will land on the wrong company. That is the right trade at 8 credits a row, but nobody has measured how much the bare-domain path degrades the 0.28% cell error rate — every measurement below was taken on rows identified by a purchased URL. Measure it if you run a large batch.

Output fields
Field Type Example Null allowed?
sales_headcount_total integer 28 no, 0 when nobody matches
sales_hires_6mo integer 8 no, 0
marketing_headcount_total integer 15 no, 0
marketing_hires_6mo integer 2 no, 0
hiring_surge_dept string sales yes, "" when the gate says NO
hiring_surge_hires integer 8 yes, 0 when the gate says NO
hiring_surge_line string see above, max 90 chars yes

hiring_surge_hires counts role starts, not hires. Nothing downstream may relabel it — including a copywriter editing the sequence by hand.

Abstain value: "" for the line and the department, 0 for the count. The counts themselves never abstain: a company with no sales team returns 0, which is a real answer.

Coverage expectation

Measured on 3,964 distinct companies:

Metric 98-row sample 3,964 companies
Sales roster resolved (1+ person) 52% 35.1%
Marketing roster resolved 61% 45.3%
Either department resolved 71% 52.9%
Ratio gate = YES 17% 14.6%
Gate = YES with the absolute floors not measured 5.2%
Action cell error rate not measured 0.28% (11 of 4,000)

Note how optimistic the 98-row sample was. Plan list size on 5.2%, not 17%. Filling a 5,000-lead campaign takes roughly 96,000 source rows.

The floors delete 64% of the gated segment, and that is the price of the precision they buy: on 20 held-out companies the gate with floors scores 17/20 (85%) against 10/20 (50%) without them. The rows they delete are the ones that would tell a two-person law firm its marketing team is scaling.

Copy-fit rules
  • Slots into: Noticed {{hiring_surge_line}}.
  • Second person only. The clause must never contain the company name. That removes the dependency on name cleaning and a whole class of capitalization bugs — a v1 prompt that allowed the company name and required a lowercase first letter produced "silktide added 8 people".
  • Starts lowercase, no trailing period, no em dashes, under 90 characters, 5th-grade reading level.
  • The time phrase is always "in the last six months" or "over the past six months". Never a month name, never a date, because the source does not carry one.
Downstream gate

If hiring_surge_line is empty: exclude the row from this campaign. This is one of the few playbooks where empty means exclusion rather than a spintax drop, because the entire campaign premise is the surge. Route excluded rows to a fallback campaign.

3. Source chain (cost-tagged)

# Source Cost What it does Stop rule
0 Company-wide growth pre-filter (list build only) FREE POST /search-company with company_headcount_growth: {min:50} optional and marginal. Only when your TAM is 4x+ oversized
0b Department contact-count pre-filter (list build only) FREE a contacts search/count endpoint filtered by job function drop companies with no department at all before they reach Clay
1 Free LinkedIn URL from intake FREE formula column 16.35% fill
2 Bare domain from Normalize a Domain FREE the fallback identifier covers the other 83.6%
3 Clay Find Employee Headcount by Criteria x4 columns FREE the engine only advance if the action is removed or made paid
4 Model phrasing pass, gated rows only CHEAP see §6 only when Hiring Surge Dept is non-empty
5 A people-data-vendor employee-count action METERED (~5 credits/row, charged even on a miss) fallback only if step 3 is unavailable and the operator signed off

Cost summary, per 1,000 source rows: four FREE columns plus about $0.0135 of model spend at the 14.6% gate rate, or $0.0048 at the floors' 5.2%. That is the entire cost. There are no enrichment credits in this chain. What remains is Clay table run time, which is why the free pre-filters are still worth running on a large intake.

On step 0, with the measurement

§1 argues company-level growth is nearly useless as a signal, and step 0 filters on exactly that. It was measured by taking companies whose gate outcome was already known and asking which sit inside the growth pool:

Sample In the headcount_growth >= 50 pool
200 gate-YES 48 (24.0%)
200 gate-NO 44 (22.0%)
at 200 vs 200 2.0 points, z = 0.47, p = 0.64, nothing
579 gate-YES 158 of 575 (27.5%)
579 gate-NO 127 of 578 (22.0%)
at 579 vs 579 5.5 points, z = 2.17, p = 0.03

Converted to what matters against a 14.6% base rate: 17.6% inside the pool clear the gate against 13.7% outside. A 1.28x relative lift bought with about 76% of your TAM. Use it only when the TAM is at least 4x oversized. A min:200 variant showed 9.0% vs 12.0%, pointing the wrong way — do not use it.

The method is reusable and worth stealing. You do not need to run the expensive step on 400 new companies to answer "does this pre-filter predict my gate". Take companies whose outcome you already have, send their domains to the filter in batches of 200 to 500, and read the total count with and without it. Four calls instead of four hundred.

Rejected alternatives
  • Per-company department counting via a people-search API. Measured: combining a company filter with a job-title filter returned NO_RESULTS on all three test companies while the unfiltered company query returned 53, 351 and 45 people. The title filter does not compose with the company filter the way you would expect, so a department count means paging the whole roster and regexing titles yourself. Fine for verification, far too slow for production.
  • A company_headcount_by_department filter. The name is accepted but every inner shape tried returned INVALID_FILTERS. Shape unknown; worth a support ticket, since it would give a free department-level TAM pre-filter.
  • Per-company LinkedIn scraping. It is per-company scraping where the Clay action gives a department count for free.
  • Richer "employee trends" enrichments. ~10 credits per row for trend data you do not need for one clause. Only for a client who explicitly buys on headcount trend charts.

4. Verification

VERDICT: PASS 17/20 (85%) on held-out companies. The floors were benched, not fitted: 17/20 with them, 10/20 without, on 20 companies drawn blind from a batch created after the floors were written.

The one miss was a wrong abstain in financial services — see §7.

5. Clay implementation

  • clay-table.md — the full column build. This is the primary path: the whole chain runs in Clay, because the headcount action has no API surface anywhere and exists only as a table action. Read clay-playbooks/clay-table-harness.md first.
  • clay-workflow.md — a CLI-buildable approximation, for the parts that do have workflow actions. Read clay-playbooks/clay-cli-harness.md first.

⚠️ Both are unbuilt specifications.

6. Locked prompt

Model choice, with the arithmetic. Measured on 10 gated rows per model. The prompt is 502 tokens as sent.

Model and params Clean JSON p50 latency Completion tokens Reasoning tokens Per 1,000 source rows at 5.2%
gpt-4o-mini, max_tokens=300 10/10 0.93s 28 0 $0.0048
gpt-5-nano, 1200 tokens, default effort 0/10 9.58s 1200 (capped) 1200 unusable
gpt-5-nano, 3000 tokens, default effort 3/3 11.6s 1445 1408 $0.031
gpt-5-nano, reasoning_effort="minimal" 1/1 2.70s 37 0 $0.0021

Two things matter here:

A small reasoning model at default effort returns empty content on this prompt. Ten of ten rows came back finish_reason=length with an empty message after burning the entire 1,200-token budget on reasoning. reasoning_effort="minimal" is the fix and makes it both reliable and the cheapest option in the table.

Inside Clay, ship gpt-4o-mini. Clay's AI column does not expose reasoning_effort, so inside Clay the nano-class model is the default-effort row above: 6.5x more expensive than mini and empty on every row at 1,200 tokens. Outside Clay, use the nano-class model with minimal effort, which is 2.3x cheaper than mini.

Params: max_completion_tokens=1200 (never max_tokens on a reasoning model) plus reasoning_effort="minimal", no temperature, flex tier for batch outside Clay.

STATIC PREFIX (byte-identical across calls, keep first)

You write one short clause for a cold email. The clause tells a company that we noticed people on one of their teams recently started new roles.

You are given a department name and how many people on that team started their current role in the last 6 months. The number is already verified. Your only job is wording.

IMPORTANT: the number counts people who STARTED A NEW ROLE. Some of them were hired from outside and some were promoted or moved internally. You cannot tell which. So never say the company hired, added, brought on, recruited, or grew by those people. Say that those people started new roles, or are new in their roles, or joined that team.

Return JSON only, no prose, no code fence:
{"hiring_surge_line": "...", "confidence": "high|low"}

Rules:
- The clause must read correctly inside this sentence: "Noticed <hiring_surge_line>."
- Write it in second person, about "you" or "your team". Never write the company name.
- Start with a lowercase letter. No trailing period. No em dashes. 5th grade reading level.
- Use the exact number you are given. Never invent a number, a job title, a person, or a date.
- Say "in the last six months" or "over the past six months". Never a specific month or date.
- Never claim the company hired anyone. Say people started new roles.
- Keep it under 90 characters.
- confidence is "high" when the number is 3 or more, otherwise "low".

Examples:
Input: {"department":"sales","role_starts_last_6_months":8}
Output: {"hiring_surge_line":"your sales team has 8 people who started new roles in the last six months","confidence":"high"}
Input: {"department":"marketing","role_starts_last_6_months":3}
Output: {"hiring_surge_line":"on your marketing team, 3 people started new roles in the past six months","confidence":"high"}
Input: {"department":"sales","role_starts_last_6_months":2}
Output: {"hiring_surge_line":"you have 2 people on the sales team who started new roles in the last six months","confidence":"low"}

PER-ROW DATA (appended last)
{"department":"{{Hiring Surge Dept}}","role_starts_last_6_months":{{Hiring Surge Hires}}}

Notice what the prompt refuses to let the model say: that anyone was hired. The input field is named role_starts_last_6_months rather than hires for the same reason, so a future editor cannot casually reintroduce the claim.

Notice what is not in this prompt: the abstain decision, the department choice, and the company name. All three were in v1 and all three caused failures. The abstain and department logic moved into deterministic formulas; the company name was removed from the output shape entirely.

Cache note. At ~460 tokens this sits below the 1,024-token prompt-cache floor, so measured cached_tokens was 0 on every call. Expected and fine at this size. Keep the static prefix first anyway — if a client variant grows it past 1,024 the discount switches on for free.

Verifier pass: not needed, and this is the rare playbook where that is genuinely true. The number is copied from a deterministic count the model is told not to change, and the only date reference is a fixed six-month phrase. There is no free-text claim to fabricate. What can go wrong is upstream — wrong company matched, stale data, the promotion confound — and a second model call sees none of that, because it only sees the same number. Spend the effort on the §7 identity checks instead.

Truncation guard: finish_reason == "length" with empty content means retry at 3,000 tokens, never abstain. Setting reasoning_effort="minimal" takes the truncation rate to zero and is the shipped fix; keep the retry ladder as a second line of defence, not as the plan.

7. Edge cases and failure modes

Symptom Cause Fix
Prospect replies "nobody joined my team, I was promoted" 28.3% of role starts are internal moves The locked prompt says "started new roles", true of promotions and joins alike, so the reply contradicts nothing. Never ship a hire claim
A 45,000-person enterprise fires the gate on below-average churn The roleCount > 6 absolute override has no upper bound. Observed: a 1,896-person sales function with 95 six-month role starts — 5.3%, below normal churn. 1.0% of gated companies ⚠️ Unvalidated guard: require the ratio clause (not the >6 override) on any department with 200+ people, or drop the override entirely. Bench it on a fresh blind draw before shipping, exactly as the floors were benched
An agency fires the sales gate on client-service staff At marketing and creative agencies, Account Executive and Account Manager are client service, and the Sales function counts them ⚠️ Unvalidated guard: exclude those titles when the industry is Marketing Services, Advertising or Design, or route agencies through job_title_keywords instead
A financial-services company abstains when it should not In finance and professional services the sellers are titled Principal, Business Development or Client Relationship, which job_functions=["Sales"] does not catch. This was the only miss in the bench Add a third department column bound to job_title_keywords: ["business development","client relationship","partnerships"] for those ICPs
Every row returns "No Role Found" for a department job_functions got a value outside the enum. A wrong enum value does not error, it silently returns zero Only "Sales" and "Marketing and Public Relations" are confirmed. Test any new value on 10 known-good companies first, or use free-text job_title_keywords
Copy reads "silktide added 8 people" with a lowercase company name v1 allowed the company name in a clause that also required a lowercase first letter v2 removes the company name entirely. Never put a company name in a clause with a lowercase-first rule
A 4-person marketing team that added 1 person is flagged YES The raw percentage check has no absolute floor: 1/(4-1) is 33%, over the 15% threshold The hires >= 2 && total >= 4 floors
Growth percent shows Infinity The whole department started inside the window, so total - recent == 0. This is a real surge, not an error The check treats "Infinity" as YES on purpose. Leave it
Counts belong to a different company with the same short name Two different companies can share a short name, and a bare domain invites the collision. This is the identifier on ~83.6% of rows Mandatory guard: sanity-check the returned count against your list's known headcount, abstain when a department count exceeds the company's known total, and abstain on any company whose name is a short generic token
A LinkedIn URL contains a raw & Observed: https://www.linkedin.com/company/cr&t Percent-encode company URLs before any bulk URL operation
Model returns empty with finish_reason=length Reasoning overrun. Happens when the prompt asks the model to make a judgment call rather than just phrase something Retry once at 3,000 tokens. If it still empties, the prompt is the problem: move the decision into a formula
A growth filter seems not to filter An unrecognized inner key is silently ignored rather than rejected Always sanity-check a filter by comparing total counts with and without it before trusting it
A company record's AI-generated description describes a completely different business Observed on a real record Never use a vendor's AI description field for copy. Use the human-written description
47% of companies resolve no department at all Small, non-US or LinkedIn-thin companies genuinely have no visible department roster. This is a correct abstain, not a failure Plan around the 5.2% rate. Do not "fix" it by loosening the gate. Pre-filter with the free count in step 0b so these rows never consume Clay run time
Counts look plausible but no second vendor confirms the recency half Contact-database snapshots lag, so their recency counts run about a third low (observed 3 vs 6, 8 vs 23) Verify the recency half against the same people dataset that produced it, which returns names, titles and role start dates
Hard rules
  • Do not buy company enrichment to get a LinkedIn URL here. ~8 credits per company. Related trap: Find Employee Headcount by Criteria is a free Clay action, but a similarly named third-party Get Employee Count by Criteria costs 5 credits even when it finds nothing. Check which one your column is bound to.
  • One lead is never in two of your campaigns at once. If you also run playbook-new-in-role, the push gate must exclude people who personally just started, or the same person gets two different sequences from you.
  • The count is role starts, not hires. No copy, no variable name, no client-facing summary may call it hires.
1---
2name: playbook-hiring-surge
3description: Produces a copy-ready clause about a company adding people to one specific department in the last 6 months, plus the department-level headcount counts that drive the segment gate. Triggers on "they're hiring", "hiring surge", "growing their sales team", "scaling the team", "headcount growth", "who added SDRs", "companies that just built a marketing team". Outputs hiring_surge_line, a lowercase second-person clause that completes "Noticed <line>."
4---
5 
6# Playbook: Hiring Surge
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 angle is "you are scaling this team right now".
11 
12**Do not use when:** you want the job ad itself (`playbook-job-posting-language`), the person who
13just started (`playbook-new-in-role`), or money rather than people (`playbook-fundraising`).
14 
15**One-line output:** `hiring_surge_line = "your sales team has 8 people who started new roles in the last six months"`
16 
17## The three things you cannot get wrong
18 
191. **It is role starts, not hires. Measured 28.3% internal moves.** The filter counts people whose
20 *current role started* in the window — promotions, re-titles and lateral moves included. Ship
21 `started new roles`; never `hired`, `added`, `brought on`.
222. **Never buy a LinkedIn URL with enrichment credits to feed this.** The company-enrichment action
23 that returns one costs ~8 credits per company and was, by three orders of magnitude, the largest
24 cost line here. Use a free LinkedIn URL off your intake payload where you have one, and the bare
25 normalized domain everywhere else. Both are free.
263. **The gate is a ratio PLUS absolute floors:** `role_starts >= 2 && dept_total >= 4`. This cuts
27 the qualifying segment from 14.6% to **5.2%**, so plan roughly **96,000 source rows per
28 5,000-lead campaign**. Do not loosen it to hit volume; source more rows.
29 
30## 1. Trigger and scope
31 
32A hiring surge is a **department-level** fact, not a company-level one. "This company grew from 200
33to 260 people" is almost useless in copy, because the reader cannot tell whether it means anything
34to them. "Your sales team has 8 people who started new roles in the last six months" is specific,
35checkable, and implies a budget owner. This playbook produces the second kind.
36 
37The whole signal comes from one Clay **table action**, `Find Employee Headcount by Criteria`, which
38is **free on every Clay plan**. It counts how many people currently at a company match a filter
39set, and it accepts a recency filter (`current_role_max_months_since_start_date`), so running it
40twice per department gives you the department total and the department's last-6-months intake in
41one pass. No other vendor does department-sliced recency counts at zero marginal cost. That is why
42this playbook has exactly one source and no expensive fallback.
43 
44### Four things it does not do
45 
46- **It does not tell you who.** Counts only. Names are a different, non-free action.
47- **It does not date the starts.** The copy can only ever say "in the last six months", never "in
48 March".
49- **It does not pick your departments.** Sales and marketing are the two shown here; the same
50 pattern extends to engineering, RevOps, customer success or finance.
51- **It does not prove anyone was HIRED.** Read this one before you write copy.
52 
53### The promotion confound, measured
54 
55The filter counts people whose **current role started** in the last 6 months. That set includes
56external hires, internal promotions, title changes and lateral moves. LinkedIn also generates a new
57role start date when someone merely edits or re-titles an existing position.
58 
59- Supported by the data: `your sales team has 8 people who started new roles in the last six months`
60- **Not supported, do not ship:** `you added 8 people to the sales team in the last six months`
61 
62The second sentence is a factual claim about the reader's own team that they can disprove instantly
63if three of those eight were promotions, and cold copy does not survive that.
64 
65**The over-count rate is measured: 28.3%.** Of 276 people whose current role started inside a
66six-month window across 18 companies, **78 were already working at that company before the window
67opened**. Sliced by title: sales-titled **24.4%**, marketing-titled **34.6%**. And that is a *lower
68bound* — the detector only sees a prior role that started 7+ months ago, so a February joiner
69promoted in July counts as a joiner. Real examples from the audit: a Business Development
70Representative who became a Mid-Market Account Executive; a Manager of Customer Success who became
71a Senior Manager; a Project Manager II who became a Project Manager III.
72 
73On one company in the audit, **5 of the 7 recent sales starters were internal moves** — "you hired
743 people for sales" would have been wrong about most of the evidence behind it.
75 
76Treat every count as an **upper bound** on hires. Roughly 1 reply in 4 has a right to say "nobody
77joined my team, I was promoted", and the locked prompt is written so that reply contradicts
78nothing you sent. If a client insists on "you hired", show them the 28.3% first. That is an
79operator sign-off, not a default.
80 
81**Re-measuring the confound on a new ICP is free** (about 40 searches). Per company, run two people
82searches: one for `current_role_max_months_since_start_date: 6` (the population the gate counts),
83one for everyone already there before the window (`include_past_experiences: true` with a
84`role_range_end_month` of `YYYY-MM`). Intersect on the LinkedIn profile URL: anyone in both started
85a new role at a company they already worked at.
86 
87## 2. Output contract
88 
89### Inputs required per row
90 
91| Field | Type | Source | Required? |
92|---|---|---|---|
93| `company_linkedin_url` | string | your intake payload, as a free formula column | yes when present — this is the preferred and free identifier |
94| `company_domain` (fallback) | string, bare lowercase, no `www` | your list, via `Normalize a Domain` | yes when no LinkedIn URL, which on a measured intake is **83.6%** of rows |
95 
96The action prefers a **LinkedIn company URL** — it is the identifier the underlying people dataset
97is keyed on. A bare domain forces a resolution step that can silently land on a different company,
98so the name-collision guard in §7 is **mandatory** on those rows.
99 
100The identifier waterfall, in order:
101 
1021. **Free LinkedIn URL from your intake payload.** Measured populated on **16.35%** of rows.
1032. **Bare company domain** on the other 83.6%. Free. Accept the collision risk, apply the guard.
1043. If a client genuinely needs LinkedIn URLs resolved at scale, resolve them **outside Clay** with a
105 domain-to-LinkedIn enrichment endpoint and webhook them in. Never buy them with Clay credits.
106 
107Stated plainly: on the 83.6% bare-domain rows the identifier is weaker and some counts will land on
108the wrong company. That is the right trade at 8 credits a row, but **nobody has measured how much
109the bare-domain path degrades the 0.28% cell error rate** — every measurement below was taken on
110rows identified by a purchased URL. Measure it if you run a large batch.
111 
112### Output fields
113 
114| Field | Type | Example | Null allowed? |
115|---|---|---|---|
116| `sales_headcount_total` | integer | `28` | no, `0` when nobody matches |
117| `sales_hires_6mo` | integer | `8` | no, `0` |
118| `marketing_headcount_total` | integer | `15` | no, `0` |
119| `marketing_hires_6mo` | integer | `2` | no, `0` |
120| `hiring_surge_dept` | string | `sales` | yes, `""` when the gate says NO |
121| `hiring_surge_hires` | integer | `8` | yes, `0` when the gate says NO |
122| `hiring_surge_line` | string | see above, max 90 chars | yes |
123 
124`hiring_surge_hires` counts **role starts**, not hires. Nothing downstream may relabel it —
125including a copywriter editing the sequence by hand.
126 
127**Abstain value:** `""` for the line and the department, `0` for the count. The counts themselves
128never abstain: a company with no sales team returns `0`, which is a real answer.
129 
130### Coverage expectation
131 
132Measured on **3,964 distinct companies**:
133 
134| Metric | 98-row sample | **3,964 companies** |
135|---|---|---|
136| Sales roster resolved (1+ person) | 52% | **35.1%** |
137| Marketing roster resolved | 61% | **45.3%** |
138| Either department resolved | 71% | **52.9%** |
139| Ratio gate = YES | 17% | **14.6%** |
140| Gate = YES **with the absolute floors** | not measured | **5.2%** |
141| Action cell error rate | not measured | **0.28%** (11 of 4,000) |
142 
143Note how optimistic the 98-row sample was. **Plan list size on 5.2%, not 17%.** Filling a 5,000-lead
144campaign takes roughly **96,000 source rows**.
145 
146The floors delete 64% of the gated segment, and that is the price of the precision they buy: on 20
147held-out companies the gate with floors scores **17/20 (85%)** against **10/20 (50%)** without them.
148The rows they delete are the ones that would tell a two-person law firm its marketing team is
149scaling.
150 
151### Copy-fit rules
152 
153- Slots into: `Noticed {{hiring_surge_line}}.`
154- **Second person only.** The clause must never contain the company name. That removes the
155 dependency on name cleaning and a whole class of capitalization bugs — a v1 prompt that allowed
156 the company name *and* required a lowercase first letter produced "silktide added 8 people".
157- Starts lowercase, no trailing period, no em dashes, under 90 characters, 5th-grade reading level.
158- The time phrase is always "in the last six months" or "over the past six months". Never a month
159 name, never a date, because the source does not carry one.
160 
161### Downstream gate
162 
163If `hiring_surge_line` is empty: **exclude the row from this campaign.** This is one of the few
164playbooks where empty means exclusion rather than a spintax drop, because the entire campaign
165premise is the surge. Route excluded rows to a fallback campaign.
166 
167## 3. Source chain (cost-tagged)
168 
169| # | Source | Cost | What it does | Stop rule |
170|---|---|---|---|---|
171| 0 | Company-wide growth pre-filter (list build only) | FREE | `POST /search-company` with `company_headcount_growth: {min:50}` | **optional and marginal.** Only when your TAM is 4x+ oversized |
172| 0b | Department contact-count pre-filter (list build only) | FREE | a contacts `search/count` endpoint filtered by job function | drop companies with no department at all before they reach Clay |
173| 1 | Free LinkedIn URL from intake | FREE | formula column | **16.35%** fill |
174| 2 | Bare domain from `Normalize a Domain` | FREE | the fallback identifier | covers the other 83.6% |
175| 3 | **Clay `Find Employee Headcount by Criteria` x4 columns** | **FREE** | the engine | only advance if the action is removed or made paid |
176| 4 | Model phrasing pass, gated rows only | CHEAP | see §6 | only when `Hiring Surge Dept` is non-empty |
177| 5 | A people-data-vendor employee-count action | METERED (~5 credits/row, **charged even on a miss**) | fallback | only if step 3 is unavailable and the operator signed off |
178 
179**Cost summary, per 1,000 source rows: four FREE columns plus about $0.0135 of model spend at the
18014.6% gate rate, or $0.0048 at the floors' 5.2%. That is the entire cost.** There are no
181enrichment credits in this chain. What remains is Clay table run time, which is why the free
182pre-filters are still worth running on a large intake.
183 
184### On step 0, with the measurement
185 
186§1 argues company-level growth is nearly useless as a signal, and step 0 filters on exactly that.
187It was measured by taking companies whose gate outcome was already known and asking which sit
188inside the growth pool:
189 
190| Sample | In the `headcount_growth >= 50` pool |
191|---|---|
192| 200 gate-YES | 48 (24.0%) |
193| 200 gate-NO | 44 (22.0%) |
194| at 200 vs 200 | 2.0 points, z = 0.47, **p = 0.64, nothing** |
195| 579 gate-YES | 158 of 575 (27.5%) |
196| 579 gate-NO | 127 of 578 (22.0%) |
197| at 579 vs 579 | 5.5 points, z = 2.17, **p = 0.03** |
198 
199Converted to what matters against a 14.6% base rate: **17.6% inside the pool clear the gate against
20013.7% outside.** A 1.28x relative lift bought with about **76% of your TAM**. Use it only when the
201TAM is at least 4x oversized. A `min:200` variant showed 9.0% vs 12.0%, pointing the wrong way — do
202not use it.
203 
204**The method is reusable and worth stealing.** You do not need to run the expensive step on 400 new
205companies to answer "does this pre-filter predict my gate". Take companies whose outcome you
206already have, send their domains to the filter in batches of 200 to 500, and read the total count
207with and without it. Four calls instead of four hundred.
208 
209### Rejected alternatives
210 
211- **Per-company department counting via a people-search API.** Measured: combining a company filter
212 with a job-title filter returned `NO_RESULTS` on all three test companies while the unfiltered
213 company query returned 53, 351 and 45 people. The title filter does not compose with the company
214 filter the way you would expect, so a department count means paging the whole roster and regexing
215 titles yourself. Fine for verification, far too slow for production.
216- **A `company_headcount_by_department` filter.** The name is accepted but every inner shape tried
217 returned `INVALID_FILTERS`. Shape unknown; worth a support ticket, since it would give a free
218 department-level TAM pre-filter.
219- **Per-company LinkedIn scraping.** It is per-company scraping where the Clay action gives a
220 department count for free.
221- **Richer "employee trends" enrichments.** ~10 credits per row for trend data you do not need for
222 one clause. Only for a client who explicitly buys on headcount trend charts.
223 
224## 4. Verification
225 
226**VERDICT: PASS 17/20 (85%)** on held-out companies. The floors were **benched, not fitted**:
22717/20 with them, 10/20 without, on 20 companies drawn blind from a batch created *after* the floors
228were written.
229 
230The one miss was a wrong abstain in financial services — see §7.
231 
232## 5. Clay implementation
233 
234- **`clay-table.md`** — the full column build. This is the primary path: the whole chain runs in
235 Clay, because the headcount action has **no API surface anywhere** and exists only as a table
236 action. Read `clay-playbooks/clay-table-harness.md` first.
237- **`clay-workflow.md`** — a CLI-buildable approximation, for the parts that *do* have workflow
238 actions. Read `clay-playbooks/clay-cli-harness.md` first.
239 
240⚠️ Both are unbuilt specifications.
241 
242## 6. Locked prompt
243 
244**Model choice, with the arithmetic.** Measured on 10 gated rows per model. The prompt is **502
245tokens** as sent.
246 
247| Model and params | Clean JSON | p50 latency | Completion tokens | Reasoning tokens | Per 1,000 source rows at 5.2% |
248|---|---|---|---|---|---|
249| **`gpt-4o-mini`**, `max_tokens=300` | **10/10** | **0.93s** | 28 | 0 | **$0.0048** |
250| `gpt-5-nano`, 1200 tokens, default effort | **0/10** | 9.58s | 1200 (capped) | 1200 | unusable |
251| `gpt-5-nano`, 3000 tokens, default effort | 3/3 | 11.6s | 1445 | 1408 | $0.031 |
252| `gpt-5-nano`, **`reasoning_effort="minimal"`** | 1/1 | 2.70s | 37 | **0** | **$0.0021** |
253 
254Two things matter here:
255 
256**A small reasoning model at default effort returns empty content on this prompt.** Ten of ten rows
257came back `finish_reason=length` with an empty message after burning the entire 1,200-token budget
258on reasoning. `reasoning_effort="minimal"` is the fix and makes it both reliable and the cheapest
259option in the table.
260 
261**Inside Clay, ship `gpt-4o-mini`.** Clay's AI column does not expose `reasoning_effort`, so inside
262Clay the nano-class model is the default-effort row above: 6.5x more expensive than mini and empty
263on every row at 1,200 tokens. **Outside Clay, use the nano-class model with minimal effort**, which
264is 2.3x cheaper than mini.
265 
266Params: `max_completion_tokens=1200` (never `max_tokens` on a reasoning model) plus
267`reasoning_effort="minimal"`, no `temperature`, flex tier for batch outside Clay.
268 
269```text
270STATIC PREFIX (byte-identical across calls, keep first)
271 
272You write one short clause for a cold email. The clause tells a company that we noticed people on one of their teams recently started new roles.
273 
274You are given a department name and how many people on that team started their current role in the last 6 months. The number is already verified. Your only job is wording.
275 
276IMPORTANT: the number counts people who STARTED A NEW ROLE. Some of them were hired from outside and some were promoted or moved internally. You cannot tell which. So never say the company hired, added, brought on, recruited, or grew by those people. Say that those people started new roles, or are new in their roles, or joined that team.
277 
278Return JSON only, no prose, no code fence:
279{"hiring_surge_line": "...", "confidence": "high|low"}
280 
281Rules:
282- The clause must read correctly inside this sentence: "Noticed <hiring_surge_line>."
283- Write it in second person, about "you" or "your team". Never write the company name.
284- Start with a lowercase letter. No trailing period. No em dashes. 5th grade reading level.
285- Use the exact number you are given. Never invent a number, a job title, a person, or a date.
286- Say "in the last six months" or "over the past six months". Never a specific month or date.
287- Never claim the company hired anyone. Say people started new roles.
288- Keep it under 90 characters.
289- confidence is "high" when the number is 3 or more, otherwise "low".
290 
291Examples:
292Input: {"department":"sales","role_starts_last_6_months":8}
293Output: {"hiring_surge_line":"your sales team has 8 people who started new roles in the last six months","confidence":"high"}
294Input: {"department":"marketing","role_starts_last_6_months":3}
295Output: {"hiring_surge_line":"on your marketing team, 3 people started new roles in the past six months","confidence":"high"}
296Input: {"department":"sales","role_starts_last_6_months":2}
297Output: {"hiring_surge_line":"you have 2 people on the sales team who started new roles in the last six months","confidence":"low"}
298 
299PER-ROW DATA (appended last)
300{"department":"{{Hiring Surge Dept}}","role_starts_last_6_months":{{Hiring Surge Hires}}}
301```
302 
303Notice what the prompt refuses to let the model say: that anyone was hired. The input field is
304named `role_starts_last_6_months` rather than `hires` for the same reason, so a future editor
305cannot casually reintroduce the claim.
306 
307Notice what is **not** in this prompt: the abstain decision, the department choice, and the company
308name. All three were in v1 and all three caused failures. The abstain and department logic moved
309into deterministic formulas; the company name was removed from the output shape entirely.
310 
311**Cache note.** At ~460 tokens this sits below the 1,024-token prompt-cache floor, so measured
312`cached_tokens` was 0 on every call. Expected and fine at this size. Keep the static prefix first
313anyway — if a client variant grows it past 1,024 the discount switches on for free.
314 
315**Verifier pass: not needed, and this is the rare playbook where that is genuinely true.** The
316number is copied from a deterministic count the model is told not to change, and the only date
317reference is a fixed six-month phrase. There is no free-text claim to fabricate. What *can* go
318wrong is upstream — wrong company matched, stale data, the promotion confound — and a second model
319call sees none of that, because it only sees the same number. Spend the effort on the §7 identity
320checks instead.
321 
322**Truncation guard:** `finish_reason == "length"` with empty content means retry at 3,000 tokens,
323never abstain. Setting `reasoning_effort="minimal"` takes the truncation rate to zero and is the
324shipped fix; keep the retry ladder as a second line of defence, not as the plan.
325 
326## 7. Edge cases and failure modes
327 
328| Symptom | Cause | Fix |
329|---|---|---|
330| Prospect replies "nobody joined my team, I was promoted" | 28.3% of role starts are internal moves | The locked prompt says "started new roles", true of promotions and joins alike, so the reply contradicts nothing. Never ship a hire claim |
331| A 45,000-person enterprise fires the gate on below-average churn | The `roleCount > 6` absolute override has no upper bound. Observed: a 1,896-person sales function with 95 six-month role starts — 5.3%, **below** normal churn. **1.0% of gated companies** | ⚠️ Unvalidated guard: require the ratio clause (not the `>6` override) on any department with 200+ people, or drop the override entirely. **Bench it on a fresh blind draw before shipping**, exactly as the floors were benched |
332| An agency fires the sales gate on client-service staff | At marketing and creative agencies, `Account Executive` and `Account Manager` are client service, and the `Sales` function counts them | ⚠️ Unvalidated guard: exclude those titles when the industry is Marketing Services, Advertising or Design, or route agencies through `job_title_keywords` instead |
333| A financial-services company abstains when it should not | In finance and professional services the sellers are titled `Principal`, `Business Development` or `Client Relationship`, which `job_functions=["Sales"]` does not catch. **This was the only miss in the bench** | Add a third department column bound to `job_title_keywords: ["business development","client relationship","partnerships"]` for those ICPs |
334| Every row returns "No Role Found" for a department | `job_functions` got a value outside the enum. **A wrong enum value does not error, it silently returns zero** | Only `"Sales"` and `"Marketing and Public Relations"` are confirmed. Test any new value on 10 known-good companies first, or use free-text `job_title_keywords` |
335| Copy reads "silktide added 8 people" with a lowercase company name | v1 allowed the company name in a clause that also required a lowercase first letter | v2 removes the company name entirely. **Never put a company name in a clause with a lowercase-first rule** |
336| A 4-person marketing team that added 1 person is flagged YES | The raw percentage check has no absolute floor: 1/(4-1) is 33%, over the 15% threshold | The `hires >= 2 && total >= 4` floors |
337| Growth percent shows `Infinity` | The whole department started inside the window, so `total - recent == 0`. **This is a real surge, not an error** | The check treats `"Infinity"` as YES on purpose. Leave it |
338| Counts belong to a different company with the same short name | Two different companies can share a short name, and a bare domain invites the collision. This is the identifier on ~83.6% of rows | **Mandatory guard:** sanity-check the returned count against your list's known headcount, abstain when a department count exceeds the company's known total, and abstain on any company whose name is a short generic token |
339| A LinkedIn URL contains a raw `&` | Observed: `https://www.linkedin.com/company/cr&t` | Percent-encode company URLs before any bulk URL operation |
340| Model returns empty with `finish_reason=length` | Reasoning overrun. Happens when the prompt asks the model to make a **judgment call** rather than just phrase something | Retry once at 3,000 tokens. If it still empties, **the prompt is the problem: move the decision into a formula** |
341| A growth filter seems not to filter | An unrecognized inner key is **silently ignored** rather than rejected | Always sanity-check a filter by comparing total counts with and without it before trusting it |
342| A company record's AI-generated description describes a completely different business | Observed on a real record | Never use a vendor's AI description field for copy. Use the human-written description |
343| 47% of companies resolve no department at all | Small, non-US or LinkedIn-thin companies genuinely have no visible department roster. **This is a correct abstain, not a failure** | Plan around the 5.2% rate. Do not "fix" it by loosening the gate. Pre-filter with the free count in step 0b so these rows never consume Clay run time |
344| Counts look plausible but no second vendor confirms the recency half | Contact-database snapshots lag, so their recency counts run about a third low (observed 3 vs 6, 8 vs 23) | Verify the recency half against the same people dataset that produced it, which returns names, titles and role start dates |
345 
346### Hard rules
347 
348- **Do not buy company enrichment to get a LinkedIn URL here.** ~8 credits per company. Related
349 trap: `Find Employee Headcount by Criteria` is a **free** Clay action, but a similarly named
350 third-party `Get Employee Count by Criteria` costs **5 credits even when it finds nothing**.
351 Check which one your column is bound to.
352- **One lead is never in two of your campaigns at once.** If you also run `playbook-new-in-role`,
353 the push gate must exclude people who personally just started, or the same person gets two
354 different sequences from you.
355- **The count is role starts, not hires.** No copy, no variable name, no client-facing summary may
356 call it hires.
357 

Discussion

Alternatives