# Scheduled jobs

> How to create and manage durable scheduled jobs in Fermix for digests, watchers, one-off tasks, and periodic checks.

A scheduled job is a saved instruction that tells the agent to do something on a schedule, for example a daily digest, a periodic check, or a one-off task. Each run is bounded (it has time and step limits) and isolated (it starts fresh and cannot see your other chats). It gets its own session and memory scope, executes through the same `AgentLoop` (the agent's run-tools-and-respond cycle) used for interactive turns, and delivers its final response through a configured channel target. Jobs survive daemon restarts (the daemon is the long-running Fermix background process) and are stored in SQLite (a single-file local database).

A job is not the right tool for a date you simply want to be told about. A birthday, an appointment, a deadline, or a plain "remind me on Friday" is a [reminder](/docs/reminders): it is stored as a date with a finite reminder plan, and when one comes due Fermix sends deterministic text with no model call at all. A job is for future work that has to *run* — reason, use tools, read something that has changed, or produce a digest. "Remind me about my appointment at 3" is a reminder; "check flight prices tomorrow and tell me the cheapest" is a job. You do not choose between them explicitly; ask in plain language and Fermix tells you which it used.

## Schedule kinds

| Kind | Expression | Notes |
|------|------------|-------|
| Interval | `every N minutes`, `every N hours`, `every N days` | Examples: `every 5 minutes`, `every 6 hours` |
| Cron | 5-field cron string | Example: `0 9 * * *`. Supports `*`, lists (`1,15`), ranges (`9-17`), and steps (`*/15`). Weekday `7` and `0` both mean Sunday. |
| Once | ISO-8601 UTC datetime | Example: `2026-12-25T09:00:00Z`. Job disables itself after the single run. |

Free-form natural language like "daily at 8am" is rejected. Use the cron form `0 8 * * *` instead.

Each job stores a `timezone` (IANA label, default `UTC`). Cron expressions are evaluated in that timezone, including DST transitions. Unknown timezone strings are rejected at creation.

## Creating a job

Use the `schedule_job` tool in any conversation. The agent does not execute the task immediately; it only creates the job record.

Required parameters:

| Parameter | Type | Description |
|-----------|------|-------------|
| `name` | string | Human-readable label |
| `schedule` | string | Schedule expression (interval, cron, or ISO-8601 datetime) |
| `task` | string | Work instructions for the future run. Bake in any values the run will need (location, account, etc.) because scheduled runs are isolated and cannot see the originating conversation. |

Optional parameters:

| Parameter | Type | Description |
|-----------|------|-------------|
| `description` | string | Short note for the source catalog |
| `timezone` | string | IANA timezone for cron evaluation |
| `expires_at` | string | ISO-8601 UTC datetime after which the job is marked expired and stops running |
| `allowed_tools` | array of strings | Narrows the tool set available to the run. Must be a subset of tools visible to the caller; unknown names are rejected. For [coding agents](/docs/coding-agents), this list is also the authorization: a job may launch `codex_run` or `claude_code_run` only when that exact tool name is listed here. |
| `skill_name` | string | Binds the run to an existing skill. The run executes inside that skill's confinement; `allowed_tools` and capability policy are intersected, never widened. Unknown skill names are rejected at creation. |
| `provider` | string | Pin the job's runs to a specific provider (`anthropic`, `openai`, `openai_codex`, `xai`, `openrouter`, `mistral`, or `ollama`; must be paired with `model`). The name is validated against Fermix's model catalog at creation time, not against your configured credentials — pinning a provider you have not set up creates the job, but its runs will fail until that provider is configured. |
| `model` | string | Provider-specific model id (must be paired with `provider`). Omit both to use the default cron route from `[fermix_core.routing]`. |
| `timeout_seconds` | integer | Wall-clock timeout per run (default: 1800, i.e. 30 minutes) |
| `inactivity_timeout_seconds` | integer | Triggers when the agent loop stops making progress (no provider or tool call) |
| `delivery_mode` | string | `none`, `origin`, `channel`, or `local` |
| `delivery_target` | object | Channel and chat id for delivery |

Example using `fermix ask` to create a daily digest job:

```bash
fermix ask 'Schedule a daily digest at 8am Eastern. \
  Task: fetch the top Hacker News stories and summarize them. \
  Deliver to my Telegram.'
```

The agent calls `schedule_job` with a cron expression `0 8 * * *`, timezone `America/New_York`, and a `delivery_mode` of `channel` resolved from the configured default.

## Default delivery configuration

Per-job delivery can be set at creation time, or inherited from a daemon-wide default in `config.toml`. The default is resolved into each job when it is created; changing the default later does not silently retarget existing jobs.

```toml
[fermix_core.jobs]
default_delivery_mode = "channel"

[fermix_core.jobs.default_delivery_target]
platform = "telegram"
chat_id = "8217352118"
```

`chat_id` is the external channel conversation id (for example, a Telegram numeric chat id), not a Fermix session id.

This target is shared: [reminders](/docs/reminders) are delivered to the same `default_delivery_target`, and setting this key once configures both rails. Two rules differ on the reminder side. A `cli` platform is accepted for a job but refused for a reminder, and when the key is absent a reminder derives your inbox from the first channel configured with an explicit owner id — jobs never derive; an untargeted `channel`-mode job is refused at creation, and chat-created jobs are usually told to report back to their origin instead. A reminder is never quietly stored with nowhere to arrive: with no target and no owner-configured channel, storing fails loud with setup guidance. As with jobs, the target is copied onto each date at creation and a later config edit does not retarget dates already stored. Jobs you create can also read your stored dates (listing only), so a digest job can fold in upcoming reminders.

## Delivery modes

| Mode | Behavior |
|------|----------|
| `none` | No channel reply. Run output is still written to the run artifacts under `~/.fermix/job_runs/`. |
| `local` | No channel reply either — same delivery behavior as `none`. The run output stays in the run artifacts. |
| `origin` | Delivered to the channel and chat where the job was created. Refused when the job is created from an [ACP client](/docs/acp) — an external editor or harness driving Fermix — because that conversation ends with the client; the refusal points at naming an explicit `delivery_target` on a configured channel, or `none`. |
| `channel` | Delivered to an explicit `delivery_target` or the configured default target. |

Regardless of mode, every non-silent run also writes a summary to the job's memory source (see [Memory sources](#memory-sources) below).

If the agent's final response text matches the `silent_marker` (default `[SILENT]`), delivery is suppressed even when the mode would normally send.

A channel send that hits a transient connection problem (a request that never obtained a network connection, common right after the host wakes from sleep) is retried — up to three attempts with a short backoff — instead of being dropped. Only that no-connection case is retried; an error after the request went out fails fast, so a message is never sent twice.

## Job tools

| Tool | Description |
|------|-------------|
| `schedule_job` | Create a new scheduled job |
| `list_jobs` | List existing jobs. Each entry surfaces `task_prompt`, `skill_name`, `provider`, `model`, `delivery_mode`, and `delivery_target`. |
| `update_job` | Edit a job in place: task, schedule, description, `skill_name` rebinding, `provider`/`model` route pin (or `clear_route_pin: true` to un-pin both back to default routing — mutually exclusive with `provider`/`model`), or delivery (`delivery_mode`, `delivery_target`). Omitted fields are left unchanged. |
| `pause_job` | Pause a job without removing it |
| `resume_job` | Resume a paused job |
| `remove_job` | Delete a job and cascade-remove its run history |
| `run_job_now` | Trigger an immediate out-of-band run without disturbing the timed cadence. Refuses paused, disabled, expired, or already-running jobs. |
| `list_job_runs` | Read a job's execution history (newest first). Accepts an optional `status` filter. |
| `get_job_run` | Read one run in full: status, trigger, timing, token usage, prompt snapshot, final response, and error details. The `task_prompt` field reflects the instructions at run time, captured from the run's config snapshot. |
| `memory_sources_list` | List memory sources, including sources backed by scheduled jobs |

Full parameter documentation is on the [tool reference](/docs/tool-reference) page.

## Runtime bounds

Each run is bounded and isolated:

| Limit | Default | How to change |
|-------|---------|---------------|
| Wall-clock timeout | 30 minutes | Per job, via the `timeout_seconds` parameter |
| Delivery timeout | 60 seconds | Internal daemon default (not a `config.toml` key) |
| Agent-loop iterations | 100 | Internal daemon default (not a `config.toml` key) |
| Inactivity timeout | off unless set | Per job, via the `inactivity_timeout_seconds` parameter |

The daemon-wide defaults (delivery timeout, iteration cap, and the freshness window below) are built-in constants, not `config.toml` settings. What you can tune per job is set through the `schedule_job` parameters above; the `[fermix_core.jobs]` config block only carries delivery defaults and the network-readiness switch.

A job can also be pinned to a smaller or cheaper provider and model, independent of the main agent's route. Jobs that do not pin a provider use the cron route from `[fermix_core.routing]` (`cron_provider`, `cron_model`, `cron_reasoning_effort`), which defaults to the same chain as the main agent when unset. See [providers and models](/docs/providers-and-models) for routing details.

## Scheduler and runner internals

The scheduler is event-driven: instead of polling on a fixed interval, it arms a timer for the next due job's scheduled time and wakes exactly then, with a periodic reconciliation sweep (about once a minute) as a backstop. That timer is capped at 24 hours, so a schedule whose next fire is months away is simply rechecked once a day rather than trusted to a single very long timer across suspends and clock changes. When a due time arrives it queries SQLite for due jobs (up to 20 at a time by default) and claims each one atomically (one claim wins, with no chance of a job being started twice), transitioning it from `scheduled` to `running`. If a claim races with another claim for the same job, only one succeeds. Each claimed job gets one `Jobs.Runner` process under a `DynamicSupervisor` (`Jobs.RunnerSupervisor`).

At most 4 scheduled runs execute concurrently. Once that ceiling is reached a due tick claims nothing further — the remaining due jobs stay `scheduled` until a slot frees, and a later tick or the reconciliation sweep claims them. The ceiling is a built-in daemon constant, not a `config.toml` key, and it gates the timed path only: a `run_job_now` trigger claims immediately regardless of how many runs are already active.

The runner:

1. Builds a prompt: a "cron guidance" system note, the skill prompt if bound, job context, the current date (in the job's timezone), and the task prompt.
2. Resolves the provider route, trust, allowed tools, and capability policy.
3. Runs `AgentLoop.run/1` in a monitored subprocess.
4. A watchdog timer (a safety timer that steps in if the run hangs) kills the subprocess on wall-clock or inactivity timeout.
5. Persists the run summary to memory (unless silent) and writes artifacts to `~/.fermix/job_runs/<run_id>/`.
6. Finalizes job state and dispatches delivery.

When multiple jobs fire in the same minute, the runner staggers each new run's first network call by 250 ms per already-active run (capped at 5 seconds) to avoid saturating the HTTP connection pool simultaneously.

### Wake-from-sleep resilience

If the host resumes from sleep and a cron run fires before the network is ready, the runner detects the connection-unavailable error and retries the entire loop with exponential backoff (waiting longer between each retry, up to 4 attempts over 60 seconds). The retry only applies while no tool has executed yet in the attempt — once a tool has run, a connection failure fails the run rather than replaying completed tool calls and their side effects. A network readiness probe (a quick connection test to the configured provider host) runs before the first model call, gated by `[fermix_core.jobs] network_readiness_enabled` (default `true`).

### Restarts during a run

A job that was mid-run when the daemon stopped does not stay stuck in `running`. On start, and again on every reconciliation sweep, the scheduler compares the runs the database still records as active against the runner processes that are actually alive. A run whose runner is gone is failed with an explicit orphaned-run error, which releases the job to schedule normally again — the failure is visible in `list_job_runs` rather than the job silently never firing again. A runner that survived a restart of the scheduler alone is re-adopted and monitored instead of being failed, so a healthy run in progress is never killed by the sweep that cleans up after a crashed one.

### Missed runs

When the daemon is offline across a recurring job's scheduled fire time, the due time is far in the past. Fermix skips the stale run rather than firing it late, and advances `next_run_at` to the next future occurrence. The staleness window is one hour (3600 seconds) — a built-in daemon default, not a `config.toml` key. One-off `once` jobs are exempt: they run late rather than being dropped.

## Trust and capability policy

Each job records the creator's trust level at creation time.

- Jobs created by an operator-trust session run at `:operator` trust. The full tool surface is available (subject to `allowed_tools` narrowing).
- Jobs created by a guest-trust session run at `:guest` trust. The capability policy is intersected with the creator's ceiling at run time and cannot be widened. The default policy for non-operator jobs is `[:read_only, :network]`.

The `schedule_job` tool does not expose a `capability_policy` parameter in its public schema; policy is derived from the caller. Only `allowed_tools` is caller-controllable.

### Delegation to sub-agents

Operator-trust runs include the `subagents` delegation tool, so a scheduled job can fan work out to sub-agent workers. Guest-trust runs never see it: `subagents` is policy class `external_api`, which the guest surface excludes.

Delegated workers run at the run's own trust, but with `read_write` and `gui_control` subtracted from their policy classes — a worker can read, browse, and run sandbox-bounded commands, but cannot mutate local state or drive the desktop. Worker routing follows the usual sub-agent precedence: when `[fermix_core.routing]` sets `subagent_provider` / `subagent_model` (or the call passes its own model), the workers use that route; otherwise they inherit the route the run itself resolved — including the job's own `provider`/`model` pin — rather than falling back to the global primary.

### Coding-agent runs

A job may launch a coding-agent run only by naming the exact tool — `codex_run` or `claude_code_run` — in its `allowed_tools`. This is a code-enforced gate checked before anything is written: a broad capability policy is not enough, and a helper sub-agent inside an allowlisted job is still refused. A launch whose tool is not in `allowed_tools` is refused at the call with a plain tool error, like any other tool the run cannot use. A launch that clears the allowlist but is still blocked — coding agents not yet approved on the machine, or a working directory the sandbox denies — is recorded as a blocked run and its guidance is delivered to the job's delivery target, so the owner hears about it.

A launched run never re-enters a conversation. Its outcome — or its failure, led by the vendor CLI's own error text — is delivered durably to the job's delivery target, frozen at launch time so a later config edit never retargets an in-flight run, with retries and dead-lettering (the message is kept and surfaced rather than dropped) when delivery keeps failing.

## Expiry

Set `expires_at` to an ISO-8601 UTC datetime to create a temporary job. When the scheduler's tick detects that `expires_at` has passed, the job is marked `expired`, `enabled` is set to `false`, and its memory source is updated to `status: expired`. No further runs fire.

## Memory sources

Each scheduled job creates a memory source entry. Run summaries are written to that source after each successful run (unless the response matches `silent_marker`). Use `memory_sources_list` to see job-backed sources, and `list_job_runs` / `get_job_run` to inspect run history. The `get_job_run` response includes the `task_prompt` the run actually executed, sourced from the run's recorded snapshot, so it reflects the instructions at run time rather than a possibly-since-edited current prompt.

## Related pages

- [Reminders](/docs/reminders): personal dates that only need to notify you, with no model call when they fire
- [Channels](/docs/channels): configuring channel targets for delivery
- [Configuration](/docs/configuration): `[fermix_core.jobs]` and `[fermix_core.routing]` config keys
- [Tool reference](/docs/tool-reference): full parameter and return value docs for all job tools
- [The agent loop](/docs/the-agent-loop): how `AgentLoop` bounds and executes each run
