0%
Add-On · Automation
EverythingThreads
AI CLARITY · AUTOMATION ADD-ON

Personalise your Automation add-on

The nine segments are identical either way. Personalised retunes every example, scenario, and tutor response to your role and sector.

or

EverythingThreads · ICO: C1896585 · Privacy Policy

Segment 1 of 9 · Automation Add-On
By the end of this segment you will be able toExplain the difference between scheduled, event-driven, and polling compute — and know which one your automation needs.
Explain the difference between scheduled, event-driven, and polling compute — and know which one your automation needs. This is the judgment call that decides the entire architecture of anything you automate from here on.

AI That Runs Without You

⏱ ~15 min• The frame, not the code
▸ 30-second preview — where you're headingEverything you built in BUILD requires you to click a button. This add-on changes that. Your tool wakes up, does the work, delivers the result — while you're asleep.
Everything you built in BUILD requires you to click a button. This add-on changes that. By the end of these nine segments your tool will wake up on a schedule, fetch fresh data, send it through Claude, and email you the result — without you touching anything. No clicking. No remembering. No babysitting.

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.

Stuck? This add-on assumes you finished BUILD with your Worker deployed. If anything breaks, use BUILD Seg 0's 5-step self-service flow — same debug habits apply here.
Your Stack — From Tool to System
BUILD Tool
Completed
Scheduled Worker
Building now
Email Digest

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.

What doesn't change from BUILD: same Worker, same API key, same Anthropic integration, same 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.

The three ways compute gets triggered

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.

① Scheduled
"Every day at 8am"
Fires on a clock. Doesn't care what happened — just runs on the schedule you set. Cron triggers.
Use when: the work is time-based ("every morning", "first of the month"). Not reactive to anything.
② Event-driven
"When X happens"
Fires when something external tells it to. A webhook, a file upload, a new row in a spreadsheet. Reactive.
Use when: there's a trigger event in the world you can listen for. Near-instant response.
③ Polling
"Check every N minutes"
Fires on a clock, but the work is "did anything change?" Technically a scheduled job faking event-driven. Often a smell.
Use when: no webhook available and you can't wait for a schedule. Almost always can be replaced by #2 if you look hard enough.
The rookie mistake: reaching for polling when a schedule would work. "I'll check the RSS feed every 5 minutes" almost always means "once a day is fine, I just didn't think about it carefully." Polling burns your free-tier request allocation, generates noise in your logs, and usually delivers the same outcome as a clean 08:00 UTC cron. Default to scheduled. Only reach for polling when you've proven a schedule won't work.

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.

Mental Model Set
You know which pattern you're building. Scheduled. Cron. Every morning at 8am.
Segment 2 writes the first cron trigger. Your Worker will fire on a schedule by the end of the next ~15 minutes.
Segment 2 of 9 · Automation Add-On
By the end of this segment you will be able toConfigure a cron trigger in wrangler.toml, write a scheduled() handler, and deploy a Worker that fires on schedule.
Configure a cron trigger in wrangler.toml, write a scheduled() handler alongside your existing fetch handler, and deploy a Worker that fires on a schedule. No code from this segment calls Claude yet — that's Segment 6. First we prove the plumbing works.

Cron Triggers — The Basics

⏱ ~20 min⬡ Desktop required

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.

A quick reality check on pricing. Cron triggers are included in Cloudflare's Workers free plan — up to 5 triggers per Worker, 100,000 requests/day total across all invocations, with a 10ms CPU-time limit per invocation. For everything this add-on teaches, the free tier is plenty. Heavier workloads eventually need Workers Paid ($5/month) for longer CPU budgets, but we'll flag that explicitly when it comes up in Segment 7.
Step 1: Add the trigger to wrangler.toml

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:

wrangler.toml · add this block
[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.

UTC is non-negotiable. This bites everyone at least once. You schedule a "morning digest" for 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.
Step 2: Add the scheduled() handler

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:

src/index.js · add the scheduled 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.
Cron syntax cheat sheet

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 * * *
Daily at 08:00 UTC
0 8 * * 1
Mondays at 08:00 UTC (1 = Monday on Cloudflare)
*/15 * * * *
Every 15 minutes
0 9 1 * *
First of every month at 09:00 UTC
0 9 * * 1-5
Weekdays at 09:00 UTC (Mon-Fri)
0 0,12 * * *
Twice daily — midnight and noon UTC
The day-of-week gotcha. Cloudflare indexes days of the week 1=Sunday through 7=Saturday, which is different from classic Unix cron (where 0=Sunday and 6=Saturday). If you've used cron before, this will catch you. Use the three-letter abbreviations if you want portability: 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."

▸ Try it now · 2 minTranslate three cron expressions into English before you go on.
Without checking crontab.guru, write down what each of these means: (1) 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.
Checkpoint — First Scheduled Deploy
Run 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?
Your Worker now has a scheduled trigger registered with Cloudflare. It will fire at the next occurrence of your cron expression, routed to whatever edge location has spare capacity. If you set it for tomorrow 08:00 UTC, you could wait until then — or you could test it locally right now, which is what Segment 3 is for. That's the whole reason local testing exists.
▲ VERIFICATION LAYER · Before You Move On
Common ways AI gets THIS wrong:
  • AI suggests adding the trigger in the Cloudflare dashboard instead of wrangler.toml — then the next wrangler deploy silently overwrites it
  • AI mixes Quartz cron (6 or 7 fields with seconds/year) with standard cron (5 fields) — Cloudflare accepts 5 only
  • AI writes local-timezone expressions and doesn't flag the UTC conversion
The 30-second check: Dashboard 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.
Cron Trigger Live
Your Worker will now fire on a schedule. For real. On Cloudflare's edge.

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.

Segment 3 of 9 · Automation Add-On
By the end of this segment you will be able toTrigger your scheduled handler locally on demand, simulate any cron pattern, and iterate on scheduled logic without waiting 24 hours.
Trigger your scheduled handler locally on demand, simulate any cron pattern via curl, and iterate on your scheduled logic without waiting for the real clock. This segment is short but it's the single most important workflow habit in the whole add-on.

Local Testing — Don't Wait 24 Hours

⏱ ~10 min• The iteration unlock

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.

This is the habit that makes automation buildable. Without local testing you are writing code blind and deploying hope. Every professional who builds scheduled Workers starts the wrangler dev server in one terminal, hits it with curl in another, watches the console output in the first. That's the whole loop. Once you internalise it, automation feels identical to normal web development.
Step 1: Start the dev server with scheduled testing enabled

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.

Terminal 1 — keep this running
# Starts a local Worker with scheduled testing route enabled
npx wrangler dev --test-scheduled

You'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:

Terminal 2 — fire the scheduled handler
# 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.

Step 2: Simulate different cron patterns + times

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:

Simulate a specific time
# Fire as if it's 2026-01-01 08:00 UTC (timestamp in ms)
curl "http://localhost:8787/__scheduled?cron=0+8+*+*+*&time=1767686400000"
The workflow rhythm — memorise this
  1. Terminal 1: npx wrangler dev --test-scheduled → leave running, watch for console output
  2. Edit src/index.js — wrangler auto-reloads on save
  3. Terminal 2: curl the /__scheduled route → scheduled handler fires
  4. Check Terminal 1 for console output → iterate → repeat
  5. When happy, npx wrangler deploy → real cron takes over

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

Direct Claude for cron translation. Rather than memorising cron syntax or second-guessing yourself, paste a natural-language schedule into Claude: "Give me the Cloudflare cron expression for 'every weekday at 9:30am London time' and show your timezone conversion working." Claude returns the expression plus the UTC conversion explicitly shown. Always verify the output by pasting it into crontab.guru — which renders it back in plain English so you can catch disagreements between your intent and Claude's reading.
Production note: Miniflare (the local runtime wrangler uses) does not fire cron triggers automatically on the real cron schedule. That's the whole reason the --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.
Checkpoint — Prove the Loop Works
Start wrangler dev --test-scheduled. Fire it with curl. Does your scheduled handler's console.log('Scheduled trigger fired at', ...) appear in Terminal 1?
You now have a working dev loop for scheduled Workers. From here on, every change to your scheduled handler is one curl away from verification. No more 24-hour waits. No more deploy-and-pray. This is the habit that makes the rest of the add-on buildable.
Bookmark this segment. You will come back to it every time something in the scheduled handler misbehaves across the next six segments. The wrangler dev --test-scheduled + curl combo is non-negotiable for anything beyond a trivial scheduled job.
Segment 4 of 9 · Automation Add-On
By the end of this segment you will be able toCreate a KV namespace, bind it to your Worker, read and write values from the scheduled handler, and track state between runs.
Create a Workers KV namespace, bind it to your Worker in wrangler.toml, read and write values from the scheduled handler, and use state-tracking patterns (last_run timestamp, processed_ids list) to avoid duplicate work across runs. This is how automations stop being amnesiac.

Persistent State with KV

⏱ ~25 min⬡ Desktop required

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.

When KV is right, when it isn't. KV for: last-run timestamps, small JSON blobs ("the digest I just generated"), lists of processed item IDs, user preferences, API response caches. KV not for: anything relational, anything over ~25 MB per key, anything that needs SQL-style queries. If you outgrow KV, Cloudflare D1 (SQLite at the edge) is the next step up. For this add-on, KV is enough.
KV vs D1 vs R2 — pick the right store

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.

KV
Key → string
Small JSON blobs, timestamps, flags, tiny caches. Eventually consistent. Dead simple. Default pick.
D1
SQLite at the edge
Real tables, SQL queries, joins. When your data has relationships. Strong consistency within a region.
R2
Object storage
Files — images, PDFs, audio, anything over ~1MB. S3-compatible. Zero egress fees.

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.

Step 1: Create a KV namespace

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:

Bridge from BUILD — wrangler.toml refresher

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.

Create the namespace
npx wrangler kv namespace create AUTOMATION_STATE

Wrangler will output something like:

Terminal output — copy the id
🌀  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.

Namespace ID is public. It appears in your Worker bundle and your dashboard. That's fine — without your Cloudflare account credentials, no one can read or write to your namespace. But don't put secrets in the namespace name.
Step 2: Read and write from scheduled()

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.

scheduled() handler with KV state tracking
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:

Storing JSON in KV
// 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) : [];
Optional expiration. 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).
Checkpoint — State Across Runs
Deploy your Worker with the KV binding. Fire the scheduled handler locally with curl twice. On the second run, does the console show a real timestamp for "Last run was:" instead of "never"?
Your automation has memory. Each run knows what the previous one did. From here you can build patterns that depend on history — "process only items added since last run," "don't re-email the same digest twice," "track how long since the last successful run." State is the foundation for everything non-trivial.
▲ VERIFICATION LAYER · State Safety
Common ways AI gets THIS wrong:
  • Forgets await on get/put — returns a Promise object that JSON.parse then crashes on
  • Stores objects directly without stringifying — KV silently stores "[object Object]" and you lose data
  • Uses env.KV when the binding is actually env.AUTOMATION_STATE — undefined, silent failure
