DEV Community

Craig Solomon
Craig Solomon

Posted on

What a scheduled drafting agent does when nobody approves the drafts

A scheduled drafting agent is a producer. The thing that consumes what it produces is a person, and a person does not run on a cron.

That mismatch is where these systems go wrong. Not in the prompt, not in the model choice. In the timer.

Start with the smallest thing that works

Wire a model call to a schedule and you get something that works on the first run:

from apscheduler.schedulers.blocking import BlockingScheduler
from anthropic import Anthropic

client = Anthropic()
scheduler = BlockingScheduler()

def run_drafters():
 for drafter in DRAFTERS:
 text = drafter(client)
 save_draft(drafter.name, text)

scheduler.add_job(run_drafters, "cron", hour="*")
scheduler.start()
Enter fullscreen mode Exit fullscreen mode

Drafts appear. The loop looks closed. It is not closed, because nothing in that code knows whether the last batch was ever read.

The timer does not wait for the model

run_drafters makes network calls to a model. Those calls take as long as they take. The trigger fires on wall clock time regardless.

APScheduler's default for an overrunning job is to log a warning and skip the new run, but the default is worth making explicit rather than inheriting, because the behavior you want depends on the job. For a drafting cycle you almost always want at most one in flight:

scheduler.add_job(
 run_drafters,
 trigger=CronTrigger.from_crontab(DRAFT_CRON),
 id="draft-cycle",
 replace_existing=True,
 max_instances=1,
 coalesce=True,
 misfire_grace_time=GRACE_SECONDS,
)
Enter fullscreen mode Exit fullscreen mode

Each of those arguments is doing real work.

id plus replace_existing matters the moment you use a persistent job store. Without a stable id, every process start adds another copy of the same job, and the copies all fire. With them, the definition in your code is the definition that wins.

max_instances caps concurrent executions of that job. Leave it high and a slow model call plus a fast trigger gives you overlapping drafting runs writing rows at the same time.

coalesce decides what happens when the scheduler wakes up and finds several missed fire times waiting. Coalesced, they collapse into a single run. Uncoalesced, the agent tries to catch up on everything it missed while the box was off, which is how you come back from a weekend to a queue nobody asked for.

misfire_grace_time is the window in which a late job is still allowed to run at all. Past that window the run is dropped. For drafting, dropping is usually correct. A draft generated for a slot that has already passed is worth less than nothing, because you still have to read it to decide that.

Restarts duplicate work unless a key says otherwise

Scheduler state and application state are separate problems. Even with a persistent job store, a process that dies mid-cycle can leave you with a partial batch and no memory of which drafters already ran.

The fix is not in the scheduler. It is a uniqueness constraint in your own storage. Give every draft a key built from the drafter and the scheduled slot it belongs to, and let the database refuse the second write:

def draft_key(drafter_name, scheduled_at):
 return f"{drafter_name}:{scheduled_at.isoformat()}"
Enter fullscreen mode Exit fullscreen mode

With a unique index on that column, a re-run after a crash is safe. The drafters that already wrote rows fail their insert and get skipped. The ones that never ran fill in. You can pass the scheduled time into the job through APScheduler so the key reflects the slot rather than the moment the code happened to execute:

def run_drafters(scheduled_at):
 for drafter in DRAFTERS:
 key = draft_key(drafter.name, scheduled_at)
 if draft_exists(key):
 continue
 save_draft(key, drafter(client))
Enter fullscreen mode Exit fullscreen mode

Now the schedule is idempotent per slot. Retry it as often as you like.

The real backpressure is a person

All of the above keeps the producer honest with itself. None of it addresses the actual failure: the agent keeps drafting whether or not anything is being read.

Review is a serial, human, low throughput operation. Generation is parallel, machine, high throughput. Connect them with nothing in between and the queue grows without limit. The arithmetic only runs one way: rows arrive every time the trigger fires and leave only when a person sits down to read them, so what accumulates is a table full of text in an ambiguous state, none of it approved, none of it rejected, and no appetite left to go through it.

So make the queue depth an input to the schedule. Before drafting, look at what is still unreviewed:

def should_draft(session, drafter_name):
 pending = session.query(Draft).filter_by(
 drafter=drafter_name,
 status="pending",
 ).count()
 return pending < PENDING_LIMIT
Enter fullscreen mode Exit fullscreen mode

Call it at the top of the loop and skip the drafters that are already ahead of you. The agent throttles itself against your actual review rate instead of a number you guessed when you wrote the cron expression.

This only works if status is real. A draft needs to occupy an explicit state that a human moved it into: pending, approved, rejected. If everything a drafter produces is immediately eligible for use, there is no pending count to measure and no gate to close. The state column is what turns a pile of generated text into a queue with a depth.

Which means the review surface is not a nice-to-have on top of the agent. It is the half that makes the schedule safe. A drafting agent with no place to say yes or no is a machine that fills a table.

What this does not fix

Backpressure and idempotency are plumbing. They keep the system from drowning itself. They do not make the drafts good. A well scheduled agent producing text you would not publish is still an agent producing text you would not publish, and no scheduler flag helps with that.

Nor does any of this handle publishing. Approving a draft sets a column. Something else has to pick that up and put it somewhere, and that something is API clients, platform credentials, and rate limits you have to write and own yourself.

A count of pending drafts is also a crude signal. It tells you the queue is deep. It does not tell you which drafter is producing the work nobody wants to approve. If you want that, you need to track rejections per drafter over time, which is a different table and a different question.

And the single process assumption runs through the whole thing. max_instances bounds one scheduler. Run the agent on more than one box against a shared store and you need locking at the job level, which is a larger problem than this post.

If you want a real starting point for the loop rather than a blank file, I build and run the AI Content Agent Kit: a Python drafting agent on APScheduler and the Anthropic SDK, plus a Next.js approval dashboard, sharing one SQLite file in a single docker-compose, with sample drafters to replace and walkthroughs for the architecture and for customizing it, MIT licensed. https://fulcrumenterprises.tech/go/content-agent-kit/?c=devto

Top comments (0)