The App SDK
What It Is
The App SDK is the browser side of a Custom App: one small JavaScript module that tells the front-end who is signed in and in which language, gives it the tenant's look, and calls the app's own routes and the tenant's records through the REST API.
Every tenant serves it, and every app page the platform serves can import it by name, without a build step:
import { user, api, records } from '@jetstack/sdk';
Why It Matters
Without it, every front-end re-implements the same plumbing: reading window.__JETSTACK__, attaching the CSRF token, turning error responses into exceptions, paging through lists, uploading files before saving a record. The SDK does it once, the same way everywhere — and with jetstack types the editor knows every record type and field, so a misspelled field fails in the editor instead of in the tenant.
How It Works
- The platform adds an import map to each app page, so
@jetstack/sdkresolves to the tenant's copy (/js/jetstack-sdk.js). When the page has its own import map, the entry is added to it; an entry the app sets itself wins. - The SDK reads the bootstrap the platform injects into the page (
window.__JETSTACK__), so it needs no configuration. - Requests run with the signed-in user's session and permissions. Writes carry the session's CSRF token, and effects a handler produces (flash messages, banners) are shown by the platform runtime.
- A failed request throws a
JetstackErrorwith the HTTPstatus, the parsedbody, and — for handler errors — theerrorIdto look up injetstack logs.
Front-ends with a bundler can install the same module as the package @jetstack/sdk.
Who And Where
| Function | Returns |
|---|---|
user() | The signed-in user — {id, name, email, roles, roleIds}, roles by system name — or null for a visitor |
hasRole(role) | Whether the user holds the role (system name) |
locale() | The user's language, e.g. cs |
timezone() | The time zone the user sees times in, e.g. Europe/Prague |
tenant() | {title, url} |
embed() | {embedded, record}: whether the app is shown inside a module page, and the record the module opened it for |
viewport() | "full" when the app takes the whole window, else "embedded" (see Full-Window Apps) |
basePath() | Where the app's pages are on the domain in use: "/" when the app is mounted at the root of its own domain (see Entry Domains), else "/app/<slug>/". Give it to a client router |
pageUrl(path) | The address of one of the app's pages on the domain in use, e.g. pageUrl('/cases/12') |
links() | The platform's own pages — {home, dashboard, profile, signIn, signOut, exitLoginAs} — for an app's own user menu; a link the person cannot use is null |
context() | The raw bootstrap |
From Another Site
A website on another domain can use the SDK to call an app's public routes:
import { configure, api } from 'https://crm.acme.com/js/jetstack-sdk.js';
configure({ url: 'https://crm.acme.com', app: 'leads' });
await api.post('/contact', { name, email, message });
How it behaves:
- The app must allow the site. It lists the site in its manifest's
cors, and the routes are"auth": "public". - No session is sent. Calls run without the visitor's session and need no CSRF token.
- Platform APIs refuse.
records(), notifications, jobs and search throw aJetstackError, because they need one of the tenant's own pages.
See External Front Ends.
The Tenant's Look
theme() returns the tenant's theme — {cssUrl, mode, colors} — and applyTheme() adopts it: it loads the tenant's Bootstrap build, colors included, and follows its light/dark setting.
import { applyTheme } from '@jetstack/sdk';
await applyTheme(); // before the first render avoids a flash of unstyled content
Use it when the app should feel like part of the tenant. Leave it out when the app has its own design, for example a public portal.
The App's Own Routes
api calls the routes of the app's manifest, relative to the app:
import { api, JetstackError } from '@jetstack/sdk';
const { cases } = await api.get('/cases', { query: { status: 'open' } });
try {
await api.post('/cases', { title: 'New case' });
} catch (e) {
if (e instanceof JetstackError && e.status === 422) showErrors(e.body);
}
To send files to a handler, post a FormData; the handler receives them in $files:
const form = new FormData(document.querySelector('#import'));
const { imported } = await api.post('/imports', form);
Records
records(type) works with the records of one type (by system name), with the signed-in user's permissions:
import { records } from '@jetstack/sdk';
const cases = records('case');
const { items, total } = await cases.list({
filters: [{ property: 'status', type: 'is', param: 'open' }],
orderBy: 'created_on', orderDir: 'desc', perPage: 20,
fields: ['title', 'status'],
});
for await (const c of cases.all({ perPage: 100 })) { /* every matching record */ }
const created = await cases.create({ title: 'New case', priority: 'high' });
await cases.patch(created.id, { status: 'closed' });
await cases.delete(created.id);
const [contract] = await cases.upload('contract', fileInput.files); // upload promise id
await cases.patch(created.id, { contract });
listreturns{items, page, perPage, total};allpages through everything.get,update,patchanddeletetake an id or a uuid.- References are written as an id, a uuid or — for enumerations — a code.
- Filters follow the REST API's filter operators; a filter the API cannot apply is refused, never ignored.
Use records for screens that work directly with data the user may see anyway — lists, detail pages, administrative screens. Put anything with rules the browser must not decide — approvals, anything public — behind a handler, and call it with api.
Live Events
events.on(type, fn) listens to the person's live event stream and returns a function that stops listening. The stream opens with the first listener; an app embedded in a platform page shares the page's stream.
import { events } from '@jetstack/sdk';
const stop = events.on('app', (event) => {
if (event.name === 'case.updated') reloadCase(event.data.id);
});
events.on('resync', reloadAll); // after a reconnect
Types: notification, notification-read, job, app (the app's own, from $jetstack->live->publish()), resync, and * for all of them.
What The Assistant Sees
page tells the AI assistant what the page shows — the title, the record, the selected records and a short summary. The chat reads it with each message:
import { page } from '@jetstack/sdk';
page.set({ title: 'Open cases', type: 'case', selection: ['12', '15'] });
page.get(); // what the assistant would read now
page.clear(); // back to what the markup says
The page can say the same with data-jetstack-* attributes instead. See Telling The Assistant What Is On Screen.
Notifications, Jobs And Search
The platform's own services, with the person's permissions — see Platform Services For Apps:
| Helper | What it does |
|---|---|
notifications.list({limit, cursor, unreadOnly, after}) | A page of the person's notifications, with unreadCount |
notifications.unreadCount() | How many are unread |
notifications.markRead(id), markAllRead(), archive(id) | Read or archive them |
notifications.onChange(fn) | Calls fn on a new notification, a read or an archive in any window, and after a reconnect |
jobs.list(), jobs.get(id), jobs.dismiss(id) | The person's background jobs of the last 24 hours |
jobs.watch(id, fn) | Calls fn with the job on every change until it ends |
search(text, {types, limit, cursor}) | Records the person may read that match the text, a page at a time |
search.types() | The record types search can find for the person |
Types In The Editor
jetstack types writes the SDK's typings, the tenant's record types and — when the project has none — a jsconfig.json to .jetstack/types/ and the project root. With them, records('case') knows the fields of a case in both plain JavaScript (// @ts-check) and TypeScript:
// @ts-check
import { records } from '@jetstack/sdk';
const c = await records('case').get(1);
c.titel; // error: Property 'titel' does not exist on type 'Case'