Systems Lab

Agent skill

run

Import one named lead list into the CRM config names, HubSpot or Salesforce, planned end to end and pushed only after an explicit yes.

activeNeeds a keyDeclared tools3,819 words

Filed under CRM and RevOps.

From sarahcallmesmadds/gtm-operator · 27 skills · 0 · pushed 2026-09-02

What it does when it runs

Import one named lead list into the CRM config names, HubSpot or Salesforce, planned end to end and pushed only after an explicit yes. Use when a lead list arrives, the user says "import this list", "get these leads into HubSpot", "get these leads into Salesforce", "load this conference CSV", or hands over a CSV file or a Notion page of contacts. Reads the one named source, the CRM, the Process artifacts, its own config and the alias map; writes exactly what the approved plan names, verifies by reading every write back, and writes back to a Notion source. Writes nothing without an explicit yes.

Read from the skill and the 0 files bundled beside it. A skill’s own description is written to be selected by an agent, so it describes the job and not the dependencies.

Keys and connectors you must supply
  • plugin_import-leads_apollo
  • plugin_import-leads_clay
  • plugin_import-leads_lusha
  • plugin_import-leads_zoominfo
  • mcp__*__notion-fetch
  • mcp__*__notion-query-data-sources
  • mcp__*__notion-update-page
Hosts it reaches
No third-party host appears in the skill or its bundled files.
Tool permissions it declares
  • Read
  • Write
  • Bash(node:*)
  • Bash(curl:*)
  • Bash(sf:*)
  • mcp__*__notion-fetch
  • mcp__*__notion-query-data-sources
  • mcp__*__notion-update-page
  • mcp__plugin_import-leads_clay__*search*
  • mcp__plugin_import-leads_clay__*enrich*
  • mcp__plugin_import-leads_clay__*match*
  • mcp__plugin_import-leads_clay__*lookup*
  • mcp__plugin_import-leads_clay__*find*
  • mcp__plugin_import-leads_clay__*get*
  • mcp__plugin_import-leads_lusha__*search*
  • mcp__plugin_import-leads_lusha__*enrich*
  • mcp__plugin_import-leads_lusha__*match*
  • mcp__plugin_import-leads_lusha__*lookup*
  • mcp__plugin_import-leads_lusha__*find*
  • mcp__plugin_import-leads_lusha__*get*
  • mcp__plugin_import-leads_apollo__*search*
  • mcp__plugin_import-leads_apollo__*enrich*
  • mcp__plugin_import-leads_apollo__*match*
  • mcp__plugin_import-leads_apollo__*lookup*
  • mcp__plugin_import-leads_apollo__*find*
  • mcp__plugin_import-leads_apollo__*get*
  • mcp__plugin_import-leads_zoominfo__*search*
  • mcp__plugin_import-leads_zoominfo__*enrich*
  • mcp__plugin_import-leads_zoominfo__*match*
  • mcp__plugin_import-leads_zoominfo__*lookup*
  • mcp__plugin_import-leads_zoominfo__*find*
  • mcp__plugin_import-leads_zoominfo__*get*
Actions present in the files
shellwrites files

Ask about run

Opens your assistant with this page's verified links already in the prompt.

Is this safe to install?ClaudeChatGPT
Adapt it to my stackClaudeChatGPT
What else do I need for it to workClaudeChatGPT
Rather ask a human? Talk to Cheetah
git clone --depth 1 --filter=blob:none --sparse https://github.com/sarahcallmesmadds/gtm-operator.git /tmp/gtm-operator
git -C /tmp/gtm-operator sparse-checkout set "plugins/import-leads/skills/run"
mkdir -p ~/.claude/skills/run
cp -R "/tmp/gtm-operator/plugins/import-leads/skills/run/." ~/.claude/skills/run/

Picked up without a restart. A project skill of the same name is shadowed by your personal one. For one repository only, swap ~/.claude/skills for .claude/skills. Claude Code docs ↗

Or take the whole library

This repo ships a .claude-plugin manifest, so Claude Code can install all 27 skills at once. Plugin skills are invoked as /<plugin>:<skill>, so they never collide with your own.

/plugin marketplace add sarahcallmesmadds/gtm-operator
/plugin

The folder is the same in every client that implements the format — 46 of them — so if yours is not above, only the destination changes.

Before you install: this skill will not complete its job on a bare agent. It needs plugin_import-leads_apollo, plugin_import-leads_clay, plugin_import-leads_lusha, plugin_import-leads_zoominfo, mcp__*__notion-fetch, mcp__*__notion-query-data-sources, mcp__*__notion-update-page, which you have to obtain separately.