The 30-second check: Every KV call is awaited. Every stored value is a string. Dashboard → your Worker → Settings → Bindings shows AUTOMATION_STATE listed.
Persistent Memory
Your automation now remembers what it did yesterday. That's half the battle.
Next: somewhere for the results to go. A scheduled AI analysis that only writes to console.log is invisible. Segment 5 adds email.
Segment 5 of 9 · Automation Add-On
By the end of this segment you will be able toPick an output destination for your automation, set up Resend, verify a domain, and send an email from your scheduled Worker.
Pick an output destination that fits your automation, set up Resend, verify your domain, and send a first email from your scheduled Worker. After this segment the automation can actually deliver something a human will see.

Output Destinations · Making the Work Visible

⏱ ~25 min• The judgment call + the recipe

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.

This is where "automation" becomes real. Up to now you've got a cron, a scheduled handler, persistent state. Useful plumbing, zero user-visible output. The email you'll send in this segment is what transforms all that infrastructure into something someone would actually pay attention to.
The three output destinations — pick one
Email · Resend, 3000/month free
Your inbox is already a place you check. Digest-style updates land naturally here. Best for human-readable summaries.
WE'LL BUILD THIS
#
Slack webhook
A single POST to an incoming-webhook URL. No account required beyond the channel admin's initial setup. Best for team-visible updates or when you want an interruption.
KV + GET endpoint + simple dashboard
Write results to KV, add a 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.

A note on Cloudflare's own Email Sending service. Announced Sept 2025, still in private beta as of April 2026, no public pricing. Once it ships publicly it will likely be the default recommendation here. For now: Resend.
Step 1: Sign up for Resend + verify a domain

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.

  1. Go to resend.com/signup — sign up with email or GitHub
  2. Once in, click DomainsAdd Domain. Enter the domain you own (e.g. yourname.com)
  3. Resend shows you 3 DNS records to add — one TXT for SPF, one TXT for DKIM, one MX. Paste these into your domain's DNS (Netlify DNS, Cloudflare DNS, GoDaddy, whoever hosts your domain)
  4. Wait ~5 minutes, click Verify on Resend. Green checkmarks = domain verified.
  5. Click API KeysCreate API Key. Name it 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:

Add Resend key as a secret
npx wrangler secret put RESEND_API_KEY
# Paste the re_... key when prompted, press Enter

From now on, env.RESEND_API_KEY is available to your Worker. Same treatment as env.ANTHROPIC_API_KEY from BUILD.

If you can't verify a domain: Resend's onboarding-mode works — you can send from their default sender to your own verified email address (the one you signed up with). That's enough for this course. You'll just be limited to emailing yourself, which is exactly what the digest pattern needs anyway.
Direct Claude for DNS record fluency. If "SPF, DKIM, MX" is unfamiliar and you want to understand rather than blindly copy-paste, drop the records Resend shows you into Claude with this prompt: "For each of these DNS records, tell me (1) what it does, (2) what breaks if I skip it, (3) how I'd verify it's live with a dig or nslookup command from my terminal." You'll learn the three foundational email-auth concepts in 5 minutes instead of treating DNS as magic.
Step 2: Send the first email from the Worker

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:

src/index.js · sendEmail helper
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:

scheduled() calls sendEmail
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);
}
Why 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.
Checkpoint — First Scheduled Email
Start wrangler dev --test-scheduled. Fire it with curl. Check your inbox. Does the "Automation test" email arrive within ~30 seconds?
You just made your Worker send an email. From a cron trigger. With one HTTP call. That's the last piece of scaffolding you need — everything from here is about what you put in the email. Segment 6 wires in the real work: RSS feeds, Claude summarisation, full end-to-end automation.
▲ VERIFICATION LAYER · Email Delivery
Common ways AI gets THIS wrong:
  • Writes fetch without checking res.ok — Resend returns 403 for unverified domains and the handler logs success anyway
  • Uses from: "[email protected]" hard-coded instead of your verified domain — silently falls back to Resend's sandbox sender
  • Omits the Authorization: Bearer header format — returns 401 with a cryptic error
The 30-second check: res.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).
Segment 6 of 9 · Automation Add-On
By the end of this segment you will be able toShip a complete automation: RSS feeds pulled, items summarised by Claude, a daily digest emailed at 8am. Real output from real work.
Ship a complete, deployed automation: RSS feeds fetched, items summarised by Claude, a daily digest email arriving in your inbox at 08:00 UTC. No placeholder functions, no console.log only — a real automation that does a real thing. This segment is the point of the add-on.

Your First Complete Automation

⏱ ~60 min⬡ The big one

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.

The scenario: pick three RSS feeds relevant to your work. Each morning at 08:00 UTC the Worker fetches each, extracts the latest 5 items per feed, asks Claude to summarise each item in one sentence and flag the two that matter most. Result: an HTML digest email with 15 one-liners, 2 flagged as priority, links to the originals. Replaces 20 minutes of morning reading with a 2-minute scan.
Step 1: Parse RSS without a library

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.

