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.
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-toolsin the frontmatter. It does act, so it runs under whatever permissions your session already grants. - Actions present in the files
- shellwrites files
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-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.
The skill
Source on GitHub ↗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.campaignIdinoutreach/campaign.json, or a name to resolve. Without either,list_campaigns(MCP) orGET /advanced/campaignslists 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 path | What it gives you |
|---|---|
GET /advanced/campaigns/{id}/activities | The activity feed of one campaign, replies included, 30 per page |
GET /advanced/campaigns/{id}/export?type=replies | The same replies as a CSV, in one call, for a large campaign |
POST /emails/reply | Send one answer |
POST and DELETE /emails/blacklists/contact | Blacklist 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:
pagestarts 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.
typestakes a comma separated list, sotypes=MAIL_REPLIED,LINKEDIN_REPLIEDfetches both in one pass.typetakes one value.queryfilters by contact but only accepts a full valid email address, anything else is ignored silently.searchis 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.contentis HTML and includes the quoted history. customData.repliedTois the mailbox that received it, which is the mailbox that must answer.customData.sentiment.classificationis present on accounts that have reply classification enabled, with values amongINTERESTED,NOT_INTERESTED,NEUTRAL,OUT_OF_OFFICE,HOSTILE,DELEGATION,INCOMPREHENSION,QUESTION_ASK, plus ascorebetween 0 and 1. OnDELEGATIONit carriesadditionalData.delegationEmail, onOUT_OF_OFFICEit carriesadditionalData.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.stepandversiontell 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
emailfield also accepts a bare domain in the formdomain.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. DELETEon 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.
blacklistUnsubin 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:
contentis the only field the reference marks as required, but the server also requires at least one ofproviderIdorsenderEmail. Send one of them. The mailbox to use iscustomData.repliedTofrom the activity, andlist_email_providersmaps that address to its_id.messageIdis what threads the answer: with it, Emelia setsIn-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.- Without
messageIdyou must supplytoandsubjectyourself. The call has nothing to inherit from. That is the shape shown above. - With a
messageIdthat Emelia cannot find, the call fails withOriginal message not found. Retry without it, addingtoandsubject.
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 path | What it does |
|---|---|
GET /linkedin-inbox/chats | Merged 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}/messages | Its messages, cursor and limit up to 250 |
POST /linkedin-inbox/chats/{chatId}/messages | Send a message in that conversation, multipart, text and up to 10 attachments |
POST /linkedin-inbox/chats | Start a conversation, multipart, accountId and attendeeProviderId required |
PATCH /linkedin-inbox/chats/{chatId}/read | Mark read or unread, body {"value": true} |
GET /linkedin-inbox/search | Search 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
okwere 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.senderNameand the text before answering by name. - Out of office answers counted as replies.
ignoreAutoRepliesis 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. ThemessageIdis not in the merged inbox any more. Send without it, withtoandsubject.Provider not found. TheproviderIddoes not belong to the account, or thesenderEmailis not a connected mailbox. Re-readlist_email_providers.- The reply arrives with the signature twice. The signature was pasted into
contentand Emelia appended the mailbox one. Remove it from the body. - You blacklisted a whole domain by mistake.
DELETE /emails/blacklists/contactwith the same value undoes it. Say what happened, do not fix it quietly. - No replies at all after several days. Check the campaign is
RUNNINGand 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.
- sales-outreach by zubair-trabzada · 1,318
- apollo-outreach by OpenClaudia · 691
- outreach-sequence-builder by Varnan-Tech · 645
- cold-outreach-sequence by BrianRWagner · 416
- cold-outreach by shawnpang · 324
- earned-media-outreach by shawnpang · 324
- partnership-outreach by shawnpang · 324
- cold-email-outreach by thatrebeccarae · 139
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.