English · Русский
Turn plain Russian or English schedules into cron expressions, and cron back into plain text.
по будням в 9:30 → 30 9 * * 1-5
каждые 15 минут с 9 до 18 по будням → */15 9-17 * * 1-5
1 и 15 числа в 12:00 → 0 12 1,15 * *
every other day at noon → 0 12 */2 * *
mon, wed and fri at 6pm → 0 18 * * 1,3,5
0 23 * * 0,1,5,6 → с пятницы по понедельник в 23:00 / from friday through monday at 11pm
- Both directions:
toCronfor text → cron,describefor cron → text - Round-trip guarantee: every description parses back to an equivalent schedule, checked on thousands of random expressions
- Real Russian: word forms, «в 3 часа дня», «со вторника по четверг», «каждую 21 минуту»
- No LLM, no dependencies, fully deterministic, runs in Node, Deno, Bun and browsers
- Refuses to guess: anything cron cannot express exactly is an error with a code and the position of the problem
- Ready for AI agents: a built-in MCP server, because LLMs often get cron wrong
npm install cronsenseNo bundler? Load it in the browser straight from jsDelivr, which builds a minified ES module from the npm package:
<script type="module">
import { toCron } from 'https://cdn.jsdelivr.net/npm/cronsense@1/+esm';
console.log(toCron('по будням в 9:30'));
</script>@1 follows the latest 1.x release; pin an exact version such as @1.7.1 in production.
The same package is published to JSR for Deno and Bun:
npx jsr add @mrprolopstar/cronsenseIt is also available from GitHub Packages as @mrprolopstar/cronsense and straight from the repository with npm install github:MrProLopstar/cronsense.
import { describe, nextRuns, parse, safeParse, toCron } from 'cronsense';
toCron('каждый день в 9 утра');
// '0 9 * * *'
const schedule = parse('every 2 hours from 8:00 to 20:00');
schedule.cron;
// '0 8-20/2 * * *'
schedule.fields.hour;
// { kind: 'step', from: 8, to: 20, step: 2 }
describe('*/15 9-17 * * 1-5', { locale: 'ru' });
// 'каждые 15 минут с 9 до 18 по будням'
describe('0 12 1,15 * *');
// 'on the 1st and 15th at noon'
nextRuns(schedule, { count: 3, timezone: 'utc' });
// [Date, Date, Date]
const result = safeParse('по будням кроме пятницы');
if (!result.ok) {
result.error.code; // 'UNSUPPORTED'
result.error.span; // { start: 10, end: 15 }
result.error.excerpt; // input with a ^^^^^ marker under the problem
}npx cronsense "по будням в 9:30"
npx cronsense -n 5 "every 15 minutes between 9 and 17"
npx cronsense --cron -n 3 --utc "0 9 * * 1-5"
npx cronsense --json "1 числа каждого месяца"
npx cronsense --cron --explain --locale ru "0 9 * * 1,3,5"toRRule builds an iCalendar RRULE (RFC 5545) from the same phrases, and also accepts what cron cannot express:
toRRule('в последний день месяца в 18:00'); // 'FREQ=MONTHLY;BYMONTHDAY=-1;BYHOUR=18;BYMINUTE=0'
toRRule('в первый понедельник месяца в 9:30'); // 'FREQ=MONTHLY;BYDAY=1MO;BYHOUR=9;BYMINUTE=30'
toRRule('каждые 2 недели по понедельникам в 10'); // 'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;BYHOUR=10;BYMINUTE=0'
toRRule('каждые 90 минут'); // 'FREQ=MINUTELY;INTERVAL=90'
toRRule('по понедельникам 1 числа'); // 'FREQ=MONTHLY;BYMONTHDAY=1;BYDAY=MO;...', Monday AND the 1stSteps that divide the hour or the day (every 15 minutes, every 2 hours) become explicit BYMINUTE/BYHOUR lists, so they do not depend on DTSTART. Only true intervals (every 90 minutes, every 3 days, every 2 weeks) use INTERVAL and count from DTSTART. Tests compare the rules with rrule.js occurrence by occurrence. CLI: --rrule; MCP: to_rrule.
toSystemd returns values for OnCalendar=. systemd calendar events can do more than cron, so this output also covers the last day of the month, the Nth weekday and day-of-month AND weekday:
toSystemd('по будням в 9:30'); // ['Mon..Fri *-*-* 09:30:00']
toSystemd('в последний день месяца в 18:00'); // ['*-*~01 18:00:00']
toSystemd('в первый понедельник месяца в 9:30'); // ['Mon *-*-01..07 09:30:00']
toSystemd('ежеквартально'); // ['*-01,04,07,10-01 00:00:00']
toSystemd('на 23 февраля и 8 марта'); // ['*-02-23 00:00:00', '*-03-08 00:00:00'], two OnCalendar= linesTrue intervals such as every 90 minutes are refused with a hint to use a monotonic timer (OnUnitActiveSec=90min). Every output is checked against systemd-analyze calendar in the test suite. CLI: --systemd; MCP: to_systemd.
occurrences(text, { from, to }) lists the moments a schedule fires in any window, forward or backward, without a DTSTART. Only true intervals («каждые 2 недели», every 90 minutes) need an anchor to know which weeks count.
occurrences('по пятницам в 19:00', { from: new Date(2026, 9, 1), to: new Date(2026, 9, 31) });
// Fridays of October 2026 at 19:00
occurrences('каждые 2 недели по понедельникам в 19:00', { from, to, anchor: new Date(2026, 0, 5) });Options: timezone ('local' by default or 'utc'), limit (10 000 by default), plus all parse options. The results are checked against rrule.js on tens of thousands of random phrases.
Cron describes repeating schedules, but reminders often need a single moment. when(text, { now }) resolves it:
when('через 4 часа'); // now + 4 hours
when('завтра в двенадцать семнадцать'); // tomorrow at 12:17
when('через месяц ровно, в 12:17'); // same day next month at 12:17
when('по пятницам в 19:00'); // a recurring phrase resolves to its next runIt understands «сегодня», «завтра», «послезавтра», today, tomorrow, «через N минут/часов/дней/недель/месяцев/лет» (also «полчаса», «полтора часа», «2 часа 30 минут») and «in N days», with an optional time. Month arithmetic keeps the day of the month and clamps it, so «через месяц» from January 31 is February 28. Options: now (the current time by default), timezone, plus all parse options.
In toCron «через день» and «через месяц» still mean every other day or month, while «через 4 часа» and «завтра» point you to when().
occurrences understands the Russian production calendar when you pass a working-day predicate, for example from prodcalendar. cronsense itself stays dependency-free, so any country's calendar works.
import { isWorkday } from 'prodcalendar';
occurrences('в первый рабочий день месяца в 9:00', { from, to, isWorkday }); // 2026-01-12, 2026-02-02, ...
occurrences('в последний рабочий день месяца в 18:00', { from, to, isWorkday }); // 2026-12-30, not the 31st
occurrences('по рабочим дням в 9:30', { from, to, isWorkday }); // skips holidays, keeps working Saturdays«Будний день» always means Monday to Friday, so «в первый будний день месяца» also works in toRRule as BYSETPOS. «Рабочий день» depends on holidays, which neither cron nor RRULE can express: toRRule refuses it, and in cron «по рабочим дням» stays Monday to Friday.
Russian «Пасха» means Orthodox Easter by default, English "Easter" means Western; «православная», «католическая», "orthodox", "western" or the easter option choose explicitly.
occurrences('в Пасху', { from, to }); // 2026-04-12, 2027-05-02
occurrences('через 49 дней после Пасхи', { from, to }); // Trinity: 2026-05-31
occurrences('на 50-й день после Пасхи', { from, to }); // the same, counting Easter as day 1
occurrences('за 46 дней до католической Пасхи', { from, to }); // Ash Wednesday: 2026-02-18
toRRule('49 days after easter'); // 'FREQ=YEARLY;BYHOUR=0;BYMINUTE=0;BYEASTER=49'
easterDate(2026, 'orthodox'); // { month: 4, day: 12 }BYEASTER is a non-standard extension of rrule.js and python-dateutil that only knows Western Easter, so toRRule refuses Orthodox Easter and points to occurrences. Cron cannot express Easter at all.
Named holidays work in every format that can express them:
toCron('в День Победы в 10 утра'); // '0 10 9 5 *'
toCron('в Рождество'); // '0 0 7 1 *'
toCron('в католическое Рождество'); // '0 0 25 12 *'
occurrences('в Троицу', { from, to }); // 2026-05-31
occurrences('на Масленицу', { from, to }); // the whole week, 2026-02-16 to 2026-02-22
occurrences('на 23 февраля и 8 марта', { from, to }); // exactly two dates
toRRule('good friday'); // 'FREQ=YEARLY;...;BYEASTER=-2'Covered: Russian public holidays (Новый год, День защитника Отечества, 8 Марта, Праздник Весны и Труда, День Победы, День России, День народного единства, День знаний), Orthodox holidays (Рождество, Крещение, Масленица, Прощёное воскресенье, Чистый понедельник, Вербное воскресенье, Страстная пятница, Радоница, Вознесение, Троица, Духов день) and Western ones (Christmas, Epiphany, Shrove Tuesday, Ash Wednesday, Palm Sunday, Good Friday, Easter Monday, Ascension, Pentecost, Corpus Christi, Halloween). Russian names use the Orthodox calendar by default, English names the Western one; «католическое», «православное» and the easter option switch it.
Fixed dates that do not form a grid («23 февраля и 8 марта») are listed exactly in occurrences; cron and RRULE refuse them rather than fire on extra days.
cronsense only produces cron strings, so it works with any scheduler:
import cron from 'node-cron';
import { toCron } from 'cronsense';
cron.schedule(toCron('по будням в 9:30'), sendDailyReport);import { Queue } from 'bullmq';
import { toCron } from 'cronsense';
await new Queue('reports').add('weekly', {}, { repeat: { pattern: toCron('every monday at 8am') } });For user-facing apps such as Telegram bots, parse what the user typed and echo back the canonical wording to confirm:
const result = safeParse(message.text);
reply(result.ok ? `Буду напоминать ${describe(result.schedule, { locale: 'ru' })}` : result.error.excerpt);LLMs regularly produce subtly wrong cron. cronsense-mcp gives agents deterministic tools instead:
| Tool | What it does |
|---|---|
to_cron |
Schedule text → cron plus canonical description |
describe_cron |
Cron → natural Russian or English |
next_runs |
Upcoming run times as ISO 8601 |
Claude Code:
claude mcp add cronsense -- npx -y -p cronsense cronsense-mcpClaude Desktop, Cursor and other clients (mcpServers config):
{
"mcpServers": {
"cronsense": {
"command": "npx",
"args": ["-y", "-p", "cronsense", "cronsense-mcp"]
}
}
}The server has no dependencies and speaks MCP over stdio (protocol versions 2024-11-05 to 2025-06-18).
| Function | Description |
|---|---|
parse(text, options?) |
Returns Schedule, throws CronsenseError |
safeParse(text, options?) |
Returns { ok: true, schedule } or { ok: false, error } |
toCron(text, options?) |
Returns the cron string |
parseCron(expression) |
Parses a 5-field cron expression or macro (@daily, …) into a Schedule |
nextRuns(schedule | expression, options?) |
Next run times; options: count (default 5), from, timezone ('local' or 'utc') |
describe(schedule | expression, options?) |
Natural-language description; locale: 'en' (default) or 'ru' |
toSystemd(text, options?) |
systemd OnCalendar= values, see systemd timers |
toRRule(text, options?) |
iCalendar RRULE string, see RRULE |
occurrences(text, { from, to, anchor?, timezone?, limit? }) |
Moments in a window without DTSTART, including Orthodox Easter |
when(text, { now?, timezone? }) |
One moment: «через 4 часа», «завтра в 12:17», or the next run of a schedule |
easterDate(year, 'orthodox' | 'western') |
Easter Sunday of a year |
formatCron(fields) |
Formats structured fields back into a cron string |
ParseOptions.strictHours rejects hours 1–12 without «утра»/«вечера» or am/pm with AMBIGUOUS instead of reading them as morning: «без шести семь» fails, «без шести семь вечера», «в 19:00» and «в 09:30» pass. Use it where a wrong guess is costly, for example in reminder bots. CLI: --strict-hours; MCP: strictHours.
ParseOptions.weeklyOn sets the weekday used by "weekly" / «еженедельно» (default 0, Sunday, as in @weekly).
| Code | Meaning |
|---|---|
EMPTY_INPUT |
Nothing to parse |
UNKNOWN_WORD |
A word is not in the vocabulary |
UNEXPECTED_TOKEN / UNEXPECTED_END |
Words are in an order the grammar does not accept |
OUT_OF_RANGE |
Hour 25, 30 February, every 90 minutes, … |
AMBIGUOUS |
A bare number could be a time or a day: add «в»/"at" or «числа»/"th" |
CONFLICT |
Parts contradict each other or cron would treat them with OR semantics |
INCOMPLETE |
«с 9 до 18» without an interval, "every" without a unit |
UNSUPPORTED |
Last day of month, seconds, exclusions, every 2 weeks, decades and centuries |
INVALID_CRON |
parseCron received a malformed expression |
- Intervals: every N minutes / hours / days / months, «через день», "every other hour", «раз в 5 минут», "once a day"
- Frequencies: hourly, daily, weekly, monthly, yearly / «ежечасно», «ежедневно», …
- Longer periods and parity: «раз в полгода», «раз в полугодие», «ежеквартально», «каждый чётный час», «по нечётным числам», «каждый чётный месяц», «каждый третий час начиная с часа ночи»; «каждый чётный четверг» is the 2nd and 4th Thursday (RRULE)
- Counted frequencies checked against the list: «три раза в месяц, 8, 10 и 12 числа», «два раза в неделю по вторникам и пятницам»
- Hours and minutes as two numbers: «в час тридцать», «в семь сорок пять утра», «в одну минуту пополудни»
- Minutes of the hour: «каждый час в 15 минут», "every hour at 15 minutes past"
- Spoken Russian time: «полвторого», «в половину третьего», «в четверть девятого вечера», «без пяти шестнадцать», «в пять минут седьмого», «в час дня», number words («в три утра», «каждые двадцать пять минут», «каждые полчаса»)
- Times:
9:30,9.30,9am,7 p.m., noon / midnight, «в 3 часа дня», «в 11 ночи», «9 часов 45 минут», several times at once - Time ranges for intervals: «с 9 до 18», "between 9 and 17", «до 12», overnight windows like «с 22 до 6»
- Weekdays: names, abbreviations, ranges (
пн-пт, "monday through friday"), weekdays / weekends - Days of month: «1 и 15 числа», «с 1 по 10 число», "on the 1st and 15th", «15 января», "jan 15"
- Months: names, lists, ranges, including ranges across the new year («с ноября по февраль»)
describe picks the most natural wording: intervals, time ranges, weekday and month ranges, «15 января», noon and midnight. Every description it produces is accepted by parse and yields an equivalent schedule; this is checked on thousands of random expressions in both languages. The only exception is a cron that restricts both day-of-month and weekday: cron joins them with OR, so the text says «или» / "or" and is intentionally not parseable.
30 9 * * 1-5 по будням в 9:30 on weekdays at 9:30am
0 23 * * 0,1,5,6 с пятницы по понедельник в 23:00 from friday through monday at 11pm
0 8-20/2 * * * каждые 2 часа с 8 до 20 every 2 hours from 8am to 8pm
5-59/10 * * * * каждый час в 5, 15, 25, 35, 45 и 55 минут every hour at 5, 15, 25, 35, 45 and 55 minutes past
- Without «утра»/«вечера» or am/pm, hours 1–12 are read as said, so «без шести семь» is 6:54 just like «в 7» is 7:00; pass
strictHours: trueto reject them instead. - Spoken hours follow the words: «полвторого» is 1:30 and «полвторого дня» is 13:30; «первого» means the twelfth hour, so «полпервого» is 12:30 and «полпервого ночи» is 0:30.
- «с 9 до 18» with a minute interval ends before 18:00 (
9-17); with an hour interval, 18:00 is included (9-18). - Cron ORs day-of-month and day-of-week, so «по понедельникам 1 числа» is rejected instead of silently meaning "every Monday or the 1st".
- Several times must form a grid (the same minutes for every hour):
9:00, 9:30, 18:00, 18:30works, while9:00 and 18:30needs two schedules. nextRunssupports local time and UTC and skips times that fall into a DST gap.
cronsense follows semver. The public API (parse, safeParse, toCron, describe, parseCron, nextRuns, formatCron, exported types and error codes) only changes in a major release. New vocabulary and new locales can make previously rejected phrases parse; that is not a breaking change. See CHANGELOG.md.
To release, run Actions → Release → Run workflow and pick patch, minor, major or an exact version.
MIT