Cloudflare cron to discord
Skill okayus/okayus-skills/skills/cloudflare-cron-to-discord
Agent Skills for Claude Code / agentskills.io-compatible agents. Cloudflare Workers + Discord integration patterns.
npx -y skills add okayus/okayus-skills --skill cloudflare-cron-to-discordAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Wire a Cloudflare Workers Cron Trigger to post to a Discord Webhook using the pure-function-then-boundary architecture. Use when adding scheduled notifications (daily summaries, reminders, health pings) to a Workers app. Covers the domain/boundary split (pure message builder + throw-less boundary sender), environment-timezone-independent UTC→JST conversion, vitest mock testing of the boundary, dev/prod webhook naming discipline that makes cross-contamination detectable, the secret management workflow, and the well-known `/__scheduled` dev caveat with `@cloudflare/[email protected]`. Assumes you already have a working Cloudflare Workers skeleton (see cloudflare-workers-deploy-skeleton).
The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
9.3 KB, ~2.0k tokens by cl100k_base, as published. Nobody here has run it
Cloudflare Cron → Discord Webhook
Send scheduled notifications from a Cloudflare Worker Cron Trigger to a Discord channel, with an architecture that's safe to extend into real domain logic later.
Prerequisite: a working Workers skeleton with scheduled handler wired (see the cloudflare-workers-deploy-skeleton skill).
When to use
- Daily / hourly / weekly Discord notifications (reminders, summaries, health checks)
- Any scheduled outbound message from Workers to any webhook-style recipient (Discord is the example; the architecture transfers to Slack, Teams, etc. by swapping the HTTP contract)
- As a walking skeleton before real domain logic is ready — skeleton message first, then replace the pure builder with real logic later
Do not use for:
- High-frequency fanout (Cron trigger is limited to 1-minute granularity)
- Bidirectional Discord interactions (use Discord Bot with Interactions, not webhooks)
- Anything requiring Cron retry semantics (Workers Cron is fire-once-per-schedule; implement retries on top yourself)
The architecture: pure → boundary
scheduled(event, env)
└─ runScheduled(event, env)
├─ buildMessage(now: Date): Payload ← pure function
└─ postToDiscord(url, payload): Promise<void> ← boundary, never throws
Two rules:
- The builder is pure. No I/O, no
env, no clock reads (takenowas a parameter). This makes it trivially unit-testable and trivially replaceable (Phase N skeleton message → Phase N+1 domain logic). - The sender never throws. All fetch failures are
console.error'd and swallowed. Reasons:- Cron runs on a schedule. A single failed notification should not prevent the next one.
- There's no retry semantics to leverage anyway.
- Throwing from a scheduled handler doesn't help the user — it just shows up as an opaque exception in the Dashboard.
Core deliverables
worker/time.ts— pure UTC→JST conversion (or your timezone of choice). Environment-TZ-independent usinggetUTC*()methodsworker/discord.ts—buildSkeletonMessage(now)(pure) +postToDiscord(url, payload)(boundary, throw-less)worker/cron.ts— wire builder → senderworker/types.ts— addDISCORD_WEBHOOK_URL: stringtoBindings- vitest setup —
vitest.config.ts+ ten tests (6 time + 4 discord) - Production Discord Webhook created and installed via
wrangler secret put DISCORD_WEBHOOK_URL - Development Discord Webhook installed in
.dev.vars(separate channel from prod)
See references/implementation.md for the full source code, ready to paste in.
Dev/prod discipline: webhook naming
Always create two webhooks in separate Discord channels:
<project-name> (dev)→ channel#<project>-dev<project-name> (prod)→ channel#<project>(or wherever prod lives)
Why the naming matters: webhook values (https://discord.com/api/webhooks/<id>/<token>) are not visible after installation (wrangler secret list shows names only). If you accidentally put the dev URL into the prod secret (or vice versa), the only way to detect it is to look at where messages actually arrive.
If a message posted by "routine-tasks (dev)" shows up in the prod channel, you've cross-contaminated. Naming the webhooks identifiably makes this detectable in 30 seconds.
Full operational workflow in references/operations.md.
The vitest testing pattern
For the pure builder: standard table-driven tests with known UTC inputs and expected JST output strings.
For the boundary sender: vi.spyOn(globalThis, "fetch") to mock fetch, then assert:
- Success path: fetch is called with the right URL and method
- Non-2xx: does NOT throw, logs to
console.error - Fetch rejection: does NOT throw, logs to
console.error
Environment must be "node" (not "jsdom"), and the vitest.config.ts must be separate from vite.config.ts — loading the @cloudflare/vite-plugin into the test runner causes unhelpful dev-server side-effects.
Full test code in references/testing.md.
Production setup flow
High-level; detailed commands in references/operations.md:
- User: Create two Discord webhooks with the dev/prod naming discipline above
- User:
wrangler secret put DISCORD_WEBHOOK_URLand paste the prod URL - User: Copy
.dev.vars.example→.dev.vars, paste the dev URL - Agent: Copy
time.ts/discord.ts/ vitest files from references, wirecron.ts - Agent:
pnpm install→pnpm check→pnpm test(10/10 green) - Agent: Commit → push → user merges → auto-deploy
- Validate prod:
wrangler tailshows[cron] fired at ...at scheduled time, Discord prod channel receives[<project>] cron fired at <JST> (skeleton)message
The /__scheduled dev caveat
@cloudflare/[email protected] does not implement /__scheduled?cron=<expr> for local Cron testing — requests fall back to the SPA HTML. 1.x fixes it but requires wrangler@^4.
Your options for local Cron verification:
- Skip local Cron testing — rely on vitest mocks + production verification. This is the practical default unless you're iterating heavily on Cron schedule changes
- Bump
@cloudflare/vite-plugin@^1.x+wrangler@^4simultaneously — major version jump, treat as its own task - Use Cloudflare Dashboard → Triggers → "Send event" / "Run now" button for on-demand prod testing (doesn't help local, but enables fast prod verification)
Full tradeoffs in references/operations.md.
Diagnosing "prod Discord isn't receiving"
When the cron fires but messages don't land, work the symptom matrix in references/operations.md:
| Local dev ch | Prod ch | Likely cause |
|---|---|---|
| ✓ arrives | ✗ nothing | Prod secret wrong, or deploy not reflected yet, or Cron not firing |
| ✗ nothing | ✓ arrives | .dev.vars missing, or /__scheduled not routed (skip path) |
| ✗ nothing | ✗ nothing | Code-level issue (cron.ts / discord.ts implementation) OR webhook URL itself invalid |
| Dev msg in prod ch | — | .dev.vars has prod URL (swap fix + rotate) |
| Prod msg in dev ch | — | Prod secret has dev URL (swap fix + rotate) |
Detailed diagnostic flow in references/operations.md — including Dashboard Observability setup, wrangler deployments list-based reflection check, and webhook rotation procedure with reflection-verification.
Progressive disclosure: when to replace skeleton
When real domain logic arrives (e.g., "notify about unfinished tasks from D1"):
- Keep the signatures of
buildMessageandpostToDiscordstable. This is the whole point of the boundary: you don't rewire Cron to swap the logic - Replace
buildSkeletonMessage(now)withbuildNotificationMessage(tasks: Task[], now: Date)— still pure - Update
discord.test.tsto cover the new builder inputs (table-driven: (tasks, now) → expected payload) - Update
cron.tsto query D1 first, then feed results to the builder
The boundary (postToDiscord) stays exactly the same. The only thing that changes is what's upstream of it.
Scope boundary
This skill does NOT cover:
- The underlying Workers skeleton (
wrangler.jsonc, GH Actions, D1) — seecloudflare-workers-deploy-skeleton - Replacing skeleton logic with real domain rules — that's a project-phase decision; the boundary is designed to make it easy
- Discord embeds, mentions, reaction-based interactions — webhooks support them (add
embeds/componentsfields in the payload) but skeleton usescontentonly - Non-Discord recipients — the architecture ports to Slack / Teams / generic HTTP recipients; only the payload shape changes
neverthrow/ Result-type error handling — deferred; skeleton uses try/catch withconsole.errorfor simplicity
References
- implementation.md — full source of
time.ts,discord.ts,cron.ts,types.tsaddition - testing.md —
vitest.config.ts,time.test.ts,discord.test.tswith fetch-mock pattern - operations.md — Discord webhook creation steps, dev/prod secret workflow, symptom matrix, diagnostic flow, rotation procedure