# cron

> Schedule functions with cron expressions - registers the `cron` trigger type.

| field | value |
|-------|-------|
| version | 0.21.7 |
| type | binary |
| license | Apache-2.0 |
| repo | https://github.com/iii-hq/workers |
| supported_targets | x86_64-apple-darwin, aarch64-apple-darwin, i686-pc-windows-msvc, x86_64-pc-windows-msvc, aarch64-pc-windows-msvc, x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, x86_64-unknown-linux-musl, armv7-unknown-linux-gnueabihf |
| author | iii |

## installation

```sh
iii trigger compose::add worker=cron@0.21.7
```

## configuration

```yaml
- adapter:
    name: local
```

## dependencies

- `configuration` @ `0.x`

## readme

# cron

Schedules registered functions with cron expressions. Any function bound to a
`cron` trigger is invoked by this worker at the next matching UTC time. The
worker replaces the legacy built-in cron worker while keeping the trigger type
and payload shape stable.

## Install

```bash
iii worker add cron
```

`iii worker add` fetches the binary, writes a config block into
`~/.iii/config.yaml`, and the engine starts the worker the next time it boots.

## Quickstart

Register a function and bind it to this worker's trigger type (`cron`) with an
`expression`:

```rust
use iii_sdk::protocol::RegisterTriggerInput;
use iii_sdk::{errors::Error, register_worker, InitOptions, RegisterFunction};
use serde_json::{json, Value};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let iii = register_worker("ws://localhost:49134", InitOptions::default());

    iii.register_function(
        "jobs::tick",
        RegisterFunction::new_async(|payload: Value| async move {
            println!("cron fired: {payload}");
            Ok::<Value, Error>(json!({"ok": true}))
        }),
    );

    iii.register_trigger(RegisterTriggerInput {
        trigger_type: "cron".to_string(),
        function_id: "jobs::tick".into(),
        config: json!({ "expression": "*/5 * * * * *" }),
        metadata: None,
    })?;

    tokio::signal::ctrl_c().await?;
    Ok(())
}
```

The function receives:

```json
{
  "trigger": "cron",
  "job_id": "<trigger-id>",
  "scheduled_time": "2026-07-03T12:00:00+00:00",
  "actual_time": "2026-07-03T12:00:00.123456789+00:00"
}
```

## Configuration

| Field | Default | Description |
|---|---|---|
| `adapter.name` | `local` | Lock backend. Use `local` for process-local locking or `redis` for multi-instance mutual exclusion. |
| `adapter.config.redis_url` | `redis://localhost:6379` | Redis URL when `adapter.name` is `redis`. |

Configuration is owned by the `configuration` worker - edit it from the
console (**Configuration -> Workers -> cron**) or seed it once via
`--config <file>.yaml` on first boot. Changing the lock adapter hot-swaps the
scheduler under a serialized apply lock: existing jobs are stopped, re-created
with the new backend, and never run in two scheduler instances at once.

## Console page

While the worker is connected it injects a **cron** page into the console
(`#/ext/cron`): every agent-owned schedule with its cadence, next UTC run and
fire count, the cron bindings other workers registered for themselves, and a
composer that turns "every weekday at 09:00, summarise open PRs" into a
registered schedule. Schedules created there live in a session of their own,
so each routine keeps its own transcript.

## Trigger type

This worker always registers the `cron` trigger type. Bind a function to it
with:

| Field | Required | Default | Description |
|---|---|---|---|
| `expression` | yes | - | Cron expression parsed by the Rust `cron` crate. Six-field `sec min hour day month weekday` expressions are supported, and a seventh year field is accepted. |
| `condition_function_id` | no | - | Function invoked before the scheduled handler. Only an explicit JSON `false` return blocks the fire; missing/null/truthy returns allow it. Condition errors skip that fire. |

All schedules use UTC. Missed fires while the worker is stopped are skipped;
there is no catch-up replay.

Write the day of week as a name (`Mon` ... `Sun`). Numerically the crate counts
Sunday as 1, so `0 0 9 * * 1` fires on Sunday, not Monday.

### Requires removing the legacy built-in cron worker

The legacy built-in cron worker also owns the `cron` trigger type. Two owners
of the same trigger type on one engine collide - whichever registers last
wins - so this worker requires it to be absent: omit it from the
engine's `config.yaml` (a config that doesn't list a worker won't run it).

On boot, this worker queries the engine for connected workers and refuses to
start with a clear error if the legacy built-in is still active, so a stale config
fails loudly instead of silently racing the built-in worker for ownership of
`cron`.

## Parity vs builtin

| Behavior | Builtin | This worker |
|---|---|---|
| Expressions | 6-7 field (`cron` crate) | same |
| Timezone | UTC only | same |
| Missed runs | skipped, no catch-up | same |
| Condition | only explicit `false` blocks | same |
| Lock TTL | 30s | same |
| Lock backends | kv (process-local), redis | local (process-local), redis |
| Service functions | none | none |

## skills

This version ships 1 skill file for agents, also browsable on the worker page:

- [`SKILL.md`](https://workers.iii.dev/workers/cron?tab=skills&skill=SKILL.md&version=0.21.7)

### SKILL.md

# cron

The `cron` worker schedules registered functions to run on recurring cron
expressions. It exposes no callable `cron::*` functions; its entire surface is
the `cron` trigger type, bound through a worker SDK trigger registration such
as `iii.registerTrigger({ type: 'cron', function_id, config })`.

Install it with `iii worker add cron`. The legacy in-engine cron service must not
run on the same engine because it also owns the `cron` trigger type. Remove the
legacy built-in from the engine config before starting this worker; the standalone
worker refuses to boot when the builtin is active.

On every firing, the worker optionally evaluates a condition function, acquires
a lock through the configured backend, and invokes the target function with an
event payload containing `trigger`, `job_id`, `scheduled_time`, and
`actual_time`. `scheduled_time` and `actual_time` let handlers observe drift
without querying the scheduler.

The schedule grammar is the Rust `cron` crate dialect: six or seven fields,
`second minute hour day-of-month month day-of-week [year]`. The year field is
optional. The leading field is always seconds, so `0 */5 * * * *` fires every
5 minutes at second 0, while `*/5 * * * * *` fires every 5 seconds.

Write the day of week as a name: `Mon`, `Tue`, `Wed`, `Thu`, `Fri`, `Sat`,
`Sun`. Numerically this dialect counts Sunday as 1, one ahead of the Unix
convention, so `0 0 9 * * 1` fires on Sunday and a `1` written for Monday
fires a day early. Names avoid the whole question.

Two lock backends govern duplicate firing. `local` is the default and is only
process-local; every worker instance can fire the same job in a multi-instance
deployment. `redis` uses Redis locking and is required for once-only firing
across a fleet. The lock TTL is 30 seconds.

## When to Use

- A function should run periodically without a separate scheduler process or
  system crontab entry.
- You need recurring cleanup, reporting, maintenance, or batch jobs that should
  fire on UTC cron windows.
- You need once-only firing across a fleet and can configure the `redis` lock
  backend.
- A scheduled job should be conditionally skipped by setting
  `condition_function_id`, without putting the condition inside the handler.

## Boundaries

- No callable functions are exposed. Never invoke `cron::*`; bind the `cron`
  trigger type to one of your own functions.
- The default `local` backend is process-local. Do not rely on it for once-only
  jobs in a multi-instance deployment.
- The worker does not catch up missed runs after downtime. It schedules the
  next upcoming UTC fire from the time it is running.
- A condition function only blocks when it returns explicit JSON `false`.
  Truthy, null, or missing results allow the fire; condition errors skip that
  fire.
- Five-field crontab expressions are not the intended format. Include the
  seconds field.
- For data-change or stream-change reactions, use the relevant state, stream,
  queue, or event worker instead; `cron` fires on the clock only.

## Reactive triggers

Bind a `cron` trigger when a handler should run on a recurring schedule. The
handler runs through the engine as a normal function invocation. With the
`redis` backend, only one worker instance should own a scheduled fire across
the fleet.

Reach for it when:

- You need recurring execution such as cleanup, reports, or maintenance.
- You want condition-gated firing: set `condition_function_id` and the worker
  evaluates it before each run.
- One function should be driven by multiple schedules; register multiple
  triggers and branch on the `job_id` payload field.

### How to bind

1. Register a handler: `iii.registerFunction('jobs::cleanup-old-data', handler)`.
2. Register the trigger:

```typescript
iii.registerTrigger({
  type: 'cron',
  function_id: 'jobs::cleanup-old-data',
  config: {
    expression: '0 0 2 * * * *',  // sec min hour dom month dow [year]; daily at 02:00:00 UTC.
    // condition_function_id is optional.
  },
})
```

`expression` is required and must parse. Bind one `function_id` to several
triggers with distinct trigger ids to drive multiple schedules into one
handler; the trigger id arrives as `job_id` in the event. The handler's return
value is ignored.

Event payload:

```json
{
  "trigger": "cron",
  "job_id": "<trigger-id>",
  "scheduled_time": "2026-07-03T12:00:00+00:00",
  "actual_time": "2026-07-03T12:00:00.123456789+00:00"
}
```

Configure the lock backend through the central configuration entry for worker
`cron`:

```yaml
adapter:
  name: redis
  config:
    redis_url: redis://localhost:6379
```

Use `adapter.name: local` for single-process development and
`adapter.name: redis` for multi-instance once-only scheduling.

## api reference

```json
{
  "functions": [
    {
      "description": "Internal: reload cron configuration from the authoritative store on change.",
      "metadata": {},
      "name": "cron::on-config-change",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "title": "ConfigChangeRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "ok": {
            "type": "boolean"
          }
        },
        "required": [
          "ok"
        ],
        "title": "ConfigChangeAck",
        "type": "object"
      }
    },
    {
      "description": "Serve the cron worker's injected console UI assets (content function for its console:script / console:style triggers).",
      "metadata": {
        "internal": true
      },
      "name": "cron::ui-content",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "Input of the content function: the console asks for one asset by path.",
        "properties": {
          "path": {
            "description": "The asset path from the trigger config (e.g. `state/page.js`).",
            "type": "string"
          }
        },
        "required": [
          "path"
        ],
        "title": "UiContentInput",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "Output of the content function.",
        "properties": {
          "content": {
            "description": "The asset source, verbatim.",
            "type": "string"
          },
          "content_type": {
            "description": "MIME type the console should serve the asset with.",
            "type": "string"
          }
        },
        "required": [
          "content",
          "content_type"
        ],
        "title": "UiContentResult",
        "type": "object"
      }
    }
  ],
  "triggers": [
    {
      "description": "Cron-based scheduled triggers",
      "invocation_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "Trigger config schema, field-parity with the engine's CronTriggerConfig.",
        "properties": {
          "condition_function_id": {
            "description": "Optional function ID to evaluate before invoking the handler.",
            "type": [
              "string",
              "null"
            ]
          },
          "expression": {
            "description": "Cron expression (6-field format: sec min hour day month weekday; a 7th year field is accepted).",
            "type": "string"
          }
        },
        "required": [
          "expression"
        ],
        "title": "CronTriggerSpec",
        "type": "object"
      },
      "metadata": {},
      "name": "cron",
      "return_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "actual_time": {
            "description": "Actual execution time (RFC3339)",
            "type": "string"
          },
          "job_id": {
            "description": "Unique job identifier",
            "type": "string"
          },
          "scheduled_time": {
            "description": "Scheduled execution time (RFC3339)",
            "type": "string"
          },
          "trigger": {
            "description": "Always \"cron\"",
            "type": "string"
          }
        },
        "required": [
          "actual_time",
          "job_id",
          "scheduled_time",
          "trigger"
        ],
        "title": "CronCallRequest",
        "type": "object"
      }
    }
  ]
}
```
