Playbook fundraising skill

Produces a copy-ready sentence about a company's funding round, and the list-building filters that select companies which have ever raised or raised recently.

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

Use now

Files of Playbook fundraising

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

Playbook: Fundraising

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 depends on the prospect having money to spend or a mandate to grow — either a TAM filtered to funded companies, or a first line that names their round.

Do not use when: you want headcount growth or open roles as the signal (playbook-hiring-surge). If you want the round as a pure list filter and no copy at all, you only need lane A in §3.

One-line output: funding_line = "Saw you raised $52M in the Series B." — the whole sentence lives in the variable. §2's downstream gate explains why that matters more than it looks.

1. Trigger and scope

Three separate questions get called "fundraising", and they route to different sources. Do not mix them up: one is a solved free problem and one is not solved at all.

  • Lane A, ever raised. "Only show me companies with venture backing." A search filter applied when the list is built. Free, reliable, no per-row work.
  • Lane B, the copy line. "Write a first line about their round." A per-row lookup producing funding_line. This is the main deliverable and the tested path.
  • Lane C, raised in the last 30 days. "Who closed this month?" The people-database funding indexes lag 3 to 4 months, measured. Lane C has no proven end-to-end path and is documented honestly below rather than faked.

Not covered: valuation claims, investor-name personalization, and anything reported only as a rumor.

2. Output contract

Inputs required per row
Field Type Required?
domain (bare, lowercase, no www, no scheme) string yes
company_name string no, used only for the abstain log and QA
Output fields
Field Type Example Max Null?
funding_line string Saw you raised $52M in the Series B. 90 no, use the abstain value
funding_clause string you raised $52M in the Series B 80 no, use ""
funding_evidence_url string a funding-round URL 300 no, use ""
funding_confidence enum high / low 4 no

Abstain value: "". funding_confidence is low whenever the line is empty.

Coverage expectation

8/10 rows usable (80%), where "usable" counts a correct abstain on a company that genuinely has no recent round.

Read the second number too: only 2/10 rows produced a non-empty line, because the test set was adversarial — three bootstrapped companies and three whose last round is years old. On a list already filtered through lane A with a 365-day funding window, every row has a funding event by construction, so the non-empty rate is governed by the equity-stage filter and the 12-month window rather than by source coverage. That rate was not measured. Measure it on your first real campaign before letting copy depend on the variable being present.

Copy-fit rules

Two fields, two different jobs. Keep them straight.

  • funding_clause is what the model writes: lowercase first letter, no trailing period, under 80 characters, 5th-grade reading level, no em dashes. It must read grammatically inside Saw <funding_clause>.
  • funding_line is what gets pushed: deterministic code wraps the clause into "Saw " + funding_clause + ".". When the clause is "", the line is "" too. The model never writes this field, a formula does, so the wrapping cannot be forgotten or hallucinated.
  • Neither field ever names a month, a season, a year, or a date, and neither names total funding, valuation, or investors. §7 explains why, with measured error rates.
Downstream gate — read this before wiring the email body

⚠️ Spintax is a RANDOM chooser, not a conditional.

{Saw {{funding_line}}. |} picks one of its branches at random on every row, with no knowledge of whether the variable is populated. On rows that abstain it renders Saw . about half the time, and on rows that DO have a line it throws the personalization away about half the time.

Spintax can never be used as an if-populated gate for any variable, in this playbook or any other. This generalizes past fundraising and is probably the single most useful thing in this file.

There are exactly two mechanisms that work. Pick one per campaign:

  1. Default: the self-erasing variable. Put the entire sentence in the variable — which is why funding_line carries the leading "Saw" and the trailing period. The body line is just {{funding_line}} on its own line. An empty variable renders nothing, so an abstaining row simply loses that sentence and the rest of the email is untouched. QA step: after upload, preview one abstaining lead and confirm the paragraph collapses cleanly without leaving a double blank line.
  2. Split at upload time. Partition the list into populated and empty, and upload two campaigns or two variants — one with the funding sentence hard-coded into the body, one without. Use this whenever the funding angle changes the whole email rather than one line, and whenever the non-empty rate is low enough that a single body would read oddly for most recipients.

