Automation and delivery
There is no daemon. Scheduling is delegated to the host scheduler; the tool provides generators and a --print mode so nothing is written without consent. The same report pipeline runs from cron, systemd, Task Scheduler or a CI job.
Related: Reports and diffs, Data sources and quotas, Crawl and audit, MCP and agent skills.
Install or preview a schedule
lv24-seo schedule install --kind weekly --cron "0 6 * * 1" --config ./seo.toml --print
lv24-seo schedule install --kind daily --cron "0 6 * * *" --config ./seo.toml
lv24-seo schedule print --cron "0 6 * * 1" --target systemd --jsonDefaults are weekly at 0 6 * * 1 and daily at 0 6 * * *. The generated command is report weekly --send --json; the daily kind adds --days 1 --lag-days 0. The absolute --config path and the config directory as working directory are embedded, so a custom filename, a path with spaces or several configs in one folder keep working.
schedule install has no --target flag: it detects the host backend. schedule print takes --target cron|systemd|windows and renders without touching the host. There is no schedule status command; inspect the host scheduler with crontab -l, systemctl --user list-timers or Task Scheduler.
Artifacts
| Target | Artifact | Location | Timezone behaviour |
|---|---|---|---|
cron | Managed crontab block with CRON_TZ=<zone> and a cd prefix | Current user's crontab | CRON_TZ is honoured by Vixie/ISC cron; other daemons use host time |
systemd | lv24-seo-<kind>.service and .timer user units | ~/.config/systemd/user (XDG-aware) | OnCalendar includes the configured IANA zone |
windows | UTF-16 Task Scheduler XML | %APPDATA%\lv24-seo\schedule | StartBoundary carries the resolved UTC offset |
The cron block is delimited by # >>> lv24-seo:<name> >>> and # <<< lv24-seo:<name> <<<. Re-installing replaces every managed block for that job. A damaged block with a missing end marker is treated as extending to the next managed marker or EOF, so schedule install repairs the file instead of appending a duplicate job.
Only simple cron shapes (M H * * * and M H * * DOW) convert to systemd or Windows. Anything else is rejected instead of silently rescheduled. An unknown or unavailable timezone is rejected before installation.
The install result always carries a warnings list with the backend limits: cron may ignore CRON_TZ, and Windows XML encodes a fixed offset rather than an IANA zone, so a seasonal DST change needs a review. Windows uses tzdata (included on Windows). Systemd enables the timer with systemctl --user daemon-reload and systemctl --user enable --now.
Recommended cadence
| Job | When | Why |
|---|---|---|
report weekly --send | Monday 06:00 | GSC data has 2–3 days of lag; Monday is stable |
crawl --start URL | Weekly, after the report | Keeps crawl evidence close to the reporting window |
auth status | Weekly | Confirms presence and source of every provider token |
| CrUX field data | Weekly | 28-day rolling window; daily polling adds nothing |
Keep one schedule per period. Delivery is at-least-once, so two overlapping schedules can send the same report twice even with the ledger.
Email and Telegram
Email uses SMTP on port 587 with STARTTLS and a verifying TLS context. The login never runs after a TLS failure. The message is multipart/alternative: the product HTML template plus the same content as 78-column plain text, with a localized subject of at most 78 characters.
The template is a 600px card with inline CSS and no external images, stylesheets, scripts, tracking pixels or remote fonts. Unknown KPI values render as the localized "No data" text, never 0. Metrika is shown as sessions, not clicks. An empty issues section says no issue data was provided instead of claiming the site is clean. Limitations and warnings are deduplicated; provider prose stays in its original language and is labelled when the report is localized. The footer carries the property id, the UTC generation time and "generated by lv24-seo".
Telegram sends the same 78-column localized text through the Bot API sendMessage method. HTTP 4xx/5xx responses, timeouts and malformed answers are failed, never recorded as sent.
Only email and Telegram channels exist. There are no Resend or Brevo adapters and no --channel github.
Configure delivery in seo.toml under [report.email] and [report.telegram]. SMTP passwords and the Telegram bot token come from auth set --provider smtp|telegram, never from the config file. --send is required; the generated schedule already includes it.
Idempotency
Report delivery is idempotent through the sends ledger, unique on (kind, property, period, channel, content_hash). The hash covers the headline and sections plus the normalized render locale; generated_at and diagnostics are excluded. English omits the locale from the hash so upgrading does not re-send an already delivered English period.
only_on_change skips a send when the content hash was already delivered (already_sent). The material gate skips it when no change reaches report.thresholds (no_material_change). The gate runs before the locale hash, so a pure locale switch can still be suppressed when the period has no material change.
Delivery is at-least-once per channel. An ambiguous transport result is reported failed and never recorded, so the next run may deliver a duplicate. SMTP and the Bot API offer no exactly-once primitive after a process crash. The database serializes delivery attempts and may block other writers up to the transport timeout.
Write gates
Provider writes are separate from report delivery. The CLI uses a dry run followed by an explicit apply:
- Run the write command with
--allow-writeandLV24_SEO_WRITE_SECRETset. Without the secret the gate refuses withWRITE_DISABLED. - Review the plan,
plan_hashandconfirm_token. The token is an HMAC over the action, target, plan hash and expiry, valid for 300 seconds. - Apply with the same flags plus
--confirm <token>. A changed plan, wrong secret, malformed token or expired token fails withCONFIRM_REQUIRED.
The gate supports a pending-to-sent idempotency claim. With an initialised workspace the CLI attaches the SQLite ledger, so a failed provider call releases the claim and a retry is allowed; a completed or in-flight claim is a duplicate and returns skipped. Without a data directory the apply still runs, but the envelope warns that replay protection is off for the token's five-minute lifetime. A dry run opens no ledger and creates no database. A crash can leave a pending claim with no TTL in 0.5.2, which is treated as in-flight until an operator releases it.
In 0.5.2, only IndexNow actually submits. GSC sitemap submit and Yandex recrawl refuse with WRITE_DISABLED instead of pretending. MCP write tools require allow_write=true; their handlers are not wired in this build either, so they return WRITE_DISABLED.
GitHub Actions
The product repository ships a weekly workflow recipe at examples/github-actions/seo-weekly.yml. Copy it into your site repository, not into the tool repository.
on:
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:
permissions:
contents: readThe recipe uses a standard ubuntu-latest runner, a single job, a 15-minute timeout, a concurrency group and short artifact retention. On a public repository with standard runners this costs $0. A private repository consumes included or billed Actions minutes and storage; larger runners, matrices and paid add-ons are out of scope for the zero-spend path.
The job restores data/seo.sqlite3 (plus -wal and -shm) with actions/cache, creates the data directory, and runs:
uvx --from lv24-seo lv24-seo report weekly --send --jsonProvider and delivery credentials come from repository secrets. Non-secret identifiers stay in the committed seo.toml. GitHub cron is always UTC, so convert the intended local time explicitly.
Timezone
report.timezone is an IANA zone (default Europe/Minsk). It drives report and stats windows, MCP default windows and local day boundaries. Provider data stays date-based and is not converted.
Scheduler backends apply the zone where they can and report a warning where they cannot. An invalid zone fails config validation with CONFIG_INVALID; it is never silently replaced. Review Windows tasks after a DST transition, and confirm that the installed cron daemon supports CRON_TZ before relying on it.
Source: _source/engine-scheduling.md, _source/engine-cli.md, _source/engine-config.md, _source/engine-releases-0.5.2.md.