Worker Apps

Open in Claude

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
}
OptionDescription
operationsWhat the handle provides: 'notify' enables handle.hub, 'suggestWriteback' enables handle.ehr
fieldsDotted paths into the nested context data that must be present before the handler fires (e.g., ['demographics.firstName'])
debounceMsWait 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:

  1. Vim shows the provider a push notification with your text and Approve and Deny buttons.
  2. On Approve, your onGranted callback receives a handle that is already authorized, and you run the update with it.
  3. 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

OptionTypeDescription
entitystringEntity type to update (e.g., 'encounter')
fieldsstring[]Field paths to update (dot notation)
notificationNotificationDetailsNotification shown to request permission
onGranted(wb) => Promise<void>Called when provider approves — perform the update here
onDenied() => voidCalled when provider declines
onTimeout() => voidCalled when the request times out
onInvalidated() => voidCalled when the context is invalidated before a response
onAppStatusChange(status) => voidCalled when app open/close state changes

On this page