Skip to main content

Scheduling

TF Code can run recurring agent work on a cron schedule — for example a daily dependency dig, a weekly report, or a periodic cleanup. There are two kinds of scheduled work:

  • A job runs the same prompt every tick. It's a fixed, repeating task: "do X at 09:00 every weekday."
  • A routine runs a decision every tick, not a fixed task. It wakes up, checks whether there is anything worth doing, and only spends tokens when there is. Idle ticks cost ~nothing.

Use a job when the work is identical every time and you want it to run unconditionally on schedule. Use a routine when the work is conditional — "if there's a new issue, triage it" — and you don't want to pay for a full agent run every time the schedule fires with nothing to do.

Both are managed from the desktop app, the TUI, or by asking the agent (which works from any client attached to a server).

tip

Jobs and routines are both provided by the scheduler builtin plugin, which is disabled by default. You must enable it before any scheduling works. See Enable.

Scheduling is powered by three layers working together:

  1. OS scheduler units (launchd on macOS, systemd timers on Linux) — fire the work at the exact cron time, even when TF Code is not running.
  2. Missed-run recovery — if the machine was off or TF Code wasn't running when a schedule was due, it is picked up on the next start (optional, see Missed-run policy).
  3. Run tracking — every run records timing, status, an optional sessionID, and logs to disk, so you can audit what ran, when, and whether it succeeded.

Enable​

Scheduling is a builtin plugin, disabled by default. Enable it in your config:

{
"builtin": { "scheduler": { "enabled": true } }
}
warning

Jobs and routines share one gate. Both are provided by the single scheduler builtin plugin, which is off by default. Until you enable it, the agent has no scheduling or routine tools, the /scheduler API returns errors, and the app/TUI panels show an enable prompt instead of your schedules. Nothing is scheduled until this is on.

You can also enable it at runtime from any client: POST /builtin/scheduler/enable, or the Enable scheduler action in the app/TUI.

The server exposes the standard plugin endpoints plus a dedicated /scheduler API:

EndpointDescription
GET /builtinPlugin status (including scheduler.enabled)
POST /builtin/scheduler/enableEnable the scheduler plugin
POST /builtin/scheduler/disableDisable the scheduler plugin
GET /schedulerList jobs + enabled status
GET /scheduler/{id}Job detail + run history
POST /schedulerCreate a job or routine
PATCH /scheduler/{id}Update a job or routine
DELETE /scheduler/{id}Delete a job and its OS scheduler unit
POST /scheduler/{id}/runRun a job immediately
POST /scheduler/{id}/testTest-run a routine (bypass the decision gate)
GET /scheduler/{id}/logs?lines=NRead the job log tail

The scheduler only manages work while the plugin is enabled. If it's disabled, management calls return an error and the panels show an enable prompt.

Cron expressions​

Jobs and routines use standard 5-field cron expressions. Example cron values:

ExpressionMeaning
0 9 * * 1-5Weekdays at 09:00
30 6 * * *Every day at 06:30
0 0 * * 0Sundays at midnight
*/15 * * * *Every 15 minutes
0 3 1 * *First day of every month at 03:00

Fields: minute hour day-of-month month day-of-week.

Agent selection​

Each job or routine runs the agent you pick. Set agent to an agent name — it is matched case-insensitively (so BuiLd and build are the same) and the default is build. Unknown or empty names fall back to build.

{
"name": "nightly-report",
"cron": "0 22 * * *",
"agent": "code",
"prompt": "..."
}

When you don't specify an agent, the work always runs with the default build agent.

Missed-run policy​

Each job or routine has a missed-run policy for what happens when a scheduled time passes while the machine is off or TF Code isn't running:

PolicyBehaviour
catch-up(default) On the next start, run the most recent missed occurrence once (best-effort recovery).
exactRun only at the exact scheduled time — if it's missed while offline, it's skipped.

