Getting startedGuidesReferenceChangelog
Apoxy:// Docs / Guides / Schedule recurring agents

Schedule recurring agents

Run a TaskAgent on a cron schedule with a fixed payload. Same agent, same image, same observability - different trigger.

You have a TaskAgent that does something useful: triage tickets, summarize yesterday's logs, refresh a dashboard. You want it to run every night at 9am UTC. This guide adds the cron trigger.

Why CLRK's cron and not a CronJob

Cron is built into the TaskAgent controller, not bolted on. The practical difference: a scheduled invocation runs the same agent on the same worker dispatch path as an HTTP one. The credential injection, token budgets, and OTLP/invocation records all apply uniformly across triggers, because they live on the egress ext_proc and worker-dispatch path - not on the ingress listener. (A cron fire does not traverse the Envoy ingress listener, so there is no Envoy ingress access-log entry per fire; see What the agent sees.)

A k8s CronJob would give you a bare Pod with none of that. You'd have to rebuild the chain yourself.

Add a schedule

spec.schedule is a standard 5-field cron string. Mnemonics (@hourly, @daily, @every 5m) work too - anything github.com/robfig/cron's ParseStandard accepts.

spec.scheduleInput is the JSON the controller POSTs as the request body on each fire. It's literal - no templating today.

$terminalYAML
apiVersion: clrk.apoxy.dev/v1alpha1 kind: TaskAgent metadata: name: ticket-triage spec: workerPoolRef: default schedule: "0 9 * * *" # 9am every day scheduleInput: source: "linear" window: "yesterday" timeout: 5m template: spec: image: <your-registry>/ticket-triage:0.1

Cron and HTTP triggering coexist on the same TaskAgent. Setting schedule does not disable HTTP. If you want one or the other, control it on the ingress side or by header check inside the agent.

What the agent sees

Each fire POSTs your scheduleInput directly to a ready worker pod on the WorkerPool dispatch path: the cron reconciler lists the <workerPoolRef>-workers Service EndpointSlices and POSTs to a ready worker pod IP on the dispatch port (no Envoy ingress in the path). Two headers are set:

  • X-Clrk-TaskAgent: <namespace>/<name> - same as any HTTP caller.
  • X-Clrk-Trigger: cron - distinguishes scheduled invocations from external HTTP callers. Use it inside the agent or in OTLP filters to separate scheduled from on-demand traffic.

The dispatcher wraps the POST body (your scheduleInput) in a CloudEvents envelope before handing it to the agent's stdin. So your agent code looks identical to the HTTP-trigger case - see Package a custom agent for the envelope shape.

Watch it fire

$terminalSH
clrk agents get ticket-triage --local # Kind: TaskAgent # Name: ticket-triage # Namespace: default # Pool: default # Image: .../ticket-triage:0.1 # Schedule: 0 9 * * * # Age: 12m # Status: # ActiveExecutions: 0 # LatestCreatedRevisionName: ticket-triage-00001 # LatestReadyRevisionName: ticket-triage-00001

The TaskAgent's status.lastScheduleTime and status.nextScheduleTime carry the cron state, but clrk agents get doesn't surface them today - pull them off the resource directly:

$terminalSH
export KUBECONFIG=~/.clrk/kubeconfig.host kubectl get ta ticket-triage \ -o jsonpath='{.status.lastScheduleTime}{"\n"}{.status.nextScheduleTime}{"\n"}' # 2026-05-19T09:00:01Z # 2026-05-20T09:00:00Z

In the otel-traces pane (or your real OTLP backend), the spans for scheduled runs are indistinguishable from HTTP-triggered ones except that the originating request carries X-Clrk-Trigger: cron. Filter on that to scope dashboards or alerts to scheduled traffic.

Real example: a daily ticket-triage agent

A working setup uses three pieces:

  1. The image runs your prompt against your ticket source. The logic doesn't care whether it was HTTP-triggered or cron-triggered.
  2. The schedule picks the fire window. 0 9 * * * is 9am container-default time (UTC unless your controller-manager pod has TZ set).
  3. The scheduleInput is whatever fixed payload the cron-triggered variant should run with - a source identifier, a window, a tenant ID.
$terminalYAML
scheduleInput: source: "linear" window: "yesterday" tenant: "acme"

If you need a templated payload (today's date, a derived window), do the derivation inside the agent - scheduleInput is verbatim.

Idempotency

The cron firer evaluates schedules on the controller-manager's elected leader. On leader-election transitions and on restarts there is at-least-once delivery: a fire that the prior leader started but didn't durably record may re-fire on the new leader.

Two consequences:

  • Side effects must tolerate replay. Sending a Slack message, inserting a row, calling an external mutating API: gate on a check-then-write, an upstream idempotency key, or a deduplication table. Do not dedup on the CloudEvents envelope's id - for a cron fire it is a fresh UUID per fire and is not stable across the at-least-once replay. Derive your dedup key from your own scheduleInput plus the fire window/slot instead.
  • MaxConcurrent is your concurrency cap. Set spec.maxConcurrent: 1 if you want one execution at a time across both cron and HTTP. The dispatcher will queue or reject overflows.

When a fire goes missing

Three places to check:

  1. Status. kubectl get ta <name> -o yaml - status.conditions carries a Scheduled condition with the reason for the most recent skip. status.lastScheduleTime shows the last successful fire. clrk agents get prints the high-level summary but doesn't surface conditions or schedule timestamps today.
  2. Controller-manager logs. Under clrk dev, run clrk dev logs (it streams the k3d server plus the in-cluster controller-manager and worker pod logs in one stream; add -f to follow), or kubectl -n clrk logs -l app.kubernetes.io/name=clrk-controller-manager against ~/.clrk/kubeconfig.host. In prod, tail the controller-manager Pod's logs. The cron reconciler logs each evaluation.
  3. OTLP. Filter your traces backend on X-Clrk-Trigger=cron and the agent name. The absence of expected fires is your signal.

What scheduling does not solve

  • State across runs. Scheduled runs see no carryover from prior runs unless you persist it externally. See Persist state across runs.
  • Drift correction. A late firing fires once, not several times to "catch up." If you need backfill semantics, do them inside the agent.
  • Distributed-lock semantics for downstream mutations. CLRK does not assert ordering between concurrent cron fires of distinct TaskAgents. If you need that, coordinate outside CLRK.

Where to next