Worker Apps
Build headless background apps with the Worker SDK
Worker Apps run in a hidden offscreen document — no UI, no sidebar. They receive the same EHR events and context as UI apps but operate in the background, ideal for processing data, calling external APIs, or triggering notifications without requiring the provider to open your app.
When to Use Worker Apps
- Background processing — Analyze patient data and prepare results before the provider opens your app
- Proactive notifications — Show push notifications when specific conditions are detected (e.g., care gaps, drug interactions)
- Data sync — Push EHR data to your backend as encounters happen
- AI assistants — Process encounter context in the background and suggest documentation
Initialization
import { initWorkerVimSDK } from '@vimconnect/app-sdk';
const worker = await initWorkerVimSDK({ accessToken, debug: true });Worker initialization is identical to UI app initialization — a worker authenticates the same way and takes the same options as initVimSDK. See Authentication for the two ways to give the SDK a token, and Initialization for the full option list. Only the returned SDK differs: WorkerSDK uses a register pattern instead of event subscriptions.
Lifecycle
One worker runs per app per EHR browser tab. Its lifecycle is managed by the extension:
- Launch — starts once the provider is logged into Vim and the tab is detected as an EHR.
- Survives — an EHR page refresh and in-EHR (SPA) navigation.
- Teardown — on tab close, Vim logout, navigation to a non-EHR page, and browser/extension close. The worker re-launches after the provider logs back in and returns to the EHR.
The worker is independent of your UI app. It starts as soon as the provider lands on the EHR,
whether or not your app has ever been opened, and it keeps running when the app is closed again.
If your app ships both surfaces, nothing about the UI app gates the worker — it only needs a Worker
URL on the app and the app installed for that provider. hub.appState reports whether the UI app
is open; it does not control the worker.
Registering Context Handlers
Unlike UI apps that subscribe to events, Worker apps register handlers that are called with a scoped handle for each context change. When you register after context is already available, the callback fires once with the current context (the seed call), then again on every change. The callback is positional — (previousData, currentData, handle) — and previousData is undefined on that first seed call:
worker.ehr.context.register(
'encounter_open:encounter',
{ operations: ['notify', 'suggestWriteback'] },
async (prev, curr, handle) => {
if (!curr) return; // encounter closed
// Use the handle to interact with the EHR
// The handle is scoped to this specific context event
console.log('Encounter opened:', curr.id);
// Show a notification
handle.hub?.pushNotification.show({
title: 'Processing...',
text: 'Analyzing encounter data',
notificationId: `processing-${curr.id}`,
type: 'info',
timeoutInSec: 5,
});
// Call your backend, run analysis, etc.
const suggestions = await analyzeEncounter(curr);
// Suggest a writeback to the EHR
if (suggestions) {
handle.ehr?.context.suggestWriteback({
entity: 'encounter',
fields: ['assessment', 'plan'],
notification: {
title: 'Documentation Ready',
text: 'AI-generated suggestions are available',
notificationId: `suggestions-${curr.id}`,
type: 'success',
},
onGranted: async (wb) => {
// Provider approved — write to the encounter
await wb.ehr.encounter.update({
assessment: suggestions.assessment,
});
},
onDenied: () => console.log('Provider declined'),
onTimeout: () => console.log('Request timed out'),
});
}
},
);Registering Workflow Handlers
worker.ehr.workflow.register(
'chart_open',
{ operations: ['notify'] },
async (event, handle) => {
const patientId = event.entities.patient?.id;
if (!patientId) return;
// Fetch data from your backend
const alerts = await fetchPatientAlerts(patientId);
if (alerts.length > 0) {
handle.hub?.pushNotification.show({
title: `${alerts.length} Alert(s)`,
text: alerts[0].message,
notificationId: `alert-${patientId}`,
type: 'warning',
timeoutInSec: 15,
actionButtons: {
rightButton: {
text: 'View',
buttonStyle: 'PRIMARY',
openAppButton: true,
callback: () => {},
},
},
});
}
},
);Hook Declarations
The second argument to register is a hook declaration that tells the extension what capabilities your handler needs:
interface HookDeclaration {
operations?: Array<'notify' | 'suggestWriteback'>;
fields?: string[]; // Only fire when these fields are non-null
debounceMs?: number; // Debounce after field availability gate
}| Option | Description |
|---|---|
operations | What the handle provides: 'notify' enables handle.hub, 'suggestWriteback' enables handle.ehr |
fields | Dotted paths into the nested context data that must be present before the handler fires (e.g., ['demographics.firstName']) |
debounceMs | Wait this many ms after all gated fields are available before firing |
fields entries are dotted paths, spelled the same way as a manifest read
grant: 'demographics.firstName', not 'firstName'. A bare name addresses a
top-level key only. An array adds no segment, so 'problems.code' reaches the
code of every item.
Declaring fields also scopes what the handler receives: curr.fields
keeps its nested shape but carries only the declared paths, so the handler still
reads curr.fields.demographics.firstName. Beyond the availability gate, the
handler is skipped when none of the declared paths changed since the previous
delivery — entering and leaving context always fire regardless.
Communicating with the UI App
Worker apps can pass data to the UI app via workerState:
// Worker: write state
worker.workerState.write('analysisResult', {
score: 85,
recommendations: ['Check blood pressure'],
});
// Worker: clear state
worker.workerState.remove('analysisResult');The UI app reads it via sdk.workerState.on():
// UI App: subscribe to worker state
sdk.workerState.on('analysisResult', (prev, next) => {
if (next) {
console.log('Worker sent:', next);
}
});Sending an Event the Other Way
workerState is worker → UI. To go UI → worker, the UI app sends a named event with
sdk.appEvents.send(), and the worker listens with worker.appEvents.on(). It is fire-and-forget:
there is no return value and no delivery acknowledgment, so observe the outcome through
workerState.
// UI App: ask the worker to re-run its analysis
if (sdk.appEvents) {
try {
sdk.appEvents.send('refresh', { reason: 'user clicked refresh' });
} catch (err) {
// VALIDATION_ERROR (bad name or oversized payload) or NO_WORKER
console.error('appEvents.send failed', err);
}
}
// Worker App: react, then publish the result back through workerState.
// The callback receives the payload directly.
worker.appEvents?.on('refresh', async (payload) => {
const result = await recompute(payload);
worker.workerState.write('analysisResult', result);
});Feature-detect appEvents on both sides
appEvents is optional on the UI SDK and on the Worker SDK — it is present only when the host
extension advertises the appEvents capability, and is undefined on older builds. Guard the
sender with if (sdk.appEvents) and the listener with worker.appEvents?.on(...).
The SDK does not catch errors your listener throws: a synchronous throw surfaces as
window.onerror and a rejected async handler as unhandledrejection, both in your own app
context. Wrap the handler body in try/catch if you want to own that.
Errors are thrown as SDKErrors: VALIDATION_ERROR for an invalid event name or a
non-serializable or oversized payload, and NO_WORKER when the app is single-surface and has no
Worker App to receive the event.
Launch Context
When a provider taps a Worker notification that has a launchPayload, the UI app can retrieve it:
// Worker: include payload in notification
handle.hub?.pushNotification.show({
text: 'Results ready',
notificationId: 'results',
type: 'success',
launchPayload: { patientId: '123', analysisId: 'abc' },
actionButtons: {
rightButton: { text: 'View', buttonStyle: 'PRIMARY', openAppButton: true, callback: () => {} },
},
});
// UI App: read the launch context (consume-once)
const context = sdk.consumeLaunchContext();
if (context?.source === 'worker-notification') {
const { patientId, analysisId } = context.launchPayload;
// Load the specific analysis
}Handle Lifecycle
Worker handles have a TTL of 10 seconds. After a handle expires, its methods become no-ops. Monitor handle validity:
worker.ehr.context.register(
'chart_open:patient',
{ operations: ['notify'] },
async (prev, curr, handle) => {
// Check if handle is still valid
if (!handle.hub?.isValid()) return;
// Get notified when the handle expires
handle.hub?.onInvalidated((reason) => {
// reason: 'ttl_expired' | 'context_invalidated' | 'superseded'
console.log('Handle invalidated:', reason);
});
// Do work while handle is valid
handle.hub?.pushNotification.show({ /* ... */ });
},
);Writing Back from a Worker
A worker cannot write to the EHR directly. Its handle can read (handle.api), but it has no
update(). To write, a worker calls suggestWriteback:
- Vim shows the provider a push notification with your text and Approve and Deny buttons.
- On Approve, your
onGrantedcallback receives a handle that is already authorized, and you run the update with it. - On Deny, timeout or a context change, the matching callback runs instead and nothing is written.
The provider approves every worker writeback, whether or not the same write would be disruptive from a UI app, and an earlier Allow always does not skip it. They never have to open your app to approve.
suggestWriteback throws while your UI app is open. In that case the UI app writes, through the
normal Writeback flow.
Suggest Writeback Options
| Option | Type | Description |
|---|---|---|
entity | string | Entity type to update (e.g., 'encounter') |
fields | string[] | Field paths to update (dot notation) |
notification | NotificationDetails | Notification shown to request permission |
onGranted | (wb) => Promise<void> | Called when provider approves — perform the update here |
onDenied | () => void | Called when provider declines |
onTimeout | () => void | Called when the request times out |
onInvalidated | () => void | Called when the context is invalidated before a response |
onAppStatusChange | (status) => void | Called when app open/close state changes |