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
| Service | Browser (SDK) | REST | Handlers | Needs |
|---|---|---|---|---|
| Live events | events.on() | — | $jetstack->live->publish() | a signed-in person |
| Notifications | notifications | /api/v1/collection/notifications | $jetstack->notifications | the notifications permission |
| Background jobs | jobs | jobs.list, jobs.get, jobs.dismiss | $jetstack->jobs | a signed-in person |
| Search | search(), search.types() | search.query, search.types | $jetstack->search | the search permission |
| Page context | page | — | — | a signed-in person |
| Platform links | links() | — | — | — |
- 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
useApiand 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:
| Type | Sent when | Carries |
|---|---|---|
notification | A notification for the person becomes visible (sent to them, their group or one of their roles) | id |
notification-read | The person read or archived notifications, here or in another window or app | ids, or all: true; archived |
job | One of the person's jobs started, moved on or ended | kind, id, status; percent for app jobs |
app | The app's own event, sent by its handlers | app, name, data |
resync | The 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 ascase.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,readOnand the validity window. unreadCountcounts all unread notifications, not just the page.onChangefires on a new notification, a read or an archive — in any window or app — and after a reconnect.- Show
titleas 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()andfinish()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
downloadUrlis 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));
Search
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.
titleisnullwhen the person may not see the title field.
How it pages:
- Results run by record type, then newest first. Pass
nextCursorascursorfor 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 whilehasMoreis 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>
typeis a record type's system name or id;recordandselectionare ids or UUIDs;summaryis short text (600 characters).- What
page.set()gives wins, field by field; set a field tonullto 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.
Platform Links
links() gives the platform's own pages, for an app that draws its own user menu:
| Link | Where it goes |
|---|---|
home | The tenant's home page |
dashboard | The platform's dashboard, for people who have it |
profile | The person's profile |
signIn | Sign-in, back to the app afterwards (visitors) |
signOut | Sign-out |
exitLoginAs | Back 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.
- The header shows the person's initials, a bell with
unreadCountfromnotifications.list(), and a menu built fromlinks(). notifications.onChange()refreshes the bell; clicking an item callsmarkRead()and follows itslink.- A search box calls
search(text, { types: ['case'] })and pages with "More results" whilehasMore. - "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. - 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
notificationsandsearchfor those services; an app's roles can grant them throughsystemPermissions. - 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
pageon every navigation, and mark only what is really selected.