Scheduled jobs
How to create and manage durable scheduled jobs in Fermix for digests, watchers, reminders, 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 reminder. 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).
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. |
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:
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.
[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.
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. |
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 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 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 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. 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:
- 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.
- Resolves the provider route, trust, allowed tools, and capability policy.
- Runs
AgentLoop.run/1in a monitored subprocess. - A watchdog timer (a safety timer that steps in if the run hangs) kills the subprocess on wall-clock or inactivity timeout.
- Persists the run summary to memory (unless silent) and writes artifacts to
~/.fermix/job_runs/<run_id>/. - 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).
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
:operatortrust. The full tool surface is available (subject toallowed_toolsnarrowing). - Jobs created by a guest-trust session run at
:guesttrust. 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.
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
- Channels: configuring channel targets for delivery
- Configuration:
[fermix_core.jobs]and[fermix_core.routing]config keys - Tool reference: full parameter and return value docs for all job tools
- The agent loop: how
AgentLoopbounds and executes each run