The nine segments are identical either way. Personalised retunes every example, scenario, and tutor response to your role and sector.
Need to set language, AI-usage tier or other preferences? Open the full setup wizard
EverythingThreads · ICO: C1896585 · Privacy Policy
BUILD gave you a tool you use. This add-on turns it into a tool that works for you. Same Cloudflare Worker, same Claude API, same infrastructure — one extra handler function and a cron expression. That's the whole trick. The rest is about doing it safely, because automation multiplies every outcome — including the bad ones.
You finished BUILD with a Worker that handles HTTP requests. That Worker can also handle scheduled events. Cloudflare fires a scheduled() function on your Worker at whatever time you specify, and everything you already know about writing Worker code — env vars, Claude calls, error handling — applies identically. The hard part isn't the code. The hard part is deciding what should run on a schedule, what should run on an event, and how you know it worked when you weren't watching.
The lit box is the unlock. Your BUILD Worker already knows how to call Claude; this segment teaches it to wake itself up and do it on a timer. The box after that — email digest — is what ships by the end of Segment 6: a real automation that runs every morning and delivers something useful to your inbox.
env.ANTHROPIC_API_KEY. You're not starting a new project. You're adding one function to an existing one. If your BUILD Worker works, you're already 80% done with this add-on.Worker-side changes you'll make across this add-on: add a scheduled handler alongside your existing fetch handler, bind a KV namespace, and add one secret for Resend's API key. Three line-level changes, total.
Every piece of software that runs does so because something told it to start. Three patterns cover 95% of real-world automation. Knowing which one you need is the judgment call — pick wrong and you'll build the right tool for the wrong problem.
This add-on teaches scheduled. It's the most common automation pattern, the cheapest on Cloudflare's free tier, and the one where the fewest things can go wrong. Event-driven via webhooks is mentioned in Segment 7 as an extension; polling gets named and dismissed and never looked at again.
Cron is 50+ years old. Unix admins have been scheduling jobs with the syntax 0 8 * * * since the 1970s. You've already seen this pattern in the BUILD glossary. Every major platform — Linux, GitHub Actions, Kubernetes CronJobs, AWS EventBridge, and Cloudflare Workers — uses the same five-field syntax. Learning cron once is a skill that works everywhere.
Cloudflare's implementation is clean: you add a [triggers] block to wrangler.toml, you add a scheduled() handler to your Worker code, you run npx wrangler deploy. That's the entire setup. The trigger fires on UTC time, on Cloudflare's edge, on whatever underutilised machine has capacity at that moment. You don't pick a data centre. You don't manage a server. You write a function and it runs on a clock.
Open the wrangler.toml file in your BUILD Worker project (the one you created when you ran npx wrangler init back in BUILD Seg 11). Add a [triggers] block anywhere below the top-level settings. For now, use a schedule that fires daily at 08:00 UTC:
[triggers] crons = ["0 8 * * *"] # Daily at 08:00 UTC
Cloudflare supports the standard five-field cron syntax plus most Quartz-scheduler extensions (step values, ranges, last-day-of-month, etc.). All times are UTC — no local timezone. If you're in London and you want something to run at 9am your time in summer, you schedule it for 08:00 UTC. If you're in New York and you want 7am local time, you schedule 12:00 UTC. Always convert before you write the cron.
0 8 * * * thinking it's local time, it fires at 3am your time, you wonder why your inbox is full at breakfast. Write UTC, verify with a UTC clock (date -u in your terminal), check once more before you ship.Your Worker's fetch handler runs when HTTP requests come in — that's what BUILD taught. The scheduled handler runs when a cron fires. Same Worker, same env, same everything — just a different entry point. Add it alongside your existing fetch handler:
export default { // Your existing BUILD handler — don't touch async fetch(request, env, ctx) { /* existing Claude proxy code */ }, // NEW: this runs when the cron fires async scheduled(controller, env, ctx) { console.log('Scheduled trigger fired at', new Date().toISOString()); console.log('Cron expression:', controller.cron); } };
That's a working scheduled handler. It does nothing useful yet — logs the firing time and the cron expression that triggered it — but it proves the plumbing. Deploy it with npx wrangler deploy and Cloudflare's dashboard will now show this Worker as having a scheduled trigger.
The three arguments are worth knowing:
controller.cron — the exact cron expression that fired this invocation (useful when you have multiple crons on one Worker)controller.scheduledTime — milliseconds since epoch, when the event was supposed to fire (not when it actually did; can differ by a few seconds)env — same as fetch handler. Your ANTHROPIC_API_KEY and any KV bindings are here.ctx.waitUntil(promise) — how you tell the runtime to wait for async work. Critical for scheduled handlers. Covered in detail in Seg 7.Five fields separated by spaces. Read left-to-right: minute, hour, day-of-month, month, day-of-week. An asterisk means "any value." Most patterns you'll write use just the first two fields and leave the rest as asterisks.
0 8 * * *
0 8 * * 1
*/15 * * * *
0 9 1 * *
0 9 * * 1-5
0 0,12 * * *
MON TUE WED THU FRI SAT SUN. Cloudflare accepts those too and they mean the same thing everywhere.Not sure your expression is right? Paste it into crontab.guru. It renders your expression in plain English and shows the next few scheduled runs. Every professional uses this. It's free, instant, and saves you from shipping a cron that fires "on the 32nd of every month."
30 14 * * * (2) 0 */6 * * * (3) 0 9 15 * *. Then check your answers. If you got any wrong, you're not alone — this is why crontab.guru exists. Use it every time.npx wrangler deploy. Go to Cloudflare dashboard → Workers & Pages → your Worker → Settings → Triggers. Do you see your cron trigger listed there with the expression you set?cd into the folder that has wrangler.toml first.[triggers] block is in the wrong place. It must be top-level (not nested under [vars] or anything else). Cron changes can take up to 15 minutes to propagate.MON, TUE...) to avoid ambiguity.
wrangler.toml — then the next wrangler deploy silently overwrites itDashboard shows trigger. wrangler.toml has [triggers] block at top level. Cron expression makes sense in UTC. controller.cron will equal your exact string character-for-character.
Your Worker is scheduled. At the next 08:00 UTC, it'll fire. But waiting 24 hours every time you change code is a terrible feedback loop. Segment 3 fixes that — you'll trigger the scheduled handler locally, on demand, as many times as you want.
If you try to debug a daily cron by deploying and waiting for 8am, you'll burn a week on one bug. Cloudflare knows this. Wrangler has a flag that exposes an HTTP route locally which fires your scheduled handler exactly as if the cron just went off. You can hit it with curl, simulate any cron expression, override the scheduled time. Three commands and you're iterating at normal speed.
In the Worker project folder, run wrangler dev with the --test-scheduled flag. That single flag exposes a special route — /__scheduled — which you can hit with an HTTP request to fire the scheduled handler manually.
# Starts a local Worker with scheduled testing route enabled
npx wrangler dev --test-scheduledYou'll see output like Ready on http://localhost:8787. Leave that terminal alone — it's your local Worker runtime, and every console.log from your scheduled handler will appear there.
In a second terminal (don't kill the first), use curl to hit the hidden scheduled route:
# Fire scheduled() as if "0 8 * * *" just triggered curl "http://localhost:8787/__scheduled?cron=0+8+*+*+*"
Switch back to Terminal 1. You'll see your console.log output appear — the scheduled handler just fired, locally, in response to your curl. Hit curl again; it fires again. You've broken the 24-hour feedback loop.
The cron query parameter is the cron expression you want to pretend fired. It matters when you have multiple crons on one Worker and want to test what happens for each one (see Seg 7 for the controller.cron switch pattern). For a single cron, any valid expression works — the scheduled handler doesn't care.
You can also override controller.scheduledTime with a time query parameter — useful if your handler does date-math and you want to test "what if this fires at midnight on a Sunday." Pass a millisecond Unix timestamp:
# Fire as if it's 2026-01-01 08:00 UTC (timestamp in ms) curl "http://localhost:8787/__scheduled?cron=0+8+*+*+*&time=1767686400000"
npx wrangler dev --test-scheduled → leave running, watch for console outputsrc/index.js — wrangler auto-reloads on save/__scheduled route → scheduled handler firesnpx wrangler deploy → real cron takes overThis loop is identical to how you iterated on your BUILD Worker's fetch handler. Same rhythm, different trigger mechanism. Once you've done it three times it becomes automatic.
--test-scheduled flag exists — to give you manual control. The real schedule only runs against deployed Workers, on Cloudflare's edge. Don't expect local wrangler to spontaneously fire your 08:00 cron at 08:00 local time. It won't.wrangler dev --test-scheduled. Fire it with curl. Does your scheduled handler's console.log('Scheduled trigger fired at', ...) appear in Terminal 1?export default object is missing the scheduled method. Go back to Seg 2 Step 2 and verify the handler shape.--test-scheduled flag. The /__scheduled route only exists when that flag is active.lsof -i :8787 (Mac/Linux) or netstat -ano | findstr :8787 (Windows) and kill it.
wrangler dev --test-scheduled + curl combo is non-negotiable for anything beyond a trivial scheduled job.Your scheduled handler has no memory. Each time it fires, it starts fresh — no variables survive from the last run. If you want "process all new RSS items since the last run," you need somewhere to remember what "last run" means. That somewhere is Workers KVCloudflare's global key-value database, replicated to their edge network. Simple string-in, string-out storage..
KV is deliberately simple: you put a string under a key, you get it back later. No schemas, no queries, no joins. Eventually consistent across Cloudflare's network (writes take ~60 seconds to propagate globally). The simplicity is the feature — for state-tracking in a cron job, a relational database would be overkill. KV is exactly the right primitive.
Cloudflare gives you three data primitives. For this add-on you need one. Knowing the difference stops you from reaching for the wrong one later.
This segment uses KV. The rest of the add-on will only need KV. If your automation grows to need SQL-queryable data, you'll be ready for D1 — the binding pattern is identical, just the API on the binding differs.
From your Worker project folder, run the wrangler KV create command. You give the namespace a binding name — what you'll refer to it as inside your Worker code. Pick something descriptive:
BUILD taught you wrangler deploy and wrangler secret put but probably never required you to hand-edit the [[kv_namespaces]] block. This is the first time you'll add a multi-line binding to wrangler.toml. The double brackets [[...]] are TOML syntax for an array of tables — it looks unusual but that's correct. If your wrangler.toml currently has no KV block at all, that's expected; you're adding one now.
npx wrangler kv namespace create AUTOMATION_STATE
Wrangler will output something like:
🌀 Creating namespace with title "worker-AUTOMATION_STATE" ✨ Success! Add the following to your wrangler.toml: [[kv_namespaces]] binding = "AUTOMATION_STATE" id = "a825455ce00f4f7282403da85269f8ea" # yours will differ
Copy that [[kv_namespaces]] block and paste it into your wrangler.toml. The double brackets [[...]] matter — they're TOML's syntax for an array of tables, because you can have multiple KV namespaces. Don't skip the extra bracket.
Once bound, your KV namespace appears on env — same way env.ANTHROPIC_API_KEY does. The two operations you'll use 99% of the time are put(key, value) and get(key). Both are async. Values are always strings — JSON.stringify on write, JSON.parse on read.
async scheduled(controller, env, ctx) { // 1. Read last-run timestamp (null if never run before) const lastRunIso = await env.AUTOMATION_STATE.get('last_run'); const lastRun = lastRunIso ? new Date(lastRunIso) : null; console.log('Last run was:', lastRun || 'never'); console.log('Scheduled time:', new Date(controller.scheduledTime).toISOString()); // 2. Do the work (placeholder — real work comes in Seg 6) // ... // 3. Write new last-run timestamp await env.AUTOMATION_STATE.put( 'last_run', new Date(controller.scheduledTime).toISOString() ); }
That's the state-tracking pattern in its minimum form. Read what the last run did. Do new work. Record that this run happened. Next invocation, the "last_run" key has your new timestamp, and you can use it to filter "what's changed since then?"
For more complex state — a list of processed item IDs, for example — stringify JSON:
// Write const processed = ['item-1', 'item-2', 'item-3']; await env.AUTOMATION_STATE.put('processed_ids', JSON.stringify(processed)); // Read const raw = await env.AUTOMATION_STATE.get('processed_ids'); const processed = raw ? JSON.parse(raw) : [];
put(key, value, { expirationTtl: 3600 }) auto-deletes the key after 3600 seconds. Useful for caches, idempotency tokens, temporary flags. For state you want to keep, omit the option and KV holds it forever (or until you delete it).remote = true to the binding, or use --remote flag on wrangler dev, if you need to test against real KV.put line isn't being reached. Add a console.log('writing state') just before it to verify.await on get/put — returns a Promise object that JSON.parse then crashes onenv.KV when the binding is actually env.AUTOMATION_STATE — undefined, silent failureEvery KV call is awaited. Every stored value is a string. Dashboard → your Worker → Settings → Bindings shows AUTOMATION_STATE listed.
console.log is invisible. Segment 5 adds email.A scheduled job with no output is worse than no scheduled job — it burns compute and you don't notice. Every automation needs a destination for its results. You get exactly three practical options on Cloudflare Workers in 2026, and picking one is the judgment call that shapes the rest of your tool.
For this add-on we'll pick email and build it out. The others get named and sketched so you know when to reach for them later.
fetch handler that reads them out as HTML. Best for accumulating data over time that you'll browse later. Effectively a mini-app.The framework: email when you want pushed updates you'll read in your normal flow. Slack webhook when you want visible-to-team updates or urgency. KV+dashboard when the output accumulates and is worth browsing. For most first automations, email wins — and Resend's free tier (3,000 emails/month, 100/day) more than covers anything this course teaches.
You need a domain you own for Resend. If you bought one for your BUILD site (Netlify handled DNS if so), you can reuse it. If you don't own a domain yet, Resend offers a sandbox mode that only sends to your own verified email — fine for this course, just slower to iterate on.
yourname.com)automation-addon. Copy the key — it starts with re_ and Resend won't show it again.Now add the key to your Worker as a secret. Secrets are different from env vars — they're encrypted and not visible anywhere in the dashboard after you set them:
npx wrangler secret put RESEND_API_KEY
# Paste the re_... key when prompted, press EnterFrom now on, env.RESEND_API_KEY is available to your Worker. Same treatment as env.ANTHROPIC_API_KEY from BUILD.
The Resend REST API is one POST. No SDK needed, no npm packages, no bundling gymnastics — just fetch, same as calling the Anthropic API. Add this helper function to your Worker:
async function sendEmail(env, { to, subject, html }) { const res = await fetch('https://api.resend.com/emails', { method: 'POST', headers: { 'Authorization': `Bearer ${env.RESEND_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ from: 'Automation <automation@yourdomain.com>', // must be verified domain to, subject, html }) }); if (!res.ok) { const err = await res.text(); throw new Error(`Resend failed: ${res.status} ${err}`); } return await res.json(); // { id: 'email-id-here' } }
Then call it from your scheduled handler. Update the handler from Seg 4:
async scheduled(controller, env, ctx) { const now = new Date().toISOString(); await sendEmail(env, { to: '[email protected]', subject: `Automation test · ${now}`, html: `<h1>Scheduled handler fired</h1> <p>Cron: <code>${controller.cron}</code></p> <p>Scheduled time: ${now}</p>` }); await env.AUTOMATION_STATE.put('last_run', now); }
html, not text? Resend's API accepts both. HTML renders formatted in every modern email client; plain text falls back gracefully if HTML is blocked. You can also pass both — Resend sends the multipart MIME correctly. For digests, HTML is worth it: you can use <h2> headers, lists, links, and the result reads like a real newsletter.wrangler dev --test-scheduled. Fire it with curl. Check your inbox. Does the "Automation test" email arrive within ~30 seconds?RESEND_API_KEY is wrong or not set. Re-run wrangler secret put RESEND_API_KEY and paste the key carefully.from domain isn't verified. Either verify in Resend dashboard or use onboarding mode (send to your own email only).\` or use ordinary strings.fetch without checking res.ok — Resend returns 403 for unverified domains and the handler logs success anywayfrom: "[email protected]" hard-coded instead of your verified domain — silently falls back to Resend's sandbox senderAuthorization: Bearer header format — returns 401 with a cryptic errorres.ok is checked. from: uses a domain you verified. Resend dashboard → Emails tab shows the send. Your inbox has the email (check spam for first 2-3 sends from a new domain).
Everything up to now has been scaffolding. Cron triggers, scheduled handlers, KV state, an email that says "test". Useful but not useful. This segment puts it together: every morning at 08:00 UTC your Worker fetches three RSS feeds, sends the new items to Claude for a 200-word summary, formats the results as HTML, and emails you a digest. A week from now you'll have read a summary of your industry's news every day without opening a single feed reader.
This is a real project. The scenario is real, the RSS feeds are real, the code runs. By the end of this segment your Worker does something worth keeping.
Workers don't have DOMParser and most XML libraries bloat your bundle. For RSS you don't need a proper parser — the structure is predictable and a handful of regexes will extract what you want. This is the kind of code you ask Claude to write, not memorise.
export async function fetchFeed(url, limit = 5) { const res = await fetch(url, { headers: { 'User-Agent': 'Automation-Addon/1.0' } }); if (!res.ok) throw new Error(`Feed ${url}: ${res.status}`); const xml = await res.text(); // Extract <item> blocks (RSS) or <entry> blocks (Atom) const itemRe = /<(item|entry)>([\s\S]*?)<\/\1>/g; const items = []; let m; while ((m = itemRe.exec(xml)) !== null && items.length < limit) { const block = m[2]; items.push({ title: extract(block, /<title>([\s\S]*?)<\/title>/), link: extract(block, /<link[^>]*>([\s\S]*?)<\/link>/) || extract(block, /<link[^>]*href="([^"]+)"/), description: extract(block, /<description>([\s\S]*?)<\/description>/) || extract(block, /<summary[^>]*>([\s\S]*?)<\/summary>/) || '' }); } return items; } function extract(s, re) { const m = s.match(re); if (!m) return null; return m[1] .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1') .replace(/<[^>]*>/g, '') // strip HTML .trim(); }
fast-xml-parser via npm. For now, 10 lines of regex cover what you need.# When the regex approach fails on a feed you care about,
# paste the feed URL + this prompt into Claude instead of debugging regex:
I'm parsing RSS inside a Cloudflare Worker. Workers have no DOMParser,
no npm packages pre-installed, and bundle size matters.
The feed at <PASTE_URL_HERE> is failing my regex-based parser because
<describe the specific symptom — missing fields, XML namespaces,
unexpected structure, etc.>.
Give me:
(1) A diagnosis of what makes this feed different from standard RSS 2.0
(2) Updated parser code that handles this feed AND still works for
normal RSS/Atom (don't break the common case to fix the edge case)
(3) One test input showing what the output looks like for this feed
Keep it vanilla JavaScript — no npm packages. Under 50 lines.Why bother the AI for this? RSS-in-the-wild is a thirty-year-old format full of personal decisions made by individual site owners. Debugging regex against one specific feed is low-value repetitive work; explaining the failure to Claude and letting it rewrite the parser is exactly the division of labour BUILD taught you.
You already wrote a Claude-calling function in BUILD Seg 11. This reuses the same pattern — same API, same model, same auth — just a different system prompt and input shape. Add this helper alongside sendEmail:
export async function summariseItems(env, items) { const list = items.map((i, n) => `${n + 1}. ${i.title}\n ${i.description.slice(0, 400)}` ).join('\n\n'); const res = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'x-api-key': env.ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01', 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'claude-sonnet-4-6', max_tokens: 1000, system: `You are a news digester. You will receive ${items.length} items from an RSS feed. For each item, return a JSON object with: { "n": item number, "summary": one sentence under 25 words, "priority": "high" or "low" }. Mark at most 2 items as high priority. Respond with ONLY a JSON array, no preamble.`, messages: [{ role: 'user', content: list }] }) }); if (!res.ok) throw new Error(`Claude: ${res.status}`); const data = await res.json(); // Claude sometimes wraps JSON in ```json fences — strip them defensively const text = data.content[0].text.replace(/^```json\s*|```\s*$/g, '').trim(); return JSON.parse(text); }
This function takes the array of items from fetchFeed, sends their titles + truncated descriptions to Claude with a tight system prompt, and parses the JSON response back into structured data. The system prompt is doing the real work — it constrains Claude to a fixed output shape so the rest of your code can rely on it.
Take the summarised items and render them as clean HTML. Keep the styling minimal — most email clients strip fancy CSS. Inline styles, system fonts, one colour accent for the priority items. Nothing you need to learn — this is ordinary HTML you'd write for any static page.
export function formatDigest(feedResults) { const date = new Date().toISOString().slice(0, 10); const sections = feedResults.map(({ feedName, items, summaries }) => { const rows = items.map((item, i) => { const s = summaries[i] || { summary: item.title, priority: 'low' }; const isHigh = s.priority === 'high'; return ` <tr> <td style="padding:8px 0;border-bottom:1px solid #eee"> ${isHigh ? '<span style="color:#ff6a1f;font-weight:700">● </span>' : ''} <a href="${item.link}" style="color:#222;text-decoration:none">${item.title}</a> <div style="font-size:13px;color:#666;margin-top:2px">${s.summary}</div> </td> </tr>`; }).join(''); return `<h2 style="font-size:18px;margin:24px 0 8px">${feedName}</h2><table width="100%">${rows}</table>`; }).join(''); return `<div style="font-family:system-ui,sans-serif;max-width:640px;margin:0 auto;color:#222"> <h1 style="font-size:22px;border-bottom:2px solid #222;padding-bottom:8px"> Daily Digest · ${date} </h1> ${sections} <p style="font-size:12px;color:#999;margin-top:32px"> Generated by your scheduled Worker. Priority marked with <span style="color:#ff6a1f">●</span>. </p> </div>`; }
HTML emails are old-fashioned — tables for layout, inline styles, no external resources. Everything here follows those conventions. It looks plain, renders identically in Gmail / Outlook / Apple Mail / Fastmail, and doesn't depend on any CSS file loading. Which matters, because most mail clients block external resources.
# When you want the digest to look like *yours* rather than the default,
# paste this into Claude along with a screenshot or description of the
# aesthetic you want:
Redesign the HTML digest template in my formatDigest function.
Constraints (non-negotiable — these are email-client rules, not preferences):
- Tables for layout, not flexbox or grid
- Every style inline — no <style> blocks, no external CSS
- System fonts only — no @font-face, no Google Fonts
- Maximum width 640px, mobile-friendly
- One accent colour (I want: <YOUR_BRAND_HEX>)
- Must render correctly in Gmail, Outlook web, Apple Mail
Aesthetic I want: <describe — minimal? magazine-style? newsletter?
tight? spacious?>
Keep formatDigest's signature the same — it still receives the
feedResults array with { feedName, items, summaries } shape.
Return the updated function and one screenshot of what the email
output will look like described in words.The email-client constraint box is the real value here. Claude knows about flexbox-breaks-in-Outlook, Gmail-strips-style-tags, Apple-Mail-hates-negative-margins — it just needs to be told the constraints matter. Without the constraint list, Claude will give you a beautiful modern HTML that renders broken in Outlook. With it, you get production-grade email HTML that actually works.
Wire it all together. This is the full end-to-end handler. Read it slowly — every line is pulling its weight:
import { fetchFeed } from './rss.js'; import { summariseItems } from './summarise.js'; import { formatDigest } from './format.js'; import { sendEmail } from './email.js'; const FEEDS = [ { name: 'Anthropic Blog', url: 'https://www.anthropic.com/news/rss.xml' }, { name: 'Cloudflare Blog', url: 'https://blog.cloudflare.com/rss/' }, { name: 'Hacker News', url: 'https://hnrss.org/frontpage' } ]; export default { async fetch(request, env, ctx) { /* your existing BUILD code */ }, async scheduled(controller, env, ctx) { const started = Date.now(); console.log('Digest run started at', new Date().toISOString()); // 1. Fetch all feeds in parallel const feedResults = await Promise.all(FEEDS.map(async (f) => { const items = await fetchFeed(f.url); const summaries = await summariseItems(env, items); return { feedName: f.name, items, summaries }; })); // 2. Format as HTML const html = formatDigest(feedResults); // 3. Send the email await sendEmail(env, { to: '[email protected]', subject: `Daily Digest · ${new Date().toISOString().slice(0,10)}`, html }); // 4. Record success in KV await env.AUTOMATION_STATE.put('last_success', new Date().toISOString()); console.log(`Digest sent in ${Date.now() - started}ms`); } };
npx wrangler deploy. Tomorrow at 08:00 UTC it fires on Cloudflare's edge without you touching anything, and the digest lands in your inbox. You just built the kind of thing companies charge monthly for.
The last three segments are about making it robust — handling failures gracefully, monitoring it, putting safety rails on the blast radius. Plumbing, not features. But important plumbing.
https://hnrss.org/frontpage is reliable.summariseItems handles ```json fences. If Claude added prose before the JSON, the parse fails. Tighten the system prompt: "Respond with ONLY valid JSON. No preamble. No backticks.".js extension. ./rss.js not ./rss.text instead of html in the Resend payload. Check sendEmail.ctx.waitUntil and when to upgrade.
try/catch around the whole handler — which swallows errors silently so you never see themawait the Promise.all — the handler returns before feeds finish, email is emptyinnerHTML-style unsafe concatenation in the HTML formatter — RSS titles with ampersands break the emailEmail arrives. HTML renders (not raw tags). Priority dots appear on high-priority items. Every RSS link in the email is clickable and goes somewhere real. last_success in KV updated.
Push it: git add . && git commit -m "automated daily digest" && npx wrangler deploy. Tomorrow morning you'll have the first real run. Three more segments to make sure it's still running in six months.
The digest you shipped in Segment 6 runs on the happy path. Every RSS feed responds quickly, Claude returns valid JSON every time, Resend accepts every email. Real life is not that clean. Networks flake. APIs 500. Claude occasionally returns "Sure, here's your JSON!" instead of JSON. A production automation has to assume any of those can happen, and keep going when they do.
Three specific things to add: retry with exponential backoff for transient failures, ctx.waitUntil for async work you need the runtime to wait for, and awareness of the CPU-time budget so you know when to upgrade and why. None of these are glamorous. All of them are what separates a script that works once from a script you can trust to run every day for a year.
Most API failures are transient — a brief network blip, a 503 while a service restarts, a timeout that disappears if you try again in two seconds. Retrying blindly can make things worse (hammering a rate-limited API), but retrying with increasing delay usually works. This is the one pattern:
export async function withRetry(fn, { tries = 3, baseMs = 1000 } = {}) { let lastErr; for (let attempt = 1; attempt <= tries; attempt++) { try { return await fn(); } catch (err) { lastErr = err; console.log(`Attempt ${attempt}/${tries} failed:`, err.message); if (attempt === tries) break; // Exponential backoff: 1s, 2s, 4s await new Promise(r => setTimeout(r, baseMs * Math.pow(2, attempt - 1))); } } throw lastErr; }
Now wrap the fragile calls in your scheduled handler:
const feedResults = await Promise.all(FEEDS.map(async (f) => { try { const items = await withRetry(() => fetchFeed(f.url)); const summaries = await withRetry(() => summariseItems(env, items)); return { feedName: f.name, items, summaries }; } catch (err) { console.error(`Feed ${f.name} failed after retries:`, err); return { feedName: f.name, items: [], summaries: [], failed: true }; } }));
awaits are the single most common reason scheduled Workers silently drop work. Paste your updated scheduled() handler into Claude with this prompt: "Audit this Cloudflare Worker scheduled handler for un-awaited async calls, fire-and-forget Promises, and any async work that might not finish before the handler returns. List each one by line number with the fix." Claude is particularly good at this flavour of review because it's mechanical pattern-matching, not creative work.Here's a gotcha that burns people: the scheduled handler can return before async work finishes, and the runtime will kill anything still in flight. Your console.log('Digest sent') runs, the function returns, and the .then() you didn't await gets terminated. Logs that look fine in dev vanish in production.
The fix: ctx.waitUntil(promise). It tells the runtime "don't tear down the environment until this Promise resolves." For anything fire-and-forget (analytics, secondary logging, non-critical side-effects), use waitUntil. For anything you await directly, you don't need it — the await keeps the function alive.
// ❌ BAD — the .then gets killed when scheduled returns async scheduled(controller, env, ctx) { await sendEmail(env, {...}); logToAnalytics(env, 'digest_sent'); // ← fire and forget } // ✅ GOOD — waitUntil keeps the runtime alive until logging finishes async scheduled(controller, env, ctx) { await sendEmail(env, {...}); ctx.waitUntil(logToAnalytics(env, 'digest_sent')); }
Rule of thumb: if you call an async function without await in a scheduled handler, wrap it in ctx.waitUntil. Otherwise Cloudflare may or may not finish it — the behaviour depends on exactly when the runtime decides to tear down.
Cloudflare's free plan limits Workers to 10 milliseconds of CPU time per invocation. That sounds tiny, but "CPU time" ≠ "wall-clock time." It's the time your code is actively computing — string manipulation, JSON parsing, regex matching. Time spent waiting for network responses (your RSS fetches, Claude calls, Resend) doesn't count.
For the digest we just built, the CPU work is: parsing RSS (~1-2ms per feed), formatting HTML (~1ms), JSON parsing Claude's response (~<1ms). You're almost certainly under 10ms total. The wall-clock time — how long the whole handler takes, waiting for 3 RSS feeds + 3 Claude calls + 1 Resend call — might be 10+ seconds, but that's fine on the free plan.
For everything this add-on teaches — a daily digest of 3 feeds with 5 items each — the free tier is comfortably enough. You'll deploy it, it'll run for months, you won't think about the limits. If you later scale to "hourly digest of 50 feeds with 30 items each," you'll want Workers Paid for the CPU budget and the Queues integration. Cross that bridge when you need to.
npx wrangler deploy). Check the Metrics tab for your Worker after the next scheduled run. Is the CPU time per invocation comfortably under 10ms, and did all three feeds succeed?wrangler deploy, watch for errors, check wrangler.toml triggers block still present.
Deploy the retry-wrapped version. Now when something fails once, the system tries three times before giving up. When it finally gives up, you still get a partial digest instead of silence. That's what production-grade means for a one-person automation: it keeps working even when things go wrong.
The worst failure mode of a scheduled automation isn't that it breaks — it's that it stops running silently. Your digest arrives every day for six months, then one Tuesday it doesn't. You don't notice for a week because you're not expecting it. By then the reason has rolled out of the logs. You spend an hour trying to reconstruct what happened.
The fix is cheap: know exactly where to look for recent invocations, what a healthy run looks like, and how to get notified when one doesn't happen. Cloudflare gives you the first two for free. The third is five lines of code.
console.log from your scheduled handler lands here with full filter & query. Search for "Digest run started" to find your handler's invocations specifically.For a one-person automation, Surfaces ① and ② are enough. Check Cron Events weekly. When something looks off, drill into Workers Logs for the console output from that run. That's your entire observability stack.
The dashboards tell you when you look. A self-notifying health check tells you when something's wrong, whether you're looking or not. The pattern: a second cron runs once a day at a different time, reads the last_success KV key you set at the end of each digest run, and emails you if it's too old.
[triggers] crons = [ "0 8 * * *", # Daily digest "0 12 * * *" # Health check, noon UTC ]
Both crons fire into the same scheduled() handler. You differentiate them with controller.cron:
async scheduled(controller, env, ctx) { switch (controller.cron) { case '0 8 * * *': await runDigest(env); break; case '0 12 * * *': await runHealthCheck(env); break; } } async function runHealthCheck(env) { const lastSuccessIso = await env.AUTOMATION_STATE.get('last_success'); const lastSuccess = lastSuccessIso ? new Date(lastSuccessIso) : null; const hoursAgo = lastSuccess ? (Date.now() - lastSuccess.getTime()) / (1000 * 60 * 60) : Infinity; // If the digest hasn't run successfully in the last 28 hours, alert if (hoursAgo > 28) { await sendEmail(env, { to: '[email protected]', subject: '⚠ Automation hasn\'t run in ' + Math.floor(hoursAgo) + 'h', html: `<p>Last successful digest: ${lastSuccess || 'never'}</p> <p>Check Cloudflare Cron Events for the failed invocation.</p>` }); } }
Set-and-forget observability. Once the health check is deployed, you either get a digest every morning (success) or you get an alert email when you don't (failure). Silence is impossible — something will always land in your inbox. That's the only way to monitor a background job you're not actively watching.
alerted_at timestamp to KV and skip re-alerting within 12 hours. Ten extra lines of code; a hundred fewer emails during an outage.last_success key from KV in the dashboard. At the next hourly health check trigger, does an alert email arrive? (Then put a timestamp back to stop the alerts.)wrangler.toml has both crons inside the same [triggers] block, not two blocks. Cloudflare accepts up to 5 crons per Worker on free tier.last_success and Last_Success are different keys. Verify spelling matches the write in your scheduled handler exactly.if (hoursAgo > 28) check calculates wrong if the stored value isn't an ISO string. Log the parsed value; confirm it's a valid Date.alerted_at KV key pattern from the callout above before going live.
Here's the question every automation you build needs to answer before you trust it: if this runs incorrectly 100 times in a row without me noticing, what's the damage?
For the digest we just built, the honest answer is "basically nothing." Worst case: the wrong three RSS feeds get summarised, I get emails I don't read, Cloudflare bills me a few cents. That's fine. Ship it. But the same architecture — cron + AI + output destination — can be pointed at very different problems, and the answer changes fast.
For any automation where blast radius matters, the scheduled run should prepare the output but not ship it. A human reviews and approves; only then does the output go out. The pattern uses two pieces you already have: KV for storing the pending output, and a new fetch endpoint that serves as the review UI.
async scheduled(controller, env, ctx) { const output = await generateOutput(env); // AI work happens here // Store as pending, don't ship yet const id = crypto.randomUUID(); await env.AUTOMATION_STATE.put(`pending:${id}`, JSON.stringify(output), { expirationTtl: 86400 // auto-expire in 24h if not approved }); // Email the human with a review link await sendEmail(env, { to: '[email protected]', subject: `Review required: ${id}`, html: `<a href="https://your-worker.../review/${id}"> Review & approve → </a>` }); } async fetch(request, env, ctx) { const url = new URL(request.url); // GET /review/:id — show the pending output + approve/reject buttons // POST /approve/:id — ship the output, delete the pending key // POST /reject/:id — just delete the pending key // ... standard Worker routing ... }
The scheduled handler becomes a draft generator. The fetch handler becomes the approval interface. Your morning inbox shows "Review required" emails; you click through, skim what the AI produced, approve or reject. Only approved outputs ship.
?token=...) instead of a header, that token ends up in server logs, email archives, and browser history. Use a cookie set via a one-click login, or rotate the APPROVAL_TOKEN every time something is approved. For a solo automation the risk is low; for anything multi-user it's a rejection-blocking concern.# Paste this into Claude when you're ready to build out the approval UI.
# Claude will return the full fetch-handler routing + the review HTML page.
I'm building a human-in-the-loop approval flow inside the same Cloudflare
Worker that runs my scheduled digest. The scheduled handler already
writes pending outputs to KV with keys like `pending:<uuid>` and emails
me a review link.
Build me the fetch handler for that Worker with three routes:
1. GET /review/:id
- Reads the pending output from env.AUTOMATION_STATE.get(`pending:${id}`)
- Returns an HTML page showing the pending output with two buttons:
"Approve & send" and "Reject"
- 404 if the id isn't found or already processed
- Minimal inline-styled HTML (no external CSS), dark theme matching
the BUILD aesthetic (#0b0b0c background, #f3efe9 text, #ff6a1f accent)
2. POST /approve/:id
- Reads the pending output, calls the existing sendEmail() (or whatever
shipping function I pass in) with the approved content, deletes the
pending key, returns a "Sent" confirmation page
3. POST /reject/:id
- Just deletes the pending key, returns "Rejected, discarded" page
Constraints:
- Use standard fetch API routing (URL + pathname matching), no framework
- Add simple token-based auth so only I can approve — check a bearer token
in the request that matches env.APPROVAL_TOKEN (I'll add that secret)
- Return 401 for any request without the correct token
- Every async call awaited, no ctx.waitUntil fire-and-forget in the approve
flow (I need to know if the send succeeded)
Return: the complete fetch handler code + one paragraph on what to add to
wrangler.toml and what secret to set.Why this prompt works: it names the existing infrastructure (KV key shape, sendEmail signature), specifies the exact three routes with their expected behaviour, gives the styling constraints up front, and asks for the config changes needed to deploy. Claude returns a complete, deployable implementation because you've left it no ambiguity. Compare to "write me an approval UI" — which returns something generic you'd have to rewrite.
The digest running in your account is the portfolio piece. Treat it like one. Three small steps turn it from "a thing that runs" into "a thing someone visiting your GitHub immediately gets."
git add . && git commit -m "automation add-on complete" && git push. Your GitHub now shows the full automation code next to your BUILD tool. The commit history documents the journey — every pedagogical segment left a commit.# Writing your own portfolio copy is the hardest writing most developers
# ever do — you underplay everything. Claude is less modest about your
# work than you are. Use that.
I built an automation add-on for an AI tool I already had deployed.
Here's what it does:
- Fires every morning at 08:00 UTC on a Cloudflare Workers cron trigger
- Fetches three RSS feeds I care about (Anthropic blog, Cloudflare blog,
Hacker News)
- Sends each new item to Claude via the Anthropic API for a one-sentence
summary + priority flag
- Formats the summaries as an HTML digest email
- Delivers it to my inbox via Resend
- Writes a `last_success` timestamp to Cloudflare KV
- A second cron at noon UTC runs a health check — if the 8am run didn't
complete, it emails me an alert
Running on Cloudflare Workers free tier. Retry logic with exponential
backoff. Monitoring via the Cron Events dashboard.
Give me:
(1) A README section I can paste into my project's main README.md — 4-6
bullet points, plain English, no marketing fluff. Link-placeholders
where screenshots would go.
(2) One LinkedIn "Featured" tile description — 280 characters max, past
tense, "built" or "shipped" not "worked on".
(3) Three variations of a one-sentence portfolio one-liner at different
technical specificity levels (recruiter-friendly / hiring-manager /
engineering-peer).
Make the copy credible, not oversold. The tool is useful but small.
Describe it like a senior developer describes a weekend project — calm
confidence, not hype.Why this prompt is calibrated to work. Three variations at different audiences (recruiter / manager / peer) stops Claude from defaulting to one register. Explicit "calm confidence not hype" in the tone instruction cuts the LinkedIn-adjacent marketing voice that most AI-generated portfolio copy falls into. The factual bullet list at the top prevents Claude from inventing features your automation doesn't have.
Key terms for this add-on
0 8 * * * for 8 AM daily).fetch()). Receives (event, env, ctx).wrangler.toml for config.