AI Agents·8 min read·

Cron Agents: Scheduled LLM Jobs on Linux VPS

Schedule LLM work with systemd timers, lock files, and spend caps so nightly jobs finish, log, and fail loud instead of overlapping on your VPS.

NB

Netbay Engineering

Netbay Engineering

On this page

An agent that only runs when someone types is a chatbot. An agent that runs at 02:15 is an operations job that happens to call a model. Treat it like a backup script: a timer, a lock, a timeout, a spend cap, and a log you can grep on Monday. Cron agents fail in boring ways — overlap, hung HTTPS, a provider outage, a prompt that started returning 8k-token apologies — and none of those are fixed by a smarter system message.

This post is the unit file and the job wrapper, not a new agent framework. You will run one Python entrypoint from a systemd timer on Ubuntu 24.04 in Lucknow, write JSONL, and refuse to start if yesterday is still running. Intel Xeon Platinum idle at night is cheap; a second overlapping job that double-posts invoices is not.

Timers, not crontab, for anything that talks to the network

crontab is fine for echo and not fine for jobs that can hang on a TLS handshake. systemd timers give you Persistent=true, RandomizedDelaySec, and a unit with TimeoutStartSec. They also put logs in journald instead of a mailed blob from cron.

ini
# /etc/systemd/system/agent-nightly.service
[Unit]
Description=nightly report agent
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=agent-cron
Group=agent-cron
EnvironmentFile=/etc/agents/cron.env
WorkingDirectory=/opt/agents
ExecStart=/opt/agents/.venv/bin/python /opt/agents/nightly.py
TimeoutStartSec=12min
Nice=10
ProtectHome=yes
PrivateTmp=yes
NoNewPrivileges=yes

# /etc/systemd/system/agent-nightly.timer
[Unit]
Description=run report agent at 02:15 IST
[Timer]
OnCalendar=*-*-* 02:15:00
Persistent=true
RandomizedDelaySec=90
[Install]
WantedBy=timers.target

Persistent=true catches the run if the VPS was down at 02:15. RandomizedDelaySec keeps a fleet from thundering a provider. TimeoutStartSec is the real control: when it fires, systemd SIGTERMs the process. Your Python must treat SIGTERM as a nack, not as a retry from inside the same invocation.

Lock, budget, then call

The first lines of nightly.py should not import an SDK. They should take a lock, check a spend file, and exit 0 if there is nothing to do. Exit 0 on "nothing to do" so the timer stays green. Exit 1 on a real failure so you get a failed unit in journalctl.

python
# /opt/agents/nightly.py
import fcntl, json, os, signal, sys, time
from pathlib import Path

LOCK = Path("/var/lib/agents/nightly.lock")
SPEND = Path("/var/lib/agents/nightly.spend")
LOG = Path("/var/log/agents/nightly.jsonl")
CAP_CENTS = int(os.environ.get("AGENT_CAP_CENTS", "400"))

def budget_ok():
    day = time.strftime("%Y-%m-%d")
    data = json.loads(SPEND.read_text()) if SPEND.exists() else {}
    return int(data.get(day, 0)) < CAP_CENTS

def add_spend(cents):
    day = time.strftime("%Y-%m-%d")
    data = json.loads(SPEND.read_text()) if SPEND.exists() else {}
    data[day] = int(data.get(day, 0)) + cents
    SPEND.write_text(json.dumps(data))

def log(event, **kv):
    rec = {"ts": int(time.time()), "event": event}
    rec.update(kv)
    with LOG.open("a", encoding="utf-8") as fh:
        fh.write(json.dumps(rec) + "
")

def on_term(*_a):
    log("signal", name="TERM")
    sys.exit(75)

signal.signal(signal.SIGTERM, on_term)
LOCK.touch(exist_ok=True)
fh = LOCK.open("r+")
try:
    fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
    log("overlap")
    sys.exit(0)
if not budget_ok():
    log("cap")
    sys.exit(0)
# ... fetch inputs, call model with a hard HTTP timeout, add_spend, write report

Exit 75 on SIGTERM is EX_TEMPFAIL. Your next hop can distinguish "killed by timer" from "model returned garbage." The HTTP timeout around the model call should be well under TimeoutStartSec — 120 seconds is a reasonable ceiling for a nightly summary.

What the job is allowed to touch

A cron agent should read from a directory and write to another. It should not SSH, it should not send mail directly, and it should not browse. Inputs: a folder of JSON exports, a SQLite replica, a status URL you already trust. Outputs: a markdown file under /var/lib/agents/out/ and one JSONL line. Delivery is a second unit that you already understand (rclone, a webhook, a human inbox).

Overlap is the classic outage. If a 02:15 run hangs until 02:40 and Persistent plus a slow provider stacks a second run, you will double-send. The non-blocking flock plus Type=oneshot is the fix, not a comment in the prompt that says "please do not run twice."

scheduled LLM job control plane timer OnCalendar flock no overlap spend cap cents per day model HTTP hard timeout TimeoutStartSec SIGTERM then nack JSONL log event + cents outbox only no SSH, no browser exit 0 if nothing to do or cap hit exit 1 on real failure, 75 on timer kill delivery is a second unit you already trust

Alerting that is not the agent

Do not let the agent page you with another model call. Point systemd OnFailure= to a unit that sends one line through your existing alert path. journalctl -u agent-nightly.service -S yesterday is the postmortem. Watch: failed units, cap events, p95 HTTP time, output byte size. A report that jumped from 2 KiB to 80 KiB is a prompt regression, not a better summary.

High-Speed SSD keeps the JSONL local. Rotate it with logrotate daily and keep 14 days. Lucknow DC01 plus L3/L4 filtering on the public NIC is enough for a job that only makes outbound HTTPS to the model provider and your status URL.

Takeaway: a cron agent is a oneshot with a lock, a cap, and a timeout. Put intelligence in the prompt if you must; put survival in the unit file. You can install the timer on Ubuntu 24.04 from Netbay in Lucknow DC01 in under 60 seconds — netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance