Skip to main content

Use the Runtime SDK

Goal​

Understand the Runtime SDK, the one interface a Custom React App or Custom Control uses to reach the Parsware platform, and use each part of it correctly.

Prerequisites​

  • A project set up as in Build a Custom React App or Build a Custom Control, with @parsware/runtime-sdk installed from https://docs.parsware.com/sdk/parsware-runtime-sdk-1.0.0.tgz.
  • @parsware/runtime-sdk declared a shared singleton in the project's Module Federation configuration. Without it the app gets its own empty copy of the SDK instead of the Shell's.

The one rule​

A hosted artifact reaches the platform only through the Runtime SDK. The Shell binds the runtime to the environment it opened the app in and to the person using it. There is no environment id to pass, no token to store and no address to configure, so the same build works in every environment it is installed in. An app that calls the platform's HTTP APIs itself has to solve all three, and gets them wrong the first time it is installed somewhere else.

Getting the runtime​

A Custom React App reads it from a hook:

import { useParswareRuntime } from '@parsware/runtime-sdk';

export default function App() {
const runtime = useParswareRuntime();
// …
}

A Custom Control receives the same surface as its context prop:

export default function Control({ value, onChange, fieldMetadata, context }: ParswareControlProps) {
context.log('debug', 'mounted');
}

Outside the Shell, in a unit test, wrap the component in ParswareRuntimeProvider with a stub: <ParswareRuntimeProvider runtime={stub}>.

What the runtime carries​

MemberWhat it is
environmentId, environmentNameThe environment the app was opened in. The name is safe to show.
artifactIdThe app's or control's logical name.
userThe person using it: id, name, email, tenantId, roles.
scopesWhat the app asked for, narrowed to what the person's session holds.
dataTables, records, queries, saved queries, Custom APIs, reports and record files, bound to the environment.
storageObject storage: buckets and the files in them.
logStructured logging, sent to the platform with the app and environment attached.

Who is using it, and what they may do​

runtime.user is for showing and greeting: a name, a role to choose a layout by. It is not a permission check.

Every call goes out as the person using the app. Their security role decides what each one may read and change, exactly as in a model-driven app, so an app can never do more than the person who opened it, and two people running the same app see what each of them is allowed to.

runtime.scopes is the app's requestedScopes narrowed to the person's session. Read it to leave out what will not work:

const canSave = runtime.scopes.includes('storage.write');

Reading records​

A page of one table​

import { FilterConnector, RecordFilterOperator } from '@parsware/runtime-sdk';

const page = await runtime.data.queryRecords('par_order', {
pageNumber: 1,
pageSize: 25,
sorts: [{ field: 'par_createdon', descending: true }],
filterGroup: {
connector: FilterConnector.And,
conditions: [{ field: 'par_status', operator: RecordFilterOperator.Equals, value: 1 }],
},
});
// page.items, page.totalCount, page.hasNextPage …

A page, never the whole table. { pageSize: 1 } is the cheap way to read just a count.

runQuery takes the same query document the platform's own grids send, so it can choose columns, follow lookups and add up:

const result = await runtime.data.runQuery('nw_visitor', {
select: ['nw_name', 'nw_company', 'new_purpose'],
order: [{ field: 'nw_name', descending: false }],
page: { number: 1, size: 50 },
});
for (const record of result.records?.items ?? []) {
record.values.nw_name; // 'Ada Lovelace'
record.values.new_purpose; // 270150000 — what is stored
record.formattedValues?.new_purpose; // 'Meeting' — what a person reads
}

With an aggregate block it answers groups instead of rows (result.kind === 'Groups'). Ask the server for totals rather than fetching every row to add them up in the browser.

A saved query, by name​

When the question belongs to the solution rather than to the app's code, save it in the solution and run it by name:

const totals = await runtime.data.runSavedQuery('nw_visitorsbycompany');
for (const group of totals.groups?.groups ?? []) {
group.groups.nw_company; // 'Analytical Engines'
group.measures.visitors; // 4
}

The definition then travels with the solution and can be improved without shipping a new build. The options can only narrow it: an extra filter is ANDed with the saved one, and a page or top limits it. What it reads, groups and counts stays the saved query's.

Lookups and choices​

  • searchLookup(table, { search }) finds candidates for a lookup, with the columns the target table's lookup view shows. resolveLookup(table, id) turns a stored id back into the text to display.
  • A choice column comes as the option's number, with its label beside it. values holds the stored number and formattedValues the label a person reads, the way a lookup's name travels. Grouped results carry it in formattedGroups. listOptionSets() gives the whole list, for a picker.

