Systems Lab

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.

activeSelf-containedActs undeclared9,479 words

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-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-sequence

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

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

InputWhere it comes fromIf it is missing
The copy, step by stepoutreach/sequence.mdStop, design a flow around copy that exists
Contact count and segmentsoutreach/leads.csvAsk, the count drives the A/B decision
Email and LinkedIn coverage on the contactable rowsoutreach/leads.csv, counted as in section 1Count it yourself, do not ask. It is what picks the template
Sending identitiesthe user, or list_email_providers on the MCP serverAsk for the mailbox addresses and the LinkedIn account
Channels the user actually hasthe userEmail only is a valid design, say so and drop the LinkedIn steps
InMail credits on the LinkedIn seatthe userAssume none, and cut the InMail step rather than designing a branch that cannot fire
Timezone of the recipientsoutreach/icp.json or the userDefault 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.

idName in the appChannelShapeWhat it assumes you have
pure-cold-emailPure Cold EmailEmail only5 emails, 43 daysA verified address for everybody
pure-linkedinPure Linkedin (for basic account)LinkedIn onlyInvitation, message, message, like, message, 11 daysA profile URL for everybody, and no InMail credits
linkedin-premium-or-sales-navigatorPure Linkedin (for Premium Account)LinkedIn onlyInvitation, then messages if accepted, InMail if notA profile URL for everybody, plus Premium or Sales Navigator with InMail credits
linkedin-emailLinkedIn then EmailMultichannelLinkedIn first, email later for the rows that have an addressA profile URL for everybody, an address for some
smart-multi-channelEmail then LinkedInMultichannelEmail first, LinkedIn later for the rows that have a profileAn 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 coverageLinkedIn coverageTemplateWhy
Near everyoneFewpure-cold-emailThere is no second channel to switch to
FewNear everyone, basic accountpure-linkedinThe invitation is the only door you have
FewNear everyone, Premium or Sales Navigatorlinkedin-premium-or-sales-navigatorThe InMail is what reaches the rows that never accept
SomeNear everyonelinkedin-emailStart where everyone is, switch to email for the rows that have one
Near everyoneSomesmart-multi-channelStart where everyone is, add LinkedIn for the rows that have a profile
Near everyoneNear everyoneeither multichannelBoth work. Pick the channel this segment answers on, and write down which you picked and why
FewFewnoneGo 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

  1. pure-cold-email ships 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.
  2. The condition windows park everybody. The 5 day window on ACCEPTED in 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.
  3. 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-email runs about 48 days, not 15. Compute the branch, do not repeat the card.
  4. The last message of pure-linkedin fires 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.
  5. The templates carry no stop conditions and no schedule. They are a tree. The eventToStop list, 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 fieldEmelia contact fieldWhat has to land in it
$emailemailthe address from outreach/leads.csv
$linkedinUrlProfilelinkedinUrlProfilethe 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.