Recovery is safe by design:

  • It only recovers the single most recent missed occurrence — never a backlog.
  • An occurrence that already produced a run is never re-run — including when that run failed. A failing schedule cannot trigger a continuous re-run loop.
  • Recently-due occurrences are left to the OS scheduler for a short grace window, so work that is simply about to fire while TF Code is online isn't double-run.
  • Disabled jobs are never recovered.
  • One-shot follow-ups (see Self-scheduling follow-ups) are never caught up — they self-clean on success and are fired exactly once.

Routines

A routine is a scheduled wake that runs a decision rather than a fixed task. The core idea: most of the time there is nothing to do, and a good routine should cost almost nothing when idle, then do real work the moment there is.

Jobs vs. routines​

AspectJobRoutine
What runs each tickThe same frozen promptA wake-and-decide prompt built from the routine's configuration
Cost when idleOne full agent run per tickZero model calls (file-gated) or one minimal wake (always)
Source of truthThe prompt (fixed at create time)A task file the routine re-reads every wake
Best forIdentical, unconditional repeating workConditional work — "if X, then act"

A job is the right tool for "run the deploy every morning." A routine is the right tool for "every morning, check the triage queue and handle anything new — but do nothing if the queue is empty."

The decision gate​

This is what makes idle routines cheap. Before a routine spawns an agent run, a gate decides whether there is anything worth doing. The gate is a local check — a file read and a hash — so it costs no tokens.

There are two gate modes:

file (default)​

The routine reads its task file and decides:

  1. Task file missing or effectively empty (only comments and whitespace) → skip. No model call. Nothing to do.
  2. Task file unchanged since the last run that handled it → skip. No model call. We already handled this exact content.
  3. Task file has new content → run the wake-and-decide prompt, and remember the file's hash so the same content isn't handled twice.

This means a routine that fires every 15 minutes but whose task file hasn't changed costs nothing until you edit the file. The hash is only recorded after a run that actually did work, so a skipped tick never marks content as "handled."

always​

The gate always runs. Every tick sends a single minimal wake that checks the current state and returns ROUTINE_OK when there's nothing to do. Use this when the routine must re-check external state on every fire — for example polling an API or an inbox that can change without you editing a file. It still avoids heavy work when idle, but it costs one small call per tick.

Rule of thumb: if the thing that triggers work is a file you edit, use file. If the thing that triggers work is the outside world changing, use always.

The task file​

A routine's task file is its source of truth — a markdown file in your workspace that describes what to do. Lines starting with # are comments and are ignored by the gate, so a file that is only headings and whitespace is treated as empty.

The default location is .tfcode/routines/<routine-id>.md, but you can point a routine at any workspace-relative file. Keeping the work-to-do in a file (rather than frozen in the prompt) means you can change what the routine does without recreating it — just edit the file.

# Triage queue

- [ ] Reproduce the crash reported in issue #421
- [ ] Review PR #88 once CI is green

When both items are done and you remove them, the file becomes empty and the routine stops spending tokens until you add the next task.

The ACT / ROUTINE_OK / REPORT contract​

When the gate decides to run, the routine sends the agent a wake prompt with a strict three-way decision. This is what keeps unattended runs from burning tokens or spinning:

  • ACT — there is a concrete, unhandled item within the routine's approval boundary. Do it.
  • ROUTINE_OK — nothing actionable, or the task is already done. The agent outputs exactly ROUTINE_OK and stops. The run is recorded as a no-op (noOp: true) and is not surfaced as real work.
  • REPORT — a source is missing or stale, or the next step needs a human. The agent does not guess and does not retry in a loop. It states the blocker in one line and stops.

The same contract appears in the create_routine tool description, so the rules the agent follows at run time are the same ones confirmed at setup time.

Approval boundary and missing-data policy​

Because routines run unattended, two fields make their behaviour predictable and safe:

  • Approval boundary — what requires a human before the routine acts (for example: sending messages, publishing, deleting, or changing production systems). Anything outside the boundary the routine may do on its own; anything inside it must stop and REPORT.
  • Missing/stale-data policy — what the routine should do when a source it needs is unavailable. The recommended policy is "report instead of using stale data," so a broken source produces a clear REPORT rather than silently acting on outdated information.