Minimal RSS parser — src/rss.js
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();
}
This works for ~95% of feeds. Edge cases (namespaced XML, malformed CDATA, podcasts with enclosure tags) will fail. That's fine — the automation silently skips those feeds, the digest just omits them, and you notice at breakfast. If a specific feed matters enough to warrant a real XML parser, install fast-xml-parser via npm. For now, 10 lines of regex cover what you need.
Feeds drift silently. An RSS URL that worked on day 1 can start 301-redirecting to a new URL on day 200, return empty arrays on day 300, or get deprecated entirely on day 365. Your automation will happily send you digests with fewer and fewer items and you won't notice. The health check in Segment 8 catches total failure — but gradual degradation is invisible. Every 3-4 months, manually verify each feed you depend on is still returning what you expect. Ten-minute chore; save the date in your calendar.
Direct Claude — prompt template for an upgraded parser
# 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.

Step 2: Claude summarises each item

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:

src/summarise.js
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.

Direct Claude to tighten its own system prompt. If you're seeing Claude respond with the occasional "Sure, here's the JSON..." preamble, or miss the priority field, paste your current system prompt into a new Claude chat with: "I'm using this system prompt in a scheduled job that parses your JSON response. It occasionally breaks because you include prose before the JSON or omit the priority field. Rewrite it to be maximally resistant to those two failure modes while keeping it under 100 words. Explain what you changed and why." Claude is genuinely good at critiquing its own prompts when given the specific failure modes. Two minutes of this saves hours of silent digest failures.
SHARP M3 The Confident Wrong AnswerClaude will occasionally return JSON with extra prose or missing fields. The JSON.parse will crash, your cron fails silently.
Claude will occasionally return JSON with extra prose before or after it, or missing the "priority" field entirely. The JSON.parse crashes, the cron fails, you get no email, you don't notice. The fix: wrap the parse in try/catch, fall back to un-summarised items, log the Claude output. Never let one bad AI response kill the whole digest. We'll add the try/catch wrapper in Segment 7.
Step 3: Format as HTML digest

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.

src/format.js — HTML digest
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.

Direct Claude — prompt template for a custom digest design
# 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.

Step 4: The complete scheduled() handler

Wire it all together. This is the full end-to-end handler. Read it slowly — every line is pulling its weight:

src/index.js · scheduled handler · complete
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`);
  }
};
This code will fail sometimes. One bad feed, one Claude JSON parse error, one flaky network second and the whole run throws. No email arrives. You don't notice until breakfast. That's fine for now. In Segment 7 we wrap each step in try/catch so a single failure doesn't kill the digest. For this segment, we prove the happy path first.
Checkpoint — Real Automation Running
Start wrangler dev --test-scheduled. Fire it. Check your inbox within a minute. Did a real HTML digest arrive with actual summarised items from your three feeds?
You've built a real automation. It fetches real feeds, summarises with a real AI model, delivers a real email. Deploy it with 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.

▲ VERIFICATION LAYER · Before You Trust It
Common ways AI gets THIS wrong:
  • Adds try/catch around the whole handler — which swallows errors silently so you never see them
  • Forgets to await the Promise.all — the handler returns before feeds finish, email is empty
  • Uses innerHTML-style unsafe concatenation in the HTML formatter — RSS titles with ampersands break the email
The 30-second check: Email 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.
Real Automation Shipped
A digest email lands in your inbox every morning. You built that.

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.

Segment 7 of 9 · Automation Add-On
By the end of this segment you will be able toAdd retries with exponential backoff, use ctx.waitUntil correctly, understand Workers' CPU budget limits, and know when to upgrade.
Wrap fragile calls in retry-with-backoff, use ctx.waitUntil so async work isn't killed mid-flight, understand why the free-tier 10ms CPU budget is usually enough and what to do when it isn't. This segment is what turns a demo into something that runs quietly for a year.

Production Safety · Making It Survive

⏱ ~25 min⬡ The unglamorous important bits

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.

What Cloudflare doesn't do for you: auto-retry failed scheduled invocations. If your handler throws, that's it — the run is lost, no retry, no second chance until the next scheduled fire. You own the retry logic. Good news: the pattern is tiny.
Step 1: Retry with exponential backoff

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:

src/retry.js
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:

Using withRetry around fragile calls
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 };
  }
}));
The key insight: one failed feed shouldn't kill the digest. Wrap each feed individually, not the whole loop. If the Cloudflare blog RSS is down today, your digest still ships with the other two feeds plus a line noting "Cloudflare Blog unavailable." That's the difference between "digest never arrived" and "digest arrived slightly shorter than usual" — a huge UX difference.
Direct Claude to audit your handler for un-awaited async. Missing 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.
Step 2: ctx.waitUntil — don't kill async work mid-flight

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.

When to reach for ctx.waitUntil
// ❌ 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.

The first ctx.waitUntil to fail is what shows up in the Cron Events dashboard. If you have multiple waitUntils and one throws, that error is what Cloudflare logs as the invocation's status. Useful to know when you're debugging — the dashboard shows the first error, not necessarily the most important one.
Step 3: CPU limits — when free tier isn't enough

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.

When you need Workers Paid ($5/month)
  • You're processing large documents inside the Worker (PDF parsing, big XML docs, heavy string manipulation) — CPU work exceeds 10ms
  • You need more than 5 cron triggers per Worker (paid allows up to 1000)
  • You're hitting 100,000 requests/day total across all your Workers
  • You want longer duration limits on Cron Triggers or Queue Consumers — Paid has no duration limit; Free caps at 30 seconds wall time

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.

Measure before you upgrade. Cloudflare's dashboard shows CPU time per invocation in the Metrics tab. Wait until you see real limits being hit (repeated "exceeded CPU time" errors in Cron Events) before paying. 90% of first-year automations never need to leave free tier.
SHARP M4 The Confident GuessAsk Claude "will my digest stay within Cloudflare's 10ms CPU budget?" and it'll confidently say yes without measuring anything.
If you paste your scheduled handler into Claude and ask "will this stay within Cloudflare's free-tier 10ms CPU limit?", Claude will confidently tell you yes — usually with reasoning that sounds plausible. Claude has no way to actually measure CPU time; it's guessing based on what the code looks like. The fix is to not trust the guess: deploy, check the Cloudflare Metrics tab, see the actual p99 CPU time. Claude's estimate and Cloudflare's measurement will often disagree by 3-5x in either direction. Trust the measurement.
Checkpoint — Retry-Wrapped Digest Deployed
Deploy the retry-wrapped handler (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?
Your automation is now production-grade in the literal sense — retry logic active, CPU budget proven empirically, not assumed. From here you can let it run for months and it'll handle transient failures invisibly.
Production-Grade
Your automation survives bad networks, bad AI responses, and flaky days. It keeps going.

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.

Segment 8 of 9 · Automation Add-On
By the end of this segment you will be able toFind your scheduled invocations in Cloudflare's dashboard, query Workers Logs, and set up a self-notifying health check.
Find your scheduled invocations in Cloudflare's Cron Events dashboard, query Workers Logs with filters, and set up a self-notifying health check that emails you when the automation hasn't run. This segment turns invisible background work into something you can verify at any time.

Monitoring · Knowing It Still Works

⏱ ~15 min• The "am I sure?" toolkit

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.

The three monitoring surfaces on Cloudflare
① Cron Events · last 100 invocations
Dashboard → Workers & Pages → your Worker → Settings → Trigger Events → View Events. Shows the last 100 scheduled runs with outcome (success / exception / exceeded-CPU), duration, and the cron expression that fired. First place to look when something's off.
Retention: last 100 only. For longer history, see ② below.
② Workers Logs · filterable log stream
Same Worker page → Logs tab. Enable persistent logs (on by default for new Workers). Every console.log from your scheduled handler lands here with full filter & query. Search for "Digest run started" to find your handler's invocations specifically.
Retention: 3 days on free plan, 7 days on paid. Good for recent investigations.
③ GraphQL Analytics API · long-term metrics
For anyone building real observability dashboards: Cloudflare exposes invocation counts, error rates, CPU time distribution via GraphQL. Out of scope for this course but worth knowing exists when you outgrow the built-in dashboards.

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 self-notifying health check

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.

Add a second cron for the health check
[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:

scheduled() routing by cron expression
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>`
    });
  }
}
Why 28 hours? The digest runs every 24. A 28-hour threshold means "you missed one run, come look." A 48-hour threshold would mean "you missed two" — probably too late. Tune based on how tolerant you are. For critical automations, drop it to 25 hours. For low-stakes, 48 is fine.

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.

