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.
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.1Cron 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
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-00001The 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:
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:00ZIn 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:
- The image runs your prompt against your ticket source. The logic doesn't care whether it was HTTP-triggered or cron-triggered.
- The schedule picks the fire window.
0 9 * * *is 9am container-default time (UTC unless your controller-manager pod hasTZset). - The
scheduleInputis whatever fixed payload the cron-triggered variant should run with - a source identifier, a window, a tenant ID.
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 ownscheduleInputplus the fire window/slot instead. MaxConcurrentis your concurrency cap. Setspec.maxConcurrent: 1if 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:
- Status.
kubectl get ta <name> -o yaml-status.conditionscarries aScheduledcondition with the reason for the most recent skip.status.lastScheduleTimeshows the last successful fire.clrk agents getprints the high-level summary but doesn't surface conditions or schedule timestamps today. - Controller-manager logs. Under
clrk dev, runclrk dev logs(it streams the k3d server plus the in-cluster controller-manager and worker pod logs in one stream; add-fto follow), orkubectl -n clrk logs -l app.kubernetes.io/name=clrk-controller-manageragainst~/.clrk/kubeconfig.host. In prod, tail the controller-manager Pod's logs. The cron reconciler logs each evaluation. - OTLP. Filter your traces backend on
X-Clrk-Trigger=cronand 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
- Cap how often the scheduled job can spend tokens - see Cap LLM spend per agent.
- Restrict where the scheduled job can reach - see Lock down agent egress.
- Separate scheduled-traffic dashboards from on-demand - see Send telemetry to OTLP endpoints.