In-app notices
Declare in-app messages in your manifest; the platform picks what each person sees by audience, conditions and schedule, remembers dismissals, and one shared renderer draws them.
Notices are declared, not coded
An in-app notice is a short message in a fixed place in your app: "Turn on two-step sign-in", "Your trial ends in 3 days", "Reports now export to CSV". On SynaptaGrid you declare notices in your app manifest under spec.notices, name the places they can appear under spec.notice_slots, and one shared renderer in the frontend SDK draws them. Your frontend writes no notice code: it mounts the renderer once and puts a slot where a notice may appear.
The Control Plane decides, for each person asking, which notices are shown. It applies the audience, the conditions, the schedule and what the person already dismissed, and it picks what fits each slot. A notice the person is not meant to see never reaches the browser, so nothing about it shows in the network tab.
Notices sit next to guided tours. A tour walks a new user through the product once; a notice is a message that stays until it is dismissed or no longer applies. New in-app prompts use this framework instead of one-off banners.
spec:
notice_slots:
- code: global.top
tier: portal
presentations: [banner]
max_visible: 1
notice_conditions:
- code: reports.none_scheduled
tier: [portal]
description: The organization has no scheduled report.
notices:
- code: schedule-a-report
version: 1
tier: portal # portal = your customers; cp = platform operators
placement: global.top # a slot declared in this manifest
presentation: banner
severity: info
priority: 50
title: Send reports on a schedule
body: Pick a report and a time, and it arrives in your inbox each week.
action:
label: Schedule a report
route: /reports
when:
- reports.none_scheduled
audience:
requires_permission: reports.update
dismiss:
mode: forever
scope: user_orgNotice fields
| Field | Meaning |
|---|---|
code | Unique within the manifest. |
version | Starts at 1. Raise it to show the notice again to people who dismissed an older version. |
tier | portal for your customers' users, cp for platform operators. |
placement | The slot that draws it. Bare, such as global.top, for a slot in your own manifest; <app>:<slot>, such as control-plane:global.top, for another app's slot. |
presentation | banner, card or modal. The slot must offer it. |
severity | info (default), success, warning or critical. |
priority | 0 to 100, default 50. When a slot has more notices than room, critical notices come first, then the higher priority, then declaration order. |
title, body | Plain language for the reader, up to 120 and 600 characters. |
action | Optional. One link: label (up to 40 characters) and route, a path in the frontend that renders the slot, starting with /. |
when | Up to 10 conditions, all of which must be true. See below. |
audience | Who it is for. See below. |
dismiss | How it goes away. See below. |
frequency | until_dismissed (default) or once_per_session, which the renderer applies once per browser session. |
max_impressions | Optional, 1 to 1000. Stop showing it after this many impressions. |
org_can_disable | Whether an organization's admins may switch it off for their organization. Unset means yes, except for critical notices, which always means no. |
schedule | Optional starts_at and ends_at, timestamps with a timezone such as 2026-11-01T00:00:00Z. Outside the window the notice is never shown. |
Audience
Every field is optional. A notice is shown only when all the ones you set match.
| Field | Matches |
|---|---|
subscription | any (default), active, trial, none or payment_failed. |
plan_tiers | A list of plan tier codes. Cannot be combined with the range below. |
plan_tier_min, plan_tier_max | A range on the plan tier ladder. Either or both. |
feature_on, feature_off | A plan feature, always written <app>:<feature>, that must be on or off. Use feature_off for an upsell to a feature the organization does not have. |
uses_app | The organization has this app. |
requires_permission | A permission code the person must hold. A bare code is checked against your manifest at validation. |
Take the permission from what the destination page enforces. A gate looser than the page's shows the notice to people who then cannot open the page.
Conditions
A condition is a yes or no question about the caller that an app answers on the server. You declare it under spec.notice_conditions with a code, the tier list it applies to, and a description, and you list it in a notice's when.
The Control Plane answers these itself, so any app uses them as control-plane:<code>:
| Condition | True when |
|---|---|
identity.mfa_not_enrolled | The person has no authenticator app for two-step sign-in. Portal and operator. |
identity.email_unverified | The person has not confirmed their email address. Portal. |
subscription.none_active | The organization has no active or trial subscription. Portal. |
subscription.payment_failed | The last renewal payment failed. Portal. |
subscription.trial_ends_within_7_days | The trial ends within 7 days. Portal. |
org.no_other_members | The person is the only member. Portal. |
A condition you declare for your own app is written bare in when. Never qualify your own codes.
Dismissal
| Field | Meaning |
|---|---|
mode | forever (default), snooze or none. |
snooze_days | 1 to 365. Required with snooze, refused otherwise. |
scope | Whose record the dismissal is: user, user_org (default), org or user_instance. |
requires_permission | Only with scope: org. Who may dismiss it for the whole organization. Unset, it follows audience.requires_permission. |
- A dismissal is recorded on the server against the version that was dismissed. Raising
versionshows the notice again. - A snooze hides it for
snooze_days, then it returns. mode: nonemeans the notice cannot be dismissed. It is allowed only forseverity: critical, and the notice must then carryschedule.ends_ator awhencondition so it cannot stay on screen forever.- A modal that cannot be dismissed ignores Esc and a click outside it.
Slots
A slot is a place in an app shell where notices may appear.
| Field | Meaning |
|---|---|
code | Unique per tier within the manifest. |
tier | portal or cp. |
presentations | One to three of banner, card, modal. |
max_visible | 1 to 10, default 1. How many notices the slot draws at once; critical first, then by priority. |
modal | true exactly when modal is in presentations. |
The Control Plane declares three slots for the customer portal and two for the operator console. Use them as control-plane:<code>:
| Slot | Tier | Offers | Shows |
|---|---|---|---|
global.top | portal, cp | banner | 1 |
dashboard.cards | portal, cp | card | 3 |
after-sign-in | portal | modal | 1 |
What publishing checks
The manifest validator and the Control Plane refuse a manifest with any of these:
- a notice whose
placementis not declared for its tier, or whosepresentationthe slot does not offer; - a
whencondition that is not declared, or not declared for the notice's tier; - an
audienceordismisspermission your manifest does not declare, or a feature of your own app thatspec.featuresdoes not declare; plan_tierstogether withplan_tier_minorplan_tier_max;- a snooze with no
snooze_days, orsnooze_dayswithout a snooze; schedule.ends_atthat is not afterschedule.starts_at, or a timestamp with no timezone;mode: noneon a notice that is not critical, or one with no end;- a critical notice with
org_can_disable: true; - a code declared twice, or one of your own codes written as
<your app>:<code>.
A reference to another app's slot, condition, permission or feature is checked by the Control Plane when you publish. It refuses the publish if the other app does not declare it.
Older drafts used requires_permission, requires_feature and state_scope on the notice itself. They are refused with a message naming the replacement: audience.requires_permission, audience.feature_on and dismiss.scope.
Mount the renderer once
import { NoticeProvider, NoticeSlot, createPortalNoticesClient } from "@egav/app-sdk/notices";
<NoticeProvider client={createPortalNoticesClient(api)} routeKey={location.pathname} navigate={navigate}>
<NoticeSlot placement="control-plane:global.top" />
<AppShell />
</NoticeProvider>apiis your SDKApiClientpointed at the Control Plane, with the active app instance set.routeKeychanges with the page. The provider then asks once for every slot on the new page.navigateopens a notice's action route. Without it the page does a full navigation.- For the operator console use
createCpNoticesClient(api)with the same provider. - Each
<NoticeSlot placement="...">takes the slot as<owner>:<slot>, as the manifest declares it.
A banner is drawn as an alert, a card as a card, a modal as a dialog. Dismissing is optimistic: the notice hides at once and comes back, with the error reported, if the server refuses. The renderer never writes its own state. If notices cannot be loaded the person sees none and your app keeps working.
Keep a test in your frontend that renders the shell and asserts each slot you mount exists and each action.route resolves to a page.
Organizations can switch notices off
Unless a notice is critical or sets org_can_disable: false, an organization's admins can switch it off for their organization in the customer portal. A notice dismissed for the whole organization can be brought back there too.
Releasing a change
Notices ship with your manifest. Change a notice, raise the manifest version, publish, and the next request serves it. No frontend release is needed unless you add a slot or move an action route.
Announcements: notices written by operators
Platform operators can also write notices without a manifest, as announcements in the Control Plane console. An announcement uses the same notice fields, the same renderer and the same slots; the server chooses its code and version. It can additionally be limited to named organizations. Use a manifest notice for a message that belongs to your app, and leave announcements to the platform's operators.