How to Build a Cron-Scheduled Local AI Agent With Python and Ollama
Learn how to build local AI agents with Python and Ollama that run automatically on a schedule using cron, tested live on a 2-CPU host with real captured output and timings.
Most introductions to AI agents show you the reactive case: you type a question, the model answers, the conversation ends. That covers a chat assistant well, but it misses a different and very useful pattern, a proactive agent that fires on its own, on a fixed schedule, without anyone sitting at a keyboard to trigger it. A morning system health check, a daily digest of pending security updates, a weather brief before you leave the house: none of these need a human to ask the question first, they just need something to run at the same time every day and hand you the answer.
Table Of Content
- What you need before you start
- Step 1: Install Ollama and pull a model
- Step 2: Set up your Python project
- Step 3: Define what an agent is in this project
- Step 4: Build the scheduler
- Step 5: Write your first agent, a system health brief
- Step 6: Add a second agent, pending package updates
- Step 7: Add a third agent, a weather brief from a free API
- Step 8: Run the scheduler once by hand
- Step 9: Schedule it with cron
- Common mistakes and how to catch them
- Verifying everything works end to end
- Next steps
In this tutorial you will build exactly that: a small Python scheduler that discovers a folder of independent AI agents, runs each one, and saves the result as a timestamped Markdown brief, all powered by a language model running entirely on your own machine through Ollama. Because the model runs locally, there is no API key to buy, no per-call bill, and no dependency on a third party’s uptime. Nearly every command, package version, and piece of output in this tutorial, including every agent, the scheduler, and all the timings below, was actually run while writing it, on a fresh Ubuntu 26.04 LTS host with only 2 CPU cores and no GPU. The one exception is the initial Ollama install, already present on that host; its expected output below is taken directly from Ollama’s own published installer script rather than a fresh run.
A few terms are worth defining before the first command, since they get used constantly from here on:
- Agent, in this tutorial, means a small Python module with two things: a name, and a
run()function that gathers some facts and asks a local model to turn them into a short, readable brief. - Scheduler is the small program that finds every agent in a folder, runs each one in turn, and saves what it produces. It has no idea what any individual agent does.
- Cron is the standard Linux service that fires a command at a fixed time, on a fixed schedule, without anything needing to stay open or logged in.
- Brief is this tutorial’s word for the short Markdown file each agent produces, a timestamp, the raw facts it worked from, and the model’s plain-English summary of them.
What you need before you start
- A Linux machine. This tutorial was written and tested on Ubuntu 26.04 LTS; other recent Ubuntu or Debian releases work the same way, and macOS works too if you swap in the macOS Ollama installer and use
launchdinstead of cron in the last step. - Python 3.10 or newer. Check yours with
python3 --version. - About 4 GB of free disk space for Ollama and the model used here, and ideally 8 GB of RAM. CPU-only inference works fine without a GPU, just slower per answer, and this tutorial’s real measured timings below will help you judge what to expect on your own hardware.
- A regular user account with sudo available. Sudo is only needed once, to install Ollama; the scheduler itself never needs root.
- Comfort with basic command-line use and basic Python (functions, imports, f-strings). No prior AI agent framework experience is assumed, and nothing in this tutorial needs an API key, a cloud account, or a credit card.
Step 1: Install Ollama and pull a model
Ollama is a small background service that downloads open-weight language models and serves them through a local API on port 11434, so every agent in this tutorial talks to localhost instead of the internet.
# Context: any Linux machine (Ubuntu 26.04 LTS used in this tutorial), regular user account with sudo available.
# Purpose: install the Ollama runtime and register it as a background service.
curl -fsSL https://ollama.com/install.sh | sh
Expected output: a short download progress display followed by lines confirming the service was installed and started, ending with a line telling you to run ollama from the command line. On a machine with no NVIDIA or AMD GPU you will also see a note that Ollama will run in CPU-only mode, which is expected on most laptops, desktops, and virtual machines, and is exactly the setup this tutorial was tested on.
Next, pull the model this tutorial uses:
# Context: Ollama installed and running from the previous command.
# Purpose: download the 4-billion-parameter tag of Qwen 3.5 for local inference.
ollama pull qwen3.5:4b
Expected output: a series of progress bars, one per model layer, ending in verifying sha256 digest, writing manifest, and success. Qwen 3.5 ships in tags from 0.8B parameters up to 122B; the 4B tag used here is about 3.4 GB and gives noticeably more reliable answers than the smallest tags, which matters for the summarizing work this tutorial asks it to do. Confirm the download with ollama list, which should print qwen3.5:4b alongside a 3.4 GB size and a “modified” timestamp.
If your machine is genuinely tight on RAM or CPU, qwen3.5:0.8b (about 1 GB) responds faster, but treat its output more skeptically: while testing this tutorial, the 0.8B tag once invented a “linux-image” package in a summary when no such package was anywhere in the real input data, while the 4B tag summarized the same facts correctly. A smaller model that answers quickly is not useful if you cannot trust what it says, so this tutorial uses the 4B tag throughout and you should sanity-check any smaller tag’s output against the raw facts before trusting it.
Step 2: Set up your Python project
Keep this project’s dependencies isolated from anything else on your system with a virtual environment:
# Context: a terminal, in whatever folder you keep coding projects.
# Purpose: create a project folder and an isolated Python virtual environment inside it.
mkdir scheduled-agents
cd scheduled-agents
python3 -m venv venv
source venv/bin/activate
Expected output: your shell prompt gains a (venv) prefix. Every pip install and every python3 command below assumes that prefix is showing.
# Context: venv activated, inside the scheduled-agents directory.
# Purpose: install the Ollama Python client and the requests library for HTTP calls.
pip install ollama requests
Expected output: pip resolves and installs both packages, ending with a line starting Successfully installed. At the time of writing this installs ollama 0.6.2 and requests 2.34.2. Confirm both imported correctly with python3 -c "import ollama, requests; print('ok')", which should print ok with no errors.
Step 3: Define what an agent is in this project
Before writing any agent, fix the contract every agent will follow: a Python file inside an agents/ folder, exposing a NAME string and a run() function that takes no arguments and returns a string. Whatever string it returns becomes the body of that agent’s brief. This is the entire contract, on purpose, since a scheduler that only needs two attributes from a module can discover and run agents it knows nothing else about.
# Context: inside the scheduled-agents directory from Step 2.
# Purpose: create the folder structure the scheduler and agents will live in.
mkdir -p agents outputs
touch agents/__init__.py
Expected output: no output on success; ls afterward should show an agents folder containing an empty __init__.py, and an empty outputs folder where briefs will be written.
Step 4: Build the scheduler
The scheduler’s job is deliberately narrow: find every agent module in the agents/ folder, run each one, and save what it returns. It does not know or care what any individual agent does, which is what lets you add a fourth or fifth agent later without touching this file at all. Save this as scheduler.py in the scheduled-agents folder:
import importlib
from datetime import datetime
from pathlib import Path
AGENTS_DIR = Path(__file__).parent / "agents"
OUTPUTS_DIR = Path(__file__).parent / "outputs"
def load_agents():
agents = []
for path in sorted(AGENTS_DIR.glob("*.py")):
if path.name.startswith("_"):
continue
module = importlib.import_module(f"agents.{path.stem}")
if hasattr(module, "NAME") and hasattr(module, "run"):
agents.append(module)
else:
print(f"[skip] {path.name} (missing NAME or run)")
return agents
def main():
OUTPUTS_DIR.mkdir(exist_ok=True)
for agent in load_agents():
print(f"[run] {agent.NAME}")
try:
output = agent.run()
except Exception as exc:
print(f"[error] {agent.NAME}: {exc}")
continue
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
out_path = OUTPUTS_DIR / f"{agent.NAME}-{timestamp}.md"
out_path.write_text(output)
print(f"[done] wrote {out_path}")
if __name__ == "__main__":
main()
load_agents() imports every .py file in agents/ as a module and keeps only the ones that actually expose both NAME and run, skipping anything else with a warning instead of crashing. Inside main(), the try/except around agent.run() is the detail most tutorials skip and the one that matters most in production: without it, one agent hitting a network timeout or an unexpected API response would stop every agent after it from running too. With it, a single failure is logged and the scheduler moves on, so a broken weather API never prevents your system health brief from being written.
Step 5: Write your first agent, a system health brief
Create agents/system_health.py. This agent needs no external service at all, since disk space and CPU load are already available from Python’s standard library:
import os
import shutil
from ollama import chat
NAME = "system-health"
def run():
disk = shutil.disk_usage("/")
free_pct = disk.free / disk.total * 100
load1, load5, load15 = os.getloadavg()
cpu_count = os.cpu_count() or 1
facts = (
f"Disk free: {disk.free // (1024 ** 3)} GB of {disk.total // (1024 ** 3)} GB "
f"({free_pct:.1f}% free).\n"
f"Load average (1/5/15 min): {load1:.2f} / {load5:.2f} / {load15:.2f} "
f"across {cpu_count} CPU core(s)."
)
prompt = (
"You are a terse Linux system health reporter. Given these raw facts, write a "
"3 to 4 sentence plain-English summary for a sysadmin. Call out anything that "
"needs attention (disk free below 15 percent, or 1-minute load average above "
"the CPU core count). If everything looks fine, say so in one short sentence.\n\n"
f"Facts:\n{facts}"
)
response = chat(model="qwen3.5:4b", messages=[{"role": "user", "content": prompt}], think=False)
return f"# System Health Brief\n\n{facts}\n\n## Summary\n\n{response.message.content}\n"
shutil.disk_usage("/") and os.getloadavg() are both part of Python’s standard library, so this agent needs no extra dependency beyond what Step 2 already installed. The think=False argument matters more than it looks: Qwen 3.5 is a “thinking” model, meaning by default it generates an internal chain-of-reasoning before its actual answer, which is wasted time for a job this simple. While testing this tutorial on the same 2-CPU host, a single one of these agent calls, using this same 4B tag, was still running after fourteen minutes with thinking left on and otherwise identical code; with think=False set, all three agents together, including two real network calls and three model calls, finished in about a minute. For a short factual summary like this one, thinking mode buys you nothing but a much slower brief.
Step 6: Add a second agent, pending package updates
Create agents/package_updates.py. This one shells out to apt, which needs no sudo for a read-only listing:
import subprocess
from ollama import chat
NAME = "package-updates"
def run():
result = subprocess.run(
["apt", "list", "--upgradable"],
capture_output=True,
text=True,
timeout=30,
)
lines = [line for line in result.stdout.splitlines() if line and not line.startswith("Listing")]
if not lines:
facts = "No pending package updates."
else:
facts = f"{len(lines)} pending update(s):\n" + "\n".join(lines[:40])
prompt = (
"You are a terse Ubuntu system administrator assistant. Given this list of "
"pending apt package updates, write a short plain-English briefing: state how "
"many updates are pending, and call out by name any packages that look security "
"or kernel related (names containing security, linux-image, openssl, or libssl). "
f"Keep it under 5 sentences.\n\nFacts:\n{facts}"
)
response = chat(model="qwen3.5:4b", messages=[{"role": "user", "content": prompt}], think=False)
return f"# Package Update Brief\n\n{facts}\n\n## Summary\n\n{response.message.content}\n"
apt list --upgradable only reads the package index that apt update already cached, so it is safe to run unprivileged and safe to run on a schedule; it is apt upgrade or apt update that need root and that you should not automate blindly. Note that apt itself prints a stability warning to stderr on every call (“apt does not have a stable CLI interface”); this code only reads result.stdout, so that warning never reaches the prompt and is safe to ignore. The timeout=30 argument protects the whole scheduler run from ever hanging indefinitely if apt itself gets stuck, working together with the try/except in scheduler.py from Step 4.
Step 7: Add a third agent, a weather brief from a free API
Create agents/weather_brief.py. Open-Meteo is a weather API that needs no signup and no API key for non-commercial use up to 10,000 calls a day, which makes it a good fit for a tutorial nobody should need an account to follow. Its data is published under a CC BY 4.0 license, so anything you build beyond this tutorial that shows Open-Meteo data to other people should credit Open-Meteo somewhere in the interface.
import requests
from ollama import chat
NAME = "weather-brief"
# New York City. Replace with your own coordinates.
LATITUDE = 40.71
LONGITUDE = -74.01
def run():
url = (
"https://api.open-meteo.com/v1/forecast"
f"?latitude={LATITUDE}&longitude={LONGITUDE}"
"&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max"
"&forecast_days=1&timezone=auto&temperature_unit=fahrenheit"
)
resp = requests.get(url, timeout=15)
resp.raise_for_status()
daily = resp.json()["daily"]
facts = (
f"Forecast high: {daily['temperature_2m_max'][0]} F, "
f"low: {daily['temperature_2m_min'][0]} F, "
f"chance of precipitation: {daily['precipitation_probability_max'][0]} percent."
)
prompt = (
"You are a friendly morning briefing assistant. Turn these weather facts into "
"a 2 sentence plain-English brief a person could read in 5 seconds, including "
"one practical suggestion (for example, bring an umbrella, or dress light).\n\n"
f"Facts:\n{facts}"
)
response = chat(model="qwen3.5:4b", messages=[{"role": "user", "content": prompt}], think=False)
return f"# Weather Brief\n\n{facts}\n\n## Summary\n\n{response.message.content}\n"
Swap LATITUDE and LONGITUDE for your own location; Open-Meteo takes plain decimal degrees, and timezone=auto tells it to resolve the forecast day using whatever timezone matches those coordinates rather than UTC. The resp.raise_for_status() call matters for the same reason the try/except in the scheduler does: if Open-Meteo is briefly unreachable, this line turns that into a clear exception that scheduler.py already knows how to catch and log, rather than a confusing KeyError further down when resp.json() returns an error body instead of the forecast you expected.
Step 8: Run the scheduler once by hand
Before scheduling anything, run it directly and confirm all three agents work:
# Context: venv activated, inside scheduled-agents, all three agent files saved.
# Purpose: run every agent once and inspect what gets written.
python3 scheduler.py
Expected output (captured from an actual run while writing this tutorial, on a 2-CPU cloud host with no GPU):
[run] package-updates
[done] wrote outputs/package-updates-2026-07-14_03-15-13.md
[run] system-health
[done] wrote outputs/system-health-2026-07-14_03-15-24.md
[run] weather-brief
[done] wrote outputs/weather-brief-2026-07-14_03-15-31.md
real 1m8.789s
All three agents, including two real network calls and three local model calls, finished in about a minute on modest, GPU-less hardware. Open one of the files it wrote, for example outputs/system-health-*.md; here is the real content from that same run:
# System Health Brief
Disk free: 78 GB of 95 GB (82.0% free).
Load average (1/5/15 min): 1.56 / 1.77 / 1.46 across 2 CPU core(s).
## Summary
System health is nominal with adequate disk space at 78 GB (82%) and a load
average of 1.56 well within the two-core capacity. No immediate attention
required regarding storage or process pressure. Monitoring should continue
for any anomalies exceeding current thresholds.
If you see three files in outputs/ and each one has both a “Facts” section and a “Summary” section with real, coherent prose, your scheduler and all three agents are working correctly and you are ready to schedule it.
Step 9: Schedule it with cron
Cron reads a per-user list of scheduled commands called a crontab, and fires each one at the time you specify, whether or not you are logged in. Open your crontab for editing:
# Context: any Linux user account that should own this scheduled job.
# Purpose: open your personal crontab in your default editor.
crontab -e
Add a single line at the bottom. This example runs the scheduler every morning at 7 AM:
# Context: scheduled-agents project created in Step 2, with its own venv.
# Purpose: run scheduler.py every day at 7:00 AM using the venv's own Python interpreter.
0 7 * * * /home/youruser/scheduled-agents/venv/bin/python3 /home/youruser/scheduled-agents/scheduler.py >> /home/youruser/scheduled-agents/scheduler.log 2>&1
Replace /home/youruser/scheduled-agents with your project’s actual absolute path everywhere it appears, found by running pwd inside that folder. The five fields before the command are minute, hour, day of month, month, and day of week, each of which accepts * to mean “every”; 0 7 * * * reads as “at minute 0 of hour 7, every day of every month, on every weekday.” The >> ... 2>&1 at the end appends both normal output and errors to a log file, which matters because cron jobs run silently by default and a mistake with nothing capturing it is a mistake you will never see.
Expected output: saving and closing the editor prints a line like crontab: installing new crontab. Confirm it saved with:
crontab -l
This should print the exact line you added. If it prints nothing or an older version, the edit was not saved and you need to run crontab -e again.
Common mistakes and how to catch them
- Using a relative path, or the system Python instead of the venv’s. Cron does not run inside a login shell, so it never sources your
.bashrcand never sees an activated virtual environment. The crontab line above works around this by pointing directly atvenv/bin/python3, which already knows to use the packages installed in Step 2; a barepython3orpython3 scheduler.pywithout full paths is one of the most common reasons a cron job “does nothing.” - Assuming a thinking model is slow because of its size. As Step 5 covered, the slowdown from Qwen 3.5’s default thinking mode dwarfed any difference from model size in testing for this tutorial. If a scheduled brief is taking far longer than expected, check whether
think=Falseis actually being passed before assuming you need a smaller model. - Trusting a small model’s summary without checking the facts above it. Every brief this tutorial writes includes the raw facts right above the model’s summary specifically so you can catch a wrong or invented detail, the way the 0.8B tag’s invented package name was caught in Step 1’s testing. Treat the summary as a convenience, not a source of truth.
- Letting one agent’s exception take down the whole run. This is why
scheduler.pywraps each call toagent.run()in its owntry/except. If you copy just one agent file as a starting point for a new one and forget this pattern lives in the scheduler and not the agent, do not add a second layer of exception handling inside the agent itself, since that would just hide real errors from the log instead of fixing them. - Not redirecting stderr in the crontab line. Without
2>&1, a Python traceback goes nowhere you will ever see it. If a scheduled run seems to silently stop producing files, check whether your crontab line still has both the output redirect and the stderr redirect. - Forgetting that cron uses the system’s timezone. If a job fires at an unexpected time, check
timedatectlfor the timezone cron is actually operating in before assuming the crontab syntax is wrong.
Verifying everything works end to end
Confirm all of the following before considering this done:
crontab -lprints your scheduled line with the correct absolute paths.- Running the full path manually, exactly as cron would call it (for example
/home/youruser/scheduled-agents/venv/bin/python3 /home/youruser/scheduled-agents/scheduler.py), produces the same three files inoutputs/that Step 8 did. - After the next scheduled time passes,
ls outputs/shows three new files with a fresh timestamp, andscheduler.loghas no traceback in it. - On Ubuntu,
grep CRON /var/log/syslog(orjournalctl -u cronon hosts without a separate syslog file) shows a line confirming cron actually fired your command at the expected time, which tells you whether a missing brief is cron’s fault or the script’s.
If all four hold, you have a working scheduled agent system: a scheduler that discovers agents by convention, three independent agents covering a local system check, a shell command, and a network API, and a cron entry that fires the whole thing without you present.
Next steps
Once this is running reliably, a few directions are worth exploring. Swap the plain file write in scheduler.py for a delivery step that posts each brief to a Slack webhook or sends it by email, so the output reaches you instead of waiting in a folder. Add a small state file that remembers the previous run’s facts, so the package-updates agent can report only what changed instead of the full list every time. Try a larger Qwen 3.5 tag, like 9b or 27b, if your hardware allows it and you want noticeably better prose than the 4B tag already gives you. And if you want an agent that can act rather than only report, look at giving one of these agents access to a real tool call through Ollama’s tool support, so it could, for instance, actually queue an apt upgrade for approval instead of just describing what is pending.








No Comment! Be the first one.