Schedule future work
protoAgent ships a scheduler so the agent can defer tasks to itself — "remind me about X tomorrow", "every Monday morning summarize last week's logs", "at 3pm check the deploy". The bundled LocalScheduler (sqlite + asyncio) is the one backend.
When to read this
- You want forks (or your own multiple agents) to support reminders, recurring sweeps, or any "do this later" intent.
- You're spinning up multiple protoAgent instances on one box and need scheduling state to stay isolated per agent.
The three tools
When the scheduler is active, three tools land in get_all_tools():
| Tool | What it does |
|---|---|
schedule_task(prompt, when, job_id?) | Persist a future invocation. when is cron ("0 9 * * *") or ISO-8601 ("2026-05-01T15:00:00"). |
list_schedules() | Show all jobs visible to this agent. |
cancel_schedule(job_id) | Remove a job by id. |
Prompts are self-contained — the agent has no memory of the scheduling moment when the task fires, so write the prompt as a fresh turn ("review last week's pipeline incidents and post a summary", not "do that thing we discussed").
Enabling / disabling
server/agent_init.py::_build_scheduler builds the bundled LocalScheduler (sqlite, asyncio polling) at startup unless scheduling is turned off:
middleware.scheduler: falsein YAML → no scheduler. The three tools don't ship. (Symmetric withmiddleware.knowledge/middleware.memory— drawer/wizard editable, survives restarts.) This is the canonical opt-out.SCHEDULER_DISABLED=1env → no scheduler. Runtime escape hatch for fleet operators who can't edit config in the moment.
The scheduler is default on — opt out via either path above for a stateless agent.
python -m server # LocalScheduler on by defaultManage from the console
The agent schedules jobs via its tools, but operators can also view and manage them directly from the React console's Schedule surface — list current jobs, open one for the full prompt + details, create, edit in place, and cancel. It's backed by these operator-API endpoints:
| Method | Path | Purpose |
|---|---|---|
GET | /api/scheduler/jobs | List jobs ({jobs, backend}) |
POST | /api/scheduler/jobs | Create — {prompt, schedule, job_id?, timezone?} → {job} |
PUT | /api/scheduler/jobs/{id} | Edit in place — {prompt, schedule, timezone?} → {job} (id/created_at/last_fire preserved, next_fire recomputed) |
DELETE | /api/scheduler/jobs/{id} | Cancel → {canceled} |
A malformed schedule returns 400 and leaves the job untouched.
Plugin-owned recurring jobs
A plugin arms its own cadence through the consumption SDK (#1642) rather than asking the operator to wire a cron job:
from graph import sdk
def register(registry):
sdk.schedule_recurring(
"Run the strategist OODA tick.", "0 9 * * *",
plugin_id=registry.plugin_id, job_id="strategist-tick",
)sdk.schedule_recurring(prompt, cron, *, plugin_id, job_id, session="", timezone=None)— a recurring cron cadence (one-shot turns stay onsdk.run_in_session; an ISO datetime is rejected here). Fires into the Activity thread by default; passsessionto target a chat context. Idempotent by id — re-calling with the samejob_idreplaces the pending job, soregister()can re-arm on every (re)load and a cadence knob change just re-schedules.sdk.cancel_scheduled(job_id, *, plugin_id)/sdk.cancel_plugin_jobs(plugin_id)— remove one cadence / all of them.
The job id is namespaced plugin:<plugin_id>:<job_id> — that ownership tag is what lets the host clean up: disabling a plugin sweeps its plugin:<id>:* jobs on the reload, and uninstalling sweeps them in the same pass that removes the code — no orphan job keeps firing prompts about a plugin that's gone. Re-enabling relies on the plugin re-arming in register() (which is why the idempotent-replace shape matters). The AGENT_NAME scoping below is untouched — plugin ownership rides on the id within an instance's jobs.db; it never crosses instances.
Multi-agent isolation
Every job is namespaced by AGENT_NAME so spinning up gina-personal alongside gina-work on the same box doesn't cross-fire prompts: the DB path is per agent (/sandbox/scheduler/<agent_name>/jobs.db, falling back to ~/.protoagent/scheduler/<agent_name>/jobs.db), every row also carries agent_name, and all reads/writes filter on it.
If you supply your own job_id in schedule_task, the id is stored as-is. Two agents sharing one DB path with the same user-supplied id will trip a primary-key collision (the second add raises a clear error). To avoid it, let the scheduler auto-generate (the auto-id is <agent>-<uuid>).
How firing works
The scheduler runs an asyncio polling task on FastAPI's startup event. Once a second:
- Read jobs where
next_fire <= now()andenabled = 1(skipping any still firing — a slow turn won't be re-claimed mid-flight). - For each due job: POST to
http://127.0.0.1:<active_port>/a2aas amessage/sendwith the job's prompt as the message text, routed into the durable Activity thread (contextId: system:activity,metadata.origin: scheduler). Bearer + X-API-Key are forwarded automatically. - One-shot ISO jobs are deleted after firing. Cron jobs reschedule forward via
croniter(advanced the instant they're claimed, so a long turn never double-fires).
Going through HTTP rather than calling into the graph directly buys parity with real callers — the audit log, cost-v1 capture, and push-notification path all behave identically.
Where the response lands. The fired turn runs in the Activity thread (ADR 0003), so its output persists and shows up live in the console's Activity surface (pushed over /api/events as an activity.message). Before ADR 0003 a fired prompt minted a throwaway context and its answer was evicted unseen.
Missed-fire recovery
On startup, jobs whose next_fire is in the past are inspected:
- Within the last 24h — fire on the next tick (so a 5-minute outage doesn't lose an upcoming reminder).
- Older than 24h — cron jobs roll forward to the next slot without firing; one-shot jobs are dropped. Avoids flooding the agent with stale prompts after a long downtime.
Persistence path
# Default (Docker)
/sandbox/scheduler/<agent_name>/jobs.db
# Local fallback (when /sandbox isn't writable)
~/.protoagent/scheduler/<agent_name>/jobs.db
# Override
export SCHEDULER_DB_DIR=/var/data/agents
# → /var/data/agents/<agent_name>/jobs.dbMount a volume at the configured path to survive container restarts (analogous to audit/ and knowledge/).
Adding a case to your eval suite
The default evals/tasks.json doesn't include scheduler cases (the fire path is async — a single eval run can't easily test that the scheduled prompt arrives). For forks that want it, the pattern is:
schedule_task(prompt, "<near-future ISO>")in setup.- Wait > 1 second.
- Assert on the audit log and/or KB state for the fired prompt's side effects.
Document the case as category: "scheduler" and gate at >= 2/3 attempts to absorb timing jitter.
References
- Configuration — env vars
- Eval your fork — for the testing pattern above