# 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](#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.

```yaml
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](./package-a-custom-agent) for the envelope shape.

## Watch it fire

```bash
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:

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

```yaml
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](./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](./cap-llm-spend-per-agent).
- Restrict where the scheduled job can reach - see [Lock down agent
  egress](./lock-down-agent-egress).
- Separate scheduled-traffic dashboards from on-demand - see [Send
  telemetry to OTLP endpoints](./send-telemetry-to-otlp).