Reproduced in full from sarahcallmesmadds/gtm-operator/blob/a93c6e98a742d183823691197b8d9be9edd114c6/plugins/import-leads/skills/run/SKILL.md, which is licensed MIT (repository). 3,819 words, 21 headings.

run

Take one named list and land the approved rows in the CRM config names: cleaned, deduped, matched to companies, on the memberships the grid names, verified by reading the writes back. One CRM per install: config's crm says which, and everything backend-specific below says which half it belongs to.

The line this skill holds: everything before the confirmation plans, and the push executes exactly the approved plan. Nothing is invented to complete a row, nobody is guessed, and a value with no source is refused.

How this skill works

scripts/import-leads.js decides what to send and what an answer means. You send it. Every CRM request is built by the script as a spec; you send each the way its backend sends things, and save the response whole for the script to judge.

node "${CLAUDE_PLUGIN_ROOT}/scripts/import-leads.js" <command> <args>

On HubSpot a spec carries a method, a url and a body. Send it with the Service Key as a bearer header, the key coming straight from its file into the header and never into the conversation:

curl -sS -X <method> "<url>" \
  -H "Authorization: Bearer $(cat <serviceKeyPath>)" \
  -H "Content-Type: application/json" \
  -d @<body.json> > response.json

Never print, echo or paste the key, and never put it in a file this plugin writes. The path is config's; the contents are curl's alone.

On Salesforce the credential lives in the sf CLI keychain under the alias every spec carries as targetOrg, so there is nothing key-shaped to handle at all. A spec's transport says how it goes:

# transport: query
sf data query --target-org <targetOrg> --query <soql> --json > response.json
# transport: rest  (write the spec's body to a file first; no --body when the spec carries none)
sf api request rest <path> --method <method> --body @<body.json> --target-org <targetOrg> > response.json
# transport: cli
sf <args...> --target-org <targetOrg> --json > response.json

Save every response to a file exactly as it came back: the judging commands refuse reshaped copies, because a reshaped response reads as whatever the reshaping assumed. A successful REST PATCH answers 204 with an empty body (measured 2026-08-26), so an empty file there is the expected shape, judged as unproved-until-read-back rather than as an error.

The live surface is proved by the live run, not by this file. The request shapes are rebuilt from the 2026-08-25 and 2026-08-26 measurements; until each backend's release gate has run one real list end to end, treat any surprising response as a fact to record, not an error to push past.

Step 0. Config, once

Run config-show. If it refuses because there is no config, this is the first run: gather what the backend needs (the crm; on HubSpot the portal id and where the Service Key lives; on Salesforce the org alias and any record-type ids; the alias-map path and any name corrections either way), searching for what can be found rather than asking anyone to type what could be looked up. Then config-draft, show the whole draft, and config-write only on an explicit yes. The file is written once; any other refusal is fixed by hand, not rewritten.

On a salesforce first run, ask the org which mailing fields it carries before the draft is shown, after sf --version has answered, because a missing CLI is a different finding from a wrong alias and check says what installs it. mailing-fields-probe <orgAlias> emits two read-only queries, one per code field, and mailing-fields-judge turns the two saved responses (state first) into one measured verdict per field: MailingStateCode where the org answered it, the plain name where the org refused it by name, so even a mixed org gets a measured pair (every branch measured 2026-08-26). Neither pair is right for every org, which is why the org is asked rather than defaulted at, and the draft enforces it: a salesforce config-draft refuses answers that carry no judged mailingFields pair. Pass the judge's use object as mailingFields in the draft answers, and show which names the org chose and why.

Step 1. Scope: one named source

One CSV file or one Notion page or database, named by the user, never found by a search. The scope gate refuses rather than narrows: half a scope is refused as hard as none, and a source nobody named is never read.

  • CSV: ingest <file.csv>. It proposes a column mapping and stops.
  • Notion: fetch the named source through the connected client, save the rows as [{pageId, columns}] exactly as fetched, then ingest-notion.

Show the proposed mapping with the unmapped columns and any ambiguities, get it confirmed or corrected, and run ingest again with the confirmed mapping. Unmapped columns ride along untouched for the writeback; they are shown, not dropped silently.

Then, before anything maps into the CRM, confirm what this import creates: Contacts, each with its company matched or planned. On Salesforce, ask the org first rather than asking the person cold: lead-contact-queries emits two COUNT() reads and lead-contact-judge turns the saved pair into the evidence; show both counts and ask whether Contacts is how this org works. An org that works in Leads deserves the mismatch named before any write is planned: this plugin lands Contacts and Accounts, a lead-based import is deliberately out of scope (plugins/import-leads/SKILLS.md records it as Open work waiting for a user who asks), and the person chooses between stopping there and proceeding deliberately, with the choice recorded in the run's report. On HubSpot there is no measured Lead surface to count, so the same confirmation is asked plainly, without counts. Silence is not a confirmation.