Only exclude abstaining rows entirely when the campaign angle is the round.

The usual word-level spintax block for wording variety is unrelated and still required. Spintax is for wording variety, never for conditional content.

3. Source chain (cost-tagged)

Lane A, ever raised / raised within 6 to 12 months (list building)
# Source Cost Call Hit rate
1 A people/company search API's funding filter FREE add a company_funding.stage array of equity stages to your ICP filters; for recency add a funding_date of 365 or 180 76% of a US software 50-500 segment had raised at any stage, 65% at an equity stage

Equity stage set: Pre seed, Seed, Series unknown, Series A through Series E-J, Angel, Corporate round, Convertible note, Equity crowdfunding.

Lane B, the copy line (per row) — THE TESTED PATH
# Source Cost What it does Result
1 Company lookup by domain, read the funding block FREE {"filters":{"company":{"websites":{"include":["acme.com"]}}},"page":1} with a browser User-Agent 6/10 domains returned a funding record, 9/10 resolved to the correct company
2 domain-equality guard (code) FREE assert the returned company.domain equals the requested domain removed 1 wrong company out of 10. Mandatory, never skip
3 equity-stage and 12-month filter (code) FREE drop Secondary market, Private equity, Debt financing, Grant, Undisclosed, Non equity assistance, all Post IPO *, Product crowdfunding. Then require the newest survivor to be ≤12 months old removed 3 misleading "latest rounds" out of 6. Mandatory
4 Model phrasing CHEAP, $0.22/1k §6. Wording only, never arithmetic or eligibility 2/2 eligible rows produced clean copy

⛔ There is no step 5. If the lookup returned no company record at all, the row abstains and the line is empty, which is the correct outcome for a signal you cannot evidence. Do not add a paid company-enrichment fallback: it costs roughly 8 credits per company and does not carry funding fields anyway.

Lane C, raised in the last 30 days — NOT SOLVED
# Source Cost Reality
1 A funding-signals feed cheap, per record dates ran through yesterday, so freshness is real. But only 9 of 25 rows carried a clean company name, and none carried a domain
2 Model cleanup plus domain resolution CHEAP read company name + source URL + round type + amount, return {company_name, is_closed_round, is_acquisition}, then resolve name→domain
3 A web-research "find all" API EXPENSIVE not tested. Write a stated reason into the run notes before firing it, and cap the run
4 A funding database's UI export, by hand expensive (seat) reliable but manual. One-off urgent lists only, never a recurring campaign
Rejected alternatives
  • A people-database funding-date filter for lane C. Measured: a 90-day window over the entire US Software Development market returned total_count = 1. The index lags 3 to 4 months.
  • Semantic "entity search" APIs for lane C. 0 of 20 returned companies had a round inside the requested window. It returned public companies, companies its own payload labelled Unfunded, and one labelled Acqui-Hired. Entity search ranks on static attributes and has no event-date awareness, so a date-bounded objective degrades into "companies that look fundable".
  • total_funding as a copy fact. One company came back at $550.9M where the press reported $452M, because these totals fold in debt facilities and secondary sales.
  • raised_at as a copy fact. Off by 15 days for one company, one month for another, two months for a third.

4. Verification

VERDICT: PASS 8/10 (80% usable) | claims 8/8 (100% correct) | chain = company lookup by domain → domain-equality guard → equity-stage + 12-month filter → model line writer | p50 3.0s/row | ~$0.22/1k.

This is a claim-bearing playbook, so it carries two bars: 70% usable AND 90% claim correctness.

The 90% is coverage correctness, not coverage — scored as true claims / rows where a claim was emitted. A company that never raised, correctly abstained on, is a correct outcome and leaves the denominator entirely. It is not a miss. The bar binds on truthfulness when the signal exists, because a fabricated round is the one failure nobody can fix after it sends.

Both misses above were coverage misses (no record for one domain; a two-month date error pushed one round outside the window), not false claims — which is why claim correctness is 8/8.

⚠️ Lane A's end-to-end list quality was not graded. Lane C is untested end to end and must not be described as working.

