Events
Internal publish-subscribe bus — typed event emission, cross-extension subscriptions, and the minimal handler context model
The platform event bus is a publish-subscribe mechanism that lets extensions react to things other extensions do, without polling or direct calls. One extension emits an event; any number of others receive it.
Use the event bus for loose coupling: an extension that creates notes should not need to know which other extensions care about that creation. It emits notes.created and moves on.
How the event bus differs from other trigger types
Extension code runs in five fundamentally different modes. Understanding when each fires clarifies when to reach for the event bus.
@ext.on_event | @chat.function | @ext.schedule | @ext.webhook | @ext.skeleton | |
|---|---|---|---|---|---|
| Who triggers it? | Web-kernel event bus | LLM routing decision | Web-kernel timer (cron) | External HTTP caller | Web-kernel scheduler |
| When does it run? | On a matching platform event | When a user asks | At the cron interval | When a POST arrives | On a per-user TTL |
| Trigger origin | Internal (another extension) | Internal (user in chat) | Internal (time) | External (outside the platform) | Internal (per-user probe) |
| Context type | Minimal — see below | Full user context | System context | Minimal webhook context | Full user context |
Has full ctx? | No — ctx is minimal | Yes | Partial (system) | No | Yes |
Has ctx.user? | No | Yes | No | No | Yes |
| Appears in chat? | No | Yes | No | No | Yes (fed to classifier) |
| Fan-out to all users? | No — single dispatch | No | Yes — explicit | No | No — per user |
Events vs @chat.function
A @chat.function is invoked when the LLM decides a user's message requires an action. The user is present, the context is fully populated, and the LLM controls when it fires.
An event handler fires when another extension emits an event — there is no user message, no LLM decision, and no chat session. Use events for internal, asynchronous reactions to platform-side state changes.
Events vs @ext.schedule
Both run outside a direct user chat session. The difference is the trigger:
- Events: reaction-driven — fires because something happened inside the platform.
- Scheduled tasks: timer-driven — fires because time has passed.
Use events when you want to react to something another extension did. Use schedules when you need to do something periodically regardless of what else is happening.
Events vs @ext.webhook
Both react to events, but the event origin is different:
@ext.on_event: the event source is internal — another extension publishing to the platform bus.@ext.webhook: the event source is external — a third-party HTTP POST from outside the platform.
For inter-extension communication, use @ext.on_event. For third-party integrations (Stripe, GitHub, Slack), use @ext.webhook.
Events vs @ext.skeleton
Both can react to data changes, but in completely different ways:
@ext.on_event: asynchronous, triggered by an emit call, handler receives the event payload.@ext.skeleton: synchronous per-user probe that feeds the LLM's ambient context. The skeleton refreshes on a TTL — it is not event-driven.
If you want to update the LLM's working memory in response to something, the correct pattern is to invalidate the skeleton cache from the emitting handler (where ctx is available), not from inside @ext.on_event.
Publish-subscribe model
The event bus is a fire-and-forget publish-subscribe system. The publisher emits and immediately continues. Subscriber handlers run asynchronously, in isolation, after the publisher's handler has already returned a result to the user.
Publisher Web-kernel bus Subscriber(s)
────────── ────────── ─────────────
@chat.function:
ctx.extensions.emit( ─── enqueue ──► @ext.on_event handler
"notes.created", (Redis stream) fires asynchronously
{"note_id": "...", ...}
)
return ActionResult.success(...)There is no acknowledgement from subscriber to publisher. The publisher does not learn whether any subscriber ran, whether it succeeded, or how long it took. Failures in subscriber handlers do not propagate to the publisher or to the user.
Fire-and-forget implications
- Do not rely on a subscriber having completed before your handler returns a result to the user.
- Subscriber failures are logged but do not affect the publisher's outcome.
- Ordering is not guaranteed across multiple subscribers for the same event type.
Declaring emissions with @ext.emits
Before emitting an event type at runtime, declare it with @ext.emits. This is a manifest registration — it records the event type in imperal.json so other extensions can discover and subscribe to it.
from imperal_sdk import Extension
ext = Extension(
"notes",
version="1.0.0",
display_name="Notes",
description="Notes — create, edit, and organize notes with AI.",
icon="icon.svg",
actions_explicit=True,
)
@ext.emits("notes.created")
@ext.emits("notes.updated")
@ext.emits("notes.deleted")
async def _declare_events() -> None: # pragma: no cover
passThe decorated function is a stub — its body is never called. Stack the decorator to declare multiple event types.
Cross-namespace enforcement
The event_type MUST be prefixed with your extension's app_id followed by a dot. The SDK enforces this at decoration time — a ValueError is raised immediately at module import if the prefix does not match.
from imperal_sdk import Extension
ext = Extension(
"billing",
version="1.0.0",
display_name="Billing",
description="Billing — manage subscriptions and payments.",
icon="icon.svg",
actions_explicit=True,
)
# Correct — prefix matches app_id
@ext.emits("billing.topup_completed")
async def _declare_billing_events() -> None: # pragma: no cover
passThe following raise ValueError at decoration time:
@ext.emits("payments.charged")— prefix"payments"does not match app_id"billing"@ext.emits("billing_topup_completed")— no dot separator
This rule is a federal cross-namespace block: one extension cannot claim to be the source of another extension's events.
Emitting at runtime with ctx.extensions.emit
To publish an event, call ctx.extensions.emit(event_type, data) from inside any handler that has a live context. The canonical location is a @chat.function after the write operation succeeds.
from pydantic import BaseModel
from imperal_sdk import Extension
from imperal_sdk.chat import ChatExtension, ActionResult
ext = Extension(
"notes",
version="1.0.0",
display_name="Notes",
description="Notes — create, edit, and organize notes with AI.",
icon="icon.svg",
actions_explicit=True,
)
chat = ChatExtension(ext, tool_name="notes", description="Notes assistant")
@ext.emits("notes.created")
async def _declare_notes_events() -> None: # pragma: no cover
pass
class CreateNoteParams(BaseModel):
title: str
content: str
@chat.function( # type: ignore[attr-defined]
"create_note",
action_type="write",
chain_callable=True,
effects=["create:note"],
description="Create a new note with the given title and content.",
)
async def create_note(ctx, params: CreateNoteParams) -> ActionResult:
doc = await ctx.store.create("notes", {
"title": params.title,
"content": params.content,
})
await ctx.extensions.emit("notes.created", {
"note_id": doc.id,
"title": params.title,
})
return ActionResult.success(
data={"note_id": doc.id},
summary=f"Created note '{params.title}'.",
)ctx.extensions.emit returns as soon as the event is enqueued to the Redis stream. It does not wait for subscribers.
Subscribing with @ext.on_event
To receive an event, decorate an async function with @ext.on_event(event_type). There is no namespace restriction on subscriptions — you may subscribe to events from any extension.
from imperal_sdk import Extension
ext = Extension(
"audit-log",
version="1.0.0",
display_name="Audit Log",
description="Audit Log — record all changes for compliance.",
icon="icon.svg",
actions_explicit=True,
)
@ext.on_event("notes.created")
async def on_note_created(ctx, event: dict) -> None:
import logging
logging.getLogger(__name__).info(
"note created: id=%s title=%s",
event.get("note_id"),
event.get("title"),
)The minimal handler context model
Event handlers receive minimal ctx — most ctx attributes will crash
The web-kernel currently dispatches @ext.on_event handlers with ctx=None.
Any attempt to access ctx.http, ctx.store, ctx.cache, ctx.ai,
ctx.user, or any other ctx attribute will raise AttributeError at runtime.
The web-kernel swallows these errors and logs them — your handler fails silently without affecting the publisher or the user's session.
This is a known platform-level constraint documented in sql-db/events.py:
"Any attempt to do ctx.cache.set from inside an @ext.on_event handler
crashes with AttributeError: 'NoneType' object has no attribute 'cache'".
Safe inside an event handler:
- Reading from the
eventdict parameter - Pure Python computation
logging.getLogger(__name__).info(...)
Unsafe — will raise AttributeError:
ctx.cache.*ctx.store.*ctx.http.*ctx.ai.*ctx.notify.*
The workaround is to place context-dependent work inline in the handler that
emits the event, where the full ctx is available — not deferred to the subscriber.
Why handlers receive minimal ctx
The event bus dispatches handlers across extension boundaries. At dispatch time, the web-kernel does not have a live per-user request context — there is no open chat session and no authenticated user. The dispatch is a web-kernel-internal operation.
This means event handlers are appropriate only for stateless or logging-only reactions. Any operation that requires user identity, network I/O, or database writes must be done where a real context exists.
The workaround: inline side effects
If your extension needs to do ctx-aware work in response to an event it emits, do that work directly in the emitting @chat.function, before or after the emit call. The emit is a signal to other extensions; your own extension's cleanup and side effects belong in the same handler.
Correct pattern — ctx-aware work in the emitting handler:
from pydantic import BaseModel
from imperal_sdk import Extension
from imperal_sdk.chat import ChatExtension, ActionResult
ext = Extension(
"notes",
version="1.0.0",
display_name="Notes",
description="Notes — create, edit, and organize notes with AI.",
icon="icon.svg",
actions_explicit=True,
)
chat = ChatExtension(ext, tool_name="notes", description="Notes assistant")
@ext.emits("notes.created")
async def _declare_ev() -> None: # pragma: no cover
pass
class NoteParams(BaseModel):
title: str
@chat.function( # type: ignore[attr-defined]
"create_note_inline",
action_type="write",
chain_callable=True,
effects=["create:note"],
description="Create a note and update the sidebar cache inline.",
)
async def create_note_inline(ctx, params: NoteParams) -> ActionResult:
doc = await ctx.store.create("notes", {"title": params.title})
await ctx.cache.delete("sidebar:notes") # inline — ctx is live
await ctx.extensions.emit("notes.created", {"note_id": doc.id})
return ActionResult.success(
data={"note_id": doc.id},
summary=f"Created note '{params.title}'.",
)The incorrect approach — deferring ctx work to @ext.on_event — crashes silently:
# CRASH: ctx is None inside @ext.on_event
# AttributeError: 'NoneType' object has no attribute 'cache'
async def on_note_created_broken(ctx, event: dict) -> None:
await ctx.cache.delete("sidebar:notes")This is the pattern established in sql-db: sidebar cache mutations happen inline in fn_execute_sql and fn_run_editor_sql, where the full ctx is available, and ctx.extensions.emit fires separately for cross-extension consumers.
Schema validation
The SDK does not validate event payloads at runtime against any schema. The schema_ref parameter on @ext.emits is advisory — it records a URL in the manifest for documentation purposes only.
Subscribers receive the raw data dict passed to ctx.extensions.emit, exactly as provided. Validate required fields inside your handler:
from imperal_sdk import Extension
import logging
log = logging.getLogger(__name__)
ext = Extension(
"my-app",
version="1.0.0",
display_name="My App",
description="My App — AI-powered tool for managing resources.",
icon="icon.svg",
actions_explicit=True,
)
@ext.on_event("inventory.item_created")
async def on_item_created(ctx, event: dict) -> None:
item_id = event.get("item_id")
if not item_id:
log.warning("item_created event missing item_id")
return
log.info("item created: %s", item_id)Failure modes
Handler error isolation
A subscriber handler that raises an exception does not propagate the error to the publisher. The web-kernel catches handler exceptions, logs them to the extension dashboard, and continues dispatching to any remaining subscribers.
This means:
- Subscriber failures are invisible to the publisher.
- Subscriber failures do not surface to the user.
- Multiple subscribers for the same event type are independent — one failing does not block the others.
No retry by default
Event handlers are not retried on failure. If your handler fails silently (due to the minimal ctx constraint or any other error), the event is not re-delivered. Design accordingly — if you need guaranteed delivery and retry, use a durable queue pattern via ctx.store.
Silent ctx failures
The most common failure class is a handler accessing ctx attributes that are None. These fail silently — the web-kernel swallows and logs AttributeError. This makes the minimal context constraint especially hazardous: the error may not be immediately visible during development if log output is not monitored.
When to use the event bus
Use events when:
- You want to notify other extensions about a state change without coupling to them directly.
- The subscriber's reaction can be asynchronous and does not need to complete before the user sees a result.
- The subscriber needs only the event payload — no live user context.
- You are logging, auditing, or triggering panel refreshes from pure platform signals.
Do not use events when:
- The subscriber needs
ctx.store,ctx.cache,ctx.http, or any other live client — these will crash. - You need the subscriber to complete before returning a result to the user — events are fire-and-forget.
- The work logically belongs to the emitting extension — do it inline.
- You need guaranteed delivery or retry semantics — events are best-effort.
Federal invariants
Cross-namespace emission is blocked at decoration time. The ValueError enforced by @ext.emits prevents one extension from claiming ownership of another extension's event namespace. This is a federal-grade constraint: there is no bypass.
Handler failures are isolated. The web-kernel's event dispatch wraps each handler invocation in exception handling. A broken subscriber cannot crash the publisher's workflow or affect the user's session.
Audit scope. Event handler invocations are logged by the web-kernel under the system actor. Actions that produce user-visible side effects should be done via @chat.function writes (which carry proper audit ledger entries) rather than attempted inside @ext.on_event handlers.
No confirmation gate. The KAV (Web-kernel Action Validation) confirmation gate does not apply to event handlers. Subscribers do not trigger user-facing confirmation prompts. This reinforces the rule: do not use @ext.on_event for write operations — writes belong in @chat.function.
Registering events
Declare emitted events with @ext.emits on the Extension instance. Declare subscriptions with @ext.on_event. Both store their definitions in the extension's internal lists and emit the corresponding manifest section when imperal build . is run.
The full reference — exact signatures, kwargs, naming rules, manifest output, and production examples — is in the @ext.on_event + @ext.emits reference.
Cross-references
@ext.on_event + @ext.emits reference
Complete reference: signatures, kwargs, naming rules, manifest output, critical minimal-ctx warning, and production examples.
@chat.function reference
User-triggered actions — the canonical location for ctx.extensions.emit calls.
Scheduled tasks concept
Time-driven background jobs — the timer-based alternative to event-driven dispatch.
Webhooks concept
External HTTP triggers — the external-event alternative to the internal platform bus.