Systems Lab

Agent skill

outreach-campaign

Creates the campaign in Emelia and gets it launched: finds or creates the contact list, loads the contacts in bulk with their custom variables, creates the email or LinkedIn campaign, attaches the list so new contacts flow in automatically, sets the cadence, the sending window and the sending accounts, runs a pre-flight checklist (deliverability green, list verified, unsubscribe link present, every variable resolved, test email read), asks for an explicit yes, then starts it.

activeNeeds a keyActs undeclared4,418 words

Filed under Outbound email.

From emelia-io/claude-outreach · 17 skills · 12 · pushed 2026-09-09

What it does when it runs

Creates the campaign in Emelia and gets it launched: finds or creates the contact list, loads the contacts in bulk with their custom variables, creates the email or LinkedIn campaign, attaches the list so new contacts flow in automatically, sets the cadence, the sending window and the sending accounts, runs a pre-flight checklist (deliverability green, list verified, unsubscribe link present, every variable resolved, test email read), asks for an explicit yes, then starts it. Writes outreach/campaign.json with the real Emelia identifiers. Triggers on: campaign, launch, launch campaign, create campaign, start sending, send the sequence, go live, attach list, contact list, cadence, daily limit, sending accounts, identities, schedule, pre-flight, sequence steps, multichannel campaign.

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
  • EMELIA_API_KEY
Hosts it reaches
  • api.emelia.io
  • www.linkedin.com
Tool permissions it declares
No allowed-tools in the frontmatter. It does act, so it runs under whatever permissions your session already grants.
Actions present in the files
shell

Ask about outreach-campaign

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/emelia-io/claude-outreach.git /tmp/claude-outreach
git -C /tmp/claude-outreach sparse-checkout set "skills/outreach-campaign"
mkdir -p ~/.claude/skills/outreach-campaign
cp -R "/tmp/claude-outreach/skills/outreach-campaign/." ~/.claude/skills/outreach-campaign/

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 17 skills at once. Plugin skills are invoked as /<plugin>:<skill>, so they never collide with your own.

/plugin marketplace add emelia-io/claude-outreach
/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 EMELIA_API_KEY, which you have to obtain separately.

Reproduced in full from emelia-io/claude-outreach/blob/585061e4d78fe14a70701fbc5aec5d1eeb3d580a/skills/outreach-campaign/SKILL.md, which is licensed MIT (skill frontmatter). 4,418 words, 19 headings.

Create and launch the campaign

What this does

Turns the files produced by the earlier steps into a live Emelia campaign: a list that holds the contacts, a campaign that holds the sequence, the two wired together, the cadence set, and the launch done only after you say yes in plain words.

It records every real identifier in outreach/campaign.json so the next steps (outreach-replies, outreach-audit) can find the campaign without asking you again.

When to use it

Run it when the list is verified and the copy is written, as step 9 of a full run. It calls the deliverability gate first and stops if the answer is BLOCKED.

Use a different skill when you want to design the sequence rather than create it (outreach-sequence), write or fix the copy (outreach-write), add contacts to a campaign that is already running (come back here, section 3, the list is the way in), or read what happened after the launch (outreach-audit).

Inputs

  • outreach/deliverability.md. Read the ## Verdict line first. If it is missing, run outreach-deliverability now. If it says BLOCKED, stop and show the fix list.
  • outreach/leads.csv. One row per contact, verified. Must carry at least email for an email campaign, or linkedinUrlProfile for a LinkedIn campaign. If the verification column is missing, stop: sending to an unverified list is a refusal.
  • outreach/sequence.md. The copy, step by step, with the variables.
  • outreach/campaign.json. The sequence spec written by outreach-sequence. If it does not exist, build it here from sequence.md and confirm it with the user.
  • EMELIA_API_KEY, or the Emelia MCP server. Without either, produce every file and the exact calls it would have made, and say plainly that nothing was created.

How to do it

0. The gate

grep -A2 '^## Verdict' outreach/deliverability.md

BLOCKED means stop. Not "warn and continue". Show the blockers and the fix list, and offer to re-run the audit once they are fixed.

READY WITH LIMITS means continue with the daily cap that file states, and carry that number into the cadence in section 6 rather than the one the user asked for.

1. Find or create the list

Contacts do not live in a campaign. They live in a list, and a list is attached to one or more campaigns. Get this right and the rest of the product makes sense.

Read the existing lists first, then create one if you need it:

