Systems Lab

Agent skill

outreach-replies

Triage the replies your Emelia campaigns received, and only those: the API exposes a campaign activity feed and a reply endpoint, it has no endpoint that lists a mailbox, so this is campaign reply handling and not an inbox client.

activeNeeds a keyActs undeclared3,450 words

Filed under Outbound email.

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

What it does when it runs

Triage the replies your Emelia campaigns received, and only those: the API exposes a campaign activity feed and a reply endpoint, it has no endpoint that lists a mailbox, so this is campaign reply handling and not an inbox client. Paginates the activity feed, sorts every reply by intent (interested, meeting request, not now with the follow up date extracted, out of office with the stand in contact extracted, wrong person with the referral, unsubscribe, negative), drafts an answer in the sender's own voice, blacklists every opt out immediately, and lists the manual tasks waiting on a human. Writes outreach/replies.md with one block per contact and the exact call to send each answer. It prepares, you send. Triggers on: replies, reply handling, campaign replies, answer, respond, triage, inbox, out of office, unsubscribe, opt out, blacklist, not interested, meeting request, follow up date, wrong person, referral, manual task, bounce, response rate.

Read from the skill and the 0 files bundled beside it. A skill’s own description is written to be selected by an agent, so it describes the job and not the dependencies.

Keys and connectors you must supply
  • EMELIA_API_KEY
Hosts it reaches
  • api.emelia.io
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
shellwrites files

Ask about outreach-replies

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-replies"
mkdir -p ~/.claude/skills/outreach-replies
cp -R "/tmp/claude-outreach/skills/outreach-replies/." ~/.claude/skills/outreach-replies/

Picked up without a restart. A project skill of the same name is shadowed by your personal one. For one repository only, swap ~/.claude/skills for .claude/skills. Claude Code docs ↗

Or take the whole library

This repo ships a .claude-plugin manifest, so Claude Code can install all 17 skills at once. Plugin skills are invoked as /<plugin>:<skill>, so they never collide with your own.

/plugin marketplace add emelia-io/claude-outreach
/plugin

The folder is the same in every client that implements the format — 46 of them — so if yours is not above, only the destination changes.

Before you install: this skill will not complete its job on a bare agent. It needs EMELIA_API_KEY, which you have to obtain separately.

Reproduced in full from emelia-io/claude-outreach/blob/585061e4d78fe14a70701fbc5aec5d1eeb3d580a/skills/outreach-replies/SKILL.md, which is licensed MIT (skill frontmatter). 3,450 words, 23 headings.

Triage the replies to your campaigns

What this does

Reads the replies your campaigns received, sorts them by what the person actually wants, writes a draft answer for each one in the voice of the person who sent the campaign, and handles the opt outs immediately and without asking. It writes everything to outreach/replies.md and stops there.

The perimeter is your campaigns, not your mailbox. Emelia's API gives you the activity feed of a campaign, which contains the replies it received, and one endpoint to answer a reply. There is no endpoint that lists a mailbox. So a message that arrived outside a campaign, or that Emelia could not attach to a contact, does not exist as far as this skill is concerned. Say that to the user the first time rather than letting them assume their inbox is being read.

It prepares, you send. No message reaches a real person without an explicit yes from you, one message at a time. The only action taken without asking is honouring an unsubscribe, because that is an obligation, not a decision.

When to use it

Run it daily while a campaign is running, and once more a week after it ends: late replies are often the good ones.

Use a different skill when you want the numbers rather than the messages (outreach-audit gives rates per step and per variant), when you want to fix the copy that produced these replies (outreach-write), or when the campaign is not sending at all, which is a deliverability question.

Do not reach for it as an email client. It cannot search a mailbox, cannot read a thread the campaign did not start, cannot mark a message read, and cannot see a reply that landed in spam. Those live in the Emelia app or in the mailbox itself.

Inputs

  • A campaign. The emelia.campaignId in outreach/campaign.json, or a name to resolve. Without either, list_campaigns (MCP) or GET /advanced/campaigns lists them with their id, name and status, so ask the user which one. Replies are read per campaign: there is no "all my replies" call, so a user running four campaigns gets four passes.
  • outreach/sequence.md. The drafts must sound like the campaign, not like a chatbot. Read the copy before writing a single answer.
  • EMELIA_API_KEY, or the Emelia MCP server. Without either there is nothing to read and nothing to send: say so rather than producing an empty triage.
  • The sender's calendar link, if there is one. Ask once, reuse it in every draft. Do not invent a booking URL.

