Imperal Docs
Core Concepts

Cache vs store

Cache vs store — when to use ctx.cache (TTL, ephemeral) versus ctx.store (durable, queryable) in an extension, plus how to migrate state between the two layers.

Looking for the LLM context layer?

This page covers ctx.cache and ctx.store as extension-owned data surfaces — they are where your code reads and writes state. They are NOT the LLM's context channels. For how data reaches the LLM (skeleton, fact-ledger, classifier prompt), see Context channels. ctx.cache is the only surface on this page that crosses over — it can be used to back panel snapshots that handlers later expose to the LLM.

Every extension gets two persistence surfaces from the web-kernel: ctx.cache and ctx.store. They are not interchangeable — each has a distinct lifetime, typing contract, and use case. Choosing the wrong one produces bugs that are subtle to diagnose (data that vanishes unexpectedly, bloated stores, unnecessary complexity). This page explains the differences, when to use each, and how to migrate between them.

There is no relational database surface

You may see a relational/SQL-style database surface referenced in older material. No such surface exists on the live runtime path. Do not build on it. For anything beyond key lookups, use ctx.store (filtered queries, sort, count) and reach for a panel-side ctx.cache snapshot for expensive recomputation. If your data genuinely needs relational queries the store cannot express, file a ticket rather than reaching for a database client that is not part of the surface.


The two persistence layers

ctx.cachectx.store
LifetimeTTL-bounded (5–300 seconds)Permanent until deleted
Type contractPydantic BaseModel onlyFree-form JSON (dict)
ScopingPer-user, per-extension, per-keyPer-user, per-extension, per-collection
Query modelKey lookup onlyCollection + where filter
RegistrationRequires @ext.cache_modelNone
Max value size64 KB per entryNo enforced cap
Available inAll tool/panel/skeleton contextsAll tool/panel/skeleton contexts
Use it forShort-lived computed data, expensive fetches, panel-side view stateExtension state, user documents, credential storage, configuration

If you are not sure which layer to reach for, start with ctx.store. ctx.cache is an optimization added when store round-trips are measurably too slow or too frequent for a hot code path.


ctx.cache

Purpose

ctx.cache is a short-lived, Pydantic-typed key-value store that lives entirely within a single user's session. The web-kernel routes it through the Auth Gateway to a per-extension Redis namespace and enforces a strict TTL — entries cannot persist beyond 300 seconds. It is intentionally not a primary data store.

Use ctx.cache when:

  • You have computed a value that is expensive to recompute (an API response, an aggregated view), and you want to reuse it across multiple panel renders within the same user interaction.
  • Your skeleton tool precomputes data for a panel that will need the same data immediately after (write once, read in panel handler within the next few minutes).
  • You want a typed read-through pattern with automatic fallback to a live fetch.

Do not use ctx.cache when the data must outlive the current user session, or when it needs to be visible to a different user.

API

from imperal_sdk import Extension
from pydantic import BaseModel

ext = Extension(
    "my-app",
    display_name="My App",
    description="My App extension — manage resources with AI assistance.",
    icon="icon.svg",
    actions_explicit=True,
)


class ResourceSummary(BaseModel):
    total: int
    active: int
    last_id: str


ext.cache_model("resource_summary")(ResourceSummary)


@ext.tool("get_resource_summary", description="Return resource summary for the current user.")
async def get_resource_summary(ctx) -> dict:
    cached = await ctx.cache.get("summary", ResourceSummary)
    if cached is not None:
        return {"status": "success", "data": cached.model_dump()}

    # Expensive operation — fetch live, then cache for 90 seconds
    total  = await ctx.store.count("resources")
    active = await ctx.store.count("resources", where={"is_active": True})
    page   = await ctx.store.query("resources", order_by="-updated_at", limit=1)
    last_id = page.data[0].id if page.data else ""

    summary = ResourceSummary(total=total, active=active, last_id=last_id)
    await ctx.cache.set("summary", summary, ttl_seconds=90)
    return {"status": "success", "data": summary.model_dump()}

get_or_fetch — the preferred read-through pattern

When a panel needs data that may or may not be cached, use get_or_fetch instead of a manual get + set:

from imperal_sdk import Extension
from pydantic import BaseModel

ext = Extension(
    "my-app",
    display_name="My App",
    description="My App extension — manage resources with AI assistance.",
    icon="icon.svg",
    actions_explicit=True,
)


class DashboardView(BaseModel):
    spend_today: float
    clicks: int
    active_campaigns: int


ext.cache_model("dashboard_view")(DashboardView)


async def _fetch_dashboard(ctx) -> DashboardView:
    total  = await ctx.store.count("campaigns", where={"status": "active"})
    return DashboardView(spend_today=0.0, clicks=0, active_campaigns=total)


@ext.panel("dashboard")
async def panel_dashboard(ctx):
    data = await ctx.cache.get_or_fetch(
        "dashboard",
        DashboardView,
        fetcher=lambda: _fetch_dashboard(ctx),
        ttl_seconds=120,
    )
    # render data ...
    return {}

If the cache holds a valid entry, get_or_fetch returns it immediately. If not, it calls the fetcher, caches the result, and returns it — all in one call.

TTL semantics

TTL is expressed in integer seconds, clamped to [5, 300]. Values outside this range raise a ValueError at the SDK boundary before any network call. There is no way to store a cache entry permanently — that is intentional. If you find yourself wanting a longer TTL, the data belongs in ctx.store.

Key constraints

Cache keys must match [A-Za-z0-9_\-:] and must not exceed 128 characters. Namespacing with colons is common and encouraged:

# Good — stable, unambiguous, collision-free
email_slug: str = "user_at_example_com"
folder_slug: str = "INBOX"
key = f"inbox:{email_slug}:{folder_slug}"
key = "dashboard"
key = "summary:v2"

# Bad — spaces, slashes, and special characters are rejected
key = "my key"       # ValueError at SDK boundary
key = "a/b/c"        # ValueError at SDK boundary

Model registration

Every Pydantic class passed to ctx.cache.get or ctx.cache.set must first be registered with @ext.cache_model. Registration ties the class name to a stable string that appears in the Redis key and the cache envelope. If you call ctx.cache.get with an unregistered class, the SDK raises ValueError before any network request is made.

Register all cache models at module load time (before any handlers are called):

from imperal_sdk import Extension
from pydantic import BaseModel

ext = Extension(
    "my-app",
    display_name="My App",
    description="My App extension — manage resources with AI assistance.",
    icon="icon.svg",
    actions_explicit=True,
)


class InboxPage(BaseModel):
    messages: list[dict]
    total: int


class UnreadSummary(BaseModel):
    count: int
    newest_from: str


# Register at module level — not inside a handler or function
ext.cache_model("inbox_page")(InboxPage)
ext.cache_model("unread_summary")(UnreadSummary)

Per-user scoping

ctx.cache is scoped to the current ctx.user.imperal_id and the current extension's app_id. Two users with the same key string are reading from completely separate namespaces. There is no cross-user cache access; the web-kernel enforces this at the HTTP layer.

Guarantees the platform enforces

GuaranteeWhat you see
TTL is cappedA ttl_seconds outside [5, 300] raises ValueError at the SDK boundary, before any network call
Pydantic values onlyset rejects non-BaseModel values; get rejects non-BaseModel classes
Models must be registeredPassing an unregistered model class raises ValueError before any network call
Value size is cappedA serialized entry exceeding 64 KB raises ValueError
Keys are validatedThe key character set and length are enforced before any network call
Cache requires a live contextThe cache is only available inside a normal handler context; using it from a bare mock context raises RuntimeError

ctx.store

Purpose

ctx.store is the primary persistence layer for extension data. Documents in the store are permanent — they are not evicted and do not expire. Think of it as a managed document database: each extension has its own partition, each user has their own namespace within that partition, and collections are the organizational unit.

Use ctx.store when:

  • Data must survive beyond the current user session (credentials, contacts, saved configurations, user-created documents).
  • You need to query by filter (where={"status": "active"}), sort by field, or count documents.
  • You need to fan out across all users from a system context (ctx.store.list_users).
  • The data is not just derived from other sources — it is the source of truth itself.

API overview

ctx.store exposes a small, consistent set of operations:

MethodUse
create(collection, data)Insert a new document; returns Document with web-kernel-assigned id
get(collection, doc_id)Fetch one document by ID; returns `Document
query(collection, where=..., order_by=..., limit=...)Filtered collection scan; returns Page[Document]
update(collection, doc_id, data)Replace document data by ID; returns updated Document
delete(collection, doc_id)Remove document by ID; returns bool
count(collection, where=...)Count documents matching a filter
set(key, data)Upsert shortcut for "collection/doc_id" paths

Collection naming

Collection names must not contain : * ? [ ] whitespace /. Use underscores or hyphens to namespace by feature area. Names should be stable — once data is written to a collection, renaming it requires a migration.

# Good
"contacts"
"mail_accounts"
"wt_monitors"
"wt_scan_results"

# Bad — forbidden characters
"mail accounts"     # whitespace rejected
"monitors/sites"    # slash rejected

Common patterns

Create or update (upsert)

from imperal_sdk import Extension

ext = Extension(
    "my-app",
    display_name="My App",
    description="My App extension — manage resources with AI assistance.",
    icon="icon.svg",
    actions_explicit=True,
)


@ext.tool("save_contact", description="Save or update a contact.")
async def save_contact(ctx, email: str, name: str) -> dict:
    existing = await ctx.store.query("contacts", where={"email": email})
    if existing.data:
        doc = await ctx.store.update("contacts", existing.data[0].id, {"email": email, "name": name})
    else:
        doc = await ctx.store.create("contacts", {"email": email, "name": name})
    return {"status": "success", "id": doc.id}

Querying and filtering

from imperal_sdk import Extension

ext = Extension(
    "my-app",
    display_name="My App",
    description="My App extension — manage resources with AI assistance.",
    icon="icon.svg",
    actions_explicit=True,
)


@ext.tool("list_active_monitors", description="List all active monitors.")
async def list_active_monitors(ctx) -> dict:
    page = await ctx.store.query(
        "wt_monitors",
        where={"owner_id": ctx.user.imperal_id, "is_active": True},
        order_by="-updated_at",
        limit=50,
    )
    return {
        "status": "success",
        "items": [{"id": d.id, **d.data} for d in page.data],
    }

Fan-out from a scheduled handler

System-context handlers (e.g. @ext.schedule) cannot see user-owned documents directly. They must use list_users to discover which users have data, then switch into a per-user context via ctx.as_user:

from imperal_sdk import Extension

ext = Extension(
    "my-app",
    display_name="My App",
    description="My App extension — manage resources with AI assistance.",
    icon="icon.svg",
    actions_explicit=True,
)


@ext.schedule("daily_digest", cron="0 8 * * *")
async def send_daily_digest(ctx) -> None:
    async for user_id in ctx.store.list_users("contacts"):
        user_ctx = ctx.as_user(user_id)
        docs = await user_ctx.store.query("contacts")
        if docs.data:
            await user_ctx.notify(f"You have {len(docs.data)} contacts.")

See Scheduled tasks for the full system context pattern.

Per-user scoping

Store documents are scoped by the web-kernel to the current ctx.user.imperal_id and the current extension's app_id. A query("contacts") call from user A will never return documents that belong to user B. The platform enforces this at the Auth Gateway — extension code cannot access another user's store partition by any normal means.

list_users and query_all are system-context-only methods that deliberately cross this boundary under controlled conditions. Calling them from a non-system context raises RuntimeError.


Decision tree

Work through these questions in order:

1. Does the data need to survive beyond the next few minutes?

  • No → use ctx.cache with an appropriate TTL. Skip the remaining questions.
  • Yes → continue.

2. Is the value a derived view — computed from other data rather than a source of truth?

  • Yes, and it needs to be recomputed regularly → still consider ctx.cache if the TTL matches how stale the derived value can reasonably be.
  • No, this is primary state (credentials, user-created documents, extension configuration) → use ctx.store.

3. Can ctx.store.query with where= express your query?

  • Yes → use ctx.store.
  • No (you need joins, aggregates, range predicates, or cross-collection analytics) → the store cannot express it today, and there is no relational database surface on the live path. File a ticket or restructure the data so the store's where= filter can express it.

Common mistakes

Using cache for permanent data

async def _wrong_cache_for_credentials(ctx, creds) -> None:
    # WRONG — TTL expires, data vanishes
    await ctx.cache.set("user_credentials", creds, ttl_seconds=300)  # type: ignore[arg-type]


async def _right_store_for_credentials(ctx, creds) -> None:
    # RIGHT — credentials are permanent user state
    await ctx.store.create("credentials", creds.model_dump())

If a user's credentials disappear after five minutes and they have to reconnect, this is almost always a cache-as-store mistake.

Using store for short-lived computed values in hot paths

async def _wrong_store_for_ephemeral(ctx, snapshot_data, snapshot) -> None:
    # WRONG — creates and deletes documents on every panel render
    doc = await ctx.store.create("inbox_snapshot", snapshot_data)
    # ... use doc ...
    await ctx.store.delete("inbox_snapshot", doc.id)


async def _right_cache_for_ephemeral(ctx, snapshot) -> None:
    # RIGHT — cache is built for this
    await ctx.cache.set("inbox_snapshot", snapshot, ttl_seconds=60)  # type: ignore[arg-type]

Using the store for ephemeral computed state pollutes the collection, generates unnecessary writes, and leaves orphaned documents if the delete step is skipped on error.

Storing unregistered models in cache

from imperal_sdk import Extension
from pydantic import BaseModel

ext = Extension(
    "my-app",
    display_name="My App",
    description="My App extension — manage resources with AI assistance.",
    icon="icon.svg",
    actions_explicit=True,
)


class InboxView(BaseModel):
    messages: list[dict]


# WRONG — InboxView is not registered, raises ValueError before network
async def _wrong_unregistered(ctx) -> None:
    await ctx.cache.set("inbox", InboxView(messages=[]), ttl_seconds=60)  # type: ignore[arg-type]


# RIGHT — register at module level before any handler runs
ext.cache_model("inbox_view")(InboxView)


async def _right_registered(ctx) -> None:
    await ctx.cache.set("inbox", InboxView(messages=[]), ttl_seconds=60)  # type: ignore[arg-type]

Migration patterns

Promoting cache → store

When you realize a cached value is actually primary state (not a derived view), migrate it by writing to the store on the next cache miss and reading from the store thereafter. The cache can act as a read-through layer during a transition period.

from imperal_sdk import Extension
from pydantic import BaseModel

ext = Extension(
    "my-app",
    display_name="My App",
    description="My App extension — manage resources with AI assistance.",
    icon="icon.svg",
    actions_explicit=True,
)


class ConnectionState(BaseModel):
    account_id: str
    connected: bool


ext.cache_model("connection_state")(ConnectionState)


async def get_connection(ctx) -> ConnectionState:
    # Check store first (primary source of truth after migration)
    doc = await ctx.store.get("connections", "primary")
    if doc is not None:
        return ConnectionState(**doc.data)

    # Fallback to cache during migration window
    cached = await ctx.cache.get("connection_state", ConnectionState)
    if cached is not None:
        # Promote to store so future reads skip the cache
        await ctx.store.create("connections", {"doc_id": "primary", **cached.model_dump()})
        return cached

    return ConnectionState(account_id="", connected=False)

Once all users have been migrated (their state has been written to the store at least once), remove the cache fallback path.

Demoting store → cache

When a store collection is being used for short-lived view state that is actually derived from other data, demote it to the cache. Delete the documents on the next read-path to clean up existing records.

from imperal_sdk import Extension
from pydantic import BaseModel

ext = Extension(
    "my-app",
    display_name="My App",
    description="My App extension — manage resources with AI assistance.",
    icon="icon.svg",
    actions_explicit=True,
)


class InboxSnapshot(BaseModel):
    message_ids: list[str]
    unread: int


ext.cache_model("inbox_snapshot")(InboxSnapshot)


async def get_inbox_snapshot(ctx) -> InboxSnapshot:
    # Try cache first (new path)
    cached = await ctx.cache.get("inbox_snapshot", InboxSnapshot)
    if cached is not None:
        return cached

    # Migrate: clean up legacy store document if it exists
    old = await ctx.store.get("inbox_snapshots", "latest")
    if old is not None:
        await ctx.store.delete("inbox_snapshots", old.id)

    # Build from live data and cache it
    snapshot = InboxSnapshot(message_ids=[], unread=0)  # simplified
    await ctx.cache.set("inbox_snapshot", snapshot, ttl_seconds=90)
    return snapshot

Guarantees the platform enforces

LayerWhat the platform guarantees
ctx.cacheTTL is capped at 300 seconds — the cache cannot masquerade as permanent storage
ctx.cacheOnly Pydantic BaseModel instances are accepted — no raw dicts or primitives
ctx.cacheA model must be declared via @ext.cache_model before use
ctx.cacheEach serialized entry is capped at 64 KB
ctx.cacheKeys are restricted to [A-Za-z0-9_\-:] and at most 128 characters
ctx.storeAttempts to bypass per-user scoping are detected and flagged as a security event — they never succeed silently
ctx.storectx.as_user() requires a system context; it raises RuntimeError for non-system callers
ctx.storectx.as_user() changes only the acting user's identity — the extension, tenant, and agency are preserved

Production examples

The following production extensions illustrate the layering pattern:

mail-client (mail-client/skeleton.py, mail-client/panels.py) — skeleton precomputes inbox page data and writes it to ctx.cache (InboxPage / UnreadSummary / InboxMessages models, TTL 90–120 seconds). Panel handlers read from cache via get_or_fetch. Persistent data (mail account credentials, contacts, last-read watermarks) lives in ctx.store collections (mail_accounts, contacts, mail_last_read). The cache is the panel's view layer; the store is the source of truth.

microsoft-ads (microsoft-ads/skeleton.py, microsoft-ads/panels.py) — skeleton_refresh_msads fetches live API data and writes a MsadsDashboard Pydantic model to ctx.cache with TTL 300 seconds. The left panel calls ctx.cache.get_or_fetch("dashboard", ...) — on a hit it renders immediately; on a miss it fetches live, caches the result, and returns it. OAuth credentials and account configuration live in ctx.store.

web-tools (web-tools/handlers_quick.py, web-tools/skeleton.py) — monitors, groups, snapshots, and scan results all live in ctx.store (wt_monitors, wt_snapshots, wt_scan_results, wt_ip_scan_results). The extension does not use ctx.cache because the data is primary state that must survive between sessions and must be queryable by filter.


Cross-references

On this page