curl -s https://api.emelia.io/lists/list -H "Authorization: $EMELIA_API_KEY"

curl -s -X POST https://api.emelia.io/lists/list \
  -H "Authorization: $EMELIA_API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"Q4 SaaS founders"}'

The create returns {"success": true, "listId": "...", "name": "..."}. Keep the campaign name and the list name identical, it saves an hour later. That listId is the same id the campaign routes take, there is no import step between the two.

Read the columns of the lists you did not build. Every list in the GET above carries a columnPreferences array, and that array is the list of variables the copy can use. A key that starts with custom is a custom variable: strip that prefix and lowercase the next letter to get the name the message uses, so customEmail1subject is {{email1subject}}. A list that already carries customEmail1subject, customEmail1message and their followers has been written for a previous campaign, and those columns are the carriers to reuse. Writing new ones next to them means the campaign sends the old copy or the new one depending on which name the steps reference, and a wrong name renders empty with no error. Read the columns, then name the carriers to match.

Never reuse a list that already feeds another running campaign unless that is what the user asked for: every contact in it would enter the new campaign too.

2. Load the contacts in bulk

One call carries up to 100 contacts:

curl -s -X POST "https://api.emelia.io/lists/list/<listId>/contacts/batch?updateIfExists=true" \
  -H "Authorization: $EMELIA_API_KEY" -H "Content-Type: application/json" \
  -d '{"contacts":[
    {"firstName":"Claire","lastName":"Fontaine","email":"[email protected]",
     "companyName":"Nexora","companyDomain":"nexora.fr",
     "linkedinUrlProfile":"https://www.linkedin.com/in/clairefontaine",
     "icebreaker":"your migration to Postgres 16 last month"}
  ]}'
  • 100 contacts per call, maximum, and 1 MB per request body. Split the CSV into chunks of 100, and clip long text fields to about 4,000 characters before sending. A hundred rows each carrying a full LinkedIn bio crosses the megabyte, and the refusal fails the whole batch, not the offending row.
  • The payload is a flat object. firstName, lastName, email, phone, linkedinUrlProfile, companyName, websiteUrl are recognised fields. Any other key creates a custom variable automatically, which is how icebreaker above becomes {{icebreaker}} in the copy. Spell the keys exactly as the copy spells the variables.
  • updateIfExists: true turns a duplicate into an update. Without it, a contact already in the list is left as it is and reported as a duplicate, not re-created.
  • The response reports created, duplicates, updated and failed counts plus a per row result, indexed on the position in the array you sent, so each result maps back to its row. It also returns createdCustomVariables, where technicalName is the name to put in the copy. Read the failed rows and report them. Never say "1,180 contacts loaded" when 41 rows failed on a malformed address.
  • The LinkedIn field is linkedinUrlProfile. Not linkedinUrl, not linkedin. A wrong name raises no error: it silently becomes a custom variable, and every LinkedIn branch of the campaign then finds an empty profile and never fires.
  • An unknown key is matched against the standard field names first, in several languages, before it becomes a custom variable. "Secteur" lands in the company industry, "Prénom" in firstName, "Société" in companyName. If you want a real custom variable, give it a name that resembles no standard field.
  • Reading back is not symmetric with writing: you send a flat object, and GET /lists/list/<listId>/rows?page=1&pageSize=50 returns the company nested under company and the custom variables in a customFields array. pageSize caps at 100.

There is also a single contact route, POST /advanced/lists/contacts with {"id":"<listId>","contact":{...}}. Use it to add one row to a list that already feeds a running campaign, never to load a file: at one call per contact it burns the rate limit of the plan for nothing. Three traps on that one, all of which fail a row rather than the batch:

  1. The contact needs either an email or a LinkedIn profile URL. The published schema lists both as required; the server accepts one. Sending neither fails.
  2. A contact already in that list returns an error, it is not silently skipped. Treat that error as a duplicate, not as a failure to retry.
  3. A Sales Navigator URL (linkedin.com/sales/lead/...) is converted to a linkedin.com/in/... URL when it can be. Anything else that is not a linkedin.com/in/ URL is rejected.

Pace the loop. Rate limits are per key per minute: 30 without a subscription, 100 on Start, 300 on Grow, 1000 on Scale. A 1,200 row list is 1,200 REST calls, so on Start that is 12 minutes of sending at a steady rate. With MCP it is 12 calls.

3. The mechanic to explain to the user, once

