Compare commits

...
31 Commits
Author SHA1 Message Date
tiennm99 fcb86bd09b fix(deps): remediate worker dependency advisories
Bump wrangler, vitest, vite and vitest-pool-workers, and override sharp and
undici so the copies nested in miniflare also take the patched releases.
2026-10-04 10:50:22 +07:00
tiennm99 5bb38fb220 build: move the worker from pnpm to npm
Replace pnpm-lock.yaml with package-lock.json. allowBuilds and
pnpm.onlyBuiltDependencies collapse into package.json#allowScripts, which npm
11 gates the same way; sharp is omitted because it is not in the tree.

cloudflare-worker/.github/workflows/ci.yml is updated for consistency even
though it never runs: GitHub only reads workflows from the repository root.
2026-08-17 12:22:21 +07:00
tiennm99 5c5463057e docs: point implementation cross-references at in-repo directories
The three deployment variants now live in one repository: GitHub Actions
at the root, the Go daemon under self-hosted/, the Worker under
cloudflare-worker/. Update comparison tables, clone instructions, and the
Go module path (binary builds as 'self-hosted'); GHCR image name is
unchanged.
2026-08-10 15:54:39 +07:00
tiennm99 1402187f2b fix(deps): bump wrangler to pull patched sharp
sharp was pinned transitively via wrangler -> miniflare below the
0.35.0 security fix. Bumping wrangler resolves the advisory without
any source change.
2026-07-25 19:30:33 +07:00
tiennm99 e7151e1652 chore: remove dependabot version-update config 2026-07-25 14:14:51 +07:00
tiennm99 bcebe58cc2 build(deps): bump wrangler, vitest, vitest-pool-workers and setup-node to v7 2026-07-17 17:49:54 +07:00
tiennm99 1766168adc build(deps): bump vite to 8.x and actions/checkout to v7 2026-07-17 17:42:53 +07:00
dependabot[bot]andtiennm99 05ac7d7939 chore(deps-dev): bump the minor-and-patch group with 2 updates (#11)
* chore(deps-dev): bump the minor-and-patch group with 2 updates

Bumps the minor-and-patch group with 2 updates: [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler) and [@cloudflare/vitest-pool-workers](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/vitest-pool-workers).


Updates `wrangler` from 4.90.0 to 4.94.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/wrangler@4.94.0/packages/wrangler)

