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: 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, 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:
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.
This target is shared: 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 — 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 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. 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:
- 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).
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
: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.
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: personal dates that only need to notify you, with no model call when they fire
- 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