# `ObanClaude.Agent.Tick`
[🔗](https://github.com/genagent/oban_claude/blob/v0.10.2/lib/oban_claude/agent/tick.ex#L1)

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",
          "arc_id" => "daily-sweep",
          "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.
  * `"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 of `ObanClaude.Agent.Instance` config; a
      custom `:worker` or `:oban` needs 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.

## Host admission boundary

A host that coordinates agent configuration or another delivery boundary can
wrap the final status/start/deliver decision with an application callback:

    config :oban_claude, tick_admission: MyApp.TickAdmission

    defmodule MyApp.TickAdmission do
      def admit(:claude, agent_id, expected_delivery_revision, context, deliver) do
        MyApp.AgentCoordinator.admit(agent_id, expected_delivery_revision, context, deliver)
      end
    end

The configured module may export `admit/5`. It receives the provider, agent
id, the optional top-level `"delivery_revision"`, a context map containing
the optional `:arc_id`, and a zero-arity function containing the complete
status check, offline start, and prompt cast. An existing `admit/4` callback
remains supported without the context argument. For compatibility, a job
without `"delivery_revision"` falls back to `"start.config_revision"`. The
callback returns the delivery function's result, or another valid
`Oban.Worker` result when admission is refused. Without this configuration,
ticks behave exactly as before.

`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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
