diff --git a/apps/cron/20260201-000000/.npmrc b/apps/cron/20260201-000000/.npmrc
new file mode 100644
index 0000000..6c57d5c
--- /dev/null
+++ b/apps/cron/20260201-000000/.npmrc
@@ -0,0 +1 @@
+registry=https://npm.nose.space
diff --git a/apps/cron/20260201-000000/bun.lock b/apps/cron/20260201-000000/bun.lock
new file mode 100644
index 0000000..5cfc9b4
--- /dev/null
+++ b/apps/cron/20260201-000000/bun.lock
@@ -0,0 +1,47 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "cron",
+ "dependencies": {
+ "@because/forge": "*",
+ "@because/hype": "*",
+ "@because/toes": "*",
+ "croner": "^9.0.0",
+ },
+ "devDependencies": {
+ "@types/bun": "latest",
+ },
+ },
+ },
+ "packages": {
+ "@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
+
+ "@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
+
+ "@because/toes": ["@because/toes@0.0.4", "https://npm.nose.space/@because/toes/-/toes-0.0.4.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.1", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-/eZB84VoARYzSBtwJe00dV7Ilgqq7DRFj3vJlWhCHg87Jx5Yr2nTqPnzclLmiZ55XvWNogXqGTzyW8hApzXnJw=="],
+
+ "@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
+
+ "@types/node": ["@types/node@25.2.0", "https://npm.nose.space/@types/node/-/node-25.2.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
+
+ "bun-types": ["bun-types@1.3.8", "https://npm.nose.space/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
+
+ "commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
+
+ "croner": ["croner@9.1.0", "https://npm.nose.space/croner/-/croner-9.1.0.tgz", {}, "sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g=="],
+
+ "diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
+
+ "hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
+
+ "kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
+
+ "typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+
+ "@because/toes/@because/hype": ["@because/hype@0.0.1", "https://npm.nose.space/@because/hype/-/hype-0.0.1.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-i92DNUXJOwt3J8dN1x8sh7i86blelcTCk8XDpwD839Ic8oe710lkDSVXJ7xYZb/i8YtzGhRg+L6eXDhaRiU2Pw=="],
+ }
+}
diff --git a/apps/cron/20260201-000000/index.tsx b/apps/cron/20260201-000000/index.tsx
new file mode 100644
index 0000000..a0696d4
--- /dev/null
+++ b/apps/cron/20260201-000000/index.tsx
@@ -0,0 +1,410 @@
+import { Hype } from '@because/hype'
+import { define, stylesToCSS } from '@because/forge'
+import { baseStyles, initScript, theme } from '@because/toes/tools'
+import { discoverCronJobs } from './lib/discovery'
+import { scheduleJob, stopJob } from './lib/scheduler'
+import { executeJob } from './lib/executor'
+import { setJobs, getJob, getAllJobs, broadcast } from './lib/state'
+import { SCHEDULES, type CronJob } from './lib/schedules'
+import type { Child } from 'hono/jsx'
+import { join } from 'path'
+import { mkdir, writeFile } from 'fs/promises'
+import { existsSync } from 'fs'
+
+const APPS_DIR = process.env.APPS_DIR!
+
+const app = new Hype({ prettyHTML: false })
+
+// Styles (follow versions tool pattern)
+const Container = define('Container', {
+ fontFamily: theme('fonts-sans'),
+ padding: '20px',
+ paddingTop: 0,
+ maxWidth: '900px',
+ margin: '0 auto',
+ color: theme('colors-text'),
+})
+
+const JobList = define('JobList', {
+ listStyle: 'none',
+ padding: 0,
+ margin: '20px 0',
+ border: `1px solid ${theme('colors-border')}`,
+ borderRadius: theme('radius-md'),
+ overflow: 'hidden',
+})
+
+const JobItem = define('JobItem', {
+ padding: '12px 15px',
+ borderBottom: `1px solid ${theme('colors-border')}`,
+ display: 'flex',
+ alignItems: 'center',
+ gap: '15px',
+ states: {
+ ':last-child': { borderBottom: 'none' },
+ ':hover': { backgroundColor: theme('colors-bgHover') },
+ },
+})
+
+const StatusDot = define('StatusDot', {
+ width: '10px',
+ height: '10px',
+ borderRadius: '50%',
+ flexShrink: 0,
+})
+
+const JobName = define('JobName', {
+ fontFamily: theme('fonts-mono'),
+ fontSize: '14px',
+ flex: 1,
+})
+
+const Schedule = define('Schedule', {
+ fontSize: '13px',
+ color: theme('colors-textMuted'),
+ minWidth: '80px',
+})
+
+const Time = define('Time', {
+ fontSize: '12px',
+ color: theme('colors-textMuted'),
+ minWidth: '100px',
+})
+
+const RunButton = define('RunButton', {
+ base: 'button',
+ padding: '4px 10px',
+ fontSize: '12px',
+ backgroundColor: theme('colors-primary'),
+ color: 'white',
+ border: 'none',
+ borderRadius: theme('radius-md'),
+ cursor: 'pointer',
+ states: {
+ ':hover': { opacity: 0.9 },
+ ':disabled': { opacity: 0.5, cursor: 'not-allowed' },
+ },
+})
+
+const EmptyState = define('EmptyState', {
+ padding: '40px 20px',
+ textAlign: 'center',
+ color: theme('colors-textMuted'),
+})
+
+const ActionRow = define('ActionRow', {
+ display: 'flex',
+ justifyContent: 'flex-end',
+ marginBottom: '15px',
+})
+
+const NewButton = define('NewButton', {
+ base: 'a',
+ padding: '8px 16px',
+ fontSize: '14px',
+ backgroundColor: theme('colors-primary'),
+ color: 'white',
+ border: 'none',
+ borderRadius: theme('radius-md'),
+ cursor: 'pointer',
+ textDecoration: 'none',
+ display: 'inline-block',
+ states: {
+ ':hover': { opacity: 0.9 },
+ },
+})
+
+const buttonStyles = {
+ padding: '8px 16px',
+ fontSize: '14px',
+ backgroundColor: theme('colors-primary'),
+ color: 'white',
+ border: 'none',
+ borderRadius: theme('radius-md'),
+ cursor: 'pointer',
+}
+
+const Form = define('Form', {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '16px',
+ maxWidth: '400px',
+})
+
+const FormGroup = define('FormGroup', {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '6px',
+})
+
+const Label = define('Label', {
+ fontSize: '14px',
+ fontWeight: 500,
+})
+
+const inputStyles = {
+ padding: '8px 12px',
+ fontSize: '14px',
+ border: `1px solid ${theme('colors-border')}`,
+ borderRadius: theme('radius-md'),
+ backgroundColor: theme('colors-bg'),
+ color: theme('colors-text'),
+}
+
+const ButtonRow = define('ButtonRow', {
+ display: 'flex',
+ gap: '10px',
+ marginTop: '10px',
+})
+
+const CancelButton = define('CancelButton', {
+ base: 'a',
+ padding: '8px 16px',
+ fontSize: '14px',
+ backgroundColor: 'transparent',
+ color: theme('colors-text'),
+ border: `1px solid ${theme('colors-border')}`,
+ borderRadius: theme('radius-md'),
+ cursor: 'pointer',
+ textDecoration: 'none',
+ states: {
+ ':hover': { backgroundColor: theme('colors-bgHover') },
+ },
+})
+
+// Layout
+function Layout({ title, children }: { title: string; children: Child }) {
+ return (
+
+
+
+
+ {title}
+
+
+
+
+
+ {children}
+
+
+
+ )
+}
+
+function formatRelative(ts?: number): string {
+ if (!ts) return '-'
+ const diff = Date.now() - ts
+ if (diff < 0) {
+ const mins = Math.round(-diff / 60000)
+ if (mins < 60) return `in ${mins}m`
+ const hours = Math.round(mins / 60)
+ if (hours < 24) return `in ${hours}h`
+ return `in ${Math.round(hours / 24)}d`
+ }
+ const mins = Math.round(diff / 60000)
+ if (mins < 60) return `${mins}m ago`
+ const hours = Math.round(mins / 60)
+ if (hours < 24) return `${hours}h ago`
+ return `${Math.round(hours / 24)}d ago`
+}
+
+function statusColor(job: CronJob): string {
+ if (job.state === 'running') return theme('colors-statusRunning')
+ if (job.state === 'disabled') return theme('colors-textMuted')
+ if (job.lastExitCode !== undefined && job.lastExitCode !== 0) return theme('colors-error')
+ return theme('colors-statusRunning')
+}
+
+// Routes
+app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
+ 'Content-Type': 'text/css; charset=utf-8',
+}))
+
+app.get('/', async c => {
+ const appFilter = c.req.query('app')
+ let jobs = getAllJobs()
+
+ if (appFilter) {
+ jobs = jobs.filter(j => j.app === appFilter)
+ }
+
+ jobs.sort((a, b) => a.id.localeCompare(b.id))
+
+ return c.html(
+
+
+ New Job
+
+ {jobs.length === 0 ? (
+
+ No cron jobs found.
+
+ Create a cron/*.ts file in any app to get started.
+
+ ) : (
+
+ {jobs.map(job => (
+
+
+ {job.app}/{job.name}
+ {job.schedule}
+
+
+
+
+ ))}
+
+ )}
+
+ )
+})
+
+app.get('/new', async c => {
+ const appName = c.req.query('app') || ''
+
+ return c.html(
+
+
+
+
+ )
+})
+
+app.post('/new', async c => {
+ const body = await c.req.parseBody()
+ const appName = body.app as string
+ const name = body.name as string
+ const schedule = body.schedule as string
+
+ if (!appName || !name || !schedule) {
+ return c.redirect('/new?error=missing-fields')
+ }
+
+ // Validate name (lowercase, numbers, hyphens)
+ if (!/^[a-z0-9-]+$/.test(name)) {
+ return c.redirect('/new?error=invalid-name')
+ }
+
+ const cronDir = join(APPS_DIR, appName, 'current', 'cron')
+ const filePath = join(cronDir, `${name}.ts`)
+
+ // Check if file already exists
+ if (existsSync(filePath)) {
+ return c.redirect('/new?error=already-exists')
+ }
+
+ // Create cron directory if needed
+ if (!existsSync(cronDir)) {
+ await mkdir(cronDir, { recursive: true })
+ }
+
+ // Write the cron file
+ const content = `export const schedule = "${schedule}"
+
+export default async function() {
+ console.log("${appName}/${name} executed at", new Date().toISOString())
+}
+`
+
+ await writeFile(filePath, content)
+ console.log(`[cron] Created ${appName}:${name}`)
+
+ // Trigger rediscovery
+ const jobs = await discoverCronJobs()
+ setJobs(jobs)
+ for (const job of jobs) {
+ if (job.id === `${appName}:${name}`) {
+ scheduleJob(job, broadcast)
+ console.log(`[cron] Scheduled ${job.id}: ${job.schedule} (${job.cronExpr})`)
+ }
+ }
+
+ return c.redirect('/')
+})
+
+app.post('/run/:app/:name', async c => {
+ const id = `${c.req.param('app')}:${c.req.param('name')}`
+ const job = getJob(id)
+
+ if (!job) {
+ return c.redirect('/?error=not-found')
+ }
+
+ await executeJob(job, broadcast)
+
+ const appFilter = c.req.query('app')
+ return c.redirect(appFilter ? `/?app=${appFilter}` : '/')
+})
+
+// Initialize
+async function init() {
+ const jobs = await discoverCronJobs()
+ setJobs(jobs)
+ console.log(`[cron] Discovered ${jobs.length} jobs`)
+
+ for (const job of jobs) {
+ scheduleJob(job, broadcast)
+ console.log(`[cron] Scheduled ${job.id}: ${job.schedule} (${job.cronExpr})`)
+ }
+}
+
+// Re-discover every 60s
+setInterval(async () => {
+ const jobs = await discoverCronJobs()
+ const existing = getAllJobs()
+
+ // Stop removed jobs
+ for (const old of existing) {
+ if (!jobs.find(j => j.id === old.id)) {
+ stopJob(old.id)
+ console.log(`[cron] Removed ${old.id}`)
+ }
+ }
+
+ // Add/update jobs
+ for (const job of jobs) {
+ const old = existing.find(j => j.id === job.id)
+ if (!old || old.cronExpr !== job.cronExpr) {
+ scheduleJob(job, broadcast)
+ console.log(`[cron] Updated ${job.id}: ${job.schedule}`)
+ } else {
+ // Preserve runtime state
+ job.state = old.state
+ job.lastRun = old.lastRun
+ job.lastDuration = old.lastDuration
+ job.lastExitCode = old.lastExitCode
+ job.lastError = old.lastError
+ job.nextRun = old.nextRun
+ }
+ }
+
+ setJobs(jobs)
+}, 60000)
+
+init()
+
+export default app.defaults
diff --git a/apps/cron/20260201-000000/lib/discovery.ts b/apps/cron/20260201-000000/lib/discovery.ts
new file mode 100644
index 0000000..6eb69a4
--- /dev/null
+++ b/apps/cron/20260201-000000/lib/discovery.ts
@@ -0,0 +1,65 @@
+import { readdir } from 'fs/promises'
+import { existsSync } from 'fs'
+import { join } from 'path'
+import { isValidSchedule, toCronExpr, type CronJob, type Schedule } from './schedules'
+
+const APPS_DIR = process.env.APPS_DIR!
+
+export async function getApps(): Promise {
+ const entries = await readdir(APPS_DIR, { withFileTypes: true })
+ const apps: string[] = []
+
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue
+ // Check if it has a current symlink (valid app)
+ if (existsSync(join(APPS_DIR, entry.name, 'current'))) {
+ apps.push(entry.name)
+ }
+ }
+
+ return apps.sort()
+}
+
+export async function discoverCronJobs(): Promise {
+ const jobs: CronJob[] = []
+ const apps = await readdir(APPS_DIR, { withFileTypes: true })
+
+ for (const app of apps) {
+ if (!app.isDirectory()) continue
+
+ const cronDir = join(APPS_DIR, app.name, 'current', 'cron')
+ if (!existsSync(cronDir)) continue
+
+ const files = await readdir(cronDir)
+ for (const file of files) {
+ if (!file.endsWith('.ts')) continue
+
+ const filePath = join(cronDir, file)
+ const name = file.replace(/\.ts$/, '')
+
+ try {
+ const mod = await import(filePath)
+ const schedule = mod.schedule as Schedule
+
+ if (!isValidSchedule(schedule)) {
+ console.error(`Invalid schedule in ${filePath}: ${schedule}`)
+ continue
+ }
+
+ jobs.push({
+ id: `${app.name}:${name}`,
+ app: app.name,
+ name,
+ file: filePath,
+ schedule,
+ cronExpr: toCronExpr(schedule),
+ state: 'idle',
+ })
+ } catch (e) {
+ console.error(`Failed to load cron file ${filePath}:`, e)
+ }
+ }
+ }
+
+ return jobs
+}
diff --git a/apps/cron/20260201-000000/lib/executor.ts b/apps/cron/20260201-000000/lib/executor.ts
new file mode 100644
index 0000000..83092db
--- /dev/null
+++ b/apps/cron/20260201-000000/lib/executor.ts
@@ -0,0 +1,50 @@
+import { join } from 'path'
+import type { CronJob } from './schedules'
+import { getNextRun } from './scheduler'
+
+const APPS_DIR = process.env.APPS_DIR!
+
+export async function executeJob(job: CronJob, onUpdate: () => void): Promise {
+ if (job.state === 'disabled') return
+
+ job.state = 'running'
+ job.lastRun = Date.now()
+ onUpdate()
+
+ const cwd = join(APPS_DIR, job.app, 'current')
+
+ try {
+ const proc = Bun.spawn(['bun', 'run', job.file], {
+ cwd,
+ env: { ...process.env },
+ stdout: 'pipe',
+ stderr: 'pipe',
+ })
+
+ const [stdout, stderr] = await Promise.all([
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ])
+
+ const code = await proc.exited
+
+ job.lastDuration = Date.now() - job.lastRun
+ job.lastExitCode = code
+ job.lastError = code !== 0 ? stderr || 'Non-zero exit' : undefined
+ job.state = 'idle'
+ job.nextRun = getNextRun(job.id)
+
+ // Log result
+ console.log(`[cron] ${job.id} finished: code=${code} duration=${job.lastDuration}ms`)
+ if (stdout) console.log(stdout)
+ if (stderr) console.error(stderr)
+ } catch (e) {
+ job.lastDuration = Date.now() - job.lastRun
+ job.lastExitCode = 1
+ job.lastError = e instanceof Error ? e.message : String(e)
+ job.state = 'idle'
+ console.error(`[cron] ${job.id} failed:`, e)
+ }
+
+ onUpdate()
+}
diff --git a/apps/cron/20260201-000000/lib/scheduler.ts b/apps/cron/20260201-000000/lib/scheduler.ts
new file mode 100644
index 0000000..20c5fae
--- /dev/null
+++ b/apps/cron/20260201-000000/lib/scheduler.ts
@@ -0,0 +1,26 @@
+import { Cron } from 'croner'
+import type { CronJob } from './schedules'
+import { executeJob } from './executor'
+
+const scheduled = new Map()
+
+export function scheduleJob(job: CronJob, onUpdate: () => void) {
+ // Stop existing if any
+ scheduled.get(job.id)?.stop()
+
+ const cron = new Cron(job.cronExpr, async () => {
+ await executeJob(job, onUpdate)
+ })
+
+ scheduled.set(job.id, cron)
+ job.nextRun = cron.nextRun()?.getTime()
+}
+
+export function stopJob(jobId: string) {
+ scheduled.get(jobId)?.stop()
+ scheduled.delete(jobId)
+}
+
+export function getNextRun(jobId: string): number | undefined {
+ return scheduled.get(jobId)?.nextRun()?.getTime()
+}
diff --git a/apps/cron/20260201-000000/lib/schedules.ts b/apps/cron/20260201-000000/lib/schedules.ts
new file mode 100644
index 0000000..049e8ff
--- /dev/null
+++ b/apps/cron/20260201-000000/lib/schedules.ts
@@ -0,0 +1,80 @@
+export type Schedule =
+ | "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday"
+ | "week" | "day" | "midnight" | "noon" | "hour"
+ | "30 minutes" | "15 minutes" | "5 minutes" | "1 minute"
+ | "30minutes" | "15minutes" | "5minutes" | "1minute"
+ | 30 | 15 | 5 | 1
+
+export type CronJob = {
+ id: string // "appname:filename"
+ app: string
+ name: string // filename without .ts
+ file: string // full path
+ schedule: Schedule
+ cronExpr: string
+ state: 'idle' | 'running' | 'disabled'
+ lastRun?: number
+ lastDuration?: number
+ lastExitCode?: number
+ lastError?: string
+ nextRun?: number
+}
+
+export const SCHEDULES = [
+ '1 minute',
+ '5 minutes',
+ '15 minutes',
+ '30 minutes',
+ 'hour',
+ 'noon',
+ 'midnight',
+ 'day',
+ 'week',
+ 'sunday',
+ 'monday',
+ 'tuesday',
+ 'wednesday',
+ 'thursday',
+ 'friday',
+ 'saturday',
+] as const
+
+const SCHEDULE_MAP: Record = {
+ sunday: '0 0 * * 0',
+ monday: '0 0 * * 1',
+ tuesday: '0 0 * * 2',
+ wednesday: '0 0 * * 3',
+ thursday: '0 0 * * 4',
+ friday: '0 0 * * 5',
+ saturday: '0 0 * * 6',
+ week: '0 0 * * 0',
+ day: '0 0 * * *',
+ midnight: '0 0 * * *',
+ noon: '0 12 * * *',
+ hour: '0 * * * *',
+ '30 minutes': '0,30 * * * *',
+ '15 minutes': '0,15,30,45 * * * *',
+ '5 minutes': '*/5 * * * *',
+ '1 minute': '* * * * *',
+ '30minutes': '0,30 * * * *',
+ '15minutes': '0,15,30,45 * * * *',
+ '5minutes': '*/5 * * * *',
+ '1minute': '* * * * *',
+}
+
+export function toCronExpr(schedule: Schedule): string {
+ if (typeof schedule === 'number') {
+ return SCHEDULE_MAP[`${schedule}minutes`]
+ }
+ return SCHEDULE_MAP[schedule]
+}
+
+export function isValidSchedule(value: unknown): value is Schedule {
+ if (typeof value === 'number') {
+ return [1, 5, 15, 30].includes(value)
+ }
+ if (typeof value === 'string') {
+ return value in SCHEDULE_MAP
+ }
+ return false
+}
diff --git a/apps/cron/20260201-000000/lib/state.ts b/apps/cron/20260201-000000/lib/state.ts
new file mode 100644
index 0000000..214d502
--- /dev/null
+++ b/apps/cron/20260201-000000/lib/state.ts
@@ -0,0 +1,29 @@
+import type { CronJob } from './schedules'
+
+const jobs = new Map()
+const listeners = new Set<() => void>()
+
+export function setJobs(newJobs: CronJob[]) {
+ jobs.clear()
+ for (const job of newJobs) {
+ jobs.set(job.id, job)
+ }
+ broadcast()
+}
+
+export function getJob(id: string): CronJob | undefined {
+ return jobs.get(id)
+}
+
+export function getAllJobs(): CronJob[] {
+ return Array.from(jobs.values())
+}
+
+export function broadcast() {
+ listeners.forEach(cb => cb())
+}
+
+export function onChange(cb: () => void): () => void {
+ listeners.add(cb)
+ return () => listeners.delete(cb)
+}
diff --git a/apps/cron/20260201-000000/package.json b/apps/cron/20260201-000000/package.json
new file mode 100644
index 0000000..3af0920
--- /dev/null
+++ b/apps/cron/20260201-000000/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "cron",
+ "private": true,
+ "module": "index.tsx",
+ "type": "module",
+ "scripts": {
+ "toes": "bun run --watch index.tsx"
+ },
+ "toes": {
+ "tool": true,
+ "icon": "⏰"
+ },
+ "dependencies": {
+ "@because/forge": "*",
+ "@because/hype": "*",
+ "@because/toes": "*",
+ "croner": "^9.0.0"
+ },
+ "devDependencies": {
+ "@types/bun": "latest"
+ }
+}
diff --git a/apps/cron/20260201-000000/tsconfig.json b/apps/cron/20260201-000000/tsconfig.json
new file mode 100644
index 0000000..83fd2a4
--- /dev/null
+++ b/apps/cron/20260201-000000/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "types": ["bun"],
+ "esModuleInterop": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "hono/jsx"
+ }
+}
diff --git a/apps/cron/current b/apps/cron/current
new file mode 120000
index 0000000..a874989
--- /dev/null
+++ b/apps/cron/current
@@ -0,0 +1 @@
+20260201-000000
\ No newline at end of file
diff --git a/docs/CRON.md b/docs/CRON.md
new file mode 100644
index 0000000..800c950
--- /dev/null
+++ b/docs/CRON.md
@@ -0,0 +1,81 @@
+# Cron
+
+A cron job is a TypeScript file that runs on a schedule.
+
+The cron tool discovers jobs from all apps and runs them automatically.
+
+## creating a cron job
+
+Add a file to `cron/` in any app:
+
+```ts
+// apps/my-app/current/cron/daily-cleanup.ts
+export const schedule = "day"
+
+export default async function() {
+ console.log("Running cleanup...")
+}
+```
+
+That's it. The cron tool picks it up within 60 seconds.
+
+## schedules
+
+| value | when |
+|-------|------|
+| `1 minute` | every minute |
+| `5 minutes` | every 5 minutes |
+| `15 minutes` | every 15 minutes |
+| `30 minutes` | every 30 minutes |
+| `hour` | top of every hour |
+| `noon` | 12:00 daily |
+| `midnight` / `day` | 00:00 daily |
+| `week` / `sunday` | 00:00 Sunday |
+| `monday` - `saturday` | 00:00 that day |
+
+Alternate forms work too: `"30minutes"`, `30`, `"30 minutes"`.
+
+## environment
+
+Jobs run with the app's working directory and inherit all env vars.
+
+- `APPS_DIR` - path to `/apps` directory
+
+## ui
+
+The cron tool shows:
+- Job name (`app/job`)
+- Schedule
+- Last run time
+- Next run time
+- Run Now button
+
+Click **New Job** to create one from the UI.
+
+## manual runs
+
+Hit the **Run Now** button or POST to the tool:
+
+```bash
+curl -X POST http://localhost:3001/run/my-app/daily-cleanup
+```
+
+## job state
+
+Jobs track:
+- `idle` / `running` / `disabled`
+- Last run timestamp
+- Last duration
+- Last exit code
+- Last error (if any)
+- Next scheduled run
+
+## discovery
+
+The cron tool:
+1. Scans `APPS_DIR/*/current/cron/*.ts`
+2. Imports each file to read `schedule`
+3. Validates the schedule
+4. Registers with croner
+
+Re-scans every 60 seconds to pick up new/changed/removed jobs.