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

# WS /substrate/v1/watch/{pid} — real-time state stream

> Stream real-time state-change notifications for a tracked process over WebSocket — get changed key lists as they happen, with no REST polling required.

The `/watch` endpoint upgrades an HTTP connection to a WebSocket and streams `StateChangeNotification` messages every time the NSP daemon detects that a tracked process's state has changed. Instead of polling `GET /state` on a timer, you open one connection and react to events as they arrive. This is the recommended pattern for real-time agent loops, dashboards, and automated test frameworks that need to observe application behaviour continuously.

## Endpoint

```text theme={null}
ws://127.0.0.1:7842/substrate/v1/watch/{pid}?key=<api_key>
```

## Parameters

<ParamField path="pid" type="integer" required>
  The OS process ID to watch. Obtain this from `GET /apps`.
</ParamField>

<ParamField query="key" type="string" required>
  Your API key. Because many WebSocket client libraries do not support custom HTTP headers at the upgrade handshake phase, the daemon accepts the API key as a `key` query parameter for this endpoint. If your client does support the `X-Substrate-Key` header at upgrade, both methods are accepted.
</ParamField>

## Message Format

The server pushes one JSON message per probe cycle in which a state change is detected. Messages are newline-terminated UTF-8 JSON.

<ResponseField name="pid" type="integer" required>
  OS process ID of the process that changed.
</ResponseField>

<ResponseField name="app_id" type="string" required>
  Stable semantic identifier for the application.
</ResponseField>

<ResponseField name="capture_id" type="string" required>
  UUIDv7 identifier for the new state snapshot. Pass this to `GET /diff/{pid}?since={capture_id}` to retrieve the full field-level diff.
</ResponseField>

<ResponseField name="prev_capture_id" type="string | null" required>
  UUIDv7 identifier of the previous snapshot. `null` on the first message after connecting. Use the gap between `prev_capture_id` and `capture_id` to detect if any captures were missed (e.g., after a reconnect).
</ResponseField>

<ResponseField name="sequence" type="integer" required>
  Monotonically increasing counter per connection. If you receive a message where `sequence` is not exactly `previous_sequence + 1`, events were dropped (typically during a reconnect). Re-fetch the full state via `GET /state` to resynchronize.
</ResponseField>

<ResponseField name="changed_keys" type="string[]" required>
  Array of dot-notation state keys that changed since the previous capture, e.g. `["inbox.unread_count", "inbox.emails[0].is_read"]`. Use this list to selectively update your agent's state model without re-reading the entire `objects` map.
</ResponseField>

<ResponseField name="timestamp" type="datetime" required>
  ISO 8601 timestamp of when the state change was captured.
</ResponseField>

### Example Message

```json theme={null}
{
  "pid": 12847,
  "app_id": "gmail-chrome-12847",
  "capture_id": "0196f4a3-1c2d-7e00-9f3a-0b1c2d3e4f50",
  "prev_capture_id": "0196f4a2-3b5c-7d00-8e1f-9a0b2c3d4e5f",
  "sequence": 142,
  "changed_keys": [
    "inbox.unread_count",
    "inbox.emails[0].is_read"
  ],
  "timestamp": "2026-07-20T13:00:02.341Z"
}
```

## Connection Behaviour

* **Event frequency:** Messages fire on every probe cycle in which a state change is detected. The default probe interval is 2 seconds.
* **No-change cycles:** The server does not send a message if no keys changed since the last capture. The connection stays open silently.
* **Process exit:** When the tracked process exits, the server closes the WebSocket with close code `1000` and reason `"process_exited"`.
* **Daemon restart:** If the daemon restarts, all open connections are dropped. Your client must reconnect.
* **Heartbeat:** The server does not send explicit ping frames. Rely on TCP keepalive and your client's built-in WebSocket timeout to detect dead connections. Implement exponential-backoff reconnection on your side.

## Examples

<CodeGroup>
  ```javascript JavaScript (browser / Node.js) theme={null}
  const pid = 12847;
  const key = "sk_nsp_...";
  const url = `ws://127.0.0.1:7842/substrate/v1/watch/${pid}?key=${key}`;

  function connect() {
    const ws = new WebSocket(url);

    ws.onopen = () => {
      console.log("Watching PID", pid);
    };

    ws.onmessage = (msg) => {
      const event = JSON.parse(msg.data);
      console.log("Capture:", event.capture_id);
      console.log("Changed keys:", event.changed_keys);
      console.log("Sequence:", event.sequence);
    };

    ws.onclose = (e) => {
      console.warn("Connection closed:", e.code, e.reason);
      if (e.code !== 1000) {
        // Reconnect with exponential backoff.
        setTimeout(connect, 2000);
      }
    };

    ws.onerror = (err) => {
      console.error("WebSocket error:", err);
    };
  }

  connect();
  ```

  ```python Python (websockets library) theme={null}
  import asyncio
  import json
  import websockets
  from websockets.exceptions import ConnectionClosedError

  PID = 12847
  KEY = "sk_nsp_..."
  URL = f"ws://127.0.0.1:7842/substrate/v1/watch/{PID}?key={KEY}"

  async def watch():
      backoff = 1
      while True:
          try:
              async with websockets.connect(URL) as ws:
                  backoff = 1  # Reset on successful connect.
                  async for raw in ws:
                      event = json.loads(raw)
                      print(f"Seq {event['sequence']}  changed: {event['changed_keys']}")
          except ConnectionClosedError as e:
              if e.code == 1000:
                  print("Process exited — stopping watch.")
                  break
              print(f"Disconnected (code {e.code}), reconnecting in {backoff}s…")
              await asyncio.sleep(backoff)
              backoff = min(backoff * 2, 30)

  asyncio.run(watch())
  ```

  ```python Python SDK theme={null}
  from nelieo import NspClient

  client = NspClient(api_key="sk_nsp_...", base_url="http://127.0.0.1:7842")
  session = await client.attach("Gmail")

  # The SDK handles reconnection automatically.
  async with session.watch() as stream:
      async for event in stream:
          print(event.changed_keys)
          print(event.sequence)

          # Fetch the full diff for this capture.
          diff = await session.diff(since=event.prev_capture_id)
          for change in diff.changed:
              print(f"  {change.path}: {change.before} → {change.after}")
  ```
</CodeGroup>

## Reconnection Strategy

After any unexpected close (code other than `1000`), reconnect with exponential backoff starting at 1–2 seconds and capping at 30 seconds. On reconnect, call `GET /state` once to resynchronize your state model — you may have missed capture events during the gap.

## Error Responses (HTTP Upgrade Phase)

These errors are returned as HTTP responses before the WebSocket upgrade completes:

| Status | Description                                                         |
| ------ | ------------------------------------------------------------------- |
| `401`  | Missing or invalid API key in the `key` query parameter             |
| `404`  | PID not tracked — verify the process is running via `GET /apps`     |
| `426`  | Upgrade Required — this endpoint only accepts WebSocket connections |
