DEV Community

Cover image for I Built a Content Outreach Bot That Runs Itself. Two MCP Servers and a Cron Did the Work
Digital Craft Workshop
Digital Craft Workshop

Posted on Originally published at Medium

I Built a Content Outreach Bot That Runs Itself. Two MCP Servers and a Cron Did the Work

I write Notes on Substack to reach other authors. The good move is to pick a strong recent article from someone in my niche, make something for it, and tag them. Done well, it's outreach that doesn't feel like outreach.

Done by hand, it's a chore. Scroll archives. Judge what's actually landing. Render a video. Post the Note. Tag the right person. Forty minutes, three times a day.

I have one good evening window. I'm not spending it scrolling.

So I built it to run without me. Now three times a day a remote agent picks an article, my home GPU renders a reel for it, and a video lands in my Telegram. I react with a thumbs-up. That ships a Substack Note with the video and a real @mention. If I don't react, nothing happens.

The interesting part isn't the AI. It's how dumb the orchestration is once the hard parts live behind MCP.

TL;DR: Two MCP servers (one on Fly, one on my home PC behind a Cloudflare tunnel) become the "hands", a scheduled claude.ai routine becomes the "schedule", and a Telegram reaction becomes the "approval." Each new capability is one MCP tool. Orchestration is a prompt on a cron.


The mental model

Three roles, cleanly split:

  • MCP servers are my hands. Each thing I can't do yet becomes a tool. Deploy it once, it exists forever.
  • A cloud routine is my schedule. A remote Claude agent on a cron. It calls the tools in order. It never asks me anything.
  • A chat reaction is my approval. The one human gate. I react to a Telegram message and that ships.

When I framed it that way, the build got boring in the best way. Every "how do I automate this?" turned into "which of those three buckets is this?"

Three roles: MCP servers as hands, a cloud routine as schedule, a chat reaction as approval

The three roles behind a self-running bot: tools do the work, a routine picks the moment, one tap approves | Generated with Claude


The flow

End to end, one run looks like this:

  ┌─────────────────────────┐
  │  cloud routine (cron 3×) │   the schedule
  └────────────┬────────────┘
               │ 1. find_reel_candidate
               ▼
  ┌─────────────────────────┐
  │      substack-mcp        │   (Fly.io)
  │  ranks archives, dedup   │
  └────────────┬────────────┘
               │ 2. render_reel_from_url  (jobId, then poll)
               ▼
  ┌─────────────────────────┐
  │         pc-mcp           │   (home PC, CF tunnel)
  │   RPK + ComfyUI → R2     │
  └────────────┬────────────┘
               │ 3. record_reel_outreach
               │ 4. send_reel_draft
               ▼
  ┌─────────────────────────┐
  │        Telegram          │   video lands in my chat
  └────────────┬────────────┘
               │  👍 (message_reaction)
               ▼
  ┌─────────────────────────┐
  │  webhook on substack-mcp │
  │      → publish_note      │
  └────────────┬────────────┘
               ▼
           Substack  (Note + video + @mention)
Enter fullscreen mode Exit fullscreen mode

I touch exactly one of those arrows. The rest runs whether I'm at my desk or asleep.


Server 1: substack-mcp, the brain on Fly

This one wraps the Substack API plus my own "grownote" engine, the thing that drafts and schedules my Substack Notes. The tools:

  • find_reel_candidate: scans the archives of publications I subscribe to, ranks them by reactions, comments, and restacks, and returns the best one I haven't touched.
  • record_reel_outreach: logs that I'm about to tap an author, so I don't hit them again next run.
  • send_reel_draft: posts the finished reel to Telegram and stashes the publish payload.
  • publish_note: posts the Note. It now supports attaching a video and a real @mention, which is the whole point of the outreach.

The dedup is the part that matters. An outreach bot that taps the same author twice a day is a spam bot. So find_reel_candidate excludes, server-side, every article I've ever made a reel for and every author I've tapped in the last 7 days.

I didn't want a migration for this. So state is just small JSON blobs in a Postgres app_config table:

-- conceptually:
select value from app_config where key = 'reel_done_article_ids';
select value from app_config where key = 'reel_author_taps';
Enter fullscreen mode Exit fullscreen mode

find_reel_candidate reads both, filters the candidate set before it ever returns, and the caller can't accidentally skip the check. The dedup lives where the decision lives.

That's the rule I keep relearning: put the guard inside the tool, not in the prompt that calls it. It's the same reason I put a code reviewer in front of every git push to main instead of trusting myself to remember.

Wiring your own capabilities into Claude Code like this? I keep the whole setup, the hooks, the config, and the mistakes that bite first, in a free email series, The Claude Code Memory Starter.


Server 2: pc-mcp, the muscle on my home PC

The reel itself is rendered locally. My home PC runs the Reel Pipeline Kit (a Next.js app) driving ComfyUI on an RTX 3060. The GPU is free; cloud GPU minutes are not.

So I exposed exactly one tool from that machine: render_reel_from_url. It takes an article URL, produces the branded reel with read-along captions, and uploads it to R2.

