Platform Services For Apps

What It Is

The platform's top bar gives people search, notifications, background jobs and the AI assistant. A Custom App that takes the whole window draws none of that bar, and an embedded app may want the same functions in its own design. The platform offers them to apps as services:

  • Live events — a stream of small messages to each signed-in person: a new notification, a finished job, or an event of the app itself.
  • Notifications — the person's notifications, their unread count, marking them read.
  • Background jobs — the person's exports and imports, and the long work apps report themselves, with progress.
  • Search — the platform's search over the records the person may read.
  • Page context — what the page shows, so the AI assistant knows what the person is looking at.
  • Platform links — the platform's own pages (dashboard, profile, sign-out), for an app's own user menu.

Each service is the same everywhere: the REST API is the contract, the App SDK wraps it for the browser, and handlers reach it through the Internal API. An app can use any of them, or none.

Why It Matters

  • Full-window apps stay complete. An app with "viewport": "full" can still offer a bell, a jobs list and a search box — in its own design.
  • One truth. The app reads the same notifications and jobs as the platform's bell and jobs drawer: marking a notification read in the app clears it everywhere.
  • Live, without polling. Pages learn about changes the moment they happen, from the person's live event stream.
  • The assistant understands the app. An app tells the assistant which record or selection is on screen, as the platform's own lists do.

Mental Model

ServiceBrowser (SDK)RESTHandlersNeeds
Live eventsevents.on()—$jetstack->live->publish()a signed-in person
Notificationsnotifications/api/v1/collection/notifications$jetstack->notificationsthe notifications permission
Background jobsjobsjobs.list, jobs.get, jobs.dismiss$jetstack->jobsa signed-in person
Searchsearch(), search.types()search.query, search.types$jetstack->searchthe search permission
Page contextpage——a signed-in person
Platform linkslinks()———
  • From an app page, REST calls run with the person's session; the runtime adds the CSRF token to writes. They count toward the tenant's API limits like any REST call.
  • From an integration, the same REST calls take an API key, with useApi and the same permissions.
  • Everything runs as the person: they see their own notifications and jobs, and only records they may read.

Live Events

Every signed-in person has one live event stream. The platform's pages use it for the bell and the jobs drawer; app pages share it through events:

import { events } from '@jetstack/sdk';

const stop = events.on('notification', (event) => refreshBell(event.id));
events.on('resync', () => reloadEverything());

Events are small JSON objects with a type:

TypeSent whenCarries
notificationA notification for the person becomes visible (sent to them, their group or one of their roles)id
notification-readThe person read or archived notifications, here or in another window or appids, or all: true; archived
jobOne of the person's jobs started, moved on or endedkind, id, status; percent for app jobs
appThe app's own event, sent by its handlersapp, name, data
resyncThe stream reconnected; events in the gap are lost—

How to use it:

  • Events are signals, not data. They say that something changed; read the details over the authenticated APIs. A page cannot be sure to receive every event — after resync, read again what it shows.
  • One stream per page. An app embedded in a platform page shares the page's stream instead of opening its own.
  • Group and role notifications reach at most 500 people live; the others see them the next time their page reads notifications.

Publishing The App's Own Events

A handler tells the app's open pages that something happened:

$jetstack->live->publish('case.updated', ['id' => $case->id], $assigneeIds);
  • name — letters, digits and . _ : -, at most 64 characters, such as case.updated.
  • data — JSON of at most 8 KB. Send an id and let the page read the rest over the app's routes.
  • users — user ids, at most 200; the signed-in person when left out.
  • Only people who may open the app receive it. The call returns how many did.

Pages receive it as {type: "app", app, name, data}:

events.on('app', (event) => {
  if (event.name === 'case.updated') reloadCase(event.data.id);
});

An app's own service (External Runtime) publishes with the runtime.publish command.

Notifications

The person's notifications — the same ones the platform's bell shows:

import { notifications } from '@jetstack/sdk';

const { items, unreadCount, nextCursor } = await notifications.list({ limit: 20 });
await notifications.markRead(items[0].id);
await notifications.markAllRead();
const stop = notifications.onChange(() => refresh());
  • Each item carries title, htmlContent (purified HTML, safe to insert), text (the same content as plain text), link, data, pinned, readOn and the validity window.
  • unreadCount counts all unread notifications, not just the page.
  • onChange fires on a new notification, a read or an archive — in any window or app — and after a reconnect.
  • Show title as text: it is the sender's text, not HTML.

Handlers use $jetstack->notifications: send() creates one, unreadCount() and markAllRead() act for the signed-in person.

Background Jobs

The person's jobs from the last 24 hours, as the platform's jobs drawer lists them:

  • Exports — "Export all" from a list, and the database export.
  • Implementation jobs — package exports and imports, configuration applies.
  • App jobs — long work an app or an automation reports.