Step 2. The Process artifacts

Read from the Process library, at run time:

  1. The required-fields rule, required. Hand it to the script as {"required": [...], "leadSourceValue": "..."}.
  2. The member-status grid, required. Hand it over as {"naming": "...", "types": {...}}, using the grid's own naming convention and statuses, never a rebuilt one.
  3. Personas, optional. When the artifact exists the persona step runs; when it does not, skip the step without complaint.
  4. Routing, optional. Absent, every owner is confirmed by hand.

Run validate-rules on what was read. A missing required artifact is named, not worked around: say which artifact is missing and that process:new is where it gets written, and refuse the step that needs it. A grid this plugin invents is exactly the thing the design says lives in Process. No plugin calls another plugin's skill.

Step 3. Enrich, blanks only, with a gate

Name the gaps. Offer them to whatever enrichment the session actually has connected. The plugin packages four enrichment connectors so they show in its Connectors tab and can be authorised from there: clay, lusha, apollo and zoominfo. Any one of them is enough and none of them is required. Only those four are pre-approved for this skill; a different enrichment tool the session already has connected is offered the gaps the same way, and its calls ask the person's permission first. This plugin carries no vendor code, and no enrichment tool at all means a working import with its gaps named honestly.

The four packaged servers are admitted to this skill's tools by read-shaped names only (search, enrich, match, lookup, find, get). A name pattern is a shape, not proof that a tool only reads, so the pre-approval is not the guard. The guard is this rule: this skill never calls or requests a write-shaped tool, including Clay table writes and Apollo sequence sends, and an approval prompt does not change that. A vendor's lookup that reads but sits under a name the list does not cover is the one case that asks the person before it runs, rather than failing. That prompt is a permission, not the confirmation: the named yes for a paid step is still asked in conversation, before the lookup, whatever the tool is called.

  • Fill blanks only. An enrichment result never overwrites a value the source list provided.
  • Every filled value names its tool in the row's fieldSources, as enrichment:<tool>. A value with no source is refused at the gate.
  • Person fields stay empty whatever a tool claims: no owner, no persona from enrichment.
  • Anything metered or paid is named and confirmed before it runs. Paid verification runs only on a named yes.

Personal addresses are part of the same conversation. Run free-mail <rows.json>: it names the rows whose email is a consumer provider (gmail, yahoo and kin). The rule those rows follow: removed, or enriched to find the work email. Offer the connected enrichment tool the lookup before anything is removed, and show what it found or that it found nothing. A found work email is shown, never silently swapped: the fill-blanks rule protects the source's own email, so replacement is the person's decision every time. A no-email row gets the same offer when the dedupe step surfaces it.

An approved replacement keeps the original address on the row, as replacedEmail, with the new address sourced enrichment:<tool> in fieldSources. The original is an identity too: dedupe searches both addresses, the in-list duplicate check collides both (a row still carrying the original, or two rows that replaced the same one, are the same person), and a contact the CRM holds under the original comes back presented, because pushing the row as it stands would create a second record for the same person under the new address.

The rule has teeth at the plan: the assembly re-runs the detector on the rows actually in it, and a row still carrying its personal address blocks the plan until it is excluded or deliberately decided. Keeping the address is a decision the person makes, never a default the plan slides past.

Step 4. Personas, only if the artifact exists

personas <rows.json> <personas.json>. Unclear titles come back flagged; show them and ask. Never guess a persona.

Step 5. Aliases, then the gate

aliases <rows.json> normalises company names through the user-owned map and shows what fired. Then gate <rows.json> <required.json>: rows that fail the floor or the org's rule come back refused with the gap named per row. A row that cannot meet the rule is refused, never padded. Show the refusals; the person may fix the source and start again, or proceed without those rows.

Step 6. Companies

company-queries <rows.json> builds one search per company: by name, and by domain wherever one is known. A list with no domain column still gets the domain half where the company's rows' own work emails agree on one; the output says where each search domain came from, so the evidence shows its work. Name search alone is not a duplicate check: the live run of 2026-08-26 proved a company can exist with no name at all, visible only to a domain search.

Send them, show the candidates with their evidence, and let the person decide match or create per company. Whether two records are the same company is the person's call; the alias map holds the answers already settled so they are not re-asked, and a new settled answer is worth offering to add to the map.