How to do it

1. Know what you can actually read

The whole email surface, and there is no more of it:

Method and pathWhat it gives you
GET /advanced/campaigns/{id}/activitiesThe activity feed of one campaign, replies included, 30 per page
GET /advanced/campaigns/{id}/export?type=repliesThe same replies as a CSV, in one call, for a large campaign
POST /emails/replySend one answer
POST and DELETE /emails/blacklists/contactBlacklist an address or a domain, and undo it

There is no GET /emails/inbox, no thread listing, no search across mailboxes. If a user asks for "everything in my inbox", the honest answer is that the API does not expose it, and the closest thing is the reply feed of each of their campaigns.

2. Pull the replies, page by page

With MCP, for a small campaign:

get_campaign_activities { "campaignId": "6612f0a9b1c2d3e4f5a6b7c8", "type": "MAIL_REPLIED" }

Repeat for LINKEDIN_REPLIED, UNSUBSCRIBED and BOUNCED. The full event list is VISITED, INVITED, ACCEPTED, MESSAGE_SENT, INMAIL_SENT, LINKEDIN_REPLIED, MAIL_REPLIED, RE_REPLY_EMAIL, RE_REPLY_LINKEDIN, FOLLOWED, LIKED, SENT, BOUNCED, OPENED, UNSUBSCRIBED, CLICKED, TASK_COMPLETED.

The feed is paginated 30 at a time and the MCP tool does not expose the page, so above 30 replies MCP alone silently gives you the first page and nothing else. Go through REST and loop:

page=0
while : ; do
  body=$(curl -s -H "Authorization: $EMELIA_API_KEY" \
    "https://api.emelia.io/advanced/campaigns/$CAMPAIGN_ID/activities?type=MAIL_REPLIED&page=$page")
  n=$(printf '%s' "$body" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["activities"]))')
  printf '%s\n' "$body" >> outreach/.replies-raw.jsonl
  [ "$n" -lt 30 ] && break
  page=$((page + 1))
done

Four things about this loop:

  • page starts at 0, and the response is {"activities": [...]}.
  • Stop on a short page, not only on an empty one. A last page of 12 is the end; waiting for a page of 0 costs one extra call, which is harmless but slower.
  • types takes a comma separated list, so types=MAIL_REPLIED,LINKEDIN_REPLIED fetches both in one pass. type takes one value.
  • query filters by contact but only accepts a full valid email address, anything else is ignored silently. search is the free text one.

For a campaign with hundreds of replies, skip the loop entirely:

curl -s -H "Authorization: $EMELIA_API_KEY" \
  "https://api.emelia.io/advanced/campaigns/$CAMPAIGN_ID/export?type=replies&start=2026-03-01" \
  -o outreach/replies-export.csv

One call, every reply, with an optional start and end range. Use it for the first run on an old campaign, and the paginated loop for the daily pass.

Record the last page you read and the date of the newest activity, so tomorrow's run starts there instead of re-reading everything.

3. What a reply looks like

{
  "_id": "6613a1...",
  "contact": { "_id": "660f...", "firstName": "Claire", "lastName": "Fontaine",
               "email": "[email protected]", "companyName": "Nexora" },
  "event": "MAIL_REPLIED",
  "date": "2026-03-18T09:12:44.000Z",
  "customData": { "repliedTo": "[email protected]", "sentiment": { "classification": "INTERESTED", "score": 0.9 } },
  "step": 2,
  "version": 0,
  "reply": { "content": "<div>Bonjour Paul, ...</div>", "text": "Bonjour Paul, ...",
             "subject": "Re: Nexora + Postgres 16", "senderName": "Claire Fontaine" }
}
  • Classify on reply.text. reply.content is HTML and includes the quoted history.
  • customData.repliedTo is the mailbox that received it, which is the mailbox that must answer.
  • customData.sentiment.classification is present on accounts that have reply classification enabled, with values among INTERESTED, NOT_INTERESTED, NEUTRAL, OUT_OF_OFFICE, HOSTILE, DELEGATION, INCOMPREHENSION, QUESTION_ASK, plus a score between 0 and 1. On DELEGATION it carries additionalData.delegationEmail, on OUT_OF_OFFICE it carries additionalData.returnDate: use those rather than re-extracting them by hand. Treat the classification as a hint that saves a first pass, and read the text yourself before acting on it, especially below a score of 0.7.
  • step and version tell you which message they answered. Quote it in the draft rather than guessing what they saw.