5. Clay implementation

  • clay-table.md — the 7-column build.
  • clay-workflow.md — the CLI-buildable version.

6. Locked prompt

Model: the one this prompt was graded on. Params: max_completion_tokens=3000 (never max_tokens on a reasoning model), no temperature, response_format={"type":"json_object"}, flex tier for batch.

⚠️ No prompt-cache discount applies here. The static prefix is ~520 tokens and caching engages at 1,024+. cached_tokens was 0 on all 20 measured calls. Do not budget for a cache discount.

The prompt's JSON key is funding_line because that is byte-for-byte what was graded — but it holds the clause. Code wraps it into the pushed sentence. Do not edit the key to "fix" the naming; that silently changes the tested prompt.

You write one short clause about a funding round for a cold email.

You will be given a JSON record for one company. Every fact in it has already been
checked. Your only job is wording. Do not do arithmetic and do not add facts.

Return JSON only, exactly these keys:
{"funding_line": "...", "evidence_url": "...", "confidence": "high|low"}

Rules:
- If eligible is false, return "" for funding_line and "low" for confidence. Nothing else.
- The clause must read grammatically inside this sentence: "Saw <funding_line>."
- Start with a lowercase letter. No trailing period. No em dashes. No quote marks.
- 5th-grade reading level. Under 80 characters.
- Use amount exactly as written in the record. Never change the number.
- If stage is not empty, name it. If stage is empty, say "round" instead.
- Never name a month, a season, a year, or a date. Never say "recently".
- Never mention total funding, valuation, or investors.
- Copy evidence_url from the record. Set confidence to the value in the record.

Examples:
Input: {"company":"Attio","amount":"$52M","stage":"Series B","eligible":true,"evidence_url":"https://www.crunchbase.com/funding_round/attio-series-b","confidence":"high"}
Output: {"funding_line":"you raised $52M in the Series B","evidence_url":"https://www.crunchbase.com/funding_round/attio-series-b","confidence":"high"}
Input: {"company":"Deel","amount":"$300M","stage":"","eligible":true,"evidence_url":"https://www.crunchbase.com/organization/deel","confidence":"high"}
Output: {"funding_line":"you closed a $300M round","evidence_url":"https://www.crunchbase.com/organization/deel","confidence":"high"}
Input: {"company":"Northwind Labs","amount":"","stage":"Series A","eligible":true,"evidence_url":"https://www.crunchbase.com/organization/northwind-labs","confidence":"high"}
Output: {"funding_line":"you closed the Series A","evidence_url":"https://www.crunchbase.com/organization/northwind-labs","confidence":"high"}
Input: {"company":"Acme Widgets","amount":"","stage":"","eligible":false,"evidence_url":"","confidence":"low"}
Output: {"funding_line":"","evidence_url":"","confidence":"low"}

PER-ROW DATA (appended last, as the user message)
<the eligibility record>

Verifier pass: not needed, and deliberately so. The usual reason to add one is that the model might hallucinate a date or an amount. Here it is structurally incapable of it: eligibility, the 12-month arithmetic, the stage filter and the rounding all happen in code before the call, and the prompt forbids dates, totals, valuations and investors outright. The only facts in play are a stage string and a pre-formatted amount string, both copied verbatim. Spend the verifier budget on lane C instead, where headline parsing genuinely can invent a round.

Truncation guard: finish_reason=length with empty content means retry, never abstain. A reasoning model burns reasoning tokens before emitting content and can hit the ceiling on a trivial prompt. Retry up to 3 times.

What an unlocked prompt looks like: the v1 of this prompt scored 4/10 and shipped you closed the Series E-J back in October and you have raised north of $550M to date. Both are exactly the failures the locked rules now forbid.

7. Edge cases and failure modes

