Contents
I started cronai.app with a simple wish: schedule an AI workflow as easily as a cron job. A time, a prompt, done.
Cron's contract is admirably small. At the minute you wrote down, it runs the command you wrote down. Any output goes by mail to the address in MAILTO, or to the crontab's owner if MAILTO is not set; cronie, a common Linux cron, can send it to syslog instead. That is about the whole reporting story. For a shell script it is mostly enough, because a shell script tends to fail the same way every time, and somebody reads the mail.
AI workflow steps fail differently. A scheduler that treats them like shell scripts leaves the operator to clean up.
Four ways an AI step fails
The provider says "slow down". HTTP 429 is the standard rate-limit answer, and RFC 6585 lets it carry a Retry-After header that says how long to wait. Not every 429 is worth retrying, though. Anthropic's API error reference says that a 429 caused by a usage tier's monthly spend cap has no Retry-After header and "keeps failing until access resumes". Retrying that on a timer burns attempts and teaches nothing.
The provider is busy for everyone. Anthropic's API also returns 529 overloaded_error, which its docs say "can occur when the API experiences high traffic across all users". The request was fine; the minute was crowded. Picture a team that runs a batch of classification calls at 09:00 sharp every weekday. Round numbers draw a crowd: GitHub's docs on scheduled workflows warn that they can be delayed under load, and that "high load times include the start of every hour".
The input isn't there yet. Picture a team that wants yesterday's support tickets summarised before a 09:45 stand-up. The export that feeds it usually lands by 07:00, and on a bad day at 08:40. A job pinned to 08:00 then fails, or worse, summarises a half-written file and reports success.
The output looks fine and isn't. A model call can return HTTP 200 with JSON that lacks a required field, or a tidy summary of the wrong day. A success status says the call completed, not that the result is usable. Checks a human defines catch some of this. Nothing catches all of it, which is why the report matters.
Cron treats all four the same way: it ran on time, and here is whatever came out.
The first two aren't unique to AI; any job that calls an API sees them. What differs is the price and the meaning of an attempt. An attempt that gets an answer is billed by the token, and a retry samples a new answer rather than replaying the same computation.
Plain schedulers aren't naive
In fairness, the plain tools have learned a lot. systemd timers have RandomizedDelaySec=, which spreads timer events out "to prevent them from firing all at the same time", and, for calendar timers, Persistent=true, which catches up on runs missed while the machine was off. Kubernetes recreates failed Job pods "with an exponential back-off delay (10s, 20s, 40s ...) capped at six minutes" and gives up after backoffLimit retries, 6 by default. A CronJob's startingDeadlineSeconds skips a run that would start too late to be useful.
I'd rather borrow these ideas than reinvent them. What the tools share is that they see little more than an exit code. A Job's pod failure policy can fail it fast on a given exit code, but the job has to do the classifying itself. On its own, the scheduler can't read a Retry-After, can't tell a spend cap from a busy minute, and assumes a retry costs nothing. Yesterday's failures don't inform today's start time.
That gap is where a scheduler for AI workflows earns its place. It is also the only honest reason to build one instead of telling people to write a timer unit.
Bounds from a human, decisions by the machine
CronAI's line is "You design the flow. The machine runs it." The first version of this post drifted away from that and ended on agents that "figure out the implementation details". I don't want that, and I doubt anyone who answers for the output does either.
The split I want: a human sets the goal, the time window, the budget and the models the job may use. Inside those bounds the machine would decide how: the exact start, whether to retry, when to stop. It would never widen the bounds. I draw the same line for what a model may write in an Obsidian vault, in Policy, not sandbox. And none of this means "fully unattended". A run ends in a report because a human is meant to read it.
What the scheduler should do
Run reports (alpha)
This is the part cronai.app does now, in alpha. It is also the foundation for the rest: the run reports are the history that timing and retry decisions would learn from.
Adaptive timing (direction)
Run inside a window, not at a fixed minute. Start when the inputs exist and the provider is answering. For the stand-up summary, the human says "between 06:00 and 09:30". The scheduler would check for yesterday's export before spending a token, and wait if the export isn't there.
Adaptive retry (direction)
Exponential backoff with jitter is the baseline. The AWS Architecture Blog's "Exponential Backoff And Jitter" (2015) shows why the randomness matters when many clients retry against the same busy service. The adaptive part is the task's own history. If a provider is consistently overloaded at 09:00 for this job, the next runs should start earlier inside the allowed window instead of queueing behind the same crowd.
A Retry-After from the provider sets a floor: the scheduler should never retry sooner than the header asks. A spend-cap 429 or an authentication error should stop the run and say why.
Fallback across providers (planned)
If the human allowed models from more than one provider, one provider's bad morning shouldn't sink the run. With OpenRouter's model fallbacks, a request already carries a list of models in priority order; OpenRouter moves to the next one when a model returns an error and bills the model that actually answered. The scheduler's part is the policy around that: only models the human allowed, and every fallback named in the report, not hidden.
Context between runs (direction)
Some jobs need memory: the last input they processed, last week's summary so this week's doesn't repeat it, which slots tended to fail. That memory should stay small, and the human who owns the job should be able to read it and clear it.
A sketch: retry and start time
To make "adaptive within bounds" concrete, here is a sketch in TypeScript. It is not production code and not taken from cronai.app. It shows the shape of the decision.
// A sketch of the direction, not cronai.app code.
type Outcome =
| "ok"
| "overloaded" // 529, or a 429 that comes with Retry-After
| "input_missing" // upstream data not there; no model call made
| "check_failed" // output failed a check the human defined
| "fatal"; // auth error, spend cap, bad request
interface Attempt { at: Date; outcome: Outcome; costUsd: number }
interface Bounds { // set by a human; the scheduler never plans past them
earliest: Date; // window opens
deadline: Date; // a result after this is worthless
maxAttempts: number;
budgetUsd: number; // across all attempts of one run
}
// What one more attempt is expected to cost and take, e.g. from past reports.
interface Estimate { costUsd: number; runMs: number }
type Decision = { at: Date } | { stop: string };
const BASE_MS = 30_000;
const CAP_MS = 10 * 60_000;
export function nextAttempt(
b: Bounds, attempts: Attempt[], now: Date, est: Estimate, retryAfterMs = 0,
): Decision {
const last = attempts.at(-1)?.outcome;
if (last === "ok") return { stop: "done" };
if (last === "fatal") return { stop: "not retryable" };
if (last === "check_failed") return { stop: "output needs a human" };
if (attempts.length >= b.maxAttempts) return { stop: "attempts used up" };
const spent = attempts.reduce((sum, a) => sum + a.costUsd, 0);
if (spent + est.costUsd > b.budgetUsd) return { stop: "would exceed the budget" };
// Full jitter, but never sooner than the provider asked.
const ceiling = Math.min(CAP_MS, BASE_MS * 2 ** attempts.length);
const delay = Math.max(Math.random() * ceiling, retryAfterMs);
const at = new Date(Math.max(now.getTime() + delay, b.earliest.getTime()));
const finish = at.getTime() + est.runMs; // when the result would land
if (finish > b.deadline.getTime()) return { stop: "would miss the deadline" };
return { at };
}
// Adaptive timing: start in the slot that was overloaded least often.
// Ties go to the later slot, so inputs get the most time to land.
// Times are UTC; the midnight wrap is ignored.
export function planStart(
b: Bounds, history: Attempt[], reserveMs = 30 * 60_000, slotMs = 15 * 60_000,
): Date {
const timeOfDay = (ms: number) => ms % 86_400_000;
const overloadRate = (t: number) => {
const near = history.filter(
(a) => Math.abs(timeOfDay(a.at.getTime()) - timeOfDay(t)) < slotMs,
);
if (near.length === 0) return 0;
return near.filter((a) => a.outcome === "overloaded").length / near.length;
};
let best = b.earliest.getTime();
for (let t = best; t <= b.deadline.getTime() - reserveMs; t += slotMs) {
if (overloadRate(t) <= overloadRate(best)) best = t;
}
return new Date(best);
}
With a window from 06:00 to a 09:30 deadline and no history, planStart picks 09:00, the latest slot that still leaves half an hour for retries. After three mornings of overload at 09:00, it picks 08:45.
Three choices in it are deliberate. First, a failed output check stops the run by default. A human could allow a re-ask for a format check, such as a missing JSON field, as one more bound: that check is objective, and a re-ask is a fresh sample. Re-asking until a content check passes, though, launders a bad answer rather than fixing it. Second, the budget and deadline checks look ahead: they count what the next attempt is expected to cost and when its result would land, not only what is already spent and when the attempt would start. Third, every exit path names a reason, so the report can say why a run ended, not only that it did.
Its gaps are just as instructive. planStart cannot notice that a slot got better: once it avoids 09:00, new attempts rarely land there, so the old overloads keep counting long after the provider has sorted out its 09:00 capacity. A real version needs history that decays, and a little deliberate exploration. The estimates are the other weak point: an attempt that costs more or runs longer than expected still overshoots. A real version would also cap each call, with a token limit and a timeout, so a bad estimate overshoots by a known amount at most.
Where it stands
- cronai.app (built by CronAI): "Scheduled AI workflows that report what ran, when, and how it ended." Alpha · waitlist.
- Adaptive timing, adaptive retry, context carried between runs: direction. None of it is shipped.
- Model gateways via OpenRouter: in active development.
- Fallback across providers: planned.
The teams in this post are hypothetical; they are there to make the failure modes concrete.
If you run AI jobs on cron or a timer today, tell me on X (@thetobiaskrug) what your worst failure looked like, and what you would have wanted the scheduler to do about it.