4. Sort by intent

Seven buckets. Assign exactly one per reply, and record the phrase that decided it.

Interested. "tell me more", "how does it work", "send me a link", "interested", "ça m'intéresse", "envoyez-moi". Draft: two sentences, answer the actual question, offer one next step. Do not re-pitch, they already said yes to the conversation.

Meeting request. "let's talk", "can we schedule", "quelles sont vos dispos", "un call". Draft: propose two or three concrete slots in their time zone and paste the calendar link if there is one. Never invent a link.

Not now. "circle back", "not this quarter", "recontactez-moi en septembre", "budget". Extract the date they gave. Draft: one line, agree, name the date, nothing else. Record the date on the contact so the next campaign picks it up:

curl -s -X PATCH https://api.emelia.io/advanced/contacts \
  -H "Authorization: $EMELIA_API_KEY" -H "Content-Type: application/json" \
  -d '{"campaignId":"'$CAMPAIGN_ID'","email":"[email protected]","fieldName":"follow_up_date","fieldValue":"2026-09-01"}'

With MCP, update_contact with { contactId: "<contact._id from the activity>", fields: { follow_up_date: "2026-09-01" } } does the same, needs no campaign id, and can set several fields at once. Either way the field is created if it does not exist yet, and becomes {{follow_up_date}} in a later campaign.

Out of office. An automatic answer with a return date, and very often a stand in: "in my absence contact X at [email protected]", "en mon absence, contactez". Extract two things: the return date, and the replacement name and address. The replacement is a real lead, so add them to the list (add_contact_to_list with MCP, or POST /advanced/lists/contacts with {"id":"<listId>","contact":{...}}) and record where they came from. Do not draft an answer to a robot. When ignoreAutoReplies is on in the campaign settings these never appear as replies at all, which is the correct setting.

Wrong person. "not my area", "je ne suis pas la bonne personne", "you should speak to". Draft: thank them, ask for the introduction by name, and stop. If they named someone, add that person to the list.

Unsubscribe. "unsubscribe", "remove me", "stop", "désinscrivez-moi", "ne me recontactez plus", "supprimez mes données". Handle it in section 5, first, before you draft anything else.

Negative. "not interested", "we already have one", "pas intéressé", and everything hostile. Draft at most one line acknowledging it, and only when the tone allows. Never argue, never send a "just to be sure" follow up. A hostile reply that mentions spam or legal action gets blacklisted, not answered.

5. Unsubscribes, immediately and without asking

An opt out is honoured on the same run it is detected. Do not queue it, do not batch it for later, do not ask the user whether they agree.

curl -s -X POST https://api.emelia.io/emails/blacklists/contact \
  -H "Authorization: $EMELIA_API_KEY" -H "Content-Type: application/json" \
  -d '{"email":"[email protected]"}'
  • The email field also accepts a bare domain in the form domain.ltd, which blacklists everyone there. Use it when someone answers for their whole company, and only then. Ask before doing it: it is account wide and it is the one blacklist action worth confirming.
  • DELETE on the same path with the same body removes an address, for the case where you blacklisted the wrong one.
  • The blacklist is account wide, so it also protects every future campaign. That is the point.
  • blacklistUnsub in the campaign settings already blacklists people who click the unsubscribe link. It does not catch someone who writes "unsubscribe" in a reply. That gap is exactly what this step closes.
  • A reply that asks for data deletion is more than an opt out: blacklist, then tell the user it needs a human answer within the legal deadline of their market.

Report the count. "3 unsubscribes, blacklisted" is a line the user must see.

6. Draft in the sender's voice

Read two or three messages from outreach/sequence.md first, then hold to these rules:

  • Match the length of their reply. A two line answer gets a two line answer.
  • Match the language of their reply, not the language of the campaign.
  • One question per message, at most.
  • No attachment, no new pitch, no second call to action.
  • No fabricated facts. If you do not know the price, the draft says the sender will confirm it, it does not invent a number.
  • Sign with the sender's name. The mailbox signature is appended automatically by Emelia, so do not paste the signature into the body.
  • Quote nothing. The reply endpoint appends the original thread by itself when it has a message id.

