Skip to main content

Tutorial: A Scheduled Agent with Cron

The go-code harness includes a built-in cron system that lets you fire shell commands — or full agent runs — on a recurring schedule. Use it to run nightly evals, periodic code-health checks, automated report generation, or anything else that should happen on a schedule without human intervention.

This tutorial walks you through creating a cron job, understanding how jitter affects fire times, and inspecting execution history.


How the cron system is structured

The harness ships two cron surfaces:

When harnessd starts and HARNESS_CRON_URL is not set, it automatically starts an embedded cron scheduler backed by a SQLite database at <workspace>/.harness/cron.db. No extra process to manage.

Jobs created through harnessd's HTTP API (POST /v1/cron/jobs) are stored there and run in-process alongside your agent workloads.

Limitation: The embedded scheduler does not expose a standalone HTTP port. The cronctl CLI talks to a separate cronsd daemon, so cronctl history and similar commands require HARNESS_CRON_URL to point at a running cronsd. If you only need job creation and management, use the POST /v1/cron/jobs API directly on harnessd.

Cron expressions are always 5-field UTC (Minute Hour Dom Month Dow). There is no seconds field — a 6-field expression will fail validation.


Before you start

This tutorial uses the key-free fake provider path. You don't need an API key to follow along.

  1. Build and start harnessd with the fake provider

    Build each binary separately (a multi-package go build without -o discards all output):

    go build -o cronsd ./cmd/cronsd
    go build -o harnessd ./cmd/harnessd
    go build -o cronctl ./cmd/cronctl

    The fake provider requires a turns file. Create one now:

    cat > /tmp/fake_turns.json <<'EOF'
    [
    {
    "content": "smoke ok",
    "usage": {"prompt": 100, "completion": 50},
    "cost_usd": 0.001,
    "cost_status": "available"
    }
    ]
    EOF

    Start the daemons:

    # Start the standalone cronsd daemon (port 9090)
    ./cronsd &

    # Start harnessd pointing at cronsd for cron operations
    HARNESS_PROVIDER=fake \
    HARNESS_FAKE_TURNS=/tmp/fake_turns.json \
    HARNESS_CRON_URL=http://localhost:9090 \
    ./harnessd &

    Both daemons should be up in under a second. Verify them:

    curl -s http://localhost:8080/healthz
    # {"status":"ok"}

    ./cronctl health
    # cronsd is healthy.
  2. Confirm cronctl knows where cronsd lives

    cronctl reads CRONSD_URL (default http://localhost:9090). Export it once for the session:

    export CRONSD_URL=http://localhost:9090

Create a cron job

Using cronctl

cronctl create \
--name daily-report \
--schedule "0 9 * * 1-5" \
--command "curl -s -X POST http://localhost:8080/v1/runs \
-H 'Content-Type: application/json' \
-d '{\"prompt\":\"Generate a daily code-health summary\"}'" \
--timeout 120

Flags:

FlagRequiredDefaultNotes
--nameyesUnique job name
--scheduleyes5-field UTC cron expression
--commandyesShell command to run via sh -c
--typenoshellshell or harness
--timeoutno30Max execution time in seconds

On success, cronctl prints the created job including its ID, next_run_at, and status (active).

Using the HTTP API

You can also create jobs directly against the harnessd API, which is useful from scripts or CI pipelines:

curl -s -X POST http://localhost:8080/v1/cron/jobs \
-H "Content-Type: application/json" \
-d '{
"name": "daily-report",
"schedule": "0 9 * * 1-5",
"execution_type": "shell",
"execution_config": "{\"command\":\"curl -s -X POST http://localhost:8080/v1/runs -H '\''Content-Type: application/json'\'' -d '\''{\\\"prompt\\\":\\\"Generate a daily code-health summary\\\"}'\''\"}",
"timeout_seconds": 120
}'

The execution_config field is a JSON-encoded string containing {"command": "<shell command>"}. execution_type must be "shell" or "harness".

List jobs to confirm:

cronctl list
# or
curl -s http://localhost:8080/v1/cron/jobs

Understand jitter

Jobs do not fire at the exact scheduled minute

By default, every job fires 1–5 minutes after its cron-scheduled time. This is intentional: jitter prevents all jobs from hammering downstream services at the top of the minute simultaneously (the "thundering herd" problem).

If you schedule a job for 0 9 * * *, it will actually fire somewhere between 9:01 and 9:05 UTC each day.

The jitter parameters are fixed at the scheduler's built-in defaults and are not currently configurable at runtime. Although HARNESS_CRON_JITTER_* environment variables and [cron] TOML keys are parsed into config, the parsed values are not wired into either the embedded or standalone scheduler; both are constructed without a Jitter field, so NewScheduler always falls back to DefaultJitterConfig regardless of what those settings contain. Do not rely on them to change or disable jitter.

The built-in defaults are:

ParameterDefault
Minimum delay60 s (1 minute)
Maximum delay300 s (5 minutes)
Avoided minute marks:00 and :30
Jitter loggingenabled