Alert-storm risk. If your main cron stays broken for a week and your health check fires every hour, you'll get ~168 alert emails — all telling you the same thing. Most monitoring systems solve this with "don't alert again for 6 hours after alerting." Cheap version for a single-person automation: write an alerted_at timestamp to KV and skip re-alerting within 12 hours. Ten extra lines of code; a hundred fewer emails during an outage.
Checkpoint — Health Check Is Monitoring Itself
Deploy the health check. Manually delete the 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.)
Your automation now has a second automation watching it. If the digest stops running, you know within an hour. Most solo-built automations never get monitoring this simple — you now have better observability than the average production system.
Observable
You can tell at any moment whether the automation is still alive. And it'll tell you when it isn't.
Deploy this version. Check Cloudflare's Cron Events tab tomorrow — you should see both cron expressions listed with their recent invocations. One last segment: the safety question every automation needs to answer before it ships.
Segment 9 of 9 · Automation Add-On
By the end of this segment you will be able toAnswer the blast-radius question for any automation you build, add human-in-the-loop review where it matters, and ship the digest to your portfolio.
Answer the blast-radius question honestly for any automation you build from now on, add a human-in-the-loop review step when the blast-radius demands it, and ship the digest properly — README, repo, portfolio close. This segment is about the question you should ask before you ever deploy a scheduled AI job.

Blast Radius + Ship

⏱ ~20 min• The safety question, then the portfolio close

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.

The blast-radius spectrum. Emails to yourself = blast radius zero. Emails to customers = blast radius significant. Posts to your company's social channels = blast radius existential. Automatic purchases from a supplier? Automatic price changes? Automatic replies in a customer-service queue? These are real things people are building with the exact stack this add-on taught, and they all need a pattern the digest doesn't: human-in-the-loop review.
Human-in-the-loop · the approval flag pattern

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.

Approval pattern · outline
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 &rarr;
    </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.

