Framework & Tooling Integration Guides · 9 min read · Jul 2, 2026

Building a Cron-Triggered Agent That Checks Its Own Inbox

Not every agent needs to run forever. A lot of real workloads are better served by a process that wakes up on a schedule, checks for new mail, does something about it, and exits — a cron job, a systemd timer, a cloud scheduler's HTTP trigger. It's simpler to operate than a long-running autonomous loop, and for anything that doesn't need to react within seconds of a message arriving, it's usually the right default.

Why polling fits a cron model

Polling and webhooks solve the same problem differently: a webhook needs something listening all the time to receive the push; a cron job, by definition, isn't running most of the time. That makes polling the natural fit here — the job wakes up, asks "anything new?", and goes back to not running. If you need near-instant reaction instead, webhook notifications are the better tool, but they require a service that's always up to receive them, which defeats the point of a cron job in the first place.

The read call

Reading is free and authenticated by the token alone, so a cron job can call it as often as the schedule allows with no cost consideration:

curl https://dimebox.app/api/inboxes/acme-support/messages \
  -H 'X-Inbox-Token: inbox_...'

The list is newest-first, which means a repeat run needs to know where it left off last time — otherwise it reprocesses the same messages on every tick. Reading a mailbox's history covers message IDs and pagination in detail; the pattern below just needs enough of that to track a "last seen" marker between runs.

A minimal script

import json, pathlib
import httpx

STATE_FILE = pathlib.Path("/opt/agent/last_seen.json")
SLUG = "acme-support"
TOKEN = "inbox_..."

def last_seen_id() -> str | None:
    if STATE_FILE.exists():
        return json.loads(STATE_FILE.read_text()).get("last_id")
    return None

def save_seen_id(message_id: str) -> None:
    STATE_FILE.write_text(json.dumps({"last_id": message_id}))

def handle(message: dict) -> None:
    ...  # your own logic - reply, log, escalate, whatever the job is for

resp = httpx.get(
    f"https://dimebox.app/api/inboxes/{SLUG}/messages",
    headers={"X-Inbox-Token": TOKEN},
)
resp.raise_for_status()
messages = resp.json().get("data", [])  # newest first

seen = last_seen_id()
new_messages = []
for m in messages:
    if m["message_id"] == seen:
        break
    new_messages.append(m)

for m in reversed(new_messages):
    handle(m)

if new_messages:
    save_seen_id(new_messages[0]["message_id"])
# crontab -e
*/5 * * * * /usr/bin/python3 /opt/agent/check_inbox.py >> /var/log/agent-inbox.log 2>&1

The state file is local to this job, not something DimeBox tracks for you — there's no server-side "mark as read" concept on an inbox, just a flat list you're free to read as many times as you want.

Making the handler idempotent

save_seen_id only runs after every message in the batch has been handled, which means a crash partway through re-processes the whole batch on the next run rather than silently dropping the unhandled half. That's deliberate: a job that updates its "last seen" marker before confirming each message actually succeeded risks losing a message forever if it crashes between the two steps. Design handle() to tolerate seeing the same message twice — checking a local idempotency key before acting, or making the action itself safe to repeat — rather than relying on the cron schedule to guarantee exactly-once delivery, which it doesn't.

Running this as a scheduled cloud function instead

A traditional crontab entry assumes a host that's always on. If the job runs instead as a scheduled serverless function — a cloud provider's own cron trigger calling an HTTP endpoint on a fixed interval — the script body above barely changes; only the state file does. A function's local filesystem usually isn't persistent between invocations, so last_seen.json needs to move somewhere that survives — a small database row, a key-value store, even a single file in object storage — rather than disk. Everything else, including the read call itself, stays identical.

What happens if a run gets skipped

An inbox keeps only its newest messages, pruning the oldest automatically once it hits the cap. If your cron job goes down for long enough — a bad deploy, a dead host — and enough mail arrives in the gap, the oldest of those messages could roll off the window before a recovered job ever sees them. Picking a cron interval that's comfortably shorter than how long it'd take typical volume to fill the window is the simplest mitigation; there's no "has this ever been pruned" flag to check after the fact.

Scaling beyond one inbox

Nothing about this pattern is specific to a single slug — the same script, parameterized over a list of inboxes, is how you'd run one scheduled job across a fleet instead of standing up a separate cron entry per agent. Budgeting for a fleet of agents is worth reading before you scale this out, since polling itself is free but whatever action handle() takes in response usually isn't.

New to agent email? Start with Email for Agents: The Complete Guide to Giving AI Agents a Real Inbox for the full picture.