Scheduling Recurring Work (Cron)
The go-code harness includes a built-in cron system for scheduling recurring shell commands or agent tasks on a calendar. You describe a job with a standard five-field cron expression, and the scheduler fires it at the right time, records the execution, and makes the results queryable — no external cron daemon required.
The system is useful for things like periodic benchmark runs, nightly cleanup, recurring agent tasks that check external services, or any work you would otherwise manage with crontab or a cloud scheduler.
What cron provides
Embedded scheduler vs. remote cronsd
By default, when harnessd starts up it spins an embedded cron scheduler backed by a SQLite database at <workspace>/.harness/cron.db. No extra process needed. Jobs are loaded from the store at startup, registered with the scheduler, and fired according to their schedule for as long as harnessd runs.
When you need to share a cron database across multiple harnessd instances, or you want the scheduler to outlive any single harness process, you can run cronsd — the standalone cron daemon — and point harnessd at it:
export HARNESS_CRON_URL=http://localhost:9090
When HARNESS_CRON_URL is set, the embedded scheduler is not started. harnessd instead proxies all cron operations over HTTP to the remote cronsd. The two modes are mutually exclusive.
cronsd listens on :9090 by default (CRONSD_ADDR) and stores its database at ~/.go-harness/cronsd.db (CRONSD_DB_PATH). These are distinct from the embedded scheduler's .harness/cron.db path inside the workspace.
Five-field UTC cron expressions
All schedules use standard five-field UTC cron syntax: Minute Hour DayOfMonth Month DayOfWeek. The scheduler is built on github.com/robfig/cron/v3 configured for exactly this five-field format — no seconds prefix.
| Field | Range | Examples |
|---|---|---|
| Minute | 0–59 | */5, 0, 30 |
| Hour | 0–23 | 9, */2 |
| Day of month | 1–31 | *, 1 |
| Month | 1–12 | *, 6 |
| Day of week | 0–6 (Sun=0) | 1-5, * |
# Every 5 minutes
*/5 * * * *
# Weekdays at 09:00 UTC
0 9 * * 1-5
# First day of each month at midnight
0 0 1 * *
Six-field expressions (with a leading seconds field) are not supported and will fail validation when you try to create a job. Keep it to five fields.
Managing jobs
There are three ways to manage cron jobs, depending on your workflow.
Job structure
Every job has the following fields:
{
"id": "job_abc123",
"name": "daily-report",
"schedule": "0 9 * * 1-5",
"execution_type": "shell",
"execution_config": "{\"command\": \"./scripts/report.sh\"}",
"status": "active",
"timeout_seconds": 60,
"tags": "reports,nightly",
"next_run_at": "2026-06-29T09:00:00Z",
"last_run_at": "2026-06-28T09:04:17Z",
"created_at": "2026-06-01T00:00:00Z",
"updated_at": "2026-06-01T00:00:00Z"
}
execution_type is either "shell" (run a headless shell command via sh -c)
or "harness" (continue the creating run's conversation with an assistant
prompt). execution_config is a JSON blob whose shape depends on the type:
shell jobs contain a non-empty "command"; harness jobs contain a non-empty
"prompt". Agent-created harness jobs inherit their tenant, agent, and
conversation from immutable run metadata rather than model-supplied scope
fields.
Job status values: "active", "paused", "deleted"
Execution status values: "queued", "starting", "running", "succeeded", "failed", "timeout", "skipped".
Harness executions include a structured run_id as soon as the harness accepts
the scheduled run. Do not infer that ID from output_summary: the summary is
for display and changes again when terminal output is available. When a second
cron fire targets the same tenant, agent, and conversation while an earlier
cron execution remains active, the scheduler records a terminal "skipped"
history row whose error text is the stable overlap reason. Different
conversations remain independently concurrent. Older installations can still
contain the legacy "pending" and "success" history values.
Jitter and gotchas
Jitter: why your jobs are never exactly on time
Every scheduled job is delayed by a random offset — called jitter — after the cron clock fires. The default range is 1 to 5 minutes (60–300 seconds). This intentional delay exists to prevent multiple jobs from stampeding simultaneously at minute-mark boundaries.
How it works:
- When a job is registered, a deterministic base jitter offset is computed from a hash of the job's ID and schedule. The same job always gets the same base offset across restarts.
- At fire time, the scheduler walks the offset forward (one second at a time, up to 120 additional seconds) to avoid landing on any of the configured "avoided minute marks" — by default
:00and:30. - The scheduler sleeps for the final adjusted offset before dispatching the job.
Every job fires 1–5 minutes after its scheduled cron time by default. If you write a test that creates a cron job and immediately checks for an execution, you will be surprised by this delay. Disable jitter in test environments or use a short window.
Configuring jitter
Output truncation
Shell job output (stdout + stderr combined) is truncated to 4096 bytes in the execution record. Jobs that produce more output will have their output_summary field silently cut off. If you need full output, redirect to a file from within the shell command.
Soft delete and name reuse
DELETE /v1/cron/jobs/{id} does not immediately free the job's name. Instead it:
- Sets
statusto"deleted". - Appends
_deleted_<UnixNano>to the job name.
This frees the original name so a new job can use it — but only after the rename completes. If you delete a job and immediately create one with the same name, the create will succeed.
Execution concurrency
The scheduler caps concurrent job executions at MaxConcurrent: 5 (both the embedded scheduler and the default cronsd setup). Jobs that would exceed this limit are queued but may be delayed.
External triggers
POST /v1/external/trigger is a normalized webhook endpoint that routes one-off webhook events from external services (GitHub, Slack, Linear) to start, steer, or continue a harness run. It is separate from the cron scheduler — think of it as a push-based complement to cron's pull-based polling.
Actions
| Action | Behavior |
|---|---|
"start" | Always starts a new run |
"steer" | Injects a message into an existing running or queued run |
"continue" | Starts a new run continuing from a completed or failed run |
Request shape
{
"source": "github",
"source_id": "<delivery-id>",
"repo_owner": "myorg",
"repo_name": "myrepo",
"thread_id": "42",
"action": "start",
"message": "Run the eval suite on PR #42",
"tenant_id": "",
"agent_id": "",
"signature": "<hmac>"
}
The signature can be provided either in the "signature" JSON field or in the X-Trigger-Signature HTTP header. The header takes precedence.
Signature validators
Each source uses a different HMAC format:
| Source | Signature format |
|---|---|
"github" | "sha256=<hex>" (HMAC-SHA256) |
"slack" | "<unix_ts>:v0=<hex>" (±5 min freshness check) |
"linear" | Raw hex HMAC-SHA256 |
The corresponding secrets are set via GITHUB_WEBHOOK_SECRET, SLACK_SIGNING_SECRET, and LINEAR_WEBHOOK_SECRET. When a secret is set, the handler for that source is registered automatically.
Response codes
| Code | Meaning |
|---|---|
202 | Accepted |
400 | Invalid JSON or missing required fields |
401 | Signature validation failed, or no validator configured for the source |
404 | steer or continue action but no existing run for the thread |
409 | Run state mismatch (e.g. steer on a completed run) |
501 | Run store not configured |
There are also source-specific webhook routes — POST /v1/webhooks/github, POST /v1/webhooks/slack, and POST /v1/webhooks/linear — that bypass Bearer auth and use HMAC validation directly. These are convenient when a platform requires a fixed webhook URL, but the /v1/external/trigger route gives you more flexibility for multi-source setups.
Reference: cronsd environment variables
When running cronsd as a standalone daemon:
| Variable | Default | Description |
|---|---|---|
CRONSD_ADDR | :9090 | Listen address |
CRONSD_DB_PATH | ~/.go-harness/cronsd.db | SQLite database file |
CRONSD_MAX_CONCURRENT | 5 | Max simultaneous job executions |
CRONSD_URL | http://localhost:9090 | Used by cronctl to locate the daemon |
Next steps
- To understand how
harnessdauthenticates these API calls, see Authentication and Tenancy. - To drive cron jobs from within a running agent, call the initial-turn core
cron_createtool directly; use the other seven core cron tools to inspect and manage the job lifecycle. - To connect external webhooks (GitHub, Slack, Linear) to run triggers, set the corresponding
*_WEBHOOK_SECRETenvironment variables and point your webhook at the appropriate/v1/webhooks/*route or at/v1/external/trigger.