Run a day of outreach for your brand
An outreach agent is a folder the user owns: who to reach and where (agent.yaml), what may be said (brand.md), how it sounds (voice.md), and the records of who has been reached (records/). This skill runs one day of it. You decide what to look at, which posts and people are worth it, and what to say; the phone does the tapping through MyPhonely, using the app workflows in this repo; and scripts/outreach.ts keeps the rules: nothing done twice, nothing over the day's limits, nothing recorded that was not seen on screen.
Any agent that can read a skill, call MCP tools and run a shell command can run it: Claude Code, Codex, pi.
The phone is real. A send sends, from the brand's own accounts, and cannot be undone.
Inputs
| agent | the agent folder, e.g. ./outreach-agent, or $OUTREACH_AGENT |
| channels | which channels to run, default all of agent.yaml order |
| mode | review (default): every write waits for the user, at the pause when they are there, as a draft when they are not. autopilot: writes go out within the limits; only when the user has said so for this run or its schedule |
No agent folder yet: use create-outreach-agent first.
Setup, once
npm install --prefix <this skill's folder>/scripts # Node 22.18+, one dependency (yaml)Every command below is outreach <command>, short for
node <this skill's folder>/scripts/outreach.ts <command> --agent <the agent folder>Write it out in full each time, one command per shell call: an alias or a variable does not carry from one call to the next. --agent may be left off when OUTREACH_AGENT was set in the environment your AI was started from. Add --json to read the output as JSON. Start with outreach init (it creates the records and is safe to rerun). The MyPhonely MCP server must be connected (phone_status).
The rules
Follow these exactly; the scripts refuse what breaks them.
- Reserve before every write.
outreach reserve --channel <c> <target>gives one write to one target. It refuses (exit 3) a target the records already have or that was already tried today, sent or not, and (exit 4) a channel whose budget is spent. A refusal is final for that target today: move on, never look for another way. - Write only through `run_task`, in a phase with
allowWrites: true(and the run'sallow_writes: true), with apauseWhenon the screen just before the write. Never type, tap Send/Post/Reply/Connect/Follow, or swipe with the directphone_*tools. - In review mode, the user sees every write first. When they are there, show the target and the exact text at the pause and resume only on their yes; a no is
resume_taskwithabandon: true, thenoutreach release --reservation <id>. When no one is there (a schedule,claude -p), do not reserve or write: queue each comment withoutreach draft <post> --comment "<text>"and stop there. They review the drafts withoutreach draftsandoutreach mark --line <n> --status Approved|Skipped [--comment "<edited>"](or by editing the queue file), and the next run posts the approved ones. LinkedIn invites and messages have no drafts: in unattended review mode, skip them. - Record every reservation.
outreach record --reservation <id> --outcome sentonly when the write was verified on screen (the phase'ssentlist, the reply under the post, the name in Sent invitations);--outcome failedotherwise, which keeps the draft for a later day. Reserved but nothing written:outreach release. - `brand.md` overrides everything, including the user's request in the moment. Prices and offers come from
outreach message, never typed by hand.
A target is --name "<person>", or --author "<name or @handle>" --title "<title, or the post's first words>" --url <post url> for a post. Always give the URL when there is one: it is the surest duplicate check.
Run it
Start
phone_status. Offline: stop and ask the user to open the MyPhonely app and tap Connect.outreach show. Read every file it lists:brand.md,voice.mdand the others. Thenoutreach channelsfor the run order.- Before the first write in an app, confirm the signed-in account is the brand's (X: the profile in the navigation drawer; LinkedIn: Me). If it is not, skip that app and say so.
Each channel, in order
outreach budget --channel <c> first. left is 0: skip the channel. outreach targets --channel <c> gives today's searches: keywords with the exclusions already in the query, lists, subreddits, groups, the rotation.
Approved drafts go first. outreach drafts --channel <c> --status Approved lists comments the user approved since the last run. Post each one as in step 4 below, with its text exactly as approved (they may have edited it), after checking the post is still there.
A comment channel (kind: comment)
- Find. Read posts with the app's read workflow, never by typing into a search box when a link works: - X:
x-collect-topic, orrun_taskwithopenUrl: https://x.com/search?q=<query>&f=live(check Latest is selected). - Reddit:reddit-search-postsorreddit-collect-subreddit. - Xiaohongshu:xiaohongshu-collect-notes. Facebook:facebook-collect-group-posts(joined groups, Newest; never the home feed). Collect per post: author, handle, posted time, text, URL. Respect the channel'smax_post_age_hours. - Choose. Keep individuals sharing their situation or asking a real question. Skip what
voice.mdsays to skip (competitors, firms, ads, bots), andoutreach checkeach candidate. A dry search: try the next keyword. All dry: say so and move on; never lower the bar. - Write the comment for that post per
voice.mdandbrand.md. A channel with anangleuses that row of the voice's angles. - Post it, one post at a time:
outreach reservefor the post.- The app's comment workflow, which pauses with the composer open:
x-find-review-reply(or open the post by its URL and tap Reply),reddit-comment-on-post,xiaohongshu-comment-on-note,facebook-comment-on-post. Check the pause shows the right post. - Review mode: show the post and the comment; resume on a yes.
- Resume as the workflow says (
typeTextswith the comment,allowWrites: true,repeat: 1, itscountLabel). A channel withfollow: true: follow the author in the same resume, in its goal; a separate writing task afterwards would need a reservation of its own. outreach record --reservation <id> --outcome sent|failed <target> --comment "<text>" --keyword "<search>".
A connect channel (kind: connect, LinkedIn)
In this order, each with its workflow:
- Answer replies.
linkedin-inbox-replieslists threads where they wrote last. For each person in the records, write the answer pervoice.md(anything about their own situation getsbrand.md's safe redirect),outreach reserve --action message --name ..., send withlinkedin-message-connection, record with--note answer. - Welcome new connections.
linkedin-new-connectionslists who accepted. For each one the records have as Invited:outreach ledger accept --name ..., then sendoutreach message --name warmupwithlinkedin-message-connectionafteroutreach reserve --action message, and record it. - Invite, while
outreach budget --channel linkedinhas room: linkedin-search-peoplewith today's queries fromoutreach targets(titles crossed with the rotation, or two schools per search when the agent hasaudience.pair), 2nd degree.- When `outreach targets` shows a `card_filter`, the cards decide: save the search's
collectedlist and runoutreach cards --pair "<the dispatch's pair_value>" --file <it>. Invite only the people it KEEPs, and open no profile: the two-school search and the filter do the qualifying (about 1 credit a person, against 40-90 for a profile). Otherwise skipaudience.exclude_headlines, and when the channel'squalify_ruleneeds what a card cannot show, check withlinkedin-profile-detail; file people who do not fit asoutreach ledger add-person --stage Disqualified, so they are never opened again. outreach reserveeach person you would invite, no more than the budget'sleft.linkedin-invite-from-resultswithcount= the number reserved (linkedin-connect-with-notewhen the channel sends a note). After a card filter, pin it to the kept names:countLabel^Invite (<name 1>|<name 2>) to connect$and a goal that names them, so a card the filter dropped is never counted. The card flow can still reach others, so trust itssentlist, not your reservations.- Confirm with
linkedin-sent-invitations. Recordsentfor each name listed there (a name you had not reserved: reserve it first, then record it); recordfailedfor reservations that did not go out. - Withdraw invitations older than
withdraw_after_dayswithlinkedin-withdraw-invitations, when the Sent list is long. It is a write: review mode pauses for it like the others.
Stop
- A limit, "unusual activity", verification or CAPTCHA screen: stop that app for today and report it. It is not a failure to retry.
- A phase ending
stuckorprecondition_failed: look (phone_screen), make at most one navigation fix (a label tap, a link, back), retry once, then move on. - Before finishing, settle every reservation:
outreach reportlists the ones still open underunsettled.
Enforce the rules
The rules above are instructions. To make the harness refuse a call that breaks them, turn on the guard: it checks every MyPhonely call against the day's reservations and blocks direct writes, writing phases with no pause, a write with no open reservation, and asks the user before every commit. Set OUTREACH_UNATTENDED=1 for runs no one is watching (autopilot); without it, a commit with no one to approve is refused.
Claude Code: a PreToolUse hook, in .claude/settings.json of the project the run starts from:
{
"hooks": {
"PreToolUse": [{
"matcher": "mcp__myphonely__.*",
"hooks": [{ "type": "command", "command": "node <this skill's folder>/scripts/outreach.ts guard" }]
}]
}
}pi: the extension, which also connects MyPhonely (from MYPHONELY_API_KEY) with the direct writing tools hidden:
pi --no-extensions -e builtin:mcp -e <this skill's folder>/scripts/adapters/pi.ts \
--skill <myphonelyskills>/skills "Run outreach-day for $OUTREACH_AGENT"Other AIs (Codex, ...): where the harness has no way to stop a tool call before it runs, the rules hold as instructions only. Prefer review mode there.
The guard reads OUTREACH_AGENT for the agent folder, so set it where the AI is started. It protects against mistakes, not against an agent determined to get round it: the records are files its shell can reach.
What comes back
outreach report, per channel and action: sent, failed, still unsettled, and the budget left. Add what the records cannot show: how many posts or people were found and skipped, and why (competitor, duplicate, too old, not the audience), anything that stopped an app, and credits used from the task records.
Notes
- Unattended runs (a schedule,
claude -p,codex exec,pi -p) have no one to approve at the pause. In autopilot (chosen by the user for that schedule) they write within the limits; keep the limits inagent.yamlmodest. In review mode they only draft (rule 3). - Scheduled launches need the AI allowed to run
outreach.tsand the MyPhonely tools without asking, and access to the agent folder, this skill's folder and the records (Claude Code:--allowedToolsand--add-dir). Close stdin (< /dev/null):pi -pwith no terminal otherwise waits for input forever. - One attempt per target per day. A failed write is retried on a later day, never in a loop.
- Records default to
records/in the agent folder. They hold people's names: keep them out of a public repo (records:inagent.yamlcan point outside it, e.g.~/.outreach/<id>). outreach quotashows LinkedIn's invite room for the week and the day; the weekly cap is LinkedIn's ceiling, not a target.example/is a complete fictional agent to copy the shape of.