Skip to main content
This guide builds a contact list in the ElasticFunnels CRM and emails it, without leaving Claude Code. You ask in plain language, Claude Code runs the ef commands, and you approve the send. Each step below shows the prompt and the commands it runs, so you can also type them yourself. Watch the walk-through: Build your own CRM with Claude Code.

Before you start

  • The ef CLI 0.28.4 or later, connected to your project. Install it with npm i -g @elasticfunnels/cli@latest and check the version with ef --version. Then run ef init in an empty folder and approve the connection in your browser. The full walk-through is Connect the ef CLI.
  • Claude Code, opened in that folder. ef init also runs ef claude, which installs the ef-crm, ef-campaigns and ef-segql skills into .claude/skills/. They tell Claude Code how to build a CRM and import into it, how to build audiences, and how to send safely. Run ef claude again if you set the folder up with --no-claude.
  • A sending account for campaigns (Amazon SES, Postmark, Brevo, Resend, Mailgun, SendGrid, Mailrelay or your own SMTP). See Before your first campaign.
  • Permissions: the CRM permissions to create entities and fields, and the email permissions to create and send campaigns. The CLI acts with the permissions of the person who approved it.

1. Create a CRM entity and its fields

An entity is a type of record in the CRM, such as Leads, and its fields are the columns each record has. An entity in CRM mode also has pipelines, the stages a record moves through. The full model is in the CRM overview. Ask Claude Code:
It runs:
entities create prints the new entity’s number: ✓ Created CRM entity #120 (leads). Keep it, because the campaign in step 3 uses that number. ef crm entities list shows it again later. pipelines create prints the pipeline’s number, which the stages create lines use. The Leads object in Apps, CRM, open on its Fields tab: First name, Last name, Phone and Source with their keys and types, and one pipeline There is no email field yet. The import in step 2 creates it from the email column of your file, with the type email. When a campaign goes to this entity, ElasticFunnels reads each address from the entity’s email-type fields (or from a field with the key email when there is none), and skips a record with no valid address. Keep a First name field (key first_name) if your emails greet people by name. The merge tag {{ customer.first_name }} takes its value from that field. Without one, it falls back to a field keyed name or full_name and inserts the whole name, so the greeting reads “Hi Ann Lee”. A full name is not split into first and last, so give your file a first name column of its own. The other field types are listed in ef crm fields create --help and in Supported field types.

2. Upload contacts from a CSV or Excel file

Put your list in a .csv, .tsv, .txt or .xlsx file with the column names in the first row. An Excel file is read from its first sheet only; save old .xls files and Numbers files as .xlsx or .csv first. One file can hold up to 20 MB and 20,000 rows, so split a bigger list. Ask Claude Code:
The ef-crm skill, installed by ef claude with the others, has Claude Code run the import in two passes: a preview first, which it shows you, and the real import only after you agree.

Preview the plan

The preview reads the file and writes nothing. It prints the number of rows and columns, then one line per column: its action, the field it goes to, the field type and a few sample values.
Read the type of every create line and change what you disagree with before you import. Under the table, Title from names the column used as each record’s title, then the pipeline and stage the records go to, and New fields lists the fields the import would create. Check the email column before you go on, because the campaign in step 3 reads the address from the entity’s email field. It has to show existing on an email field, or create with the type email. When the header is something else, or a type is wrong, map it yourself:
  • --map "Column=key[:type]" sends a column (its header, or its position counting from 0) to a field key. A key the entity does not have yet is created with the suggested type, or with the type after the colon: text, textarea, email, phone, url, number, date, datetime, boolean, select or multiselect. Repeat it for more columns.
  • A tag column becomes a multiselect field when its values look like lists. If it is not detected as one, --map "Tags=tags:multiselect" forces it.
  • --skip <column> leaves a column out. Repeatable.
  • --title <column> picks the column used as each record’s title.
  • --pipeline <id> and --stage <id> pick where the records go in an entity in CRM mode. Without them, records go to the first stage of the default pipeline: New in the Sales pipeline here.
