Scheduling Guide
August 12, 2026 · View on GitHub
The Schedule tool lets the LLM create tasks that run on a cron schedule. Every
fire runs a real agent and records the run to storage; when the job was created
from a messaging channel (e.g. Telegram), the output is additionally delivered
back through that channel. Typical use cases: "every morning at 8 AM, send me an
inspiring quote" from a chat with the bot, or record-only background jobs
created from any session whose results are read from storage.
How it works
┌──────────────────────────────────────────────────────────────┐
│ infer daemon (long-running) │
│ │
│ SchedulerService │
│ ├─ robfig/cron/v3 scheduler │
│ ├─ 2s poll + diff against storage backend │
│ └─ on fire: persist RunRecord, spawn │
│ `infer headless --session-id <uuid>`, │
│ emit run events ──► ScheduleNotifier │
│ └─ job has a channel? Send(...) │
│ ChannelManagerService │
│ └─ inbound msgs → spawn `infer headless` │
└──────────────────────────────────────────────────────────────┘
▲ ▲
│ writes via storage backend │ reads via storage backend
┌──────────┴───────────┐ ┌─────────┴──────────────┐
│ Schedule tool │ create / update │ Storage backend │
│ (runs in any agent) │ ──────────────► │ (sqlite, postgres, │
│ │ │ redis, jsonl, memory) │
└──────────────────────┘ └────────────────────────┘
Key properties:
- Backend-agnostic storage. The
Scheduletool and the scheduler both use the configured storage backend (sqlite, postgres, redis, jsonl, or memory). Jobs are persisted through theScheduledJobStorageinterface. - Hot reload. The scheduler polls the storage backend every 2 seconds and
diffs the jobs against its cron entries: created, updated (including hand-edited
YAML on the jsonl backend), and deleted jobs are picked up within ~2s. The
scheduler refuses to start on the memory backend (
storage.enabled: false), since a per-process store can never be seen by the daemon. - Fresh session per fire. Each scheduled run gets a brand-new agent session ID. Nothing carries between fires; design prompts to be self-contained.
- Run records. Every fire persists a
RunRecord(session_id,job_id,status,error, timestamps) through the storage backend. Thesession_idis the conversation ID of that run, so the full transcript is readable from conversation storage - this is how non-channel consumers (e.g. the desktop app) pick up job output. The newest 200 records are retained. - Optional delivery.
channel/recipient_idon a job are a delivery target, not a requirement. Jobs created from a channel session deliver their output back to that channel; jobs created anywhere else are record-only. - Daemon-bound execution. Jobs only fire while
infer daemonis running.
Setup
1. Enable the tool
Add to .infer/config.yaml (or ~/.infer/config.yaml for user-wide defaults):
tools:
enabled: true
schedule:
enabled: true # off by default
require_approval: true # default; require_approval is highly recommended
max_jobs: 100 # safety cap
You can also use environment variables:
export INFER_TOOLS_SCHEDULE_ENABLED=true
2. (Optional) Configure a channel
Channel delivery is optional - without one, jobs are record-only. To have job output delivered to a messaging platform, set up Telegram (or any other supported channel) following Channels Guide and create the job from a chat on that channel. The tool errors when a channel-driven session references a channel that isn't enabled - a misconfiguration, not a record-only job.
3. Run the daemon
infer daemon
You should see a log line like Scheduler started jobs=0.
Cron syntax
Standard 5-field crontab format: minute hour day-of-month month day-of-week.
| Expression | Meaning |
|---|---|
0 8 * * * | Every day at 08:00 |
*/15 * * * * | Every 15 minutes |
0 9 * * 1-5 | Weekdays at 09:00 |
0 0 1 * * | First of every month at midnight |
@every 1h | Every hour |
@every 30m | Every 30 minutes |
@daily | Equivalent to 0 0 * * * |
The full grammar (including @every, @daily, @hourly descriptors) is documented
at robfig/cron.
GitHub backend
Set scheduler.backend: github to run schedules on GitHub Actions instead of a
local daemon - cloud scheduling with zero user-owned infrastructure. Each job is
materialized as one scheduled workflow (.github/workflows/<job-id>.yml) in a
repository you configure; the workflow runs the job's prompt via
inference-gateway/infer-action
under your infer GitHub App's bot identity.
# .infer/config.yaml
scheduler:
backend: github
github:
repository: "" # "" => <your login>/.routines, created private on first save
pull_requests: false # true => deploy via PR instead of pushing to main
artifacts:
enabled: true
poll_interval: 10m
initial_delay: 1m
max_attempts: 3 # download attempts per artifact, then skipped
rate_limit_backoff: 1h # pause after a rate-limited GitHub API call
How it behaves:
- Save → deploy. Creating, updating, or deleting a job clones the repo,
writes (or removes) that job's workflow file, and pushes the commit to the
default branch. With
pull_requests: truethe change lands on a branch and a PR is opened instead - merging deploys, and the PR is the review step and audit trail. - Repo auto-creation. If the configured repository does not exist it is
created private (default name
.routinesunder the authenticated user). All GitHub access goes through theghCLI, sogh auth loginmust be done once. - Cron is translated. GitHub Actions cron is UTC-only, 5-field, minimum
5-minute interval. Descriptors are translated (
@daily→0 0 * * *,@every 10m→*/10 * * * *, ...); expressions that cannot be expressed (e.g.@every 7m,* * * * *) are rejected at save time with a clear error. - One-off jobs.
run_oncejobs render a final step that disables the workflow after its first fire (the file stays in the repo, disabled). - Conversation pull-back. The workflow uploads the run's conversation
*.jsonlfiles as an Actions artifact (infer-conversations-<run_id>). Whileinfer daemonis running, an artifact poller downloads new artifacts into local conversation storage on the configured interval (jsonl storage backend only). Each artifact gets up tomax_attemptsdownload attempts; a rate-limited API call pauses polling forrate_limit_backoff. - Local list stays authoritative for the CLI. Jobs are still recorded
locally, so
list/get/update/deletework as usual. A failed GitHub sync aborts the save entirely - no phantom jobs.
Required repository secrets
The CLI never writes secrets. Set these as Actions secrets on the routines repository (Settings → Secrets and variables → Actions):
APP_CLIENT_ID/APP_PRIVATE_KEY- your infer GitHub App's client ID and private key; the workflow mints an installation token withactions/create-github-app-tokenand runs infer-action as the App bot. Forrun_onceself-disabling, the App needs the Actions (read & write) repository permission.- The API key secret(s) for your provider(s):
ANTHROPIC_API_KEY,OPENAI_API_KEY,GOOGLE_API_KEY,DEEPSEEK_API_KEY,GROQ_API_KEY,MISTRAL_API_KEY,CLOUDFLARE_API_KEY,COHERE_API_KEY,OLLAMA_CLOUD_API_KEY,MOONSHOT_API_KEY,MINIMAX_API_KEY,NVIDIA_API_KEY,ZAI_API_KEY. Only the ones for the models your jobs use are needed.
Out of scope for this backend (first cut): channel delivery of job output,
syncing runs into local RunRecords.
Tool operations
The Schedule tool is a single tool with an operation parameter. The LLM picks
the operation at call time.
create - recurring
Required: cron_expression, prompt. Optional: run_once, name, description,
model.
Channel and recipient are derived automatically from the current session
(format: channel-<name>-<sender_id>). The LLM never passes them. Outside a
channel-driven session (chat, headless, desktop) the job is created without a
delivery target and its output is read from storage via the run records.
{
"operation": "create",
"cron_expression": "0 8 * * *",
"prompt": "Find an inspiring quote for today and respond with the quote and its author. Keep it under 3 sentences.",
"name": "Daily morning quote",
"description": "Wake-up quote"
}
create - one-off
Set run_once: true to make the scheduler delete the job after its first
fire. The LLM is instructed to always confirm with the user whether they
want a one-off or recurring job before creating one.
{
"operation": "create",
"cron_expression": "0 18 26 4 *",
"prompt": "Remind me to call mum.",
"run_once": true,
"name": "Call mum reminder"
}
The tool returns the generated job ID. The job is persisted through the configured storage backend and the running daemon picks it up within ~2s via its poll loop.
list
{ "operation": "list" }
Returns all jobs sorted by creation time, including their last_run and last_error
fields when available.
get
{ "operation": "get", "job_id": "0a1b2c3d-..." }
update
Provide job_id and any of: cron_expression, prompt, run_once, name,
description, model. Untouched fields are preserved.
{
"operation": "update",
"job_id": "0a1b2c3d-...",
"cron_expression": "0 9 * * *"
}
delete
{ "operation": "delete", "job_id": "0a1b2c3d-..." }
End-to-end Telegram example - recurring
- User (Telegram): "Can you send me an inspiring quote every day at 8 AM?"
- Bot: "Sure - should this run every day from now on, or just once tomorrow?"
- User: "Every day."
- Bot calls
Scheduletool with:operation=createcron_expression="0 8 * * *"prompt="Find one inspiring quote and respond with quote + author, max 3 sentences."- (channel + recipient are derived from the session ID - not passed)
- User approves (because
require_approval: true). - Bot: "Done - job 01HG... scheduled. I'll message you tomorrow at 8 AM UTC."
- At 08:00 UTC the next day, the daemon fires the job: spawns a fresh
infer headlesssession with the saved prompt, captures the assistant's response, and sends it to the user via Telegram.
End-to-end Telegram example - one-off
- User (Telegram): "Remind me at 6pm today to call mum."
- Bot: "Got it - should this be a one-off reminder for today, or recurring every day at 6pm?"
- User: "Just once, today."
- Bot calls
Scheduletool with:operation=createcron_expression="0 18 26 4 *"(6pm on April 26)prompt="Remind me to call mum."run_once=true
- User approves.
- Bot: "Done - I'll ping you at 6pm today."
- At 18:00, the daemon fires the job, sends the reminder, and deletes the
job (because
run_once=true). Next April 26 it will not fire again.
Troubleshooting
Jobs aren't firing.
- Make sure
infer daemonis running andScheduler startedappears in the logs. - Check that the channel referenced in the job is enabled in config.
- Inspect the job's
last_errorfield after the expected fire time.
Jobs fire but no message arrives.
- The agent may have been silent (no assistant content). Check daemon logs for
Failed to send scheduled-job output. - Check that the channel is registered (
Registered channel channel=telegramlog line on daemon startup).
Security considerations
- Approval required by default. The LLM cannot create/modify/delete jobs
without explicit user confirmation. Keep
tools.schedule.require_approval: trueunless you fully trust the channel. - Full agent capabilities at fire time. Each fire is a real agent session - it can read files, call other tools, etc. Do not schedule prompts that would do anything sensitive without explicit narrow framing.
- Per-channel allowlists still apply. The schedule tool only lets the LLM
create jobs targeting channels that are enabled in config; per-channel
allowed_usersstill gates inbound interactions.