Meeting Prep
One job: the calls and leads in front of you today. A rep opens this between meetings, so every deliverable is one screen, every claim carries a date, and nothing is invented to fill a section.
Three modes:
- Inbound triage — someone filled a form or emailed in. Is this worth your time?
- Pre-call brief — the daily driver. One screen you can read in the two minutes before a call.
- Call plan — a discovery question plan, or a demo flow built only from stated pains.
Ask which one if it isn't obvious from the ask. "Prep my 2pm" is mode 2. "Score this lead" is mode 1. "Build me a discovery plan" is mode 3.
Step 0 — context (never blocks)
- If
config/gtm-config.md or config/persona-profile.md exist in the working directory, read them: ICP, what you sell, buyer titles, customer list, competitor list, voice. These drive the scoring rubric and the opener's voice.
- If they don't exist, ask 1-2 questions inline ("Who do you sell to — industry, size, geo? Which titles buy?") or point at the icp-builder skill to build the config properly. A missing config never blocks a run.
- Optional read-only enrichment: if a CRM, call recorder, or email is connected, a read-only sweep for prior threads, notes, and calls with the same domain sharpens any brief — past objections, who already talked to them, why the last deal stalled. Read only, never write, and never build the deliverable around it: the brief has to stand up on public data alone.
- For "prep my 2pm" with no calendar connected, just ask which meeting: company, and who you're meeting.
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.
- Plain JavaScript only. Author against the typed 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 spend.
- Every script opens with a source-labeled comment:
// user query: ... for the 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 "no signal".
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 — map to compact rows, never raw profiles.
- Fan out independent calls with
await parallelMap(items, fn); batch first with chunk(list, 25). Never parallelize cursor pagination or dependent stages — identify → enrich stays sequential, and you parallelize within a stage.
company_identify is free but fuzzy. One domain or name can match several companies. Take the top confidence_score match. Do not project social_profiles on identify — it is plan-gated and 403s the whole call.
- Plan-gated projections fail the whole call with a 403 naming the field. Never project
professional_network.followers or metadata on person_search, or certifications / honors / updated_at on person_enrich.
- Filter paths ≠ response paths. You filter
experience.employment_details.current.company_name; the response key is ...current[].name. The LinkedIn URL returns at social_handles.professional_network_identifier.profile_url — use the profileUrl(p) accessor.
- Categorical values are closed sets. A plausible-but-wrong value silently returns zero rows. Resolve with
company_autocomplete / person_autocomplete (free) before filtering on industries, funding round types, or seniority.
- Zero results ≠ no signal. Read the
trajectory in the execute response before telling a rep an account is quiet.
Mode 1 — Inbound triage
Input: whatever the form or email gave you — name, work email, company or domain, self-reported title, and often a free-text "what are you looking for". A LinkedIn URL if you're lucky.
Step 1 — resolve and read the firmographics (cheap, and it batches)
company_identify is free, so resolve every lead first, then read size, industry, funding, and growth for the whole batch in one company_search scoped to the resolved ids (~0.03 credits per row). Do not reach for company_enrich here — 2 credits per lead is the wrong tool for triage.
// user query: triage today's inbound — 6 form fills
const leads = inputs.leads; // [{ email, name, title, domain }, ...]
const batches = chunk(leads.map(l => l.domain).filter(Boolean), 25);
const identified = await parallelMap(batches, async (batch) => {
const r = await callTool("company_identify", {
domains: batch, // ONE identifier type per call
fields: ["crustdata_company_id", "basic_info"],
});
return r.ok ? r.data : batch.map(d => ({ matched_on: d, matches: [], error: r.message }));
});
const rows = identified.flat();
const unresolved = rows.filter(m => !m.matches?.length).map(m => m.matched_on);
const byDomain = {};
for (const m of rows) {
const top = (m.matches ?? []).slice().sort((a, b) => (b.confidence_score ?? 0) - (a.confidence_score ?? 0))[0];
const cd = top?.company_data;
if (cd) byDomain[m.matched_on] = cd.basic_info?.crustdata_company_id ?? cd.crustdata_company_id;
}
const ids = Object.values(byDomain).filter(Boolean);
checkpoint({ ids, unresolved });
const r = await callTool("company_search", {
filters: in_("crustdata_company_id", ids),
fields: ["crustdata_company_id", "basic_info", "headcount", "funding", "locations"],
limit: ids.length,
});
if (!r.ok) return { error: r.message, unresolved };
return {
unresolved, // personal-email leads and dead domains — surfaced, never silently dropped
companies: r.data.companies.map(c => ({
id: c.crustdata_company_id,
name: c.basic_info?.name,
domain: c.basic_info?.primary_domain,
industries: c.basic_info?.industries,
hc: c.headcount?.total,
growth12m: c.headcount?.growth_percent?.["12m"],
lastRound: c.funding?.last_round_type,
lastRaise: c.funding?.last_fundraise_date,
country: c.locations?.country,
})),
};
locations.country accepts ISO-3 (USA) as a filter but returns the normalized full name — don't compare a response value against "USA".
Step 2 — timing signals, near-free
One job_search aggregation with limit: 0 gives open-role counts across every resolved account at once. A company hiring into the function you sell to has budget moving.
// model query: open-role counts per inbound account (timing signal, counts only)
const r = await callTool("job_search", {
filters: in_("company.basic_info.company_id", inputs.ids),
aggregations: [{ type: "group_by", field: "company.basic_info.crustdata_company_id", agg: "count", size: 100 }],
limit: 0,
});
if (!r.ok) return { error: r.message };
return r.data.aggregations;
Step 3 — the person
- LinkedIn URL given →
person_enrich (1 credit) with fields: ["basic_profile", "experience", "social_handles"]. Real title, real tenure, real employer. Worth it for a single high-stakes lead; skip it for a batch of 20 unless the user asks.
- No URL → use the self-reported title and label it self-reported in the output. Forms lie in both directions.
- Match the title against the ICP's buyer titles from config. The normalized seniority band rides inside the
experience group that both person_enrich and person_search already return — read cur.seniority_level, don't buy a search for it. When you filter on it, the value vocabulary is a closed, autocomplete-first set: Entry Level, Entry Level Manager, Experienced Manager, Senior, Director, Vice President, CXO, Owner / Partner, In Training, Strategic.
Step 4 — score it, and show your work
A transparent rubric out of 7. Never output a "close probability %" — without your closed-won history there is nothing to calibrate it against, and a made-up percentage is worse than no number.
| Component |
Points |
What earns them |
| Firmographic fit |
0-3 |
Industry, size band, and geography vs the ICP — one point each |
| Buyer-title match |
0-2 |
Named buyer title or seniority band = 2; adjacent or influencer = 1; unrelated or well below the band = 0 |
| Timing |
0-2 |
One point each, capped at 2: funding inside 6 months, 12-month headcount growth above 20%, open roles in the function you sell to, a relevant news item or post inside 60 days |
This mode is for leads that came to you today. To rank your own book, territory, or a pasted account CSV, use sales-prospecting's rank-my-accounts recipe instead.
Hard disqualifiers override the score entirely. Any one of these means decline, whatever the points say: a competitor (from the config's competitor list if it carries one — if it doesn't, ask for your competitors in the same inline question as the ICP, before applying this disqualifier), an existing customer (route to the account owner instead), a student or job seeker, a personal email address with no resolvable company, a geography you can't serve, or headcount below your floor.
Verdict tiers (chat output; in a rendered artifact these become coloured pills, never emoji):
- 🔥 Route now — fit ≥ 2, title ≥ 1, at least one timing point, no disqualifier
- 🟡 Nurture — real company, wrong moment or wrong person. Say what would flip it
- ⚪ Decline — no fit, or a disqualifier fired. Name which one
Every row shows the reasons and dates the evidence ("Series B, 2026-04-12", "17 open roles, 4 in data engineering"). A signal without a date is a signal a rep can't use.
Batch mode
Rank a whole day's inbound in one table: Lead — Company — Fit — Title — Timing — Score — Verdict — Why now. Sort by score, group by verdict. Put unresolved domains in their own short list at the bottom with the reason (personal email, dead domain, no match) so nothing disappears. Then: "here are the three to call back today."
Mode 2 — Pre-call brief
The daily driver. Input: company (name or domain) and who you're meeting (name, ideally a LinkedIn URL).
The whole brief in one script
Identify first (free, fuzzy — take the top confidence match), then fan out the four independent pulls. Person enrichment doesn't depend on the company, and neither does the person's post history, so they run in the same stage.
// user query: prep my 2pm with acme.com — meeting their VP Data
const idr = await callTool("company_identify", { domains: ["acme.com"] });
if (!idr.ok) return { error: idr.message };
const top = (idr.data[0]?.matches ?? [])
.slice().sort((a, b) => (b.confidence_score ?? 0) - (a.confidence_score ?? 0))[0];
if (!top) return { error: "no company match for acme.com" };
const cd0 = top.company_data ?? {};
const companyId = cd0.basic_info?.crustdata_company_id ?? cd0.crustdata_company_id;
const logo = cd0.basic_info?.logo_permalink; // free, use it if the brief gets rendered
const calls = [
{ key: "company", name: "company_enrich", params: {
crustdata_company_ids: [companyId], exact_match: true,
fields: ["basic_info", "headcount", "funding", "news"] } }, // 2 cr, exactly one match
{ key: "jobs", name: "job_search", params: {
filters: { op: "and", conditions: [
{ field: "company.basic_info.primary_domain", type: "=", value: "acme.com" } ]},
sorts: [{ field: "metadata.date_added", order: "desc" }], limit: 15 } },
{ key: "person", name: "person_enrich", params: {
professional_network_profile_urls: ["https://www.linkedin.com/in/example"],
fields: ["basic_profile", "experience", "education", "social_handles"] } }, // 1 cr
{ key: "posts", name: "social_post_list_live", params: {
professional_network_profile_url: "https://www.linkedin.com/in/example",
limit: 5 } }, // 1 cr PER POST — cap it
];
const out = await parallelMap(calls, async (c) => ({ key: c.key, r: await callTool(c.name, c.params) }));
checkpoint(out); // ~8-10 credits already spent — bank them before shaping, so a shaping error can't discard them
const get = (k) => out.find(x => x.key === k)?.r;
const ce = get("company");
const cd = ce?.ok ? (ce.data[0]?.matches?.[0]?.company_data ?? {}) : {};
const cutoff = new Date(Date.now() - 60 * 24 * 3600 * 1000).toISOString().slice(0, 10);
const news = (firstArray(cd.news) ?? [])
.map(n => ({ title: n.article_title, url: n.article_url, date: n.article_publish_date }))
.filter(n => n.date && n.date >= cutoff)
.slice(0, 6);
const jr = get("jobs");
const pr = get("person");
const person = pr?.ok ? (pr.data[0]?.matches?.[0]?.person_data ?? {}) : {};
const cur = person.experience?.employment_details?.current?.[0] ?? {};
const posts = get("posts")?.ok
? (get("posts").data.posts ?? []).map(p => ({
date: p.date_posted, text: (p.text ?? "").slice(0, 280),
reactions: p.engagement?.total_reactions }))
: [];
return {
company: {
name: cd.basic_info?.name,
what: cd.basic_info?.description,
hc: cd.headcount?.total,
growth12m: cd.headcount?.growth_percent?.["12m"],
lastRound: cd.funding?.last_round_type,
lastRaise: cd.funding?.last_fundraise_date,
logo,
},
news,
openRoles: jr?.ok
? { total: jr.data.total_count,
newest: jr.data.job_listings.map(j => ({ title: j.job_details?.title, added: j.metadata?.date_added })) }
: { error: jr?.message },
person: {
name: person.basic_profile?.name,
title: person.basic_profile?.current_title,
company: cur.name,
since: cur.start_date,
past: (person.experience?.employment_details?.past ?? []).slice(0, 4).map(e => ({ company: e.name, title: e.title })),
schools: (person.education?.schools ?? []).map(e => e.school).filter(Boolean).slice(0, 3),
linkedin: profileUrl(person),
photo: person.basic_profile?.profile_picture_permalink, // free
},
posts,
};
Notes on this script:
fields is a whitelist on both enrich calls — the news group is not returned unless you ask for it, and education on the person is what gives you shared-school common ground.
social_post_list_live costs 1 credit per post. Five is enough to know what someone is talking about. Say the cost before you raise it.
- If the person pull returns no match, confirm the URL and run the brief company-only. A thin person block is honest; an invented one gets a rep caught.
- Want last-60-days coverage the
news group missed? One web_search_live query (1 credit). Keep it to one, and discard or label anything older than 60 days.
The deliverable — one screen, in this order
Company block
One line on what they do, then headcount and 12-month growth, last round with its date, 3-5 dated signals (news items with their URLs, the open-roles shape, a funding event). Hiring says more than the press release: 20 open data roles is a data problem; a first security hire is a security budget about to open.
Person block
Role and tenure, career path in one line, their last posts with dates, and common ground worth mentioning — shared employer, shared school, same city, a person you both know. Common ground is optional. A forced one is worse than none.
The part a rep actually reads
- Three talking points. Each is one sentence with its evidence and date attached. Not "they're growing fast" — "headcount up 34% in 12 months and 6 open roles on the data team, so the pipeline they built in 2024 is probably straining."
- One opener. Exactly one, written out as a sentence you could say out loud, referencing something real and dated inside 60 days. If nothing recent exists, say so and lean the opener on role context instead. Never invent a recent event to have something to open with.
- Two discovery questions. Open, specific to this account, derived from the signals above.
- The likely objection and the pre-empt. Name the one objection this person, in this role, at this company size, is most likely to raise, and the sentence that takes it off the table early.
Nothing else. If a section has nothing real in it, cut it and say why it's empty.
Batch of briefs
Several calls today? Run the identify stage for all of them, then one brief per meeting, in calendar order, each still one screen. State the credit total before starting: a brief runs about 8-10 credits, so five meetings is roughly 50.
Mode 3 — Call plan
Say which kind up front: discovery or demo.
Discovery plan
- Two or three pain hypotheses, each evidenced. Build them from the mode 2 signals — open roles, funding, growth rate, what they post about. Test a hypothesis cheaply before you build questions on it: count queries with the
[.] exact-token operator on job titles and descriptions.
// model query: which pain themes show up in acme.com job posts
const themes = ["data quality", "observability", "migration"];
const brands = ["Snowflake", "dbt", "Airflow"];
const probes = [
...themes.map(t => ({ label: t, cond: { field: "content.description", type: "(.)", value: t } })),
...brands.map(b => ({ label: b, cond: exactToken("content.description", b) })), // [.] for brand names
];
const hits = await parallelMap(probes, async (p) => {
const r = await callTool("job_search", {
filters: { op: "and", conditions: [
{ field: "company.basic_info.primary_domain", type: "=", value: "acme.com" },
p.cond,
]},
limit: 1,
});
return { probe: p.label, postings: r.ok ? r.data.total_count : null, error: r.ok ? undefined : r.message };
});
return hits;
(.) is typo-tolerant and matches lookalike words — fine for descriptive multi-word themes, wrong for a brand or product name. Always use [.] (exactToken) for those.
- The question plan: 8-12 open questions, not 30. A rep asks maybe eight questions in a first call. Map each one to a slot in a qualification framework — MEDDICC by default, or SPICED if the user works that way, and ask which if the config doesn't say. Every question carries three things: the framework slot it fills, the hypothesis it tests, and why you're asking it now.
| Slot |
Question |
Tests |
| Metrics |
"How are you measuring the cost of that today?" |
Pain hypothesis 1 is quantified |
| Pain |
"Walk me through what happens when a pipeline breaks at 2am." |
Hypothesis 1 is real, not assumed |
| Champion |
"Who else feels this every week?" |
Multithreading path |
Rules for the questions: open, not leading. Never ask what public data already answered — asking a VP how big their team is when their headcount is on the page wastes the one thing you have. Sequence them: situation, then pain, then impact, then process.
The committed next step. Write the exact ask, with a date and a name in it. "Would it make sense to get your data lead on a 30-minute working session next Tuesday?" beats "let's find time."
Coverage check. List which framework slots this plan will not fill in one call, so the rep knows what's left for call two rather than discovering it at forecast time.
Demo flow
Built only from pains the prospect actually stated. Sources for "stated": the user pastes their discovery notes, or the call plan follows a discovery call in the same session, or a connected recorder or CRM has the notes (read-only). Public signals generate hypotheses, never stated pains — keep the two apart and label them.
If discovery is thin, say so before you build anything. "You have one stated pain and two hypotheses. This demo will be generic. Want a 15-minute discovery block at the top instead?" Then build the shorter version if they still want it.
Structure:
- Recap open — their pains in their own words, read back. Confirm before showing anything.
- One beat per stated pain, in their priority order, not your feature order: tell what you'll show and why it matters to them, show it, then confirm ("does that match how your team would use it?"). A beat with no stated pain behind it gets cut — that is the whole discipline of this mode.
- Close plan — the specific next step, who else needs to see it, what they need to decide, by when.
Explicitly list what you are not showing and why. A demo that skips six features on purpose reads as confidence.
Rules
- Grounded only. Every claim in a brief traces to a Crustdata result, a URL, or something the user told you. Unclear means ask or omit. Never invent a recent event, a mutual connection, or a pain.
- Dates on everything. A signal with no date is unusable. Anything older than 60 days is labeled background.
- One screen per deliverable. Reps skim. Cut a section rather than padding it.
- No-slop rule on every word you draft — openers, questions, recaps: no em dashes, never "delve" / "leverage" / "streamline", no filler sentences. Write like a colleague who did the homework.
- Self-reported vs verified. Form data is self-reported until Crustdata confirms it. Label it.
- Free data first. Contact enrichment is opt-in and cost-confirmed.
person_contact_enrich has 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. Quote the ceiling before running ("emails for 12 leads = at most ~60 credits, go?").
- Read-only on connected systems. If a CRM, recorder, or email is connected, read it to sharpen the brief. This skill never writes, logs, sends, or schedules anything.
- Never output a close-probability percentage. Show the rubric score and the reasons instead.
- 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 name, a 12-word title, 40 inbound rows — change the layout: let the card grow, wrap instead of truncating, drop to one column, raise the cap, or give the wide thing its own scroll container. Never clip a card, ellipsis a name, or silently drop rows. State any unavoidable cap in the UI ("showing the top 50 of 214") and look at the rendered output before handing it over.
- Icons in rendered output: Lucide, inlined as SVG with a
currentColor stroke. No emojis in artifact UI — the 🔥/🟡/⚪ verdict tiers are chat-only; in a rendered brief they become coloured pills.
- Logos and photos are free — use them in rendered output.
basic_info.logo_permalink comes back from the free company_identify; basic_profile.profile_picture_permalink rides inside the basic_profile group person_enrich already returns. 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: briefs and triage tables are chat-native by default — never render an artifact just to render one. But IF the user wants a brief or a triage board as a rendered page or document, 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.
- Hand off: a hot inbound that needs a full account plan → account-research; more companies like this one → sales-prospecting; no ICP defined yet → icp-builder; who do we already know there → warm-path-workspace; write the touch this brief sets up → sales-outreach.
Error handling
- Branch on
r.ok in every script. An unchecked failure looks like "no signal" and a rep walks into a call believing it.
company_identify returns no match → the lead's domain may be personal, parked, or new. Surface it as unresolved with the reason; never guess a company.
- A 403 naming a field is a plan-gated projection — drop that field and re-run.
person_enrich no match → confirm the URL, then run the brief company-only and say the person block is thin.
- Empty posts or empty news is not an error. Say "no public activity in the last 60 days" and write the opener from role context.
- Every execute response carries
credits and credits_remaining; account_credits (free) reports the balance on demand.
Cost cheat sheet
| Call |
Cost |
company_identify, autocomplete, get_schema, account_credits |
Free |
company_search / person_search / job_search |
~0.03 cr/result |
job_search aggregation with limit: 0 |
counts only, ~free |
person_enrich (profile groups only) |
1 cr |
company_enrich by id with exact_match: true |
2 cr for one match |
social_post_list_live |
1 cr per post — always set limit |
web_search_live |
1 cr/query |
person_contact_enrich (opt-in) |
no base charge; cap 5 cr/person — narrowing fields is not a guaranteed cap |
Typical spend: a triage batch of 10 leads is under 1 credit. A full pre-call brief is about 8-10.
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. Tools used here: company_identify, company_search, company_enrich, person_search, person_enrich, job_search, social_post_list_live, web_search_live, company_autocomplete, person_autocomplete, person_contact_enrich (opt-in), account_credits
- Optional connectors the user may already have — a CRM, a call recorder, a calendar, email — all read-only, all optional. The skill runs fully on public data without any of them
Ships alongside icp-builder (writes the config this skill reads), sales-prospecting, account-research, and warm-path-workspace.
1---2name: meeting-prep3description: Today's calls and today's inbound. Three modes - score an inbound lead against your ICP (single or a whole day's batch, ranked), build a one-screen pre-call brief (company + person + 3 talking points, one sharp opener, 2 discovery questions, the likely objection), and build a call plan (discovery question plan mapped to a qualification framework, or a demo flow built only from pains the prospect actually stated). Runs on Crustdata company, people, job, news, and post data. Use for "prep my 2pm", "prep my call with <company>", "score this lead", "is this inbound worth my time", "triage today's inbound", "build me a discovery plan".4---56# Meeting Prep78One job: **the calls and leads in front of you today.** A rep opens this between meetings, so every deliverable is one screen, every claim carries a date, and nothing is invented to fill a section.910Three modes:11121. **Inbound triage** — someone filled a form or emailed in. Is this worth your time?132. **Pre-call brief** — the daily driver. One screen you can read in the two minutes before a call.143. **Call plan** — a discovery question plan, or a demo flow built only from stated pains.1516Ask which one if it isn't obvious from the ask. "Prep my 2pm" is mode 2. "Score this lead" is mode 1. "Build me a discovery plan" is mode 3.1718---1920## Step 0 — context (never blocks)2122- If `config/gtm-config.md` or `config/persona-profile.md` exist in the working directory, read them: ICP, what you sell, buyer titles, customer list, competitor list, voice. These drive the scoring rubric and the opener's voice.23- If they don't exist, ask 1-2 questions inline ("Who do you sell to — industry, size, geo? Which titles buy?") or point at the **icp-builder** skill to build the config properly. A missing config never blocks a run.24- **Optional read-only enrichment:** if a CRM, call recorder, or email is connected, a read-only sweep for prior threads, notes, and calls with the same domain sharpens any brief — past objections, who already talked to them, why the last deal stalled. Read only, never write, and never build the deliverable around it: the brief has to stand up on public data alone.25- For "prep my 2pm" with no calendar connected, just ask which meeting: company, and who you're meeting.2627---2829## Code Mode ground rules (read once, apply everywhere)3031All Crustdata calls run inside the `execute` tool of the Crustdata MCP server ([install.crustdata.com/mcp](https://install.crustdata.com/mcp)) as a plain-JavaScript script.3233- **Plain JavaScript only.** Author against the typed 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 spend.34- **Every script opens with a source-labeled comment**: `// user query: ...` for the literal ask, `// model query: ...` for a derived step. Scripts without one are rejected before running.35- **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 "no signal".36- **`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.37- **Return the smallest projection.** Only what the script returns reaches the model — map to compact rows, never raw profiles.38- **Fan out independent calls** with `await parallelMap(items, fn)`; batch first with `chunk(list, 25)`. Never parallelize cursor pagination or dependent stages — identify → enrich stays sequential, and you parallelize *within* a stage.39- **`company_identify` is free but fuzzy.** One domain or name can match several companies. Take the top `confidence_score` match. Do not project `social_profiles` on identify — it is plan-gated and 403s the whole call.40- **Plan-gated projections fail the whole call with a 403** naming the field. Never project `professional_network.followers` or `metadata` on `person_search`, or `certifications` / `honors` / `updated_at` on `person_enrich`.41- **Filter paths ≠ response paths.** You filter `experience.employment_details.current.company_name`; the response key is `...current[].name`. The LinkedIn URL returns at `social_handles.professional_network_identifier.profile_url` — use the `profileUrl(p)` accessor.42- **Categorical values are closed sets.** A plausible-but-wrong value silently returns zero rows. Resolve with `company_autocomplete` / `person_autocomplete` (free) before filtering on industries, funding round types, or seniority.43- **Zero results ≠ no signal.** Read the `trajectory` in the execute response before telling a rep an account is quiet.4445---4647## Mode 1 — Inbound triage4849Input: whatever the form or email gave you — name, work email, company or domain, self-reported title, and often a free-text "what are you looking for". A LinkedIn URL if you're lucky.5051### Step 1 — resolve and read the firmographics (cheap, and it batches)5253`company_identify` is free, so resolve every lead first, then read size, industry, funding, and growth for the whole batch in **one** `company_search` scoped to the resolved ids (~0.03 credits per row). Do not reach for `company_enrich` here — 2 credits per lead is the wrong tool for triage.5455```js56// user query: triage today's inbound — 6 form fills57const leads = inputs.leads; // [{ email, name, title, domain }, ...]58const batches = chunk(leads.map(l => l.domain).filter(Boolean), 25);59const identified = await parallelMap(batches, async (batch) => {60 const r = await callTool("company_identify", {61 domains: batch, // ONE identifier type per call62 fields: ["crustdata_company_id", "basic_info"],63 });64 return r.ok ? r.data : batch.map(d => ({ matched_on: d, matches: [], error: r.message }));65});66const rows = identified.flat();67const unresolved = rows.filter(m => !m.matches?.length).map(m => m.matched_on);68const byDomain = {};69for (const m of rows) {70 const top = (m.matches ?? []).slice().sort((a, b) => (b.confidence_score ?? 0) - (a.confidence_score ?? 0))[0];71 const cd = top?.company_data;72 if (cd) byDomain[m.matched_on] = cd.basic_info?.crustdata_company_id ?? cd.crustdata_company_id;73}74const ids = Object.values(byDomain).filter(Boolean);75checkpoint({ ids, unresolved });7677const r = await callTool("company_search", {78 filters: in_("crustdata_company_id", ids),79 fields: ["crustdata_company_id", "basic_info", "headcount", "funding", "locations"],80 limit: ids.length,81});82if (!r.ok) return { error: r.message, unresolved };83return {84 unresolved, // personal-email leads and dead domains — surfaced, never silently dropped85 companies: r.data.companies.map(c => ({86 id: c.crustdata_company_id,87 name: c.basic_info?.name,88 domain: c.basic_info?.primary_domain,89 industries: c.basic_info?.industries,90 hc: c.headcount?.total,91 growth12m: c.headcount?.growth_percent?.["12m"],92 lastRound: c.funding?.last_round_type,93 lastRaise: c.funding?.last_fundraise_date,94 country: c.locations?.country,95 })),96};97```9899`locations.country` accepts ISO-3 (`USA`) as a filter but **returns** the normalized full name — don't compare a response value against `"USA"`.100101### Step 2 — timing signals, near-free102103One `job_search` aggregation with `limit: 0` gives open-role counts across every resolved account at once. A company hiring into the function you sell to has budget moving.104105```js106// model query: open-role counts per inbound account (timing signal, counts only)107const r = await callTool("job_search", {108 filters: in_("company.basic_info.company_id", inputs.ids),109 aggregations: [{ type: "group_by", field: "company.basic_info.crustdata_company_id", agg: "count", size: 100 }],110 limit: 0,111});112if (!r.ok) return { error: r.message };113return r.data.aggregations;114```115116### Step 3 — the person117118- **LinkedIn URL given** → `person_enrich` (1 credit) with `fields: ["basic_profile", "experience", "social_handles"]`. Real title, real tenure, real employer. Worth it for a single high-stakes lead; skip it for a batch of 20 unless the user asks.119- **No URL** → use the self-reported title and **label it self-reported** in the output. Forms lie in both directions.120- Match the title against the ICP's buyer titles from config. The normalized seniority band rides inside the `experience` group that both `person_enrich` and `person_search` already return — read `cur.seniority_level`, don't buy a search for it. When you *filter* on it, the value vocabulary is a closed, autocomplete-first set: `Entry Level`, `Entry Level Manager`, `Experienced Manager`, `Senior`, `Director`, `Vice President`, `CXO`, `Owner / Partner`, `In Training`, `Strategic`.121122### Step 4 — score it, and show your work123124A transparent rubric out of 7. Never output a "close probability %" — without your closed-won history there is nothing to calibrate it against, and a made-up percentage is worse than no number.125126| Component | Points | What earns them |127|---|---|---|128| Firmographic fit | 0-3 | Industry, size band, and geography vs the ICP — one point each |129| Buyer-title match | 0-2 | Named buyer title or seniority band = 2; adjacent or influencer = 1; unrelated or well below the band = 0 |130| Timing | 0-2 | One point each, capped at 2: funding inside 6 months, 12-month headcount growth above 20%, open roles in the function you sell to, a relevant news item or post inside 60 days |131132This mode is for leads that came to *you* today. To rank your own book, territory, or a pasted account CSV, use **sales-prospecting**'s rank-my-accounts recipe instead.133134**Hard disqualifiers override the score entirely.** Any one of these means decline, whatever the points say: a competitor (from the config's competitor list if it carries one — if it doesn't, ask for your competitors in the same inline question as the ICP, before applying this disqualifier), an existing customer (route to the account owner instead), a student or job seeker, a personal email address with no resolvable company, a geography you can't serve, or headcount below your floor.135136**Verdict tiers** (chat output; in a rendered artifact these become coloured pills, never emoji):137138- 🔥 **Route now** — fit ≥ 2, title ≥ 1, at least one timing point, no disqualifier139- 🟡 **Nurture** — real company, wrong moment or wrong person. Say what would flip it140- ⚪ **Decline** — no fit, or a disqualifier fired. Name which one141142Every row shows the reasons and **dates the evidence** ("Series B, 2026-04-12", "17 open roles, 4 in data engineering"). A signal without a date is a signal a rep can't use.143144### Batch mode145146Rank a whole day's inbound in one table: Lead — Company — Fit — Title — Timing — Score — Verdict — Why now. Sort by score, group by verdict. Put unresolved domains in their own short list at the bottom with the reason (personal email, dead domain, no match) so nothing disappears. Then: "here are the three to call back today."147148---149150## Mode 2 — Pre-call brief151152The daily driver. Input: company (name or domain) and who you're meeting (name, ideally a LinkedIn URL).153154### The whole brief in one script155156Identify first (free, fuzzy — take the top confidence match), then fan out the four independent pulls. Person enrichment doesn't depend on the company, and neither does the person's post history, so they run in the same stage.157158```js159// user query: prep my 2pm with acme.com — meeting their VP Data160const idr = await callTool("company_identify", { domains: ["acme.com"] });161if (!idr.ok) return { error: idr.message };162const top = (idr.data[0]?.matches ?? [])163 .slice().sort((a, b) => (b.confidence_score ?? 0) - (a.confidence_score ?? 0))[0];164if (!top) return { error: "no company match for acme.com" };165const cd0 = top.company_data ?? {};166const companyId = cd0.basic_info?.crustdata_company_id ?? cd0.crustdata_company_id;167const logo = cd0.basic_info?.logo_permalink; // free, use it if the brief gets rendered168169const calls = [170 { key: "company", name: "company_enrich", params: {171 crustdata_company_ids: [companyId], exact_match: true,172 fields: ["basic_info", "headcount", "funding", "news"] } }, // 2 cr, exactly one match173 { key: "jobs", name: "job_search", params: {174 filters: { op: "and", conditions: [175 { field: "company.basic_info.primary_domain", type: "=", value: "acme.com" } ]},176 sorts: [{ field: "metadata.date_added", order: "desc" }], limit: 15 } },177 { key: "person", name: "person_enrich", params: {178 professional_network_profile_urls: ["https://www.linkedin.com/in/example"],179 fields: ["basic_profile", "experience", "education", "social_handles"] } }, // 1 cr180 { key: "posts", name: "social_post_list_live", params: {181 professional_network_profile_url: "https://www.linkedin.com/in/example",182 limit: 5 } }, // 1 cr PER POST — cap it183];184const out = await parallelMap(calls, async (c) => ({ key: c.key, r: await callTool(c.name, c.params) }));185checkpoint(out); // ~8-10 credits already spent — bank them before shaping, so a shaping error can't discard them186const get = (k) => out.find(x => x.key === k)?.r;187188const ce = get("company");189const cd = ce?.ok ? (ce.data[0]?.matches?.[0]?.company_data ?? {}) : {};190const cutoff = new Date(Date.now() - 60 * 24 * 3600 * 1000).toISOString().slice(0, 10);191const news = (firstArray(cd.news) ?? [])192 .map(n => ({ title: n.article_title, url: n.article_url, date: n.article_publish_date }))193 .filter(n => n.date && n.date >= cutoff)194 .slice(0, 6);195196const jr = get("jobs");197const pr = get("person");198const person = pr?.ok ? (pr.data[0]?.matches?.[0]?.person_data ?? {}) : {};199const cur = person.experience?.employment_details?.current?.[0] ?? {};200const posts = get("posts")?.ok201 ? (get("posts").data.posts ?? []).map(p => ({202 date: p.date_posted, text: (p.text ?? "").slice(0, 280),203 reactions: p.engagement?.total_reactions }))204 : [];205206return {207 company: {208 name: cd.basic_info?.name,209 what: cd.basic_info?.description,210 hc: cd.headcount?.total,211 growth12m: cd.headcount?.growth_percent?.["12m"],212 lastRound: cd.funding?.last_round_type,213 lastRaise: cd.funding?.last_fundraise_date,214 logo,215 },216 news,217 openRoles: jr?.ok218 ? { total: jr.data.total_count,219 newest: jr.data.job_listings.map(j => ({ title: j.job_details?.title, added: j.metadata?.date_added })) }220 : { error: jr?.message },221 person: {222 name: person.basic_profile?.name,223 title: person.basic_profile?.current_title,224 company: cur.name,225 since: cur.start_date,226 past: (person.experience?.employment_details?.past ?? []).slice(0, 4).map(e => ({ company: e.name, title: e.title })),227 schools: (person.education?.schools ?? []).map(e => e.school).filter(Boolean).slice(0, 3),228 linkedin: profileUrl(person),229 photo: person.basic_profile?.profile_picture_permalink, // free230 },231 posts,232};233```234235Notes on this script:236237- `fields` is a whitelist on both enrich calls — the `news` group is not returned unless you ask for it, and `education` on the person is what gives you shared-school common ground.238- `social_post_list_live` costs **1 credit per post**. Five is enough to know what someone is talking about. Say the cost before you raise it.239- If the person pull returns no match, confirm the URL and run the brief company-only. A thin person block is honest; an invented one gets a rep caught.240- Want last-60-days coverage the `news` group missed? One `web_search_live` query (1 credit). Keep it to one, and discard or label anything older than 60 days.241242### The deliverable — one screen, in this order243244**Company block**245One line on what they do, then headcount and 12-month growth, last round with its date, 3-5 dated signals (news items with their URLs, the open-roles shape, a funding event). Hiring says more than the press release: 20 open data roles is a data problem; a first security hire is a security budget about to open.246247**Person block**248Role and tenure, career path in one line, their last posts with dates, and common ground worth mentioning — shared employer, shared school, same city, a person you both know. Common ground is optional. A forced one is worse than none.249250**The part a rep actually reads**2512521. **Three talking points.** Each is one sentence with its evidence and date attached. Not "they're growing fast" — "headcount up 34% in 12 months and 6 open roles on the data team, so the pipeline they built in 2024 is probably straining."2532. **One opener.** Exactly one, written out as a sentence you could say out loud, referencing something real and dated inside 60 days. If nothing recent exists, say so and lean the opener on role context instead. **Never invent a recent event to have something to open with.**2543. **Two discovery questions.** Open, specific to this account, derived from the signals above.2554. **The likely objection and the pre-empt.** Name the one objection this person, in this role, at this company size, is most likely to raise, and the sentence that takes it off the table early.256257Nothing else. If a section has nothing real in it, cut it and say why it's empty.258259### Batch of briefs260261Several calls today? Run the identify stage for all of them, then one brief per meeting, in calendar order, each still one screen. State the credit total before starting: a brief runs about 8-10 credits, so five meetings is roughly 50.262263---264265## Mode 3 — Call plan266267Say which kind up front: **discovery** or **demo**.268269### Discovery plan2702711. **Two or three pain hypotheses, each evidenced.** Build them from the mode 2 signals — open roles, funding, growth rate, what they post about. Test a hypothesis cheaply before you build questions on it: count queries with the `[.]` exact-token operator on job titles and descriptions.272273```js274// model query: which pain themes show up in acme.com job posts275const themes = ["data quality", "observability", "migration"];276const brands = ["Snowflake", "dbt", "Airflow"];277const probes = [278 ...themes.map(t => ({ label: t, cond: { field: "content.description", type: "(.)", value: t } })),279 ...brands.map(b => ({ label: b, cond: exactToken("content.description", b) })), // [.] for brand names280];281const hits = await parallelMap(probes, async (p) => {282 const r = await callTool("job_search", {283 filters: { op: "and", conditions: [284 { field: "company.basic_info.primary_domain", type: "=", value: "acme.com" },285 p.cond,286 ]},287 limit: 1,288 });289 return { probe: p.label, postings: r.ok ? r.data.total_count : null, error: r.ok ? undefined : r.message };290});291return hits;292```293294`(.)` is typo-tolerant and matches lookalike words — fine for descriptive multi-word themes, wrong for a brand or product name. Always use `[.]` (`exactToken`) for those.2952962. **The question plan: 8-12 open questions, not 30.** A rep asks maybe eight questions in a first call. Map each one to a slot in a qualification framework — MEDDICC by default, or SPICED if the user works that way, and ask which if the config doesn't say. Every question carries three things: the framework slot it fills, the hypothesis it tests, and why you're asking it now.297298| Slot | Question | Tests |299|---|---|---|300| Metrics | "How are you measuring the cost of that today?" | Pain hypothesis 1 is quantified |301| Pain | "Walk me through what happens when a pipeline breaks at 2am." | Hypothesis 1 is real, not assumed |302| Champion | "Who else feels this every week?" | Multithreading path |303304Rules for the questions: open, not leading. Never ask what public data already answered — asking a VP how big their team is when their headcount is on the page wastes the one thing you have. Sequence them: situation, then pain, then impact, then process.3053063. **The committed next step.** Write the exact ask, with a date and a name in it. "Would it make sense to get your data lead on a 30-minute working session next Tuesday?" beats "let's find time."3073084. **Coverage check.** List which framework slots this plan will *not* fill in one call, so the rep knows what's left for call two rather than discovering it at forecast time.309310### Demo flow311312**Built only from pains the prospect actually stated.** Sources for "stated": the user pastes their discovery notes, or the call plan follows a discovery call in the same session, or a connected recorder or CRM has the notes (read-only). Public signals generate hypotheses, never stated pains — keep the two apart and label them.313314**If discovery is thin, say so before you build anything.** "You have one stated pain and two hypotheses. This demo will be generic. Want a 15-minute discovery block at the top instead?" Then build the shorter version if they still want it.315316Structure:3173181. **Recap open** — their pains in their own words, read back. Confirm before showing anything.3192. **One beat per stated pain**, in their priority order, not your feature order: tell what you'll show and why it matters to *them*, show it, then confirm ("does that match how your team would use it?"). A beat with no stated pain behind it gets cut — that is the whole discipline of this mode.3203. **Close plan** — the specific next step, who else needs to see it, what they need to decide, by when.321322Explicitly list what you are **not** showing and why. A demo that skips six features on purpose reads as confidence.323324---325326## Rules327328- **Grounded only.** Every claim in a brief traces to a Crustdata result, a URL, or something the user told you. Unclear means ask or omit. Never invent a recent event, a mutual connection, or a pain.329- **Dates on everything.** A signal with no date is unusable. Anything older than 60 days is labeled background.330- **One screen per deliverable.** Reps skim. Cut a section rather than padding it.331- **No-slop rule on every word you draft** — openers, questions, recaps: no em dashes, never "delve" / "leverage" / "streamline", no filler sentences. Write like a colleague who did the homework.332- **Self-reported vs verified.** Form data is self-reported until Crustdata confirms it. Label it.333- **Free data first.** Contact enrichment is opt-in and cost-confirmed. `person_contact_enrich` has 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. Quote the ceiling before running ("emails for 12 leads = at most ~60 credits, go?").334- **Read-only on connected systems.** If a CRM, recorder, or email is connected, read it to sharpen the brief. This skill never writes, logs, sends, or schedules anything.335- **Never output a close-probability percentage.** Show the rubric score and the reasons instead.336- **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 name, a 12-word title, 40 inbound rows — change the layout: let the card grow, wrap instead of truncating, drop to one column, raise the cap, or give the wide thing its own scroll container. Never clip a card, ellipsis a name, or silently drop rows. State any unavoidable cap in the UI ("showing the top 50 of 214") and look at the rendered output before handing it over.337- **Icons in rendered output**: Lucide, inlined as SVG with a `currentColor` stroke. **No emojis in artifact UI** — the 🔥/🟡/⚪ verdict tiers are chat-only; in a rendered brief they become coloured pills.338- **Logos and photos are free — use them in rendered output.** `basic_info.logo_permalink` comes back from the free `company_identify`; `basic_profile.profile_picture_permalink` rides inside the `basic_profile` group `person_enrich` already returns. 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.339- **Artifact branding**: briefs and triage tables are chat-native by default — never render an artifact just to render one. But IF the user wants a brief or a triage board as a rendered page or document, 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.340- **Hand off**: a hot inbound that needs a full account plan → **account-research**; more companies like this one → **sales-prospecting**; no ICP defined yet → **icp-builder**; who do we already know there → **warm-path-workspace**; write the touch this brief sets up → **sales-outreach**.341342## Error handling343344- **Branch on `r.ok` in every script.** An unchecked failure looks like "no signal" and a rep walks into a call believing it.345- `company_identify` returns no match → the lead's domain may be personal, parked, or new. Surface it as unresolved with the reason; never guess a company.346- A 403 naming a field is a plan-gated projection — drop that field and re-run.347- `person_enrich` no match → confirm the URL, then run the brief company-only and say the person block is thin.348- Empty posts or empty news is not an error. Say "no public activity in the last 60 days" and write the opener from role context.349- Every execute response carries `credits` and `credits_remaining`; `account_credits` (free) reports the balance on demand.350351## Cost cheat sheet352353| Call | Cost |354|---|---|355| `company_identify`, autocomplete, `get_schema`, `account_credits` | Free |356| `company_search` / `person_search` / `job_search` | ~0.03 cr/result |357| `job_search` aggregation with `limit: 0` | counts only, ~free |358| `person_enrich` (profile groups only) | 1 cr |359| `company_enrich` by id with `exact_match: true` | 2 cr for one match |360| `social_post_list_live` | 1 cr per post — always set `limit` |361| `web_search_live` | 1 cr/query |362| `person_contact_enrich` (opt-in) | no base charge; cap 5 cr/person — narrowing `fields` is not a guaranteed cap |363364Typical spend: a triage batch of 10 leads is under 1 credit. A full pre-call brief is about 8-10.365366## Tool dependencies367368This skill requires:369370- **Crustdata MCP server** ([install.crustdata.com/mcp](https://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. Tools used here: `company_identify`, `company_search`, `company_enrich`, `person_search`, `person_enrich`, `job_search`, `social_post_list_live`, `web_search_live`, `company_autocomplete`, `person_autocomplete`, `person_contact_enrich` (opt-in), `account_credits`371- **Optional connectors** the user may already have — a CRM, a call recorder, a calendar, email — all read-only, all optional. The skill runs fully on public data without any of them372373Ships alongside **icp-builder** (writes the config this skill reads), **sales-prospecting**, **account-research**, and **warm-path-workspace**.