Run the preview again after each change until the plan is right.

Import

Pass the same --map, --skip and --title flags you used in the preview. The import first creates the new fields (Created field email (email). for each), then sends the rows in batches of up to 250 (--batch sets a smaller size) and prints a line per batch. It ends with a summary:
When rows fail, the first 20 follow with their line number and the reason. --json prints the whole result, every error included. Without --yes, the command asks for confirmation in a terminal. Where there is no terminal to ask in, as when Claude Code runs it, it stops instead, so Claude Code adds --yes once you have approved the plan.
A plain re-import adds the rows again. To update the people already in the entity instead, merge on the email:
A row whose email matches a record (upper and lower case count as the same) updates that record: a filled cell overwrites the old value, an empty cell keeps it, and tags are combined. Rows with a new email are added. If a run stops partway, it prints the row to resume from; run the same command with --offset <n>, and the rows before it are not imported again.
Imported records do not trigger stage automations or form webhooks. They show up in Apps → CRM under the entity, like records added by hand. The Entries tab of the Leads object after the import: leads titled by their example.com address, with first name, last name, phone and the New stage

3. Send an email campaign to the list

Write the email

Ask Claude Code for the email itself as an HTML file, and say who it is from:
Claude Code writes the HTML into the folder; the campaign reads it with --html-file in the next step. {{ customer.first_name }} is filled from each lead’s First name field when the email is sent. ElasticFunnels adds the footer with the unsubscribe link and your postal address to every campaign email when it is sent, so the HTML only needs the message. See The footer and unsubscribe link.

Create the campaign for the CRM list

--crm-entity takes the entity number from step 1. The command prints ✓ Created draft email campaign #35. and then lists anything that would still block sending, such as a missing subject or sending account (--integration <id> picks the sending account). ef campaigns email get 35 shows the same list later. To change the email of a draft, run ef campaigns email set 35 --html-file welcome.html.
A campaign can also go to a saved audience: people picked by what they did, written as a SegQL query. Ask Claude Code something like “email everyone who bought twice in the last year”, and it writes and checks the query with the ef-segql skill:
--audience takes the number audiences create printed. More on audiences: Audiences and campaigns from the ef CLI.

Count who will receive it

count is short for audience-preview. It prints one line, then a few sample addresses:
The excluded counts are addresses on your suppression list (bounced, complained, unsubscribed), people who said no to emails at opt-in, and duplicates (the same address in two records counts once). The sample addresses come back masked unless your role can see contact details unmasked. The same count is on the draft’s Audience step in the app: The draft campaign Claude Code created, open on its Audience step: the Leads CRM collection selected and 38 people who will receive it

Send a test to yourself

It prints ✓ Test sent to alex@example.com via mailgun., naming the service it went through. Without --to, the test goes to the address of the account that connected the CLI. The test is the real email, footer included, and it never goes to the list. You can send 10 tests per campaign per hour.

Schedule or launch

Both print the number of people who will receive it and then stop to ask. In a terminal you type the confirmation; from Claude Code the command stops until it is run again with --yes, and the ef-campaigns skill tells Claude Code to show you the count, the subject and the time and to add --yes only after you say go. The send is also refused while no test was sent. --skip-test overrides that and sends without a test; leave it out unless you have already checked the email another way. A schedule has to be more than a minute ahead, and ef campaigns email unschedule 35 turns a scheduled campaign back into a draft. cancel stops one that is scheduled or already sending, but emails already sent stay sent.

Read the results

stats shows recipients, sent, delivered, failed, bounced, opened, clicked, unsubscribed and complaints. recipients lists each address and what happened to it. Opens and clicks are counted only when your sending account reports them back; see Delivery stats. The same report is in the app under Campaigns: The report. Report of the Welcome to Acme Health campaign sent to the Leads: recipients, delivered, opened, clicked, bounced and unsubscribed, the most clicked link and the recipient list