The flow behind "here's the list from the conference — dedupe it against
Salesforce and add the new ones to the campaign."
It is also the single most common way bad data gets into a system of record, so
the tools are deliberately staged: inspect, then dedupe, then import, then add to
the campaign. Each step reports numbers a human can check before the next one
runs.
Nothing beyond the normal Salesforce connection —
see Connect Salesforce. The account or user doing the
importing needs Create on Lead and Contact, and Create on CampaignMember.
The list arrives as an attachment, so the Slack app needs to read files:
files:read scopeAttachments land in the agent's workspace at /workspace/attachments. Addfiles:write too if you want the agent to attach the cleaned files back to the
thread, which is worth having — it means the numbers it reports are reviewable.
The one decision worth making before your first import.
MOPERATOR_IMPORT_OBJECT=Lead # default
MOPERATOR_IMPORT_OBJECT=Contact # for orgs that do not use Leads
Lead is the default because it is the classic Salesforce model and a Lead
stands alone — it needs no Account until someone converts it. If your team works
a queue of unconverted people, this is you.
Contact is right for the many orgs that never adopted Leads and run
everything as Contacts under Accounts. Set it and the agent stops proposing
Leads.
That choice has one real consequence, and it is why this is not just a label:
A Contact wants an
AccountId. One created without it is a private Contact,
which most B2B reporting cannot see. So in a contact-only org, an import
normally has to resolve the Account first — match existing ones by email domain
or company name, create the missing ones, or accept private Contacts
deliberately.
The agent will ask rather than guess. It knows which model your org runs, becauseMOPERATOR_IMPORT_OBJECT is rendered into its instructions.
You can also override per import — "import these as Contacts" — without
changing configuration. The setting is the default, not a restriction.
MOPERATOR_DEDUPE_OBJECTS=Contact,Lead # default, in priority order
Both by default regardless of the import target, because orgs migrate and legacy
Leads outlive the decision to stop using them. First match wins, so put the
object that "already exists" most authoritatively first.
Narrow it only if you are certain — each object costs one chunked query pass per
import.
Imports create records, so they follow the same identity rule as every other
write: the change is recorded under the person who asked, and if it cannot
be, it does not happen. The first import will pause for a one-time Salesforce
sign-in. See Salesforce write identity.
Scheduled runs cannot import, by design — there is nobody to attribute the
records to.
npm run agent:doctor
It confirms Salesforce is reachable and that per-user identity has what it needs.
An import that fails at the last step because MOPERATOR_TOKEN_ENCRYPTION_KEY is
unset is a bad time to find out.
MOPERATOR_MOCK=true npm run agent
Gives you a small in-memory org — including one deliberately opted-out contact,
so the suppression step has something to catch.
This is where imports actually fail. Salesforce's requirements are not obvious,
and the error it returns names the field but not the fix.
Which table applies depends on MOPERATOR_IMPORT_OBJECT above.
| Field | Required | Notes |
|---|---|---|
LastName | Yes | Salesforce rejects the row without it. A single "Full Name" column has to be split first. |
Company | Yes | The one people forget. Every Lead needs a company, and a blank one fails the row. Use defaults to set something like "Unknown" if the file genuinely lacks it. |
Email | No, but | Not required by Salesforce. Required in practice — without it the record cannot be deduped, mailed, or matched later. |
FirstName | No | |
LeadSource | No | Set it via defaults. Skipping it is why attribution reports have a large "unknown" bucket. |
Status | No | Defaults to your org's default lead status. |
| Field | Required | Notes |
|---|---|---|
LastName | Yes | |
AccountId | No, but | Technically optional — a Contact without one is a private Contact, which most B2B reporting cannot see. In practice this is the field that decides whether the import was useful. |
Email | No, but | Same as Lead: not Salesforce-required, required in practice. |
Note what is not here: Company. Contacts get their company throughAccountId, which is the whole difference between the two models. A Lead carries
its company as text and stands alone; a Contact points at a real Account record.
Resolving the Account is therefore the work. Three defensible approaches:
inspect_list matters.normalize_list to produce a comparison keyAsk for whichever you want; the agent will not pick for you.
| Field | Required | Notes |
|---|---|---|
CampaignId | Yes | Starts with 701. |
ContactId or LeadId | Yes | Exactly one, never both. The agent routes this for you from the id prefix — 003 is a Contact, 00Q is a Lead — so a mixed list works. |
Status | No | Optional, but it must be one of that campaign's configured member statuses. They are per-campaign. A value you invented fails every row. |
To see the valid statuses for a campaign:
SELECT Label, IsDefault FROM CampaignMemberStatus WHERE CampaignId = '701...'
Omit status and Salesforce uses the campaign's default.
Everything above is standard Salesforce. Your org almost certainly adds more —
a required Country__c, a validation rule demanding a region, a required picklist
on Lead. Those are invisible from here.
Ask the agent to check before importing:
Describe the Lead object and tell me which fields are required
It calls describe_salesforce_object, which reports required: true per field.
Doing that first turns "600 rows failed" into "add a Region and we're fine."
Not required to create a record, so it is not blocking. But two things matter:
If your org has a required consent field, describe_salesforce_object will show
it and the import will fail without it — which is the correct outcome.
Here's the list from the conference (attach the CSV). What's in it?
inspect_list reports the row count, the columns, which column holds emails, and
the counts that decide what happens next: malformed emails, duplicates within the
file, role addresses (info@, sales@), free-mail domains.
Nothing is fixed silently. A list with 40 bad rows is a conversation.
Normalize the countries and job titles first
normalize_list maps country spellings to ISO codes, job titles to seniority
bands, and company names to a comparison key. It adds columns rather than
overwriting, so the originals survive.
Its unrecognized counts are the useful output — they are what your picklists are
missing.
Dedupe it against Salesforce
dedupe_list_against_salesforce queries Contact and Lead in chunks (SOQL cannot
take a thousand emails in one IN clause) and writes three files:
| File | What it is |
|---|---|
…-new.csv | Not in Salesforce. The import candidates. |
…-existing.csv | Already known, annotated with the object and record id. |
…-suppressed.csv | Already opted out or bounced. Do not mail these. |
A Contact outranks a Lead when someone is both.
Import the new ones, source Conference
The agent uses the configured target. Say "as Contacts" or "as Leads" to
override for one import.
create_salesforce_records
object_name: "Lead"
csv_path: "/workspace/leads-new.csv"
field_map: { "Work Email": "Email", "Surname": "LastName" }
defaults: { "LeadSource": "Conference", "Company": "Unknown" }
field_map renames CSV columns to Salesforce API names. defaults applies to
every row — the right place for LeadSource, and for a Company fallback.
Columns starting with _ are dropped, so the annotations the dedupe step added do
not leak into the CRM.
Partial success is normal and reported: good rows land, bad rows come back with
reasons. Do not re-run the whole file to retry a handful.
Add them all to campaign 701xx000000ABCD with status Sent
add_campaign_members takes Contact ids, Lead ids, or a mix. After a dedupe the
mix is the normal case — the already-known people are Contacts, the ones you just
created are Leads.
| Limit | Value | Why |
|---|---|---|
| Records per insert call | 200 | Salesforce's collections API. The tool chunks; you do not. |
| Emails per dedupe query | 200 | Keeps the SOQL statement inside its length limit. |
| Rows per tool call | MOPERATOR_BULK_MAX (1,500) | Refused above this. Split the file. |
| Approval threshold | MOPERATOR_BULK_APPROVAL_THRESHOLD (100) | Above this, always reviewed — even for approvers. |
Splitting a file to get under the approval threshold does not work: the cap is per
call and the agent is told why.
/workspace/attachments. If itLead assignment rules do not fire on an API insert unless explicitly
triggered. Imported Leads land with whatever owner the API defaults to. If your
org routes leads by territory, run assignment afterwards or set OwnerId viadefaults.
Nothing is emailed. Importing puts people in the CRM. Sending is a separate,
separately approved action.
Person Accounts are not handled specially. If your org uses them, check what
a Contact insert does before running a large import.
agent/skills/list-hygiene.md — the checklist the agent follows