Signal Builder - the judgment layer (05)
Signal vendors sell facts ("company X raised", "company Y is hiring"). Scrapers sell
pages. Neither tells you whether a fact is a Pain-led trigger, a Value-led opening, or noise for
this particular client. That call - which finding matters, how much, and what to do
with it - is this skill. It runs on model judgment alone: no API key, no credits spent.
Two ways in:
| You have |
Mode |
| One prospect - a URL, pasted content, or a content file |
A - Single prospect |
| Chain output - records.jsonl and run folders from 01/02/03/04 |
B - Batch over records |
Both modes need icp_context, and both produce the same two outputs: a human report
and chain records.
icp_context and icp_shape
icp_context (required): what the client sells, who they sell to, and what pain it
removes. 2-4 sentences is enough. If it's missing, ask once - accept a pasted
description or a file path. A signal only exists relative to this context: a hiring
spree is a 9 for one client and noise for another.
icp_shape picks the calibration catalog:
| Shape |
Prospects look like |
Catalog |
b2b-saas |
funded companies, title-based orgs, tech stacks, exec moves |
references/signal-types-b2b-saas.md |
vertical-smb |
local or owner-operated - studios, venues, clinics, restaurants, trades |
references/signal-types-vertical-smb.md |
If the user doesn't pass a shape, infer it from icp_context (end-customers that are
local, Maps-addressable businesses → vertical-smb; otherwise b2b-saas) and state
which one you picked. Read the catalog before scanning - it carries the per-shape
scoring adjustments, not just examples. For a vertical neither catalog fits, see
references/calibration-guide.md.
Mode A - single prospect
- Get content. Pasted or file content: use as-is. URL only: if the
03-firecrawl-research skill is installed, run its standard mode and use the scan
output; otherwise fetch the high-signal pages directly (homepage, /about, /careers,
/pricing, /blog) with whatever fetch tool is available. List any page you couldn't
get - a missing careers page is a coverage gap to report, not a zero signal.
- Judge (method below) and write the report. If this prospect is part of a chain run,
also emit a record (Mode B, step 3).
Mode B - batch over chain records
scripts/signal_io.py is stdlib-only Python - no installs. Paths below are relative
to this skill's directory.
Collect - assemble one evidence bundle per domain from upstream outputs:
python3 scripts/signal_io.py collect \
--records path/to/03-run/records.jsonl \
--records path/to/04-run/records.jsonl \
--scrape-run path/to/03-run \
--crustdata-run path/to/04-run \
--out runs/2026-07-21-acme-batch
--records takes any chain records.jsonl - 01, 02 (Maps), and 04 run
folders all emit one.
Every flag except --out is optional and repeatable; pass whatever upstream
artifacts exist. Records merge by domain (upstream fields are kept, never
overwritten). Output: inputs/<domain>.md bundles, records_in.jsonl,
tracker.json, meta.json. Re-running collect is safe - done domains stay done.
Bundles with no page content and no structured signals are flagged thin in the
tracker. For a thin batch, run 03-firecrawl-research first; for 10 or fewer
domains it's fine to fetch pages yourself as in Mode A and judge from that.
Judge each pending domain: read inputs/<domain>.md, apply the method below,
write the signal JSON to a temp file, then:
Emit - validate and append to the chain output:
python3 scripts/signal_io.py emit --run runs/2026-07-21-acme-batch \
--domain acme.com --signals-json /tmp/acme-signals.json
Emit enforces the record contract (fields, score range, approach enum, non-empty
sentence and source), merges signals into the upstream record, appends to
records.jsonl, and marks the domain done. On a validation failure it names the
bad signal and rule - fix the JSON and re-emit. Re-emitting a domain replaces its
record instead of duplicating it.
Resume / finish: python3 scripts/signal_io.py status --run runs/<id> shows
done/pending. When all domains are done, write runs/<id>/report.md (per-domain
report sections plus segment-level patterns you noticed) and point the user at
records.jsonl - that file is what 06-resolution and the router consume.
For batches over ~100 domains, judge in passes of 25 and re-check score distribution
between passes (see the honesty threshold below) so drift doesn't compound.
How to judge
Work one domain at a time with the shape catalog open.
- Pull candidates from every source. Page content: careers/jobs, blog/news,
pricing, integrations, footer tech clues. Structured blocks: funding rounds,
headcount and department growth, recent hires, job postings. Enrichment beats
inference - if a structured block says 12 recent hires, don't re-derive headcount
from prose.
- A structured signal is a fact, not a verdict. "18 recent hires" becomes a
signal only when the roles map to the pain the client solves. That mapping is the
judgment. The same goes in reverse: don't discard a boring-looking fact before
checking it against icp_context.
- Score each candidate 1-10 against the anchors below, adjusted by the catalog.
- Assign an approach per signal (table below).
- Provenance, every signal. Web signal: quote the sentence verbatim from the page
and cite the page URL. Structured signal: state the fact exactly as the data
supports it (round, amount, date; name, title, start date) and cite
vendor:section:domain, e.g. crustdata:funding:acme.com. A finding you can't
source isn't a signal - drop it or report it as a gap.
- Prefer combinations. Hiring for the role plus competitor friction outranks
either alone. Write the combination as one signal: lead sentence and source_url
from the strongest piece of evidence, the rest in
notes.
- Cap at 5 signals, always end with a fallback. Downstream copy uses the top 2-3;
a 12-item list is unranked noise. If nothing clears a 3, say so plainly - the
fallback is the campaign in that case.
Scoring anchors
| Score |
Means |
Typical evidence |
| 9-10 |
Switching intent or acute pain, now |
competitor in place plus visible friction; active job post for the exact role the product replaces or augments; public complaint that maps 1:1 to the value prop |
| 7-8 |
One strong behavioral signal |
leadership hire in the relevant function; fresh funding aimed at the client's area; JD language describing the exact pain; negative reviews in the problem area |
| 5-6 |
Problem-space evidence |
adjacent tools in the stack; hiring around (not in) the function; growth or expansion straining the relevant process |
| 3-4 |
Contextual fit only |
right stage, size, or business model - no behavioral evidence |
| 1-2 |
ICP match only |
fallback territory |
Score honestly - downstream copy calibrates tone on the number, so an inflated 8 reads
as false familiarity in the email. If more than 40% of a batch scores 8+, re-check
those domains against the anchors before emitting more.
Approach per signal
| Approach |
Use when |
Email shape |
| Pain-led |
the signal names a specific, acute pain you can ask about |
"Noticed [signal]. Teams in that spot usually deal with [pain]. Is that you?" |
| Value-led |
you can hand over something genuinely useful tied to the signal before asking for anything |
"Put together [artifact] for you - [insight]." |
| Segment fallback |
moderate or contextual signals; every fallback |
lead with the most common pain for the profile, ask if it resonates |
Signals scoring 8+ usually carry Pain-led. Pick Value-led only when a real artifact exists to
give (an audit, a teardown, a list), not a hypothetical one. 4 and under: Segment fallback.
Output 1 - the report
One section per judged prospect. Keep this structure so reports stay comparable
across prospects and runs:
## Signal Scan: {Company}
**URL:** {url} · **Shape:** {icp_shape}
**Summary:** {1-2 sentences: what they do, their situation, the most interesting finding}
### Signal 1: {Name} (Score: X/10)
**Detected:** {the factual finding}
**Implies:** {what their team is dealing with day to day}
**Approach:** {Pain-led | Value-led | Segment fallback}
**Angle:** {one sentence - the core message this signal enables}
**Source:** {url or vendor ref} - "{the quotable sentence}"
**Data points for copy:** {variable}: {value} · {variable}: {value}
### Signal 2: ... (highest score first)
### Fallback (Score: X/10)
**Assumption:** {most common pain for this profile}
**Approach:** Segment fallback
**Angle:** {one sentence}
Close the report with the handoff: top 1-2 signals plus the fallback are the campaign
set to pass to email-writer (or whatever writes the copy) - signal 1 drives the lead
campaign, the fallback drives the catch-all.
Output 2 - the chain record
What 06-resolution and the router parse. One JSON object per domain in
records.jsonl: the upstream record with two added fields. Upstream fields
(company, domain, person, filters_matched, anything else) pass through
untouched.
{"company": "Acme Corp", "domain": "acme.com", "person": null,
"filters_matched": ["has_homepage", "has_careers", "Series A $18M"],
"signals": [
{"signal_type": "hiring-role-replaced",
"signal_sentence": "You'll own supplier escalations across email, WhatsApp and phone.",
"source_url": "https://acme.com/careers",
"score": 9, "approach": "Pain-led",
"evidence_date": "2026-07", "notes": "2 open Supply Chain Coordinator roles"}
],
"fallback_approach": {"approach": "Segment fallback",
"angle": "Mid-market distributors usually drown in manual PO chasing"}}
Field rules (emit enforces them):
signal_type - kebab-case; use the catalog's type name where one fits, free-form otherwise
signal_sentence - the referenceable evidence: verbatim quote for web signals, faithful fact statement for structured signals
source_url - http(s):// page URL, or vendor:section:domain for structured signals
score - integer 1-10 · approach - Pain-led | Value-led | Segment fallback
- signals sorted by score descending, max 5;
evidence_date and notes optional
fallback_approach - always present: {approach, angle}
Failure modes to avoid
- Embellished quotes. The signal_sentence for a web signal is copied from the
page, not improved. If the finding is a composition ("3 roles in 30 days"), the
composition goes in the report and
notes; the sentence stays verbatim.
- Generic signals. "They're growing" is not a signal. "Posted 3 Supply Chain
Coordinator roles in the last 30 days" is.
- Inflated scores. A 4/10 batch is useful information - it tells the client this
segment needs a Segment fallback catch-all, not personalization theater.
- Invented sources. Never cite a URL you didn't see in the bundle or fetch. If
coverage was thin, say which pages were missing instead.
- Editing upstream fields. The record is additive; you add
signals and
fallback_approach, nothing else changes.
Scope boundaries
| Not this skill |
Use instead |
| Scrape sites at scale |
03-firecrawl-research |
| Pull funding / headcount / hires from vendors |
04-crustdata-signals |
| Find or verify email addresses |
06-resolution-email-person |
| Write the actual emails |
email-writer |
References
references/signal-types-b2b-saas.md - B2B SaaS calibration catalog
references/signal-types-vertical-smb.md - vertical SMB calibration catalog
references/calibration-guide.md - reply-gym format: calibrate a new vertical from classified reply data
1---2name: 05-signal-builder3description: Rank a prospect's outbound signals and pick the campaign approach (Pain-led / Value-led / Segment fallback) - the judgment layer (step 05) of the API-first GTM chain. Takes scraped website markdown (03-firecrawl-research), vendor signals like funding / headcount / recent hires (04-crustdata-signals), or just a URL or pasted content, and emits ranked provenance-backed signals: the exact quotable sentence, source URL, 1-10 score, and recommended approach. Use whenever the user wants to score, rank, or prioritize prospects or accounts, decide outreach angles, or asks "which of these should we email first", "what's the angle for this account", "rank these signals", or "turn this scrape into campaign angles" - and whenever a records.jsonl or enrichment output is ready for judgment before writing copy. Scoring lives here; scraping belongs to firecrawl-research, vendor pulls to crustdata-signals, email finding to resolution.4---56# Signal Builder - the judgment layer (05)78Signal vendors sell facts ("company X raised", "company Y is hiring"). Scrapers sell9pages. Neither tells you whether a fact is a Pain-led trigger, a Value-led opening, or noise for10this particular client. That call - which finding matters, how much, and what to do11with it - is this skill. It runs on model judgment alone: no API key, no credits spent.1213Two ways in:1415| You have | Mode |16|---|---|17| One prospect - a URL, pasted content, or a content file | A - Single prospect |18| Chain output - records.jsonl and run folders from 01/02/03/04 | B - Batch over records |1920Both modes need `icp_context`, and both produce the same two outputs: a human report21and chain records.2223## icp_context and icp_shape2425`icp_context` (required): what the client sells, who they sell to, and what pain it26removes. 2-4 sentences is enough. If it's missing, ask once - accept a pasted27description or a file path. A signal only exists relative to this context: a hiring28spree is a 9 for one client and noise for another.2930`icp_shape` picks the calibration catalog:3132| Shape | Prospects look like | Catalog |33|---|---|---|34| `b2b-saas` | funded companies, title-based orgs, tech stacks, exec moves | `references/signal-types-b2b-saas.md` |35| `vertical-smb` | local or owner-operated - studios, venues, clinics, restaurants, trades | `references/signal-types-vertical-smb.md` |3637If the user doesn't pass a shape, infer it from icp_context (end-customers that are38local, Maps-addressable businesses → `vertical-smb`; otherwise `b2b-saas`) and state39which one you picked. Read the catalog before scanning - it carries the per-shape40scoring adjustments, not just examples. For a vertical neither catalog fits, see41`references/calibration-guide.md`.4243## Mode A - single prospect44451. Get content. Pasted or file content: use as-is. URL only: if the46 03-firecrawl-research skill is installed, run its standard mode and use the scan47 output; otherwise fetch the high-signal pages directly (homepage, /about, /careers,48 /pricing, /blog) with whatever fetch tool is available. List any page you couldn't49 get - a missing careers page is a coverage gap to report, not a zero signal.502. Judge (method below) and write the report. If this prospect is part of a chain run,51 also emit a record (Mode B, step 3).5253## Mode B - batch over chain records5455`scripts/signal_io.py` is stdlib-only Python - no installs. Paths below are relative56to this skill's directory.57581. **Collect** - assemble one evidence bundle per domain from upstream outputs:5960 ```bash61 python3 scripts/signal_io.py collect \62 --records path/to/03-run/records.jsonl \63 --records path/to/04-run/records.jsonl \64 --scrape-run path/to/03-run \65 --crustdata-run path/to/04-run \66 --out runs/2026-07-21-acme-batch67 ```6869 `--records` takes any chain `records.jsonl` - 01, 02 (Maps), and 04 run70 folders all emit one.7172 Every flag except `--out` is optional and repeatable; pass whatever upstream73 artifacts exist. Records merge by domain (upstream fields are kept, never74 overwritten). Output: `inputs/<domain>.md` bundles, `records_in.jsonl`,75 `tracker.json`, `meta.json`. Re-running collect is safe - done domains stay done.7677 Bundles with no page content and no structured signals are flagged `thin` in the78 tracker. For a thin batch, run 03-firecrawl-research first; for 10 or fewer79 domains it's fine to fetch pages yourself as in Mode A and judge from that.80812. **Judge** each pending domain: read `inputs/<domain>.md`, apply the method below,82 write the signal JSON to a temp file, then:83843. **Emit** - validate and append to the chain output:8586 ```bash87 python3 scripts/signal_io.py emit --run runs/2026-07-21-acme-batch \88 --domain acme.com --signals-json /tmp/acme-signals.json89 ```9091 Emit enforces the record contract (fields, score range, approach enum, non-empty92 sentence and source), merges signals into the upstream record, appends to93 `records.jsonl`, and marks the domain done. On a validation failure it names the94 bad signal and rule - fix the JSON and re-emit. Re-emitting a domain replaces its95 record instead of duplicating it.96974. **Resume / finish**: `python3 scripts/signal_io.py status --run runs/<id>` shows98 done/pending. When all domains are done, write `runs/<id>/report.md` (per-domain99 report sections plus segment-level patterns you noticed) and point the user at100 `records.jsonl` - that file is what 06-resolution and the router consume.101102For batches over ~100 domains, judge in passes of 25 and re-check score distribution103between passes (see the honesty threshold below) so drift doesn't compound.104105## How to judge106107Work one domain at a time with the shape catalog open.1081091. **Pull candidates from every source.** Page content: careers/jobs, blog/news,110 pricing, integrations, footer tech clues. Structured blocks: funding rounds,111 headcount and department growth, recent hires, job postings. Enrichment beats112 inference - if a structured block says 12 recent hires, don't re-derive headcount113 from prose.1142. **A structured signal is a fact, not a verdict.** "18 recent hires" becomes a115 signal only when the roles map to the pain the client solves. That mapping is the116 judgment. The same goes in reverse: don't discard a boring-looking fact before117 checking it against icp_context.1183. **Score each candidate 1-10** against the anchors below, adjusted by the catalog.1194. **Assign an approach** per signal (table below).1205. **Provenance, every signal.** Web signal: quote the sentence verbatim from the page121 and cite the page URL. Structured signal: state the fact exactly as the data122 supports it (round, amount, date; name, title, start date) and cite123 `vendor:section:domain`, e.g. `crustdata:funding:acme.com`. A finding you can't124 source isn't a signal - drop it or report it as a gap.1256. **Prefer combinations.** Hiring for the role plus competitor friction outranks126 either alone. Write the combination as one signal: lead sentence and source_url127 from the strongest piece of evidence, the rest in `notes`.1287. **Cap at 5 signals, always end with a fallback.** Downstream copy uses the top 2-3;129 a 12-item list is unranked noise. If nothing clears a 3, say so plainly - the130 fallback is the campaign in that case.131132## Scoring anchors133134| Score | Means | Typical evidence |135|---|---|---|136| 9-10 | Switching intent or acute pain, now | competitor in place plus visible friction; active job post for the exact role the product replaces or augments; public complaint that maps 1:1 to the value prop |137| 7-8 | One strong behavioral signal | leadership hire in the relevant function; fresh funding aimed at the client's area; JD language describing the exact pain; negative reviews in the problem area |138| 5-6 | Problem-space evidence | adjacent tools in the stack; hiring around (not in) the function; growth or expansion straining the relevant process |139| 3-4 | Contextual fit only | right stage, size, or business model - no behavioral evidence |140| 1-2 | ICP match only | fallback territory |141142Score honestly - downstream copy calibrates tone on the number, so an inflated 8 reads143as false familiarity in the email. If more than 40% of a batch scores 8+, re-check144those domains against the anchors before emitting more.145146## Approach per signal147148| Approach | Use when | Email shape |149|---|---|---|150| **Pain-led** | the signal names a specific, acute pain you can ask about | "Noticed [signal]. Teams in that spot usually deal with [pain]. Is that you?" |151| **Value-led** | you can hand over something genuinely useful tied to the signal before asking for anything | "Put together [artifact] for you - [insight]." |152| **Segment fallback** | moderate or contextual signals; every fallback | lead with the most common pain for the profile, ask if it resonates |153154Signals scoring 8+ usually carry Pain-led. Pick Value-led only when a real artifact exists to155give (an audit, a teardown, a list), not a hypothetical one. 4 and under: Segment fallback.156157## Output 1 - the report158159One section per judged prospect. Keep this structure so reports stay comparable160across prospects and runs:161162```markdown163## Signal Scan: {Company}164**URL:** {url} · **Shape:** {icp_shape}165**Summary:** {1-2 sentences: what they do, their situation, the most interesting finding}166167### Signal 1: {Name} (Score: X/10)168**Detected:** {the factual finding}169**Implies:** {what their team is dealing with day to day}170**Approach:** {Pain-led | Value-led | Segment fallback}171**Angle:** {one sentence - the core message this signal enables}172**Source:** {url or vendor ref} - "{the quotable sentence}"173**Data points for copy:** {variable}: {value} · {variable}: {value}174175### Signal 2: ... (highest score first)176177### Fallback (Score: X/10)178**Assumption:** {most common pain for this profile}179**Approach:** Segment fallback180**Angle:** {one sentence}181```182183Close the report with the handoff: top 1-2 signals plus the fallback are the campaign184set to pass to email-writer (or whatever writes the copy) - signal 1 drives the lead185campaign, the fallback drives the catch-all.186187## Output 2 - the chain record188189What 06-resolution and the router parse. One JSON object per domain in190`records.jsonl`: the upstream record with two added fields. Upstream fields191(`company`, `domain`, `person`, `filters_matched`, anything else) pass through192untouched.193194```json195{"company": "Acme Corp", "domain": "acme.com", "person": null,196 "filters_matched": ["has_homepage", "has_careers", "Series A $18M"],197 "signals": [198 {"signal_type": "hiring-role-replaced",199 "signal_sentence": "You'll own supplier escalations across email, WhatsApp and phone.",200 "source_url": "https://acme.com/careers",201 "score": 9, "approach": "Pain-led",202 "evidence_date": "2026-07", "notes": "2 open Supply Chain Coordinator roles"}203 ],204 "fallback_approach": {"approach": "Segment fallback",205 "angle": "Mid-market distributors usually drown in manual PO chasing"}}206```207208Field rules (emit enforces them):209210- `signal_type` - kebab-case; use the catalog's type name where one fits, free-form otherwise211- `signal_sentence` - the referenceable evidence: verbatim quote for web signals, faithful fact statement for structured signals212- `source_url` - `http(s)://` page URL, or `vendor:section:domain` for structured signals213- `score` - integer 1-10 · `approach` - `Pain-led` | `Value-led` | `Segment fallback`214- signals sorted by score descending, max 5; `evidence_date` and `notes` optional215- `fallback_approach` - always present: `{approach, angle}`216217## Failure modes to avoid218219- **Embellished quotes.** The signal_sentence for a web signal is copied from the220 page, not improved. If the finding is a composition ("3 roles in 30 days"), the221 composition goes in the report and `notes`; the sentence stays verbatim.222- **Generic signals.** "They're growing" is not a signal. "Posted 3 Supply Chain223 Coordinator roles in the last 30 days" is.224- **Inflated scores.** A 4/10 batch is useful information - it tells the client this225 segment needs a Segment fallback catch-all, not personalization theater.226- **Invented sources.** Never cite a URL you didn't see in the bundle or fetch. If227 coverage was thin, say which pages were missing instead.228- **Editing upstream fields.** The record is additive; you add `signals` and229 `fallback_approach`, nothing else changes.230231## Scope boundaries232233| Not this skill | Use instead |234|---|---|235| Scrape sites at scale | 03-firecrawl-research |236| Pull funding / headcount / hires from vendors | 04-crustdata-signals |237| Find or verify email addresses | 06-resolution-email-person |238| Write the actual emails | email-writer |239240## References241242- `references/signal-types-b2b-saas.md` - B2B SaaS calibration catalog243- `references/signal-types-vertical-smb.md` - vertical SMB calibration catalog244- `references/calibration-guide.md` - reply-gym format: calibrate a new vertical from classified reply data