Sales Outreach
One job: what do I say — and can they tell I actually looked?
The copy is the easy half. The reason a touch lands is that it opens with something
specific, recent, and true about the person reading it: a post they wrote last week, the
round they closed last month, the role they just opened, the exec they just hired. This
skill pulls that from Crustdata first, then writes around it.
The hook comes before the copy. Always. A beautifully written email built on
"I saw you're in fintech" is a template. A blunt three-line email built on "your Tuesday
post about the on-call rotation" gets replies. If no real signal exists for a target, say
so and offer a different angle — never invent a hook.
Code Mode ground rules (read once, apply everywhere)
All Crustdata calls run inside the execute tool of the Crustdata MCP server
(install.crustdata.com/mcp) as a plain-JavaScript
script. Every snippet below follows these rules:
- Plain JavaScript only. Author against the typed tool surface from
get_schema, but
the script body carries zero type annotations — a : Type, as, or generic is a parse
error that fails the whole run before any tool call, at zero spend.
- Open every script with a source-labeled comment:
// user query: ... for the
user's literal ask, // model query: ... for a derived step. Scripts without one are
rejected before running.
- One I/O primitive:
const r = await callTool(name, params) → { ok: true, data }
or { ok: false, status, errorType, message }. Always branch on r.ok — a failed
call does not abort the script, so an unchecked failure silently proceeds on empty data
and looks like "they have no recent activity". That is exactly the failure that
produces a fabricated hook.
fields is a response whitelist. The result carries only the groups you list; an
omitted group reads as undefined later and looks like missing data. List every group
you read.
- Return the smallest projection. Only what the script returns reaches the model. For
outreach that means hook candidates — date, one line of text, a link — never raw posts
or whole profiles.
- Fan out independent work with
await parallelMap(items, fn); chunk first where
there is a per-call cap. Never parallelize cursor pagination or dependent stages.
checkpoint(acc) after a costly stage so a timeout still returns the hooks you paid
for.
Step 0 — Intake (always, before writing a word)
- Config backbone, never blocking. If
config/persona-profile.md or
config/gtm-config.md exist in the working directory, read them: the rep's voice
(tone, sentence length, sign-off, never-do list), what they sell, who buys. If they
don't exist, ask one inline question ("What do you sell, and what's your sign-off?")
or point at the icp-builder skill, which writes both files from one LinkedIn URL.
A missing config never blocks a draft — write in a plain, direct voice and say you
did.
- Confirm the ask in one line: WHO (person, account, or list) — CHANNEL (email /
call / LinkedIn) — MOMENT (first touch / follow-up / reply / objection) — GOAL
(a meeting? an answer? an intro?).
- Pull the hook (next section). One sharp hook beats five soft ones.
THE HOOK LAYER (this is the skill)
Four sources, cheapest and most specific first. Pull one; stop when you have
something good. Running all four on one person is usually a waste of credits.
Hook 1 — what they said (their posts)
The best hook there is, because it's theirs and it's dated. Keyed on the person's
professional network profile URL. 1 credit per post, so limit is the spend dial —
5 is plenty to find one hook.
// user query: write a cold email to https://www.linkedin.com/in/example — find a hook first
const url = "https://www.linkedin.com/in/example";
const cutoffMs = Date.now() - 60 * 24 * 3600 * 1000; // hooks older than ~60 days read as stale
const r = await callTool("social_post_list_live", {
professional_network_profile_url: url, // person key; company_domain / crustdata_company_id key the company feed
limit: 5, // 1 CREDIT PER POST — set this deliberately
fields: ["text", "date_posted", "post_type", "engagement", "hyperlinks", "share_url"], // whitelist
});
if (!r.ok) return { hooks: [], error: r.message };
const parse = (d) => { const t = Date.parse(d ?? ""); return Number.isNaN(t) ? null : t; };
const posts = (r.data.posts ?? []).map(p => ({
date: p.date_posted,
ms: parse(p.date_posted),
type: p.post_type,
url: p.share_url, // the link to the post itself — the rep needs it to verify in five seconds
text: (p.text ?? "").slice(0, 400),
reactions: p.engagement?.total_reactions,
comments: p.engagement?.total_comments,
links: p.hyperlinks?.other_urls ?? [],
}));
return {
fresh: posts.filter(p => p.ms !== null && p.ms >= cutoffMs),
stale: posts.filter(p => p.ms !== null && p.ms < cutoffMs).length,
undated: posts.filter(p => p.ms === null), // never silently drop these — read them yourself
};
Reading the result: prefer a post they wrote over a reshare with no commentary, and
an opinion over an announcement. A post with a real take gives you a sentence you can
agree or disagree with, which is what earns a reply.
Hook 2 — what happened to the company (funding, news, growth)
company_identify is free but fuzzy — one domain can match several companies. Take the
top confidence_score, then enrich by id so you pay for exactly one match (2 credits).
Enriching by domain or name instead can fuzzy-match several companies and bill 2 credits
per returned match, which exact_match: true narrows but does not guarantee down to one.
// model query: recent company events at acme.com worth opening an email with
const idr = await callTool("company_identify", { domains: ["acme.com"] }); // free, one identifier type per call
if (!idr.ok) return { error: idr.message };
const matches = idr.data[0]?.matches ?? [];
const best = matches.slice().sort((a, b) => (b.confidence_score ?? 0) - (a.confidence_score ?? 0))[0];
if (!best) return { error: "no company match for acme.com" };
const companyId = best.company_data?.basic_info?.crustdata_company_id ?? best.company_data?.crustdata_company_id;
const er = await callTool("company_enrich", {
crustdata_company_ids: [companyId], // one id = one match = exactly 2 credits
exact_match: true, // narrows the fuzzy match; by-name/by-domain can still return >1 -> N x 2 cr
fields: ["basic_info", "funding", "headcount", "news"], // whitelist: list EVERY group you read
});
if (!er.ok) return { error: er.message };
const cd = er.data[0]?.matches?.[0]?.company_data ?? {};
const news = (firstArray(cd.news) ?? []).slice(0, 10).map(n => ({
title: n.article_title, url: n.article_url, date: n.article_publish_date,
}));
return {
name: cd.basic_info?.name,
lastRound: cd.funding?.last_round_type,
lastRaise: cd.funding?.last_fundraise_date,
lastRoundUsd: cd.funding?.last_round_amount_usd,
investors: cd.funding?.investors,
headcount: cd.headcount?.total,
growth12m: cd.headcount?.growth_percent?.["12m"],
news,
};
Funding is a hook with a shelf life. A round closed 3 weeks ago is "congrats + here's what
usually breaks next"; a round closed 14 months ago is background, not an opener. News
articles carry a real publish date — use it in the sentence and link the article if you
reference a specific claim.
Hook 3 — what they're hiring for
Job posts are the most honest thing a company publishes. The roles they're opening are
the problems they've agreed to spend money on.
// model query: what acme.com is hiring for, and whether their posts name our category
const domain = "acme.com";
const term = "<the tool you displace>"; // a brand/product/tech name -> ALWAYS [.] exact token, never (.)
const [fresh, named] = await parallelMap([
{
filters: eq("company.basic_info.primary_domain", domain),
sorts: [{ field: "metadata.date_added", order: "desc" }],
fields: ["job_details", "location", "metadata"],
limit: 15,
},
{
filters: and_(
eq("company.basic_info.primary_domain", domain),
exactToken("content.description", term) // [.] guarantees the literal token; (.) is typo-tolerant and pulls lookalikes
),
fields: ["job_details"],
limit: 1, // count query — read total_count, don't download rows
},
], async (params) => await callTool("job_search", params));
if (!fresh.ok) return { error: fresh.message };
return {
newest: fresh.data.job_listings.map(j => ({
title: j.job_details?.title,
city: j.location?.city,
added: j.metadata?.date_added,
})),
total_open: fresh.data.total_count,
postings_naming_term: named.ok ? named.data.total_count : null,
};
Scope roles by title, not job_details.category — the category field is coarse and
most postings land in the catch-all. If you want to say "you've got four data engineering
roles open", count titles.
The hook writes itself from the shape: three SDR reqs open means a pipeline target they
just raised. A first security hire means they're about to buy security tooling. Name the
actual role and when it was posted.
Hook 4 — a signal that already fired
The best case: the work is already done. sales-prospecting's champion tracker,
expansion radar, and account ranking all emit dated signal rows. Hand one straight in and
skip the pull entirely.
A handed-over row looks like: person — was [role] at [your customer] — now [title] at
[new company] — landed [date]. That IS the hook, and it's already dated. Same for
"raised Series B on 2026-04-12" or "hired a VP Data 6 weeks ago".
Do not re-pull what you were handed. Spend a credit only to add colour (their first post
in the new job, say) and only if the draft needs it.
Signal → angle
| Signal |
The angle |
The opening move |
| Fresh raise (<3 months) |
Scaling pain — what breaks at the next headcount |
Congratulate in half a sentence, then the pain the round creates |
| New exec in your function |
New mandate, 90-day plan, fresh budget |
"New in seat" energy: what are they inheriting |
| Champion moved to a new company |
They already know you work |
"Congrats — you know how this goes" (warmest touch there is) |
| Hiring surge in a relevant team |
They've agreed the problem is real |
Reference the roles; ask what happens between now and the hires landing |
| A post with a real opinion |
Agree, extend, or politely disagree |
Respond to the idea, not to the fact that they posted |
| Tech named in job posts |
Stack fit or displacement |
Name the tool, name the seam |
| Company news / launch |
Timing |
Tie the launch to the pain your product removes |
No hook, no fabrication
If the pulls come back empty — no recent posts, funding two years old, no open roles —
say that out loud and offer the alternatives instead of inventing a reference:
- Write from a role-level insight ("every VP Eng at 300 people hits this") and label
it as such — a good generic beats a fake specific.
- Pull a hook on a different person at the same account who is active — if you already
have their profile URL (from the user's list, or from sales-prospecting /
account-research, which do the person discovery), run Hook 1 on them instead.
- Use the account's own words:
social_post_list_live keyed on company_domain gives
the company feed when the individual is quiet.
- Recommend waiting for a signal and setting up the watcher in sales-prospecting's
champion tracker.
A hook must pass three tests before it goes in a draft: is it theirs (not their
industry's), is it dated (post ≤60 days, funding/news ≤90 days), and would they
recognize it in one read.
THE TOUCH TYPES
1. Cold email
Under 90 words. Subject 3-5 words, lowercase-ish, no clickbait, no "quick question".
- Line 1 — the hook. Their thing, dated, in their language. The reader should think
"yes, I did that".
- Line 2 — the bridge. One sentence connecting their thing to the pain you solve.
- Line 3 — one concrete outcome. A number or a named customer situation, not a value
prop.
- Line 4 — one ask. The lowest-friction version that still moves ("worth 15 minutes
Thursday?" or "want me to send the two-line version?").
- Their sign-off, if the persona config carries one. The config written by icp-builder records tone and style but not a sign-off, so ask for it inline the first time and reuse it after.
One hook, one idea, one ask. Offer 2-3 angle variants for testing (different hook, or
same hook different pain) and say which one you'd send.
2. Call script
- Opener + permission line ("caught you cold — 30 seconds and you can tell me to go
away?").
- Reason for the call, tied to the same hook. Cold calls die on "just reaching out".
- Value hypothesis in one sentence, framed as a guess they can correct.
- 2-3 open discovery questions — how they handle it today, what it costs them.
- The ask: a meeting, not a sale.
- Objection branches, written out: not interested / send me an email / we already use
something / no budget / gatekeeper. Each is one acknowledgment plus one question that
keeps the call alive.
- 15-second voicemail and a one-line follow-up text, both referencing the same
hook so the touches stack.
3. LinkedIn touches
- Connection note — 300 characters max, no pitch. The hook plus a reason to
connect.
- First DM — value or a smart question. Still no pitch.
- Follow-up — a soft ask, only after a reply or a signal.
- Break-up — one line, gracious, leaves the door open.
Before any of it, suggest 1-2 of their recent posts worth a genuine comment — you
already have them from Hook 1, at no extra cost. A real comment three days before a
connection request does more than any first line.
4. Reply handler
Classify first, then advance. Match their length and their energy — a two-line reply gets
a two-line answer.
| Reply type |
The move |
| Interested |
Propose two concrete times. Nothing else. Do not re-pitch. |
| Question |
Answer it directly in one paragraph, then the ask. |
| Objection |
Go to talk-tracks (below). |
| Referral out ("talk to X") |
Thank them, ask for the intro or permission to name them, then open the new thread with the referral as the hook. |
| Not now |
Get the date. "Should I come back in Q1?" Then actually log it. |
| Hard no |
One gracious line, exit. No last-ditch pitch. |
| Unsubscribe |
Honor immediately. No counter-offer, no "just confirming". Remove from every sequence. |
5. Objection talk-tracks
Diagnose the real objection first. "Too expensive" is usually "value unclear" or "no
internal champion". "Bad timing" is usually "not a priority". Say what you think the real
one is before answering it.
Then: acknowledge → reframe → one real proof point → soft re-ask. One proof point,
and it must be real — a named situation, a number you can defend. Never invent a case
study.
Give two variants: direct (for a buyer who's blunt) and soft (for a relationship
you're still building).
Playbook mode: the team's top 8-10 objections in one document, each with the
diagnosis, both variants, and the trap to avoid. This one is worth rendering as a page —
see the artifact rule under Rules.
6. Sequence builder
- The cadence table first — day, channel, angle, personalization level. Vary the
angle across touches: problem → proof → a different persona's pain → social proof →
break-up. Never send the same idea five times in different words.
- Then write every touch using types 1-3.
- Mark each touch
1:1 (rep writes a custom line) vs merge-field (scales across
the list), and flag exactly where the rep must add something only they know.
- Hook freshness: touch 1 carries the pulled hook. By touch 4 that hook is two weeks
old — either re-pull or switch to an angle that doesn't depend on recency. Say which
in the table.
Typical shape (adapt to the motion, don't ship this as-is):
| Day |
Channel |
Angle |
Personalization |
| 1 |
Email |
The hook + the pain it implies |
1:1 |
| 3 |
LinkedIn |
Connection note, same hook, no pitch |
1:1 |
| 5 |
Call + voicemail |
Reason-for-call = the hook |
1:1 |
| 8 |
Email |
Proof point, in-thread |
merge-field |
| 12 |
Email |
A different persona's version of the pain |
merge-field |
| 18 |
Email |
Break-up, one line |
merge-field |
7. Signal-triggered drafts
Input: a signal that just fired (new funding, exec hire, champion moved, hiring surge),
usually handed over from sales-prospecting. Output: a draft that references the actual
trigger with its date, mapped through the signal → angle table above.
The rule that makes these work: the draft must be sendable the week the signal fired. A
"congrats on the round" email six weeks late is worse than nothing. If the signal is
already stale, say so and re-angle.
To keep signals flowing automatically, point the user at sales-prospecting's champion
tracker, which sets up the recurring watcher.
THE NO-SLOP RULE (every draft, no exceptions)
Run this pass on every piece of copy before showing it. A touch that smells like AI is
worse than no touch — it tells the reader they were on a list.
Kill on sight:
- Em dashes. Use a period or a comma.
- "I hope this finds you well", "I wanted to reach out", "quick question", "circling
back", "just following up".
- "delve", "leverage", "streamline", "unlock", "elevate", "seamless", "robust".
- Rule-of-three constructions ("faster, cheaper, and more reliable").
- Negative parallelism ("not just X, but Y").
- Filler adverbs: "really", "actually", "truly", "incredibly".
- Fake familiarity ("Hope you're crushing it!") and fake urgency.
- Any sentence that would survive a find-and-replace of the company name. If it works for
any company, it works for none.
Then read it out loud. It should sound like the rep texting a smart colleague. If a
draft reads like a template, rewrite it — do not ship slop.
LIST MODE — many accounts, one run
When the input is a list (usually a scored list from sales-prospecting), the order is
always: hooks → copy → export. Not the reverse.
Step 1 — fan out the hooks. Independent per person, so parallelMap them. State the
cost before running: posts are 1 credit each, so limit: 3 across 40 people is up to 120
credits.
// user query: draft outreach for these 40 accounts — pull a hook for each first
const people = inputs.people; // [{ name, profileUrl, company, domain }]
const cutoffMs = Date.now() - 60 * 24 * 3600 * 1000;
const hooks = await parallelMap(people, async (p) => {
const r = await callTool("social_post_list_live", {
professional_network_profile_url: p.profileUrl,
limit: 3, // 1 cr/post x 3 x N people — quote this before running
fields: ["text", "date_posted", "post_type", "share_url"],
});
if (!r.ok) return { name: p.name, hook: null, reason: r.message };
const fresh = (r.data.posts ?? [])
.map(x => ({ date: x.date_posted, ms: Date.parse(x.date_posted ?? ""), url: x.share_url, text: (x.text ?? "").slice(0, 300) }))
.filter(x => !Number.isNaN(x.ms) && x.ms >= cutoffMs);
return { name: p.name, company: p.company, hook: fresh[0] ?? null, reason: fresh.length ? null : "no recent post" };
});
checkpoint(hooks);
return {
withHook: hooks.filter(h => h.hook),
needCompanyFallback: hooks.filter(h => !h.hook).map(h => ({ name: h.name, company: h.company, reason: h.reason })),
};
Everyone in needCompanyFallback gets Hook 2 or Hook 3 at the account level, or an
honest merge-field template. Never let an empty pull turn into a made-up first line.
Step 2 — contact enrichment is opt-in and cost-confirmed. Quote the worst case before
you run it, then run it:
"Business emails for 40 people: at most ~40 credits (1 per matched person). Adding
personal emails and phones raises the ceiling to 5 per person, so ~200. Business only —
go?"
// user query: get business emails for the 40 people on the list (worst case ~40 credits, confirmed)
const urls = inputs.profileUrls;
const batches = chunk(urls, 25); // hard cap: 25 URLs per call
const results = await parallelMap(batches, async (batch) => {
const r = await callTool("person_contact_enrich", {
professional_network_profile_urls: batch,
fields: ["contact.business_emails"], // narrow the request; see the cost note below
});
return r.ok ? r.data : batch.map(u => ({ matched_on: u, matches: [], error: r.message }));
});
const rows = results.flat();
return {
contacts: rows.map(m => ({
url: m.matched_on,
email: m.matches?.[0]?.person_data?.contact?.business_emails?.[0]?.email ?? null,
status: m.matches?.[0]?.person_data?.contact?.business_emails?.[0]?.status ?? null,
})),
unmatched: rows.filter(m => !m.matches?.length).map(m => m.matched_on),
};
Omitting fields requests all three tiers (business email, personal email, phone) and
takes the ceiling to 5 credits per matched person. Narrow the whitelist to the tiers you
actually need, and read credits_remaining on the response for the real spend. Report
unmatched rows honestly — never pad a list with guessed email patterns.
Step 3 — hand off. Two artifacts, no credentials, no auto-send:
- Campaign CSV:
email, first_name, last_name, company, title, linkedin_url, hook_text, hook_date, hook_url, personalization_line, tier. The hook, its date, and the
link to it ride in the file so the rep can sanity-check every row before it sends.
- Campaign spec the user pastes into whatever sequencer they run: the sequence steps
with the full copy per step, the merge fields used, send window and timezone, daily
ramp (start at 20-30/day on a new domain), threading (follow-ups in-thread), and
stop-on-reply ON.
- Deliverability guardrails in the spec: warmed domain, at most one link in touch 1,
plain text over HTML, unsubscribe honored instantly.
Rules
- The hook comes first, and it is real or it is absent. Never fabricate a post, a
quote, a round, a mutual connection, or a case study. "I couldn't find a recent signal
for this person, here's a role-level angle instead" is a valid, honest answer.
- Date every reference in the draft and in your notes to the rep, so they can check
it in five seconds.
- One hook, one idea, one ask per touch. Shorter beats clever.
- Never auto-send anything. Email drafts stay drafts. This skill never writes to a CRM; any log entry is yours to make before
writing. Sequencer setup is a spec plus a CSV that the rep loads and starts themselves.
- Respect opt-outs completely and immediately. Never volume your way past a
compliance limit; if the user wants that, it's theirs to own explicitly.
- No-slop rule on every deliverable (full list above): no em dashes, no
"delve"/"leverage"/"streamline", no filler, nothing that survives a find-and-replace of
the company name.
- Costs are real: state expected spend before post pulls, list fan-outs, or contact
enrichment. Every
execute response carries credits and credits_remaining;
account_credits (free) reports the balance.
- Every deliverable ends with the handoff question: copy in chat, a CSV plus campaign
spec for your sequencer, or a rendered doc.
- Adapt the layout to the content — never let it hide anything. The brand system is
fixed; the layout is not. If real content doesn't fit — a long company or person name, a
12-word title, 200 rows — change the layout, not the content: let the card grow, wrap
instead of truncating, drop to one column, widen the column, raise the cap, or give the
wide thing its own scroll container. Never solve a fit problem by clipping a card,
ellipsing a name, or silently dropping rows. Where a cap really is unavoidable, say so
in the UI ("showing the top 50 of 214"). Look at the rendered output and fix what's cut
off before you hand it over.
- Icons in rendered output: Lucide, the dashboard's icon set, inlined as SVG with a
currentColor stroke. No emojis in artifact UI.
- Photos and logos are free — use them in rendered output.
basic_profile.profile_picture_permalink (person) rides in the basic_profile group of
any person record handed to you; basic_info.logo_permalink (company) comes
free from company_identify and from company_enrich's basic_info. Neither costs an
extra credit. Base64-inline both as data:image/jpeg;base64,... URIs — the media CDN
serves them as binary/octet-stream, so a remote <img src> renders blank. Monogram
fallback when an image is missing.
- Artifact branding: copy is chat-native by default, and the CSV stays a plain data
file — never render an artifact just to render one. But IF a deliverable is rendered as
a page or document (an objection playbook, a sequence spec, a campaign one-pager), it
carries the Crustdata brand lockup in the header or footer: a small uppercase
"Powered by" eyebrow plus the official Crustdata wordmark, linking to crustdata.com.
The wordmark pair ships in this skill's
assets/ — crustdata-logo-light.png (dark
text, for light backgrounds) and crustdata-logo-dark.png (white text, for dark
backgrounds). Base64-inline the theme-appropriate variant at ~17px height — never
hotlink; rendered artifacts cannot fetch remote images. Brand accent: #5547E2
(#8387FF on dark grounds). Body font: Geist when embeddable, else the system stack.
Error handling
- A failed call is the dangerous case, not the obvious one.
r.ok === false on a
post pull looks identical to "this person doesn't post". Branch on it, report it as an
error, and fall back to Hook 2 or Hook 3 — never let it become a generic first line
dressed up as personalization.
- 403 naming a field = a plan-gated projection. Drop that field from
fields and
re-run. 403 naming the endpoint (contact enrichment is the common one) = the plan
doesn't include it; say so plainly and hand off the list without emails rather than
guessing address patterns.
- Empty posts, empty news, no open roles is a normal outcome, not a failure. Report
which sources came back empty and take the no-hook path above.
company_identify returns several matches for one domain — that's expected, it's
fuzzy. Take the top confidence_score; if two are close, name both and ask.
- Unmatched rows in a list run are reported by name, never dropped and never
silently swapped for a template row.
Cost cheat sheet
| Call |
Cost |
company_identify, autocomplete, get_schema, account_credits |
Free |
social_post_list_live |
1 cr per post — limit is the spend dial |
company_enrich by id + exact_match: true |
2 cr for one match |
job_search |
~0.03 cr/result; limit: 1 + total_count ≈ 0.03 cr |
person_contact_enrich |
no base charge, billed per contact type returned per matched person (business email 1, personal 2, phone 2), capped at 5 per person. Narrowing fields sets the worst case but is not a guaranteed cap — a single-tier request has been observed billing the full 5-credit cap and returning all three tiers, so quote 5/person as the ceiling and read credits_remaining for the actual spend |
| Writing, classifying, sequencing |
Free — it's all model work |
A single well-hooked cold email typically costs 3-5 credits. A 40-person sequence with
hooks and business emails lands around 150-200.
Works with
- sales-prospecting — builds the list and fires the signals this skill writes on.
Champion-tracker rows are the highest-converting input here.
- account-research — deep-dive an account before a big-ticket touch; its account plan
gives you the pain map the bridge line needs.
- icp-builder — writes
config/persona-profile.md (voice) and config/gtm-config.md
(what you sell) from one LinkedIn URL, so drafts sound like the rep from run one.
- warm-path-workspace — when there's a warm route in, use it: a referral opener beats
the best cold email you'll ever write.
Tool dependencies
This skill requires:
- Crustdata MCP server (install.crustdata.com/mcp):
a single Code Mode MCP exposing
list_tools, get_schema, and execute. All Crustdata
data tools are reached inside an execute({ code }) plain-JavaScript script via
await callTool(name, params) — author against the typed surface from get_schema, but
the script body carries zero type annotations (a type annotation is a parse error that
fails the whole run). Tools used here: social_post_list_live, company_identify,
company_enrich, job_search, person_contact_enrich (opt-in, cost-confirmed),
account_credits.
- Nothing else. No sequencer credentials, no email-sending access, no CRM write
access. Outbound leaves this skill as copy, a CSV, and a written campaign spec — the
rep loads and starts it.
1---2name: sales-outreach3description: Write the touch, grounded in a real signal. Every cold email, call script, LinkedIn note, reply, and sequence is built on ONE specific, recent, dated signal pulled from Crustdata — their post, their raise, their new exec, their job listing — never a generic template and never an invented reference. 7 modes: cold email, call script, LinkedIn touches, reply handler, objection talk-tracks, multi-touch sequence builder, signal-triggered drafts. Use for "write a cold email to <person>", "what do I say", "they replied saying <X>", "handle the <objection> objection", "build a sequence", "draft outreach for these accounts", "write my follow-up", "give me a call script for <company>".4---56# Sales Outreach78One job: **what do I say — and can they tell I actually looked?**910The copy is the easy half. The reason a touch lands is that it opens with something11specific, recent, and true about the person reading it: a post they wrote last week, the12round they closed last month, the role they just opened, the exec they just hired. This13skill pulls that from Crustdata **first**, then writes around it.1415**The hook comes before the copy. Always.** A beautifully written email built on16"I saw you're in fintech" is a template. A blunt three-line email built on "your Tuesday17post about the on-call rotation" gets replies. If no real signal exists for a target, say18so and offer a different angle — **never invent a hook.**1920---2122## Code Mode ground rules (read once, apply everywhere)2324All Crustdata calls run inside the `execute` tool of the Crustdata MCP server25([install.crustdata.com/mcp](https://install.crustdata.com/mcp)) as a plain-JavaScript26script. Every snippet below follows these rules:2728- **Plain JavaScript only.** Author against the typed tool surface from `get_schema`, but29 the script body carries zero type annotations — a `: Type`, `as`, or generic is a parse30 error that fails the whole run before any tool call, at zero spend.31- **Open every script with a source-labeled comment**: `// user query: ...` for the32 user's literal ask, `// model query: ...` for a derived step. Scripts without one are33 rejected before running.34- **One I/O primitive:** `const r = await callTool(name, params)` → `{ ok: true, data }`35 or `{ ok: false, status, errorType, message }`. **Always branch on `r.ok`** — a failed36 call does not abort the script, so an unchecked failure silently proceeds on empty data37 and looks like "they have no recent activity". That is exactly the failure that38 produces a fabricated hook.39- **`fields` is a response whitelist.** The result carries only the groups you list; an40 omitted group reads as `undefined` later and looks like missing data. List every group41 you read.42- **Return the smallest projection.** Only what the script returns reaches the model. For43 outreach that means hook candidates — date, one line of text, a link — never raw posts44 or whole profiles.45- **Fan out independent work** with `await parallelMap(items, fn)`; `chunk` first where46 there is a per-call cap. Never parallelize cursor pagination or dependent stages.47 `checkpoint(acc)` after a costly stage so a timeout still returns the hooks you paid48 for.4950---5152## Step 0 — Intake (always, before writing a word)53541. **Config backbone, never blocking.** If `config/persona-profile.md` or55 `config/gtm-config.md` exist in the working directory, read them: the rep's voice56 (tone, sentence length, sign-off, never-do list), what they sell, who buys. If they57 don't exist, ask one inline question ("What do you sell, and what's your sign-off?")58 or point at the **icp-builder** skill, which writes both files from one LinkedIn URL.59 A missing config never blocks a draft — write in a plain, direct voice and say you60 did.612. **Confirm the ask in one line**: WHO (person, account, or list) — CHANNEL (email /62 call / LinkedIn) — MOMENT (first touch / follow-up / reply / objection) — GOAL63 (a meeting? an answer? an intro?).643. **Pull the hook** (next section). One sharp hook beats five soft ones.6566---6768## THE HOOK LAYER (this is the skill)6970Four sources, cheapest and most specific first. Pull **one**; stop when you have71something good. Running all four on one person is usually a waste of credits.7273### Hook 1 — what they said (their posts)7475The best hook there is, because it's theirs and it's dated. Keyed on the person's76professional network profile URL. **1 credit per post, so `limit` is the spend dial** —775 is plenty to find one hook.7879```js80// user query: write a cold email to https://www.linkedin.com/in/example — find a hook first81const url = "https://www.linkedin.com/in/example";82const cutoffMs = Date.now() - 60 * 24 * 3600 * 1000; // hooks older than ~60 days read as stale8384const r = await callTool("social_post_list_live", {85 professional_network_profile_url: url, // person key; company_domain / crustdata_company_id key the company feed86 limit: 5, // 1 CREDIT PER POST — set this deliberately87 fields: ["text", "date_posted", "post_type", "engagement", "hyperlinks", "share_url"], // whitelist88});89if (!r.ok) return { hooks: [], error: r.message };9091const parse = (d) => { const t = Date.parse(d ?? ""); return Number.isNaN(t) ? null : t; };92const posts = (r.data.posts ?? []).map(p => ({93 date: p.date_posted,94 ms: parse(p.date_posted),95 type: p.post_type,96 url: p.share_url, // the link to the post itself — the rep needs it to verify in five seconds97 text: (p.text ?? "").slice(0, 400),98 reactions: p.engagement?.total_reactions,99 comments: p.engagement?.total_comments,100 links: p.hyperlinks?.other_urls ?? [],101}));102return {103 fresh: posts.filter(p => p.ms !== null && p.ms >= cutoffMs),104 stale: posts.filter(p => p.ms !== null && p.ms < cutoffMs).length,105 undated: posts.filter(p => p.ms === null), // never silently drop these — read them yourself106};107```108109Reading the result: prefer a post they **wrote** over a reshare with no commentary, and110an opinion over an announcement. A post with a real take gives you a sentence you can111agree or disagree with, which is what earns a reply.112113### Hook 2 — what happened to the company (funding, news, growth)114115`company_identify` is free but fuzzy — one domain can match several companies. Take the116top `confidence_score`, then enrich **by id** so you pay for exactly one match (2 credits).117Enriching by domain or name instead can fuzzy-match several companies and bill 2 credits118per returned match, which `exact_match: true` narrows but does not guarantee down to one.119120```js121// model query: recent company events at acme.com worth opening an email with122const idr = await callTool("company_identify", { domains: ["acme.com"] }); // free, one identifier type per call123if (!idr.ok) return { error: idr.message };124const matches = idr.data[0]?.matches ?? [];125const best = matches.slice().sort((a, b) => (b.confidence_score ?? 0) - (a.confidence_score ?? 0))[0];126if (!best) return { error: "no company match for acme.com" };127const companyId = best.company_data?.basic_info?.crustdata_company_id ?? best.company_data?.crustdata_company_id;128129const er = await callTool("company_enrich", {130 crustdata_company_ids: [companyId], // one id = one match = exactly 2 credits131 exact_match: true, // narrows the fuzzy match; by-name/by-domain can still return >1 -> N x 2 cr132 fields: ["basic_info", "funding", "headcount", "news"], // whitelist: list EVERY group you read133});134if (!er.ok) return { error: er.message };135const cd = er.data[0]?.matches?.[0]?.company_data ?? {};136const news = (firstArray(cd.news) ?? []).slice(0, 10).map(n => ({137 title: n.article_title, url: n.article_url, date: n.article_publish_date,138}));139return {140 name: cd.basic_info?.name,141 lastRound: cd.funding?.last_round_type,142 lastRaise: cd.funding?.last_fundraise_date,143 lastRoundUsd: cd.funding?.last_round_amount_usd,144 investors: cd.funding?.investors,145 headcount: cd.headcount?.total,146 growth12m: cd.headcount?.growth_percent?.["12m"],147 news,148};149```150151Funding is a hook with a shelf life. A round closed 3 weeks ago is "congrats + here's what152usually breaks next"; a round closed 14 months ago is background, not an opener. News153articles carry a real publish date — use it in the sentence and link the article if you154reference a specific claim.155156### Hook 3 — what they're hiring for157158Job posts are the most honest thing a company publishes. The roles they're opening are159the problems they've agreed to spend money on.160161```js162// model query: what acme.com is hiring for, and whether their posts name our category163const domain = "acme.com";164const term = "<the tool you displace>"; // a brand/product/tech name -> ALWAYS [.] exact token, never (.)165const [fresh, named] = await parallelMap([166 {167 filters: eq("company.basic_info.primary_domain", domain),168 sorts: [{ field: "metadata.date_added", order: "desc" }],169 fields: ["job_details", "location", "metadata"],170 limit: 15,171 },172 {173 filters: and_(174 eq("company.basic_info.primary_domain", domain),175 exactToken("content.description", term) // [.] guarantees the literal token; (.) is typo-tolerant and pulls lookalikes176 ),177 fields: ["job_details"],178 limit: 1, // count query — read total_count, don't download rows179 },180], async (params) => await callTool("job_search", params));181if (!fresh.ok) return { error: fresh.message };182return {183 newest: fresh.data.job_listings.map(j => ({184 title: j.job_details?.title,185 city: j.location?.city,186 added: j.metadata?.date_added,187 })),188 total_open: fresh.data.total_count,189 postings_naming_term: named.ok ? named.data.total_count : null,190};191```192193**Scope roles by title, not `job_details.category`** — the category field is coarse and194most postings land in the catch-all. If you want to say "you've got four data engineering195roles open", count titles.196197The hook writes itself from the shape: three SDR reqs open means a pipeline target they198just raised. A first security hire means they're about to buy security tooling. Name the199actual role and when it was posted.200201### Hook 4 — a signal that already fired202203The best case: the work is already done. **sales-prospecting**'s champion tracker,204expansion radar, and account ranking all emit dated signal rows. Hand one straight in and205skip the pull entirely.206207A handed-over row looks like: *person — was [role] at [your customer] — now [title] at208[new company] — landed [date]*. That IS the hook, and it's already dated. Same for209"raised Series B on 2026-04-12" or "hired a VP Data 6 weeks ago".210211Do not re-pull what you were handed. Spend a credit only to add colour (their first post212in the new job, say) and only if the draft needs it.213214### Signal → angle215216| Signal | The angle | The opening move |217|---|---|---|218| Fresh raise (<3 months) | Scaling pain — what breaks at the next headcount | Congratulate in half a sentence, then the pain the round creates |219| New exec in your function | New mandate, 90-day plan, fresh budget | "New in seat" energy: what are they inheriting |220| Champion moved to a new company | They already know you work | "Congrats — you know how this goes" (warmest touch there is) |221| Hiring surge in a relevant team | They've agreed the problem is real | Reference the roles; ask what happens between now and the hires landing |222| A post with a real opinion | Agree, extend, or politely disagree | Respond to the *idea*, not to the fact that they posted |223| Tech named in job posts | Stack fit or displacement | Name the tool, name the seam |224| Company news / launch | Timing | Tie the launch to the pain your product removes |225226### No hook, no fabrication227228If the pulls come back empty — no recent posts, funding two years old, no open roles —229**say that out loud** and offer the alternatives instead of inventing a reference:230231- Write from a **role-level** insight ("every VP Eng at 300 people hits this") and label232 it as such — a good generic beats a fake specific.233- Pull a hook on a **different person** at the same account who is active — if you already234 have their profile URL (from the user's list, or from **sales-prospecting** /235 **account-research**, which do the person discovery), run Hook 1 on them instead.236- Use the account's own words: `social_post_list_live` keyed on `company_domain` gives237 the company feed when the individual is quiet.238- Recommend waiting for a signal and setting up the watcher in **sales-prospecting**'s239 champion tracker.240241A hook must pass three tests before it goes in a draft: **is it theirs** (not their242industry's), **is it dated** (post ≤60 days, funding/news ≤90 days), and **would they243recognize it in one read**.244245---246247## THE TOUCH TYPES248249### 1. Cold email250251**Under 90 words.** Subject 3-5 words, lowercase-ish, no clickbait, no "quick question".252253- **Line 1 — the hook.** Their thing, dated, in their language. The reader should think254 "yes, I did that".255- **Line 2 — the bridge.** One sentence connecting their thing to the pain you solve.256- **Line 3 — one concrete outcome.** A number or a named customer situation, not a value257 prop.258- **Line 4 — one ask.** The lowest-friction version that still moves ("worth 15 minutes259 Thursday?" or "want me to send the two-line version?").260- Their sign-off, if the persona config carries one. The config written by **icp-builder** records tone and style but not a sign-off, so ask for it inline the first time and reuse it after.261262One hook, one idea, one ask. Offer **2-3 angle variants** for testing (different hook, or263same hook different pain) and say which one you'd send.264265### 2. Call script266267- **Opener + permission line** ("caught you cold — 30 seconds and you can tell me to go268 away?").269- **Reason for the call**, tied to the same hook. Cold calls die on "just reaching out".270- **Value hypothesis** in one sentence, framed as a guess they can correct.271- **2-3 open discovery questions** — how they handle it today, what it costs them.272- **The ask: a meeting, not a sale.**273- **Objection branches**, written out: not interested / send me an email / we already use274 something / no budget / gatekeeper. Each is one acknowledgment plus one question that275 keeps the call alive.276- **15-second voicemail** and a **one-line follow-up text**, both referencing the same277 hook so the touches stack.278279### 3. LinkedIn touches280281- **Connection note** — 300 characters max, **no pitch**. The hook plus a reason to282 connect.283- **First DM** — value or a smart question. Still no pitch.284- **Follow-up** — a soft ask, only after a reply or a signal.285- **Break-up** — one line, gracious, leaves the door open.286287Before any of it, suggest **1-2 of their recent posts worth a genuine comment** — you288already have them from Hook 1, at no extra cost. A real comment three days before a289connection request does more than any first line.290291### 4. Reply handler292293Classify first, then advance. Match their length and their energy — a two-line reply gets294a two-line answer.295296| Reply type | The move |297|---|---|298| Interested | Propose two concrete times. Nothing else. Do not re-pitch. |299| Question | Answer it directly in one paragraph, then the ask. |300| Objection | Go to talk-tracks (below). |301| Referral out ("talk to X") | Thank them, ask for the intro or permission to name them, then open the new thread with the referral as the hook. |302| Not now | Get the date. "Should I come back in Q1?" Then actually log it. |303| Hard no | One gracious line, exit. No last-ditch pitch. |304| Unsubscribe | Honor immediately. No counter-offer, no "just confirming". Remove from every sequence. |305306### 5. Objection talk-tracks307308**Diagnose the real objection first.** "Too expensive" is usually "value unclear" or "no309internal champion". "Bad timing" is usually "not a priority". Say what you think the real310one is before answering it.311312Then: **acknowledge → reframe → one real proof point → soft re-ask.** One proof point,313and it must be real — a named situation, a number you can defend. Never invent a case314study.315316Give two variants: **direct** (for a buyer who's blunt) and **soft** (for a relationship317you're still building).318319**Playbook mode**: the team's top 8-10 objections in one document, each with the320diagnosis, both variants, and the trap to avoid. This one is worth rendering as a page —321see the artifact rule under Rules.322323### 6. Sequence builder3243251. **The cadence table first** — day, channel, angle, personalization level. Vary the326 angle across touches: problem → proof → a different persona's pain → social proof →327 break-up. Never send the same idea five times in different words.3282. **Then write every touch** using types 1-3.3293. **Mark each touch** `1:1` (rep writes a custom line) vs `merge-field` (scales across330 the list), and flag exactly where the rep must add something only they know.3314. **Hook freshness**: touch 1 carries the pulled hook. By touch 4 that hook is two weeks332 old — either re-pull or switch to an angle that doesn't depend on recency. Say which333 in the table.334335Typical shape (adapt to the motion, don't ship this as-is):336337| Day | Channel | Angle | Personalization |338|---|---|---|---|339| 1 | Email | The hook + the pain it implies | 1:1 |340| 3 | LinkedIn | Connection note, same hook, no pitch | 1:1 |341| 5 | Call + voicemail | Reason-for-call = the hook | 1:1 |342| 8 | Email | Proof point, in-thread | merge-field |343| 12 | Email | A different persona's version of the pain | merge-field |344| 18 | Email | Break-up, one line | merge-field |345346### 7. Signal-triggered drafts347348Input: a signal that just fired (new funding, exec hire, champion moved, hiring surge),349usually handed over from **sales-prospecting**. Output: a draft that references the actual350trigger **with its date**, mapped through the signal → angle table above.351352The rule that makes these work: the draft must be sendable the week the signal fired. A353"congrats on the round" email six weeks late is worse than nothing. If the signal is354already stale, say so and re-angle.355356To keep signals flowing automatically, point the user at **sales-prospecting**'s champion357tracker, which sets up the recurring watcher.358359---360361## THE NO-SLOP RULE (every draft, no exceptions)362363Run this pass on every piece of copy **before showing it**. A touch that smells like AI is364worse than no touch — it tells the reader they were on a list.365366Kill on sight:367368- **Em dashes.** Use a period or a comma.369- "I hope this finds you well", "I wanted to reach out", "quick question", "circling370 back", "just following up".371- **"delve", "leverage", "streamline", "unlock", "elevate", "seamless", "robust".**372- **Rule-of-three constructions** ("faster, cheaper, and more reliable").373- **Negative parallelism** ("not just X, but Y").374- Filler adverbs: "really", "actually", "truly", "incredibly".375- Fake familiarity ("Hope you're crushing it!") and fake urgency.376- Any sentence that would survive a find-and-replace of the company name. If it works for377 any company, it works for none.378379Then read it out loud. It should sound like the rep texting a smart colleague. **If a380draft reads like a template, rewrite it — do not ship slop.**381382---383384## LIST MODE — many accounts, one run385386When the input is a list (usually a scored list from **sales-prospecting**), the order is387always: hooks → copy → export. Not the reverse.388389**Step 1 — fan out the hooks.** Independent per person, so `parallelMap` them. State the390cost before running: posts are 1 credit each, so `limit: 3` across 40 people is up to 120391credits.392393```js394// user query: draft outreach for these 40 accounts — pull a hook for each first395const people = inputs.people; // [{ name, profileUrl, company, domain }]396const cutoffMs = Date.now() - 60 * 24 * 3600 * 1000;397398const hooks = await parallelMap(people, async (p) => {399 const r = await callTool("social_post_list_live", {400 professional_network_profile_url: p.profileUrl,401 limit: 3, // 1 cr/post x 3 x N people — quote this before running402 fields: ["text", "date_posted", "post_type", "share_url"],403 });404 if (!r.ok) return { name: p.name, hook: null, reason: r.message };405 const fresh = (r.data.posts ?? [])406 .map(x => ({ date: x.date_posted, ms: Date.parse(x.date_posted ?? ""), url: x.share_url, text: (x.text ?? "").slice(0, 300) }))407 .filter(x => !Number.isNaN(x.ms) && x.ms >= cutoffMs);408 return { name: p.name, company: p.company, hook: fresh[0] ?? null, reason: fresh.length ? null : "no recent post" };409});410checkpoint(hooks);411return {412 withHook: hooks.filter(h => h.hook),413 needCompanyFallback: hooks.filter(h => !h.hook).map(h => ({ name: h.name, company: h.company, reason: h.reason })),414};415```416417Everyone in `needCompanyFallback` gets Hook 2 or Hook 3 at the account level, or an418honest merge-field template. **Never let an empty pull turn into a made-up first line.**419420**Step 2 — contact enrichment is opt-in and cost-confirmed.** Quote the worst case before421you run it, then run it:422423> "Business emails for 40 people: at most ~40 credits (1 per matched person). Adding424> personal emails and phones raises the ceiling to 5 per person, so ~200. Business only —425> go?"426427```js428// user query: get business emails for the 40 people on the list (worst case ~40 credits, confirmed)429const urls = inputs.profileUrls;430const batches = chunk(urls, 25); // hard cap: 25 URLs per call431const results = await parallelMap(batches, async (batch) => {432 const r = await callTool("person_contact_enrich", {433 professional_network_profile_urls: batch,434 fields: ["contact.business_emails"], // narrow the request; see the cost note below435 });436 return r.ok ? r.data : batch.map(u => ({ matched_on: u, matches: [], error: r.message }));437});438const rows = results.flat();439return {440 contacts: rows.map(m => ({441 url: m.matched_on,442 email: m.matches?.[0]?.person_data?.contact?.business_emails?.[0]?.email ?? null,443 status: m.matches?.[0]?.person_data?.contact?.business_emails?.[0]?.status ?? null,444 })),445 unmatched: rows.filter(m => !m.matches?.length).map(m => m.matched_on),446};447```448449**Omitting `fields` requests all three tiers** (business email, personal email, phone) and450takes the ceiling to 5 credits per matched person. Narrow the whitelist to the tiers you451actually need, and read `credits_remaining` on the response for the real spend. Report452unmatched rows honestly — never pad a list with guessed email patterns.453454**Step 3 — hand off.** Two artifacts, no credentials, no auto-send:455456- **Campaign CSV**: `email, first_name, last_name, company, title, linkedin_url,457 hook_text, hook_date, hook_url, personalization_line, tier`. The hook, its date, and the458 link to it ride in the file so the rep can sanity-check every row before it sends.459- **Campaign spec** the user pastes into whatever sequencer they run: the sequence steps460 with the full copy per step, the merge fields used, send window and timezone, daily461 ramp (start at 20-30/day on a new domain), threading (follow-ups in-thread), and462 stop-on-reply ON.463- **Deliverability guardrails** in the spec: warmed domain, at most one link in touch 1,464 plain text over HTML, unsubscribe honored instantly.465466---467468## Rules469470- **The hook comes first, and it is real or it is absent.** Never fabricate a post, a471 quote, a round, a mutual connection, or a case study. "I couldn't find a recent signal472 for this person, here's a role-level angle instead" is a valid, honest answer.473- **Date every reference** in the draft and in your notes to the rep, so they can check474 it in five seconds.475- One hook, one idea, one ask per touch. Shorter beats clever.476- **Never auto-send anything.** Email drafts stay drafts. This skill never writes to a CRM; any log entry is yours to make before477 writing. Sequencer setup is a spec plus a CSV that the rep loads and starts themselves.478- **Respect opt-outs completely** and immediately. Never volume your way past a479 compliance limit; if the user wants that, it's theirs to own explicitly.480- **No-slop rule on every deliverable** (full list above): no em dashes, no481 "delve"/"leverage"/"streamline", no filler, nothing that survives a find-and-replace of482 the company name.483- Costs are real: state expected spend before post pulls, list fan-outs, or contact484 enrichment. Every `execute` response carries `credits` and `credits_remaining`;485 `account_credits` (free) reports the balance.486- **Every deliverable ends with the handoff question**: copy in chat, a CSV plus campaign487 spec for your sequencer, or a rendered doc.488- **Adapt the layout to the content — never let it hide anything.** The brand system is489 fixed; the layout is not. If real content doesn't fit — a long company or person name, a490 12-word title, 200 rows — change the layout, not the content: let the card grow, wrap491 instead of truncating, drop to one column, widen the column, raise the cap, or give the492 wide thing its own scroll container. Never solve a fit problem by clipping a card,493 ellipsing a name, or silently dropping rows. Where a cap really is unavoidable, say so494 in the UI ("showing the top 50 of 214"). Look at the rendered output and fix what's cut495 off before you hand it over.496- **Icons in rendered output**: Lucide, the dashboard's icon set, inlined as SVG with a497 `currentColor` stroke. No emojis in artifact UI.498- **Photos and logos are free — use them in rendered output.**499 `basic_profile.profile_picture_permalink` (person) rides in the `basic_profile` group of500 any person record handed to you; `basic_info.logo_permalink` (company) comes501 free from `company_identify` and from `company_enrich`'s `basic_info`. Neither costs an502 extra credit. Base64-inline both as `data:image/jpeg;base64,...` URIs — the media CDN503 serves them as `binary/octet-stream`, so a remote `<img src>` renders blank. Monogram504 fallback when an image is missing.505- **Artifact branding**: copy is chat-native by default, and the CSV stays a plain data506 file — never render an artifact just to render one. But IF a deliverable is rendered as507 a page or document (an objection playbook, a sequence spec, a campaign one-pager), it508 carries the Crustdata brand lockup in the header or footer: a small uppercase509 "Powered by" eyebrow plus the official Crustdata wordmark, linking to crustdata.com.510 The wordmark pair ships in this skill's `assets/` — `crustdata-logo-light.png` (dark511 text, for light backgrounds) and `crustdata-logo-dark.png` (white text, for dark512 backgrounds). Base64-inline the theme-appropriate variant at ~17px height — never513 hotlink; rendered artifacts cannot fetch remote images. Brand accent: `#5547E2`514 (`#8387FF` on dark grounds). Body font: Geist when embeddable, else the system stack.515516## Error handling517518- **A failed call is the dangerous case, not the obvious one.** `r.ok === false` on a519 post pull looks identical to "this person doesn't post". Branch on it, report it as an520 error, and fall back to Hook 2 or Hook 3 — never let it become a generic first line521 dressed up as personalization.522- **403 naming a field** = a plan-gated projection. Drop that field from `fields` and523 re-run. **403 naming the endpoint** (contact enrichment is the common one) = the plan524 doesn't include it; say so plainly and hand off the list without emails rather than525 guessing address patterns.526- **Empty posts, empty news, no open roles** is a normal outcome, not a failure. Report527 which sources came back empty and take the no-hook path above.528- **`company_identify` returns several matches** for one domain — that's expected, it's529 fuzzy. Take the top `confidence_score`; if two are close, name both and ask.530- **Unmatched rows in a list run** are reported by name, never dropped and never531 silently swapped for a template row.532533## Cost cheat sheet534535| Call | Cost |536|---|---|537| `company_identify`, autocomplete, `get_schema`, `account_credits` | Free |538| `social_post_list_live` | **1 cr per post** — `limit` is the spend dial |539| `company_enrich` by id + `exact_match: true` | 2 cr for one match |540| `job_search` | ~0.03 cr/result; `limit: 1` + `total_count` ≈ 0.03 cr |541| `person_contact_enrich` | no base charge, billed per contact type returned per matched person (business email 1, personal 2, phone 2), **capped at 5 per person**. Narrowing `fields` sets the worst case but is not a guaranteed cap — a single-tier request has been observed billing the full 5-credit cap and returning all three tiers, so quote 5/person as the ceiling and read `credits_remaining` for the actual spend |542| Writing, classifying, sequencing | Free — it's all model work |543544A single well-hooked cold email typically costs 3-5 credits. A 40-person sequence with545hooks and business emails lands around 150-200.546547## Works with548549- **sales-prospecting** — builds the list and fires the signals this skill writes on.550 Champion-tracker rows are the highest-converting input here.551- **account-research** — deep-dive an account before a big-ticket touch; its account plan552 gives you the pain map the bridge line needs.553- **icp-builder** — writes `config/persona-profile.md` (voice) and `config/gtm-config.md`554 (what you sell) from one LinkedIn URL, so drafts sound like the rep from run one.555- **warm-path-workspace** — when there's a warm route in, use it: a referral opener beats556 the best cold email you'll ever write.557558## Tool dependencies559560This skill requires:561562- **Crustdata MCP server** ([install.crustdata.com/mcp](https://install.crustdata.com/mcp)):563 a single Code Mode MCP exposing `list_tools`, `get_schema`, and `execute`. All Crustdata564 data tools are reached inside an `execute({ code })` plain-JavaScript script via565 `await callTool(name, params)` — author against the typed surface from `get_schema`, but566 the script body carries zero type annotations (a type annotation is a parse error that567 fails the whole run). Tools used here: `social_post_list_live`, `company_identify`,568 `company_enrich`, `job_search`, `person_contact_enrich` (opt-in, cost-confirmed),569 `account_credits`.570- **Nothing else.** No sequencer credentials, no email-sending access, no CRM write571 access. Outbound leaves this skill as copy, a CSV, and a written campaign spec — the572 rep loads and starts it.