Skip to content

Scheduling and Conditional Jobs

Daniel Ellison edited this page Feb 17, 2026 · 5 revisions

Scheduling and Conditional Jobs

Kai can schedule reminders and recurring tasks through natural language or the HTTP API. This page covers the scheduling system in depth with practical examples.

How it works

When you ask Kai to schedule something (e.g., "remind me to check the laundry at 3pm"), the inner Claude Code instance calls the scheduling API via curl. The job is stored in SQLite and registered with APScheduler, which fires at the scheduled time.

There are two job types with different execution models:

Reminders

A reminder simply delivers a message to Telegram at the scheduled time. The prompt field is the message text — it's sent as-is without going through Claude.

Good for: time-based notifications, deadlines, simple alerts.

"remind me to take a break at 3pm"
"remind me every 2 hours to drink water"
"remind me tomorrow at 9am about the standup"

Claude jobs

A Claude job runs the prompt through a fresh Claude session at the scheduled time. Claude processes the prompt with full tool access (shell, files, web search) and the response is sent to Telegram.

Good for: tasks that require reasoning, checking external state, generating summaries.

"every morning at 8am, give me a weather summary"
"every day at 5pm, summarize my git activity for today"
"check every hour if the deploy pipeline has finished"

Schedule types

Type schedule_data Example
once {"run_at": "ISO8601 datetime"} Fire once at a specific time
daily {"times": ["HH:MM", ...]} (UTC) Fire every day at the given time(s)
interval {"seconds": N} Fire every N seconds

Time zones

The daily schedule type uses UTC. If you're in EST (UTC-5) and want a 9am reminder, set the time to "14:00". When asking Kai in natural language, it should handle the conversion if it knows your timezone (make sure it's in memory).

The once schedule type accepts a full ISO8601 datetime with timezone offset, e.g., "2026-02-10T09:00:00-05:00".

Conditional jobs (auto-remove)

Conditional jobs are Claude jobs with auto_remove: true. They're designed for monitoring — checking a condition repeatedly until it's met, then stopping.

The protocol

When a conditional job fires, Claude processes the prompt and is expected to respond with one of two markers:

  • CONDITION_MET: <message> — The condition is satisfied. The message after the marker is sent to Telegram, and the job is deactivated.
  • CONDITION_NOT_MET — The condition is not yet satisfied. Nothing is sent to Telegram (unless notify_on_check is enabled — see below). The job continues firing on schedule.

Practical examples

Watch for a PR to be merged:

"check every 30 minutes if PR #42 on dcellison/kai has been merged"

Claude will use gh pr view 42 each time, respond with CONDITION_NOT_MET while it's open, and CONDITION_MET: PR #42 has been merged! once it's done.

Wait for a deploy to finish:

"check every 5 minutes if the latest GitHub Actions run on main has completed"

Monitor a website:

"check every hour if example.com/status returns 200"

Progress updates with notify_on_check

By default, CONDITION_NOT_MET is silent — the user hears nothing until the condition is met. Setting notify_on_check: true changes this: any message after CONDITION_NOT_MET: is delivered to the user while the job continues running.

This turns a silent sentinel into a heartbeat that gives progress updates on every check.

Example: A job tracking a package delivery. With notify_on_check: false (default), you only hear when the package arrives. With notify_on_check: true, you get status updates on every check ("Still in transit, currently in Memphis") until it arrives.

"check every hour if my package has been delivered — give me a status update each time"

When using notify_on_check, include a status update after the marker:

  • CONDITION_NOT_MET: Still in transit, expected delivery tomorrow — delivers the message, job continues
  • CONDITION_MET: Package delivered at 2:15pm! — delivers the message, job deactivates

Lifecycle

  1. Job is created with auto_remove: true (and optionally notify_on_check: true)
  2. APScheduler fires at each interval
  3. Claude processes the prompt with full tool access
  4. If CONDITION_MET: message is delivered, job is deactivated
  5. If CONDITION_NOT_MET: nothing happens (or message is delivered if notify_on_check is enabled), job fires again next interval
  6. If Claude responds without either marker: the full response is sent to Telegram (treated as a regular Claude job)

HTTP API reference

Create a job

POST /api/schedule
Header: X-Webhook-Secret: <your secret>
Content-Type: application/json
Field Required Default Description
name Yes Human-readable job name
prompt Yes Message text (reminders) or Claude prompt (Claude jobs)
schedule_type Yes once, daily, or interval
schedule_data Yes Schedule parameters (see table above)
job_type No reminder reminder or claude
auto_remove No false Deactivate when condition met (Claude jobs only)
notify_on_check No false Send CONDITION_NOT_MET messages to user (auto-remove Claude jobs only)

Response:

{"job_id": 1, "name": "daily standup"}

List active jobs

GET /api/jobs
Header: X-Webhook-Secret: <your secret>

Returns an array of all active jobs for the chat.

Get a single job

GET /api/jobs/{id}
Header: X-Webhook-Secret: <your secret>

Returns the full job object, or 404 if not found.

Update a job

PATCH /api/jobs/{id}
Header: X-Webhook-Secret: <your secret>
Header: Content-Type: application/json
Body: { "name": "new name", "schedule_data": {"seconds": 7200}, ... }

Updates any combination of mutable fields: name, prompt, schedule_type, schedule_data, auto_remove, notify_on_check. If the schedule changes (type or data), the job is automatically re-registered with APScheduler to pick up the new timing.

Returns {"updated": <id>} on success, or 404 if the job doesn't exist or is inactive.

Delete a job

DELETE /api/jobs/{id}
Header: X-Webhook-Secret: <your secret>

Permanently removes the job from both the database and APScheduler's in-memory queue. Returns {"deleted": <id>} on success, or 404 if not found.

This is equivalent to the /canceljob Telegram command — both delete the job permanently.

Managing jobs via Telegram

  • /jobs — list all active jobs with their schedules
  • /canceljob <id> — cancel and remove a job (same as DELETE /api/jobs/{id})

Security

The scheduling API listens on localhost:8080 and is not exposed through the Cloudflare Tunnel. The tunnel config only routes /webhook/* and /health — requests to /api/* from the internet receive a 404 at the tunnel level.

Authentication uses the same WEBHOOK_SECRET as the webhook endpoints, passed via the X-Webhook-Secret header. This means the inner Claude Code instance (which knows the secret) can create jobs, but external services cannot.

Clone this wiki locally