The base jitter offset for a given job is deterministic: it is computed from a hash of the job ID and schedule, so the same job always gets the same base delay across restarts. The minute-mark avoidance walk then adjusts that offset at each fire time to avoid landing on the avoided marks.

When jitter logging is enabled (the built-in default), the scheduler logs the applied jitter offset for each job at fire time — for example: cron: job <id> jittered by 2m15s (original schedule: 0 9 * * *, base jitter: 2m10s).


Trigger a run on schedule

A cron job whose command calls POST /v1/runs will start a full agent run at each scheduled (jittered) time. Here is a minimal example that you can adapt:

cronctl create \
--name hourly-eval \
--schedule "0 * * * *" \
--command "curl -s -X POST http://localhost:8080/v1/runs \
-H 'Content-Type: application/json' \
-d '{\"prompt\":\"Run the test suite and report failures\",\"max_steps\":10}'"

The command runs via sh -c. It has access to everything in the shell environment at the time cronsd (or harnessd) started, including any environment variables you exported before launch.

The execution_type of "harness" exists in the schema for future use. For now, use "shell" and have your command call the POST /v1/runs API to start agent runs.


Observe and manage

Inspect execution history

cronctl history and the underlying GET /v1/jobs/{id}/history endpoint require the job's UUID. Passing a name returns an empty list rather than an error, making the mismatch silent — always use the ID:

# List recent executions for a job (default: last 20)
cronctl history c9f9627f-1fa1-4320-aeac-9cfd4383f54f

# Limit to 5
cronctl history c9f9627f-1fa1-4320-aeac-9cfd4383f54f --limit 5

This calls GET /v1/jobs/{id}/history on cronsd. Each execution record includes:

FieldDescription
statuspending, running, success, failed, or timeout
started_at / finished_atWall-clock timestamps
duration_msActual execution time
output_summaryFirst 4096 bytes of combined stdout+stderr
errorError message if status is failed or timeout
run_idThe harness run ID, when execution type is harness

Output is truncated at 4096 bytes

The execution record stores only the first 4096 bytes of combined stdout and stderr. Commands that produce more output will have their output silently cut off in the history record. Redirect verbose output to a file or log aggregator if you need the full stream.

Get a single job

cronctl get daily-report
# or by ID
cronctl get 3f9a1c2e-...

Pause and resume

Pausing a job prevents future executions without deleting it. Any execution already in progress will complete.

cronctl pause, cronctl resume, and cronctl delete require the job's UUID — they do not accept names. Use cronctl get <name> or cronctl list to find the ID first:

# Get the job ID
cronctl get daily-report
# ID: c9f9627f-1fa1-4320-aeac-9cfd4383f54f
# ...

cronctl pause c9f9627f-1fa1-4320-aeac-9cfd4383f54f
# Job paused.

cronctl resume c9f9627f-1fa1-4320-aeac-9cfd4383f54f
# Job resumed.

Via the API on harnessd (ID required — names return 404):

JOB_ID="c9f9627f-..." # substitute your actual ID

curl -s -X POST http://localhost:8080/v1/cron/jobs/$JOB_ID/pause
curl -s -X POST http://localhost:8080/v1/cron/jobs/$JOB_ID/resume

Both endpoints return the updated job JSON (status: "paused" or status: "active").

Update a job

Change the schedule or timeout without recreating the job (job ID required):

# PATCH via harnessd API — use the job's UUID, not its name
JOB_ID="c9f9627f-..." # substitute your actual ID

curl -s -X PATCH http://localhost:8080/v1/cron/jobs/$JOB_ID \
-H "Content-Type: application/json" \
-d '{"schedule": "30 8 * * 1-5", "timeout_seconds": 180}'

Valid PATCH status values are "active" and "paused""deleted" is not a valid patch value. Use DELETE to remove a job.

Delete a job

cronctl delete requires the job's UUID (names are not accepted):

cronctl delete c9f9627f-1fa1-4320-aeac-9cfd4383f54f
# Job deleted.

Deletion is a soft-delete: the job's name gets a _deleted_<timestamp> suffix appended, freeing the original name for reuse.


Quick reference

cronctl commands

CommandWhat it does
cronctl create --name ... --schedule ... --command ...Create a new job
cronctl listList all jobs
cronctl get <id-or-name>Get a single job (accepts name or ID)
cronctl history <id>Show execution history (requires job UUID)
cronctl pause <id>Pause a job (requires job UUID)
cronctl resume <id>Resume a paused job (requires job UUID)
cronctl delete <id>Delete a job (requires job UUID)
cronctl healthCheck that cronsd is reachable

Key environment variables

VariableDefaultEffect
HARNESS_CRON_URL(empty)When set, harnessd uses this cronsd URL instead of the embedded scheduler
CRONSD_URLhttp://localhost:9090Base URL used by cronctl
CRONSD_ADDR:9090Listen address for the cronsd daemon

Next steps

  • Read Configuration to learn how TOML config layers and environment variables interact.
  • See Runs and Conversations for a full description of the POST /v1/runs request body and event stream.
  • To trigger runs from external systems instead of a schedule, see the Events page for the SSE event format, or explore POST /v1/external/trigger for webhook-driven runs from GitHub, Slack, or Linear.