When to use this pattern: anything that sends to people other than yourself, anything that costs money each time it runs, anything that's hard to undo (sending emails, posting content, making API calls that charge). For the digest we built, it's overkill. For an automated "daily customer outreach," it's the minimum safe pattern.
Token leakage surface. The approval link in the email contains the review UUID and gets handled by an authenticated endpoint. If you use a bearer-token-in-URL pattern (?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.
Direct Claude — prompt template for the full HITL review UI
# 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.

SHARP M1 The Confident ShipAI will tell you your automation is "production-ready" without asking about blast radius.
When you ask AI to review your automation code, it will focus on the technical correctness — retry logic, error handling, CPU budgets — and confidently tell you it's ready. What it won't ask is "what's the blast radius if this runs wrong 100 times?" That's the question you have to bring. AI gets M1 wrong constantly with automation specifically, because the technical code can be perfect while the behavioural design is unsafe.
Ship it · portfolio close

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

① Update the README
Add a new section to your BUILD project's README: Automation. Four bullet points — what the automation does, how often, where the output goes, how to set it up on your own account. Link to a screenshot of a recent digest email (with personal info blurred). Anyone browsing your repo immediately sees this is more than a demo.
② Push the code
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.
③ The "about" line
On your GitHub profile / LinkedIn / wherever you list projects, one sentence: "An AI tool I built — deployed live, with an automated daily digest that runs on Cloudflare Workers and Claude." That's a harder line to write than most portfolio descriptions. Because most portfolio projects aren't still running.
What you actually did in this add-on. You didn't just add a feature. You built a system. Scheduled compute, persistent state, external API integration, failure handling, observability, safety patterns. Most professional developers don't wire all of those together until they've been on the job for a year. You did it in nine segments.
If you extend this to email other people — add a privacy policy. The automation you built is fine without one because you're emailing yourself; no third-party data is involved. The moment you change the recipient list to include customers, teammates, or anyone else, you're processing their email addresses for a "digest service" and GDPR / CCPA privacy-policy obligations kick in. The Chrome Extension add-on Seg 10 has a Claude prompt template for drafting a privacy policy — it transfers almost directly: swap "Chrome extension" for "scheduled email digest", list Resend + Anthropic as your processors, and host the result at a public URL on your Netlify site. Ten minutes; prevents an ugly conversation later.
Direct Claude — draft README + portfolio one-liner
# 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.

Automation Add-On Complete
A real scheduled AI system running on your account. Your BUILD tool now works while you sleep.
FINAL · TESTS JUDGMENT NOT TRIVIA
You want to build a daily automation that posts a generated social media update to your company's public Twitter account. Same architecture as the digest — cron, Claude, output. What's the single most important change to make before shipping?
Upgrade to Workers Paid for the CPU budget
Not the most important. The CPU work is similar to the digest — within free tier limits. The blast radius is what changed, not the compute cost.
Add more retries to the scheduled handler
Retries help with reliability, but they don't help with the fundamental safety problem. A confidently-wrong AI-generated tweet going out 3 times is worse than going out once.
Add a human-in-the-loop approval step before the tweet goes live
Correct. The blast radius changed from "emails to yourself" (low) to "public posts on a company account" (high). The technical architecture from the digest is fine; the behavioural design isn't. Generate the draft on schedule, email yourself for approval, only post after human review. This is the pattern every responsible "AI does things for my company" automation needs.
Add rate limiting so it doesn't post too often
Rate limiting solves a different problem. The cron already fires only once a day — rate isn't the issue. The issue is whether any single AI-generated post should go out without human review.
Add-On Complete.
A scheduled digest in your inbox every morning. A health-check email when it doesn't arrive. The code on GitHub, the automation live on Cloudflare's edge. Built in nine segments, on free infrastructure, reusing the Worker you finished BUILD with. That's the shape of a real production system. Enjoy the first digest.
What's next: the Chrome Extension add-on teaches a different distribution path (browser-first tools, Chrome Web Store publishing). The PWA add-on teaches installability + offline-first UX. You can do them in any order. SCALE is the deeper system course — when your tool has real users and the verification habits aren't enough anymore.
Glossary

Key terms for this add-on

Blast radius
The number of things that break when something goes wrong. Cron jobs and service workers have massive blast radius — one bad deploy affects every run.
Cron expression
A 5-field string that tells Cloudflare when to fire your scheduled handler (e.g. 0 8 * * * for 8 AM daily).
ctx.waitUntil
A Worker-runtime method that extends a scheduled handler's lifetime so async work like DB writes finishes before the Worker terminates.
Exponential backoff
A retry strategy where the delay between attempts doubles each time (1s → 2s → 4s → 8s). Prevents stampede and gives transient failures time to resolve.
Human-in-the-loop (HITL)
A safety pattern where an automated action pauses for human approval before executing. Essential for anything with real-world side effects.
Idempotent
A request that produces the same result whether run once or many times. Safe to retry. Non-idempotent actions (sending money, emails) need extra care.
KV (Key-Value)
Cloudflare's edge-distributed key-value storage. Persistent across Worker invocations. 53 mentions in Automation — the go-to for cross-run state.
Polling
Repeatedly checking a data source for new items (RSS feed every hour, API every 15 min). The cron-triggered pattern the Automation add-on teaches.
Resend
A developer-first email delivery service. 3000 emails/month free tier. Used in this add-on to deliver digest emails.
RSS feed
An XML format that exposes updates from a site as a structured list. The Automation example fetches these, summarises via Claude, emails the result.
scheduled() handler
The Cloudflare Worker export that fires on cron triggers (instead of or alongside fetch()). Receives (event, env, ctx).
Scheduled worker
A Cloudflare Worker that runs on a timer instead of in response to HTTP requests. No user trigger needed.
withRetry
Helper function that wraps a fetch/API call with retry logic + exponential backoff. Defined in Automation Seg 7; reused in PWA Seg 8.
wrangler
Cloudflare's CLI tool for creating, testing, and deploying Workers. Uses wrangler.toml for config.
wrangler secret put
Command to securely upload environment secrets (API keys) to a deployed Worker without committing them to git.
Welcome back — resume at slide ??