Each job is {id, kind, title, status, percent, phase, error, downloadUrl, app, createdAt, finishedAt}, with status one of queued, running, done, failed or expired (an export whose file is gone).

Reporting An App's Long Work

A handler starts a job for the person, reports progress, and ends it:

$id = $jetstack->jobs->start('Monthly report');
// … work, in batches or scheduled steps …
$jetstack->jobs->progress($id, 40, 'Reading cases');
// …
$jetstack->jobs->finish($id, ['downloadUrl' => '/app/reports/api/files/2026-10']);
// or: $jetstack->jobs->finish($id, ['error' => 'The source list was empty.']);
  • The job shows at once in the person's jobs drawer and system tray, in every window, and in jobs.list.
  • start() takes a user id as its second argument when nobody is signed in (a scheduled step).
  • progress() and finish() work from any later request that knows the id — a scheduled task, a queue step. An app cannot report on another app's jobs.
  • A downloadUrl is a path on the tenant, such as one of the app's routes; the drawer offers it as a download.

In the browser, jobs.watch() follows one job until it ends:

import { api, jobs } from '@jetstack/sdk';

const { jobId } = await api.post('/reports', { month: '2026-10' });
const stop = jobs.watch(jobId, (job) => showProgress(job.percent, job.phase));

The platform's search, for what the person may read:

import { search } from '@jetstack/sdk';

const page = await search('pump', { types: ['case', 'asset'], limit: 20 });
// page.items: [{ type, id, uuid, title, url, matches: [{ field, label, text }] }]
const next = page.hasMore ? await search('pump', { cursor: page.nextCursor }) : null;
const types = await search.types(); // what a type filter can offer

How it matches:

  • The text is found as a substring of the searchable fields (a property's Searchable setting), ignoring case and accents. % and _ match themselves.
  • Only records the person may read appear, matched in fields they may see; a match only in a hidden field finds nothing.
  • title is null when the person may not see the title field.

How it pages:

  • Results run by record type, then newest first. Pass nextCursor as cursor for the next page.
  • A page can come back short, even empty, with hasMore: each call examines at most 500 matching records, and a person who may read little of what matches gets the rest on the next call. Keep asking while hasMore is true.

Handlers search as the current identity with $jetstack->search->query(), or as the app inside asApp().

Telling The Assistant What Is On Screen

The AI assistant reads the page with each message: its title, the record it shows and the selected records. Platform screens tell it themselves; an app tells it in one of two ways.

From code, whenever the screen changes:

import { page } from '@jetstack/sdk';

page.set({ title: 'Open cases in Prague', type: 'case', selection: selectedIds, summary: '12 cases, filtered by region' });
page.set({ record: caseId }); // a detail screen
page.clear();

From markup, with no code:

<section data-jetstack-type="case">
  <h1 data-jetstack-title>Open cases</h1>
  <article data-jetstack-record="12" aria-selected="true">…</article>
  <article data-jetstack-record="15" aria-selected="false">…</article>
</section>
<div data-jetstack-type="case" data-jetstack-record="12">… a detail view …</div>
  • type is a record type's system name or id; record and selection are ids or UUIDs; summary is short text (600 characters).
  • What page.set() gives wins, field by field; set a field to null to hand it back to the markup.
  • The assistant gets the app's name and page title, the record as the current item, and up to 100 selected records, each by id and title. Records the person may not read are left out, and a record whose title they may not see is named by its id.
  • The assistant treats it all as data from the page, never as instructions. It hears from an app only when the app is live and the person may open it.

links() gives the platform's own pages, for an app that draws its own user menu:

LinkWhere it goes
homeThe tenant's home page
dashboardThe platform's dashboard, for people who have it
profileThe person's profile
signInSign-in, back to the app afterwards (visitors)
signOutSign-out
exitLoginAsBack to the administrator, while they are signed in as someone else

A link the person cannot use is null.

Example

A full-window case desk with its own header.

  1. The header shows the person's initials, a bell with unreadCount from notifications.list(), and a menu built from links().
  2. notifications.onChange() refreshes the bell; clicking an item calls markRead() and follows its link.
  3. A search box calls search(text, { types: ['case'] }) and pages with "More results" while hasMore.
  4. "Export the month" posts to an app route, whose handler starts a job and schedules the work; the page follows it with jobs.watch(), and the person also sees it in the system tray.
  5. The case list marks selected rows with aria-selected, so "Summarise these" in the assistant knows which cases are meant.

Best Practices

  • Read on events, don't poll. Subscribe once, refresh on the event, and reload on resync.
  • Keep events small. Ids and names, never personal data: the page reads details with the person's permissions.
  • Grant the permissions the services need. People need notifications and search for those services; an app's roles can grant them through systemPermissions.
  • Report long work as jobs rather than inventing progress screens: people find it in the jobs drawer from any page.
  • Describe the screen as people see it. Set page on every navigation, and mark only what is really selected.