A list attached to a running campaign feeds it continuously. Add a contact to that list tomorrow and the contact enters the campaign tomorrow, at step 1, without touching the campaign. That is how you keep a campaign alive: you add to the list, never to the campaign.

The consequences, all of which surprise people the first time:

  • Deleting a contact from the list removes it from the campaigns using that list.
  • A list attached to two campaigns feeds both. Someone would get both sequences.
  • Excluding people is done with an exclusion list on the campaign, not by trimming the main list.
  • POST /advanced/campaign/contacts with { "id": "<campaignId>", "contact": {...} } does exist and pushes one contact straight into a campaign. Use it only for a one off addition: those contacts are not in a reusable list, so you cannot re-target them later without rebuilding them.

Say this out loud the first time you create a campaign for someone. It is the single thing that makes the rest programmable.

4. Create the campaign

Email or multichannel:

curl -s -X POST https://api.emelia.io/advanced/campaigns \
  -H "Authorization: $EMELIA_API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"Q4 SaaS founders"}'
# {"success":true,"campaignId":"6612f0a9b1c2d3e4f5a6b7c8"}

LinkedIn only campaign: POST https://api.emelia.io/linkedin/campaigns with the same {"name":"..."} body; it returns the whole campaign object rather than just the id. With MCP, create_campaign with { name } does the same thing.

The campaign is created empty, with a default schedule and empty steps. name is the only field that endpoint takes, but everything else is set right after, by API.

5. Push the steps, from campaign.json

PATCH /advanced/campaigns/{campaignId}/steps with the tree wrapped in an object: {"steps": { ... }}, not an array. Read the campaign back first with GET /advanced/campaigns/{campaignId} so you change what you mean to change, then push, then read it back again and compare it to outreach/campaign.json.

The tree is a linked structure, not a list: it starts at a START node and each node carries the next one, with yes and no branches on a condition. The five shipped templates in outreach-sequence are the reference for what a valid tree looks like.

Every PATCH on this API replaces the whole block, and there is no partial update. Not for steps, not for recipients, not for identities, not for settings. What you send becomes the block, and what you leave out is gone. Sending only the step you wanted to change deletes the rest of the sequence. Sending one list in lists detaches all the others. So the only correct shape is read, modify, send back:

GET   /advanced/campaigns/{id}          the whole object
                                        change the one field, in the object you just read
PATCH /advanced/campaigns/{id}/steps    send the entire tree back

Never invent a step _id. Omit it on a new tree and the server generates a real ObjectId for every step and version. Write "e1" or a UUID instead and the campaign saves without complaint, reads back correctly, and then sends the same email to the same person over and over: the activity record that logs a send casts stepId to an ObjectId, the cast fails after the message has already gone out, no activity is stored, and the scheduler replans a step it has no record of having run. Conditions on that step hang forever for the same reason. The only _id you write by hand is "START" on the root. outreach-sequence has the full explanation and the recovery for a campaign already built that way.

On a campaign that has already sent something, keep the _id of every step and every version you are not changing: activities, per version statistics and each contact's position in the sequence hang off those ids, and new ids restart people mid sequence.

And a campaign that is RUNNING refuses every one of these calls with You must pause your campaign before updating it. Pause, patch, start again.

What goes in an email step, verbatim. Subject and body are the carrier variables, never the written text:

subject: {{email1subject}}
body:    <p>{{email1message}}</p><p></p><p>{{signature}}</p>

From step 2 the opt out link is appended, as a real anchor:

<p></p><p><a target="_blank" rel="noopener noreferrer" href="{{unsubscribe_link}}">Se désabonner</a></p>

The body is HTML. Paragraphs are <p>, an empty line is <p></p>, and a \n typed in the body renders as nothing, which turns the whole email into one block.

Inside a variable's value it is the opposite: a \n becomes a paragraph break. The sending engine converts \n\n to </p><p></p><p> and \n to </p><p> when it substitutes a value. That is exactly why the carrier pattern works: you write the message into email1message as plain text with ordinary line breaks, and it comes out as paragraphs. Write HTML in the body, plain text with \n in the variable, and never the other way round.

Variables are double braces. A name that does not resolve renders empty, silently, with no error and no default, so the campaign sends "Bonjour ," rather than failing. Guarantee the data instead of guarding the template: every variable a step uses must be filled on every contact of the list, which is what the pre-flight below checks.

