Cron Scheduler
Scheduled cron jobs are LibreFang's way to run agent turns on a clock. Each job belongs to a specific agent and fires either on a fixed interval, a future one-shot timestamp, or a 5-field cron expression. This page documents the three features that turn the scheduler from "fire and forget the response into one channel" into a controllable pipeline:
- Multi-destination delivery — fan a single fire out to many channels, webhooks, files, or email recipients with per-target failure isolation.
- Pre-scripts — run a deterministic command before the LLM turn and inject its stdout into the prompt as additional context.
- Silent marker — let the agent decide at runtime that this fire should produce no delivery at all.
Included Topics
- CronJob Schema
- Multi-Destination Delivery
- SSRF Protection on Webhooks
- Pre-Scripts
- Wake Gate vs Pre-Script
- Silent Marker
- Limits and Validation
CronJob Schema
A cron job is persisted as a JSON record under the agent's data directory and
exposed through the /api/agents/{id}/cron endpoints. The same shape can be
constructed from TOML at the API/test boundary:
# Minimal job
id = "00000000-0000-0000-0000-000000000000"
agent_id = "research-agent"
name = "morning-briefing"
enabled = true
created_at = "2026-04-25T00:00:00Z"
[schedule]
kind = "cron"
expr = "0 9 * * 1-5"
tz = "America/New_York"
[action]
kind = "agent_turn"
message = "Summarize overnight market activity in three bullets."
timeout_secs = 120
[delivery]
kind = "channel"
channel = "telegram"
to = "123456789"
The fields that matter for the new features are all on CronAction::AgentTurn
(action.kind = "agent_turn") and on the top-level delivery_targets list:
| Field | Where | Purpose |
|---|---|---|
pre_check_script | [action] | Pre-existing wake gate. Last non-empty stdout line of {"wakeAgent": false} skips the LLM call. |
pre_script | [action] | Run a script and inject its stdout into the LLM prompt as extra context. |
silent_marker | [action] | Marker string the agent can emit on the last line to suppress delivery. Defaults to "[SILENT]". |
delivery_targets | top-level | Additional fan-out destinations on top of delivery. Empty by default. |
Multi-Destination Delivery
The legacy single delivery field still works and remains the primary
destination. delivery_targets is an additive list — each entry produces an
extra send when the job fires. Targets run concurrently and one failure does
not abort the others.
[action]
kind = "agent_turn"
message = "Run the daily ops digest."
timeout_secs = 180
# Primary destination — same as before.
[delivery]
kind = "channel"
channel = "slack"
to = "C0OPS"
# Additional fan-out destinations.
[[delivery_targets]]
type = "channel"
channel_type = "telegram"
recipient = "987654321"
[[delivery_targets]]
type = "webhook"
url = "https://ops.example.com/hooks/daily-digest"
auth_header = "Bearer prod-token-redacted"
[[delivery_targets]]
type = "local_file"
path = "reports/daily-digest.md"
append = true
[[delivery_targets]]
type = "email"
to = "ops-team@example.com"
subject_template = "Daily digest from {job}"
Target variants
Each entry uses a tagged-union shape with type selecting the variant.
type | Required fields | Optional fields | Notes |
|---|---|---|---|
channel | channel_type, recipient | thread_id, account_id | channel_type matches the adapter key in [channels] ("telegram", "slack", "discord", …). account_id resolves to <channel>:<account_id> for multi-account setups. |
webhook | url | auth_header | URL must start with http:// or https://. URL length capped at 2048. SSRF-blocked hosts are rejected at validation time (see below). |
local_file | path | append (default false) | Path must be workspace-relative — absolute paths and .. traversal are rejected. With append = true the file is opened for append; with false (default) each fire overwrites it. |
email | to | subject_template | Routes through the "email" channel adapter — that adapter must be registered under [channels]. {job} placeholder in subject_template is replaced with the job name at send time. |
Failure isolation
When the job fires and the agent produces a response, the dispatcher iterates
the legacy delivery and every delivery_targets entry. Each send runs in
its own task. If the webhook in the example above returns 503, the Slack and
Telegram messages still go out, the file is still written, and the email is
still queued. Per-target errors are logged with the job name and target index
but do not propagate to the next target or mark the job itself as failed —
the LLM turn already completed successfully.
This isolation is also why [delivery] was kept as a separate field instead
of being merged into delivery_targets: legacy single-destination jobs keep
their existing semantics (the dispatcher's cron_deliver_response path) and
new fan-out targets are layered on top without any migration required.
SSRF Protection on Webhooks
Webhook URLs are validated when the job is created or updated, before they are ever fetched. The validator rejects hosts that point at the loopback interface, link-local addresses, and the well-known cloud metadata services.
The blocked-host list is checked case-insensitively against the URL's host component:
| Pattern | Why it's blocked |
|---|---|
localhost | Loopback by name. |
127.* | IPv4 loopback range. |
[::1] | IPv6 loopback. |
169.254.* | IPv4 link-local — covers AWS / Azure / DigitalOcean metadata IP 169.254.169.254. |
fe80:* and [fe80:* | IPv6 link-local. |
metadata | Bare hostname used in some metadata endpoints. |
metadata.google.internal | GCP metadata service. |
metadata.aws.amazon.com | AWS metadata service hostname. |
Userinfo (user:pass@host) and ports are stripped before the check, so
http://attacker@127.0.0.1:8080/x is rejected on its 127.0.0.1 host the
same way http://127.0.0.1/x would be.
If you genuinely need to fan out to a host on the loopback range — for
example a sidecar process on the same machine — route it through a channel
adapter or use a local_file target instead of a webhook. The webhook path
is intentionally restricted to non-internal destinations.
Pre-Scripts
A pre-script runs before the LLM turn fires and its stdout becomes part of the prompt. Use it to split deterministic data fetching (HTTP scrape, diff against a previous state, computation) from the LLM reasoning step — the LLM gets fresh data without spending tokens on tool-call overhead, and hallucination risk drops because the data is fed in literally rather than reconstructed.
[action]
kind = "agent_turn"
message = "Summarize the new GitHub issues since yesterday in three bullets. If there are none, reply with [SILENT]."
timeout_secs = 120
silent_marker = "[SILENT]"
[action.pre_script]
argv = ["fetch-new-issues.sh", "myorg/myrepo"]
cwd = "/home/alice/.librefang/scripts"
[action.pre_script.env]
GITHUB_REPO_FILTER = "is:open"
When the job fires, the dispatcher:
-
Validates
argv[0]against the<home_dir>/scripts/allowlist (see Path Allowlist). -
Spawns the binary directly (no shell —
argvis split form, no$VARexpansion, no piping). -
Layers
pre_script.envon top of the daemon environment and appliescwdif set. -
Captures stdout under a 60 s tokio timeout. A timed-out child is reaped via
kill_on_drop(true). -
If stdout is non-empty after trimming, appends it to the prompt as:
{scheduled message} --- pre_script output --- {stdout} -
If validation fails, the binary fails to launch, the script exits non-zero, or the timeout fires — the agent still runs, just without injected context. Pre-script failures are additive and never block the scheduled fire. The wake gate (
pre_check_script) is the right tool if you want failure to skip the run.
Path Allowlist
pre_script.argv[0] must canonicalize to a path under <home_dir>/scripts/,
where <home_dir> is the daemon home (typically ~/.librefang).
- Relative paths are joined onto
<home_dir>/scripts/first, then canonicalized. A bareargv = ["fetch.sh"]resolves to~/.librefang/scripts/fetch.sh. - Absolute paths are canonicalized directly; the result must still be
under
<home_dir>/scripts/. Symlinks are followed, so a symlink inside the allowlist pointing outside is rejected. ..traversal is collapsed by canonicalize and the resulting path is prefix-checked at the path-component level — so<home_dir>/scripts-othercannot pretend to satisfy<home_dir>/scriptseven though their string representations share a prefix.- Missing files fail validation with
NotFound— the script must exist on disk at validation time.
Dangerous Environment Keys
pre_script.env is merged on top of the daemon environment, but a denylist
rejects keys that would defeat the path allowlist by hijacking the spawned
binary's library or path resolution:
| Denied key | Why |
|---|---|
LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT | glibc dynamic-linker injection. |
DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH, DYLD_FALLBACK_LIBRARY_PATH | Darwin dynamic-linker injection. |
PATH | The script's own subprocess.Popen("subcmd") calls would resolve through an attacker-controlled PATH. |
IFS | POSIX shell field splitter. |
Comparison is ASCII-case-insensitive, so path and Path are rejected
the same as PATH. Set sensitive values through the encrypted vault
(LIBREFANG_VAULT_KEY / ~/.librefang/vault.enc) rather than plain
strings in pre_script.env.
Wake Gate vs Pre-Script
pre_check_script and pre_script look similar but answer different
questions:
pre_check_script (wake gate) | pre_script (context injection) | |
|---|---|---|
| Decides | "Should the agent run at all?" | "What extra context does the run get?" |
| Output protocol | Last non-empty stdout line must be JSON. {"wakeAgent": false} skips the LLM call. Anything else wakes. | Stdout is appended to the prompt verbatim when non-empty. |
| Timeout | 30 s. Timeout wakes the agent (failsafe). | 60 s. Timeout fires the agent without context (failsafe). |
| Path allowlist | <home_dir>/scripts/ (canonicalize + component prefix). Path violations wake the agent. | <home_dir>/scripts/ (canonicalize + component prefix). Validation failure fires the agent without context. |
| Use case | Skip the LLM call when there's nothing new. Save tokens. | Feed deterministic data into the prompt. Reduce hallucination on numbers/diffs. |
Both can coexist on the same [action]. A common pattern is "wake-gate the
fetch, then inject the fetched data":
[action]
kind = "agent_turn"
message = "Triage the new issues."
pre_check_script = "/home/alice/.librefang/scripts/has-new-issues.sh"
[action.pre_script]
argv = ["fetch-new-issues.sh"]
has-new-issues.sh returns {"wakeAgent": false} when the issue list is
empty — the agent is never woken, no LLM tokens are spent. When new issues
do exist, the wake gate returns silently, then fetch-new-issues.sh runs
and its stdout is injected into the prompt.
Silent Marker
silent_marker is the runtime escape hatch on the agent's side: even after
the LLM turn has run and produced a response, the agent can suppress all
delivery by ending its response with the marker.
[action]
kind = "agent_turn"
message = "If there are critical alerts in the log, summarize them. Otherwise reply with exactly [SILENT]."
silent_marker = "[SILENT]"
[delivery]
kind = "channel"
channel = "telegram"
to = "123456789"
[[delivery_targets]]
type = "email"
to = "oncall@example.com"
Behaviour:
- The marker defaults to
"[SILENT]"whensilent_markeris unset. - Match is strict last-non-empty-trimmed-line equality. A response of
"Nothing to report.\n[SILENT]\n\n "matches (trailing blanks ignored). A response that mentions[SILENT]mid-paragraph does not match — the marker has to stand alone on the final line. - An empty response does not match the marker. Empty responses fall through
to the existing "silent" handling (the
result.silentflag from the agent loop). - When the marker matches, both the legacy
deliveryand everydelivery_targetsentry are suppressed for that fire. The job itself still records as success — the agent intentionally chose to stay quiet this run, that is not an error.
This is the right tool for "noisy no-op" jobs: cron the agent every five
minutes, but only actually message the team when something interesting
happened. Combined with pre_script to fetch the data, the cost-floor for
quiet hours is one shell script invocation plus one LLM turn that decides
to emit [SILENT] — no fan-out cost.
Limits and Validation
Hard caps and validation rules enforced at job-create / job-update time:
| Limit | Value |
|---|---|
| Jobs per agent | 50 |
| Job name length | 128 chars (alphanumeric, space, hyphen, underscore) |
every_secs interval | 60 s – 86 400 s (24 h) |
One-shot at horizon | up to 1 year in the future |
SystemEvent.text length | 4096 chars |
AgentTurn.message length | 16 384 chars |
AgentTurn.timeout_secs | 10 s – 600 s |
| Webhook URL length | 2048 chars |
| Webhook host | not on the SSRF blocklist |
local_file.path | non-empty, workspace-relative, no .. |
pre_script.argv[0] | resolves under <home_dir>/scripts/ |
pre_script.env keys | none from the dynamic-linker / PATH / IFS denylist |
| Pre-script timeout | 60 s wall-clock |
| Wake-gate timeout | 30 s wall-clock |
A job that fails validation is rejected with a descriptive error from
/api/workflows/cron — the rule is "first failure wins", so fix the
reported issue and resubmit. See crates/librefang-types/src/scheduler.rs
for the canonical schema and crates/librefang-kernel/src/cron_delivery.rs
for the dispatcher.
Related
- Session Auto-Reset — clears
message history on a schedule. Cron jobs share a single
(agent, "cron")session by default;session_resetis what keeps that session's history from growing forever. - Workflows —
session_moderesolution order and cron-specific caveats. Per-triggersession_mode = "new"is the right knob when each cron fire needs full session isolation.