> ## Documentation Index
> Fetch the complete documentation index at: https://omniloy.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Activity events (onEvent)

> Subscribe to what happens inside the widget —recording, clinician activity, report generation— to detect inactivity and keep your system's session alive

The `handleReport`, `onReportApply` and `handleExtras` callbacks fire at the **end** of a workflow. They tell you nothing about what happens in between: whether the clinician is recording, whether they are typing, whether SofIA is generating the note. `onEvent` fills that gap with a stream of **typed, PHI-free events**.

The use case that motivated it: a HIS that logs users out on inactivity cannot tell "the clinician has been recording a consultation for twenty minutes without touching the keyboard" from "the clinician walked away twenty minutes ago". With the microphone active and no other activity, it was logging the clinician out mid-consultation.

<Info>
  Events **never** carry clinical text, report content, what was typed, or patient or clinician identifiers. Every payload field is an enum, a boolean or a number. If you need the clinical content, you already have dedicated channels for it (`handleReport`, `onReportApply`, `handleExtras`).
</Info>

## The two props

| Property               | Type       | How to assign it                                                                                                                             |
| ---------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| **onEvent**            | `function` | **JS property only** (`el.onEvent = fn`). In React, the `onEvent` prop                                                                       |
| **eventSubscriptions** | `string[]` | JS property `el.eventSubscriptions = [...]` or web component attribute `event-subscriptions` (JSON). In React, the `eventSubscriptions` prop |

```typescript theme={null}
onEvent: (event: SdkEvent) => void
eventSubscriptions: Array<'recording.*' | 'activity.*' | 'report.*' | 'lifecycle.*' | '*' | 'recording.started' | /* …any exact name */>
```

Subscription is **explicit**: without `eventSubscriptions` (or with an empty array) no event is delivered, and the SDK logs a console warning if you pass `onEvent` without it. It accepts exact names (`"recording.started"`), family wildcards (`"recording.*"`) or everything (`"*"`).

<Tabs>
  <Tab title="React">
    ```tsx theme={null}
    <Omniscribe
      apikey={apiKey}
      userid={userId}
      patientid={patientId}
      eventSubscriptions={['recording.*', 'activity.*', 'report.*']}
      onEvent={event => {
        if (event.name === 'recording.started') idleTimer.cancel();
        if (event.name === 'recording.stopped') idleTimer.start();
        if (event.family === 'activity') idleTimer.reset();
      }}
    />
    ```
  </Tab>

  <Tab title="Web Component">
    ```html theme={null}
    <sofia-sdk
      id="sofia"
      apikey="your-api-key"
      userid="user_12345"
      patientid="patient_67890"
      event-subscriptions='["recording.*", "activity.*", "report.*"]'
    ></sofia-sdk>

    <script>
      customElements.whenDefined('sofia-sdk').then(() => {
        const sofia = document.getElementById('sofia');
        sofia.onEvent = (event) => {
          if (event.name === 'recording.started') idleTimer.cancel();
          if (event.name === 'recording.stopped') idleTimer.start();
          if (event.family === 'activity') idleTimer.reset();
        };
      });
    </script>
    ```
  </Tab>
</Tabs>

## Shape of an event

```typescript theme={null}
interface SdkEvent {
  name: string;          // 'recording.started', 'activity.typing', …
  family: 'recording' | 'activity' | 'report' | 'lifecycle';
  level: 'info' | 'warn' | 'error';
  ts: string;            // ISO-8601
  seq: number;           // monotonic per page load; guarantees ordering
  sdkVersion: string;
  sessionId: string | null;  // random UUID per consultation; identifies nobody
  payload: Record<string, unknown>;  // per event, see the table
}
```

`sessionId` is the same random identifier the SDK uses for its internal analytics. It changes with every patient, so you can correlate a burst of events to one consultation and discard any that arrive after a patient switch.

## Event catalogue

### `recording`

