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.cache | ctx.store | |
|---|---|---|
| Lifetime | TTL-bounded (5–300 seconds) | Permanent until deleted |
| Type contract | Pydantic BaseModel only | Free-form JSON (dict) |
| Scoping | Per-user, per-extension, per-key | Per-user, per-extension, per-collection |
| Query model | Key lookup only | Collection + where filter |
| Registration | Requires @ext.cache_model | None |
| Max value size | 64 KB per entry | No enforced cap |
| Available in | All tool/panel/skeleton contexts | All tool/panel/skeleton contexts |
| Use it for | Short-lived computed data, expensive fetches, panel-side view state | Extension 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 boundaryModel 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
| Guarantee | What you see |
|---|---|
| TTL is capped | A ttl_seconds outside [5, 300] raises ValueError at the SDK boundary, before any network call |
| Pydantic values only | set rejects non-BaseModel values; get rejects non-BaseModel classes |
| Models must be registered | Passing an unregistered model class raises ValueError before any network call |
| Value size is capped | A serialized entry exceeding 64 KB raises ValueError |
| Keys are validated | The key character set and length are enforced before any network call |
| Cache requires a live context | The 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:
| Method | Use |
|---|---|
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 rejectedCommon 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.cachewith 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.cacheif 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 snapshotGuarantees the platform enforces
| Layer | What the platform guarantees |
|---|---|
ctx.cache | TTL is capped at 300 seconds — the cache cannot masquerade as permanent storage |
ctx.cache | Only Pydantic BaseModel instances are accepted — no raw dicts or primitives |
ctx.cache | A model must be declared via @ext.cache_model before use |
ctx.cache | Each serialized entry is capped at 64 KB |
ctx.cache | Keys are restricted to [A-Za-z0-9_\-:] and at most 128 characters |
ctx.store | Attempts to bypass per-user scoping are detected and flagged as a security event — they never succeed silently |
ctx.store | ctx.as_user() requires a system context; it raises RuntimeError for non-system callers |
ctx.store | ctx.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
@ext.cache_model reference
Full kwarg docs, registration patterns, and envelope contract for ctx.cache model classes.
Skeletons
How skeleton tools use ctx.cache to share precomputed data with panel handlers.
Scheduled tasks
System context fan-out with ctx.store.list_users() + ctx.as_user().
Web-kernel context
The full ctx surface — all clients, their types, and availability by tool type.
Federal invariants
All platform-enforced invariants including the cache and store series.
Fact-ledger — verbatim cross-turn recall
Fact-ledger — verbatim cross-turn recall of exact tool results across the last 5 turns, no extension API; the anti-fabrication surface under the classifier.
Scheduled tasks
@ext.schedule — cron-driven periodic jobs in an Imperal Cloud extension, running detached from chat with fan-out across users, idempotency, and retries.