Companies get the same care as the people on them. A domain hit with no name, or with a name that disagrees with the list, is presented like any duplicate: it may be the portal's own auto-created record, and the person chooses between adopting it (match it, filling only fields the candidate's own evidence showed empty, an empty name included) and creating a named company beside it. Nothing about a company record is resolved silently.

An adoption rides the plan, never beside it. The decision is recorded as {"decision": "match", "companyId": "...", "fill": {"name": "..."}}; the plan carries the fill whether or not any create needs that company, the push executes it as one PATCH, and the read-back proves it, the same as every other write.

On a create decision, a website the person names wins; otherwise the list's domain fills in automatically where config maps a website property, and an org that maps none gets the company created bare.

On HubSpot the portal may auto-create a company from an email domain and take the primary association. The plan names that collision; do not resolve it silently. Measured 2026-08-26: it did not fire when a company already carrying the domain existed, and the portal derived that company's domain from the website the push set. On Salesforce nothing like it was observed and none is designed for; whether an org's own automation creates accounts is unmeasured rather than known absent, so a surprise in the read-backs is a fact to record.

Step 7. Dedupe

dedupe-queries, send the searches, then dedupe. Per row: create, update filling blanks only, or nothing to write. Show, and never auto-resolve: rows sharing an email inside the list, cross-company conflicts, and rows with no email (unknown is not new). Every one of them needs the person's decision, recorded in the plan inputs as exclusions or decided rows.

A CRM holding more than one contact under one of a row's addresses is a different question, with a different answer. The question is which record this person is, so the answer names one: the candidates come back each with the verdict the row would get against it, and the person's choice goes in the plan inputs as resolutions.chosen, {"index": n, "contactId": "..."}, realised as that record's blanks-only update or as nothing to write. Marking such a row decided is not an answer, because a create would add a third record; the row is chosen or excluded, never created.

Step 8. The multi-event check, mandatory

events <rows.json>, before any campaign setup. Show the grouping candidates, the date columns and the event-word hits, and decide with the person whether this is one campaign or several. The expensive mistake is one campaign wrapped around three events, discovered after the memberships are written. The plan refuses to assemble without this step's output.

Step 9. Statuses and memberships

With the campaigns decided, assign each row its status from the grid, by campaign type and what is known about the person's engagement. A row the grid does not cover is a question, not a default. The script validates every assignment against the grid.

On HubSpot the grid realises as lists, one per status per campaign, named by the grid's own convention, and the lists are matched, or planned for creation, never assumed absent: list-queries realises the names and builds one lookup per list, you send them, and list-judge turns the saved responses into the decisions the plan needs. A list that exists gets its id and new members; a list judged absent is created; an answer the judge does not recognise is a question, because reading it as absent is how a second copy of an existing list appears.

On Salesforce the grid maps onto native member statuses, and the same asked-not-assumed rule runs three lookups. campaign-queries builds one exact-name lookup per campaign and campaign-judge turns the responses into decisions; a campaign that exists gets its id, an empty answer plans a create, and two campaigns with one name is a question. status-queries reads the existing member-status rows of every matched campaign and status-judge hands the plan their labels, binding each answer to its campaign by the CampaignId the rows carry, so a status create is planned only where the row genuinely is not there (a fresh campaign carries Sent and Responded, measured 2026-08-25) and reversed saved files surface as questions instead of crediting the wrong campaign. And flag-query with flag-judge read the user record's Marketing User flag, whoami first and the flag read second, because campaign creation is refused while it is off (measured 2026-08-25): a plan that needs a campaign while the flag is off carries the measured one-call fix to the operator's own User record as its own named line, pushed before the campaign family and proved by reading the flag back. Striking that line strikes the campaign half of the plan with it, and the run says so rather than pushing a plan that dies.

Step 10. The checkpoint, the plan, and the one confirmation

Before assembling the plan, stop and ask the person, in as many words: "Are there any other fields that we should be stamping for new or updated accounts and contacts?" Show the lead-source value the artifact gives, or that none exists, as part of that question, never as a settled fact: an empty lead source is an answer someone gave, not a default to report past. An answer naming a field the plugin can carry becomes a config mapping or an artifact edit, each on its own explicit yes, before the plan is built. The fields the plugin can carry are its own list fields plus persona, owner and the lead source, through config's property map, and name and website on a company. An answer naming anything else is refused by name at the checkpoint, because the payload builders ignore unknown fields, and a stamp that silently cannot be written reads as written. Say the refusal in the run's own report, with the field named, so the request is on the record; the report is the record, and this plugin writes no file for it.