Together these enforce the run-time rule: when in doubt, report and stop — never guess, never retry-loop.

Active hours​

A routine can be scoped to an active-hours window (start–end in HH:MM, with an optional timezone). Ticks that fire outside the window are skipped at the gate — no model call. This lets a routine live on an aggressive cron (say every 15 minutes) while only actually running during business hours. The OS scheduler still fires on schedule; the gate simply decides the run is outside hours and records a skipped run.

Active hours wrap past midnight, so "22:00"–"06:00" means active overnight.

Error backoff​

If a routine fails repeatedly — typically because an external source is flaky — it is not re-fired every tick. After a small number of consecutive failures, the gate enters a backoff window and skips ticks until it elapses. The failure counter resets on the first success. This stops a broken source from burning tokens on every fire while you fix it; the most recent failure is always visible in the run history and logs.

Self-scheduling follow-ups​

During a routine run, the agent may decide a later check is needed — for example "no reply yet, check again in 4 hours." It can create a one-shot follow-up by calling schedule_job with once: true: a job that fires a single time and deletes itself after a successful run. One-shot follow-ups are never caught up by missed-run recovery, so they fire exactly once.

Schedule a one-shot follow-up (once=true) called "recheck-4h" with cron
"0 20 * * *" that re-checks the ticket and reports. It should run only once.

Because routine runs are unattended (interactive prompts are denied), schedule_job with once/kind is the right way to self-schedule from inside a run — create_routine is built for interactive setup and confirms a checklist first. This turns a routine from a fixed schedule into something that can adapt its own timing when it needs to.

Creating a routine​

Routines are designed to be set up in conversation. When you ask the agent to create a routine, it confirms a short checklist before doing so — asking via the question tool whenever anything is unclear:

  1. Schedule + timezone (the cron expression).
  2. Task file / source of truth — an existing file, or a new .tfcode/routines/<name>.md to seed.
  3. Expected output — what a completed run looks like.
  4. Approval boundary — what needs a human.
  5. Missing/stale-data policy — report-and-stop vs. guess.
  6. Gate mode — file (default) or always.

The agent only creates the routine once the checklist is clear (or you tell it to proceed). It also knows when not to create a routine: one-off tasks should just be done now, and a workflow that isn't reliable yet should be run manually first and automated only once it works. Don't automate something you haven't verified.

Create a routine called "inbox-triage" that runs every 30 minutes during
business hours. Read .tfcode/routines/inbox-triage.md and handle anything
listed there. Never send messages without asking me. If the inbox API is
unreachable, report it instead of using old data.

The agent will ask for anything it's missing (for example the timezone or the approval boundary), then create the routine, seed the task file, and show the next run time.

Testing a routine before relying on it​

Before depending on a routine's schedule, run it once with the test action. A test run bypasses the decision gate so you see the routine's real output regardless of the task-file state — this is the equivalent of "run it now, ignoring the skip rules." Check the logs afterward to confirm it behaves the way you want.

Test-run the "inbox-triage" routine and show me the result.

Because a test run does real work, give it safe inputs and keep write actions behind the approval boundary.

Routine fields​

FieldDefaultDescription
name—Human-readable label.
cron—5-field cron expression.
prompt—Short intent summary (wrapped by the wake-and-decide prompt at run time).
task_file.tfcode/routines/<id>.mdWorkspace-relative source-of-truth file the gate checks.
gatefilefile skips on empty/unchanged task file; always ticks every fire.
approval_boundary—What requires a human before the routine acts.
missing_data_policy—What to do when a source is missing or stale.
expected_output—What a completed run looks like.
active_hours—{ start, end, tz? } window; ticks outside it skip.
agentbuildAgent to run (case-insensitive).
timeout_seconds300Max run duration before kill (0 = no timeout).
missed_run_policycatch-upcatch-up or exact.