Changing records​

const created = await runtime.data.createRecord('par_order', { par_name: 'ORD-1001' });
await runtime.data.updateRecord('par_order', created.id, { par_status: 2 });
await runtime.data.deleteRecord('par_order', created.id);

updateRecord takes only the columns being changed. Sending a whole record back re-writes values somebody else may have changed since you read it. Server plug-in steps run on these writes as on any other; a form's client business rules do not, because there is no form.

Calling a Custom API​

const overview = await runtime.data.callApi('GET', 'psl/v1/overview', { season: '2026' });

The path is the API's address below the environment: publisher prefix, version, route. For GET and DELETE the inputs go in the query string, otherwise in the JSON body. A refusal throws with the API's own message. An app and an outside system that call the same API read the same answer.

Object storage​

For files that belong to no single record: an export the app made, a document somebody handed it, a batch it is about to process.

await runtime.storage.createBucket('nw-frontdesk'); // or returns the existing one
const saved = await runtime.storage.put('nw-frontdesk', 'visitors/2026-10-14.csv',
new Blob([csv], { type: 'text/csv' }), '2026-10-14.csv');
const files = await runtime.storage.list('nw-frontdesk', 'visitors/');
const blob = await runtime.storage.get('nw-frontdesk', saved.key);
await runtime.storage.remove('nw-frontdesk', saved.key);
  • A bucket belongs to the tenant, not to the environment. Two environments of one tenant share it, so put the environment in the key if their files must stay apart.
  • Bucket names are shared across every tenant, as in S3. Choose one specific to you.
  • Ask for storage.read and storage.write in the app's requestedScopes.
  • A file that is one record's own goes on the record instead (next section).

A record's files and pictures​

A File or Image column holds a small file belonging to that record:

const info = await runtime.data.getRecordFile('par_contact', id, 'par_photo'); // null when empty
await runtime.data.uploadRecordFile('par_contact', id, 'par_photo', file);
const blob = await runtime.data.downloadRecordFile('par_contact', id, 'par_photo');
await runtime.data.deleteRecordFile('par_contact', id, 'par_photo');
  • The upload sets the column itself. Don't put the field in createRecord or updateRecord.
  • The record must exist first. Create it, then upload.
  • There is no link to put in <img src>. The file is authorized as its record, so show the Blob through URL.createObjectURL(blob).

Tables and their definitions​

listEntities(), listFields(entityId), listViews(entityId), getViewDefinition(…), listForms(entityId), getFormDefinition(…), listRelationships(), listOptionSets() and getEntityStatus(entityId) describe the environment, for an app that draws its own grid or form. getRecordPermissions(table) says whether the person may create, read, update or delete in a table, so the app can leave out a button before it is refused. canImport says whether they may load a spreadsheet into it, which takes the Import data privilege as well as creating.

Logging​

runtime.log('info', 'Visitor list saved', { rows: 8 });
runtime.log('error', 'Could not load visitors', { message });

Levels are debug, info, warn and error. The app and the environment are attached for you. Never log record values, personal data or anything from a token. The browser console is invisible to whoever supports the app; this is not.

Handling failures​

Every call returns a promise that can reject: no permission, a record deleted meanwhile, a network down. Show the message, and stop setting state once the app has closed:

useEffect(() => {
let cancelled = false;
runtime.data.runQuery('nw_visitor', { page: { number: 1, size: 50 } })
.then((r) => { if (!cancelled) setRows(r.records?.items ?? []); })
.catch((e: unknown) => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [runtime]);

What the SDK does not do​

  • It does not send notifications. There is no notification surface; don't fake one with a direct HTTP call.
  • It does not save a Custom Control's value. A control commits through onChange, and the form saves.
  • It does not hand out tokens. There is nothing to read and nothing to store.
  • It does not reach other environments. Moving work between environments is a solution export and import.

Troubleshooting​

  • useParswareRuntime() throws — the component rendered outside a runtime provider. In the Shell that cannot happen; in a test, wrap it in ParswareRuntimeProvider.
  • The runtime is there but empty, or hooks fail — @parsware/runtime-sdk or React is not singleton: true in the Module Federation shared block, so the app bundled its own copy.
  • A call is refused — the person using the app may not do that; their security role decides. Check runtime.scopes and getRecordPermissions, and leave the action out.
  • A choice column shows a number — read formattedValues, not values, for its label.
  • Cannot find module …/dist/types under Vitest — the SDK was installed from an older download. Install the current one.
  • Build a Custom React App.
  • Build a Custom Control.
  • Obtain an access token.