Ask a second question in the same breath: "Should these contacts carry a marketing status or an email opt-out?" This import writes neither, on either backend: HubSpot's marketing-contact status and its subscription statuses, and Salesforce's org-dependent opt-out field, are all unmeasured surfaces, deliberately outside the write contract. The question exists so nobody walks away assuming the import set one. An answer wanting them stamped is refused by name, like any other field the plugin cannot carry, and the ask goes in the run's report: that recorded demand is exactly what un-parks the opt-out measurement (plugins/import-leads/SKILLS.md, Open).

The checkpoint is deliberately a step of the conversation, not a command: there is no request to send and no response to judge, and its refusal is grounded in the same field lists the gate, the payload builders and config's own validation already enforce, so a field waved through here still cannot reach a payload.

Then write the inputs file (rows, events output, dedupe output, grid, required fields, campaigns, assignments, company decisions, the membership decisions the backend's lookups produced, resolutions; on Salesforce also the judged flag read) and run plan. It refuses, by name, anything undecided, and it re-runs the gate on what is actually in it, so nothing between the steps can have slipped past the floor.

Show the whole plan inline: company creates, adoption fills and associations, contact creates and updates, the exclusions with their reasons, the membership writes (list creates and memberships on HubSpot; campaign, member-status and campaign-member creates on Salesforce, with the Marketing User flag fix when the plan carries it), the lead source, and the writeback the run will make. Then ask, and write only on an explicit yes. Anything ambiguous is not yet confirmed.

Step 11. Push, exactly the plan

push <plan.json> emits the requests in dependency order, with opaque {kind:number} tokens standing for ids that do not exist yet and a placeholders legend saying which record each token is. Send them in order, substituting each returned id for its exact token, saving every response. Partial success is per record: one refusal does not stop the rest.

Then judge-push. The measured cases fold into the report rather than becoming errors. On HubSpot: a duplicate list add is a silent no-op, and a duplicate contact create is refused carrying the existing record's id. Report that refusal with its id. Never improvise an update nobody approved. On Salesforce: a duplicate campaign member fails individually with the existing row untouched, folded into the report, and a REST PATCH answering nothing is the measured 204, judged as unproved until its read-back rather than as a success or a failure.

Step 12. Verify, by reading back

readbacks, fetch every one, then prove. An id is a locator, not a proof: the comparison is the proof, and it says what it did not check. Every read-back is bound to the record it was fetched for by the id it carries, and a campaign-scoped read by its rows' CampaignId, so a saved response filed under the wrong key, or reused under two, fails the proof instead of proving a write nothing read. The one read that carries nothing to bind by is HubSpot's list membership envelope, record ids with no list identity, so the proof names that limit in its unchecked rather than vouching for it: fetch and save membership read-backs one list at a time. Report both halves, what is proved and what is not, and never round up to "it worked". If a write did not land, say so loudly and show exactly which.

Step 13. Writeback, Notion sources only

writeback emits the entries in batches; on Salesforce it takes the instance url as its third argument, read from the org display answer, because config holds no url to build a record link from. Link each created record on its source row; fill email only where the source row is blank at write time. A writeback failure is reported and never fails the run: the CRM is the system of record. A CSV source is never modified.

Close with the run's own report: what was pushed, what was proved, what was refused, and what needs a person. The report is the record; nothing is posted anywhere.


What this skill does not do

  • Never writes without the confirmation summary and an explicit yes.
  • Never guesses a person, an owner, or a persona. An owner comes from a routing artifact or an explicit confirmation, and the plan refuses one with no recorded source.
  • Never overwrites a source-provided value, in the CRM or in the source.
  • Never auto-resolves a duplicate or a cross-company conflict.
  • Never pads a row to pass the floor or the required-fields rule.
  • Never reads rows from anywhere but the one named source: no second list, no mailbox, no search. Beyond it, only the CRM it writes, the Process artifacts, its own config, the alias map, and, on a yes, the connected enrichment tool.
  • Never sends email, never posts anywhere, never runs unattended.
  • Never creates or renames a skill, a database or a property.

The judgment this skill carries

  1. Whether two records are the same person or the same company. Shown with evidence, decided by the person, settled answers kept in the alias map.
  2. Whether this is one campaign or several. The multi-event check is mandatory, and its signals are shown rather than concluded from.
  3. Which membership each row lands on, the status list on HubSpot and the member status on Salesforce, read from the grid and never from a built-in table. An uncovered row is a question.

Other skills for the same job

Different authors, same problem. Matched on the words in the skill name, across every library in the catalogue except this one.

Need help setting it up?

This page tells you what run does and what it needs. Cheetah builds the agent setup it runs inside: data, CRM, sequencing and the guardrails.

Book a call →

The directory stays free. There is nothing gated behind this.