Back to 0cron
0cron

"Every Day at 9am": Natural Language Schedule Parsing

How we built a 152-line regex-based NLP parser that converts plain English like "every Monday at 2pm" into cron expressions -- and why we chose regex over an LLM.

Thales & Claude | March 25, 2026 16 min 0cron
0cronnlpregexrustcronuxparsing

Nobody likes writing cron expressions.

Seriously. 0 9 * is not something a human should have to type in 2026. It is a notation designed for computers in 1975, and every time a developer has to look up which field is the day-of-week and which is the month, we collectively lose another hour of human productivity. We have sent probes to Jupiter. We should be able to say "every day at 9am" and have the machine figure out the rest.

That conviction became the centrepiece feature of 0cron.dev. When a user creates a job, they can type plain English -- "every Monday at 2pm", "weekdays at 6am", "first day of the month at 9:30am" -- and the system converts it to a valid cron expression behind the scenes. No documentation required. No five-field guessing game.

The entire parser is 152 lines of Rust. It uses regex. Not an LLM. Not a dependency tree that pulls in half of crates.io. Just pattern matching against the phrases people actually use when they talk about schedules.

This article walks through every line of that parser, explains why we made the choices we did, and shows why sometimes the simplest approach is the correct one.

The UX Decision: Why Plain English Matters

Before we touch code, let us talk about the product decision.

0cron targets two audiences: developers who are comfortable with cron syntax but find it annoying, and semi-technical users (DevOps juniors, startup founders, freelancers) who know they need to run a script on a schedule but cannot remember whether */5 means every five minutes or every fifth of the month.

For the first group, we still accept raw cron expressions. If you want to type 30 2 /3 1-5, go ahead. The parser detects whether the input looks like a cron expression and passes it through unchanged.

For the second group -- and honestly, for the first group too, because everyone prefers clarity -- we wanted something radical: type what you mean in English, and it just works.

The question was: how do you build that parser?

Why Not an LLM?

The obvious 2026 answer: call an LLM. Send "every Tuesday at 3pm" to GPT-4o or DeepSeek, get back 0 15 2, done.

We rejected this immediately, for three reasons:

Latency. An LLM call adds 200-800ms to what should be an instantaneous UI interaction. When a user types a schedule and hits "Create Job", they expect the response in under 100ms. Adding a network round-trip to an inference API makes the product feel sluggish.

Cost. 0cron is $1.99/month with unlimited jobs. If every job creation triggers an LLM call, even a cheap one at $0.001 per request, a power user creating 500 jobs would cost us $0.50 -- a quarter of their subscription revenue -- just for schedule parsing. The economics do not work.

Determinism. Cron expressions must be exact. There is no room for "creative interpretation". If an LLM occasionally parses "every Tuesday" as 0 0 3 instead of 0 0 2 (because it confused the numbering), that user's job runs on Wednesday and they lose trust in the platform. A regex either matches or it does not. There is no hallucination risk.

The right tool for this job is not artificial intelligence. It is pattern matching. Specifically, regular expressions applied to a normalised input string.

The Main Function: 152 Lines of Clarity

Here is the complete entry point of the NLP parser. Every schedule pattern is handled by a single function that reads top-to-bottom, tries each pattern in order, and returns either a valid cron expression or a helpful error message:

pub fn parse_natural_language(input: &str) -> AppResult<String> {
    let input = input.trim().to_lowercase();

// "every minute" if input == "every minute" { return Ok(" *".to_string()); }

// "every N minutes" if let Some(caps) = re(r"^every (\d+) minutes?$").captures(&input) { let n: u32 = caps[1].parse().unwrap_or(1); return Ok(format!("/{n} *")); }

// "every day at H:MMam/pm" if let Some(caps) = re(r"^every day at (\d{1,2})(?::(\d{2}))?\s*(am|pm)$") .captures(&input) { let (hour, minute) = parse_time(&caps[1], caps.get(2).map(|m| m.as_str()), &caps[3])?; return Ok(format!("{minute} {hour} *")); }

// "every [weekday] at H:MMam/pm" if let Some(caps) = re( r"^every (monday|tuesday|wednesday|thursday|friday|saturday|sunday) at (\d{1,2})(?::(\d{2}))?\s*(am|pm)$" ).captures(&input) { let dow = weekday_to_cron(&caps[1]); let (hour, minute) = parse_time(&caps[2], caps.get(3).map(|m| m.as_str()), &caps[4])?; return Ok(format!("{minute} {hour} {dow}")); }

// "weekdays at H:MMam/pm" if let Some(caps) = re(r"^weekdays at (\d{1,2})(?::(\d{2}))?\s*(am|pm)$") .captures(&input) { let (hour, minute) = parse_time(&caps[1], caps.get(2).map(|m| m.as_str()), &caps[3])?; return Ok(format!("{minute} {hour} 1-5")); }

// "first day of month at H:MMam/pm" if let Some(caps) = re( r"^first day of (?:the )?month at (\d{1,2})(?::(\d{2}))?\s*(am|pm)$" ).captures(&input) { let (hour, minute) = parse_time(&caps[1], caps.get(2).map(|m| m.as_str()), &caps[3])?; return Ok(format!("{minute} {hour} 1 ")); }

// "twice a day at Ham/pm and Ham/pm" // ... and more patterns

Err(AppError::Validation(format!( "Unrecognized schedule pattern: '{input}'. Try patterns like \ 'every day at 9am', 'every Monday at 2pm', 'every 15 minutes', \ 'weekdays at 6am'." ))) } ```

A few things to notice about this design.

Top-to-bottom readability. Every pattern is a self-contained block: a comment explaining what it matches, a regex, extraction of captured groups, and a return statement. A new developer (or a future version of Claude) can scan this function in 30 seconds and understand every schedule type the system supports.

Early returns. Each pattern immediately returns if matched. There is no state machine, no accumulator, no ambiguity about which pattern won. The first match wins. This is important because patterns are ordered from most specific to most general -- so "every minute" (exact match) comes before "every N minutes" (parameterised match).

Normalisation upfront. The very first line does input.trim().to_lowercase(). This means we never have to worry about "Every Day At 9AM" vs "every day at 9am" vs " every day at 9am ". One normalisation step eliminates an entire category of edge cases.

Anatomy of a Pattern: The Weekday Match

Let us dissect one pattern in detail -- the "every [weekday] at H:MMam/pm" match, because it exercises every part of the system:

// "every [weekday] at H:MMam/pm"
if let Some(caps) = re(
    r"^every (monday|tuesday|wednesday|thursday|friday|saturday|sunday) at (\d{1,2})(?::(\d{2}))?\s*(am|pm)$"
).captures(&input) {
    let dow = weekday_to_cron(&caps[1]);
    let (hour, minute) = parse_time(&caps[2], caps.get(3).map(|m| m.as_str()), &caps[4])?;
    return Ok(format!("{minute} {hour} * * {dow}"));
}

The regex has five components:

1. ^every -- anchored to the start, begins with the word "every" 2. (monday|tuesday|...) -- captures the day name as group 1 3. at (\d{1,2}) -- captures the hour (1-2 digits) as group 2 4. (?::(\d{2}))? -- optionally captures minutes after a colon as group 3. The outer (?:...)? makes the entire minute portion optional, so both "9am" and "9:30am" work 5. \s*(am|pm)$ -- captures the meridiem as group 4, anchored to end

When a user types "every Tuesday at 2:30pm", the regex produces: - Group 1: "tuesday" - Group 2: "2" - Group 3: "30" - Group 4: "pm"

Then weekday_to_cron("tuesday") returns "2", parse_time("2", Some("30"), "pm") returns (14, 30), and the formatted result is "30 14 2" -- which means "at 14:30 on Tuesdays", exactly what the user asked for.

The subtle detail is caps.get(3).map(|m| m.as_str()). In Rust's regex crate, .get() returns an Option, which is None if the optional group did not participate in the match. This is how we handle "every Tuesday at 2pm" (no minutes specified) versus "every Tuesday at 2:30pm" (minutes specified). The parse_time function receives None for the minutes and defaults to zero.

The parse_time Helper: AM/PM Without Tears

Time parsing sounds trivial until you remember that 12am is midnight, 12pm is noon, and everyone gets confused by both. Here is the helper:

fn parse_time(hour_str: &str, min_str: Option<&str>, ampm: &str) -> AppResult<(u32, u32)> {
    let mut hour: u32 = hour_str
        .parse()
        .map_err(|_| AppError::Validation("Invalid hour".to_string()))?;
    let minute: u32 = min_str
        .unwrap_or("0")
        .parse()
        .map_err(|_| AppError::Validation("Invalid minute".to_string()))?;
    if ampm == "pm" && hour != 12 {
        hour += 12;
    } else if ampm == "am" && hour == 12 {
        hour = 0;
    }
    Ok((hour, minute))
}

The AM/PM conversion logic has exactly two special cases:

  • PM and hour is not 12: Add 12. So 2pm becomes 14, 11pm becomes 23. But 12pm stays 12 (noon).
  • AM and hour is 12: Set to 0. So 12am becomes 0 (midnight). All other AM hours stay as-is.

Every other combination falls through unchanged: 9am stays 9, 12pm stays 12, 1pm becomes 13. Four lines of logic cover all twelve-to-twenty-four-hour conversions correctly.

The min_str.unwrap_or("0") handles the case where minutes were not specified. "Every day at 9am" becomes hour 9, minute 0 -- which formats to the cron expression 0 9 *.

We considered adding validation (rejecting hour > 12, minute > 59), but the regex already constrains input to 1-2 digit hours and exactly 2-digit minutes. If someone manages to type "every day at 99:99pm", the regex will not match, and they will hit the fallback error message instead. The validation is structural, not conditional.

Weekday Mapping: Simple but Correct

fn weekday_to_cron(day: &str) -> &str {
    match day {
        "sunday" => "0", "monday" => "1", "tuesday" => "2",
        "wednesday" => "3", "thursday" => "4", "friday" => "5",
        "saturday" => "6", _ => "0",
    }
}

This function maps English day names to cron day-of-week numbers. The only subtlety is the numbering convention: in standard cron, Sunday is 0 and Saturday is 6. Some cron implementations use 7 for Sunday as well, but we stick with the POSIX standard because our scheduler engine uses the cron Rust crate, which follows POSIX.

The _ => "0" default is a safety net that should never trigger in practice, because the calling regex only matches the seven exact day names. But Rust's exhaustive match analysis requires handling all cases, and defaulting to Sunday is a reasonable fallback.

We briefly considered using an enum for weekdays -- enum Weekday { Monday, Tuesday, ... } -- but it would add fifteen lines of boilerplate (FromStr impl, variant definitions, display formatting) for a function that is called in exactly one place. Sometimes a match statement on strings is the right level of abstraction.

Error Messages That Teach

The final branch of the parser is arguably the most important for user experience:

Err(AppError::Validation(format!(
    "Unrecognized schedule pattern: '{input}'. Try patterns like \
     'every day at 9am', 'every Monday at 2pm', 'every 15 minutes', \
     'weekdays at 6am'."
)))

This is not a generic "Invalid input" error. It does three things:

1. Echoes back the input. The user sees exactly what string was rejected, which helps with debugging (especially if there was unexpected whitespace or a typo). 2. Provides concrete examples. Instead of pointing to documentation, the error message itself contains four working patterns. A user can copy one of these examples verbatim and modify it. 3. Covers the most common use cases. The four examples represent the four most likely intentions: daily at a specific time, weekly on a specific day, periodic intervals, and business-day schedules.

We learned this pattern from compiler error messages. The best compilers do not just say "syntax error on line 47" -- they say "expected ; after expression, did you mean to add one here?" Our NLP parser is a tiny compiler that translates English to cron, and it should have the same quality of error reporting.

What We Deliberately Do Not Support

The parser rejects certain patterns on purpose, and each rejection was a conscious product decision.

Sub-Minute Schedules

"Every 30 seconds" is explicitly not supported. Cron expressions have a minimum granularity of one minute, and for good reason. A job that runs every 30 seconds generates 2,880 executions per day. At that frequency, you do not have a scheduled job -- you have a service that should be running continuously. We would rather nudge users toward the correct architecture than enable patterns that will generate huge volumes of failed executions and fill up their logs.

Complex Multi-Day Patterns

"Every Monday and Wednesday at 9am" is not currently parsed. The cron expression for this (0 9 1,3) is straightforward, but the regex for parsing arbitrary day combinations gets complicated fast. "Monday and Wednesday", "Monday, Wednesday, and Friday", "Tuesday through Thursday" -- each requires a different pattern. We chose to launch with the most common single-day pattern and let power users enter raw cron for multi-day schedules.

Relative Schedules

"Every 2 hours starting at 8am" implies state -- the "starting at" requires knowing when the job was created or last run. Cron does not have this concept. 0 8-23/2 * is the closest approximation, but it is not semantically identical. Rather than producing something that almost-but-not-quite matches the user's intent, we reject the input and let them express it in cron syntax.

Timezone Qualifiers

"Every day at 9am EST" is not parsed because timezone handling is a separate concern. The user's timezone is set at the account level, and all schedules are interpreted relative to it. Allowing per-schedule timezone overrides would create confusion and conflicting configurations.

The Regex Helper: Compile Once, Match Many

One implementation detail worth noting is the re() helper function used throughout the parser:

fn re(pattern: &str) -> Regex {
    Regex::new(pattern).unwrap()
}

In the current implementation, this compiles a new Regex on every call. For a function that runs once per job creation (not in a hot loop), this is acceptable. The compilation takes microseconds, and the code is clearer than maintaining a static lazy_static! or once_cell::Lazy map of pre-compiled patterns.

If profiling ever showed this as a bottleneck -- which it would not, because job creation is a low-frequency operation -- we would move to LazyLock (stabilised in Rust 1.80) with pre-compiled patterns. But we follow the principle: do not optimise what does not need optimising.

Integration: Where the Parser Lives in the Stack

The NLP parser is invoked in two places:

1. Job creation API endpoint. When a user submits a new job with a schedule string, the API first tries parse_natural_language(). If it returns a valid cron expression, that expression is stored in the database. If it fails, the API tries to parse the input as a raw cron expression. If both fail, the user gets the teaching error message.

2. Job update API endpoint. Same logic. If a user edits a job's schedule from "every day at 9am" to "every Monday at 3pm", the parser handles the conversion transparently.

The parser's output -- a standard five-field cron expression -- is what gets stored in PostgreSQL and what the scheduler engine evaluates. The natural language input is never stored. This means the database always contains normalised, machine-readable schedules, and the NLP parser is purely a translation layer at the API boundary.

This separation of concerns is deliberate. The scheduler engine does not need to know whether a schedule originated from natural language or was typed as a raw cron expression. It just sees 0 9 * and fires the job at 9:00 UTC (adjusted for the user's timezone).

What We Learned

Building this parser taught us a few things about product development.

Scope control matters more than sophistication. We could have spent two weeks building a full natural language understanding system with fuzzy matching, spell correction, and ambiguity resolution. Instead, we spent two hours writing 152 lines of regex. The result covers 90% of what users actually type, and the error message handles the other 10% gracefully.

Regex is not a dirty word. There is a famous joke: "Some people, when confronted with a problem, think 'I know, I'll use regular expressions.' Now they have two problems." But that joke applies to using regex for tasks where it is the wrong tool -- parsing HTML, validating email addresses, matching nested structures. For matching a small, well-defined set of English phrases against known patterns, regex is the right tool. It is fast, deterministic, and testable.

Error messages are UI. The error message from the parser is often the first thing a new user sees after their first failed attempt. If that message says "Invalid schedule", the user is lost. If it says "Try 'every day at 9am'", the user succeeds on their second attempt. The error message is not an edge case -- it is part of the happy path for new users.

152 lines is not a limitation. The parser handles seven distinct schedule patterns, covers the vast majority of real-world cron use cases, and fits in a single file that any Rust developer can understand in five minutes. If we need to add "every hour" or "twice a day" support, each new pattern is five to eight lines of code. The architecture scales linearly with the number of patterns, and there is no complexity cliff.

---

This is Part 4 of a 10-part series on building 0cron.dev.

#ArticleFocus
1Why the World Needs a $2 Cron Job ServiceMarket analysis and pricing philosophy
24 Agents, 1 Product: Building 0cron in a Single SessionParallel build with 4 Claude agents
3Building a Cron Scheduler Engine in RustAxum, Redis sorted sets, job executor
4"Every Day at 9am": Natural Language Schedule ParsingThis article
5Multi-Channel Notifications: Email, Slack, Discord, Telegram, WebhooksNotification dispatch across 5 channels
6Stripe Integration for a $1.99/month SaaSBilling, trials, and webhook handling
7From Static HTML to SvelteKit Dashboard OvernightFrontend architecture and Svelte 5 runes
8Heartbeat Monitoring: When Your Job Should Ping YouMonitor model, pings, and grace periods
9Encrypted Secrets, API Keys, and SecurityAES-256-GCM, API key auth, HMAC signing
10From Abidjan to Production: Launching 0cron.devThe full story and what comes next
Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles