Skip to content

Notifications and action displays

Plugins can communicate user-facing information in three ways:

Intent Use
State that belongs to the running server/application Application notification
State that belongs to one session and must survive reopening it Session notification
The immediate result of the action being executed Direct action display

Tools can also create session notifications through the failure-contained notification_sink.

This page describes the currently implemented plugin APIs, record lifecycle, presentation behavior, actions, and failure boundaries.

Runtime references:

  • core/python/agent_app/notifications.py
  • core/python/agent_app/application_owner.py
  • core/python/agent_app/application_future.py
  • core/python/agent_app/app_context.py

Reference plugins:

  • plugins/dummy-application-notification-app
  • plugins/dummy-session-notification-app
  • plugins/dummy-action-display-app

Python method examples below are intended for plugin classes and assume from typing import Any.

Notification or direct display?

Use a notification when the information has a lifecycle independent from the action that created it. Examples include:

  • configuration reload is waiting or failed;
  • a session dependency is unavailable;
  • a login or other recoverable condition needs attention;
  • a tool discovers a warning that should remain associated with the session.

Use a direct display when the content is only the result of the current action:

return {
    "status": "ok",
    "display": {
        "format": "markdown",
        "title": "Export complete",
        "body": "The file was created successfully.",
        "variant": "success",
        "presentation": "modal",
    },
}

A direct display is not stored as a notification and has no notification create/update/resolve lifecycle.

Mobile renders direct modal displays in its standard action-result overlay. Desktop renders direct modal displays in a separate action-display window. Lengthy bodies remain bounded and scroll inside either surface. Desktop application and session notification modal requests continue to use their documented inline fallback.

Notification record

Application and session notifications use the same basic record:

notification = {
    "key": "example:dependency:unavailable",
    "severity": "warning",
    "title": "Dependency unavailable",
    "body": "The request continued without the optional dependency.",
    "source": {
        "plugin": "example_app",
        "subsystem": "dependency",
    },
    "display": {
        "format": "markdown",
        "title": "Dependency unavailable",
        "body": "The request continued without the optional dependency.",
        "variant": "warning",
        "presentation": "inline",
        "dismissible": True,
    },
    "model_visibility": "hidden",
}

The backend adds and maintains:

  • id;
  • revision;
  • kind: "notification";
  • status: "pending" | "dismissed" | "resolved" | "expired";
  • created_at and updated_at;
  • optional request_id;
  • optional resolution details.

Required producer fields are:

  • a non-empty stable key;
  • title;
  • source.plugin;
  • a valid display.

body defaults to an empty string, severity defaults to info, and model_visibility must currently be hidden.

Application notifications

Application notifications belong to the stable application owner. They:

  • survive replacement of AgentApplication;
  • do not survive restart of the owner process;
  • are not associated with a session_id;
  • support banner and modal;
  • reject inline.

Create an application notification from an application action:

def execute_action(
    self,
    app: Any,
    action_id: str,
    params: dict[str, Any],
    context: dict[str, Any] | None,
    state: dict[str, Any],
) -> dict[str, Any]:
    stored = app.create_application_notification(
        {
            "key": "example:reload:status",
            "severity": "info",
            "title": "Reload requested",
            "body": "New work is paused while active work finishes.",
            "source": {"plugin": self.name},
            "display": {
                "format": "text",
                "title": "Reload requested",
                "body": "New work is paused while active work finishes.",
                "variant": "info",
                "presentation": "banner",
                "dismissible": True,
            },
            "model_visibility": "hidden",
        }
    )
    return {
        "status": "ok",
        "notification_id": stored["id"],
    }

Available application methods:

app.create_application_notification(notification)
app.update_application_notification(
    notification_id,
    patch,
)
app.dismiss_application_notification(
    notification_id,
    reason="user",
    expected_revision=revision,
)
app.resolve_application_notification(
    notification_id,
    resolution,
)
app.expire_application_notification(
    notification_id,
    reason="expired",
)
app.list_application_notifications(include_terminal=True)

Producer publication (create, update, resolve, expire) does not require expected_revision; the notification service applies the patch to the current active occurrence and increments revision internally. Dismissal accepts optional expected_revision because a stale user gesture should not hide a newer revision.

Session notifications

Session notifications are stored in:

Session.metadata.notifications.items

They survive session reload, frontend reconnect, and server restart as part of the session record. They remain hidden from model/provider history.

Create one from an application plugin:

def execute_action(
    self,
    app: Any,
    action_id: str,
    params: dict[str, Any],
    context: dict[str, Any] | None,
    state: dict[str, Any],
) -> dict[str, Any]:
    session_id = str((context or {})["session_id"])
    stored = app.create_session_notification(
        session_id,
        {
            "key": "example:session:warning",
            "severity": "warning",
            "title": "Session warning",
            "body": "An optional session capability is unavailable.",
            "source": {"plugin": self.name},
            "display": {
                "format": "text",
                "title": "Session warning",
                "body": "An optional session capability is unavailable.",
                "variant": "warning",
                "presentation": "inline",
                "dismissible": True,
            },
            "model_visibility": "hidden",
        },
    )
    return {
        "status": "ok",
        "notification_id": stored["id"],
    }

Available session methods:

app.create_session_notification(session_id, notification)
app.update_session_notification(
    session_id,
    notification_id,
    patch,
)
app.update_session_notification_by_key(
    session_id,
    key,
    patch,
)
app.dismiss_session_notification(
    session_id,
    notification_id,
    reason="user",
    expected_revision=revision,
)
app.resolve_session_notification(
    session_id,
    notification_id,
    resolution,
)
app.expire_session_notification(
    session_id,
    notification_id,
    reason="expired",
)
app.list_session_notifications(session_id)
app.list_session_notifications(session_id, include_terminal=True)

Stable keys and updates

Creating a pending notification with the same key updates the existing notification in place:

app.create_session_notification(
    session_id,
    {
        "key": "example:session:warning",
        "severity": "error",
        "title": "Session dependency failed",
        "body": "The dependency is still unavailable.",
        "source": {"plugin": self.name},
        "display": {
            "format": "text",
            "title": "Session dependency failed",
            "body": "The dependency is still unavailable.",
            "variant": "error",
            "presentation": "banner",
            "dismissible": True,
        },
        "model_visibility": "hidden",
    },
)

The stored notification keeps its existing id, increments revision, and receives a later updated_at. Creating with the key of a dismissed, resolved, or expired record creates a new notification occurrence with a new ID. Use separate keys when two active conditions must be represented independently.

Producer and consumer update behavior

New notification records begin at revision 1. The notification service owns revision assignment and increment.

Producer publication (create, update by ID, update by key, resolve, expire) does not require expected_revision. The service applies the patch to the current active occurrence for the stable key and increments revision internally. If the previous occurrence is terminal or absent, publication creates a new occurrence.

Dismissal is the only frontend lifecycle mutation that accepts optional expected_revision and reason. A stale revision raises NotificationRevisionConflictError. The exception contains the current notification. HTTP dismissal routes return the same condition as status 409. This prevents a stale user gesture from hiding a newer revision or a later stable-key occurrence.

Existing records created before integer revisions use their updated_at value as the compatibility expected-revision token. Their first mutation assigns integer revision 1.

Operation-occurrence guard

For operation notifications, the stable key identifies the notification stream, while a unique operation ID identifies one execution. A late update from an older operation occurrence must not overwrite a newer operation using the same stable key. Producers may include an optional operation identifier in their publication payload; the service rejects updates whose operation identifier does not match the current active occurrence for that key. This guard replaces plugin-side revision-conflict recovery for producer updates.

Background-operation heartbeats

Background work can keep one pending notification and update it by key. Use one stable key per operation stream, not one key per operation occurrence:

notification = app.create_session_notification(
    session_id,
    {
        "key": "example:operation",
        "severity": "info",
        "title": "Operation running",
        "body": "Waiting for the provider. Elapsed: 0 seconds.",
        "source": {"plugin": self.name},
        "display": {
            "format": "text",
            "title": "Operation running",
            "body": "Waiting for the provider. Elapsed: 0 seconds.",
            "variant": "info",
            "presentation": "inline",
            "dismissible": True,
        },
        "operation": {
            "version": 1,
            "type": "example_operation",
            "id": operation_id,
            "state": "running",
            "phase": "waiting_for_provider",
            "elapsed_seconds": 0,
            "cancellable": True,
        },
        "model_visibility": "hidden",
    },
)

Each heartbeat updates the visible body and structured operation field by republishing the same stable progress notification key. The structured operation mapping is plugin-owned presentation data; its optional fields do not define the application operation registration contract. The minimal active registration used for admission and lookup remains separate. Notification failure must not grant commit authority or cancel the underlying operation.

Keep the unique operation ID in the structured operation.id field and in Cancel follow-up action parameters. The notification service uses it as the operation-occurrence guard so a late update from a superseded occurrence cannot overwrite a newer operation using the same stable key.

Operation-progress notifications are dismissible by default. The user can dismiss the banner at any time; dismissing the presentation does not cancel the underlying operation. Cancel remains an explicit follow-up action. Use dismissible: false only for genuinely permanent notifications such as status indicators whose dismissal would remove the only path to a required response. A persistent notification must never become a dead end: if it cannot be dismissed, it must have a viable recovery action or reliably transition into a dismissible state.

When your plugin uses dismissible banners for in-progress work, design follow-up actions so users can still find an explicit continuation or retry path.

Notification UI effects

Notifications may carry an optional ui_effects mapping using the same refresh field names as application-action results:

{
  "ui_effects": {
    "reload_session_ids": ["session-1"],
    "reload_sessions_list": true,
    "reload_application_actions": true
  }
}

The shared notification-safe effects are:

Field Meaning
reload_session_ids Invalidate the listed sessions on the notification's connection and refresh any listed session that is currently mounted or open. This does not open or navigate to a session.
reload_sessions_list Invalidate and refresh the session collection for the notification's connection.
reload_application_actions Invalidate and refresh application-action definitions and related application UI schema for the notification's connection.

These refresh effects are applicable to both notification scopes:

  • a session notification applies them within the connection that owns its session;
  • an application notification applies them within the connection on which it was received.

Effects never cross connection boundaries. A connection-level notification may request reload_session_ids, but the frontend does not open those sessions. It refreshes mounted consumers and invalidates cached data so a later open observes current state.

Known fields are validated. Additional JSON-compatible ui_effects fields are preserved and remain producer-owned. Frontends may implement recognized optional fields and must ignore unknown fields.

navigate_to_session_id is deliberately not part of notification UI effects. Navigation is focus-changing and is appropriate after a direct user action, such as Fork, but not when an asynchronous notification revision arrives. A notification follow-up may still run an action whose successful action response navigates after the user explicitly selects it.

Notifications also do not carry action mutations and do not infer effects from created, updated, or deleted session IDs. A notification producer must request every desired refresh explicitly through ui_effects.

Delivery is revision-based:

  • an effect is considered once per connection, notification ID, and revision;
  • observing the same revision through a snapshot and event does not repeat it;
  • ui_effects belongs to the revision that explicitly supplies it and is not inherited by later notification updates;
  • dismissal, resolution, expiration, and other terminal revisions do not execute effects, including when reading legacy records that retained them;
  • a newer pending revision may request another effect by explicitly supplying ui_effects;
  • reconnecting or remounting may apply the latest persisted revision once so a stale client catches up;
  • effect execution is best effort and does not change notification lifecycle, retry the producer, or affect operation commit authority.

Refresh delivery is durable for the lifetime of the frontend connection. Session, session-list, and application-action invalidations remain pending until a matching consumer successfully reloads authoritative data. Therefore:

  • a mounted active consumer reloads immediately;
  • an inactive consumer may defer the reload without losing it;
  • a later-mounted consumer receives the pending invalidation;
  • a failed reload leaves the invalidation pending for retry;
  • successful reload acknowledgment prevents later remounts from replaying the same invalidation.

Action responses remain one-shot and are not revisioned. Their shared refresh fields should use the same parsing and routing implementation as notification refresh effects.

Resolving a notification

Resolve a notification when the underlying condition is no longer active:

app.resolve_session_notification(
    session_id,
    notification_id,
    {
        "action_id": "dependency_recovered",
        "values": {"dependency": "example"},
    },
)

Resolution sets status: "resolved", records resolved_at, and publishes a session notification resolved event.

Application notification resolution is equivalent:

app.resolve_application_notification(
    notification_id,
    {"action_id": "reload_completed"},
)

Dismissing and expiring

Dismissal is a terminal server-side lifecycle transition. It accepts optional expected_revision so a stale user gesture cannot hide a newer revision:

dismissed = app.dismiss_session_notification(
    session_id,
    notification_id,
    reason="user",
    expected_revision=revision,
)

Expiration is also terminal. Producer-initiated expiration does not require expected_revision:

expired = app.expire_application_notification(
    notification_id,
    reason="manual_expiration",
)

Dismiss, resolve, and expire are idempotent when the record is already in the requested state. Transitioning from one terminal state to another raises NotificationTransitionError.

Tool notification sink

Tools receive a session- and request-bound notification sink:

def execute_tool(
    self,
    tool_name: str,
    params: dict[str, Any],
    context: dict[str, Any],
    state: dict[str, Any],
) -> dict[str, Any]:
    sink = context.get("notification_sink")
    if sink is not None:
        sink.create(
            {
                "key": "example:tool:warning",
                "severity": "warning",
                "title": "Tool warning",
                "body": "The tool used its fallback mode.",
                "source": {"plugin": self.name},
                "display": {
                    "format": "text",
                    "title": "Tool warning",
                    "body": "The tool used its fallback mode.",
                    "variant": "warning",
                    "presentation": "inline",
                    "dismissible": True,
                },
                "model_visibility": "hidden",
            }
        )

    return {"status": "ok"}

The sink catches notification errors and returns None, so notification failure does not abort the tool or request. Application plugins call the application methods directly and receive validation errors for malformed records.

Presentation

Supported presentations are:

Producer banner inline modal
Application notification Mobile Sessions; desktop connection rail Rejected Mobile Sessions modal; desktop inline fallback
Session notification Matching mobile Chat; active desktop ChatPane Matching session region Matching mobile Chat modal; desktop inline fallback
Direct action display Owning action surface Owning action surface Mobile modal; desktop action-display window

Supported semantic variants are:

  • info;
  • success;
  • warning;
  • error.

Application notifications default to banner, session notifications default to inline, and direct action displays default to modal when presentation is omitted.

Follow-up actions

Notification and direct displays can use:

  • run_action;
  • open_url;
  • copy_text;
  • download_attachment_asset;
  • dismiss.

Example:

"actions": [
    {
        "kind": "run_action",
        "id": "retry",
        "label": "Retry",
        "plugin": self.name,
        "action_id": "retry_dependency",
        "action_owner": "application",
        "fixed_params": {"source": "notification"},
    },
    {
        "kind": "copy_text",
        "id": "copy-details",
        "label": "Copy details",
        "text": "Dependency unavailable",
    },
]

Displays may also contain the common embedded form schema. A run_action follow-up can map submitted fields through $form.<field_name>. See Plugin actions for a complete form example.

Current frontend dismissal behavior

The backend and HTTP API support persistent dismissal. Mobile and desktop notification Dismiss controls use those server operations for application and session notifications. Direct action-display close remains frontend-local.

Plugins can already call the server-side dismiss methods. API clients can use:

GET  /application/notifications
POST /application/notifications/{notification_id}/dismiss
GET  /sessions/{session_id}/notifications
POST /sessions/{session_id}/notifications/{notification_id}/dismiss

List routes return active notifications by default and accept include_terminal=true.

The initiating mobile frontend stores an exact-revision dismissal cache entry and hides the notification immediately. If the server request fails, that revision stays hidden without a blocking error and the same expected-revision request is retried when the record is observed after reconnect or rehydration.

The cache never transfers dismissal to a newer revision. If the server returns the same notification ID at a newer pending revision, mobile removes the obsolete cache entry and presents the newer content. Direct action displays do not enter this cache.

Another already-open frontend is not forced to remove a notification immediately; a newly opened, reconnected, or authoritatively refreshed mobile or desktop frontend does not present a server-dismissed record.

Desktop uses conservative success-only dismissal. It keeps the notification visible if the server rejects or cannot complete the mutation, and therefore does not currently use the mobile failed-dismissal cache.

Dismissal records a user presentation decision. Resolve the notification when the underlying condition itself is complete.

Events and reconnect

Application notification events:

  • application_notification_created;
  • application_notification_updated;
  • application_notification_dismissed;
  • application_notification_expired.

Session notification events:

  • session_notification_created;
  • session_notification_updated;
  • session_notification_resolved;
  • session_notification_dismissed;
  • session_notification_expired.

Session metadata is authoritative when a session is reopened. Application notifications are stored in the runtime's durable application notification store and survive AgentApplication replacement and server-process restart. Events remain best effort; the application snapshot/list endpoint is the reconnect authority.

Failure and security boundaries

  • Notification event delivery is best effort.
  • Tool sink failures never fail the surrounding request.
  • Application-plugin calls report validation and missing-record errors.
  • Do not put credentials, provider keys, auth headers, private callback payloads, or sensitive configuration in notification fields, displays, events, or action results.

Testing

Use the non-production demonstration packages as examples:

  • plugins/dummy-application-notification-app
  • plugins/dummy-session-notification-app
  • plugins/dummy-action-display-app

Run their focused tests:

pytest plugins/dummy-application-notification-app/tests -q
pytest plugins/dummy-session-notification-app/tests -q
pytest plugins/dummy-action-display-app/tests -q

These plugins are intentionally absent from the production default configuration.

The application and session notification demos expose presentation, variant, retention, TTL duration, and follow-up-button controls. Follow-up buttons may be disabled, shown once, or shown as a group of three for stacked-notification testing. Follow-up action responses preserve the originating presentation and variant. Application notifications support follow-up actions normally; their earlier absence was only a limitation of the demonstration plugin.

Retention

Notifications accept a versioned retention object with until_resolved, ttl, until_request_complete, or until_server_restart policy.

TTL uses an absolute expires_at timestamp. It is intentionally a frontend presentation deadline: a notification already visible remains visible after the deadline, while a newly opened or reconstructed frontend host does not present it. TTL alone does not mutate status, increment revision, or publish an expiration event.

Request-completion and server-lifetime policies have explicit server boundaries and transition matching pending records to expired. Terminal history is bounded by application.notifications.history_limit; active records are never silently pruned.

Server lifetime refers to the hosting runtime or server process, not one replaceable AgentApplication generation. An inner application reload may reuse the same lifetime and therefore does not provide a portable expiration signal.

Explicit dismissal and resolution always win. A resolved notification stays resolved after its TTL deadline, request completion, or a later server restart; automatic retention expiration only targets records still pending.

Notification history UI, historical replay, and interaction-compatible records remain planned.