Only a fixed set of contact fields resolve at the root: firstName, lastName, fullName, email, secondaryEmail, phone, mobilePhone, jobTitle, seniority, department, gender, age, language, linkedinUrlProfile, twitterUrl, country, region, city, postalCode, address, timezone, yearsOfExperience, education, bio and companyName. Resolution then falls through to your custom variables, then to the linked company, where companyCity and companyCountry reach the company's own fields since the bare names belong to the contact. A field that exists on the contact but is not in that list, birthDate or skills for instance, renders empty in a message even though the API accepted it on import.

The shapes Emelia uses:

  • stepType is one of START, EMAIL, CONNECTION, MESSAGE, INMAIL, VISIT, LIKE, FOLLOW, AUDIO, TASK, API_CALL, CONDITION, END_OF_CAMPAIGN. The LinkedIn ones carry no LINKEDIN_ prefix, and there is no WAIT step: waiting is the delay of the next step.
  • delay is { "amount": 3, "unit": "DAYS" }, and unit is DAYS, HOURS or MINUTES. The delay is counted from the previous step, not from the start.
  • A message step carries versions, one per A/B variant, each with subject, message and disabled.
  • A CONDITION step branches on a field with an operator among EQUAL, GREATER, LOWER, CONTAINS, IS_EMPTY, DOES_NOT_CONTAIN, DOES_NOT_EQUAL, IS_NOT_EMPTY.

Four rules Emelia enforces when you press Launch, so check them now rather than there:

  1. Every email step needs at least one active version.
  2. Every active version needs content. An empty body fails, an image only body passes the check but see outreach-deliverability for why it should not.
  3. The first email of a branch needs a subject. The following emails inherit the thread and can leave it empty, which is what makes a follow up land in the same thread.
  4. A LinkedIn message step with an audio note needs the audio actually uploaded.

Variables, exactly as the engine resolves them:

  • {{firstName}}, {{lastName}}, {{fullName}}, {{email}}, {{phone}}, {{jobTitle}}, {{companyName}}, {{city}}, {{country}}, {{linkedinUrlProfile}} and the rest of the contact fields, plus every custom key you loaded in section 2.
  • {{unsubscribe_link}} renders the opt out URL and feeds the List-Unsubscribe header. Put it from step 2 onwards, not on step 1: the first touch stays as short as possible. Note the trade off, the List-Unsubscribe header is only set when the variable is in that step's body, so step 1 goes out without it.
  • {{SENDER}} is not resolved by the sending engine and renders empty. Write your own name in the copy instead.
  • Resolution order is contact field, then custom variable, then company field. A custom variable named city will never win over the contact's own city.

6. Cadence, sending accounts and recipients

Three more calls, all by API:

  • PATCH /advanced/campaigns/{id}/settings with {"settings": { ... }}. Note that trackLinks, trackOpens and blacklistUnsub are required inside that object, so send them even when you are not changing them.
  • PATCH /advanced/campaigns/{id}/recipients with {"lists": [...], "excludedLists": [...]} at the top level of the body. lists is required even when empty, and both arrays replace what was there.
  • PATCH /advanced/campaigns/{id}/identities with {"identities": [...]}, each entry carrying a name and at least one of email or linkedin, whose values are the provider ids, not the addresses.

The settings fields, with the values the product accepts:

SettingAcceptedWhat to set for cold outreach
dailyEmailAdded / dailyEmailLimit1 to 500The number from the deliverability ramp, per mailbox. Not 500.
dailyLinkedinAdded1 to 8040 maximum when the sequence contains a connection request, 80 otherwise. Emelia rejects the launch above that.
days0 to 6Monday to Friday only
start / endHH:MMThe recipient's working hours, not yours
timeZoneIANA nameThe recipient's zone
trackOpensbooleanOff. Apple already broke the number.
trackLinksbooleanOff on a cold first touch
blacklistUnsubbooleanOn. An unsubscribe click blacklists the address account wide.
ignoreAutoRepliesbooleanOn. Out of office answers stop counting as replies and stop stopping the sequence.
excludeAlreadyMessagedbooleanOn, unless you deliberately want a second campaign to reach the same people
eventToStoplist of eventsAt minimum the reply events, so a person who answers stops receiving follow ups
bccaddressYour CRM dropbox if you have one

Sending accounts are called identities: one email mailbox, optionally paired with a LinkedIn account. Attach as many as the ramp plan requires. Every identity must have an email provider attached when the campaign has an email step, and a campaign with a LinkedIn step needs either one shared LinkedIn account or one per identity.

GET /email-providers lists the mailboxes. The address is in senderEmail, not email, and the id you attach is _id. Skip any entry whose disabled or disconnected is true, and read customDomain.status: OK means that mailbox has its own tracking domain, which is what makes the opt out link and the click tracking work on its sends.

Never attach every mailbox the account holds. An agency account, and any account that has ever run a campaign for someone else, carries several sending domains belonging to different senders. Attaching all of them sends this campaign's cold email from a stranger's domain and burns a reputation that is not yours to spend. Filter to the domains that belong to this campaign's sender, list the addresses back to the user, and get a yes before you patch. The same account can legitimately hold the same address more than once, so deduplicate on the id.

An empty identities array is accepted with a 200 and leaves the campaign unable to send. So is an array built from a field name that does not exist, which is the same thing with extra steps. Read the campaign back after patching and count the identities.

Get the real identifiers rather than guessing them:

What you needWhere it comes from
Email mailbox idGET /email-providers, field _id on each provider
LinkedIn account idGET /linkedin-scrappers/authes, field _id, only entries whose status is valid
Tracking domain statusGET /domains, a domain counts only when its status is OK

outreach-deliverability already collects all three when it runs, so read its output before calling these again. The LinkedIn response also carries a live session token: read the id and the status, and never print or store the rest.

Then PATCH /advanced/campaigns/{id}/identities with {"identities": [...]}, where each entry carries a name and at least one of email or linkedin.

Recipients: attach the list from section 1. Confirm the contact count Emelia shows matches the rows you loaded. A campaign is capped at 15,000 recipients.

7. Pre-flight, all of it, before you ask anything

Run the mechanical half first, against the campaign as Emelia now holds it, not against your files:

python3 scripts/preflight-campaign.py <campaignId>

It reads the campaign back, walks the tree including the branches, and checks the six things that a 200 response will not tell you: an identity is attached, a list is attached and holds contacts, no body is empty or missing its <p> paragraphs, the first email has a subject, no LinkedIn step carries several versions in the belief that it is an A/B test, and every {{variable}} any step uses resolves on the contacts of the attached lists. That last one is the check that matters: it samples the list and tells you a variable is empty on 25 of 99 contacts, which is 25 emails with a hole in them and no error anywhere. It exits non zero when something blocks.

Then the half a script cannot do:

