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 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
tool of the Crustdata MCP server (
install.crustdata.com/mcp) as a plain-JavaScript script.
- Plain JavaScript only. Author against the typed surface from , but the script body carries zero type annotations — a , , or generic is a parse error that fails the whole run before any spend.
- Every script opens with a source-labeled comment: for the literal ask, for a derived step. Scripts without one are rejected before running.
- One I/O primitive:
const r = await callTool(name, params)
→ or { ok: false, status, errorType, message }
. Always branch on — a failed call does not abort the script, so an unchecked failure silently proceeds on empty data and looks like "no signal".
- is a response whitelist. The result carries only the groups you list; an omitted group reads as 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 . Never parallelize cursor pagination or dependent stages — identify → enrich stays sequential, and you parallelize within a stage.
- is free but fuzzy. One domain or name can match several companies. Take the top match. Do not project 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 on , or / / on .
- Filter paths ≠ response paths. You filter
experience.employment_details.current.company_name
; the response key is . The LinkedIn URL returns at social_handles.professional_network_identifier.profile_url
— use the accessor.
- Categorical values are closed sets. A plausible-but-wrong value silently returns zero rows. Resolve with / (free) before filtering on industries, funding round types, or seniority.
- Zero results ≠ no signal. Read the 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)
is free, so resolve every lead first, then read size, industry, funding, and growth for the whole batch in
one scoped to the resolved ids (~0.03 credits per row). Do not reach for
here — 2 credits per lead is the wrong tool for triage.
js
// 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,
})),
};
accepts ISO-3 (
) as a filter but
returns the normalized full name — don't compare a response value against
.
Step 2 — timing signals, near-free
One
aggregation with
gives open-role counts across every resolved account at once. A company hiring into the function you sell to has budget moving.
js
// 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 → (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 group that both and already return — read , don't buy a search for it. When you filter on it, the value vocabulary is a closed, autocomplete-first set: , , , , , , , , , .
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.
js
// 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:
- is a whitelist on both enrich calls — the group is not returned unless you ask for it, and on the person is what gives you shared-school common ground.
- 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 group missed? One 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.
js
// 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
(
) 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. 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 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 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 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 ; basic_profile.profile_picture_permalink
rides inside the group already returns. Base64-inline both as data:image/jpeg;base64,...
URIs — the media CDN serves them as , so a remote 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 — (dark text, for light backgrounds) and (white text, for dark backgrounds). Base64-inline the theme-appropriate variant at ~17px height — never hotlink; rendered artifacts cannot fetch remote images. Brand accent: ( 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 in every script. An unchecked failure looks like "no signal" and a rep walks into a call believing it.
- 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.
- 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 and ; (free) reports the balance on demand.
Cost cheat sheet
| Call | Cost |
|---|
| , autocomplete, , | Free |
| / / | ~0.03 cr/result |
| aggregation with | counts only, ~free |
| (profile groups only) | 1 cr |
| by id with | 2 cr for one match |
| 1 cr per post — always set |
| 1 cr/query |
| (opt-in) | no base charge; cap 5 cr/person — narrowing 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 , , and . All Crustdata data tools are reached inside an plain-JavaScript script via
await callTool(name, params)
— author against the typed surface from , but the script body carries zero type annotations. Tools used here: , , , , , , , , , , (opt-in),
- 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.