Symptom Cause Fix
Lookup returns a completely different company A website filter matches other listed websites too, so a vendor that lists your target as a customer wins the match. Observed live, with a total count of 1 and no warning Assert the returned domain equals the requested domain. There is often no strict-domain filter available — a company_domain filter 400s
A first line calls a secondary sale a fundraise The funding-events array includes Secondary market rows, sometimes with a large recent amount. One company had a $300,000,000 secondary Filter to the equity stage set before sorting for the latest event. Existing shareholders selling is not money the company raised, and a founder will notice
A first line calls a PE minority stake a fundraise Databases record a PE firm's purchase of a stake as a Private equity round Exclude Private equity by default. Buying shares from existing holders is not money into the company, so calling it a raise is a false claim. Toggleable per campaign for a PE-adjacent offer; note the override
Copy contains Series E-J That is a database bucket label for late-stage rounds, not a real round name Blank it and fall back to the word "round". Same for Series unknown
The month in the copy is wrong raised_at is systematically early. Measured: off 15 days, one month, and two months on three companies Never put a month or a date in the line. The locked prompt forbids it
The total raised does not match the press Database totals fold in debt facilities and secondary sales Never quote total funding. Quote the round amount, which checked out exactly on 4 of 4
A company that clearly raised gets an empty line No record at that domain, or the round is newer than the index (3 to 4 month lag) Expected. Abstain is correct, and the whole-sentence variable renders as nothing. Do not "fix" it by loosening the guard
An email goes out reading Saw . Somebody wrapped the clause in spintax as an if-populated gate Spintax is a random chooser. Use the whole-sentence variable or split at upload
A recency filter returns HTTP 400 Some APIs return 400 with NO_RESULTS instead of an empty list Treat 400 + NO_RESULTS as zero matches, not an outage. A client that raises on any 400 looks broken
A "last 30 days" list is full of acquisitions and rumors Funding feeds mix in acquisitions, VC fund closes (a firm raising its own fund), and rumors ("in talks to", "reportedly raising") The cleanup step must return is_closed_round and is_acquisition, and you must filter on both
A "last 30 days" amount is absurd Headline parsing produced $600,000,000,000 from an article about AI budgets, and 0.261 USD tagged as debt Require the amount to reconcile with the linked article, or drop the amount and name only the round
Empty model response with finish_reason=length Reasoning overran the ceiling Retry up to 3 times. Never record it as an abstain
Vendor timeout mid-batch Transient Retry once, then mark the row error and exclude it from any hit-rate denominator. An API error is never a data verdict
Hard rules
  • Pace company lookups at about 1 request per second. Zero rate-limit errors were observed at 0.8 req/s across 37 requests.
  • Funding-signal feeds usually charge per record returned. A limit: 200 call spends 200 credits.
  • Spintax is a random chooser and must never gate a personalization variable.
  • Never ship a model you have not graded on this prompt. A cheaper model may look better on paper; if the abstain rule, the character cap, the lowercase-first rule and the no-date rule were never verified on it, it is untested. A $0.11 saving per 1,000 rows is not worth that.
