ICP Builder
One LinkedIn URL in, a working GTM config out. This skill enriches the user's own
profile via Crustdata and writes config/persona-profile.md + config/gtm-config.md —
the files sales-prospecting and account-research read at startup.
Three steps, always in this order:
- Stack (optional, fully skippable): which tools they use.
- Persona: one LinkedIn URL; Crustdata turns it into who they are, what they sell,
an inferred ICP, and their writing voice.
- Write config + hand off.
Never interrogate the user. Do not ask "what do you sell", "who's your ICP", or
"paste your voice emails". All of that is derived from the LinkedIn URL and their posts.
The URL is the entire interview.
Step 0: check for an existing config
If config/gtm-config.md or config/persona-profile.md already exist in the working
directory, read them, summarize what's there in two lines, and ask whether to refresh
the whole persona or update specific fields. Never silently overwrite a config the user
already corrected. On a refresh, carry the existing Stack entries forward unchanged and
do not re-ask the stack question unless the user asks to change it. Missing files are
the normal case — this skill creates them.
Step 1: welcome + optional stack question
Open with one short welcome line, then ONE optional question: which tools do you
use? One quick pass through the slots; the user names a tool or says skip. If they
skip the whole question, write none everywhere and move on.
- Data provider — Crustdata, the data source these skills run on (added as a
connector; if it's not connected, use the no-data fallback below)
- CRM — or skip
- Calendar — or skip
- Email — or skip
- Call recorder — or skip
- Sequencer — or skip
- Team chat — or skip
Rules for this step:
- Never assume the stack from connected connectors. A connected connector is not
the user's choice. Ask, or write
none.
- Every slot is skippable; never pressure or re-ask a declined tool.
- Skipped slot =
none in the config = downstream skills run that slot draft-only:
drafts and CSV exports instead of pushing to the tool ("export a CSV for your
sequencer", "log to a file instead of the CRM").
Step 2: LinkedIn URL → persona
Ask for one thing: their LinkedIn URL. Then build the persona in one execute
script. The person lookup comes first; the company enrich and the posts pull both
depend on it but not on each other, so fan those two out with parallelMap.
Every script must open with a source-labeled query comment (// user query: ... or
// model query: ...) — scripts without one are rejected before running, at zero spend.
// user query: set up my GTM config — my LinkedIn is https://www.linkedin.com/in/example
const url = "https://www.linkedin.com/in/example";
// Stage 1: the person. Base cost 1 credit. `fields` is a response WHITELIST —
// the result carries ONLY the groups listed here; an omitted group reads as
// undefined later and looks like missing data. basic_profile + experience covers
// the persona; social_handles carries the canonical profile URL the posts pull
// is keyed on; contact groups only add cost.
const pr = await callTool("person_enrich", {
professional_network_profile_urls: [url],
fields: ["basic_profile", "experience", "social_handles"],
});
if (!pr.ok) return { error: pr.message };
const person = pr.data[0]?.matches?.[0]?.person_data;
if (!person) return { error: "no_match" }; // → confirm the URL, then no-data fallback
const canonicalUrl = profileUrl(person) ?? url; // preloaded accessor
const current = person.experience?.employment_details?.current?.[0] ?? {};
const companyId = currentCompanyIds(person)[0]; // preloaded accessor
// Stage 2: company + posts are independent of each other — fan them out.
const calls = [
{ name: "social_post_list_live",
params: { professional_network_profile_url: canonicalUrl, limit: 10 } }, // 1 cr/post — cap deliberately
];
if (companyId) {
calls.push({ name: "company_enrich",
params: { crustdata_company_ids: [companyId], exact_match: true,
fields: ["basic_info", "taxonomy"] } }); // 2 cr, exactly one match
}
const results = await parallelMap(calls, async (c) => ({ name: c.name, r: await callTool(c.name, c.params) }));
const postsR = results.find(x => x.name === "social_post_list_live")?.r;
const companyR = results.find(x => x.name === "company_enrich")?.r;
// Posts are optional: a failed or empty pull means neutral voice, not a failed run.
const posts = postsR && postsR.ok
? (postsR.data.posts ?? []).map(p => ({
text: p.text,
date: p.date_posted,
reactions: p.engagement?.total_reactions,
comments: p.engagement?.total_comments,
}))
: [];
const company = companyR && companyR.ok
? pick(companyR.data[0]?.matches?.[0]?.company_data ?? {}, ["basic_info", "taxonomy"])
: null;
// Return the smallest projection — only what the script returns reaches the model.
return {
identity: {
name: person.basic_profile?.name,
title: person.basic_profile?.current_title,
location: person.basic_profile?.location,
company: current.name,
company_domain: current.company_website_domain,
start_date: current.start_date, // tenure = today minus this
},
past_roles: (person.experience?.employment_details?.past ?? []).slice(0, 5)
.map(e => ({ company: e.name, title: e.title })),
company,
posts,
};
Notes on this script:
- Never set
preview: true on person_enrich. It is plan-dependent and returns a
400 on some accounts. The flow must never depend on it; base cost is 1 credit anyway.
- Keep
person_enrich fields to basic_profile + experience + social_handles.
Without social_handles in the whitelist the profileUrl accessor reads
undefined and the posts pull falls back to the raw user-typed URL. Some groups
(certifications, honors, updated_at) are plan-gated — a gated projection fails
the WHOLE call with a 403 that names the field. If that happens, drop the field and
re-run.
- Response paths differ from filter paths: the title lives at
basic_profile.current_title, the current employer at
experience.employment_details.current[].name, the canonical profile URL at
social_handles.professional_network_identifier.profile_url (the profileUrl
accessor reads it for you).
Company fallback: no company id on the profile
If the current employment carries no company id, resolve the company by domain (or
name) first. company_identify is free and fuzzy — one identifier can return several
companies — so pick the top confidence_score match, then enrich by id with
exact_match: true. That is the cheapest exact path: free identify + 2 credits for
exactly one enriched match.
// model query: resolve and enrich the user's current company by domain
const idr = await callTool("company_identify", { domains: ["example.com"] }); // ONE identifier type per call
if (!idr.ok) return { error: idr.message };
const matches = idr.data[0]?.matches ?? [];
const top = matches.slice().sort((a, b) => (b.confidence_score ?? 0) - (a.confidence_score ?? 0))[0];
if (!top) return { error: "no_company_match" };
const id = top.company_data?.basic_info?.crustdata_company_id ?? top.company_data?.crustdata_company_id;
const er = await callTool("company_enrich", {
crustdata_company_ids: [id],
exact_match: true,
fields: ["basic_info", "taxonomy"],
});
if (!er.ok) return { error: er.message };
return pick(er.data[0]?.matches?.[0]?.company_data ?? {}, ["basic_info", "taxonomy"]);
Do not project social_profiles on company_identify — it is plan-gated and 403s the
whole call.
Derive the persona from the returned data
- Identity: name, title, company, tenure (from
start_date), one-line background
from the past roles.
- Company & what we sell: product and category from
basic_info + taxonomy;
keywords to monitor from the company description and the user's post topics.
- Voice: tone and style notes from the actual posts — sentence length, first vs.
third person, jargon level, emoji use, how they open. If posts are empty, write
"neutral" and move on.
- Topics they care about: recurring themes across the posts, weighted by
engagement.
Inferred ICP — label it, and make it filter-ready
Derive the ICP from what the company sells plus who typically buys it: industries,
headcount range, geography, funding stage, buyer titles, buyer seniority. Always
label it inferred — it is a hypothesis for the user to correct, not a fact.
Write ICP values that downstream searches can use directly. Categorical fields are
closed sets — a plausible-but-wrong value silently returns zero rows — so resolve them
via autocomplete (free) before writing the config:
// model query: resolve filter-ready values for the inferred ICP
const probes = [
{ tool: "company_autocomplete", params: { field: "basic_info.industries", query: "software" } },
{ tool: "person_autocomplete", params: { field: "experience.employment_details.current.seniority_level", query: "vice" } },
];
return await parallelMap(probes, async (p) => {
const r = await callTool(p.tool, p.params);
// Returns shape is { suggestions: [{ value }] } — project to the value strings.
return { field: p.params.field, values: r.ok ? (r.data.suggestions ?? []).map(s => s.value) : [], error: r.ok ? null : r.message };
});
Buyer seniority must use the exact vocabulary of
experience.employment_details.current.seniority_level: Entry Level,
Entry Level Manager, Experienced Manager, Senior, Director, Vice President,
CXO, Owner / Partner, In Training, Strategic. When unsure, resolve through
person_autocomplete rather than guessing.
Accuracy is non-negotiable
This profile drives every downstream skill; wrong info poisons everything.
- Only write what the source data supports. If something can't be confirmed, say so
instead of guessing.
- Label every inference (the ICP is always labeled
inferred).
- Show the persona back before writing files: "Here's who I think you are —
correct me if I'm off." Apply corrections, then write.
Step 3: write the config files
Write both files in the working directory. config/persona-profile.md is the full
persona; config/gtm-config.md repeats the Company / ICP / Voice essentials plus the
stack so every skill finds them in one read.
config/persona-profile.md
# Persona Profile
Built by icp-builder on <YYYY-MM-DD>. Read by sales-prospecting, account-research, sales-outreach, and meeting-prep.
## Identity
- Name:
- Title:
- Company: <name> (<domain>)
- Tenure: since <start date>
- Background: <one line from past roles>
## Company & what we sell
- Product:
- Category:
- Keywords to monitor:
## Inferred ICP
Label: inferred from <what the company sells + typical buyers>. User-confirmed: <yes/no>
- Industries: <filter-ready values>
- Headcount:
- Geography:
- Funding stage:
- Buyer titles:
- Buyer seniority: <exact seniority vocabulary values>
## Voice
- Tone:
- Style notes:
- Always: no em dashes; never "delve", "leverage", or "streamline"; no filler;
write like a colleague.
## Topics they care about
- <from posts, weighted by engagement>
config/gtm-config.md
# GTM Config
Read by sales-prospecting, account-research, sales-outreach, and meeting-prep at startup.
## Stack
- Data provider: crustdata | none
- CRM: <tool> | none
- Calendar: <tool> | none
- Email: <tool> | none
- Call recorder: <tool> | none
- Sequencer: <tool> | none
- Team chat: <tool> | none
`none` = that slot runs draft-only: drafts and CSV exports instead of pushing to the tool.
## What we sell
<one or two lines>
## ICP (inferred)
- Industries:
- Headcount:
- Geography:
- Funding stage:
- Buyer titles:
- Buyer seniority:
## Customers
none yet — add names or domains as you close; sales-prospecting uses them for lookalikes.
## Voice
<tone in one line>. No em dashes; never "delve", "leverage", or "streamline"; no filler;
write like a colleague.
Hand off
Summarize: stack connected vs skipped, the persona in 2-3 lines, and what was labeled
inferred. Then:
You're set up. Try sales-prospecting ("build me a list from my ICP") or
account-research ("research ") — both read this config automatically.
No-data fallback
If Crustdata isn't connected, or enrichment comes back thin (no match, sparse profile,
zero posts):
- Take 2-3 lines from the user instead: name and role, what the company does, who
they sell to. That's the whole interview — never run a long questionnaire.
- Write both config files from those lines. Voice = neutral plus the no-slop rule.
ICP = still labeled
inferred.
- If enrichment was partial, keep what was verified, say exactly what couldn't be
inferred, and let the user add a line for just that.
Costs
person_enrich with basic_profile + experience + social_handles: 1 credit.
company_identify, company_autocomplete, person_autocomplete: free.
company_enrich by id with exact_match: true: 2 credits for one match.
social_post_list_live: 1 credit per post — always set limit deliberately
(10 is plenty for voice).
- Typical full run: about 13 credits. Every
execute response carries credits and
credits_remaining; account_credits (free) reports the balance.
Error handling
- Branch on
r.ok in every script. A failed call does not abort the script; an
unchecked failure silently proceeds on empty data and looks like "no results".
person_enrich returns no match → confirm the URL with the user (typo, vanity slug
change), then use the no-data fallback.
- A 403 that names a field means a plan-gated projection — drop that field and re-run.
- A failed or empty posts call is not an error: voice goes neutral.
- Company enrich fails → keep the persona from person data alone and note what's
missing.
Rules
- Welcome first; one optional stack question; the URL is the entire interview.
- Never assume the stack from connected connectors. Ask, or write
none.
- Never ask what they sell, their ICP, or their voice — derive it. If enrichment is
thin, take 2-3 lines, never a full interview.
- Show the persona back for correction before writing files.
- Label inferences. Write
none for skipped tools. Never invent stack or persona
details.
- Voice always carries the no-slop rule: no em dashes; never "delve", "leverage",
or "streamline"; no filler; write like a colleague.
- A missing config never blocks anything: this skill creates it, and downstream skills
point back here when it's absent.
- 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") so the reader knows what they're not seeing. 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.
- The persona's own photo is free too —
basic_profile.profile_picture_permalink rides in
the basic_profile group the Step 2 person_enrich already returns. A persona one-pager is
about a person; base64-inline the photo (same binary/octet-stream rule) with a monogram
fallback.
- The company logo is free — use it on a rendered persona page.
basic_info.logo_permalink comes from the free company_identify and from the company_enrich you already run for the persona. Base64-inline it as a data:image/jpeg;base64,... URI (the media CDN serves these as binary/octet-stream, so a remote <img src> renders blank); monogram fallback when there's none.
- Artifact branding: the config files stay plain markdown — no branding noise in
machine-read files. But IF the persona is rendered as a page or document (a persona
one-pager, an ICP summary doc), 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), the same files
app.crustdata.com's header renders. Base64-inline the theme-appropriate variant at
~17px height — never hotlink; rendered artifacts cannot fetch remote images. Brand
accent: #5547E2 (the product primary; #8387FF on dark grounds). Body font: Geist
when embeddable, else the system stack. Never render an artifact just to carry the mark.
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: person_enrich,
company_identify, company_enrich, social_post_list_live,
company_autocomplete, person_autocomplete, account_credits.
- Write access to the working directory — creates
config/persona-profile.md and
config/gtm-config.md.
Ships alongside sales-prospecting and account-research, which read the config
this skill writes.
1---2name: icp-builder3description: One-time GTM setup: paste one LinkedIn URL and Crustdata enriches it into a persona profile — who you are, what your company sells, your inferred ICP, your writing voice — saved as config/gtm-config.md and config/persona-profile.md, the files the sales-prospecting, account-research, sales-outreach, and meeting-prep skills read at startup. Use when someone says "build my ICP", "set up my GTM config", "create my persona profile", "onboard me", "get me started", or when another GTM skill reports the config is missing.4---56# ICP Builder78One LinkedIn URL in, a working GTM config out. This skill enriches the user's own9profile via Crustdata and writes `config/persona-profile.md` + `config/gtm-config.md` —10the files **sales-prospecting** and **account-research** read at startup.1112Three steps, always in this order:13141. **Stack** (optional, fully skippable): which tools they use.152. **Persona**: one LinkedIn URL; Crustdata turns it into who they are, what they sell,16 an inferred ICP, and their writing voice.173. **Write config + hand off.**1819**Never interrogate the user.** Do not ask "what do you sell", "who's your ICP", or20"paste your voice emails". All of that is derived from the LinkedIn URL and their posts.21The URL is the entire interview.2223---2425## Step 0: check for an existing config2627If `config/gtm-config.md` or `config/persona-profile.md` already exist in the working28directory, read them, summarize what's there in two lines, and ask whether to refresh29the whole persona or update specific fields. Never silently overwrite a config the user30already corrected. On a refresh, carry the existing Stack entries forward unchanged and31do not re-ask the stack question unless the user asks to change it. Missing files are32the normal case — this skill creates them.3334## Step 1: welcome + optional stack question3536Open with one short welcome line, then ONE optional question: **which tools do you37use?** One quick pass through the slots; the user names a tool or says skip. If they38skip the whole question, write `none` everywhere and move on.3940- **Data provider** — Crustdata, the data source these skills run on (added as a41 connector; if it's not connected, use the no-data fallback below)42- **CRM** — or skip43- **Calendar** — or skip44- **Email** — or skip45- **Call recorder** — or skip46- **Sequencer** — or skip47- **Team chat** — or skip4849Rules for this step:5051- **Never assume the stack from connected connectors.** A connected connector is not52 the user's choice. Ask, or write `none`.53- Every slot is skippable; never pressure or re-ask a declined tool.54- Skipped slot = `none` in the config = downstream skills run that slot draft-only:55 drafts and CSV exports instead of pushing to the tool ("export a CSV for your56 sequencer", "log to a file instead of the CRM").5758## Step 2: LinkedIn URL → persona5960Ask for one thing: their **LinkedIn URL**. Then build the persona in one `execute`61script. The person lookup comes first; the company enrich and the posts pull both62depend on it but not on each other, so fan those two out with `parallelMap`.6364Every script must open with a source-labeled query comment (`// user query: ...` or65`// model query: ...`) — scripts without one are rejected before running, at zero spend.6667```js68// user query: set up my GTM config — my LinkedIn is https://www.linkedin.com/in/example69const url = "https://www.linkedin.com/in/example";7071// Stage 1: the person. Base cost 1 credit. `fields` is a response WHITELIST —72// the result carries ONLY the groups listed here; an omitted group reads as73// undefined later and looks like missing data. basic_profile + experience covers74// the persona; social_handles carries the canonical profile URL the posts pull75// is keyed on; contact groups only add cost.76const pr = await callTool("person_enrich", {77 professional_network_profile_urls: [url],78 fields: ["basic_profile", "experience", "social_handles"],79});80if (!pr.ok) return { error: pr.message };81const person = pr.data[0]?.matches?.[0]?.person_data;82if (!person) return { error: "no_match" }; // → confirm the URL, then no-data fallback8384const canonicalUrl = profileUrl(person) ?? url; // preloaded accessor85const current = person.experience?.employment_details?.current?.[0] ?? {};86const companyId = currentCompanyIds(person)[0]; // preloaded accessor8788// Stage 2: company + posts are independent of each other — fan them out.89const calls = [90 { name: "social_post_list_live",91 params: { professional_network_profile_url: canonicalUrl, limit: 10 } }, // 1 cr/post — cap deliberately92];93if (companyId) {94 calls.push({ name: "company_enrich",95 params: { crustdata_company_ids: [companyId], exact_match: true,96 fields: ["basic_info", "taxonomy"] } }); // 2 cr, exactly one match97}98const results = await parallelMap(calls, async (c) => ({ name: c.name, r: await callTool(c.name, c.params) }));99100const postsR = results.find(x => x.name === "social_post_list_live")?.r;101const companyR = results.find(x => x.name === "company_enrich")?.r;102103// Posts are optional: a failed or empty pull means neutral voice, not a failed run.104const posts = postsR && postsR.ok105 ? (postsR.data.posts ?? []).map(p => ({106 text: p.text,107 date: p.date_posted,108 reactions: p.engagement?.total_reactions,109 comments: p.engagement?.total_comments,110 }))111 : [];112113const company = companyR && companyR.ok114 ? pick(companyR.data[0]?.matches?.[0]?.company_data ?? {}, ["basic_info", "taxonomy"])115 : null;116117// Return the smallest projection — only what the script returns reaches the model.118return {119 identity: {120 name: person.basic_profile?.name,121 title: person.basic_profile?.current_title,122 location: person.basic_profile?.location,123 company: current.name,124 company_domain: current.company_website_domain,125 start_date: current.start_date, // tenure = today minus this126 },127 past_roles: (person.experience?.employment_details?.past ?? []).slice(0, 5)128 .map(e => ({ company: e.name, title: e.title })),129 company,130 posts,131};132```133134Notes on this script:135136- **Never set `preview: true` on `person_enrich`.** It is plan-dependent and returns a137 400 on some accounts. The flow must never depend on it; base cost is 1 credit anyway.138- **Keep `person_enrich` fields to `basic_profile` + `experience` + `social_handles`.**139 Without `social_handles` in the whitelist the `profileUrl` accessor reads140 `undefined` and the posts pull falls back to the raw user-typed URL. Some groups141 (`certifications`, `honors`, `updated_at`) are plan-gated — a gated projection fails142 the WHOLE call with a 403 that names the field. If that happens, drop the field and143 re-run.144- Response paths differ from filter paths: the title lives at145 `basic_profile.current_title`, the current employer at146 `experience.employment_details.current[].name`, the canonical profile URL at147 `social_handles.professional_network_identifier.profile_url` (the `profileUrl`148 accessor reads it for you).149150### Company fallback: no company id on the profile151152If the current employment carries no company id, resolve the company by domain (or153name) first. `company_identify` is free and fuzzy — one identifier can return several154companies — so pick the top `confidence_score` match, then enrich by id with155`exact_match: true`. That is the cheapest exact path: free identify + 2 credits for156exactly one enriched match.157158```js159// model query: resolve and enrich the user's current company by domain160const idr = await callTool("company_identify", { domains: ["example.com"] }); // ONE identifier type per call161if (!idr.ok) return { error: idr.message };162const matches = idr.data[0]?.matches ?? [];163const top = matches.slice().sort((a, b) => (b.confidence_score ?? 0) - (a.confidence_score ?? 0))[0];164if (!top) return { error: "no_company_match" };165const id = top.company_data?.basic_info?.crustdata_company_id ?? top.company_data?.crustdata_company_id;166167const er = await callTool("company_enrich", {168 crustdata_company_ids: [id],169 exact_match: true,170 fields: ["basic_info", "taxonomy"],171});172if (!er.ok) return { error: er.message };173return pick(er.data[0]?.matches?.[0]?.company_data ?? {}, ["basic_info", "taxonomy"]);174```175176Do not project `social_profiles` on `company_identify` — it is plan-gated and 403s the177whole call.178179### Derive the persona from the returned data180181- **Identity**: name, title, company, tenure (from `start_date`), one-line background182 from the past roles.183- **Company & what we sell**: product and category from `basic_info` + `taxonomy`;184 keywords to monitor from the company description and the user's post topics.185- **Voice**: tone and style notes from the actual posts — sentence length, first vs.186 third person, jargon level, emoji use, how they open. If posts are empty, write187 "neutral" and move on.188- **Topics they care about**: recurring themes across the posts, weighted by189 engagement.190191### Inferred ICP — label it, and make it filter-ready192193Derive the ICP from what the company sells plus who typically buys it: industries,194headcount range, geography, funding stage, buyer titles, buyer seniority. **Always195label it `inferred`** — it is a hypothesis for the user to correct, not a fact.196197Write ICP values that downstream searches can use directly. Categorical fields are198closed sets — a plausible-but-wrong value silently returns zero rows — so resolve them199via autocomplete (free) before writing the config:200201```js202// model query: resolve filter-ready values for the inferred ICP203const probes = [204 { tool: "company_autocomplete", params: { field: "basic_info.industries", query: "software" } },205 { tool: "person_autocomplete", params: { field: "experience.employment_details.current.seniority_level", query: "vice" } },206];207return await parallelMap(probes, async (p) => {208 const r = await callTool(p.tool, p.params);209 // Returns shape is { suggestions: [{ value }] } — project to the value strings.210 return { field: p.params.field, values: r.ok ? (r.data.suggestions ?? []).map(s => s.value) : [], error: r.ok ? null : r.message };211});212```213214Buyer seniority must use the exact vocabulary of215`experience.employment_details.current.seniority_level`: `Entry Level`,216`Entry Level Manager`, `Experienced Manager`, `Senior`, `Director`, `Vice President`,217`CXO`, `Owner / Partner`, `In Training`, `Strategic`. When unsure, resolve through218`person_autocomplete` rather than guessing.219220### Accuracy is non-negotiable221222This profile drives every downstream skill; wrong info poisons everything.223224- Only write what the source data supports. If something can't be confirmed, say so225 instead of guessing.226- Label every inference (the ICP is always labeled `inferred`).227- **Show the persona back before writing files**: "Here's who I think you are —228 correct me if I'm off." Apply corrections, then write.229230## Step 3: write the config files231232Write both files in the working directory. `config/persona-profile.md` is the full233persona; `config/gtm-config.md` repeats the Company / ICP / Voice essentials plus the234stack so every skill finds them in one read.235236### `config/persona-profile.md`237238```markdown239# Persona Profile240Built by icp-builder on <YYYY-MM-DD>. Read by sales-prospecting, account-research, sales-outreach, and meeting-prep.241242## Identity243- Name:244- Title:245- Company: <name> (<domain>)246- Tenure: since <start date>247- Background: <one line from past roles>248249## Company & what we sell250- Product:251- Category:252- Keywords to monitor:253254## Inferred ICP255Label: inferred from <what the company sells + typical buyers>. User-confirmed: <yes/no>256- Industries: <filter-ready values>257- Headcount:258- Geography:259- Funding stage:260- Buyer titles:261- Buyer seniority: <exact seniority vocabulary values>262263## Voice264- Tone:265- Style notes:266- Always: no em dashes; never "delve", "leverage", or "streamline"; no filler;267 write like a colleague.268269## Topics they care about270- <from posts, weighted by engagement>271```272273### `config/gtm-config.md`274275```markdown276# GTM Config277Read by sales-prospecting, account-research, sales-outreach, and meeting-prep at startup.278279## Stack280- Data provider: crustdata | none281- CRM: <tool> | none282- Calendar: <tool> | none283- Email: <tool> | none284- Call recorder: <tool> | none285- Sequencer: <tool> | none286- Team chat: <tool> | none287288`none` = that slot runs draft-only: drafts and CSV exports instead of pushing to the tool.289290## What we sell291<one or two lines>292293## ICP (inferred)294- Industries:295- Headcount:296- Geography:297- Funding stage:298- Buyer titles:299- Buyer seniority:300301## Customers302none yet — add names or domains as you close; sales-prospecting uses them for lookalikes.303304## Voice305<tone in one line>. No em dashes; never "delve", "leverage", or "streamline"; no filler;306write like a colleague.307```308309### Hand off310311Summarize: stack connected vs skipped, the persona in 2-3 lines, and what was labeled312inferred. Then:313314> You're set up. Try **sales-prospecting** ("build me a list from my ICP") or315> **account-research** ("research <company>") — both read this config automatically.316317---318319## No-data fallback320321If Crustdata isn't connected, or enrichment comes back thin (no match, sparse profile,322zero posts):323324- Take 2-3 lines from the user instead: name and role, what the company does, who325 they sell to. That's the whole interview — **never run a long questionnaire.**326- Write both config files from those lines. Voice = neutral plus the no-slop rule.327 ICP = still labeled `inferred`.328- If enrichment was partial, keep what was verified, say exactly what couldn't be329 inferred, and let the user add a line for just that.330331## Costs332333- `person_enrich` with `basic_profile` + `experience` + `social_handles`: 1 credit.334- `company_identify`, `company_autocomplete`, `person_autocomplete`: free.335- `company_enrich` by id with `exact_match: true`: 2 credits for one match.336- `social_post_list_live`: 1 credit per post — always set `limit` deliberately337 (10 is plenty for voice).338- Typical full run: about 13 credits. Every `execute` response carries `credits` and339 `credits_remaining`; `account_credits` (free) reports the balance.340341## Error handling342343- **Branch on `r.ok` in every script.** A failed call does not abort the script; an344 unchecked failure silently proceeds on empty data and looks like "no results".345- `person_enrich` returns no match → confirm the URL with the user (typo, vanity slug346 change), then use the no-data fallback.347- A 403 that names a field means a plan-gated projection — drop that field and re-run.348- A failed or empty posts call is not an error: voice goes neutral.349- Company enrich fails → keep the persona from person data alone and note what's350 missing.351352## Rules353354- **Welcome first; one optional stack question; the URL is the entire interview.**355- **Never assume the stack from connected connectors.** Ask, or write `none`.356- **Never ask what they sell, their ICP, or their voice** — derive it. If enrichment is357 thin, take 2-3 lines, never a full interview.358- **Show the persona back** for correction before writing files.359- **Label inferences.** Write `none` for skipped tools. Never invent stack or persona360 details.361- **Voice always carries the no-slop rule**: no em dashes; never "delve", "leverage",362 or "streamline"; no filler; write like a colleague.363- A missing config never blocks anything: this skill creates it, and downstream skills364 point back here when it's absent.365- **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") so the reader knows what they're not seeing. Look at the rendered output and fix what's cut off before you hand it over.366- **Icons in rendered output**: Lucide, the dashboard's icon set, inlined as SVG with a367 `currentColor` stroke. No emojis in artifact UI.368- **The persona's own photo is free too** — `basic_profile.profile_picture_permalink` rides in369 the `basic_profile` group the Step 2 `person_enrich` already returns. A persona one-pager is370 about a person; base64-inline the photo (same `binary/octet-stream` rule) with a monogram371 fallback.372- **The company logo is free — use it on a rendered persona page.** `basic_info.logo_permalink` comes from the free `company_identify` and from the `company_enrich` you already run for the persona. Base64-inline it as a `data:image/jpeg;base64,...` URI (the media CDN serves these as `binary/octet-stream`, so a remote `<img src>` renders blank); monogram fallback when there's none.373- **Artifact branding**: the config files stay plain markdown — no branding noise in374 machine-read files. But IF the persona is rendered as a page or document (a persona375 one-pager, an ICP summary doc), it carries the Crustdata brand lockup in the header or376 footer: a small uppercase "Powered by" eyebrow plus the official Crustdata wordmark,377 linking to crustdata.com. The wordmark pair ships in this skill's `assets/` —378 `crustdata-logo-light.png` (dark text, for light backgrounds) and379 `crustdata-logo-dark.png` (white text, for dark backgrounds), the same files380 app.crustdata.com's header renders. Base64-inline the theme-appropriate variant at381 ~17px height — never hotlink; rendered artifacts cannot fetch remote images. Brand382 accent: `#5547E2` (the product primary; `#8387FF` on dark grounds). Body font: Geist383 when embeddable, else the system stack. Never render an artifact just to carry the mark.384385## Tool dependencies386387This skill requires:388389- **Crustdata MCP server** ([install.crustdata.com/mcp](https://install.crustdata.com/mcp)):390 a single Code Mode MCP exposing `list_tools`, `get_schema`, and `execute`. All391 Crustdata data tools are reached inside an `execute({ code })` plain-JavaScript392 script via `await callTool(name, params)` — author against the typed surface from393 `get_schema`, but the script body carries zero type annotations (a type annotation is394 a parse error that fails the whole run). Tools used here: `person_enrich`,395 `company_identify`, `company_enrich`, `social_post_list_live`,396 `company_autocomplete`, `person_autocomplete`, `account_credits`.397- **Write access to the working directory** — creates `config/persona-profile.md` and398 `config/gtm-config.md`.399400Ships alongside **sales-prospecting** and **account-research**, which read the config401this skill writes.