Write each draft into outreach/replies.md. That file is the deliverable, and it is the deliverable even when the user is in a hurry: the drafts wait there until a human approves them one by one.

7. Send, one at a time, only on an explicit yes

Show the draft, ask, wait for a yes for that specific message. A yes on one draft is not a yes on the next one, and "send them all" is a request to show them all first.

curl -s -X POST https://api.emelia.io/emails/reply \
  -H "Authorization: $EMELIA_API_KEY" -H "Content-Type: application/json" \
  -d '{
        "providerId": "65e0aa11bb22cc33dd44ee55",
        "to": ["[email protected]"],
        "subject": "Re: Nexora + Postgres 16",
        "content": "Bonjour Claire,<br><br>Merci pour votre retour. ..."
      }'

Four things that make this call fail, none of them obvious from the schema:

  1. content is the only field the reference marks as required, but the server also requires at least one of providerId or senderEmail. Send one of them. The mailbox to use is customData.repliedTo from the activity, and list_email_providers maps that address to its _id.
  2. messageId is what threads the answer: with it, Emelia sets In-Reply-To, adds the references and appends the quoted history. It comes from the merged inbox in the app, not from the activity feed, so you usually will not have it. This is the practical consequence of there being no inbox endpoint: your answers are usually correct emails rather than threaded replies.
  3. Without messageId you must supply to and subject yourself. The call has nothing to inherit from. That is the shape shown above.
  4. With a messageId that Emelia cannot find, the call fails with Original message not found. Retry without it, adding to and subject.

content is HTML. Use <br> for line breaks, not \n. cc, bcc and attachments ([{ "name": ..., "url": ... }]) exist if you need them, and you almost never do.

8. LinkedIn, where there is a real inbox

Unlike email, LinkedIn conversations do have a full inbox API. These routes were verified on 9 September 2026 and are not in the public specification, so treat them exactly like the campaign build routes in the dispatcher: use them, and tell the user they are not contractual yet and may change without notice.

Method and pathWhat it does
GET /linkedin-inbox/chatsMerged conversation list across the connected accounts. Query: accounts (comma separated auth ids), cursor, limit up to 100, unread, after, before as ISO dates
GET /linkedin-inbox/chats/{chatId}One conversation
GET /linkedin-inbox/chats/{chatId}/messagesIts messages, cursor and limit up to 250
POST /linkedin-inbox/chats/{chatId}/messagesSend a message in that conversation, multipart, text and up to 10 attachments
POST /linkedin-inbox/chatsStart a conversation, multipart, accountId and attendeeProviderId required
PATCH /linkedin-inbox/chats/{chatId}/readMark read or unread, body {"value": true}
GET /linkedin-inbox/searchSearch conversations by participant name, q of at least 2 characters

Pagination here is cursor based, not page based: read cursor from the response and pass it back. The same rule applies as everywhere else in this skill: draft, show, wait for a yes, then post. A LinkedIn message is a message to a real person.

If the user has no LinkedIn account connected, these calls return nothing useful. Draft the LinkedIn answers in the file and tell them to send from the app.

9. Manual tasks

Steps of type TASK wait for a human. List them:

list_manual_tasks { "campaignId": "6612f0a9b1c2d3e4f5a6b7c8" }

Each task carries the contact, the step id as taskId, and its name. The action itself is done by hand, outside Emelia. When it is genuinely done:

complete_manual_task { "campaignId": "...", "contactId": "...", "taskId": "...", "status": "ok" }

ko marks it failed and lets the sequence move on. Only mark ok for something that actually happened: the sequence continues from there, and a false ok sends the next step to someone who never got the previous one. These two tools have no documented REST equivalent, so without MCP the tasks are handled in the app.

Output

outreach/replies.md, rewritten on every run, newest first.

# Replies, Q4 SaaS founders, run of 2026-03-18

Source: campaign activity feed, 3 pages read (74 activities), newest 2026-03-18 09:12.
This is what the campaign received. It is not your mailbox: the API has no endpoint
that lists one.

17 replies since the last run: 4 interested, 2 meetings, 3 not now, 5 out of office,
1 wrong person, 3 unsubscribes (blacklisted), 1 negative. 2 bounces removed.
Nothing has been sent. 6 drafts are waiting for your yes.

---

## Claire Fontaine, Nexora, interested
[email protected] | step 2, version A | 2026-03-18 09:12 | replied to [email protected]