Updates `@cloudflare/vitest-pool-workers` from 0.5.41 to 0.16.9
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Changelog](https://github.com/cloudflare/workers-sdk/blob/main/packages/vitest-pool-workers/CHANGELOG.md)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/@cloudflare/vitest-pool-workers@0.16.9/packages/vitest-pool-workers)

---
updated-dependencies:
- dependency-name: wrangler
  dependency-version: 4.94.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@cloudflare/vitest-pool-workers"
  dependency-version: 0.16.9
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(test): migrate vitest config to cloudflareTest() plugin for vitest-pool-workers v0.16

* chore(deps): bump actions/checkout from 4 to 6 (#8)

Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump actions/setup-node from 4 to 6 (#10)

Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump vitest from 2.1.9 to 4.1.7 (#12)

* chore(deps-dev): bump vitest from 2.1.9 to 4.1.7

Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 2.1.9 to 4.1.7.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.7/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.7
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(test): migrate to vitest@4 with vitest-pool-workers v0.16, cloudflareTest() API, and vite@6

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: tiennm99 <tiennm99@outlook.com>

* chore(deps): bump pnpm/action-setup from 4 to 6 (#9)

Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 4 to 6.
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v4...v6)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the minor-and-patch group with 2 updates

Bumps the minor-and-patch group with 2 updates: [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler) and [@cloudflare/vitest-pool-workers](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/vitest-pool-workers).

Updates `wrangler` from 4.90.0 to 4.94.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/wrangler@4.94.0/packages/wrangler)

Updates `@cloudflare/vitest-pool-workers` from 0.5.41 to 0.16.9
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Changelog](https://github.com/cloudflare/workers-sdk/blob/main/packages/vitest-pool-workers/CHANGELOG.md)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/@cloudflare/vitest-pool-workers@0.16.9/packages/vitest-pool-workers)

---
updated-dependencies:
- dependency-name: wrangler
  dependency-version: 4.94.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@cloudflare/vitest-pool-workers"
  dependency-version: 0.16.9
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: tiennm99 <tiennm99@outlook.com>
2026-05-23 18:42:40 +07:00
dependabot[bot] 62272e4f6b chore(deps): bump pnpm/action-setup from 4 to 6 (#9)
Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 4 to 6.
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v4...v6)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 18:36:25 +07:00
dependabot[bot]andtiennm99 b486ca5b39 chore(deps-dev): bump vitest from 2.1.9 to 4.1.7 (#12)
* chore(deps-dev): bump vitest from 2.1.9 to 4.1.7

Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 2.1.9 to 4.1.7.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.7/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.7
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(test): migrate to vitest@4 with vitest-pool-workers v0.16, cloudflareTest() API, and vite@6

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: tiennm99 <tiennm99@outlook.com>
2026-05-23 18:29:02 +07:00
dependabot[bot] d24dc83b9c chore(deps): bump actions/setup-node from 4 to 6 (#10)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 18:20:05 +07:00
dependabot[bot] 95d9b6b5ee chore(deps): bump actions/checkout from 4 to 6 (#8)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 18:19:59 +07:00
tiennm99 6cf3232854 chore: add dependabot config (#7) 2026-05-23 10:48:49 +07:00
tiennm99 1df67b0c8d chore(ci): bump node to 24 2026-05-13 10:52:29 +07:00
tiennm99 4baa828831 fix(ci): approve native dependency builds via pnpm-workspace.yaml allowBuilds 2026-05-13 10:39:09 +07:00
tiennm99 7570bb7e23 fix(ci): allow build scripts for esbuild, sharp, workerd via pnpm onlyBuiltDependencies 2026-05-13 10:34:07 +07:00
tiennm99 22c4fae915 fix(ci): remove pnpm version override conflicting with packageManager 2026-05-13 10:29:06 +07:00
tiennm99 c5cb39b89a chore: migrate from npm to pnpm 2026-05-13 10:18:17 +07:00
tiennm99 d786f993cc docs: add 3-way decision matrix linking all routine-trigger variants 2026-05-11 20:09:38 +07:00
tiennm99 c7dcec1770 fix(cron): use empty crons=[] to actually clear CF dashboard
Commenting out [triggers] left stale schedules visible in the CF
dashboard — Wrangler only pushes declared config, never removes
implicitly. An empty array is the explicit "clear schedules" signal.

Sample schedule preserved as a comment above the empty array so
re-enabling is a single-line edit.
2026-05-09 13:14:46 +07:00
tiennm99 11b908cdac chore: park worker, recommend cron-job.org as primary scheduler
Comment out [triggers].crons in wrangler.toml — migrated this routine to
cron-job.org (free, verified) to keep the CF cron-trigger free-tier quota
available for other workers. Worker code, secrets, and observability
config stay intact so re-enabling is one toml uncomment + redeploy.

README restructured: lead with cron-job.org setup (signup → headers → JSON
body), demote CF Worker setup to a secondary path under "Using Cloudflare
Workers" for users who specifically want CF infra.
2026-05-09 13:07:59 +07:00
tiennm99 df7711231b feat(observability): enable full Workers Logs traces (100% sampling) 2026-05-09 12:03:47 +07:00
tiennm99 723f5773fa feat(cron): simplify to single expression — 00:00 + hourly 05-23 UTC+7 2026-05-09 11:58:10 +07:00
tiennm99 4626889387 feat(cron): expand to 20 fires/day, packed into 5 expressions 2026-05-09 11:52:31 +07:00
tiennm99 2721b214d9 fix(ci): bump Node to 22 (wrangler 4 requires Node >=22) 2026-05-09 11:37:29 +07:00
tiennm99 d6c89ba803 feat: wrangler config with default 5x-daily crons (UTC+7) 2026-05-09 11:29:07 +07:00
tiennm99 1b9a6eba89 test: vitest in Workers pool, 12 cases incl token redaction + CI 2026-05-09 11:29:07 +07:00
tiennm99 b26b101c9a docs: CHANGELOG v0.1.0 + planning artifacts 2026-05-09 11:29:07 +07:00
tiennm99 a7ec7589a6 feat: scheduled handler that fires Claude Code routines via /fire 2026-05-09 11:29:00 +07:00
tiennm99 b20e3b38b6 chore: init repo scaffold (Apache-2.0, package.json, .gitignore) 2026-05-09 11:28:49 +07:00
tiennm99 919cccfcbd docs: link to claude-code-routine-cron for self-hosted scheduling 2026-05-08 23:53:08 +07:00
21 changed files with 4433 additions and 5 deletions

No files matched your search

+14
View File
@@ -4,6 +4,20 @@
> **No longer used by the author.** Claude Code routines now ship with a **built-in cron trigger** in the routine editor — that's what I use now. Prefer the native scheduler; it runs on Anthropic's infra (no GitHub Actions delay/drop issues), needs no repo, no secrets, no workflow file. > **No longer used by the author.** Claude Code routines now ship with a **built-in cron trigger** in the routine editor — that's what I use now. Prefer the native scheduler; it runs on Anthropic's infra (no GitHub Actions delay/drop issues), needs no repo, no secrets, no workflow file.
> >
> This repo is kept as a reference for anyone who still wants to fire routines from an external scheduler (GitHub Actions, own cron, Cloud Scheduler, etc.). > This repo is kept as a reference for anyone who still wants to fire routines from an external scheduler (GitHub Actions, own cron, Cloud Scheduler, etc.).
>
> If you want **self-hosted scheduling with sub-second precision** (no GitHub cron delays/drops), see [`self-hosted/`](./self-hosted/) — Go daemon shipped as a multi-arch Docker image on GHCR (`ghcr.io/tiennm99/claude-code-routine-cron`).
## Choose your deployment
| | **GitHub Actions** (repo root) | [`self-hosted/`](./self-hosted/) | [`cloudflare-worker/`](./cloudflare-worker/) |
| ---------------- | -------------------------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| Runs on | GitHub Actions runners | your infra (Docker, k8s, NAS, RPi) | Cloudflare edge |
| Cost | free (within GitHub minutes) | minimal (your infra) | free (CF free tier) |
| Cron precision | ±30 min – 2 h, can drop runs | sub-second | within ~15 sec |
| Setup | fork + 2 repo secrets | env vars + Docker | `wrangler deploy` + 2 secrets |
| Audit trail | GitHub Actions runs page | container stdout | CF dashboard / `wrangler tail` |
Pick **GitHub Actions** if you want zero-infra and don't care about precise firing time. Pick **`self-hosted/`** for behind-firewall / on-prem / precise timing. Pick **`cloudflare-worker/`** for edge-native deploys with CF dashboard observability.
Scheduled GitHub Actions workflow that fires a [Claude Code routine](https://code.claude.com/docs/en/routines) via the [`/fire` API](https://platform.claude.com/docs/en/api/claude-code/routines-fire). Ships with a 5×-daily default cron and exposes a `workflow_dispatch` button for on-demand runs. Fire URL and per-routine token live in repo secrets. Scheduled GitHub Actions workflow that fires a [Claude Code routine](https://code.claude.com/docs/en/routines) via the [`/fire` API](https://platform.claude.com/docs/en/api/claude-code/routines-fire). Ships with a 5×-daily default cron and exposes a `workflow_dispatch` button for on-demand runs. Fire URL and per-routine token live in repo secrets.
+4
View File
@@ -0,0 +1,4 @@
# Copy this file to `.dev.vars` for `wrangler dev` local runs.
# `.dev.vars` is gitignored. Real secrets go through `wrangler secret put` for production.
ROUTINE_FIRE_URL=https://api.anthropic.com/v1/claude_code/routines/trig_REPLACE_ME/fire
ROUTINE_FIRE_TOKEN=sk-ant-oat01-REPLACE_ME
+21
View File
@@ -0,0 +1,21 @@
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '24'
cache: 'npm'
- run: npm ci
- run: npm test
- run: npx wrangler deploy --dry-run
env:
CLOUDFLARE_API_TOKEN: dummy
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
.wrangler/
.dev.vars
.env
.env.*
*.log
+23
View File
@@ -0,0 +1,23 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Changed
- Enable Workers Logs with `head_sampling_rate = 1` and `invocation_logs = true` — every cron tick produces a full trace, browsable in the CF dashboard for 3 days.
## [0.1.0] - 2026-05-09
### Added
- Cloudflare Workers `scheduled` handler that POSTs to the Claude Code routine `/fire` endpoint.
- Default 20×daily cron at UTC+7 — fires at 00:00 and hourly from 05:00 through 23:00. Single cron expression (`0 0-17,22-23 * * *`).
- Token-substitution template with `{ISO}`, `{LocalTime}`, `{Cron}` (default: `Scheduled trigger at {LocalTime}`).
- IANA timezone support via `TZ` env var (default: `UTC`).
- Structured JSON logs for fire success / non-2xx / network failure.
- Vitest test suite covering template substitution, fire paths, header correctness, token redaction.
- GitHub Actions CI: `npm test` + `wrangler deploy --dry-run` on push and PR.
- Apache-2.0 license.
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 tiennm99
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+191
View File
@@ -0,0 +1,191 @@
# claude-code-routine-trigger-worker
Cloudflare Worker that fires a [Claude Code routine](https://code.claude.com/docs/en/routines) on a precise cron schedule via the [`/fire` API](https://platform.claude.com/docs/en/api/claude-code/routines-fire). Runs on Cloudflare's free tier — no servers, no GitHub minutes, no Docker host.
> [!TIP]
> **Anthropic's routine editor now ships a built-in cron trigger** — it runs on Anthropic's infra, no setup. Use it first.
>
> If you need an external scheduler (Anthropic cron disabled, custom payloads, audit trail outside Anthropic), I recommend **[cron-job.org](https://cron-job.org)** — free, no infra, ~2-min setup. I tried it on this exact routine and am satisfied, so I've parked this worker (cron triggers are commented out in `wrangler.toml`) to keep my CF cron-trigger quota free for other projects. See [Recommended: cron-job.org](#recommended-cron-joborg).
>
> This worker is still a valid path if you specifically want secrets in CF / logs in CF / schedule in code review. See [Using Cloudflare Workers](#using-cloudflare-workers).
## Recommended: cron-job.org
[cron-job.org](https://cron-job.org) is a free hosted cron service with a clean dashboard, per-fire history, and 1-min granularity on the free tier. Setup:
1. Sign up at [console.cron-job.org/signup](https://console.cron-job.org/signup).
2. Click **CREATE CRONJOB**.
3. **Common** tab:
- **Title**: anything (e.g. `claude-code-routine`)
- **URL**: paste the `/fire` URL from the Anthropic routine editor → *API trigger* → *URL* (looks like `https://api.anthropic.com/v1/claude_code/routines/trig_.../fire`)
- **Schedule**: pick your timezone and the times to fire — cron-job.org accepts both UI selectors and raw cron syntax
4. **Advanced** tab → set **Request method** to `POST`.
5. **Headers** tab → add four headers:
| Key | Value |
| ------------------- | ---------------------------------------------- |
| `Authorization` | `Bearer sk-ant-oat01-...` (your routine token) |
| `anthropic-version` | `2023-06-01` |
| `anthropic-beta` | `experimental-cc-routine-2026-04-01` |
| `Content-Type` | `application/json` |
6. **Body** tab → select `Raw` and paste:
```json
{"text": "Scheduled trigger"}
```
7. **Notifications** tab (optional) → enable email on failure so you know if the token expires.
8. **Save**.
Each fire shows up in *History* with status code and response body — open `claude_code_session_url` from the response JSON to watch the run.
**Operational notes:**
- **Token rotation**: edit the cronjob → swap the `Authorization` header value. No redeploy.
- **Beta header**: when Anthropic ships a new dated `anthropic-beta` value, update the header. Older dated values keep working for a transition window per Anthropic's beta policy.
- **No retry on failure**: each `/fire` POST creates a new Claude Code session, so retrying would multiply sessions and burn quota. cron-job.org's default is one attempt per fire, which is what you want.
- **Limits**: cron-job.org's free tier allows up to 50 cronjobs and unlimited executions at 1-min granularity — way more than enough for routine triggering.
## Why this vs the siblings
| | [GitHub Actions](../) (repo root) | [`self-hosted/`](../self-hosted/) | **cloudflare-worker** (this directory) |
| ---------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | --------------------------------------------- |
| Runs on | GitHub Actions runners | your infra (Docker host, k8s, NAS, RPi) | Cloudflare edge |
| Cost | free (within GitHub minutes) | minimal (your infra) | free (CF free tier) |
| Cron precision | ±30 min – 2 h, can drop runs | sub-second | within ~15 sec |
| Setup | fork + 2 repo secrets | env vars + Docker | `wrangler deploy` + 2 secrets |
| Audit trail | GitHub Actions runs page | container stdout | CF dashboard / `wrangler tail` |
## Using Cloudflare Workers
> The default `wrangler.toml` ships with the `[triggers]` block **commented out** — see the note in the file. To activate this worker, uncomment the block (and edit the schedule) before deploying.
### Quickstart
```bash
git clone https://github.com/tiennm99/claude-code-routine-trigger
cd claude-code-routine-trigger/cloudflare-worker
npm install
# Upload secrets (from Anthropic routine editor → API trigger)
echo -n 'https://api.anthropic.com/v1/claude_code/routines/trig_.../fire' \
| npx wrangler secret put ROUTINE_FIRE_URL
echo -n 'sk-ant-oat01-...' \
| npx wrangler secret put ROUTINE_FIRE_TOKEN
# Uncomment [triggers].crons in wrangler.toml, edit the schedule + timezone, then:
npx wrangler deploy
```
Tail logs:
```bash
npx wrangler tail
```
A successful fire logs a JSON line with `session_url`. Open it to watch the run.
### Environment variables
Configured in `wrangler.toml` (`[vars]` for plain values) or via `wrangler secret put` (for secrets).
| Name | Type | Required | Default | Notes |
| -------------------- | ------ | :------: | ---------------------------------------- | ----- |
| `ROUTINE_FIRE_URL` | secret | yes | — | Anthropic `/fire` endpoint. From routine editor → API trigger. |
| `ROUTINE_FIRE_TOKEN` | secret | yes | — | `sk-ant-oat01-...` per-routine token. Shown once in the editor. |
| `TEXT_TEMPLATE` | var | no | `Scheduled trigger at {LocalTime}` | Token-substitution template. See *Templates*. |
| `TZ` | var | no | `UTC` | IANA tz name (`Asia/Ho_Chi_Minh`, `America/New_York`, …). Used for `{LocalTime}` formatting. |
### Customize the schedule
Uncomment and edit `wrangler.toml` `[triggers].crons` — Cloudflare requires literal cron expressions (same constraint as GitHub Actions). To change when the routine fires:
```toml
[triggers]
crons = [
"0 0-17,22-23 * * *", # UTC+7: 00:00 + 05:00..23:00 hourly
]
```
Then redeploy: `npx wrangler deploy`.
Tips:
- Cron runs in **UTC**. Convert your local time: `UTC = local − offset` (e.g. 09:00 UTC+7 → 02:00 UTC → `0 2 * * *`).
- Validate expressions at <https://crontab.guru/>.
- **Free tier limit:** 5 cron expressions per worker. Default config uses 1.
- Standard 5-field syntax — supports `*`, `,`, `-`, `/`. Comma lists (`22,23,0,1,2`) and ranges (`3-7`) let you cram many fires into one expression.
### Templates
`TEXT_TEMPLATE` supports these `{Token}` substitutions, rendered per fire. Unknown tokens are left intact in the output.
| Token | Example |
| ------------- | -------------------------------------- |
| `{ISO}` | `2026-05-09T03:19:00.000Z` |
| `{LocalTime}` | `2026-05-09 10:19 GMT+7` |
| `{Cron}` | `19 3 * * *` — the expression that fired |
Example:
```toml
[vars]
TEXT_TEMPLATE = "Daily digest at {LocalTime} (cron {Cron})"
TZ = "America/New_York"
```
### Local development
Copy `.dev.vars.example` to `.dev.vars` and fill in your routine credentials:
```bash
cp .dev.vars.example .dev.vars
# edit .dev.vars
npx wrangler dev --test-scheduled
```
Then trigger a scheduled run:
```bash
curl "http://localhost:8787/__scheduled?cron=*+*+*+*+*"
```
`.dev.vars` is gitignored — never commit it.
### Tests
```bash
npm test
```
Vitest runs in the Workers runtime via `@cloudflare/vitest-pool-workers`. Tests mock `fetch`, so they consume no Anthropic quota.
### Secret rotation
`wrangler secret put` overwrites silently. Rotate by re-running it with the new value:
```bash
echo -n 'sk-ant-oat01-newvalue' | npx wrangler secret put ROUTINE_FIRE_TOKEN
```
The change takes effect on the next deploy or within a few seconds via the live config.
### Beta header
The request pins `anthropic-beta: experimental-cc-routine-2026-04-01` (constant in `worker.js`). When Anthropic ships a new dated beta, bump it via a release. Older dated values keep working for a transition window per Anthropic's beta policy.
### Operational notes
- **Time accuracy**: cron precision on CF Workers is within ~15 seconds — adequate for routine triggering.
- **No retry**: each `/fire` POST creates a new Claude Code session — retrying multiplies sessions and burns quota. The worker logs failures and moves on.
- **Logs / traces**: `[observability]` is enabled in `wrangler.toml` with `head_sampling_rate = 1` and `invocation_logs = true` — every invocation produces a structured log + trace, retained per the [Workers Logs retention policy](https://developers.cloudflare.com/workers/observability/logs/workers-logs/) (3 days on the free plan). View live with `npx wrangler tail`, or browse history in the CF dashboard under **Workers & Pages → your worker → Logs**.
- **Cost**: scheduled handlers count against the Workers Free plan's 100k requests/day budget. 5 daily fires × 30 days = 150 requests/month — negligible.
### Security
- The token is **per-routine**: a leak only fires that one routine.
- Secrets live in CF's encrypted secret store, never in the bundle, never in logs (verified by tests).
- TLS to `api.anthropic.com` uses CF's standard cert verification.
- `worker.js` has no `fetch` HTTP handler — the worker is unreachable from the public internet, only fires on cron tick.
## License
[Apache-2.0](./LICENSE)
+2974
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "claude-code-routine-trigger-worker",
"version": "0.0.0",
"description": "Cloudflare Workers cron port of claude-code-routine-cron — fires Claude Code routines on a schedule.",
"type": "module",
"private": true,
"license": "Apache-2.0",
"main": "worker.js",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"test": "vitest run"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.22.0",
"vite": "^8.3.2",
"vitest": "^4.1.11",
"wrangler": "^4.147.0"
},
"allowScripts": {
"esbuild": true,
"workerd": true
},
"overrides": {
"sharp": "^0.35.5",
"undici": "^7.30.0"
}
}
@@ -0,0 +1,72 @@
---
phase: 1
title: Repo scaffold
status: completed
priority: P1
effort: 1h
dependencies: []
---
# Phase 1: Repo scaffold
## Overview
Greenfield repo init. Apache-2.0 license, README skeleton, JS + wrangler toolchain, gitignore. No transpile step — Workers runtime executes ES modules natively.
## Requirements
- **Functional:** `npm install` succeeds; `wrangler deploy --dry-run` validates an empty `worker.js` handler.
- **Non-functional:** No secrets in repo. Pin wrangler major version. kebab-case file names.
## Architecture
Flat layout — single-purpose worker, no need for src/ subdirs (KISS).
```
claude-code-routine-trigger-worker/
├── .github/workflows/ci.yml # phase 4
├── .gitignore
├── LICENSE # Apache-2.0
├── README.md # phase 5
├── package.json
├── wrangler.toml # phase 3
├── worker.js # phase 2
└── worker.test.js # phase 4
```
## Related Code Files
- Create: `LICENSE` (Apache-2.0 verbatim)
- Create: `.gitignore` (node_modules, .dev.vars, .wrangler, dist, .env*)
- Create: `package.json` (name, version 0.0.0, `"type": "module"`, scripts: test, deploy, dev)
- Create: `README.md` (one-liner placeholder; full content in phase 5)
## Implementation Steps
1. `cd /config/workspace/tiennm99/claude-code-routine-trigger-worker && git init -b main`
2. Write `LICENSE` (copy Apache-2.0 from sibling `claude-code-routine-cron/LICENSE`).
3. Write `.gitignore`:
```
node_modules/
dist/
.wrangler/
.dev.vars
.env
.env.*
*.log
```
4. Write `package.json` with `"type": "module"`, devDeps: `wrangler` (^4), `vitest` (phase 4), `@cloudflare/vitest-pool-workers` (phase 4).
Scripts:
- `dev`: `wrangler dev`
- `deploy`: `wrangler deploy`
- `test`: `vitest run`
5. Run `npm install` — confirm lockfile generated.
6. Stub `README.md` with one-liner: `> Cloudflare Workers cron port of claude-code-routine-cron — fires Claude Code routines on a schedule.`
7. First commit: `chore: init repo scaffold`.
## Success Criteria
- [ ] `npm install` exits 0
- [ ] `wrangler deploy --dry-run` validates empty `worker.js` exporting `export default {}`
- [ ] `git status` clean after first commit
- [ ] LICENSE file matches Apache-2.0 exactly
- [ ] `package.json` has `"type": "module"`
## Risk Assessment
- **Wrangler version drift:** pin to current major (e.g. `^4.0.0`); document upgrade path in README.
- **Node version:** wrangler 4 needs Node ≥18; document in README prereqs.
- **No type safety:** mitigate by JSDoc type hints in `worker.js` for `Env` and handler signatures (cheap, optional, no toolchain).
@@ -0,0 +1,142 @@
---
phase: 2
title: Worker handler + fire client
status: completed
priority: P1
effort: 2-3h
dependencies:
- 1
---
# Phase 2: Worker handler + fire client
## Overview
Implement the `scheduled` handler in plain JS with JSDoc type annotations: render text template, POST to `/fire`, log structured JSON. Single `worker.js` file. No `fetch` handler (per locked decision: scheduled-only).
## Requirements
- **Functional:**
- On cron tick, POSTs to `ROUTINE_FIRE_URL` with bearer token, beta header, JSON body.
- Logs `claude_code_session_url` on 2xx; logs status + body on non-2xx; never throws (would crash CF retry).
- Renders `TEXT_TEMPLATE` with `{LocalTime}`, `{Cron}`, `{ISO}` placeholders.
- No retry (each `/fire` = new session).
- **Non-functional:**
- Token never logged.
- Use `console.log(JSON.stringify({...}))` for structured logs (CF picks these up).
- JSDoc `@typedef Env` for environment bindings + `@param`/`@returns` on all exports.
- Total file ≤ 200 lines (split if exceeds).
## Architecture
```
ScheduledEvent (cron, scheduledTime)
│
▼
fireRoutine(env, controller)
├── renderText(template, vars) # simple {Tok} substitution
├── fetch(URL, POST, headers, body)
└── log(json) # success or error
```
### Mapping from Go daemon
| Go (claude-code-routine-cron) | JavaScript (this worker) |
|---|---|
| `Config.FireURL` env | `env.ROUTINE_FIRE_URL` secret |
| `Config.Token` env | `env.ROUTINE_FIRE_TOKEN` secret |
| `Config.Schedules` env | `wrangler.toml` `[triggers].crons` (literal) |
| `Config.Location` (TZ) | `env.TZ` plain var, default `UTC` |
| `Config.Template` (Go text/template) | `env.TEXT_TEMPLATE` plain string + `{Tok}` substitution |
| `FireClient.Fire()` | `fireRoutine()` function |
| `cron.New()` scheduler | CF Workers scheduled handler (built-in) |
| `slog` JSON logs | `console.log(JSON.stringify(...))` |
### JSDoc typedefs
```js
/**
* @typedef {object} Env
* @property {string} ROUTINE_FIRE_URL - secret: Anthropic /fire endpoint
* @property {string} ROUTINE_FIRE_TOKEN - secret: per-routine bearer token
* @property {string} [TEXT_TEMPLATE] - plain var: prompt template, supports {LocalTime} {Cron} {ISO}
* @property {string} [TZ] - plain var: IANA tz, default 'UTC'
*/
/**
* @typedef {object} FireResponse
* @property {string} type
* @property {string} claude_code_session_id
* @property {string} claude_code_session_url
*/
```
### Headers (verbatim from siblings)
```
Authorization: Bearer ${token}
anthropic-version: 2023-06-01
anthropic-beta: experimental-cc-routine-2026-04-01
Content-Type: application/json
```
### Body
```json
{ "text": "<rendered template>" }
```
### Template substitution (KISS — not Go text/template)
| Token | Value |
|---------------|-------------------------------------------------------|
| `{ISO}` | `new Date(scheduledTime).toISOString()` |
| `{LocalTime}` | formatted via `Intl.DateTimeFormat(env.TZ ?? 'UTC')` |
| `{Cron}` | `controller.cron` |
Default template: `Scheduled trigger at {LocalTime}` — same default as Go daemon.
## Related Code Files
- Create: `worker.js` (export default `{ scheduled }`, JSDoc-annotated)
- (Optional split if >200 lines) `fire-client.js`, `template-renderer.js`
## Implementation Steps
1. Top of `worker.js`: write JSDoc `@typedef Env` + `@typedef FireResponse` blocks.
2. Implement `renderText(template, vars)` with JSDoc:
```js
/**
* @param {string} template
* @param {Record<string, string>} vars
* @returns {string}
*/
```
Replaces `{Key}` tokens, leaves unknown tokens intact.
3. Implement `formatLocalTime(date, tz)` using `Intl.DateTimeFormat`. JSDoc: `@param {Date} date`, `@param {string} tz`, `@returns {string}`.
4. Implement `fireRoutine(env, controller)` with JSDoc `@param {Env} env`, `@param {ScheduledController} controller`, `@returns {Promise<void>}`:
- Build `text` via `renderText`.
- `fetch(env.ROUTINE_FIRE_URL, { method: 'POST', headers, body: JSON.stringify({text}) })`.
- On `!response.ok`: `console.log(JSON.stringify({level:'error', cron, status, body}))`.
- On `ok`: parse JSON, log `{level:'info', cron, session_url, session_id}`.
- Wrap in try/catch — log network errors, don't rethrow.
5. Export default with JSDoc `@type {ExportedHandler<Env>}`:
```js
/** @type {ExportedHandler<Env>} */
export default {
async scheduled(controller, env, ctx) {
ctx.waitUntil(fireRoutine(env, controller));
},
};
```
6. `npx tsc --noEmit --allowJs --checkJs worker.js --target es2022 --module esnext --moduleResolution bundler --types @cloudflare/workers-types` (one-shot smoke check; not added to scripts to avoid TS dependency creep). Optional — skip if undesired.
7. Local smoke: `wrangler dev --test-scheduled`, then `curl "http://localhost:8787/__scheduled?cron=*+*+*+*+*"` — confirm log line.
## Success Criteria
- [ ] `worker.js` ≤ 200 lines (split if needed)
- [ ] All exported functions have JSDoc with `@param` + `@returns`
- [ ] `wrangler deploy --dry-run` validates module
- [ ] Token never appears in logged output (manual grep on `wrangler dev` output)
- [ ] Local `__scheduled` test produces structured JSON log
## Risk Assessment
- **`Intl.DateTimeFormat` TZ support on Workers runtime:** supported since 2023; verify with smoke test.
- **`ctx.waitUntil` vs awaiting in `scheduled`:** `waitUntil` ensures fire completes even if handler returns; preferred per CF docs.
- **No compile-time type errors:** JSDoc + editor LSP catches most; runtime tests (phase 4) catch the rest.
- **Template injection:** `text` is sent only to Anthropic API; no XSS / SQL surface — safe.
## Security Considerations
- Token comes only from `env.ROUTINE_FIRE_TOKEN` (CF Secret), never bundled.
- `console.log` body must not include `Authorization` header value.
- TLS to `api.anthropic.com` is automatic (Workers `fetch` validates certs).
@@ -0,0 +1,109 @@
---
phase: 3
title: wrangler config + secrets
status: completed
priority: P1
effort: 1h
dependencies:
- 1
- 2
---
# Phase 3: wrangler config + secrets
## Overview
Author `wrangler.toml` with literal `[triggers].crons`, define plain vars vs secrets, document deployment flow. CF Workers cron triggers fire from `wrangler.toml` only — same constraint as GH Actions.
## Requirements
- **Functional:**
- `wrangler deploy` succeeds with valid `wrangler.toml`.
- At least one cron expression in `[triggers]` (default 5×daily mirroring sibling repos).
- Secrets uploaded out-of-band (not in `wrangler.toml`).
- **Non-functional:**
- All comments above each block explain the field.
- Default crons use scattered minutes (avoid minute `0`) per `claude-code-routine-trigger` README warning — though CF cron is more reliable than GH, scattering is still good hygiene.
## Architecture
### `wrangler.toml` shape
```toml
name = "claude-code-routine-trigger-worker"
main = "worker.js"
compatibility_date = "2026-05-09"
compatibility_flags = ["nodejs_compat"] # only if Intl needs polyfill — verify in phase 2
# Plain vars (visible in dashboard, fine for non-secrets)
[vars]
TEXT_TEMPLATE = "Scheduled trigger at {LocalTime}"
TZ = "Asia/Ho_Chi_Minh"
# Cron triggers — LITERAL, cannot read from env/secrets/vars
[triggers]
crons = [
"7 17 * * *", # 00:07 UTC+7
"13 22 * * *", # 05:13 UTC+7
"19 3 * * *", # 10:19 UTC+7
"23 8 * * *", # 15:23 UTC+7
"37 13 * * *", # 20:37 UTC+7
]
# Observability — opt-in, free tier supports basic logs
[observability]
enabled = true
```
### Secrets (uploaded via `wrangler secret put`)
| Name | Source |
|----------------------|-------------------------------------|
| `ROUTINE_FIRE_URL` | Anthropic routine editor → API trigger |
| `ROUTINE_FIRE_TOKEN` | Anthropic routine editor (shown once) |
### Local dev: `.dev.vars`
```
ROUTINE_FIRE_URL=https://api.anthropic.com/v1/claude_code/routines/trig_.../fire
ROUTINE_FIRE_TOKEN=sk-ant-oat01-...
```
Gitignored. Used by `wrangler dev` only.
### `wrangler.toml.example`
Committed copy with placeholder values for documentation; real `wrangler.toml` ships with author's defaults but secrets are NEVER literal.
## Related Code Files
- Create: `wrangler.toml`
- Create: `wrangler.toml.example` (or document in README — KISS, prefer README)
- Create: `.dev.vars.example` with placeholder secret names + dummy values
## Implementation Steps
1. Write `wrangler.toml` per architecture block above.
- Pick `compatibility_date` = today (2026-05-09).
- Decide on `nodejs_compat` flag — only enable if phase 2 needs it (`Intl` is built-in, likely **don't need it**).
2. Write `.dev.vars.example`:
```
ROUTINE_FIRE_URL=https://api.anthropic.com/v1/claude_code/routines/trig_REPLACE/fire
ROUTINE_FIRE_TOKEN=sk-ant-oat01-REPLACE
```
3. Verify `.gitignore` includes `.dev.vars` (added in phase 1).
4. Local validation: `wrangler dev` — must boot without error.
5. Document secret-upload flow in phase 5 README:
```bash
echo -n 'https://...' | wrangler secret put ROUTINE_FIRE_URL
echo -n 'sk-ant-oat01-...' | wrangler secret put ROUTINE_FIRE_TOKEN
```
6. Commit: `feat: wrangler config with default 5x-daily crons`.
## Success Criteria
- [ ] `wrangler dev` starts cleanly with `.dev.vars` populated
- [ ] `wrangler deploy --dry-run` validates `wrangler.toml` without errors
- [ ] All 5 default crons match sibling repos' cadence (UTC+7 daily 00/05/10/15/20)
- [ ] No secret values in any committed file
- [ ] `.dev.vars` confirmed in `.gitignore`
## Risk Assessment
- **CF cron expression syntax:** CF Workers cron supports standard 5-field; `*/N` and ranges work. Avoid `?` (Quartz-only).
- **Minute-`0` GH issue does not apply to CF** but scattering minutes is still recommended (free tier shared infra benefits).
- **Free tier limit:** 5 cron triggers per worker. Default config uses exactly 5 — at limit. Document that adding a 6th requires another worker.
## Security Considerations
- Secrets never in `wrangler.toml` (only `[vars]` for non-secrets).
- `.dev.vars` is gitignored.
- `wrangler secret put` accepts stdin, avoiding shell history leak (use `echo -n ... | wrangler secret put`).
@@ -0,0 +1,101 @@
---
phase: 4
title: Tests + CI
status: completed
priority: P2
effort: 2h
dependencies:
- 2
- 3
---
# Phase 4: Tests + CI
## Overview
Vitest with `@cloudflare/vitest-pool-workers` for in-Workers-runtime tests. Cover template rendering, fire client behavior (success / non-2xx / network error), token redaction. GitHub Actions CI on push.
## Requirements
- **Functional:**
- Tests cover: template substitution, missing-token rendering, fire success path, non-2xx path, fetch throws path.
- CI runs `npm test` on push to `main` and PRs.
- **Non-functional:**
- Coverage report (text summary in CI logs).
- Tests use mocked `fetch` — no real network calls (don't burn user's Anthropic quota).
- Tests written in plain JS with JSDoc, matching production code style.
- Race-free is N/A on Workers; instead assert `console.log` output never contains token.
## Architecture
### Test layout
```
worker.test.js # all tests in one file initially (KISS)
```
Split if exceeds 200 lines.
### Test cases (mirror Go daemon's test coverage)
| # | Case | Assertion |
|---|------|-----------|
| 1 | `renderText` substitutes `{LocalTime}`, `{Cron}`, `{ISO}` | output equals expected string |
| 2 | `renderText` leaves unknown `{Foo}` tokens intact | output contains `{Foo}` |
| 3 | `fireRoutine` 2xx response logs `session_url` | console captured contains `session_url` |
| 4 | `fireRoutine` 401 response logs `level:error` | captured log has `level:'error'`, `status:401` |
| 5 | `fireRoutine` `fetch` rejects (network) logs error, doesn't throw | no thrown exception, error log present |
| 6 | Token redaction | for cases 3, 4, 5: stringified logs do NOT contain token value |
| 7 | Headers correctness | mocked `fetch` receives `Authorization: Bearer ${token}`, `anthropic-version`, `anthropic-beta` |
| 8 | Body shape | request body parses as `{ "text": <rendered> }` |
| 9 | Default template when `TEXT_TEMPLATE` unset | output starts with `Scheduled trigger at ` |
| 10 | TZ defaults to UTC when `env.TZ` unset | local time formatting uses UTC |
### CI workflow
`.github/workflows/ci.yml` — single job, Node 20, runs test + wrangler dry-run as smoke gate.
## Related Code Files
- Create: `worker.test.js`
- Create: `vitest.config.js` (uses `@cloudflare/vitest-pool-workers`)
- Create: `.github/workflows/ci.yml`
## Implementation Steps
1. Install: `npm i -D vitest @cloudflare/vitest-pool-workers`.
2. Write `vitest.config.js`:
```js
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
export default defineWorkersConfig({
test: { poolOptions: { workers: { wrangler: { configPath: './wrangler.toml' } } } },
});
```
3. Write `worker.test.js` covering all 10 cases. Use `vi.fn()` mock for `globalThis.fetch`. JSDoc-annotate test helper functions for IDE assist.
4. Run `npm test` — all green.
5. Write `.github/workflows/ci.yml`:
```yaml
name: ci
on:
push: { branches: [main] }
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npm test
- run: npx wrangler deploy --dry-run
```
6. Push to GitHub; verify green CI.
## Success Criteria
- [ ] All 10 test cases pass locally
- [ ] `npm test` exits 0 in CI
- [ ] `wrangler deploy --dry-run` exits 0 in CI
- [ ] No real HTTP traffic generated by tests (verify by mock spy)
- [ ] Coverage ≥ 80% (track via vitest `--coverage`; not enforced as blocking gate)
## Risk Assessment
- **Workers test pool quirks:** `@cloudflare/vitest-pool-workers` requires wrangler config; ensure `wrangler.toml` validates first.
- **Mocking `fetch` inside Workers runtime:** verify pattern works — fall back to dependency-injected fetch if not.
- **Beta header pinning in tests:** assert exact value to catch accidental edits.
## Security Considerations
- Test fixtures use placeholder token `sk-ant-oat01-test-only` — never a real value.
- Test asserts logs **don't contain** the token (regression guard).
@@ -0,0 +1,89 @@
---
phase: 5
title: Docs + release
status: completed
priority: P2
effort: 1.5h
dependencies:
- 1
- 2
- 3
- 4
---
# Phase 5: Docs + release
## Overview
Full README mirroring sibling repos' tone, comparison-table back-link in both siblings, v0.1.0 GitHub release, public deployment of the author's own instance.
## Requirements
- **Functional:**
- README covers: why-this-vs-siblings, quickstart, env vars, secret upload, customize schedule, beta header policy, security, license.
- GitHub release `v0.1.0` tagged.
- Both sibling READMEs updated with a row pointing to this repo.
- **Non-functional:**
- Same prose voice as siblings (`> [!TIP]` / `> [!WARNING]` callouts, terse).
- Code blocks copy-pasteable.
- All claims about CF cron precision sourced (link CF docs).
## Architecture
### README sections (in order)
1. **Header + tagline** — one-liner.
2. **Why this vs siblings** — three-column comparison table.
3. **Quickstart** — `wrangler deploy` + 2 secret-puts + done.
4. **Environment variables** — table (name, required, default, notes).
5. **Multiple schedules** — `wrangler.toml` `[triggers].crons` array.
6. **Templates** — `{LocalTime}`, `{Cron}`, `{ISO}`.
7. **Customize schedule** — edit `wrangler.toml`, `wrangler deploy`.
8. **Local development** — `.dev.vars` + `wrangler dev --test-scheduled`.
9. **Secret rotation** — `wrangler secret put` overwrites.
10. **CF Workers free tier** — link, note 5-cron-per-worker limit.
11. **Beta header** — pinned value, bump policy.
12. **Operational notes** — log access (`wrangler tail`), no idempotency.
13. **License** — Apache-2.0.
### Sibling repo updates
- Edit `claude-code-routine-trigger/README.md` — add row in the "no longer used by author" callout listing `trigger-worker` as another option.
- Edit `claude-code-routine-cron/README.md` — extend "Why this vs `claude-code-routine-trigger`" table to a 3-way comparison including `trigger-worker`.
## Related Code Files
- Modify: `README.md` (this repo)
- Modify (out of repo, requires PR/commit): `/config/workspace/tiennm99/claude-code-routine-trigger/README.md`
- Modify (out of repo): `/config/workspace/tiennm99/claude-code-routine-cron/README.md`
- Create: `CHANGELOG.md` (Keep-A-Changelog format)
- Optional: `.github/release.yml` for release-notes automation
## Implementation Steps
1. Draft README per architecture sections — use sibling READMEs verbatim where applicable (Apache-2.0 license clauses, beta header note, security note about per-routine token scope).
2. Write `CHANGELOG.md` with `## [0.1.0] - 2026-MM-DD` initial entry listing features.
3. Local proof: deploy author's instance via `wrangler deploy`, attach a non-prod routine, observe one cron tick, capture `session_url` for README screenshot.
4. Tag and release:
```bash
git tag v0.1.0
git push origin v0.1.0
gh release create v0.1.0 --generate-notes
```
5. Open PRs (or direct commits if author owns) to update sibling READMEs with the comparison row.
6. Final commit: `docs: README + v0.1.0 release notes`.
## Success Criteria
- [ ] README renders cleanly on GitHub
- [ ] All code blocks in README are runnable (copy-paste tested)
- [ ] Comparison table accurately reflects all 3 options
- [ ] Both sibling READMEs reference this repo
- [ ] `v0.1.0` tag pushed and GitHub release published
- [ ] Author's own deployment fired at least one successful cron tick (proof-of-life)
## Risk Assessment
- **Beta header drift:** if Anthropic ships new dated beta between phase 2 and release, bump in code + CHANGELOG before tagging.
- **Sibling repo edits cause merge conflicts:** branch + PR if user has unmerged work; otherwise direct commit.
- **CF dashboard URL format changes:** don't hardcode dashboard URLs in README; use `wrangler tail` (CLI) which is stable.
## Security Considerations
- README must NOT include real `ROUTINE_FIRE_URL` or token — only `sk-ant-oat01-...` placeholder format.
- Document that pushing to a public fork preserves `.dev.vars` exclusion (gitignore line).
## Next Steps (post v0.1.0)
- v0.2: optional `WEBHOOK_URL` to mirror fire output to Slack/Discord (low priority — most users use `wrangler tail`).
- v0.3: `wrangler.toml.example` with multi-routine deployment helper script.
@@ -0,0 +1,94 @@
---
title: claude-code-routine-trigger-worker — CF Workers cron port
description: >-
Cloudflare Workers port of claude-code-routine-cron: scheduled handler fires
Claude Code routine /fire endpoint, zero infra, free tier
status: completed
completed: 2026-05-09
priority: P2
created: 2026-05-09T00:00:00.000Z
target_repo: /config/workspace/tiennm99/claude-code-routine-trigger-worker
reference_repos:
- /config/workspace/tiennm99/claude-code-routine-trigger
- /config/workspace/tiennm99/claude-code-routine-cron
license: Apache-2.0
blockedBy: []
blocks: []
---
# claude-code-routine-trigger-worker — CF Workers cron port
## Problem
Two existing siblings cover the routine-fire space, both with trade-offs:
- `claude-code-routine-trigger` (GH Actions): free, but cron unreliable (30 min – 2h delays, occasional drops at minute `0`).
- `claude-code-routine-cron` (Go daemon, Docker): sub-second precision, but requires self-hosted infra.
Gap: a free, no-infra option with reliable timing.
## Goal
Cloudflare Workers port: scheduled handler POSTs to the Claude Code routine `/fire` endpoint on a fixed cron. Free tier (CF Workers cron triggers cost $0 within free limits). Single TypeScript file, deploy via `wrangler deploy`.
## Locked Decisions
| # | Decision | Choice |
|---|----------|--------|
| 1 | Repo name | Completed |
| 2 | Scope | Completed |
| 3 | Cron config | Completed |
| 4 | Manual HTTP fire | Completed |
| 5 | Language | Completed |
| 6 | Secret management | `wrangler secret put` — `ROUTINE_FIRE_URL`, `ROUTINE_FIRE_TOKEN` |
| 7 | Optional config | `TEXT_TEMPLATE` (env var, plain string with placeholders), `TZ` (env var) |
| 8 | License | Apache-2.0 (matches siblings) |
## Non-goals
- Manual `/fire` HTTP endpoint (KISS — scheduled only)
- Multiple routines per worker (deploy N workers for N routines)
- Retry on failure (each POST = new session; retries multiply sessions)
- Durable Objects / KV / D1 (stateless)
- Custom log shipping (CF dashboard `wrangler tail` is enough)
- Go-style `text/template` (use simple `{token}` substitution — KISS)
## Comparison Matrix (post-implementation)
| | trigger (GH Actions) | cron (Go daemon) | **trigger-worker (this)** |
| ---------------- | -------------------- | ------------------------ | ------------------------- |
| Runs on | GitHub runners | self-hosted Docker | Cloudflare edge |
| Cost | free (GH minutes) | minimal (own infra) | free (CF free tier) |
| Cron precision | ±30 min – 2 h | sub-second | ±15 sec (CF SLA) |
| Setup | fork + 2 secrets | env vars + Docker | wrangler deploy + secrets |
| Audit trail | Actions runs page | container stdout | CF dashboard / `tail` |
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Repo scaffold](./phase-01-repo-scaffold.md) | Completed |
| 2 | [Worker handler + fire client](./phase-02-worker-handler-fire-client.md) | Completed |
| 3 | [wrangler config + secrets](./phase-03-wrangler-config-secrets.md) | Completed |
| 4 | [Tests + CI](./phase-04-tests-ci.md) | Completed |
| 5 | [Docs + release](./phase-05-docs-release.md) | Completed |
## Dependencies
None. Greenfield repo. Reference repos read-only.
## Risk Register
| Risk | Severity | Mitigation |
|---|---|---|
| CF Workers cron precision in practice | Low | Documented ±15 sec — adequate for routine triggering |
| Wrangler config drift with new CF features | Low | Pin wrangler major version in `package.json` |
| Beta header (`experimental-cc-routine-2026-04-01`) churn | Medium | Constant in code; bump via release; siblings have same exposure |
| Free tier cron limit (5 unique cron expressions per worker) | Low | One-routine-per-worker model means single-digit crons typical |
| Secrets accidentally committed | High | `.dev.vars` gitignored; secrets only via `wrangler secret put` in CI |
## Success Criteria
- [ ] `wrangler deploy` ships worker; first cron tick fires `/fire` and logs `claude_code_session_url`.
- [ ] `wrangler tail` shows JSON-structured logs for each fire.
- [ ] All CF Workers cron expressions in `wrangler.toml` validated by `wrangler dev` locally.
- [ ] CI runs typecheck + tests on push.
- [ ] README documents quickstart, env vars, secret rotation, beta header policy.
- [ ] Release `v0.1.0` published with `wrangler.toml` example.
## References
- Sibling Go daemon: `/config/workspace/tiennm99/claude-code-routine-cron`
- Sibling GH Actions: `/config/workspace/tiennm99/claude-code-routine-trigger`
- Anthropic `/fire` API: https://platform.claude.com/docs/en/api/claude-code/routines-fire
- CF Workers cron: https://developers.cloudflare.com/workers/configuration/cron-triggers/
+10
View File
@@ -0,0 +1,10 @@
import { cloudflareTest } from '@cloudflare/vitest-pool-workers';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
cloudflareTest({
wrangler: { configPath: './wrangler.toml' },
}),
],
});
+127
View File
@@ -0,0 +1,127 @@
/**
* @typedef {object} Env
* @property {string} ROUTINE_FIRE_URL - secret: Anthropic /fire endpoint URL
* @property {string} ROUTINE_FIRE_TOKEN - secret: per-routine bearer token (sk-ant-oat01-...)
* @property {string} [TEXT_TEMPLATE] - plain var: prompt template, supports {LocalTime} {Cron} {ISO}
* @property {string} [TZ] - plain var: IANA tz, default 'UTC'
*/
/**
* @typedef {object} FireResponse
* @property {string} type
* @property {string} claude_code_session_id
* @property {string} claude_code_session_url
*/
const HEADER_VERSION = '2023-06-01';
const HEADER_BETA = 'experimental-cc-routine-2026-04-01';
const DEFAULT_TEMPLATE = 'Scheduled trigger at {LocalTime}';
const DEFAULT_TZ = 'UTC';
/**
* Substitutes `{Key}` tokens in template. Unknown tokens left intact and warn-logged.
* @param {string} template
* @param {Record<string, string>} vars
* @returns {string}
*/
export function renderText(template, vars) {
return template.replace(/\{(\w+)\}/g, (match, key) => {
if (Object.prototype.hasOwnProperty.call(vars, key)) {
return vars[key];
}
return match;
});
}
/**
* Formats a Date as `YYYY-MM-DD HH:mm ZZZ` in the given IANA timezone.
* @param {Date} date
* @param {string} tz
* @returns {string}
*/
export function formatLocalTime(date, tz) {
const fmt = new Intl.DateTimeFormat('en-CA', {
timeZone: tz,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
timeZoneName: 'short',
});
const parts = Object.fromEntries(fmt.formatToParts(date).map(p => [p.type, p.value]));
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute} ${parts.timeZoneName}`;
}
/**
* POSTs to the Claude Code routine /fire endpoint. Logs structured JSON.
* Never throws — network/HTTP failures are logged at error level so the
* Worker doesn't crash and CF doesn't retry (each /fire = new session).
*
* @param {Env} env
* @param {{ cron: string, scheduledTime: number }} controller
* @returns {Promise<void>}
*/
export async function fireRoutine(env, controller) {
const cron = controller.cron;
const tz = env.TZ ?? DEFAULT_TZ;
const template = env.TEXT_TEMPLATE ?? DEFAULT_TEMPLATE;
const now = new Date(controller.scheduledTime);
const text = renderText(template, {
ISO: now.toISOString(),
LocalTime: formatLocalTime(now, tz),
Cron: cron,
});
let resp;
try {
resp = await fetch(env.ROUTINE_FIRE_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.ROUTINE_FIRE_TOKEN}`,
'anthropic-version': HEADER_VERSION,
'anthropic-beta': HEADER_BETA,
'Content-Type': 'application/json',
},
body: JSON.stringify({ text }),
});
} catch (err) {
console.log(JSON.stringify({
level: 'error', msg: 'fire request failed', cron,
err: err instanceof Error ? err.message : String(err),
}));
return;
}
const respBody = await resp.text();
if (!resp.ok) {
console.log(JSON.stringify({
level: 'error', msg: 'fire non-2xx response',
cron, status: resp.status, body: respBody,
}));
return;
}
/** @type {FireResponse | null} */
let parsed = null;
try {
parsed = JSON.parse(respBody);
} catch {
console.log(JSON.stringify({
level: 'warn', msg: 'fire 2xx but body unparseable',
cron, status: resp.status, body: respBody,
}));
return;
}
console.log(JSON.stringify({
level: 'info', msg: 'fire ok', cron,
session_url: parsed?.claude_code_session_url,
session_id: parsed?.claude_code_session_id,
}));
}
/** @type {ExportedHandler<Env>} */
export default {
async scheduled(controller, env, ctx) {
ctx.waitUntil(fireRoutine(env, controller));
},
};
+180
View File
@@ -0,0 +1,180 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import worker, { renderText, formatLocalTime, fireRoutine } from './worker.js';
const TEST_TOKEN = 'sk-ant-oat01-test-only-not-real';
const TEST_URL = 'https://api.anthropic.com/v1/claude_code/routines/trig_TEST/fire';
/**
* Captures `console.log` output for assertion. Restores on cleanup.
* @returns {{ logs: string[], restore: () => void }}
*/
function captureLogs() {
const logs = [];
const original = console.log;
console.log = (msg) => { logs.push(typeof msg === 'string' ? msg : JSON.stringify(msg)); };
return { logs, restore: () => { console.log = original; } };
}
/**
* Builds a minimal Env for tests.
* @param {Partial<import('./worker.js').Env>} overrides
* @returns {import('./worker.js').Env}
*/
function buildEnv(overrides = {}) {
return {
ROUTINE_FIRE_URL: TEST_URL,
ROUTINE_FIRE_TOKEN: TEST_TOKEN,
...overrides,
};
}
const fixedTime = Date.UTC(2026, 4, 9, 3, 19, 0); // 2026-05-09T03:19:00Z (cron 19 3 * * *)
const baseController = { cron: '19 3 * * *', scheduledTime: fixedTime };
describe('renderText', () => {
it('substitutes {LocalTime}, {Cron}, {ISO}', () => {
const out = renderText('At {LocalTime} cron {Cron} iso {ISO}', {
LocalTime: '2026-05-09 10:19 GMT+7',
Cron: '19 3 * * *',
ISO: '2026-05-09T03:19:00.000Z',
});
expect(out).toBe('At 2026-05-09 10:19 GMT+7 cron 19 3 * * * iso 2026-05-09T03:19:00.000Z');
});
it('leaves unknown {Foo} tokens intact', () => {
const out = renderText('hello {Foo}', { Bar: 'baz' });
expect(out).toBe('hello {Foo}');
});
});
describe('formatLocalTime', () => {
it('defaults to UTC formatting', () => {
const s = formatLocalTime(new Date(fixedTime), 'UTC');
expect(s).toMatch(/^2026-05-09 03:19 /);
});
it('respects IANA tz', () => {
const s = formatLocalTime(new Date(fixedTime), 'Asia/Ho_Chi_Minh');
expect(s).toMatch(/^2026-05-09 10:19 /);
});
});
describe('fireRoutine', () => {
/** @type {ReturnType<typeof captureLogs>} */
let cap;
/** @type {ReturnType<typeof vi.fn>} */
let fetchSpy;
beforeEach(() => {
cap = captureLogs();
fetchSpy = vi.fn();
globalThis.fetch = fetchSpy;
});
function tearDown() {
cap.restore();
vi.restoreAllMocks();
}
it('logs session_url on 2xx', async () => {
fetchSpy.mockResolvedValue(new Response(JSON.stringify({
type: 'ok',
claude_code_session_id: 'sess_1',
claude_code_session_url: 'https://claude.ai/s/sess_1',
}), { status: 200 }));
await fireRoutine(buildEnv(), baseController);
const joined = cap.logs.join('\n');
expect(joined).toContain('"level":"info"');
expect(joined).toContain('https://claude.ai/s/sess_1');
expect(joined).not.toContain(TEST_TOKEN);
tearDown();
});
it('logs level:error on 401 non-2xx', async () => {
fetchSpy.mockResolvedValue(new Response('unauthorized', { status: 401 }));
await fireRoutine(buildEnv(), baseController);
const joined = cap.logs.join('\n');
expect(joined).toContain('"level":"error"');
expect(joined).toContain('"status":401');
expect(joined).not.toContain(TEST_TOKEN);
tearDown();
});
it('logs error and does not throw when fetch rejects', async () => {
fetchSpy.mockRejectedValue(new Error('network down'));
await expect(fireRoutine(buildEnv(), baseController)).resolves.toBeUndefined();
const joined = cap.logs.join('\n');
expect(joined).toContain('"level":"error"');
expect(joined).toContain('network down');
expect(joined).not.toContain(TEST_TOKEN);
tearDown();
});
it('sends required headers and JSON body', async () => {
fetchSpy.mockResolvedValue(new Response(JSON.stringify({
type: 'ok', claude_code_session_id: 's', claude_code_session_url: 'u',
}), { status: 200 }));
await fireRoutine(buildEnv(), baseController);
expect(fetchSpy).toHaveBeenCalledOnce();
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe(TEST_URL);
expect(init.method).toBe('POST');
expect(init.headers['Authorization']).toBe(`Bearer ${TEST_TOKEN}`);
expect(init.headers['anthropic-version']).toBe('2023-06-01');
expect(init.headers['anthropic-beta']).toBe('experimental-cc-routine-2026-04-01');
expect(init.headers['Content-Type']).toBe('application/json');
const parsed = JSON.parse(init.body);
expect(parsed).toHaveProperty('text');
expect(typeof parsed.text).toBe('string');
tearDown();
});
it('uses default template when TEXT_TEMPLATE unset', async () => {
fetchSpy.mockResolvedValue(new Response(JSON.stringify({
type: 'ok', claude_code_session_id: 's', claude_code_session_url: 'u',
}), { status: 200 }));
await fireRoutine(buildEnv(), baseController);
const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
expect(body.text).toMatch(/^Scheduled trigger at /);
tearDown();
});
it('defaults TZ to UTC when env.TZ unset', async () => {
fetchSpy.mockResolvedValue(new Response(JSON.stringify({
type: 'ok', claude_code_session_id: 's', claude_code_session_url: 'u',
}), { status: 200 }));
await fireRoutine(buildEnv(), baseController);
const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
expect(body.text).toContain('03:19'); // UTC hour from fixedTime
tearDown();
});
it('warn-logs unparseable 2xx body', async () => {
fetchSpy.mockResolvedValue(new Response('not-json', { status: 200 }));
await fireRoutine(buildEnv(), baseController);
const joined = cap.logs.join('\n');
expect(joined).toContain('"level":"warn"');
expect(joined).toContain('unparseable');
tearDown();
});
});
describe('default export (scheduled handler)', () => {
it('exposes async scheduled function', () => {
expect(typeof worker.scheduled).toBe('function');
});
});
+40
View File
@@ -0,0 +1,40 @@
name = "claude-code-routine-trigger-worker"
main = "worker.js"
compatibility_date = "2026-05-09"
compatibility_flags = ["nodejs_compat"]
# Plain (non-secret) vars. Override per-environment in the dashboard or via
# `wrangler.toml` env blocks. Secrets go through `wrangler secret put`.
[vars]
TEXT_TEMPLATE = "Scheduled trigger at {LocalTime}"
TZ = "Asia/Ho_Chi_Minh"
# Cron triggers fire from this list. CF Workers requires literal cron
# expressions here — they cannot be loaded from env vars or secrets.
#
# DISABLED: this worker is parked. I migrated this routine to cron-job.org
# to keep my CF cron-trigger quota free for other workers (see README).
#
# `crons = []` (empty array) is REQUIRED to clear schedules from CF — just
# omitting/commenting the block leaves stale entries in the dashboard,
# because Wrangler only pushes what you declare.
#
# To re-enable: replace the empty array with your schedule (sample below)
# and `npx wrangler deploy`.
# crons = [
# "0 0-17,22-23 * * *", # UTC+7: 00:00 + 05:00..23:00 hourly
# ]
[triggers]
crons = []
# Workers Logs / traces. `head_sampling_rate = 1` keeps 100% of invocations
# (low volume here — ~20/day — so full retention is free). Drop to e.g. 0.1
# if traffic ever grows enough to matter.
[observability]
enabled = true
head_sampling_rate = 1
[observability.logs]
enabled = true
head_sampling_rate = 1
invocation_logs = true
+4 -4
View File
@@ -9,7 +9,7 @@ Self-hosted Go daemon that fires a [Claude Code routine](https://code.claude.com
## Why this vs `claude-code-routine-trigger` ## Why this vs `claude-code-routine-trigger`
| | [claude-code-routine-trigger](https://github.com/tiennm99/claude-code-routine-trigger) | **claude-code-routine-cron** (this repo) | | | [GitHub Actions](../) (repo root) | **self-hosted** (this directory) |
| ---------------- | -------------------------------------------------------------------------------------- | ---------------------------------------- | | ---------------- | -------------------------------------------------------------------------------------- | ---------------------------------------- |
| Runs on | GitHub Actions runners | your infra (Docker host, k8s, NAS, RPi) | | Runs on | GitHub Actions runners | your infra (Docker host, k8s, NAS, RPi) |
| Cost | free (within GitHub minutes) | minimal (your infra) | | Cost | free (within GitHub minutes) | minimal (your infra) |
@@ -113,10 +113,10 @@ The request pins `anthropic-beta: experimental-cc-routine-2026-04-01`. When Anth
## Build from source ## Build from source
```bash ```bash
git clone https://github.com/tiennm99/claude-code-routine-cron git clone https://github.com/tiennm99/claude-code-routine-trigger
cd claude-code-routine-cron cd claude-code-routine-trigger/self-hosted
go build . go build .
./claude-code-routine-cron # uses env vars from your shell ./self-hosted # uses env vars from your shell
``` ```
Tests: Tests:
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/tiennm99/claude-code-routine-cron module github.com/tiennm99/claude-code-routine-trigger/self-hosted
go 1.22 go 1.22