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

# Events WebSocket

> Subscribe to desktop events: window focus, clipboard, processes, screen size, and idle state.

Subscribe to real-time desktop events over WebSocket. Window focus changes, clipboard updates, process lifecycle, screen resolution changes, and idle/active state are pushed to your client as they happen. File change events are defined but not delivered on Linux today; see [File changes](#file-changes).

## Connection URL

```text theme={null}
wss://www.orgo.ai/desktops/{instance_id}/ws/events?token={password}
```

`{instance_id}` is the computer's `id` from [Create computer](/api-reference/computers/create). Its instance id also resolves.

### Authentication

Pass the computer's password as the `token` query parameter. Retrieve it from [Get VNC password](/api-reference/computers/vnc-password) before connecting.

Always authenticate with the computer password. orgo-web also accepts an API key, sent as `Authorization: Bearer $ORGO_API_KEY` or as `?token=`, but it does not forward the key to the computer. The computer then refuses the stream, and the connection closes with code `1000` right after it opens. An `sk_` key in the query string is refused outright, with `4001`, for any connection that carries an `Origin` header.

A connection with no usable credential is closed with code `4001`. See [Close codes](#close-codes).

### Query parameters

<ParamField query="token" type="string" required>
  Computer password, from [Get VNC password](/api-reference/computers/vnc-password).
</ParamField>

## Event catalog

### Available event types

| Type | Description | Detection |
| - | - | - |
| `window_focus` | Active window changed | Polled every 100 ms |
| `window_open` | New window opened | Polled every 100 ms |
| `window_close` | Window closed | Polled every 100 ms |
| `clipboard` | Clipboard content changed | Polled every 250 ms |
| `file_change` | File created, modified, or deleted under a watched directory | `inotifywait -m -r` |
| `screen_change` | Display resolution changed | Polled every 1 s |
| `audio_stream_start` | An audio stream started playing | Polled every 500 ms |
| `audio_stream_stop` | An audio stream stopped | Polled every 500 ms |
| `process_start` | New process started | Polled every 2 s |
| `process_stop` | Process exited | Polled every 2 s |
| `idle` | No user input for 30 seconds | Polled every 1 s |
| `active` | User input resumed after idle | Polled every 1 s |

Watchers run for the life of the computer, independent of any connection. An event describes a change, never current state, so connecting does not replay what you missed.

<Note>
  The catalog, detection, and payloads on this page are a Linux computer's. A Windows computer emits only `window_focus`, `clipboard`, and `file_change`. There, `window_id` is a hexadecimal window handle such as `0x1a2b3c`, `clipboard` carries `text` and `length` instead of `content` and `truncated`, and `file_change` carries `action` (`created`, `deleted`, `modified`, `renamed_from`, `renamed_to`, or `unknown`), `path`, `directory`, `name`, and `is_hidden`. Windows `file_change` events are delivered, for the Administrator's Desktop and Downloads folders.
</Note>

## Subscription model

New connections receive **no events** until they send a `subscribe` message. This lets you opt in to only the event types you care about, reducing noise and bandwidth.

Subscriptions are:

* **Per-connection**: each WebSocket connection has its own subscription set
* **Additive**: subscribe to more types at any time
* **Selective**: unsubscribe from specific types without disconnecting

<Warning>
  Event type names are not validated. Subscribing to a name that does not exist, such as a typo or a type from a newer version, returns `subscribed` / `ok` and then silently delivers nothing. Check your names against the [catalog](#available-event-types).
</Warning>

## Message protocol

### Client → server messages

<AccordionGroup>
  <Accordion title="subscribe" icon="bell">
    Start receiving specific event types. Call it as many times as you like to add more.

    ```json theme={null}
    {
      "type": "subscribe",
      "event_types": ["window_focus", "clipboard", "file_change"]
    }
    ```

    <ParamField body="type" type="string" required>
      Must be `"subscribe"`.
    </ParamField>

    <ParamField body="event_types" type="string[]" required>
      Event type names to add to this connection's filter. See the [event catalog](#available-event-types). Names are not validated. See the warning above. Omitting the field adds nothing and still returns `subscribed`.
    </ParamField>
  </Accordion>

  <Accordion title="unsubscribe" icon="bell-slash">
    Stop receiving specific event types.

    ```json theme={null}
    {
      "type": "unsubscribe",
      "event_types": ["clipboard"]
    }
    ```

    <ParamField body="type" type="string" required>
      Must be `"unsubscribe"`.
    </ParamField>

    <ParamField body="event_types" type="string[]" required>
      Event type names to remove from this connection's filter. Names not currently subscribed are ignored.
    </ParamField>
  </Accordion>

  <Accordion title="ping" icon="heart-pulse">
    Ask for a `pong`.

    ```json theme={null}
    {
      "type": "ping"
    }
    ```
  </Accordion>
</AccordionGroup>

<Note>
  A frame that parses as JSON but carries an unrecognised `type` is discarded silently. You get no reply and no error.
</Note>

### Server → client messages

<AccordionGroup>
  <Accordion title="event" icon="bolt">
    A desktop event matching one of your subscribed types.

    ```json theme={null}
    {
      "type": "event",
      "event": {
        "type": "window_focus",
        "timestamp": "2026-03-03T12:00:00.123Z",
        "data": {
          "window_id": "52428804",
          "window_name": "Google Chrome"
        }
      }
    }
    ```

    <ResponseField name="type" type="string">
      Always `"event"`.
    </ResponseField>

    <ResponseField name="event" type="object">
      The event payload.
    </ResponseField>

    <ResponseField name="event.type" type="string">
      Event type name, for example `"window_focus"` or `"clipboard"`.
    </ResponseField>

    <ResponseField name="event.timestamp" type="string">
      ISO 8601 timestamp of when the event was published.
    </ResponseField>

    <ResponseField name="event.data" type="object">
      Event-specific data. See [Event data schemas](#event-data-schemas) below.
    </ResponseField>
  </Accordion>

  <Accordion title="subscribed" icon="check">
    Confirmation that your subscription was updated. Sent even when every name you passed was unknown.

    ```json theme={null}
    {
      "type": "subscribed",
      "message": "ok"
    }
    ```
  </Accordion>

  <Accordion title="unsubscribed" icon="check">
    Confirmation that event types were removed from your subscription.

    ```json theme={null}
    {
      "type": "unsubscribed",
      "message": "ok"
    }
    ```
  </Accordion>

  <Accordion title="pong" icon="heart-pulse">
    Response to a `ping`.

    ```json theme={null}
    {
      "type": "pong"
    }
    ```
  </Accordion>

  <Accordion title="error" icon="circle-exclamation">
    Your last frame was not valid JSON. This is the only `error` this endpoint sends, and the connection stays open.

    ```json theme={null}
    {
      "type": "error",
      "message": "invalid JSON"
    }
    ```

    <ResponseField name="type" type="string">
      Always `"error"`.
    </ResponseField>

    <ResponseField name="message" type="string">
      Always `"invalid JSON"`.
    </ResponseField>
  </Accordion>
</AccordionGroup>

## Event data schemas

Each event carries a `data` object with type-specific fields.

### Window events

```json theme={null}
// window_focus
{ "window_id": "52428804", "window_name": "Google Chrome" }

// window_open
{ "window_id": "52428808", "window_name": "Terminal" }

// window_close
{ "window_id": "52428808" }
```

`window_id` is the X11 window id as a decimal string, as printed by `xdotool`. `window_name` is the X11 window title, as `xdotool getwindowname` prints it. `window_close` carries no name. The window is already gone by the time the change is noticed.

### Clipboard

```json theme={null}
// clipboard
{ "content": "copied text from the clipboard", "truncated": false }
```

<ResponseField name="content" type="string">
  Clipboard text, trimmed of surrounding whitespace and capped at 1024 bytes.
</ResponseField>

<ResponseField name="truncated" type="boolean">
  `true` when the real clipboard was longer than 1024 bytes and `content` holds only its first 1024. Check this before treating `content` as the whole clipboard.
</ResponseField>

### File changes

```json theme={null}
// file_change
{ "path": "/root/Desktop/report.pdf", "operation": "CREATE" }
{ "path": "/root/Downloads/data.csv", "operation": "CLOSE_WRITE,CLOSE" }
{ "path": "/root/Desktop/old.txt", "operation": "DELETE" }
```

<ResponseField name="path" type="string">
  Absolute path of the file that changed.
</ResponseField>

<ResponseField name="operation" type="string">
  The raw inotify event name in uppercase, for example `CREATE`, `MODIFY`, `DELETE`, `CLOSE_WRITE`, `MOVED_TO`, `MOVED_FROM`. inotify reports several flags for one change, in which case they arrive comma-joined with no spaces, as in `CLOSE_WRITE,CLOSE`. Match on substrings rather than on equality.
</ResponseField>

Monitored directories are `/root/Desktop` and `/root/Downloads`, watched recursively. The desktop runs as root, so these are the desktop and downloads folders a user of the computer sees. Detection is via Linux inotify.

<Warning>
  On Linux, `file_change` events are not delivered today. The computer buffers the watcher's output until the watcher exits, and it never exits, so a `file_change` subscription receives nothing. To detect a finished download, poll the directory with [Execute bash](/api-reference/computers/bash) instead.
</Warning>

### Screen

```json theme={null}
// screen_change
{ "width": 1920, "height": 1080 }
```

### Audio

```json theme={null}
// audio_stream_start
{ "count": 1 }

// audio_stream_stop
{ "count": 0 }
```

<ResponseField name="count" type="integer">
  Number of active PulseAudio streams **after** the change. `audio_stream_start` fires when the count rises, `audio_stream_stop` when it falls. A second stream starting while one is already playing sends `audio_stream_start` with `count: 2`.
</ResponseField>

### Process lifecycle

```json theme={null}
// process_start
{ "pid": "1234", "name": "chrome" }

// process_stop
{ "pid": "1234", "name": "chrome" }
```

`pid` is a string, not a number. `name` is the short command name from `ps -eo comm`, not a full command line. When that name contains a space, only its first word is sent.

### Idle / active

```json theme={null}
// idle
{ "idle_ms": 30000 }

// active
{ "idle_ms": 15 }
```

<ResponseField name="idle_ms" type="integer">
  Milliseconds since the last user input, as reported by `xprintidle` at the moment the transition was noticed. Present on **both** events: `idle` carries the elapsed idle time that crossed the 30-second threshold, and `active` carries the residual reading at the moment input resumed. There is no `idle_seconds` field.
</ResponseField>

## Examples

<CodeGroup>
  ```javascript JavaScript theme={null}
  // The computer's id fills the /desktops/{instance_id} slot.
  const instanceId = process.env.COMPUTER_ID;
  const apiKey = process.env.ORGO_API_KEY;

  // Step 1: Get the computer password
  const res = await fetch(
    `https://www.orgo.ai/api/computers/${instanceId}/vnc-password`,
    { headers: { 'Authorization': `Bearer ${apiKey}` } }
  );
  const { password } = await res.json();

  // Step 2: Connect to the events stream
  const ws = new WebSocket(
    `wss://www.orgo.ai/desktops/${instanceId}/ws/events?token=${password}`
  );

  ws.onopen = () => {
    // Subscribe to the events you care about
    ws.send(JSON.stringify({
      type: 'subscribe',
      event_types: ['window_focus', 'clipboard', 'file_change', 'idle', 'active']
    }));
  };

  ws.onmessage = (event) => {
    const msg = JSON.parse(event.data);

    switch (msg.type) {
      case 'event':
        const { type, timestamp, data } = msg.event;
        console.log(`[${timestamp}] ${type}:`, data);

        // React to specific events
        if (type === 'window_focus') {
          console.log(`Focus moved to: ${data.window_name}`);
        }
        if (type === 'clipboard') {
          console.log(`Clipboard: ${data.content}${data.truncated ? '…' : ''}`);
        }
        if (type === 'idle') {
          console.log(`Desktop idle for ${data.idle_ms / 1000}s`);
        }
        break;

      case 'subscribed':
        console.log('Subscription confirmed');
        break;

      case 'error':
        console.error('Event error:', msg.message);
        break;
    }
  };

  // Later: add more subscriptions
  ws.send(JSON.stringify({
    type: 'subscribe',
    event_types: ['process_start', 'process_stop']
  }));

  // Or unsubscribe from some
  ws.send(JSON.stringify({
    type: 'unsubscribe',
    event_types: ['idle', 'active']
  }));
  ```

  ```python Python theme={null}
  import asyncio
  import websockets
  import json
  import os
  import requests

  async def monitor_events(instance_id: str, api_key: str):
      # Step 1: Get the computer password
      res = requests.get(
          f"https://www.orgo.ai/api/computers/{instance_id}/vnc-password",
          headers={"Authorization": f"Bearer {api_key}"}
      )
      password = res.json()["password"]

      # Step 2: Connect to the events stream
      url = f"wss://www.orgo.ai/desktops/{instance_id}/ws/events?token={password}"

      async with websockets.connect(url) as ws:
          # Subscribe to events
          await ws.send(json.dumps({
              "type": "subscribe",
              "event_types": ["window_focus", "clipboard", "file_change"]
          }))

          # Listen for events
          async for message in ws:
              data = json.loads(message)

              if data["type"] == "event":
                  event = data["event"]
                  print(f"[{event['timestamp']}] {event['type']}: {event['data']}")

                  if event["type"] == "file_change":
                      d = event["data"]
                      print(f"  {d['operation']} on {d['path']}")

              elif data["type"] == "subscribed":
                  print("Subscribed successfully")

              elif data["type"] == "error":
                  print(f"Error: {data['message']}")

  # The computer's id fills the /desktops/{instance_id} slot.
  asyncio.run(monitor_events(os.environ["COMPUTER_ID"], os.environ["ORGO_API_KEY"]))
  ```

  ```typescript TypeScript theme={null}
  interface DesktopEvent {
    type: string;
    timestamp: string;
    data: Record<string, unknown>;
  }

  interface EventMessage {
    type: 'event' | 'subscribed' | 'unsubscribed' | 'pong' | 'error';
    event?: DesktopEvent;
    message?: string;
  }

  class EventStream {
    private ws!: WebSocket;
    private handlers: Map<string, (event: DesktopEvent) => void> = new Map();

    static async connect(instanceId: string, apiKey: string) {
      const stream = new EventStream();

      // Get the computer password
      const res = await fetch(
        `https://www.orgo.ai/api/computers/${instanceId}/vnc-password`,
        { headers: { 'Authorization': `Bearer ${apiKey}` } }
      );
      const { password } = await res.json();

      // Connect
      stream.ws = new WebSocket(
        `wss://www.orgo.ai/desktops/${instanceId}/ws/events?token=${password}`
      );

      stream.ws.onmessage = (event) => {
        const msg: EventMessage = JSON.parse(event.data);
        if (msg.type === 'event' && msg.event) {
          const handler = stream.handlers.get(msg.event.type);
          handler?.(msg.event);
        }
      };

      return stream;
    }

    on(eventType: string, handler: (event: DesktopEvent) => void) {
      this.handlers.set(eventType, handler);

      // Auto-subscribe
      this.ws.send(JSON.stringify({
        type: 'subscribe',
        event_types: [eventType]
      }));

      return this;
    }

    off(eventType: string) {
      this.handlers.delete(eventType);
      this.ws.send(JSON.stringify({
        type: 'unsubscribe',
        event_types: [eventType]
      }));
      return this;
    }

    close() {
      this.ws.close();
    }
  }

  // Usage: the computer's id fills the /desktops/{instance_id} slot.
  const events = await EventStream.connect(
    process.env.COMPUTER_ID!,
    process.env.ORGO_API_KEY!
  );

  events
    .on('window_focus', (e) => console.log('Window:', e.data.window_name))
    .on('clipboard', (e) => console.log('Clipboard:', e.data.content))
    .on('file_change', (e) => console.log('File:', e.data.operation, e.data.path))
    .on('idle', (e) => console.log('Idle for', Number(e.data.idle_ms) / 1000, 'seconds'));
  ```
</CodeGroup>

## Use cases

<CardGroup cols={2}>
  <Card title="Agent awareness" icon="robot">
    Subscribe to `window_focus` and `idle` to give your agent context about what is on screen and when to act.
  </Card>

  <Card title="File monitoring" icon="folder-open">
    `file_change` is meant to detect completed downloads and saved documents, but it is not delivered on Linux today. See the warning under [File changes](#file-changes).
  </Card>

  <Card title="Clipboard sync" icon="clipboard">
    Monitor `clipboard` to mirror clipboard content between the computer and your application.
  </Card>

  <Card title="Process tracking" icon="microchip">
    Use `process_start` and `process_stop` to track application lifecycle: when Chrome launches, when a build finishes.
  </Card>
</CardGroup>

## Best practices

<CardGroup cols={2}>
  <Card title="Subscribe selectively" icon="filter">
    Only subscribe to what you need. It reduces message volume and keeps your handler simple.
  </Card>

  <Card title="Heartbeat" icon="heart-pulse">
    Send a `ping` every 30 seconds to detect a dead connection early.
  </Card>

  <Card title="Handle backpressure" icon="gauge-high">
    Events are dropped, not queued, for slow consumers. The per-connection buffer is 256 events. Process quickly or offload to a queue.
  </Card>

  <Card title="Reconnection" icon="rotate">
    Reconnect with exponential backoff and re-send your `subscribe` message. Subscriptions are per-connection and are not restored.
  </Card>
</CardGroup>

<Note>
  Events require the computer to be running; a stopped computer is closed with code `4003`. Unlike the terminal and audio streams, the events stream has no idle timeout, so a long-lived monitoring connection is not disconnected for inactivity.
</Note>

## Close codes

| Code | Meaning |
| - | - |
| `1000` | Normal closure. Also sent right after opening when the computer refuses the credential, as happens when you authenticate with an API key instead of the computer password. |
| `1001` | orgo-web is draining for a restart or deploy. Reconnect. |
| `1006` | Dropped with no close frame. The server terminates a socket that stops answering its 30-second protocol ping. |
| `4001` | Authentication required. No session cookie and no valid `token`, an `sk_` key in the query string from a browser, a view-only workspace role, or a cookie-authenticated browser connecting from a disallowed origin. |
| `4003` | The computer exists but is not running, its workspace is not active, or its trial has ended. Start it, then reconnect. An open stream authenticated as a user is also closed with `4003` when that user's workspace access changes, which orgo-web checks every 15 seconds. |
| `4004` | No computer with that id. |
| `4006` | The API key is valid but scoped to a different workspace. |
| `4007` | MFA is required for this session. Re-challenge, then reconnect. |
| `4500` | Internal proxy error. |
| `4502` | orgo-web has no route to the computer's host. Retry; if it persists, contact support. |
| `4503` | orgo-web could not look the computer up. Retry. |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.