Where data and logs live​

Job definitions, run history, and logs are stored per project scope in the TF Code state directory:

~/.local/state/tfcode/scheduler/scopes/<scope-id>/
<job-id>.json # job/routine definition
<job-id>.runs.json # run history (status, timing, sessionID, errors)
<job-id>.log # combined job log
runs/<job-id>/<run-id>.out # per-run output

On macOS the path may differ depending on your XDG state directory. ~/.local/state is the default.

Each run record captures startedAt, finishedAt, status (running | succeeded | failed | crashed | skipped), an optional exitCode, the sessionID of the agent session that executed the run, any error, and — for routines — a noOp flag (the run produced ROUTINE_OK) and a skipReason (why a skipped run was skipped). Runs left in running after a crash are reconciled to crashed on the next start — they are never rescheduled.


App (Desktop UI)​

The web app has a Schedules panel in the session side panel (next to the file / changes tabs).

note

The Schedules panel only appears with live data when the scheduler plugin is enabled. If it's off, the panel shows a single Enable scheduler button instead of your jobs and routines — click it (or enable the plugin in config) to start scheduling. Jobs and routines are gated by the same one plugin.

Create a job or routine​

  1. Open the Schedules panel.
  2. Click New job.
  3. Fill in:
    • Name — a human-readable label.
    • Cron — the schedule, e.g. 0 9 * * 1-5.
    • Prompt — for a job, what the agent should do each run; for a routine, a short intent summary (the routine wraps it in its wake-and-decide prompt).
    • Agent — which agent to run (default build; matched case-insensitively).
    • Timeout (s) — how long the run may take before it's killed (0 = no timeout).
    • Missed-run policy — Catch up or Exact.
  4. Click Create.

Manage jobs​

Each job row shows its next run, last run, last status, and policy, with actions:

ActionDescription
Run nowTrigger the job immediately.
Pause / ResumeToggle whether the job is scheduled.
DeleteRemove the job and its OS scheduler unit.
(expand)Show the run history for the job.

If the scheduler plugin is disabled, the panel shows an Enable scheduler button instead — enable it to manage jobs and routines from the panel.


Server​

Any client attached to a running server — including a headless server — schedules work by asking the agent, which calls the scheduling tools server-side. There are no user-facing CLI commands for creating jobs; the agent manages them on your behalf. (Routines also have a headless scheduler tick command used internally by the OS scheduler — see How a routine fires.)

Ask the agent to schedule a job​

Schedule a job called "daily-dependency-dig" to run at 9am on weekdays
that updates the dependency report and opens a PR if there are changes.

The agent registers the work with the tools:

  • schedule_job — create a job (name, cron, prompt, agent, timeout_seconds, missed_run_policy). Runs the same prompt every tick. Optional once makes it a one-shot that self-deletes after a successful run; optional kind: "routine" creates a routine without the conversational checklist (use this to self-schedule a follow-up from inside an unattended run).
  • create_routine — create a routine (a wake-and-decide schedule). Confirms a checklist first; seeds the task file; supports task_file, gate, approval_boundary, missing_data_policy, expected_output, active_hours.
  • test_routine — test-run a routine, bypassing the decision gate.
  • list_jobs — list jobs and routines for the project.
  • get_job — show one job or routine.
  • update_job — change cron, prompt, agent, timeout, enabled, policy, or routine-specific fields.
  • delete_job — remove a job or routine.
  • run_job — run immediately (fire-and-forget; skips if already running).
  • job_logs — read the last N lines of a job's log.

Examples​

Schedule a job called "weekly-cleanup" with cron "0 6 * * 1" using the
"code" agent to delete temporary build artifacts and report disk usage.
Set a timeout of 600 seconds.
Create a routine called "pr-watch" that runs every 20 minutes. It reads
.tfcode/routines/pr-watch.md and reviews any PR listed there whose CI is
green. Don't merge anything — just leave a review. If the CI status API
is down, report it and stop.
List my scheduled jobs and routines, and show the run history of "pr-watch".
Test-run the "pr-watch" routine, then show me its recent logs.