| Event                               | Payload                                                      | When                                                                                                                                                                                                                                            |
| ----------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `recording.started`                 | `{ mode: 'consultation' \| 'dictation' }`                    | The microphone opened and recording begins                                                                                                                                                                                                      |
| `recording.stopped`                 | `{ mode, durationSeconds: number }`                          | Recording ended, for any reason (manual stop, server loss, startup timeout)                                                                                                                                                                     |
| `recording.heartbeat`               | `{}`                                                         | **Audio is actually flowing** to the transcriber. Beats on the first audio chunk and then every \~30s for as long as chunks keep being sent. See [keeping an external session alive](#keeping-an-external-session-alive)                        |
| `recording.audio_lost`              | `{ cause: 'mic' \| 'network' \| 'server', durationSeconds }` | *(level `warn`)* Audio was lost during a recording. For `mic`/`network` it is emitted on recovery and reports how long the gap lasted; for `server` it is emitted immediately and reports the recording position at which the connection closed |
| `recording.microphone_disconnected` | `{}`                                                         | *(level `warn`)* The microphone track ended or was muted at system level while recording                                                                                                                                                        |

### `activity`

| Event                  | Payload                                                                                              | When                                                                                                                                                                                                                                           |
| ---------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `activity.interaction` | `{ kind: 'recording' \| 'chat' \| 'settings' \| 'report' \| 'transcript' \| 'history' \| 'widget' }` | The clinician clicked a control in the widget. `kind` is the coarse area, never the specific button                                                                                                                                            |
| `activity.typing`      | `{ surface: 'chat' \| 'note' }`                                                                      | The clinician typed in the chat or edited the note in the [insertion preview](/sofia/en/sdk/insertion-preview). Surface only: **not a single character of what was typed**. Text that dictation inserts into the chat does not count as typing |

Both are throttled to **one event every 5 seconds per kind/surface**, on the leading edge: the first click or keystroke is emitted immediately and the following ones are suppressed for the window. For idle detection, over-emitting is harmless (your timer just resets); under-emitting would close the app on someone mid-sentence.

### `report`

| Event                       | Payload           | When                                                                                                                          |
| --------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `report.generation_started` | `{}`              | The clinician clicked **Generate** or **Regenerate** and the request is in flight                                             |
| `report.settled`            | `{ ok: boolean }` | Generation finished. `ok: true` only if a report was delivered; `false` on any other exit (error, cancellation, cached draft) |

<Warning>
  **Suppress your inactivity timer between `report.generation_started` and `report.settled`.** Generation runs for tens of seconds with no input from the clinician —it looks exactly like idleness— and closing the widget there loses the note. The two events **always come in pairs**: the SDK guarantees the `settled` from every code exit, not from event names.
</Warning>

### `lifecycle`

| Event              | Payload | When                                                                            |
| ------------------ | ------- | ------------------------------------------------------------------------------- |
| `lifecycle.ready`  | `{}`    | The widget finished loading its configuration and is usable. Once per page load |
| `lifecycle.closed` | `{}`    | The clinician clicked the widget's close button                                 |

## Keeping an external session alive

If your system logs users out on inactivity, what it needs is **periodic activity pings**, not the `started`/`stopped` edges. That is what `recording.heartbeat` is for.

```typescript theme={null}
onEvent={event => {
  if (event.name === 'recording.heartbeat') session.keepAlive();
  if (event.family === 'activity') session.keepAlive();
}}
```

Your integration holds no state. And it **fails closed**: if audio stops flowing for any reason —microphone unplugged, network down, zombie connection, frozen tab— the beats stop and your own timeout runs.

<Tip>
  Why not build it on `recording.started` / `recording.stopped`? Because twenty minutes can pass between the two with no other event, so you would need your own `setInterval` between them. And if you ever miss the `stopped` (a reload mid-recording, a hung tab, a `throw` in your handler), that interval keeps the session alive forever — precisely the failure an inactivity logout exists to prevent.
</Tip>

The heartbeat comes from the very spot the SDK uses internally to know a recording is healthy: the send of each audio chunk, which only happens with the transcriber ready, the connection open and the microphone capturing. It keeps beating while the clinician is silent, because audio chunks are sent regardless.

## Full recipe for an inactivity timer

```typescript theme={null}
let generating = false;

onEvent={event => {
  switch (event.family) {
    case 'recording':
    case 'activity':
      idleTimer.reset();           // anything here is the clinician working
      break;
    case 'report':
      generating = event.name === 'report.generation_started';
      if (generating) idleTimer.pause(); else idleTimer.resume();
      break;
  }
}}
```

With `eventSubscriptions={['recording.*', 'activity.*', 'report.*']}`. A `recording.audio_lost` or `recording.microphone_disconnected` also resets the timer here —reasonable: the clinician is still in front of the screen looking at the warning— but if you would rather treat them as the end of activity, compare on `event.name`.

## Delivery guarantees

* **Asynchronous.** Every event is delivered on a later microtask, never inside React's render or on the audio path. Your handler can `setState` safely, and a slow handler does not delay recording.
* **Ordered.** `seq` is monotonic and events arrive in that order.
* **Isolated.** If your handler throws, the SDK catches it and keeps working; it logs a single console warning per page load.
* **Stable across patients.** Changing `patientid` or `userid` remounts the widget internally, but your subscription and handler survive without being reassigned. Passing an inline function on every render does not re-subscribe either.
* **One widget per page.** The SDK supports a single `<Omniscribe>`/`<sofia-sdk>` instance. If a second `onEvent` were registered, the newest wins and a console warning is logged.
* **Open set of names.** New events may be added in minor releases. Compare on `event.name` or `event.family` and treat unknown names as "something happened"; do not assume a closed set.

<Info>
  For an exhaustive `switch` in TypeScript, narrow `event.name` to `KnownSdkEventName`. The `SdkEvent`, `SdkEventName`, `KnownSdkEventName`, `SdkEventFamily`, `SdkEventHandler`, `SdkEventPayloadMap` and `SdkEventSubscription` types are exported from `@omniloy/sofia-sdk`.
</Info>

## Privacy

The design rests on an invariant the SDK's own compiler enforces: **no field of any payload is a free-form string**. Errors travel as codes, reports as booleans, typing as the name of the surface. The SDK has a test that reads the type definition and fails the build if an unconstrained `string` appears.

That is why `onEvent` is **not** a dump of the SDK's internal log: log messages are hand-written prose that interpolates identifiers and whole error objects, and that cannot be made safe with a filter. If you need to debug, use the `debug` prop and the console; if you need clinical content, use the dedicated callbacks.

## Next steps

<CardGroup cols={2}>
  <Card title="Optional properties" icon="sliders" href="/sofia/en/sdk/optional-properties">
    Every prop and callback of the component
  </Card>

  <Card title="Insertion preview" icon="eye" href="/sofia/en/sdk/insertion-preview">
    Where `activity.typing` with `surface: 'note'` comes from
  </Card>
</CardGroup>