stepTypeChannelNeedsNotes
STARTnonenothingThe entry point, always the root, delay 0
EMAILemailan email identity, versions[].subject and versions[].messagemessage 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
VISITLinkedIna LinkedIn identityA profile view. Costs nothing, notifies the prospect, warms an invitation
CONNECTIONLinkedIna LinkedIn identityThe invitation. versions[0].message is the note, capped at 300 characters, and an empty note usually gets accepted more often than a pitched one
MESSAGELinkedInan accepted connectionThe direct message. Useless before an acceptance, and it waits for one on its own when it follows a CONNECTION
INMAILLinkedInInMail credits on the accountPaid, use it on the rows the invitation did not reach. One version carrying subject and message
LIKE, FOLLOWLinkedIna LinkedIn identityLight touches, low value on their own, fine as a warm up
AUDIOLinkedIna LinkedIn identityA voice note. High reply rate, does not scale, keep it for a short list
TASKhumannothingThe manual step, and this is the call step. Its name is versions[0].subject
CONDITIONnoneconditions plus yes and noThe branch, see below
API_CALLnonea URLFires a webhook, use it to write into your CRM
END_OF_CAMPAIGNnonenothingEnds 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. ensureStepIds walks the tree on save, including the yes and no branches, and generates a real ObjectId for every step and every version that has none. The only _id you 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:

  1. Invitation accepted or not: LinkedIn message on yes, email follow-up on no.
  2. Call task done or not: on ko the contact goes back into the email path.
  3. Clicked but no reply: change the ask, not the angle. They are interested and the question was wrong.
  4. Bounced: end the campaign for that contact, and blacklist the domain if several rows from it bounce.
  5. 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.

  1. 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).
  2. {{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.
  3. 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 in campaign.json under opt_out so outreach-campaign builds what you decided rather than what it assumes.
  • The opt out link is exempt from trackLinks. Every other href is 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 the List-Unsubscribe header, 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:

  • cc sits 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.
  • rawHtml exists 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

BetweenDelayWhy
Email 1 and email 23 business daysUnder 2 days the first is often still unread. Past 5 it has left the first screen
Email 2 and email 34 to 5 business daysLong enough to be a new conversation, short enough to be remembered
Email 3 and the break-up6 to 8 business daysThe break-up works because time passed
Profile visit and invitation12 to 24 hoursThe view must register before the invitation, or it looks like one automated burst
Invitation accepted and first messageat least 24 hoursMessaging within minutes of an acceptance reads as a bot, and it is the fastest way to get reported
Any LinkedIn action and the next one1 day minimumLinkedIn 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:

FieldDefaultWhat to do
timeZoneEurope/BrusselsSet it to the recipients' timezone, not yours
days[0,1,2,3,4] where 0 is MondayKeep Monday to Friday
start, end08:00 and 17:00Match a working day in the recipients' timezone
dailyEmailAdded35, range 1 to 500New contacts entering the email track each day. This is the ramp dial
dailyEmailLimit100, range 1 to 500All 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
dailyLinkedinAdded20New contacts entering the LinkedIn track each day. Capped at 40 when the sequence contains a CONNECTION, 80 without
trackOpensfalseLeave 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
trackLinksfalseTurn 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
blacklistUnsubfalseTurn it on. An unsubscribe then blacklists the address account wide, not just for this campaign
excludeAlreadyMessagedoffTurn it on whenever the list overlaps a previous campaign
ignoreAutoRepliesfalseTurn it on, otherwise every out of office counts as a reply and stops the contact
alwaysOnfalseLeave it off. On, it ignores days and start/end and sends around the clock
eventToStopemptySee 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:

StepWhat versions meansWhat the sender does
EMAILA/B variantsThe job carries one versionId. Each contact receives one version
MESSAGE on LinkedInSub-messagesThe 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 detectContacts per version
5% against 6% repliesabout 8,200
5% against 7% repliesabout 2,200
5% against 10% repliesabout 435
3% against 6% repliesabout 750
50% against 55% opensabout 1,600
50% against 60% opensabout 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:

SignalThresholdWhat you do
Bounce rate on a running campaignabove 3%Pause. The list was not verified properly, and every further send costs reputation
Unsubscribe rateabove 1.5%Pause. This is a targeting problem, not a copy problem
Reply rate after 300 sendsunder 1%Pause. The offer or the segment is wrong, and a fourth variant will not fix it
Spam complaintsanyStop. This is the domain, not the campaign
A step producing more unsubscribes than repliesanyCut 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 START root, and every _id is unique.
  • template names one of the five ids, or is null with a written reason, and the two coverage percentages that led to that choice were said out loud to the user.
  • template_changes lists 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-email or smart-multi-channel, the conditions use $email and $linkedinUrlProfile, and you told the user those are Emelia contact fields that the import has to fill.
  • No INMAIL step survives unless the user confirmed the seat has InMail credits.
  • No step is left with an empty versions[0].message where the template had a placeholder.
  • Every stepType is one of the values in the table, and every CONDITION has both a yes and a no.
  • Every message step has at least one version with a non-empty message, and every EMAIL step that starts a new thread has a subject.
  • Every version of every EMAIL step 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.csv exits 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.
  • dailyEmailAdded times the number of days is not larger than the list, and the daily volume matches what outreach-deliverability allows.
  • eventToStop contains at least the replies, unsubscribes and bounces, and does not contain OPENED.
  • If an A/B test is declared, min_contacts_per_version times 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.

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.