1---
2name: playbook-fundraising
3description: Produces a copy-ready sentence about a company's funding round, and the list-building filters that select companies which have ever raised or raised recently. Triggers on "raised a round", "recently funded", "companies that raised", "Series A list", "VC backed", "post funding outreach", "who just raised". Outputs funding_line, a COMPLETE sentence including the leading "Saw" and the trailing period, so an empty value renders as nothing at all.
4---
5 
6# Playbook: Fundraising
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 depends on the prospect having money to spend or a mandate to grow — either
11a TAM filtered to funded companies, or a first line that names their round.
12 
13**Do not use when:** you want headcount growth or open roles as the signal (`playbook-hiring-surge`).
14If you want the round as a pure list filter and no copy at all, you only need lane A in §3.
15 
16**One-line output:** `funding_line = "Saw you raised $52M in the Series B."` — the whole sentence
17lives in the variable. §2's downstream gate explains why that matters more than it looks.
18 
19## 1. Trigger and scope
20 
21Three separate questions get called "fundraising", and they route to different sources. **Do not mix
22them up: one is a solved free problem and one is not solved at all.**
23 
24- **Lane A, ever raised.** "Only show me companies with venture backing." A search filter applied
25 when the list is built. Free, reliable, no per-row work.
26- **Lane B, the copy line.** "Write a first line about their round." A per-row lookup producing
27 `funding_line`. **This is the main deliverable and the tested path.**
28- **Lane C, raised in the last 30 days.** "Who closed this month?" **The people-database funding
29 indexes lag 3 to 4 months, measured.** Lane C has no proven end-to-end path and is documented
30 honestly below rather than faked.
31 
32Not covered: valuation claims, investor-name personalization, and anything reported only as a rumor.
33 
34## 2. Output contract
35 
36### Inputs required per row
37 
38| Field | Type | Required? |
39|---|---|---|
40| `domain` (bare, lowercase, no `www`, no scheme) | string | yes |
41| `company_name` | string | no, used only for the abstain log and QA |
42 
43### Output fields
44 
45| Field | Type | Example | Max | Null? |
46|---|---|---|---|---|
47| `funding_line` | string | `Saw you raised $52M in the Series B.` | 90 | no, use the abstain value |
48| `funding_clause` | string | `you raised $52M in the Series B` | 80 | no, use `""` |
49| `funding_evidence_url` | string | a funding-round URL | 300 | no, use `""` |
50| `funding_confidence` | enum | `high` / `low` | 4 | no |
51 
52**Abstain value:** `""`. `funding_confidence` is `low` whenever the line is empty.
53 
54### Coverage expectation
55 
56**8/10 rows usable (80%)**, where "usable" counts a **correct abstain** on a company that genuinely
57has no recent round.
58 
59**Read the second number too:** only **2/10 rows produced a non-empty line**, because the test set
60was adversarial — three bootstrapped companies and three whose last round is years old. On a list
61already filtered through lane A with a 365-day funding window, every row has a funding event by
62construction, so the non-empty rate is governed by the equity-stage filter and the 12-month window
63rather than by source coverage. **That rate was not measured. Measure it on your first real campaign
64before letting copy depend on the variable being present.**
65 
66### Copy-fit rules
67 
68Two fields, two different jobs. Keep them straight.
69 
70- **`funding_clause` is what the model writes:** lowercase first letter, no trailing period, under
71 80 characters, 5th-grade reading level, no em dashes. It must read grammatically inside
72 `Saw <funding_clause>.`
73- **`funding_line` is what gets pushed:** deterministic **code** wraps the clause into
74 `"Saw " + funding_clause + "."`. When the clause is `""`, the line is `""` too. **The model never
75 writes this field, a formula does, so the wrapping cannot be forgotten or hallucinated.**
76- Neither field ever names a month, a season, a year, or a date, and neither names total funding,
77 valuation, or investors. §7 explains why, with measured error rates.
78 
79### Downstream gate — read this before wiring the email body
80 
81⚠️ **Spintax is a RANDOM chooser, not a conditional.**
82 
83`{Saw {{funding_line}}. |}` picks one of its branches at random on every row, with **no knowledge of
84whether the variable is populated.** On rows that abstain it renders `Saw .` about half the time,
85and on rows that DO have a line it **throws the personalization away** about half the time.
86 
87**Spintax can never be used as an if-populated gate for any variable, in this playbook or any
88other.** This generalizes past fundraising and is probably the single most useful thing in this
89file.
90 
91There are exactly two mechanisms that work. Pick one per campaign:
92 
931. **Default: the self-erasing variable.** Put the entire sentence in the variable — which is why
94 `funding_line` carries the leading "Saw" and the trailing period. The body line is just
95 `{{funding_line}}` on its own line. An empty variable renders nothing, so an abstaining row
96 simply loses that sentence and the rest of the email is untouched.
97 **QA step:** after upload, preview one *abstaining* lead and confirm the paragraph collapses
98 cleanly without leaving a double blank line.
992. **Split at upload time.** Partition the list into populated and empty, and upload two campaigns
100 or two variants — one with the funding sentence hard-coded into the body, one without. Use this
101 whenever the funding angle changes the whole email rather than one line, and whenever the
102 non-empty rate is low enough that a single body would read oddly for most recipients.
103 
104Only exclude abstaining rows entirely when the campaign angle **is** the round.
105 
106The usual word-level spintax block for wording variety is unrelated and still required. **Spintax is
107for wording variety, never for conditional content.**
108 
109## 3. Source chain (cost-tagged)
110 
111### Lane A, ever raised / raised within 6 to 12 months (list building)
112 
113| # | Source | Cost | Call | Hit rate |
114|---|---|---|---|---|
115| 1 | A people/company search API's funding filter | FREE | add a `company_funding.stage` array of equity stages to your ICP filters; for recency add a `funding_date` of `365` or `180` | **76%** of a US software 50-500 segment had raised at any stage, **65%** at an equity stage |
116 
117Equity stage set: `Pre seed`, `Seed`, `Series unknown`, `Series A` through `Series E-J`, `Angel`,
118`Corporate round`, `Convertible note`, `Equity crowdfunding`.
119 
120### Lane B, the copy line (per row) — THE TESTED PATH
121 
122| # | Source | Cost | What it does | Result |
123|---|---|---|---|---|
124| 1 | Company lookup by domain, read the funding block | FREE | `{"filters":{"company":{"websites":{"include":["acme.com"]}}},"page":1}` with a browser User-Agent | 6/10 domains returned a funding record, 9/10 resolved to the correct company |
125| 2 | **domain-equality guard (code)** | FREE | assert the returned `company.domain` equals the requested domain | **removed 1 wrong company out of 10.** Mandatory, never skip |
126| 3 | **equity-stage and 12-month filter (code)** | FREE | drop `Secondary market`, `Private equity`, `Debt financing`, `Grant`, `Undisclosed`, `Non equity assistance`, all `Post IPO *`, `Product crowdfunding`. Then require the newest survivor to be ≤12 months old | **removed 3 misleading "latest rounds" out of 6.** Mandatory |
127| 4 | Model phrasing | CHEAP, $0.22/1k | §6. **Wording only, never arithmetic or eligibility** | 2/2 eligible rows produced clean copy |
128 
129⛔ **There is no step 5.** If the lookup returned no company record at all, **the row abstains** and
130the line is empty, which is the correct outcome for a signal you cannot evidence. Do not add a paid
131company-enrichment fallback: it costs roughly 8 credits per company and does not carry funding
132fields anyway.
133 
134### Lane C, raised in the last 30 days — NOT SOLVED
135 
136| # | Source | Cost | Reality |
137|---|---|---|---|
138| 1 | A funding-signals feed | cheap, per record | dates ran through yesterday, so **freshness is real**. But only 9 of 25 rows carried a clean company name, and **none carried a domain** |
139| 2 | Model cleanup plus domain resolution | CHEAP | read company name + source URL + round type + amount, return `{company_name, is_closed_round, is_acquisition}`, then resolve name→domain | **not tested** |
140| 3 | A web-research "find all" API | **EXPENSIVE** | **not tested.** Write a stated reason into the run notes before firing it, and cap the run |
141| 4 | A funding database's UI export, by hand | expensive (seat) | reliable but manual. One-off urgent lists only, never a recurring campaign |
142 
143### Rejected alternatives
144 
145- **A people-database funding-date filter for lane C.** Measured: a 90-day window over the entire US
146 Software Development market returned `total_count = 1`. **The index lags 3 to 4 months.**
147- **Semantic "entity search" APIs for lane C.** 0 of 20 returned companies had a round inside the
148 requested window. It returned public companies, companies its own payload labelled `Unfunded`, and
149 one labelled `Acqui-Hired`. **Entity search ranks on static attributes and has no event-date
150 awareness, so a date-bounded objective degrades into "companies that look fundable".**
151- **`total_funding` as a copy fact.** One company came back at `$550.9M` where the press reported
152 $452M, because these totals fold in debt facilities and secondary sales.
153- **`raised_at` as a copy fact.** Off by 15 days for one company, one month for another, **two
154 months** for a third.
155 
156## 4. Verification
157 
158**VERDICT: PASS 8/10 (80% usable) | claims 8/8 (100% correct)** | chain = company lookup by domain →
159domain-equality guard → equity-stage + 12-month filter → model line writer | p50 3.0s/row | ~$0.22/1k.
160 
161**This is a claim-bearing playbook, so it carries two bars: 70% usable AND 90% claim correctness.**
162 
163The 90% is **coverage correctness, not coverage** — scored as `true claims / rows where a claim was
164emitted`. A company that never raised, correctly abstained on, is a **correct outcome and leaves the
165denominator entirely.** It is not a miss. The bar binds on truthfulness *when the signal exists*,
166because **a fabricated round is the one failure nobody can fix after it sends.**
167 
168Both misses above were **coverage** misses (no record for one domain; a two-month date error pushed
169one round outside the window), not false claims — which is why claim correctness is 8/8.
170 
171⚠️ **Lane A's end-to-end list quality was not graded. Lane C is untested end to end and must not be
172described as working.**
173 
174## 5. Clay implementation
175 
176- **`clay-table.md`** — the 7-column build.
177- **`clay-workflow.md`** — the CLI-buildable version.
178 
179## 6. Locked prompt
180 
181Model: the one this prompt was **graded** on. Params: `max_completion_tokens=3000` (never
182`max_tokens` on a reasoning model), no `temperature`, `response_format={"type":"json_object"}`,
183flex tier for batch.
184 
185⚠️ **No prompt-cache discount applies here.** The static prefix is ~520 tokens and caching engages
186at 1,024+. `cached_tokens` was `0` on all 20 measured calls. **Do not budget for a cache discount.**
187 
188The prompt's JSON key is `funding_line` because that is byte-for-byte what was graded — but it holds
189the **clause**. Code wraps it into the pushed sentence. **Do not edit the key to "fix" the naming;
190that silently changes the tested prompt.**
191 
192```text
193You write one short clause about a funding round for a cold email.
194 
195You will be given a JSON record for one company. Every fact in it has already been
196checked. Your only job is wording. Do not do arithmetic and do not add facts.
197 
198Return JSON only, exactly these keys:
199{"funding_line": "...", "evidence_url": "...", "confidence": "high|low"}
200 
201Rules:
202- If eligible is false, return "" for funding_line and "low" for confidence. Nothing else.
203- The clause must read grammatically inside this sentence: "Saw <funding_line>."
204- Start with a lowercase letter. No trailing period. No em dashes. No quote marks.
205- 5th-grade reading level. Under 80 characters.
206- Use amount exactly as written in the record. Never change the number.
207- If stage is not empty, name it. If stage is empty, say "round" instead.
208- Never name a month, a season, a year, or a date. Never say "recently".
209- Never mention total funding, valuation, or investors.
210- Copy evidence_url from the record. Set confidence to the value in the record.
211 
212Examples:
213Input: {"company":"Attio","amount":"$52M","stage":"Series B","eligible":true,"evidence_url":"https://www.crunchbase.com/funding_round/attio-series-b","confidence":"high"}
214Output: {"funding_line":"you raised $52M in the Series B","evidence_url":"https://www.crunchbase.com/funding_round/attio-series-b","confidence":"high"}
215Input: {"company":"Deel","amount":"$300M","stage":"","eligible":true,"evidence_url":"https://www.crunchbase.com/organization/deel","confidence":"high"}
216Output: {"funding_line":"you closed a $300M round","evidence_url":"https://www.crunchbase.com/organization/deel","confidence":"high"}
217Input: {"company":"Northwind Labs","amount":"","stage":"Series A","eligible":true,"evidence_url":"https://www.crunchbase.com/organization/northwind-labs","confidence":"high"}
218Output: {"funding_line":"you closed the Series A","evidence_url":"https://www.crunchbase.com/organization/northwind-labs","confidence":"high"}
219Input: {"company":"Acme Widgets","amount":"","stage":"","eligible":false,"evidence_url":"","confidence":"low"}
220Output: {"funding_line":"","evidence_url":"","confidence":"low"}
221 
222PER-ROW DATA (appended last, as the user message)
223<the eligibility record>
224```
225 
226**Verifier pass: not needed, and deliberately so.** The usual reason to add one is that the model
227might hallucinate a date or an amount. Here it is **structurally incapable** of it: eligibility, the
22812-month arithmetic, the stage filter and the rounding all happen in code before the call, and the
229prompt forbids dates, totals, valuations and investors outright. The only facts in play are a stage
230string and a pre-formatted amount string, both copied verbatim. Spend the verifier budget on lane C
231instead, where headline parsing genuinely can invent a round.
232 
233**Truncation guard:** `finish_reason=length` with empty content means **retry**, never abstain. A
234reasoning model burns reasoning tokens before emitting content and can hit the ceiling on a trivial
235prompt. Retry up to 3 times.
236 
237**What an unlocked prompt looks like:** the v1 of this prompt scored **4/10** and shipped
238`you closed the Series E-J back in October` and `you have raised north of $550M to date`. Both are
239exactly the failures the locked rules now forbid.
240 
241## 7. Edge cases and failure modes
242 
243| Symptom | Cause | Fix |
244|---|---|---|
245| Lookup returns a **completely different company** | A website filter matches *other* listed websites too, so a vendor that lists your target as a customer wins the match. Observed live, with a total count of 1 and **no warning** | Assert the returned domain equals the requested domain. There is often no strict-domain filter available — a `company_domain` filter 400s |
246| A first line calls a **secondary sale** a fundraise | The funding-events array includes `Secondary market` rows, sometimes with a large recent amount. One company had a $300,000,000 secondary | Filter to the equity stage set **before** sorting for the latest event. **Existing shareholders selling is not money the company raised, and a founder will notice** |
247| A first line calls a **PE minority stake** a fundraise | Databases record a PE firm's purchase of a stake as a `Private equity` round | **Exclude `Private equity` by default.** Buying shares from existing holders is not money into the company, so calling it a raise is a false claim. Toggleable per campaign for a PE-adjacent offer; note the override |
248| Copy contains `Series E-J` | That is a database bucket label for late-stage rounds, not a real round name | Blank it and fall back to the word "round". Same for `Series unknown` |
249| The month in the copy is wrong | `raised_at` is systematically early. Measured: off 15 days, one month, and **two months** on three companies | **Never put a month or a date in the line.** The locked prompt forbids it |
250| The total raised does not match the press | Database totals fold in debt facilities and secondary sales | **Never quote total funding.** Quote the round amount, which checked out exactly on 4 of 4 |
251| A company that clearly raised gets an empty line | No record at that domain, or the round is newer than the index (3 to 4 month lag) | **Expected. Abstain is correct**, and the whole-sentence variable renders as nothing. Do not "fix" it by loosening the guard |
252| An email goes out reading `Saw .` | Somebody wrapped the clause in spintax as an if-populated gate | **Spintax is a random chooser.** Use the whole-sentence variable or split at upload |
253| A recency filter returns HTTP 400 | Some APIs return `400` with `NO_RESULTS` instead of an empty list | Treat `400 + NO_RESULTS` as zero matches, **not an outage.** A client that raises on any 400 looks broken |
254| A "last 30 days" list is full of acquisitions and rumors | Funding feeds mix in acquisitions, VC **fund** closes (a firm raising its own fund), and rumors ("in talks to", "reportedly raising") | The cleanup step must return `is_closed_round` and `is_acquisition`, and you must filter on both |
255| A "last 30 days" amount is absurd | Headline parsing produced **$600,000,000,000** from an article about AI budgets, and `0.261` USD tagged as debt | Require the amount to reconcile with the linked article, or drop the amount and name only the round |
256| Empty model response with `finish_reason=length` | Reasoning overran the ceiling | Retry up to 3 times. **Never record it as an abstain** |
257| Vendor timeout mid-batch | Transient | Retry once, then mark the row `error` and **exclude it from any hit-rate denominator. An API error is never a data verdict** |
258 
259### Hard rules
260 
261- **Pace company lookups at about 1 request per second.** Zero rate-limit errors were observed at
262 0.8 req/s across 37 requests.
263- **Funding-signal feeds usually charge per record returned.** A `limit: 200` call spends 200
264 credits.
265- **Spintax is a random chooser and must never gate a personalization variable.**
266- **Never ship a model you have not graded on this prompt.** A cheaper model may look better on
267 paper; if the abstain rule, the character cap, the lowercase-first rule and the no-date rule were
268 never verified on it, it is untested. A $0.11 saving per 1,000 rows is not worth that.
269 

Discussion