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 Codex 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 Codex turn itself is still enqueued by
ObanCodex.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 * * *", ObanCodex.Agent.Tick,
args: %{
"agent_id" => "standup",
"arc_id" => "daily-sweep",
"prompt" => "Summarize overnight CI failures.",
"session" => "fresh",
"if_offline" => "start",
"start" => %{"args" => %{"model" => "gpt-5"}}
}}
]}Args
"agent_id"(required) -- the agent to prompt."prompt"(required) -- the prompt to deliver."arc_id"-- optional named conversation arc. Omission uses the backward-compatible"default"arc."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","session_arcs","max_session_arcs", and"config_revision"(the JSON-clean subset ofObanCodex.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 Codex session;"fresh"starts a new one for this beat. Prefer"fresh"for long-lived routines, or the session's context grows without bound.
Delivery uses ObanCodex.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.
Admission
A host that owns Agent configuration may set :tick_admission to a module
with admit/5:
config :oban_codex, :tick_admission, MyApp.TickAdmission
def admit(:codex, agent_id, expected_delivery_revision, context, run) do
MyApp.with_current_agent_config(agent_id, expected_delivery_revision, context, run)
endThe callback receives the provider, agent id, the optional top-level
"delivery_revision", a context map containing the optional :arc_id, and
a zero-arity function. Existing admit/4 callbacks remain supported without
the context argument. Jobs without the revision field fall back to
"start"["config_revision"] for compatibility. The function contains the
complete status check, optional Agent start, and prompt delivery. The
callback's return value becomes the worker result. With no callback
configured, Tick invokes the function directly.
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.