name: typeclaw-cron
description: Use this skill whenever the user asks you to schedule recurring work OR a one-off future task/reminder, run something on a cron, do something every day/hour/week, do something once at a future time, set up a periodic task, list or inspect scheduled jobs, or read or edit your cron schedule — AND whenever the user wants you to STOP, disable, pause, or remove a recurring/scheduled job, or complains that you "keep" posting, sending, or doing the same thing on a schedule (a repeating message you send every N minutes is almost always a cron job). Triggers include "every morning", "every Monday", "schedule a", "remind me every", "set up a cron", "run X periodically", "remind me in 3 days", "remind me tomorrow", "remind me at 9am", "remind me next Monday", "in N hours/days do X", "do X once at hh:mm", "stop after N times", "until ", "list cron jobs", "list scheduled jobs", "show me the cron", "what cron jobs do you have", "what's on my cron", "when does X run", as well as STOP/DISABLE triggers in any language like "turn it off", "turn that off", "stop that message", "stop sending that", "stop posting that", "stop doing that", "disable it", "disable that job", "pause that", "remove that job", "delete that cron", "why do you keep posting/sending this", "make it stop", and their non-English equivalents (e.g. Korean 꺼/꺼줘/멈춰/그만/중지/왜 자꾸, Spanish para/detén/desactiva/deja de enviar, Japanese 止めて/停止/オフにして/何度も, Chinese 停止/关闭/别再发/取消定时), or any mention of cron.json. Read it before touching cron.json — the file has a strict schema, restart semantics, and a best-effort execution model that you must not misrepresent to the user.
cron.json. Read it before touching cron.json — the file has a strict schema, restart semantics, and a best-effort execution model that you must not misrepresent to the user.typeclaw-cron
You have a cron file at ./cron.json in your agent folder. It defines periodic jobs that the typeclaw runtime fires on schedule. This skill exists so you do not corrupt the file, do not promise behavior the runtime cannot deliver, and do not surprise the user.
What cron actually does
The typeclaw runtime starts a scheduler when the container boots. The scheduler reads cron.json once at startup. While the container runs, it fires each enabled job at its next scheduled time. There is no daemon outside the container — if the container is down, nothing runs.
This is a best-effort scheduler. Concretely:
- Missed ticks are not replayed. If the container was down at 23:30 and starts at 23:45, the 23:30 fire is lost forever.
- Overlapping fires are skipped, not queued. If a job is still running when its next tick arrives, the new tick is dropped (logged) and the next attempt is the tick after that.
- There are no retries, no timeouts, no failure hooks. A job that throws is logged and forgotten until its next scheduled fire.
Tell the user this if they ask about reliability. Do not invent guarantees the runtime does not give them.
The two job kinds
cron.json has one top-level key: jobs. Each job has a kind discriminator, plus shared fields and kind-specific fields.
Shared fields (all jobs)
| Field | Required | Notes |
|---|---|---|
id |
yes | Unique. Letters, digits, hyphens, underscores. Used in logs and to coalesce. |
schedule |
one of these | Standard 5-field cron expression (min hr dom mon dow) or 6-field with seconds. Recurring. See "Schedule syntax" below. |
at |
one of these | One-shot ISO instant — fires once then retires. Mutually exclusive with schedule; set exactly one. See "One-shot reminders (at)" below. |
until |
no | Recurring only. Absolute ISO instant; last allowed fire (inclusive). The job retires after this. |
count |
no | Recurring only. Stop after N accepted fires. Coexists with until — whichever boundary is reached first wins. |
enabled |
no | Defaults to true. Set to false to keep a job in the file but skip it. |
timezone |
no | IANA name like Asia/Seoul. Recurring (schedule) only — NOT valid with at. Defaults to UTC (the container's timezone). |
schedule XOR at: every job has exactly one of schedule (recurring) or at (one-shot). Setting both, or neither, is rejected. at jobs may not set until, timezone, or count > 1 (the instant already pins the single fire).
kind: "prompt" — fire a prompt into a fresh session
{
"id": "daily-summary",
"schedule": "30 23 * * *",
"kind": "prompt",
"prompt": "Read today's session jsonl files in sessions/ and summarize the day into memory/."
}
When this fires, the runtime opens a brand new AgentSession (yours, with your IDENTITY/SOUL/AGENTS files loaded), sends it the prompt text as if the user typed it, and disposes the session when done.
What this means for how you write prompts:
- Treat the prompt as a self-contained instruction to your future self. It runs in a session with no memory of past prompt-job runs unless you persist across runs (e.g. by writing to
MEMORY.mdormemory/). - The session has all your tools. You can
read,write,bash, edit files, commit to git — anything you can do in a normal turn. - There is no human on the other end. No one will answer clarifying questions. Make the prompt complete and unambiguous.
- Be specific about side effects. "Summarize today" is vague. "Read every
sessions/*.jsonlmodified today and append a summary tomemory/$(date +%F)-summary.md" is actionable.
kind: "exec" — run a shell command, no LLM
{
"id": "hourly-backup",
"schedule": "0 * * * *",
"kind": "exec",
"command": ["git", "commit", "-am", "hourly snapshot"]
}
The runtime spawns the command directly with Bun.spawn from the agent folder (/agent inside the container). No agent session is created. No LLM call happens. The command's exit code and stderr are captured to container logs.
Use exec only for jobs that are pure mechanics — no judgement required. Examples that fit: git snapshots, log rotation, calling a script that already exists. Examples that don't fit: anything where "what do I commit" or "what should I write" depends on context. Use prompt for those — or, when the work needs imperative control flow that mixes shell calls and LLM calls (probe → maybe prompt → write file) and both the cadence and the logic belong to the same plugin, write a kind: 'handler' plugin cron job (see below). That's the best practice for the exec → LLM pattern; a cron exec pointing at typeclaw <plugin-cmd> is a narrower fallback for reusable / host-surface cases.
command is an array. Index 0 is the executable, the rest are argv. Do not put a single shell pipeline in command[0] — that won't be parsed by a shell. If you need shell features (|, >, &&), wrap explicitly: ["sh", "-c", "your | pipeline | here"].
One-shot reminders and future tasks (at)
When the user wants something to happen once at a future time — "remind me in 3 days to cancel the subscription", "ping me tomorrow at 9", "in 2 hours, check if the build finished" — use at instead of schedule. The job fires exactly once at that instant, then retires (the scheduler stops arming it; it never fires again).
{
"id": "cancel-sub",
"at": "2026-06-11T09:00:00+09:00",
"kind": "prompt",
"prompt": "Remind the user to cancel the subscription they mentioned on 2026-06-08. If they already handled it, say so and move on.",
"scheduledByRole": "owner"
}
at works with any kind (prompt for "remind me / do this judgement task", exec for a one-off mechanical command). The same best-effort rules apply: if the container is down at the at instant, the fire is lost, not replayed. Say so if the user is relying on it for something important.
The at value MUST carry an explicit zone or offset
at is parsed as an absolute instant, so it requires a trailing Z or a numeric offset. A bare local-time string is rejected (it would silently resolve to UTC and surprise the user).
- ✅
2026-06-11T09:00:00+09:00(Seoul morning) or2026-06-11T00:00:00Z - ❌
2026-06-11T09:00:00(no zone — rejected byparseCronFile)
The at instant must be in the future. Writing an enabled at in the past is rejected at write time (the reload/guard validation returns "at" is in the past), because a past reminder would be retired immediately and never fire — a silent no-op the user would mistake for a scheduled reminder. If you get this error, recompute the instant (you probably botched the timezone offset) and write again. (An already-fired one-shot left on disk is the one exception — it stays valid so it can't brick reload — but you never author one of those by hand.)
Resolving "9am" / "tomorrow" / "in 3 days" to an instant:
- Get the user's timezone. Check
USER.mdfor a recorded zone; if it's not there and the wall-clock matters, ask once. - Compute the absolute instant in that zone. "Remind me in 3 days" → take now, add 3×24h (or the next 09:00 in their zone if they said a time), and emit it with the zone's offset, e.g.
+09:00. - Use
bash(date) if you need to compute the offset precisely rather than guessing — e.g.date -u -d '+3 days' +%Y-%m-%dT%H:%M:%SZ. Don't hand-roll DST math.
Do not invent until, timezone, or count > 1 on an at job — they're rejected. The single instant is the whole schedule.
Clean up after a one-shot fires (self-prune)
A fired at job does not delete itself from cron.json — it stays on disk as an inert, already-retired entry (the runtime never writes back to cron.json; that's by design). On its own this is harmless: the scheduler sees the past instant and retires it without firing, so it will not run again and will not break reload.
But to keep cron.json clean, a one-shot prompt job should remove its own entry as the last step of its fire. Because the fire runs in a full agent session with all your tools, end the reminder prompt with a self-cleanup instruction. Write your prompt so your future self does this:
"... After delivering the reminder, remove the cron job with id
cancel-subfromcron.jsonand call thereloadtool so the dead one-shot doesn't linger."
Removing a job passes the cronPromotion guard freely — deletions are privilege reductions and are never blocked. If the cleanup is ever skipped (model error, crash mid-session), the worst case is a harmless leftover entry you can prune on any later edit — never a broken schedule. This self-prune only applies to prompt jobs; an at exec job has no LLM to clean up after itself, so its entry just lingers until a human or a later prompt removes it.
exec → LLM: write a plugin cron handler (best practice)
If a scheduled job needs imperative control flow that mixes shell calls and LLM calls (probe → maybe prompt → write file), the best practice is a plugin cron handler: a TypeScript function the plugin registers under its own cronJobs with kind: 'handler'. The cron consumer invokes it directly — no shell-out, no WS round-trip, no Bun.spawn. Prefer this whenever the cadence and the logic both belong to the same plugin (which is almost always — see "When to reach for the exec bridge instead" below for the two narrow exceptions).
cron.json itself supports only prompt and exec — kind: 'handler' is plugin-only because the handler is a TypeScript function reference (not JSON-serializable). User-authored cron files that try to declare kind: 'handler' are rejected by parseCronFile.
// packages/dev-audits/index.ts
import { definePlugin } from 'typeclaw/plugin'
export default definePlugin({
plugin: async () => ({
cronJobs: {
daily: {
schedule: '0 22 * * *',
timezone: 'Asia/Seoul',
kind: 'handler',
handler: async (ctx) => {
const { stdout } = await ctx.exec`git log --since='24h' --pretty=format:'%h %s'`
if (stdout.trim().length === 0) return
await ctx.prompt(
`These commits landed in the last 24h:\n${stdout}\nAppend a critique of weak commit messages to memory/audits/$(date +%F)-commits.md. Be specific — quote bad messages and suggest rewrites.`,
)
},
},
},
}),
})
typeclaw.json:
{
"plugins": ["./packages/dev-audits"]
}
That's the whole installation. No cron.json edit, no CLI command shim. typeclaw restart and the job is live.
The CronHandlerContext surface
The handler receives a ctx with the LLM-call surface of a container plugin command, minus the CLI-shaped fields:
| Field | Type | Notes |
|---|---|---|
ctx.jobId |
string |
The global cron id (__plugin_<plugin-name>_<key>). Useful for log lines. |
ctx.name |
string |
The plugin name that registered this cron job. Mirrors ContainerCommandContext.name. |
ctx.agentDir |
string |
/agent in the container. |
ctx.logger |
PluginLogger |
Plugin-prefixed info / warn / error going to container stdout. |
ctx.signal |
AbortSignal |
Reserved for future cancellation; currently never aborted by the runtime (matches existing prompt/exec cron behavior — in-flight work runs to completion on container shutdown). Already threaded into ctx.prompt and ctx.exec, so future aborts propagate without handler-author changes. |
ctx.permissions |
PermissionService |
Same service the rest of the runtime uses. Most handlers don't need it; the LLM session resolves permissions through ctx.origin automatically. |
ctx.origin |
SessionOrigin (cron-shaped) |
{ kind: 'cron', jobId, jobKind: 'handler', scheduledByRole, scheduledByOrigin }. Plugin-contributed jobs default to scheduledByRole: 'owner'. |
ctx.prompt |
(text: string) => Promise<string> |
Opens a brand-new agent session with the full toolset, sends text, returns the final assistant message. Uses slim system prompt mode (saves ~2000 tokens per LLM call vs a TUI session). |
ctx.subagent |
(name: string, payload?) => Promise<void> |
Invokes a registered subagent. Same dispatch path as PluginContext.spawnSubagent. |
ctx.exec |
ctx.exec`shell pipeline` → Promise<CommandExecResult> |
Tagged template; runs in the agent folder with ctx.signal threaded through. Abort kills the entire process group (SIGTERM → 5s grace → SIGKILL). |
What's NOT on the handler ctx (and why):
stdin/stdout/stderr— cron has no caller piping bytes in or reading bytes out. Usectx.loggeror write files for output.args— handlers are scheduled, not invoked with flags. Configurable values come through the plugin'sconfigSchema.- Return value — the function returns
Promise<void>. Throw to signal failure; the cron consumer catches and logs. (Note: do NOT writereturn 0— handler return isvoid, not a numeric exit code like a container command'srun.)
Trust model
Plugin-contributed cron handlers run with scheduledByRole: 'owner' because installed plugins already execute arbitrary in-process TypeScript at boot, on every hook, and inside every tool — granting cron handlers a tighter role wouldn't be a security boundary anyway, since the plugin code already has full process privileges. The role is real (every tool call inside ctx.prompt resolves against it) and a future API could tighten it for specific contexts, but today plugin authors are trusted runtime contributions, not user input. See typeclaw-permissions for the broader model.
When to reach for the exec bridge instead
The kind: 'handler' path is the right answer for plugin-internal scheduled imperative work. The exec bridge — a kind: 'exec' cron job invoking ["typeclaw", "<plugin-command>", ...] — is the right answer ONLY when reusability is a real requirement, not just because the work is scheduled. The bridge buys you a callable CLI surface; the handler does not. Use the bridge when one of these holds:
The same logic must also be invocable as a CLI command. The user wants to run
typeclaw audit-commits --since=7dmanually from the TUI or a shell, or another plugin /composeorchestration wants to call it, or the work needs flags that are part of a public command interface. Write the logic once inside asurface: 'container'plugin command'srun, then point cron at it. Same imperative control flow lives in the command body; cron just provides a different trigger.The user owns the cadence. Someone else's plugin ships
audit-commits(as a container command, seetypeclaw-plugins§5.7) but no cron registration, or its default cadence doesn't match what the user wants. The user adds acron.jsonexec job pointing at the command — no need to fork the plugin to change the schedule.The scheduled job needs to invoke a
surface: 'host'command. Host commands run outside the container with no agent runtime — neitherctx.promptnorctx.subagentis available there. A cronexecjob invokingtypeclaw <host-cmd>is the only way to schedule host-side work from inside the container's cron.
If none of those apply — the plugin owns both the cadence and the logic, and nothing else needs to call the logic — write a kind: 'handler' job. "It's scheduled work that needs LLM judgement" alone is NOT a reason to reach for the bridge; the bridge costs a shell-out, a WS round-trip, and an args-parse round-trip that the handler avoids entirely.
In both cases the command array is ['typeclaw', '<cmd>', ...] and the runtime injects TYPECLAW_PARENT_ORIGIN_JSON so the spawned subprocess inherits the cron job's role through the same mechanism that protects plugin-contributed handlers from silent elevation.
// cron.json — user wants someone else's plugin command on a custom schedule
{
"jobs": [
{
"id": "weekly-commit-audit",
"schedule": "0 22 * * 0",
"timezone": "Asia/Seoul",
"kind": "exec",
"command": ["typeclaw", "audit-commits", "--since=7d"],
"scheduledByRole": "owner"
}
]
}
A plugin can ship a kind: 'handler' default in cronJobs AND the user can add a different cadence in cron.json for the same command. They are independent cron jobs at the scheduler layer.
Decision rules — which arm picks what
"I have scheduled work" → start here
Is the work pure mechanics (git commit, log rotation, calling a known script)?
└─ Yes → kind: 'exec' in cron.json. No plugin needed.
Does it need LLM judgement?
│
├─ One-shot natural-language prompt, no probes, no shell pre-work?
│ └─ kind: 'prompt' in cron.json.
│
├─ Imperative control flow (probe → maybe prompt → write file)?
│ │
│ ├─ Default: cadence + logic both belong to the same plugin,
│ │ nothing outside cron needs to call this logic?
│ │ └─ kind: 'handler' in the plugin's cronJobs. ← BEST PRACTICE
│ │
│ ├─ The same logic ALSO needs to be a callable CLI command
│ │ (TUI / manual shell / compose), or the user owns the
│ │ cadence for someone else's command?
│ │ └─ kind: 'exec' in cron.json, command: ['typeclaw', '<cmd>']
│ │ (write the command as surface: 'container')
│ │
│ └─ The work needs a `surface: 'host'` plugin command?
│ └─ kind: 'exec' in cron.json, command: ['typeclaw', '<host-cmd>']
What this pattern is NOT
- It is not a way to bypass permissions. Plugin
kind: 'handler'jobs run under the plugin-default role ('owner'). Pluginkind: 'exec'andcron.jsonkind: 'exec'stampscheduledByRoleinto the spawned subprocess viaTYPECLAW_PARENT_ORIGIN_JSON; the plugin command'sctx.origincarries that role into every tool call insidectx.prompt's session. A cron scheduled asscheduledByRole: 'member'runs as a member — no silent elevation. Seetypeclaw-permissions. - It is not a wrapper for shell pipelines you already have working. If
bash some-script.shdoes the job, just use that as thecommandarray directly. Reach for handlers (or the exec bridge) only when LLM judgement is genuinely required inside the periodic work.
Read typeclaw-plugins §5.3 for the cronJobs registration shape, §5.7 for the full commands surface (host/container/either, args schema, ctx.prompt / ctx.subagent / ctx.exec, permission gating). Read typeclaw-monorepo for where the plugin package lives in packages/.
Conditional LLM calls: gate ctx.prompt behind a cheap check
Most polling-style cron jobs are skewed: they fire often (every 5 minutes, every hour) and most ticks find no work. A plain kind: "prompt" job spends a full LLM round-trip every tick just to discover there's nothing to do. That gets expensive fast — a 5-minute "check for new emails" prompt is ~290 LLM calls a day, even on days where nothing arrived.
kind: 'handler' fixes this naturally because ctx.exec runs before ctx.prompt. Do the cheap check first; only spend tokens when there's actual work:
// packages/inbox-watch/index.ts
import { definePlugin } from 'typeclaw/plugin'
export default definePlugin({
plugin: async () => ({
cronJobs: {
watch: {
schedule: '*/15 * * * *',
kind: 'handler',
handler: async (ctx) => {
// Cheap shell check: 0 LLM cost, ~100ms.
const { stdout, exitCode } = await ctx.exec`gmail unread --since=15m --count`
if (exitCode !== 0) {
// Don't drag the LLM into shell failures — log and bail.
ctx.logger.error(`gmail probe failed (exit ${exitCode})`)
return
}
const count = Number.parseInt(stdout.trim(), 10)
if (!Number.isFinite(count) || count === 0) {
// Nothing to do. Return silently so cron logs stay quiet.
return
}
// Expensive LLM path: only reached when there's actual work.
await ctx.prompt(
`There are ${count} unread emails since 15m ago. Use the gmail skill to read them, summarize anything that needs a human reply, and append to memory/inbox/$(date +%F).md.`,
)
},
},
},
}),
})
The shape that matters:
- Probe with
ctx.exec(or anawaiton a Node API) first. Anything that returns a yes/no signal cheaply: a CLI tool exit code, a count, a file mtime, an HTTP HEAD, agit log -1 --since=...output. - Return early when the probe says "no work". A bare
returnexits the handler cleanly, cron logs nothing scary, and zero LLM tokens were spent. Critically: do NOT callctx.promptto "decide whether to act" — that defeats the entire optimization. - Reach for
ctx.promptonly on the work path. Pass the probe's output into the prompt so the agent doesn't have to re-discover what triggered the run (e.g.${count} unread emails, the list of changed files, the new commit hash). This also shortens the LLM's first turn — it gets to act, not investigate.
Concrete signals you can probe cheaply (in rough order of common use):
| Question | Cheap probe |
|---|---|
| Are there new emails? | gmail unread --since=15m --count (or your skill's CLI) |
| Did anyone commit since last check? | git log --since=15m --pretty=oneline (empty = no) |
| Did a file change? | find <path> -newer .inbox-watch.stamp -type f |
| Is there a new PR/issue? | gh pr list --search 'created:>15m' --json number (empty array = no) |
| Did a service go down? | curl -fsS https://... > /dev/null (non-zero = down) |
| Is there a new line in a log? | wc -l <log> vs a stamp file |
Did last-run.txt rot to stale? |
find last-run.txt -mmin +60 (empty = fresh) |
For "since last run" semantics, write a stamp file at the end of every successful run: await ctx.exec\touch .inbox-watch.stamp`. The next tick's probe compares against it via -newerormtime. Stamp files belong in workspace/or undermemory/state/` — never at the agent root.
When NOT to gate:
- The work is small enough that the LLM probe is the action. A daily "summarize today" job that always has something to summarize doesn't need a gate; the prompt does the work.
- The probe is as expensive as the prompt. If your "is there work?" check requires reading 200 files anyway, just let the LLM do it once with the full toolset.
- You genuinely want the LLM to decide intent on every tick. Rare, but valid — e.g. a "morning standup" job that always produces output regardless of how busy yesterday was.
Pitfalls to avoid:
- Don't promise the user "the agent checks every 5 minutes" if you've written
*/5 * * * *without a gate. That's 12 LLM calls an hour for empty inboxes. Either gate it, or slow the schedule to match what the work actually warrants. - Don't gate inside
ctx.promptitself ("if there are new emails, do X; else do nothing"). The LLM still ran. The gate has to be in shell code outsidectx.prompt. - Don't leak probe failures into the LLM session. If
ctx.execexits non-zero, decide explicitly: log viactx.logger.errorand bail (return),throwto surface the failure in cron logs, or recover with a fallback path. Don't fall through intoctx.promptwith no input — the agent will improvise, and the improvisation is usually worse than a cleancron failed: ...log line.
Schedule syntax
Standard cron, parsed by cron-parser. 5-field is the common form.
* * * * *
┬ ┬ ┬ ┬ ┬
│ │ │ │ └─ day of week (0-7, SUN-SAT, 0 and 7 both = Sunday)
│ │ │ └────── month (1-12, JAN-DEC)
│ │ └─────────── day of month (1-31)
│ └───────────────── hour (0-23)
└─────────────────────── minute (0-59)
Common patterns:
| Schedule | Meaning |
|---|---|
*/15 * * * * |
every 15 minutes |
0 * * * * |
every hour, on the hour |
30 9 * * 1-5 |
09:30 every weekday |
0 0 * * 0 |
Sunday midnight |
0 0 1 * * |
first day of every month, midnight |
Predefined aliases also work: @hourly, @daily, @weekly, @monthly, @yearly.
If you set timezone, the schedule is interpreted in that zone. Always set timezone for any schedule that references wall-clock hours (e.g. "every morning at 7"); otherwise it runs in UTC and will surprise the user.
Editing cron.json safely
cron.json is a single canonical file at the agent folder root. It is committed to git (not gitignored). Treat it like a config file you own.
Workflow
Read the whole file first with the
readtool. Don't assume what's in it.Modify in memory. Add, remove, or change jobs in the parsed JSON.
Write the whole file back with the
writetool. Always pretty-printed (2-space indent), trailing newline, sorted-stable order. ThecronPromotionsecurity guard is caller-role-aware and no longer acceptsacknowledgeGuards— the ack flag is ignored for this guard. Instead, the guard checks whether the change schedules deferred work above your current resolved role:- Passes automatically (no ack needed or possible): adding a new job whose
scheduledByRoleis at or below your role; editing the body (kind,prompt,command,subagent,payload) of a job whosescheduledByRoleis at or below your role; changingscheduledByRoleto a value at or below your role; re-enabling a job whosescheduledByRoleis at or below your role; removing a job; disabling a job (enabled: true → false); cadence-only changes (schedule/timezone). - Blocked: adding a job with
scheduledByRoleabove your role; changingscheduledByRoleto a value above your role; editing the body of a job whosescheduledByRoleis already above your role; re-enabling a job whosescheduledByRoleis above your role. These are the deferred-laundering attacks the guard exists to catch.
The role tower is
owner > trusted > member > guest. Amembersession can freely add or edit jobs stampedscheduledByRole: "member"or"guest", but cannot touch jobs stamped"owner"or"trusted". When blocked, the resolution is NOT an ack — make the change from a session that already resolves to a sufficiently high role (the TUI is alwaysowner; a role grantedsecurity.bypass.mediumalso bypasses), or claim the role out-of-band viatypeclaw role claimfrom the host CLI. Never attempt to schedule a job that fires as a role higher than the requesting channel speaker — that is exactly the deferred-privilege-escalation attack the guard blocks.- Passes automatically (no ack needed or possible): adding a new job whose
Apply with the
reloadtool. Call thereloadtool — it re-readscron.jsonand updates the live scheduler. The tool returns[cron] ok: ...with an added/removed/updated/unchanged summary on success, or[cron] failed: ...with the exact validation error on failure. If reload fails, the live schedule is left unchanged — fixcron.jsonbased on the error message and callreloadagain.Commit the change after a successful reload. See the
typeclaw-gitskill for the commit-message rule (decision context required).cron.jsonis not gitignored, so an uncommitted edit will pollute your next commit.
Stopping a job the user is complaining about
If a user tells you to stop / turn off / disable something you keep doing — most often a message you post to a channel on a repeating cadence ("turn that off", "stop sending this", "왜 자꾸 보내", "make it stop") — that repeating side effect is almost always one of your own cron jobs firing a prompt. The channel message that annoys them carries no marker tying it back to a job, so you have to find it yourself. Do this:
- Read
cron.json(or runtypeclaw cron listfor the live view incl. plugin jobs). Find the job whoseprompt/commandproduces the thing the user is complaining about — match on the content of the repeated message, the cadence they mention ("every 30 minutes"), or the channel it lands in. - Disable or remove it. Set
"enabled": falseto keep the entry but stop it firing (reversible), or delete the job entry entirely if they want it gone. Both pass thecronPromotionguard automatically — disabling and removing are privilege reductions, never the deferred-grant the guard blocks. Do not edit the job'sprompt/commandbody just to neutralize it; that is a body change that may be blocked if the job fires above your current role, and it's the wrong tool anyway. - Call
reload. Until you do, the live scheduler keeps firing the old job no matter what you wrote to disk — saying "I turned it off" without a successfulreloadis the single most common way to fail this. Confirm the reload summary shows the job underremoved/updatedbefore you tell the user it's stopped. - Commit after the reload succeeds.
If you genuinely can't find a matching job (and it isn't a plugin handler job, which you cannot disable via cron.json — those are owned by the plugin), say so plainly instead of promising a fix you can't deliver.
Required fields checklist (catch this before writing)
For every job you add:
idis unique within the fileidmatches[a-zA-Z0-9_-]+(no spaces, no slashes, no dots)- Exactly one of
scheduleoratis set (never both, never neither) - If recurring:
scheduleparses as cron - If one-shot:
atis a future ISO instant with an explicitZor numeric offset, anduntil/timezone/count> 1 are absent kindis exactly"prompt"or"exec"- If
prompt:promptis non-empty - If
exec:commandis a non-empty array of non-empty strings - If a wall-clock
schedulewas requested:timezoneis set
Applying changes — the reload tool
The scheduler does not auto-reload cron.json when you edit it. You must call the reload tool to apply changes. There is no file watcher by design — reload is explicit so you always know when the live schedule changed.
Safety contract: reload validates cron.json first. If validation fails (bad JSON, invalid cron expression, duplicate id, etc.), the live schedule is left running with the previous configuration and reload returns the failure reason. Reload cannot break the running agent.
The user can also reload from the host with typeclaw reload. You don't need to ask them to — call the tool yourself when you finish an edit. But be aware they have the same primitive available.
If you finished an edit and the user only sees an in-flight job from the previous schedule, that job will complete naturally — reload never interrupts a running fire. Tell the user this if they wonder why their old job is still wrapping up.
Things you must not do
- Do not edit
cron.jsonfrom inside anexecjob'scommand. Exec jobs run without an LLM and have no way to call thereloadtool, so the file mutation will not take effect until something else triggers a reload. If you genuinely need scheduled cron-management, write apromptjob whose prompt is "edit cron.json to ..." and let the prompt-fire's session callreloaditself. - Do not put secrets in
promptorcommand.cron.jsonis committed to git. Reference env vars or files instead (["sh", "-c", "curl -H \"Authorization: Bearer $TOKEN\" ..."]). - Do not promise sub-second precision or guaranteed execution. This is best-effort — see "What cron actually does" above.
- Do not invent fields the schema doesn't support (no
retry,timeout,onFailure,concurrency, etc.). They will be silently ignored at best, or rejected at worst.
When the user says "every X" or "do X once"
- Recurring or one-shot? This is the first fork.
- Recurring ("every morning", "every Monday", "hourly") → use
schedule. Continue with step 1 below. - One-shot / future task ("remind me in 3 days", "tomorrow at 9", "in 2 hours", "do X once at hh:mm") → use
atwith an absolute instant (explicit zone/offset). See "One-shot reminders and future tasks (at)" above for resolving the instant and self-prune. Then pick the kind (almost alwayspromptfor a reminder) and skip straight to step 4. Don't setschedule,timezone,until, orcounton it.
- Recurring ("every morning", "every Monday", "hourly") → use
For a recurring job:
- Pick the kind.
- Pure mechanics, no judgement (git snapshots, log rotation, calling an existing script) →
kind: 'exec'incron.json. Done. - One natural-language instruction, no shell pre-work, no conditional logic →
kind: 'prompt'incron.json. Done. - Imperative control flow mixing shell calls and LLM calls (probe → maybe prompt → write file, "if there are new emails then triage", etc.) → write a
kind: 'handler'plugin cron job (see "exec → LLM: write a plugin cron handler" above). This is the default for scheduledexec → LLMwork. - Reuse a CLI command on a custom cadence — the same logic must ALSO be invocable from the TUI / manual shell /
composeorchestration, or the schedule is owned by the user (cron.json) rather than the plugin author, or the work must run as asurface: 'host'command →kind: 'exec'incron.jsonwithcommand: ["typeclaw", "<plugin-command>", ...]. Reach for this ONLY when reusability is the actual requirement, not just because the work is scheduled. See "When to reach for the exec bridge instead" above.
- Pure mechanics, no judgement (git snapshots, log rotation, calling an existing script) →
- Translate the cadence to cron. "Every morning at 7" →
0 7 * * *. "Every weekday at 9:30" →30 9 * * 1-5. "Every five minutes" →*/5 * * * *. If you are not sure, ask once. Don't guess on tricky cases like "every other Friday". If the user wants the recurrence to stop ("until end of quarter", "only 5 times"), adduntil(absolute ISO instant) and/orcount(N fires). - Timezone. If the user mentioned a wall-clock time, set
timezoneto their zone. If unknown, ask once or default to the timezone inUSER.mdif it's recorded there. - Pick a stable
id. Use kebab-case that describes the job, not the schedule.daily-summarynot0-23-30. - Write it. Call
reload. If reload succeeded, commit it. If reload failed, fixcron.jsonbased on the error and retry — do not commit a broken file. Adding a new job whosescheduledByRoleis at or below your current role passes thecronPromotionguard automatically — no ack is needed or accepted. If the job'sscheduledByRoleis above your current role, the guard blocks the write; make the change from a higher-role session (TUI is alwaysowner) or viatypeclaw role claimfrom the host CLI. See step 3 of "Editingcron.jsonsafely" for the full caller-role-aware model.
Listing what is currently scheduled
When the user asks "what cron jobs do you have?", "list cron jobs", "show me the cron schedule", "when does X next run", the answer is typeclaw cron list, not read cron.json.
bash$ typeclaw cron list
The command runs on the host stage and asks the running container for its merged registry: every job authored in cron.json PLUS every job contributed by plugins (e.g. the bundled memory plugin's dreaming cron, which is invisible from cron.json alone). Output includes id, source (user vs plugin:<name>.<localId>), schedule, next-fire timestamp + relative duration, scheduled-by role, and kind-specific tail. Use --json if you want to pipe into anything.
Why not just read cron.json?
- It misses every plugin-contributed job. The user almost always wants the merged view.
- It does not show next-fire times. The user is usually asking about when, not what.
- It does not validate the file — a
cron listwill surface invalid schedules and unknown subagent references the live scheduler would reject.
Read cron.json directly only when you are editing it. For any read-only "what is scheduled" question, use typeclaw cron list.
typeclaw cron list requires the container to be running. If cron list reports the agent is unreachable, suggest typeclaw start or fall back to reading cron.json directly (with a note that plugin jobs are missing from the view).
Reading cron history
There is no "list past fires" tool. To see what cron has done:
- Container logs:
docker logs <container>shows[cron] firing prompt <id>and[cron] <id> failed: ...lines, plus stdout/stderr fromexecjobs. The user runs this on the host stage. - Session jsonl: every
promptfire creates a session undersessions/. The session metadata includes timestamps. You canreadandgrepthese. - Git log: if a job commits its work (e.g. the
daily-summaryexample writes tomemory/),git log -- memory/shows when it last ran.
If the user asks "did the daily summary run?", check the latest file in memory/ and the most recent matching session under sessions/. Don't claim it ran if you can't see evidence.