Imperal Docs
Core Concepts

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 decisionWeb-kernel schedulerExternal HTTP caller
When does it run?At the cron intervalWhen a user asksOn a per-user TTLWhen a POST arrives
Context typeSystem — no real userUser — ctx.user presentUser — ctx.user presentUser or system, depending on configuration
PurposeTime-driven background workUser-facing action (read / write / destructive)Ambient LLM context feedReact to external events
Appears in chat?NoYes — result surfaced to userYes — output injected into classifierNo
Access to ctx.user?No — system actor onlyYesYesContext-dependent
Fan-out required?Yes — must iterate users explicitlyNoNo — web-kernel calls per userNo

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.email is "". There is no real user.
  • ctx.tenant is None. 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 typeExample 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.schedule

Within @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 production

The full reference — signature, kwargs, cron format, ctx availability table, production examples, and common pitfalls — is in the @ext.schedule reference.


Cross-references

On this page