The PC sits behind a Cloudflare tunnel so the cloud routine can reach it without me opening a port. Which leads to the one design detail that actually bit me.


The async-job pattern that bites everyone

A reel render takes one to eight minutes. Cloudflare's edge kills a request at roughly 100 seconds on the free tier. So a synchronous render_reel_from_url that blocks until the video is done will always time out for the slow ones. The tunnel hangs up, the routine sees an error, and you've burned a render for nothing.

The fix is to never do long work synchronously behind MCP. Return a handle, poll for the result:

// render_reel_from_url returns immediately
{ jobId: "reel_a3f9", status: "queued" }

// render_reel_status(jobId) is called until done
{ status: "rendering" }
{ status: "rendering" }
{ status: "done", videoUrl: "https://...r2.dev/reel_a3f9.mp4" }
Enter fullscreen mode Exit fullscreen mode

The caller fires the job, then polls render_reel_status every so often until status is done. Each call is well under the timeout. The render keeps running on the PC regardless of who's connected.

The job plus poll pattern keeping every call under the Cloudflare edge timeout

Fire the job, then poll for status, so no single call hits the 100-second edge limit | Generated with Claude

I'd already learned this with my local image-gen tool, which works the exact same way. So when I added the reel renderer, job + poll was the default, not a fix after the first timeout.

If you take one thing from this post: long work behind MCP-over-Cloudflare has to be job + poll, never a sync call. The edge timeout isn't negotiable, and you don't want it to be. The alternative is a held-open socket for eight minutes, which is its own kind of fragile.


The orchestrator is just a prompt on a cron

Here's the part that still feels like cheating. The thing tying all this together is a claude.ai cloud routine, a scheduled remote Claude agent that runs three times a day, with the MCP servers connected and a prompt that says, in effect:

Call find_reel_candidate. Take the article URL. Call render_reel_from_url, then poll render_reel_status until done. Call record_reel_outreach. Call send_reel_draft with the video URL. Stop.

That's it. No local machine has to be awake, and no session I have to babysit. It never asks me a question, because there's nothing to ask. The candidate is chosen, the dedup is enforced server-side, and the only human decision happens later, in Telegram.

The orchestration logic is just a paragraph, not code I maintain. When I want to change the behavior, say render two candidates or skip weekends, I edit the prompt, not a deploy. It's the same instinct behind wiring persistent memory into every Claude Code session: let the agent read the state, don't hard-code it.


The approval loop: react-to-publish

This is my favorite trick. send_reel_draft posts the reel to Telegram as a video, and stores the full publish_note payload keyed by the Telegram message_id. The draft just sits there.

Then I set a Telegram bot webhook on substack-mcp, scoped to exactly one update type:

// one-time setup
setWebhook({
  url: "https://substack-mcp.fly.dev/telegram/webhook",
  allowed_updates: ["message_reaction"],
})
Enter fullscreen mode Exit fullscreen mode

Now when I react to a draft message with any emoji, Telegram fires a message_reaction update. The webhook reads the message_id, looks up the stored payload, and calls publish_note. Reacting is shipping.

There are no buttons and no separate app to open. Nothing to remember. I'm in Telegram anyway, so a thumbs-up from my phone publishes a Substack Note with the video attached and the author @mentioned.

One detail worth stealing: the handler removes the pending entry before it publishes. If it published first and deleted after, a second reaction (or Telegram retrying the webhook) could double-post the same Note.

const payload = pending.take(message_id); // atomic read-and-remove
if (!payload) return ok();                // already shipped, or never existed
await publishNote(payload);
Enter fullscreen mode Exit fullscreen mode

Delete-then-act makes the operation idempotent. A second reaction finds nothing and quietly does nothing.


What this pattern is, generalized

Strip away my specifics and the shape is reusable for any solo builder:

  • Every capability you don't have yet becomes one MCP tool you deploy. Now it exists for every agent, forever.
  • Keep your guards (dedup, rate limits, idempotency) inside the tools, where a drifting prompt can't skip them.
  • Use job + poll for anything slow behind a tunnel.
  • Let a cloud routine be the clock. Orchestration as a prompt is editable in seconds and needs no machine of yours awake.
  • Make one human gate, and put it where you already are. A chat reaction beats a dashboard you have to remember to open.

The split is the whole trick: the tools do the work, a cloud routine picks the moment, and I approve with a tap. Wire those together and "build it once, it runs itself" stops being a slogan.

I haven't manually picked a reel candidate in two weeks. The bot taps three authors a day. I tap an emoji when I like the result. That's the whole job now.

If you're building your own Claude Code setup, the hooks and guardrails I wish I'd had on day one are in a free email series, The Claude Code Memory Starter.


External Sources

P.S. The funniest part is how little of this is AI. The model picks an article and writes a Note, sure. But the architecture (job + poll, server-side dedup, react-to-publish, a prompt on a cron) is plain systems plumbing. What made it work was drawing the boundaries right. The model just colors inside them, as usual.


I turn articles like this one into short vertical videos with my own pipeline. The free playbook is here: https://danielrusnok.gumroad.com/l/article-to-reel-playbook

Top comments (0)