The adapter between Oban.Plugins.Cron (which schedules at the worker
layer) and the agent state machine (which owns turn enqueueing): a scheduled
tick delivers a prompt to an agent through the facade, never a claude job
behind the machine's back.
Modeled on the "Routines" idea: a routine is a prompt on a schedule, aimed at
a named agent. The claude turn itself is still enqueued by
ObanClaude.Agent.Instance, so every machine invariant (session threading,
:running + watchdog, approval gating) holds for scheduled work exactly as
for interactive work.
{Oban.Plugins.Cron,
crontab: [
{"0 9 * * *", ObanClaude.Agent.Tick,
args: %{
"agent_id" => "standup",
"prompt" => "Summarize overnight CI failures.",
"session" => "fresh",
"if_offline" => "start",
"start" => %{"args" => %{"model" => "sonnet"}}
}}
]}Args
"agent_id"(required) -- the agent to prompt."prompt"(required) -- the prompt to deliver."if_busy"-- what to do when the agent is not:idle:"skip"(default) -- cancel this beat ({:cancel, :agent_busy}); a missed beat is better than a backlog of stale scheduled prompts."queue"-- deliver anyway; the machine queues it behind the in-flight turn / pending gate. Beware: on a slow agent a fast schedule accumulates a backlog.
"if_offline"-- what to do when the agent is not running (e.g. after a restart):"skip"(default) -- cancel this beat ({:cancel, :agent_not_running})."start"-- start the agent, then deliver. Config comes from the optional"start"map --"args","approved_args","job_timeout"(the JSON-clean subset ofObanClaude.Agent.Instanceconfig; a custom:workeror:obanneeds the agent started by the host app instead). With"start"the crontab is effectively the agent's spec.
"session"--"resume"(default) continues the agent's claude session;"fresh"starts a new one for this beat. Prefer"fresh"for long-lived routines, or the session's context (and per-turn cost) grows without bound.
Delivery uses ObanClaude.Agent.cast_prompt/3 with origin: :tick, so a
scheduled prompt can never be consumed as the operator's answer to a
pending :waiting_for_user question -- it queues behind it. The "skip"
policies are a best-effort status pre-check; the origin flag is the
race-safe backstop.
A :paused agent never receives a tick ({:cancel, :agent_paused}) --
lockdown outranks the schedule, in both "if_busy" modes.
max_attempts: 1: a tick is a point-in-time beat; retrying a failed one
later would deliver a stale prompt (and risk a duplicate), so a missed beat
is simply missed. The cancels are visible per-beat in the oban_jobs table.
Ticks default to their OWN queue (:ticks -- run it alongside the agents
queue, e.g. queues: [agents: 2, ticks: 1]). On a queue shared with agent
turns a tick serializes BEHIND the very turn it should observe, so by the
time it runs the agent looks idle again and the "skip" policy can never
fire -- a lesson from live fleet use.