CheckHowPass condition
Deliverability## Verdict in outreach/deliverability.mdREADY, or READY WITH LIMITS and the cadence set to that cap
List verifiedthe verification column in outreach/leads.csvevery row verified, invalid rows removed
Unsubscribegrep the copy{{unsubscribe_link}} present from step 2 onwards, as a link and not a bare variable
Variables resolvedcount blanks per variable across the CSV0 blanks, or a fallback written into the copy
Test emailsend one from the campaign to your own addressreceived, read on a phone, every variable filled, links working
Subject on step 1GET /advanced/campaigns/{id}not empty
Body is HTMLGET /advanced/campaigns/{id}every step body contains <p>, and no raw \n
Variable syntaxGET /advanced/campaigns/{id}only {{name}}, no {#, no `
Carrier columnsget_list_contacts on one rowevery emailNsubject and emailNmessage the steps reference exists and is filled
Greetingone rendered samplethe message opens with a greeting, not with the first sentence
SubscriptionEmelia accountactive, the product refuses to start a campaign without one
IdentitiesGET /advanced/campaigns/{id}at least one, none disconnected or expired
Recipientsget_campaignabove 0, at most 15,000, and at least one contact reachable on each channel the sequence uses
ScheduleGET /advanced/campaigns/{id}set, with days and hours

A blank variable is the most common way a good campaign embarrasses someone. Count them per variable and show the count. If {{icebreaker}} is empty on 34 rows, either write those 34 or cut them from this campaign.

8. Ask, then launch

Show the summary and stop:

Campaign     Q4 SaaS founders (6612f0a9b1c2d3e4f5a6b7c8)
List         Q4 SaaS founders (65f1a2b3c4d5e6f7a8b9c0d1), 1,139 contacts loaded, 41 rejected
Sequence     3 steps (2 emails, 1 LinkedIn visit), 4 days end to end
Mailboxes    [email protected], [email protected]
Cadence      20 per mailbox per day, Mon to Fri, 09:00 to 17:00 Europe/Paris
First send   tomorrow 09:00
Ends around  2026-04-24
Tracking     opens off, links off, unsubscribe link present in both email steps

Launch? This sends real email to 1,139 people.

Wait for an explicit yes. Not "ok next", not silence. Then the user presses Launch in the app: the API does not expose starting a campaign. Confirm it took with get_campaign and check that status is RUNNING. Then update outreach/campaign.json with the real identifiers before you report back.

Output

outreach/campaign.json, enriched in place. The emelia block is what the later skills read.

{
  "name": "Q4 SaaS founders",
  "channel": "email",
  "emelia": {
    "campaignId": "6612f0a9b1c2d3e4f5a6b7c8",
    "listId": "65f1a2b3c4d5e6f7a8b9c0d1",
    "status": "RUNNING",
    "startedAt": "2026-03-13T08:00:00.000Z",
    "identities": ["[email protected]", "[email protected]"]
  },
  "contacts": { "loaded": 1139, "duplicates": 12, "failed": 41, "source": "outreach/leads.csv" },
  "schedule": {
    "dailyEmailAdded": 20,
    "days": [1, 2, 3, 4, 5],
    "start": "09:00",
    "end": "17:00",
    "timeZone": "Europe/Paris",
    "trackOpens": false,
    "trackLinks": false,
    "blacklistUnsub": true,
    "ignoreAutoReplies": true
  },
  "steps": [
    { "stepType": "EMAIL", "delay": { "amount": 0, "unit": "DAYS" },
      "versions": [{ "subject": "{{companyName}} + Postgres 16", "message": "Hi {{firstName}}, ..." }] },
    { "stepType": "EMAIL", "delay": { "amount": 3, "unit": "DAYS" },
      "versions": [{ "subject": "", "message": "Following up, {{firstName}}. ... {{unsubscribe_link}}" }] },
    { "stepType": "VISIT", "delay": { "amount": 1, "unit": "DAYS" }, "versions": [] }
  ],
  "preflight": {
    "deliverability": "READY WITH LIMITS",
    "listVerified": true,
    "blankVariables": { "icebreaker": 0, "companyName": 0 },
    "testEmailSentTo": "[email protected]",
    "confirmedBy": "user, 2026-03-12T18:41:00Z"
  }
}

Checks before finishing

  • campaignId and listId are real values returned by the API, never placeholders.
  • The contact count Emelia reports matches the rows you loaded, or the difference is explained in contacts.failed and contacts.duplicates.
  • The deliverability verdict is recorded in the file, with the date it was produced.
  • Every pre-flight row passed, and the failures were fixed rather than waived.
  • The launch confirmation is recorded with a timestamp. No confirmation means no launch, and the file says status: "DRAFT".
  • The user has been told, in words, that adding to the list is how they add to the campaign from now on.

Failure modes

  • The campaign shows 0 recipients after you attached the list. Emelia materialises the recipients asynchronously; recipients.processing is true while it does. Wait and read get_campaign again before doing anything else.
  • "none of your contacts has an email address". The column mapping is wrong: the CSV column was loaded into a custom variable instead of email. Check one contact with get_list_contacts using { listId, email: "..." } and fix the keys, then reload.
  • "You cannot have more than 15,000 recipients". Split into several campaigns by segment. Do not trim the list at random.
  • "Your email provider has been disconnected". Reconnect it in the app. It is not something the API can do, and the campaign will not start until it is done.
  • "You must have a subscription to start a campaign". The account is on trial or expired. Everything up to the launch still works, so finish the setup and say what is blocking.
  • The LinkedIn cadence is rejected. The cap drops from 80 to 40 as soon as a connection request exists in the sequence, and it is re-checked at launch. Lower the number, do not remove the step.
  • A rate limit error while loading contacts. Stop, wait a minute, resume at the row that failed. Do not restart the batch: the rows already loaded would come back as duplicates.
  • The test email arrives with {{firstName}} in it. The variable name in the copy does not match a key on the contact. Compare them character by character, they are case sensitive.

Limits

This skill does not write the copy and does not judge whether the sequence is any good. It cannot create the steps, the schedule or the sending accounts through the API, and it cannot press Launch for you: those happen once in the Emelia app, and the skill tells you exactly what to set there. It does not verify email addresses (outreach-verify does), does not create A/B variants through the API. It will not launch on a BLOCKED verdict, on an unverified list, or without an explicit yes, and no phrasing of the request changes that.

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 outreach-campaign 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.