Ask someone what a company AI is and they'll describe a chatbot. A box you type into. The box answers.
A chatbot is idle until you show up. A company AI is the opposite. It's the thing that worked all night and has something to show you. The difference between those two isn't the model. It's the scheduler. Strip everything else away and my system is a job table: 44 entries in the weekday schedule as I write this, a reduced set of about 22 on weekends, a few flagged Monday-only or Sunday-only. Email triage at 6:30. Contact sync at 7:30. Outreach research at 7:55. Post dispatch at 9:45 and 5:30. Engagement collection at 6:30pm. Memory synthesis at 11:30pm. The overnight pipeline at half past midnight. Whether or not anyone's awake.
This is the chapter on the part that sounds least like AI and matters most. The scheduler is maybe a thousand lines of Python, and it's the organ everything else hangs off. Here's how ours works, what it took three incidents to learn, and when you should just use cron instead.
One daemon, one table, one ledger
Ours is a single Python daemon under PM2. The schedule itself is a dictionary in the source file: time of day, job key, the command to run, a description, and a per-job timeout. Every 30 seconds it wakes, checks the clock against the table, and runs whatever's due. Every run gets a row in a SQLite ledger with scheduled time, start, end, duration, return code, status, and error.
That ledger is the design center, and I want to dwell on it because it's the thing cron doesn't give you. The ledger is how the system answers "did contact sync run today?" without anyone grepping logs. It's what the morning briefing reads. It's what recovery reasons from. A schedule without a ledger is a wish. The ledger is what makes it an operation.
Two details in the run path that earned their place.
Record the start before you execute. The daemon writes a "running" row to the ledger before the subprocess launches, then updates that same row on completion. Why: if the scheduler restarts mid-job, the naive version sees no completed run for that slot and fires the job again. Double-firing a job that sends email is not a theoretical concern. The "running" row makes the duplicate check see the first attempt immediately.
Per-job timeouts, sized from evidence. The Telegram digest gets 120 seconds. The deep CRM research job gets four hours. The outreach cycle used to get 30 minutes until we did the math. Up to 20 research passes and 10 drafts a day, each making local-model and web calls at about a minute apiece, and 30 minutes was exactly at the ceiling. It's 60 now, and the comment in the source shows the arithmetic. A timeout you can't justify with numbers is a timeout that will page you eventually, in one direction or the other.
One more cheap habit that pays daily: at boot, the daemon walks the entire job table and checks that every script exists and compiles. A syntax check, nothing imported or executed, plus a check for duplicate job keys. It costs a second at startup and converts "the 2am job has a typo" from a silent overnight failure into a loud boot error.
Missed jobs, and the bug that taught us recovery
A box that runs 24/7 still reboots. Windows updates itself. PM2 restarts things. The power blinks. The question isn't whether you'll miss scheduled slots. It's what happens to the missed ones.
Our first recovery design used a heartbeat file. The daemon writes a timestamp every cycle, and on startup, if the heartbeat is old, re-run what was missed in the gap. Reasonable. Wrong. We hit a failure mode where the scheduler was stuck, not executing jobs, but its main loop was alive and happily writing fresh heartbeats. The heartbeat said all good. The ledger said nothing ran for hours. Recovery looked at the heartbeat and concluded there was nothing to recover.
The fix was a rule I'd now apply anywhere: recover from the ledger, not the pulse. Current logic ignores the heartbeat entirely for this purpose. Any slot more than ten minutes overdue with no row in today's ledger is missed, and gets run. Because it's keyed on the ledger, it's idempotent. You can run recovery repeatedly and it won't double-fire anything.
Then recovery taught us a second lesson. This June, a recovery pass after downtime took two hours, since recovered jobs run one at a time and some are long. While it ground through the backlog, three new slots came due, and the single-pass design silently skipped them. We lost three jobs to the act of recovering others. So recovery is now multi-pass. After a pass completes, re-scan, because the world moved while you were catching up. If your recovery logic has never eaten a job, it just hasn't been measured yet.
The alarm is the silence
Failure alerting has a trap in it: the alert path depends on the thing that's failing. If the scheduler is down, who tells you the scheduler is down?
Our answer is a daily digest with the polarity flipped. Every morning at 9:05, a job reads the ledger and sends a Telegram message. What ran, what failed, what was missed. The content is useful. The existence of the message is the real signal. The description in the job table literally says "absence of digest = scheduler dark." If 9:05 passes and my phone is quiet, that silence is the alarm, and it's an alarm no failure can suppress, because it doesn't require anything to be working.
The same philosophy runs the morning brief at 6:00. The machine reads its own ledgers and databases and reports on what happened overnight, before I've had coffee. The jobs feeding it are sequenced for exactly that; the growth-analysis pass runs at 5:30 specifically so its findings are fresh for the briefing. Fifteen minutes later, another job exports key database state to flat JSON files so anything else, including the AI brain when a session wakes up, can read system state instantly without touching live SQLite.
The pattern underneath all of it: the system reports to you. You don't go spelunking. If checking on your AI requires opening a terminal, you'll stop checking around week three.
Resume cursors, or why every long job must checkpoint
Here's the constraint that shapes long jobs. The contact sync walks a CRM of 156,925 contacts, pulling from Google's API at a thousand contacts a page, and its slot has a ten-minute timeout. A full pass doesn't fit. The naive loop runs ten minutes, dies, and starts from page one tomorrow, making zero net progress forever while looking busy in the logs. We shipped exactly that bug.
The fix is a resume cursor. The job caps itself at 15 pages per run, saves the API's page token to a small JSON file, and the next run continues where it left off. Same idea inside the time budget: the job watches the clock against a 520-second budget, under the 600-second timeout so it stops gracefully rather than being killed, and persists the remainder. There's even handling for the unglamorous edge case where a saved page token expires between runs. Detect the rejection, clear the cursor, restart cleanly from page one.
The general rule: any job that can be interrupted will be, so every long job must be able to die at any moment and lose nothing but the current bite. Checkpoint to disk, not memory. Make the work idempotent so re-processing a page is harmless. This is the least exciting paragraph in this chapter and it's worth more than most of the others.
When you should not build this
Fair turns for the alternatives, because a custom daemon is not the default answer.
Cron (or Windows Task Scheduler) is the right call for fewer than ten independent jobs where a missed run doesn't matter. It is rock-solid at firing on time. What it doesn't do: recovery, a ledger, timeouts, or any answer to "what happened last night?" beyond grep. We started closer to cron, a pile of PM2 restart-on-schedule entries, and consolidated into one daemon precisely when "did it run?" became a question I was asking daily.
Airflow and Prefect are real orchestrators and they're better than my daemon at almost everything. DAGs, retries, backfills, a UI. They're built for data teams, and they cost like it. Not money, operational surface. Airflow is itself several services that need tending. I think they're right the day a second engineer joins, and a tax before that. My entire scheduler is one file I can read in fifteen minutes, which is a feature with no checkbox on a comparison chart.
n8n and the workflow tools are good glue for event-driven flows. Webhook in, actions out. Scheduling is something they do, not what they are. The weakness shows at the ledger: when the question is "which of my 44 jobs ran, failed, or got skipped this week, and why," you want a database you own, not a run history inside someone's UI.
The blunt summary: the scheduler itself is a solved problem and you should steal rather than innovate. What's not solved generically is the stuff bolted to it. Recovery semantics that match your jobs. The silence-is-the-alarm digest. Cursors in everything long. That's where the thousand lines went.
The test
Before you call your system autonomous, answer one question: if your machine was off from 2am to 6am last night, what happens, and how do you find out?
If the answer is "the missed work runs, once, in order, and a message on my phone tells me it happened," you have a heartbeat. If the answer involves you noticing something's stale two days later, you have a chatbot with appointments.