Agent skill
outreach-sequence
Designs the multichannel flow of a campaign, starting from one of Emelia's five real campaign templates (Pure Cold Email, Pure Linkedin basic, Pure Linkedin Premium with InMail, LinkedIn then Email, Email then LinkedIn) rather than from a blank tree, and picking the template mechanically from the email and LinkedIn coverage the enrichment actually produced.
Filed under Outbound email.
From emelia-io/claude-outreach · 17 skills · 12 · pushed 2026-09-09
What it does when it runs
Designs the multichannel flow of a campaign, starting from one of Emelia's five real campaign templates (Pure Cold Email, Pure Linkedin basic, Pure Linkedin Premium with InMail, LinkedIn then Email, Email then LinkedIn) rather than from a blank tree, and picking the template mechanically from the email and LinkedIn coverage the enrichment actually produced. Then: which steps exist in Emelia (email, LinkedIn visit, invitation, message, InMail, manual call task), how conditions and branches work (invitation accepted, email bounced, link clicked, reply received, contact fields such as the email and the LinkedIn profile URL), the delays that work and why, a clean A/B test with a sample size that makes the result mean something, what goes in the body of an email step (the message or its custom variable, then the signature, then a real unsubscribe link from step 2 on), and the conditions that stop a contact or stop the campaign. Produces outreach/campaign.json, the spec that outreach-campaign turns into a real campaign. Triggers on: sequence, flow, cadence, steps, template, campaign template, which template, pure cold email, pure linkedin, linkedin then email, email then linkedin, smart multi channel, sales navigator, InMail, multichannel, multicanal, delays, wait, condition, branch, if accepted, if bounced, A/B test, split test, variant, variants, sample size, stop condition, stop the sequence, campaign design, campaign structure, LinkedIn plus email, unsubscribe link, opt out, signature, step body.
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
- None found.
- Hosts it reaches
- No third-party host appears in the skill or its bundled files.
- Tool permissions it declares
- No
allowed-toolsin the frontmatter. It does act, so it runs under whatever permissions your session already grants. - Actions present in the files
- shell
Install it
View source on GitHub ↗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-sequence" mkdir -p ~/.claude/skills/outreach-sequence cp -R "/tmp/claude-outreach/skills/outreach-sequence/." ~/.claude/skills/outreach-sequence/
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.
The skill
Source on GitHub ↗Reproduced in full from emelia-io/claude-outreach/blob/585061e4d78fe14a70701fbc5aec5d1eeb3d580a/skills/outreach-sequence/SKILL.md, which is licensed MIT (skill frontmatter). 9,479 words, 23 headings.
Design the sequence flow
What this does
Turns approved copy into a step tree: which channel touches the prospect, in which
order, after how long, under which condition, and when the whole thing stops. It starts
from one of Emelia's five campaign templates, chosen from what the enrichment actually
found rather than from preference, then adapts it. It writes outreach/campaign.json,
which is both the spec outreach-campaign consumes and the build sheet a human follows
in the Emelia app.
When to use it
Use it after outreach-write has produced approved copy, and before anything is created in Emelia. Use it again when a campaign runs and one step is carrying it: cutting a step is a flow decision, not a copy decision. Use it as well when the only question is "which template do I pick", because the answer comes from the list and section 1 computes it in one command.
Use a different skill when: you are writing the text of the steps
(outreach-write), filling variables per contact
(outreach-personalize), or checking whether the
mailboxes can carry the volume this schedule implies (outreach-deliverability).
Inputs
| Input | Where it comes from | If it is missing |
|---|---|---|
| The copy, step by step | outreach/sequence.md | Stop, design a flow around copy that exists |
| Contact count and segments | outreach/leads.csv | Ask, the count drives the A/B decision |
| Email and LinkedIn coverage on the contactable rows | outreach/leads.csv, counted as in section 1 | Count it yourself, do not ask. It is what picks the template |
| Sending identities | the user, or list_email_providers on the MCP server | Ask for the mailbox addresses and the LinkedIn account |
| Channels the user actually has | the user | Email only is a valid design, say so and drop the LinkedIn steps |
| InMail credits on the LinkedIn seat | the user | Assume none, and cut the InMail step rather than designing a branch that cannot fire |
| Timezone of the recipients | outreach/icp.json or the user | Default Europe/Paris for a French list and say you defaulted |
Read the honest constraint first, and tell the user before designing: the API creates
a campaign name and nothing else. POST /advanced/campaigns takes name. The step
tree, the schedule and the sending accounts are built once in the Emelia app. This file
is what makes that build fast and repeatable, and after it exists the skills feed the
attached list, so every contact added enters the running campaign on its own.
The good news about that build: you do not do it from a blank canvas. Emelia offers five campaign templates, section 1 says which one your list picks, and the tree it drops in is most of the work. Everything after section 1 is how you adapt it.
How to do it
1. Start from a template, not from a blank tree
How long a sequence should be, before you look at any template. On email, four or five steps. On LinkedIn, one connection request and then three messages at most: LinkedIn quotas cap your daily volume anyway, and someone who has not answered after three is not going to answer at the fourth. Users will adjust afterwards, and that is fine, but this is where you start.
LinkedIn delays are longer than email delays, on purpose. Not everyone lives on LinkedIn. Someone who signs in once a fortnight must not open the app and find three messages waiting, break-up included, because that reads as a machine and not as a person. Treat the delays that come with the templates as a floor rather than a target, and stretch them when the audience is one that checks LinkedIn rarely: founders of small companies, technical roles, anyone outside sales and recruiting.
Emelia ships five campaign templates, and one of them is nearly always closer to the
right answer than a tree you invent. Their trees are reproduced in this
skill, delays and conditions already wired, so you build one by pushing it through
PATCH /advanced/campaigns/{id}/steps rather than by clicking. You then cut what you do not need, paste
the copy in, and record what you changed. Design from scratch only when none of the five
fits, and write the reason into campaign.json.
The five, as they are in the product on 9 September 2026. The id is what you write into
campaign.json, the name is what the app shows you.
id | Name in the app | Channel | Shape | What it assumes you have |
|---|---|---|---|---|
pure-cold-email | Pure Cold Email | Email only | 5 emails, 43 days | A verified address for everybody |
pure-linkedin | Pure Linkedin (for basic account) | LinkedIn only | Invitation, message, message, like, message, 11 days | A profile URL for everybody, and no InMail credits |
linkedin-premium-or-sales-navigator | Pure Linkedin (for Premium Account) | LinkedIn only | Invitation, then messages if accepted, InMail if not | A profile URL for everybody, plus Premium or Sales Navigator with InMail credits |
linkedin-email | LinkedIn then Email | Multichannel | LinkedIn first, email later for the rows that have an address | A profile URL for everybody, an address for some |
smart-multi-channel | Email then LinkedIn | Multichannel | Email first, LinkedIn later for the rows that have a profile | An address for everybody, a profile URL for some |
A template hands you the tree and nothing else. Every step comes back with empty
versions and empty identities, and every _id is regenerated, so the words are still
yours to write and the sending accounts still yours to attach.
The choice follows from the enrichment, not from taste
The template is decided by two numbers, and you can read both off outreach/leads.csv
once outreach-enrich has run. Count them on the rows you are actually going to contact,
which is send_decision equal to send.
python3 - <<'PY'
import csv
rows = [r for r in csv.DictReader(open('outreach/leads.csv'))
if (r.get('send_decision') or 'send') == 'send']
n = len(rows) or 1
email = sum(1 for r in rows if r.get('email'))
li = sum(1 for r in rows if r.get('linkedin_url'))
print(f"{len(rows)} contactable rows, email {email} ({email/n:.0%}), "
f"linkedin {li} ({li/n:.0%})")
PY
Read the two percentages into three bands. They are rules of thumb, not thresholds measured on anything: near everyone above 90%, some between 40 and 90%, few below 40%.
| Email coverage | LinkedIn coverage | Template | Why |
|---|---|---|---|
| Near everyone | Few | pure-cold-email | There is no second channel to switch to |
| Few | Near everyone, basic account | pure-linkedin | The invitation is the only door you have |
| Few | Near everyone, Premium or Sales Navigator | linkedin-premium-or-sales-navigator | The InMail is what reaches the rows that never accept |
| Some | Near everyone | linkedin-email | Start where everyone is, switch to email for the rows that have one |
| Near everyone | Some | smart-multi-channel | Start where everyone is, add LinkedIn for the rows that have a profile |
| Near everyone | Near everyone | either multichannel | Both work. Pick the channel this segment answers on, and write down which you picked and why |
| Few | Few | none | Go back to outreach-enrich. A list with no channel is not a campaign |
Say the numbers to the user before naming a template: "312 contactable rows, 96% have an address, 41% have a LinkedIn profile, so this is Email then LinkedIn." That sentence is the whole justification, and it also tells them what enrichment would have to improve for a different template to become available.
linkedin-email, when LinkedIn is the channel everyone has
Use it when you have the profile URL of everybody and the address of only part of the
list. It opens on LinkedIn, because that is the channel that reaches the whole list, and
switches to email only for the contacts whose address exists. Both branches check
$email before they try to send one, so a row with no address never falls into a dead
email step: it either ends or gets an InMail.
START day 0
CONNECTION the invitation +0
CONDITION ACCEPTED == true window 5 days
yes MESSAGE +1 hour
MESSAGE +5 days
LIKE +8 days
MESSAGE +1 hour
CONDITION $email IS_NOT_EMPTY window 3 days
yes EMAIL +1 day
EMAIL +5 days
EMAIL +20 days
END_OF_CAMPAIGN +1 day
no END_OF_CAMPAIGN +1 day
no CONDITION $email IS_NOT_EMPTY window 1 day
yes EMAIL +1 day
EMAIL +5 days
EMAIL +10 days
no INMAIL +1 day
Three things to notice. The accepted branch talks on LinkedIn for two weeks before it touches email at all, so email is the second act, not a parallel channel. The InMail at the bottom right is the only path for a contact who neither accepted nor has an address, and it costs a credit, so cut that step if the account has none. And both arms end explicitly, which is the template telling you that branches never rejoin.
Do not pick it when your LinkedIn coverage is partial: everyone whose profile URL is missing sits at the first step with an invitation that cannot be sent. Coverage on LinkedIn is what this template spends, so it has to be near total.
smart-multi-channel, when email is the channel everyone has
The mirror image, and it is the one to use when you have the address of everybody and the profile of only part of the list. It opens on email, then adds LinkedIn for the rows whose profile exists. It is also the only template that reads a bounce as a signal: a contact whose first email bounced is routed to LinkedIn immediately rather than being sent three more emails to an address that does not exist.
START day 0
EMAIL email 1 +0
CONDITION BOUNCED == true window 1 day
yes CONDITION $linkedinUrlProfile IS_NOT_EMPTY window 1 day
yes CONNECTION +1 day
MESSAGE +1 day
no nothing, the contact stops here
no EMAIL email 2 +1 day
EMAIL email 3 +5 days
CONDITION $linkedinUrlProfile IS_EMPTY window 1 day
yes EMAIL email 4 +1 day
no CONNECTION +2 days
MESSAGE +1 day
MESSAGE +5 days
Read the second condition carefully, because its polarity is the opposite of the first
one: it tests IS_EMPTY, so the yes branch is "no LinkedIn profile, send a fourth
email" and the no branch is "there is a profile, go to LinkedIn". Getting that backwards
sends your LinkedIn sequence to the rows that have no profile, and Emelia will not warn
you: the invitation simply never fires.
The no branch of the first condition is empty in the template. A contact whose email
bounced and who has no LinkedIn profile stops there, silently, with no
END_OF_CAMPAIGN. That is the correct outcome, but add the END_OF_CAMPAIGN yourself so
the report reads as a decision rather than as a step that failed.
Do not pick it when your email coverage is partial: the first step is an email, so every
row without an address is dead on arrival. If that is your list, you wanted
linkedin-email.
What the templates get right, and the five things to fix in them
pure-cold-emailships five email steps. outreach-write section 8 writes four and explains why a fifth mostly buys unsubscribes. Delete the last step, or decide deliberately to write a fifth and say so. Do not leave an empty step in the tree: an email step with no body is an email step that sends nothing and still counts as a touch in the stats.- The condition windows park everybody. The 5 day window on
ACCEPTEDin the three LinkedIn templates means every contact who has not accepted waits 5 days before the fallback runs. That is the design, and it is the right default, but say the number out loud: on a 500 row list nothing at all happens on the email side for the first week. - The "~N days" on the template card is a label, not the span. It is a hand written
approximation in the product, and on the branching templates it understates. The real
span is the sum of the delays along the branch a contact takes, condition windows
included: the accepted arm of
linkedin-emailruns about 48 days, not 15. Compute the branch, do not repeat the card. - The last message of
pure-linkedinfires one minute after the like. A like and a message landing in the same minute reads as one automated action, which is the thing section 5 says to avoid everywhere else. Raise it to a few hours unless you have a reason not to. - The templates carry no stop conditions and no schedule. They are a tree. The
eventToStoplist, the timezone, the daily volumes and the tracking flags are settings you still have to set, in section 5 and section 7 below.
The trap that decides whether the multichannel branches work at all
Both multichannel templates branch on contact fields, and those are Emelia's field names, not your CSV headers:
| Condition field | Emelia contact field | What has to land in it |
|---|---|---|
$email | email | the address from outreach/leads.csv |
$linkedinUrlProfile | linkedinUrlProfile | the profile URL from outreach/leads.csv |
If the LinkedIn URL is imported into a custom field called linkedin_url or linkedin,
$linkedinUrlProfile is empty on every row. The condition then routes the whole list down
one branch, no error is raised, and you find out when the invitations never fire. Check
the mapping on the import screen, and check one contact in the app after the load, before
you start the campaign.
The campaign.json for each template
Same schema as the Output section below: these blocks give template, channels and
steps, and everything else in the file (goal, identities, recipients, schedule,
ab_test, stop_rules, emelia) is written as shown there. Keep the template field
even when you have edited the tree, and list what you changed in template_changes, so
six weeks later you can tell a deliberate cut from a mistake.
pure-cold-email:
{
"template": "pure-cold-email",
"template_changes": ["dropped the fifth email, outreach-write writes four"],
"channels": ["email"],
"steps": {
"_id": "START", "stepType": "START", "identities": [], "delay": {"amount": 0, "unit": "DAYS"},
"next": {
"stepType": "EMAIL", "identities": [], "delay": {"amount": 0, "unit": "DAYS"},
"versions": [{"subject": "status page vs reality", "message": "sequence.md step 1, then {{signature}}, no opt out link", "disabled": false}],
"next": {
"stepType": "EMAIL", "identities": [], "delay": {"amount": 3, "unit": "DAYS"},
"versions": [{"subject": "", "message": "sequence.md step 2, then {{signature}}, then the opt out anchor", "disabled": false}],
"next": {
"stepType": "EMAIL", "identities": [], "delay": {"amount": 5, "unit": "DAYS"},
"versions": [{"subject": "who gets paged", "message": "sequence.md step 3, then {{signature}}, then the opt out anchor", "disabled": false}],
"next": {
"stepType": "EMAIL", "identities": [], "delay": {"amount": 10, "unit": "DAYS"},
"versions": [{"subject": "", "message": "sequence.md step 4, the break up, then {{signature}}, then the opt out anchor", "disabled": false}]
}
}
}
}
}
}
pure-linkedin:
{
"template": "pure-linkedin",
"template_changes": ["last message moved from 1 minute after the like to 4 hours"],
"channels": ["linkedin"],
"steps": {
"_id": "START", "stepType": "START", "identities": [], "delay": {"amount": 0, "unit": "DAYS"},
"next": {
"stepType": "CONNECTION", "identities": [], "delay": {"amount": 0, "unit": "DAYS"},
"versions": [{"message": ""}],
"next": {
"stepType": "MESSAGE", "identities": [], "delay": {"amount": 1, "unit": "DAYS"},
"versions": [{"message": "sequence.md LinkedIn message 1"}],
"next": {
"stepType": "MESSAGE", "identities": [], "delay": {"amount": 3, "unit": "DAYS"},
"versions": [{"message": "sequence.md LinkedIn message 2"}],
"next": {
"stepType": "LIKE", "identities": [], "delay": {"amount": 7, "unit": "DAYS"},
"next": {
"stepType": "MESSAGE", "identities": [], "delay": {"amount": 4, "unit": "HOURS"},
"versions": [{"message": "sequence.md LinkedIn break up"}]
}
}
}
}
}
}
}
linkedin-premium-or-sales-navigator:
{
"template": "linkedin-premium-or-sales-navigator",
"template_changes": [],
"channels": ["linkedin"],
"steps": {
"_id": "START", "stepType": "START", "identities": [], "delay": {"amount": 0, "unit": "DAYS"},
"next": {
"stepType": "CONNECTION", "identities": [], "delay": {"amount": 0, "unit": "DAYS"},
"versions": [{"message": ""}],
"next": {
"stepType": "CONDITION", "identities": [], "delay": {"amount": 5, "unit": "DAYS"},
"conditions": {"field": "ACCEPTED", "operator": "EQUAL", "value": "true"},
"yes": {
"stepType": "MESSAGE", "identities": [], "delay": {"amount": 2, "unit": "HOURS"},
"versions": [{"message": "sequence.md LinkedIn message 1"}],
"next": {
"stepType": "MESSAGE", "identities": [], "delay": {"amount": 5, "unit": "DAYS"},
"versions": [{"message": "sequence.md LinkedIn message 2"}],
"next": {
"stepType": "MESSAGE", "identities": [], "delay": {"amount": 10, "unit": "DAYS"},
"versions": [{"message": "sequence.md LinkedIn break up"}]
}
}
},
"no": {
"stepType": "INMAIL", "identities": [], "delay": {"amount": 1, "unit": "DAYS"},
"versions": [{"subject": "who owns response monitoring", "message": "sequence.md InMail body"}],
"next": {
"stepType": "LIKE", "identities": [], "delay": {"amount": 2, "unit": "DAYS"},
"next": {
"stepType": "MESSAGE", "identities": [], "delay": {"amount": 5, "unit": "DAYS"},
"versions": [{"message": "sequence.md LinkedIn last message"}]
}
}
}
}
}
}
}
linkedin-email:
{
"template": "linkedin-email",
"template_changes": [
"dropped the InMail, the account has no credits",
"third message moved from 1 hour after the like to 4 hours"
],
"channels": ["linkedin", "email"],
"steps": {
"_id": "START", "stepType": "START", "identities": [], "delay": {"amount": 0, "unit": "DAYS"},
"next": {
"stepType": "CONNECTION", "identities": [], "delay": {"amount": 0, "unit": "DAYS"},
"versions": [{"message": ""}],
"next": {
"stepType": "CONDITION", "identities": [], "delay": {"amount": 5, "unit": "DAYS"},
"conditions": {"field": "ACCEPTED", "operator": "EQUAL", "value": "true"},
"yes": {
"stepType": "MESSAGE", "identities": [], "delay": {"amount": 1, "unit": "HOURS"},
"versions": [{"message": "sequence.md LinkedIn message 1"}],
"next": {
"stepType": "MESSAGE", "identities": [], "delay": {"amount": 5, "unit": "DAYS"},
"versions": [{"message": "sequence.md LinkedIn message 2"}],
"next": {
"stepType": "LIKE", "identities": [], "delay": {"amount": 8, "unit": "DAYS"},
"next": {
"stepType": "MESSAGE", "identities": [], "delay": {"amount": 4, "unit": "HOURS"},
"versions": [{"message": "sequence.md LinkedIn message 3"}],
"next": {
"stepType": "CONDITION", "identities": [], "delay": {"amount": 3, "unit": "DAYS"},
"conditions": {"field": "$email", "operator": "IS_NOT_EMPTY", "value": ""},
"yes": {
"stepType": "EMAIL", "identities": [], "delay": {"amount": 1, "unit": "DAYS"},
"versions": [{"subject": "status page vs reality", "message": "sequence.md step 1, then {{signature}}, no opt out link", "disabled": false}],
"next": {
"stepType": "EMAIL", "identities": [], "delay": {"amount": 5, "unit": "DAYS"},
"versions": [{"subject": "", "message": "sequence.md step 2, then {{signature}}, then the opt out anchor", "disabled": false}],
"next": {
"stepType": "EMAIL", "identities": [], "delay": {"amount": 20, "unit": "DAYS"},
"versions": [{"subject": "", "message": "sequence.md step 3, then {{signature}}, then the opt out anchor", "disabled": false}],
"next": {"stepType": "END_OF_CAMPAIGN", "identities": [], "delay": {"amount": 1, "unit": "DAYS"}}
}
}
},
"no": {"stepType": "END_OF_CAMPAIGN", "identities": [], "delay": {"amount": 1, "unit": "DAYS"}}
}
}
}
}
},
"no": {
"stepType": "CONDITION", "identities": [], "delay": {"amount": 1, "unit": "DAYS"},
"conditions": {"field": "$email", "operator": "IS_NOT_EMPTY", "value": ""},
"yes": {
"stepType": "EMAIL", "identities": [], "delay": {"amount": 1, "unit": "DAYS"},
"versions": [{"subject": "status page vs reality", "message": "sequence.md step 1, then {{signature}}, no opt out link", "disabled": false}],
"next": {
"stepType": "EMAIL", "identities": [], "delay": {"amount": 5, "unit": "DAYS"},
"versions": [{"subject": "", "message": "sequence.md step 2, then {{signature}}, then the opt out anchor", "disabled": false}],
"next": {
"stepType": "EMAIL", "identities": [], "delay": {"amount": 10, "unit": "DAYS"},
"versions": [{"subject": "who gets paged", "message": "sequence.md step 3, then {{signature}}, then the opt out anchor", "disabled": false}],
"next": {"stepType": "END_OF_CAMPAIGN", "identities": [], "delay": {"amount": 1, "unit": "DAYS"}}
}
}
},
"no": {"stepType": "END_OF_CAMPAIGN", "identities": [], "delay": {"amount": 1, "unit": "DAYS"}}
}
}
}
}
}
The InMail of the stock template sat where end4 is. Keep it instead of the
END_OF_CAMPAIGN when the account has credits, as an INMAIL step with a
versions[0].subject and a versions[0].message.
smart-multi-channel:
{
"template": "smart-multi-channel",
"template_changes": ["added END_OF_CAMPAIGN on the bounced with no profile branch"],
"channels": ["email", "linkedin"],
"steps": {
"_id": "START", "stepType": "START", "identities": [], "delay": {"amount": 0, "unit": "DAYS"},
"next": {
"stepType": "EMAIL", "identities": [], "delay": {"amount": 0, "unit": "DAYS"},
"versions": [{"subject": "status page vs reality", "message": "sequence.md step 1, then {{signature}}, no opt out link", "disabled": false}],
"next": {
"stepType": "CONDITION", "identities": [], "delay": {"amount": 1, "unit": "DAYS"},
"conditions": {"field": "BOUNCED", "operator": "EQUAL", "value": "true"},
"yes": {
"stepType": "CONDITION", "identities": [], "delay": {"amount": 1, "unit": "DAYS"},
"conditions": {"field": "$linkedinUrlProfile", "operator": "IS_NOT_EMPTY", "value": ""},
"yes": {
"stepType": "CONNECTION", "identities": [], "delay": {"amount": 1, "unit": "DAYS"},
"versions": [{"message": ""}],
"next": {
"stepType": "MESSAGE", "identities": [], "delay": {"amount": 1, "unit": "DAYS"},
"versions": [{"message": "sequence.md LinkedIn message 1"}]
}
},
"no": {"stepType": "END_OF_CAMPAIGN", "identities": [], "delay": {"amount": 0, "unit": "DAYS"}}
},
"no": {
"stepType": "EMAIL", "identities": [], "delay": {"amount": 1, "unit": "DAYS"},
"versions": [{"subject": "", "message": "sequence.md step 2, then {{signature}}, then the opt out anchor", "disabled": false}],
"next": {
"stepType": "EMAIL", "identities": [], "delay": {"amount": 5, "unit": "DAYS"},
"versions": [{"subject": "who gets paged", "message": "sequence.md step 3, then {{signature}}, then the opt out anchor", "disabled": false}],
"next": {
"stepType": "CONDITION", "identities": [], "delay": {"amount": 1, "unit": "DAYS"},
"conditions": {"field": "$linkedinUrlProfile", "operator": "IS_EMPTY", "value": ""},
"yes": {
"stepType": "EMAIL", "identities": [], "delay": {"amount": 1, "unit": "DAYS"},
"versions": [{"subject": "", "message": "sequence.md step 4, the break up, then {{signature}}, then the opt out anchor", "disabled": false}]
},
"no": {
"stepType": "CONNECTION", "identities": [], "delay": {"amount": 2, "unit": "DAYS"},
"versions": [{"message": ""}],
"next": {
"stepType": "MESSAGE", "identities": [], "delay": {"amount": 1, "unit": "DAYS"},
"versions": [{"message": "sequence.md LinkedIn message 1"}],
"next": {
"stepType": "MESSAGE", "identities": [], "delay": {"amount": 5, "unit": "DAYS"},
"versions": [{"message": "sequence.md LinkedIn message 2"}]
}
}
}
}
}
}
}
}
}
}
The LinkedIn steps in both multichannel trees carry the copy written by the LinkedIn prompt, not the email copy translated. A LinkedIn message is unsigned, has no subject and ends on a question: outreach-write section 1, under "Mode A on LinkedIn".
2. The steps that exist
These are Emelia's real step types. Use these exact values in campaign.json.
stepType | Channel | Needs | Notes |
|---|---|---|---|
START | none | nothing | The entry point, always the root, delay 0 |
EMAIL | an email identity, versions[].subject and versions[].message | message is the whole body, assembled as in section 4. Leave the subject empty to stay in the thread: Emelia reuses the last subject, prefixes it with Re: , and quotes the history underneath | |
VISIT | a LinkedIn identity | A profile view. Costs nothing, notifies the prospect, warms an invitation | |
CONNECTION | a LinkedIn identity | The invitation. versions[0].message is the note, capped at 300 characters, and an empty note usually gets accepted more often than a pitched one | |
MESSAGE | an accepted connection | The direct message. Useless before an acceptance, and it waits for one on its own when it follows a CONNECTION | |
INMAIL | InMail credits on the account | Paid, use it on the rows the invitation did not reach. One version carrying subject and message | |
LIKE, FOLLOW | a LinkedIn identity | Light touches, low value on their own, fine as a warm up | |
AUDIO | a LinkedIn identity | A voice note. High reply rate, does not scale, keep it for a short list | |
TASK | human | nothing | The manual step, and this is the call step. Its name is versions[0].subject |
CONDITION | none | conditions plus yes and no | The branch, see below |
API_CALL | none | a URL | Fires a webhook, use it to write into your CRM |
END_OF_CAMPAIGN | none | nothing | Ends the sequence for this contact |
Every step carries _id, stepType, identities, delay and a link to what comes
next. The tree is chained through next, and a CONDITION chains through yes and
no instead.
The email body is HTML, every LinkedIn body is plain text. An EMAIL version's
message is light HTML: <p> paragraphs, <p></p> for a blank line. A MESSAGE,
CONNECTION or INMAIL version's message is plain text, where a line break is a real
\n and a <p> would be shown to the prospect as the characters <p>.
Attachments hang off a version as attachments: [{name, url}], uploaded first with
POST /advanced/attachments. On LinkedIn each one also carries mediaType, which is
MEDIA or ATTACHMENT for a file or an image and AUDIO for a voice note, uploaded
with POST /advanced/linkedin/audio and carrying a duration in seconds. On email,
prefer a link: an attachment costs deliverability on a first touch.
disabled: true on a version keeps it in the campaign and takes it out of the A/B
rotation. That is how you retire the losing variant without deleting the history that
proved it lost.
identities on a step is always []. It is a leftover field in the schema and the
scheduler never reads it. Who sends is decided at campaign level: Emelia keeps only the
identities that can do that channel, a mailbox for an EMAIL step and a LinkedIn account
for a LinkedIn step, and among those it picks the least used, so the volume spreads on its
own. Once a contact has been touched, it stays with the identity that touched it, which is
what keeps the follow ups in the same thread and the LinkedIn messages under the same
account. Writing an address or an account name in a step's identities does nothing at
all, and reads as if it worked.
Do not write your own _id. Omit it and let the server generate it. That is not a
style preference, it is the difference between a campaign that runs and one that
silently re-sends.
The steps field is stored as Mixed, so Mongoose saves whatever shape you send. A
_id of "e1", or a UUID, or anything else, is accepted, saved, and reads back
perfectly from GET /advanced/campaigns/{id}. The campaign looks right in the app.
It breaks later, at send time. The activity record that logs the send declares
stepId as an ObjectId, and it is required. A value that cannot be cast to a
24 character hex ObjectId raises a CastError, and that write happens after the
message has left. So the email is sent, the activity is not stored, and from the
scheduler's point of view the step never happened: it plans it again, and the same
person receives the same email on the next pass. Every condition that depends on that
step is stuck too, because it looks for the origin activity and finds nothing, so the
contact waits at the branch forever. From the outside it reads as "all the actions are
failing".
versionId has a rescue setter that turns an uncastable value into undefined, so a
bad version id only costs you the per variant statistics. stepId has no such setter.
Two consequences:
- A new tree ships without ids.
ensureStepIdswalks the tree on save, including theyesandnobranches, and generates a real ObjectId for every step and every version that has none. The only_idyou write by hand is"START"on the root, which is the schema's own default and never produces an activity because it is not an action. - An existing tree keeps the ids it has. Read it with
GET, change what you need in the object you just read, send the whole thing back. Every activity, every per version statistic and every contact's position in the sequence hangs off those ids, and regenerating them restarts people who were already deep in the sequence.
If you inherited a campaign built with invented ids, the fix is not to patch the ids in
place while it runs. Pause it, read the tree, strip every _id except the root, send it
back so the server regenerates them, and accept that the contacts already in flight lose
their position. Anything else leaves a campaign that keeps re-sending.
The manual call task is worth its own line. A TASK step parks the contact and shows
up in the Emelia app as a pending task with the name you gave it. When the MCP server
is configured, list_manual_tasks lists them and complete_manual_task closes one
with ok or ko. Write the task name as an instruction, not a label: "Call the
mobile, ask who owns response monitoring" beats "Call".
3. Conditions and branches
A CONDITION step holds conditions with three fields, plus a yes branch and a no
branch:
{ "field": "ACCEPTED", "operator": "EQUAL", "value": "true" }
There are two families.
Activity conditions. The field is an event name in capitals and the value is
"true". Available: ACCEPTED, MAIL_REPLIED, LINKEDIN_REPLIED, OPENED,
CLICKED, UNSUBSCRIBED, BOUNCED, TASK_COMPLETED.
Contact field conditions. The field starts with a dollar sign and names a contact
field or a custom variable, for example $jobTitle or $icebreaker. Operators:
EQUAL, DOES_NOT_EQUAL, GREATER, LOWER, CONTAINS, DOES_NOT_CONTAIN,
IS_EMPTY, IS_NOT_EMPTY. This is how you route rows that have an icebreaker down one
path and rows that do not down another.
The trap that catches everyone: the condition's delay is the waiting window, and it
counts from the originating action, not from the condition. Each activity has an
originating action: ACCEPTED counts from the invitation, MAIL_REPLIED, OPENED,
CLICKED, UNSUBSCRIBED and BOUNCED count from the send, LINKEDIN_REPLIED counts
from the message or InMail, TASK_COMPLETED counts from the task. Until that window
expires, the contact sits pending and nothing else happens to them. So a 7 day window
on "invitation accepted" holds every unaccepted contact for 7 days before the no
branch runs. Choose the window as the amount of time you are willing to lose, not as a
generous guess.
Two more mechanical facts: a delay.amount of zero or less is treated as one unit, and
a TASK_COMPLETED condition is forced to a 30 day window whatever you write.
A condition on an event the campaign does not track is never true. OPENED needs
trackOpens on, CLICKED needs trackLinks on. With the flag off the event is never
recorded, so the condition waits out its window and sends every contact down the no
branch. It fails silently, and it looks like nobody opened anything.
Do not branch on OPENED. Apple Mail Privacy Protection pre-fetches images, so a
recorded open does not mean a human read anything, and a contact who never saw your
email will take the "opened" branch. Branch on CLICKED, on a reply, on ACCEPTED, or
on a contact field. This is also why trackOpens is set to false in the schedule
below: an inflated open rate is worse than no open rate, because it hides the truth
from outreach-audit.
Branches never rejoin. The tree has no merge. Anything that must happen after both arms has to be written twice, once in each arm. Keep branches short for that reason.
Branches worth building, in order of usefulness:
- Invitation accepted or not: LinkedIn message on
yes, email follow-up onno. - Call task done or not: on
kothe contact goes back into the email path. - Clicked but no reply: change the ask, not the angle. They are interested and the question was wrong.
- Bounced: end the campaign for that contact, and blacklist the domain if several rows from it bounce.
- Icebreaker empty or not, when the copy has an opener that only works with one.
4. What goes in the body of an email step
versions[].message is the whole body of that email, and it is three blocks separated by
a blank line, in this order. outreach-write section 10 has
the detail and the reasoning, this is the part the flow has to get right.
- The copy. Either the written text (Mode B) or the custom variable that holds the
message written for that contact (Mode A, usually
{{message}}, and nothing else around it). {{signature}}, on its own line. Lowercase only:{{Signature}}silently renders nothing. It pulls the signature of the identity that sends, so the same step signs correctly from every mailbox you attach.- The opt out link, from step 2 on, as a real anchor and never a bare variable:
<p><a href="{{unsubscribe_link}}">Unsubscribe</a></p>
Four flow level consequences:
- Step 1 carries no opt out link, every later email step does. That is the default in
this repository. The reasoning is in outreach-write
section 10, along with what it costs and how to override it with
Opt-out: every step. Record the choice incampaign.jsonunderopt_outsooutreach-campaignbuilds what you decided rather than what it assumes. - The opt out link is exempt from
trackLinks. Every otherhrefis rewritten as a redirect. This one keeps its own URL, so it does not spend the one link a step is allowed and it does not look like a redirect to a filter. {{unsubscribe_link}}is what sets theList-Unsubscribeheader, per step. A step without the variable goes out without the header, which is exactly what the step 1 rule means in practice. Say that to the user rather than letting them find out.- Every A/B version of a step needs all three blocks. Versions are separate bodies: a signature or an opt out link added to version A only is a bug you will read as a result.
Two fields on an email step that are easy to reach for and worth knowing about:
ccsits on the step, not on the version, and puts an address in carbon copy. It accepts a variable, so{{assistantEmail}}copies each contact's own assistant. A value that does not resolve to a valid address is dropped without an error, which is the behaviour you want here rather than a failed send.rawHtmlexists in the version schema and is not read by the multichannel sending path. Setting it changes nothing. If you need the HTML sent untouched, that is not available on this campaign type, so write the body as the light HTML the engine expects:<p>paragraphs,<p></p>for a blank line, one anchor.
5. Delays that work, and why
| Between | Delay | Why |
|---|---|---|
| Email 1 and email 2 | 3 business days | Under 2 days the first is often still unread. Past 5 it has left the first screen |
| Email 2 and email 3 | 4 to 5 business days | Long enough to be a new conversation, short enough to be remembered |
| Email 3 and the break-up | 6 to 8 business days | The break-up works because time passed |
| Profile visit and invitation | 12 to 24 hours | The view must register before the invitation, or it looks like one automated burst |
| Invitation accepted and first message | at least 24 hours | Messaging within minutes of an acceptance reads as a bot, and it is the fastest way to get reported |
| Any LinkedIn action and the next one | 1 day minimum | LinkedIn rate limits per account, not per campaign |
A four step email sequence therefore spans 14 to 21 calendar days. Longer sequences do not earn more meetings, they earn unsubscribes.
The unit is MINUTES, HOURS or DAYS, and the delay is spent against the campaign's
open days and hours: a 3 DAYS delay on a Monday to Friday schedule lands on the next
open day, not on a Saturday. So a 6 day delay on a schedule that only opens Tuesday to
Thursday is nearly two real weeks. That is the intended behaviour, not a bug, and it is
worth saying to the user when you show them the plan.
The delay on the first action is ignored. When the sequence starts is decided by the
daily quota (dailyEmailAdded, dailyLinkedinAdded), not by the delay you put on step
one. Write {"amount": 0, "unit": "DAYS"} there and set the pace in the settings.
On a CONDITION the delay means something else entirely: it is the observation
window, how long the engine waits for the event before taking the no branch. See
section 3.
A MESSAGE that follows a CONNECTION waits for the invitation to be accepted on its
own, without a condition step in between. The delay you set applies after the
acceptance, not after the invitation was sent. Adding an ACCEPTED condition on top is
still worth it when you want the people who never accepted to go somewhere else, since
without it they simply stop.
Schedule fields and their defaults in Emelia, so you know what you are changing:
| Field | Default | What to do |
|---|---|---|
timeZone | Europe/Brussels | Set it to the recipients' timezone, not yours |
days | [0,1,2,3,4] where 0 is Monday | Keep Monday to Friday |
start, end | 08:00 and 17:00 | Match a working day in the recipients' timezone |
dailyEmailAdded | 35, range 1 to 500 | New contacts entering the email track each day. This is the ramp dial |
dailyEmailLimit | 100, range 1 to 500 | All emails sent per day, follow-ups included. Set it from what the mailboxes can carry, which outreach-deliverability decides. Must stay at or above dailyEmailAdded, or there is no room left for the follow-ups and they never go out |
dailyLinkedinAdded | 20 | New contacts entering the LinkedIn track each day. Capped at 40 when the sequence contains a CONNECTION, 80 without |
trackOpens | false | Leave it off: it adds a pixel to a cold email and the number it produces cannot be trusted. Turning it on is the prerequisite for an OPENED condition, which is a reason not to build one |
trackLinks | false | Turn it on only if you need click data, and accept that your links become redirects. It is the prerequisite for a CLICKED condition: without it the condition can never be true and every contact takes the no branch |
blacklistUnsub | false | Turn it on. An unsubscribe then blacklists the address account wide, not just for this campaign |
excludeAlreadyMessaged | off | Turn it on whenever the list overlaps a previous campaign |
ignoreAutoReplies | false | Turn it on, otherwise every out of office counts as a reply and stops the contact |
alwaysOn | false | Leave it off. On, it ignores days and start/end and sends around the clock |
eventToStop | empty | See section 7 |
trackLinks, trackOpens and blacklistUnsub are required in every call to
PATCH /settings, even when you are not changing them: the route replaces the whole
object, so omitting one resets it to its default.
On LinkedIn volume: keep new invitations well under the platform's weekly ceiling. A widely used rule of thumb is around 100 invitations per week on an established account, and far less on a new one, so 15 to 20 a day established and 5 to 10 a day on a young account. That is a rule of thumb, not a documented limit, and the cost of being wrong is the account.
Emelia draws the same line in its own cadence screen, and the thresholds are worth
knowing because you will see them: with an invitation step in the tree,
dailyLinkedinAdded above 20 raises a warning and above 40 an error; without one, so a
LinkedIn track made of visits, likes and messages only, the warning starts at 40; above
80 it is an error whatever the tree contains. Read that as the product's own opinion of
what is safe, and treat 20 a day with invitations as the ceiling rather than the target.
6. A clean A/B test
Email only. There is no A/B test on LinkedIn, and getting this wrong sends every
variant to the same person. The product uses the same versions array on both, for
two opposite behaviours, verified in the sending code on 9 September 2026:
| Step | What versions means | What the sender does |
|---|---|---|
EMAIL | A/B variants | The job carries one versionId. Each contact receives one version |
MESSAGE on LinkedIn | Sub-messages | The sender loops over every version and sends them all, 3.5 to 6.2 seconds apart |
Sub-messages exist to look like someone typing in several goes. So three "variants" on a LinkedIn step means one person receives three messages back to back, in about fifteen seconds. Never put variants on a LinkedIn step.
When a user asks for an A/B test on LinkedIn, explain rather than refuse flatly: a test only says something with volume. On email you send a thousand a day, so two hundred per variant, and after a few days a gap means something. On LinkedIn the quotas put you at a handful of touches a day, and comparing five people to five people measures noise, not copy. Test on email, then carry what wins over to LinkedIn.
The mechanism, on email. An email step holds versions, an array. Each version has
its own _id, subject, message and disabled flag. Emelia splits the contacts
across the enabled versions, and the activity feed accepts a versionId filter, so
results are attributable per version. Spintax is not: do not confuse the two.
Up to five variants. Two is the usual case, five is the ceiling worth running. More variants means more angles tested at once, which is the right move in exactly two situations: you have no per row data so personalization is off the table, and you have so much volume that writing one message per contact would cost more than it returns. Nobody writes a hundred thousand personalized emails. Five angles across a hundred thousand contacts, on the other hand, teaches you which one to keep. You can also split variants by company size rather than at random, and read each band separately.
Test approaches, not synonyms. Two versions that say the same thing in different
words cost a full send and teach you nothing. A variant is worth running when a win
changes what you write next: a different angle, a different promise, a different ask, a
different kind of proof. The table of what to vary, and what a win in each case actually
tells you, is in outreach-write section 1, under "Mode B".
Write both versions there, and bring them here as versions[].
One variable at a time. If A and B differ in the subject and the body, a win tells you nothing you can reuse. Test in this order, because this is the order of effect size: the angle, then the ask, then the proof, then the subject, then the send time.
In Mode A there is no A/B test on the body. Every contact gets a different message, so there is nothing to hold constant and nothing to attribute. You can still test the subject line, the delays or the channel mix. Say that rather than declaring a test that cannot be read.
Sample size, so the result means something. These are the contacts needed per version to detect a difference at the usual 95% confidence and 80% power. They come from the standard two proportion formula, not from anyone's dashboard.
| Difference you want to detect | Contacts per version |
|---|---|
| 5% against 6% replies | about 8,200 |
| 5% against 7% replies | about 2,200 |
| 5% against 10% replies | about 435 |
| 3% against 6% replies | about 750 |
| 50% against 55% opens | about 1,600 |
| 50% against 60% opens | about 390 |
Read the top row again: on a list of 1,000 you cannot detect a one point difference in reply rate, and no amount of staring at the dashboard changes that. So on a small list, either test something big enough to move the rate by half again, or do not run a test: run one version, call it a learning campaign, and say so.
Duration. Cold email replies mostly arrive within the first 3 business days after a send. Do not read a step's result until every contact in the test has had at least 5 business days past that step. A step 1 test is not final until the follow-ups have run, because a weak first email can be rescued by the second.
No peeking. Write the sample size and the metric into campaign.json before the
launch and hold to them. Checking a 5% base rate repeatedly and stopping at the first
gap produces a "winner" by chance often enough that the habit is worse than not testing.
The metric is replies, then positive replies. Not opens, for the Apple reason above.
7. Stop conditions
Two layers. The first is Emelia's, in schedule.eventToStop: the events that take a
contact out of the campaign. A sane default for email plus LinkedIn is
["MAIL_REPLIED", "LINKEDIN_REPLIED", "UNSUBSCRIBED", "BOUNCED"]. Add CLICKED only
when a click means a human takes over. Never add OPENED.
The second layer is yours, and it stops the campaign rather than a contact. Write these
into stop_rules and check them in outreach-audit:
| Signal | Threshold | What you do |
|---|---|---|
| Bounce rate on a running campaign | above 3% | Pause. The list was not verified properly, and every further send costs reputation |
| Unsubscribe rate | above 1.5% | Pause. This is a targeting problem, not a copy problem |
| Reply rate after 300 sends | under 1% | Pause. The offer or the segment is wrong, and a fourth variant will not fix it |
| Spam complaints | any | Stop. This is the domain, not the campaign |
| A step producing more unsubscribes than replies | any | Cut that step |
8. Write the file, then show what will be built
Write outreach/campaign.json, then print the step tree as an indented list in the
conversation, with the delays, so the user can read the sequence before anything is
pushed. Name the template you started from and the changes you made to it. Then flag the
three things that are easy to get wrong: the timezone, dailyEmailAdded, and the
condition windows.
outreach-campaign reads this file and pushes it with
PATCH /advanced/campaigns/{id}/steps. The user reads the tree here, not in the
interface, so this printout is their last look before it exists.
Nothing is created in Emelia by this skill. outreach-campaign does that, and it asks
before it does.
Output
outreach/campaign.json. Everything under schedule and steps uses Emelia's own
field names and values, so it can be read straight into the app or into a future API.
The blocks outside them (goal, template, ab_test, stop_rules, emelia) are this
repository's, and carry the decisions the platform does not store.
{
"spec_version": "1",
"name": "FR SaaS CTOs, response monitoring, Q4",
"copy_source": "outreach/sequence.md",
"list_source": "outreach/leads.csv",
"template": "smart-multi-channel",
"template_changes": [
"branch on ACCEPTED rather than BOUNCED: the list is verified, bounces are near zero",
"profile visit added before the invitation",
"call task added on the clicked branch",
"END_OF_CAMPAIGN added where the stock tree stopped silently"
],
"coverage_at_design_time": { "contactable": 1000, "with_email": 0.96, "with_linkedin": 0.41 },
"channels": ["email", "linkedin", "call"],
"goal": { "metric": "replies", "target_rate": 0.05, "contacts": 1000 },
"copy_mode": "B",
"opt_out": { "placement": "from_step_2", "wording": "Unsubscribe from these emails" },
"identities": [],
"recipients": {
"list_name": "FR SaaS CTOs Q4",
"lists": [],
"excludedLists": ["Customers", "Open opportunities"]
},
"schedule": {
"timeZone": "Europe/Paris",
"days": [0, 1, 2, 3, 4],
"start": "08:30",
"end": "17:30",
"dailyEmailAdded": 30,
"dailyEmailLimit": 200,
"dailyLinkedinAdded": 15,
"trackOpens": false,
"trackLinks": true,
"excludeAlreadyMessaged": true,
"ignoreAutoReplies": true,
"eventToStop": ["MAIL_REPLIED", "LINKEDIN_REPLIED", "UNSUBSCRIBED", "BOUNCED"]
},
"steps": {
"_id": "START",
"stepType": "START",
"identities": [],
"delay": { "amount": 0, "unit": "DAYS" },
"next": {
"stepType": "EMAIL",
"identities": [],
"delay": { "amount": 0, "unit": "DAYS" },
"versions": [
{ "subject": "status page vs reality", "message": "sequence.md step 1, variant A, ends with {{signature}}, no opt out link", "disabled": false },
{ "subject": "status page vs reality", "message": "sequence.md step 1, variant B, ends with {{signature}}, no opt out link", "disabled": false }
],
"next": {
"stepType": "VISIT",
"identities": [],
"delay": { "amount": 1, "unit": "DAYS" },
"next": {
"stepType": "CONNECTION",
"identities": [],
"delay": { "amount": 1, "unit": "DAYS" },
"versions": [{ "message": "" }],
"next": {
"stepType": "CONDITION",
"identities": [],
"delay": { "amount": 5, "unit": "DAYS" },
"conditions": { "field": "ACCEPTED", "operator": "EQUAL", "value": "true" },
"yes": {
"stepType": "MESSAGE",
"identities": [],
"delay": { "amount": 1, "unit": "DAYS" },
"versions": [{ "message": "sequence.md step 2, LinkedIn wording" }]
},
"no": {
"stepType": "EMAIL",
"identities": [],
"delay": { "amount": 3, "unit": "DAYS" },
"versions": [{ "subject": "", "message": "sequence.md step 2, then {{signature}}, then the opt out anchor" }],
"next": {
"stepType": "EMAIL",
"identities": [],
"delay": { "amount": 5, "unit": "DAYS" },
"versions": [{ "subject": "who gets paged", "message": "sequence.md step 3, then {{signature}}, then the opt out anchor" }],
"next": {
"stepType": "CONDITION",
"identities": [],
"delay": { "amount": 3, "unit": "DAYS" },
"conditions": { "field": "CLICKED", "operator": "EQUAL", "value": "true" },
"yes": {
"stepType": "TASK",
"identities": [],
"delay": { "amount": 1, "unit": "DAYS" },
"versions": [{ "subject": "Call the mobile, ask who owns response monitoring" }]
},
"no": {
"stepType": "EMAIL",
"identities": [],
"delay": { "amount": 7, "unit": "DAYS" },
"versions": [{ "subject": "", "message": "sequence.md step 4, then {{signature}}, then the opt out anchor" }],
"next": {
"stepType": "END_OF_CAMPAIGN",
"identities": [],
"delay": { "amount": 0, "unit": "DAYS" }
}
}
}
}
}
}
}
}
}
},
"ab_test": {
"step_id": "s1-email",
"variable": "the ask",
"versions": { "v-a": "question about ownership", "v-b": "interest based, the two minute version" },
"metric": "replies",
"min_contacts_per_version": 435,
"min_days_after_last_entry": 5,
"decision_rule": "read it only when both versions have 435 contacts and 5 quiet days, then take the higher reply rate. No peeking before that."
},
"stop_rules": {
"bounce_rate_above": 0.03,
"unsubscribe_rate_above": 0.015,
"reply_rate_below_after_300_sends": 0.01,
"spam_complaints_above": 0,
"action": "pause and report, do not continue"
},
"emelia": {
"campaignId": null,
"listId": null,
"status": "draft",
"built_in_app": false,
"note": "POST /advanced/campaigns creates the name only. The tree above is the build sheet for the Emelia app. outreach-campaign creates the campaign and the list, then pushes contacts into the list, and each contact added enters the running campaign."
}
}
In this example the accepted branch deliberately ends after the LinkedIn message: they are a connection now, and the rest happens by hand. That is also the only honest way to handle a branch, since branches never rejoin.
Checks before finishing
- The file is valid JSON, the tree has exactly one
STARTroot, and every_idis unique. templatenames one of the five ids, or isnullwith a written reason, and the two coverage percentages that led to that choice were said out loud to the user.template_changeslists every deviation from the stock tree, including the deletions. An empty array means the tree really is the template, not that nobody checked.- If the template is
linkedin-emailorsmart-multi-channel, the conditions use$emailand$linkedinUrlProfile, and you told the user those are Emelia contact fields that the import has to fill. - No
INMAILstep survives unless the user confirmed the seat has InMail credits. - No step is left with an empty
versions[0].messagewhere the template had a placeholder. - Every
stepTypeis one of the values in the table, and everyCONDITIONhas both ayesand ano. - Every message step has at least one version with a non-empty
message, and everyEMAILstep that starts a new thread has a subject. - Every version of every
EMAILstep ends with{{signature}}, lowercase, and every version of the same step carries the same blocks as its sibling. - The opt out link matches
opt_out.placement: absent from the first email step and present on every later one by default, or present everywhere if that was the choice. It is an<a href="{{unsubscribe_link}}">with a short text, never a bare variable. python3 scripts/check-copy.py outreach/sequence.md outreach/leads.csvexits 0 on the copy this tree points at.- Every step's copy exists in
outreach/sequence.md, and no step references copy that was never written. - The condition windows are deliberate, and you told the user how long each one parks a contact.
dailyEmailAddedtimes the number of days is not larger than the list, and the daily volume matches whatoutreach-deliverabilityallows.eventToStopcontains at least the replies, unsubscribes and bounces, and does not containOPENED.- If an A/B test is declared,
min_contacts_per_versiontimes the number of versions is at most the list size. If it is not, say the test is underpowered and offer to run one version instead. - The user has seen the indented tree and said yes.
Failure modes
The whole list goes down one branch of a multichannel template. The condition tests
$linkedinUrlProfile or $email, and the import put the value somewhere else, so the
field is empty on every row. Nothing errors. Open one contact in the app, check the
field is the standard one and not a custom field with a similar name, and reload the
list rather than editing the tree.
The LinkedIn half of the campaign never fires. Check the polarity of the condition
before anything else. In smart-multi-channel the second condition is
$linkedinUrlProfile IS_EMPTY, so LinkedIn hangs off the no branch. Copying that step
into another tree and reading it as "has a profile" inverts your whole audience.
The template was picked from the campaign name rather than the data. "LinkedIn then Email" on a list where 30% have a profile means 70% of the contacts stop at step one. Run the count in section 1 first, every time, even when the user names a template.
Nothing happens for a week and the user thinks it is broken. The ACCEPTED condition
window in the LinkedIn templates is 5 days, and every contact who has not accepted waits
it out before the fallback runs. Say the number when you hand over the build sheet.
The campaign is much longer than the card said. The "~15 days" on a template card is an approximation written in the product, not the sum of the branch. Add the delays along the branch the contact actually takes, condition windows included, and quote that.
The flow is longer than the list can feed. Ten steps over 30 days on 200 contacts produces a trickle and no signal. Fewer steps on more contacts beats the opposite.
The condition never fires and everyone is stuck. The window was set on the
condition but counts from the originating action, and that action never happened for
those contacts: no invitation sent means no ACCEPTED to wait for. Check that the step
before the condition is the one that produces the originating event.
The LinkedIn steps are designed and there is no LinkedIn account. Ask before you design, not after. Email only is a perfectly good sequence.
Both A/B versions win, depending on the day. That is what an underpowered test looks like. Go back to the sample size table and either wait or stop calling it a test.
The campaign is built in the app but contacts do not enter it. They were added to a list that is not the one attached to the campaign. Check the attached list id before pushing anything.
Opens look great and there are no replies. If trackOpens was left on, part of that
number is Apple pre-fetching images. Turn it off, and read the reply rate instead.
One A/B version has a footer and the other does not. Versions are separate bodies, so a block added to one is missing from the other. Whatever wins, you cannot say why. Diff the two versions block by block before the launch.
Recipients complain there is no way out. The opt out link is on steps 2 and later by
design, so a contact who only ever received step 1 never saw one. That is the accepted
cost of the rule, and the reply path still works. If the user is not comfortable with it,
switch to Opt-out: every step rather than arguing.
Limits
This skill does not create anything in Emelia and does not send anything: it writes a
file and a build sheet, and outreach-campaign does the rest with an explicit
confirmation. The five templates are the ones the product shipped on 9 September 2026,
read from its own template file: if the app shows a sixth, or different delays, trust the
app and say the skill is behind. It cannot build the step tree through the API, because that endpoint does
not exist, and it will say so rather than pretending. It does not know your mailbox
capacity: the daily numbers here are proposals that outreach-deliverability has the
final word on. The delay and volume figures are rules of thumb from common practice, not
measurements from your account, and outreach-audit is what replaces them with yours.
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.
- outreach-sequence-builder by Varnan-Tech · 645
- cold-outreach-sequence by BrianRWagner · 416
- outreach-sequence-builder by Othmane-Khadri · 56
- sales-outreach by zubair-trabzada · 1,318
- apollo-outreach by OpenClaudia · 691
- email-sequence by OpenClaudia · 691
- sales-sequence-builder by zapier · 336
- cold-outreach by shawnpang · 324
Need help setting it up?
This page tells you what outreach-sequence 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.