Exact timing with no catch-up​

Schedule "publish-daily-cut" with cron "0 17 * * *" and use the
"exact" missed-run policy so it only runs at 17:00 exactly.

How a routine fires​

When a routine's cron time arrives, the OS scheduler runs:

tfcode scheduler tick <id> --workdir <dir> --scope <scope-id>

This headless command loads the routine, runs the decision gate (skipping with a recorded skipped run and no model call when there's nothing to do), or spawns the wake-and-decide prompt and records the result. It never throws — a tick failure is logged, never crashes the OS scheduler. You can run it yourself to trigger a routine wake outside its schedule:

tfcode scheduler tick <id> --workdir /path/to/project

Add --force to bypass the gate (a manual test run). This is the same command test_routine and the POST /scheduler/{id}/test endpoint use under the hood.


TUI​

The TUI exposes scheduling through the /schedules dialog and by asking the agent in-session.

note

Scheduling in the TUI needs the scheduler plugin enabled. If it's off, the /schedules dialog is empty and prompts an enable, and the agent has no scheduling/routine tools to call. Press e in the dialog (or enable the plugin in config) to turn it on. Jobs and routines share this one gate.

/schedules dialog​

Type /schedules and press Enter. The dialog lists jobs with their cron, policy, agent, next run, and status. Use the keys shown in the dialog footer:

KeyAction
SpacePause / resume the selected job
rRun the selected job now
dDelete the selected job
eEnable / disable the plugin

Ask the agent (same as the server)​

In the TUI prompt you can schedule, list, update, or delete jobs and routines with natural language, exactly as on a server:

Create a routine called "morning-standup-brief" to run at 8:30 on weekdays
that summarizes the last 24 hours of issues into .tfcode/routines/standup.md.
Only post the summary if I've reviewed it.
Pause "morning-standup-brief" until I say otherwise.

Tips & safety​

  • Enable the plugin first. Both jobs and routines come from the single scheduler builtin plugin, which is off by default. Nothing schedules until you enable it — the agent has no scheduling tools, the API returns errors, and the app/TUI panels show an enable prompt. Enable it in config ("builtin": { "scheduler": { "enabled": true } }), via POST /builtin/scheduler/enable, or with the Enable scheduler action in the app/TUI.
  • A failing schedule will not loop. Recovery never re-runs an occurrence that produced a run, so failures result in a recorded failed run — not a retry storm.
  • Routines are cheap when idle. A file-gated routine costs nothing until its task file changes; an always-gated routine costs one small wake per tick and returns ROUTINE_OK when there's nothing to do.
  • Don't automate before you verify. Run a workflow manually first; only make it a routine once it's reliable. Use test_routine to confirm before relying on the schedule.
  • Self-schedule follow-ups with once. Inside an unattended routine run, use schedule_job with once: true to wake exactly once at a future time and self-delete — never use it for recurring work.
  • Set an approval boundary. Anything that contacts people, publishes, deletes, or changes production should be behind the boundary so an unattended run reports instead of acting.
  • Prefer report-over-stale. A missing/stale-data policy of "report instead of using old data" turns a broken source into a clear REPORT rather than silent wrong action.
  • Error backoff protects you. A flaky source won't re-fire every tick — backoff kicks in after a few consecutive failures and resets on success.
  • Pause before deleting. Deleting removes the OS unit and job files; pausing just disables the schedule while keeping the definition.
  • Pick exact for time-sensitive actions (e.g. a cutover or publish) so an offline period doesn't trigger a late, surprising run.
  • Review logs before re-running. Use job_logs (agent) or the /scheduler/{id}/logs endpoint to see why a run failed or was skipped.
  • The lock prevents overlap. A job or routine won't start a second run if one is already active — the lock is cross-process, so two ticks firing at once can't double-run.