> Bonjour Paul, sujet intéressant, on a justement migré en février. Comment vous
> vous comparez à Datadog sur le coût ?

Decided by: "comment vous vous comparez", a direct question, not a brush off.

**Draft** (French, 3 lines, answers the question, one next step)

> Bonjour Claire,
> Sur un parc de votre taille on est en général 40% sous Datadog, parce qu'on ne
> facture pas à l'ingestion. Je peux vous montrer le calcul sur vos volumes.
> 20 minutes jeudi ou vendredi matin ?

Send with: POST /emails/reply, providerId 65e0aa11bb22cc33dd44ee55,
to [email protected], subject "Re: Nexora + Postgres 16"
No messageId available, so this goes out as a new email, not a threaded reply.

---

## Marc Ostrowski, Vlantis, out of office
[email protected] | step 1 | 2026-03-17 16:40

> Absent jusqu'au 30 mars. En cas d'urgence, contactez Julie Ferrand,
> [email protected]

No draft, this is an auto responder.
Done: Julie Ferrand added to list 65f1a2b3c4d5e6f7a8b9c0d1, source "OOO of [email protected]".
Done: follow_up_date set to 2026-03-31 on [email protected].

---

## Unsubscribes, handled without asking

| Address | Phrase | Action | Time |
|---|---|---|---|
| [email protected] | "merci de me retirer de votre liste" | blacklisted | 09:31 |
| [email protected] | "unsubscribe" | blacklisted | 09:31 |
| [email protected] | "stop" | blacklisted | 09:31 |

## Manual tasks waiting

| Contact | Task | Step |
|---|---|---|
| Sophie Lenoir | Comment on her post before the connection request | 3 |

Checks before finishing

  • The file says where the replies came from and what that does not cover. A user must never come away thinking their mailbox was read.
  • The feed was paginated to the end, or the file says at which page you stopped and why. Exactly 30 replies is a page boundary, not a total: check it.
  • Every reply pulled is in the file with a bucket and the phrase that decided it. No reply is silently dropped.
  • Every unsubscribe was blacklisted before the file was written, and the file says so with a timestamp.
  • No draft contains a fact that is not in the reply, in the sequence, or given by the user. No invented price, no invented calendar link, no invented case study.
  • Nothing was sent without a yes for that specific message. The file records what was sent and what is still waiting.
  • Manual tasks marked ok were actually done by a human who said so.

Failure modes

  • The user expects their inbox. They ask why a message from a prospect who wrote from a different address is missing. It is missing because the API has no inbox listing: only what Emelia attached to a campaign contact appears here. Say it plainly, and point them at the Emelia app for the rest.
  • Only 30 replies come back. That is one page. The MCP tool cannot ask for the next one, so switch to REST and loop on page, or pull the CSV export.
  • A reply is attached to the wrong contact. Emelia matches a reply by sender address, message id or thread id. A reply from a colleague's address on a forwarded thread lands on the original contact. Read reply.senderName and the text before answering by name.
  • Out of office answers counted as replies. ignoreAutoReplies is off in the campaign settings. Turn it on: otherwise every auto responder stops the sequence for that contact and inflates your reply rate.
  • Original message not found. The messageId is not in the merged inbox any more. Send without it, with to and subject.
  • Provider not found. The providerId does not belong to the account, or the senderEmail is not a connected mailbox. Re-read list_email_providers.
  • The reply arrives with the signature twice. The signature was pasted into content and Emelia appended the mailbox one. Remove it from the body.
  • You blacklisted a whole domain by mistake. DELETE /emails/blacklists/contact with the same value undoes it. Say what happened, do not fix it quietly.
  • No replies at all after several days. Check the campaign is RUNNING and that mail is actually going out, then look at deliverability. An empty feed is rarely a triage problem.

Limits

This skill reads the replies to your campaigns. It does not read your mailbox, and no Emelia endpoint does: there is no inbox listing, no thread search, no way to see a message that arrived outside a campaign or that Emelia could not match to a contact. Those are handled in the Emelia app or in the mailbox itself. Anything that claims otherwise about the email API is wrong.

LinkedIn is the exception, with a real inbox API, and that one is not in the public specification: use it, and expect it to change.

It cannot book a meeting or touch a calendar, cannot see whether your reply landed in spam, and cannot judge a reply that says nothing (a bare "ok" is ambiguous and it will say so rather than guess).

It drafts, it does not decide: the answer that goes out is yours, one yes at a time.

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