Scheduled tasks
Background jobs that run on a cron — when, why, and how the web-kernel hands them off
A scheduled task is a cron-driven background job your extension registers. The web-kernel fires it on a timer, completely outside the user's chat session. There is no message, no user typing, and no LLM routing decision involved. The handler just runs.
Use scheduled tasks for any work your extension needs to do regardless of whether any user is actively interacting with it — pre-warming data caches, purging stale records, polling external APIs, sending digest notifications, or aggregating metrics across all users.
How they differ from other extension primitives
Extension code runs in four fundamentally different modes. Understanding when each fires clarifies when to reach for a scheduled task.
@ext.schedule | @chat.function | @ext.skeleton | @ext.webhook | |
|---|---|---|---|---|
| Who triggers it? | Web-kernel timer (cron) | LLM routing decision | Web-kernel scheduler | External HTTP caller |
| When does it run? | At the cron interval | When a user asks | On a per-user TTL | When a POST arrives |
| Context type | System — no real user | User — ctx.user present | User — ctx.user present | User or system, depending on configuration |
| Purpose | Time-driven background work | User-facing action (read / write / destructive) | Ambient LLM context feed | React to external events |
| Appears in chat? | No | Yes — result surfaced to user | Yes — output injected into classifier | No |
Access to ctx.user? | No — system actor only | Yes | Yes | Context-dependent |
| Fan-out required? | Yes — must iterate users explicitly | No | No — web-kernel calls per user | No |
Scheduled task vs skeleton
Both run on a schedule. The difference is their output and scope:
- Skeleton: runs per-user, produces a small ambient snapshot injected into the classifier context. Every user gets their own probe. The LLM sees the result without being asked.
- Scheduled task: runs once across all users, executes background work (writes, external calls, notifications). It does not contribute anything to the classifier context.
A good mental split: skeletons feed the LLM's working memory; scheduled tasks do the extension's housekeeping.
Scheduled task vs webhook
Both run outside a user chat session. The difference is the trigger:
- Scheduled task: timer-driven — runs because time has passed.
- Webhook: event-driven — runs because an external system sent an HTTP request.
If you need to react to "something happened externally" (a new order, a CI build completing, a payment event), use @ext.webhook. If you need to act periodically regardless of external events, use @ext.schedule.
The system context model
Every scheduled handler receives a Context object, but it is a system context — not a per-user context. The key signal is ctx.user.imperal_id == "__system__".
This means:
ctx.store.query("collection")queries the system namespace. There is no user-scoped partition behind it.ctx.user.emailis"". There is no real user.ctx.tenantisNone. There is no single tenant.- Sending
ctx.notify(...)goes nowhere — the notification system requires a real user.
The practical consequence: scheduled handlers cannot operate on user data directly. They must explicitly discover users and switch into a user-scoped context for any per-user operation. This is not a limitation — it is the correct boundary. The handler is a system-level job, not a user-level tool.
Why not just call with every user in parallel?
The web-kernel does not automatically fan out across users because it does not know which collection is relevant to your job. You declare which collection to iterate over. This gives you control over the fan-out scope, lets you skip users who have not interacted with the relevant feature, and avoids wasted calls on inactive accounts.
Fan-out via list_users and as_user
The canonical scheduled task pattern has two steps.
Step 1 — iterate users. Call ctx.store.list_users(collection), where collection is the name of a store collection your extension owns. The method returns an async iterator that yields one user_id string per user who has at least one document in that collection. Users who never used the relevant feature are automatically excluded.
Step 2 — switch context. For each user ID, call ctx.as_user(user_id). This returns a new Context object scoped to that user — user_ctx.store, user_ctx.cache, user_ctx.notify, and all other clients operate in that user's namespace.
import logging
from imperal_sdk import Extension
log = logging.getLogger(__name__)
ext = Extension(
"my-app",
version="1.0.0",
display_name="My App",
description="My App — AI-powered tool for managing your resources.",
icon="icon.svg",
actions_explicit=True,
)
@ext.schedule("nightly_sweep", cron="0 3 * * *")
async def nightly_sweep(ctx) -> None:
"""Purge stale drafts for all users at 03:00 UTC."""
async for user_id in ctx.store.list_users("drafts"):
user_ctx = ctx.as_user(user_id)
try:
page = await user_ctx.store.query(
"drafts", where={"stale": True}, limit=500
)
for doc in page.data:
await user_ctx.store.delete("drafts", doc.id)
except Exception as exc:
log.warning("nightly_sweep: user %s failed: %s", user_id, exc)The try/except around each user's work is not optional. If one user's store operation raises — due to a transient error, a corrupt record, or a network blip — the exception must be caught and logged so that the remaining users are processed. Without isolation, one failing user silently aborts every user who appears after it in the iteration order.
Cron scheduling
The cron parameter is a standard 5-field Unix cron expression. The web-kernel interprets it in UTC.
┌───── minute (0–59)
│ ┌─── hour (0–23)
│ │ ┌─ day of month (1–31)
│ │ │ ┌ month (1–12)
│ │ │ │ ┌ day of week (0–7, where 0 and 7 = Sunday)
│ │ │ │ │
* * * * *Common schedules for typical extension jobs:
| Job type | Example cron |
|---|---|
| High-frequency pre-warmer (inbox cache) | "*/3 * * * *" — every 3 minutes |
| Hourly health probe | "0 * * * *" — on the hour |
| OAuth token refresh | "0 */6 * * *" — every 6 hours |
| Daily digest notification | "0 8 * * *" — 08:00 UTC |
| Weekly report | "0 9 * * 1" — Monday 09:00 UTC |
| Monthly cleanup | "0 2 1 * *" — 1st of month, 02:00 UTC |
Validate before deploying
A cron expression that fires every minute instead of every hour generates 60× the expected ICNLI Worker load. Use a cron expression tester (e.g. crontab.guru) before deploying high-interval schedules. Even a correct expression can surprise you — 0 * * * * fires at minute zero of every hour, not every minute.
Failure modes and retry semantics
The ICNLI Worker executes scheduled handlers as activities. The web-kernel may retry a handler that raises an unhandled exception. This has two implications:
Idempotency is required. Design handlers so that running them twice produces the same outcome as running them once. The timestamp-guard pattern is the most reliable approach: store a last_run_at field on each processed record and skip it if the elapsed time is below the scheduled interval.
Long-running handlers may be interrupted. If a worker restarts mid-job, the handler may resume from the beginning. Handlers that process large numbers of users should checkpoint progress where possible, or be designed so that re-processing already-handled users is harmless.
One user's failure should not block others. Wrap per-user code in try/except. Log warnings for individual failures. Only propagate an exception to the web-kernel if the entire job is irrecoverably broken — in which case the web-kernel will log it and retry.
Dead jobs
A scheduled job that consistently raises unhandled exceptions is logged to the extension dashboard with the failure reason. The web-kernel continues firing the schedule — it does not automatically disable it. Monitor the extension dashboard for repeated failures after deploying a new scheduled handler.
When to use each trigger type
The decision tree for picking a trigger:
User wants something done right now?
→ Use @chat.function
LLM needs ambient facts about the user's state on every turn?
→ Use @ext.skeleton
An external service is pushing an event via HTTP?
→ Use @ext.webhook
Work needs to happen automatically on a timer?
→ Use @ext.scheduleWithin @ext.schedule, consider frequency and scope:
- High-frequency (< 5 min), few users, small payloads — suitable for cache pre-warming (mail-client inbox warmup at 3 min).
- Hourly, many users, external I/O — suitable for monitor runners, token refreshes. Add a concurrency semaphore (
asyncio.Semaphore) to cap parallel user processing. - Daily or weekly, heavy computation — suitable for digest emails, aggregation jobs. Expect > 1 minute of wall-clock time; design for interruption tolerance.
Do not use @ext.schedule to approximate real-time event handling. A cron schedule with a 1-minute interval is not a substitute for @ext.webhook or @ext.on_event for event-driven work.
Federal invariants
Scheduled handlers operate under the same audit framework as all other extension entry points. The following points apply:
Audit ledger. Actions taken inside scheduled handlers via user_ctx.store.create/update/delete are logged in the platform's action ledger under the system actor (action_type="system_schedule"). No additional annotation is needed from the handler.
No KAV confirmation gate. The KAV (Web-kernel Action Validation) confirmation gate only applies to user-facing write operations (@chat.function with action_type="write" or "destructive"). Scheduled handlers write directly without a confirmation step — they are pre-authorized system operations.
Extensions isolation. If a scheduled handler calls ctx.extensions.emit(event_type, data) or ctx.extensions.call(app_id, method), the call is subject to the same extension isolation rules as any other cross-extension call. Circular calls raise CircularCallError.
Data isolation. After switching to user_ctx = ctx.as_user(uid), all store and cache operations are strictly scoped to that user. The web-kernel enforces this boundary — the system context cannot read or write another user's data without going through as_user.
Registering a scheduled task
Decorate an async function with @ext.schedule(name, cron) on the Extension instance. The function is stored in ext._schedules and emitted in the manifest under schedules[].
from imperal_sdk import Extension
ext = Extension(
"my-app",
version="1.0.0",
display_name="My App",
description="My App — AI-powered tool for managing your resources.",
icon="icon.svg",
actions_explicit=True,
)
@ext.schedule("hourly_sync", cron="0 * * * *")
async def hourly_sync(ctx) -> None:
"""Sync external data for all users once per hour."""
async for user_id in ctx.store.list_users("sync_accounts"):
user_ctx = ctx.as_user(user_id)
try:
resp = await user_ctx.http.get(
"https://api.example.com/v1/data",
headers={"Authorization": f"Bearer {user_ctx.config.require('api_key')}"},
)
if resp.ok:
await user_ctx.store.update("sync_state", "current", {
"last_synced": resp.json().get("timestamp", ""),
})
except Exception:
pass # silent — log in productionThe full reference — signature, kwargs, cron format, ctx availability table, production examples, and common pitfalls — is in the @ext.schedule reference.
Cross-references
@ext.schedule reference
Complete reference: signature, cron format, ctx availability table, fan-out pattern, idempotency, and production examples.
Skeletons concept
Per-user ambient probes — the contrast to scheduled tasks for surfacing live state to the LLM.
@ext.skeleton reference
Reference for the @ext.skeleton decorator — section naming, TTL, alert mode, return contract.
@chat.function reference
User-triggered actions — the synchronous counterpart to scheduled tasks.