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

# Streaming Responses (SSE & Socket.IO)

> Stream Praxis AI model output token-by-token — HTTP Server-Sent Events (SSE) for most integrations, and Socket.IO for persistent, bidirectional real-time connections.

Praxis AI can stream model output token-by-token so your UI can render responses as they are generated. There are two mechanisms: **HTTP Server-Sent Events (SSE)** — the recommended, stateless approach for most integrations — and **Socket.IO** for persistent, bidirectional real-time connections (typically browser clients). Start with SSE unless you specifically need a live socket.

## HTTP Streaming with Server-Sent Events (SSE)

<Tip>
  **Start here.** SSE is the recommended way to stream responses for most integrations. It's a pure HTTP, stateless endpoint with nothing to manage beyond a standard request — ideal for automation, SDKs, and server-side code. Reach for [Socket.IO](#streaming-with-socketio) only when you specifically need a persistent, bidirectional real-time connection (typically a browser app).
</Tip>

Praxis AI offers a pure HTTP streaming endpoint using **Server-Sent Events (SSE)**. This approach is ideal for:

* Server-side applications (Node.js, Python, Go, etc.)
* Automation, agents, and orchestration tools
* Environments where WebSockets are restricted
* Simpler integrations without persistent socket connections
* SDK and CLI tool development

<Info>
  The SSE streaming endpoint uses the same JWT authentication as other REST endpoints. No Socket.IO connection is required.
</Info>

### Endpoint

```
POST /api/ai/personal-stream/qanda-stream
```

### Request Headers

| Header           | Required | Description                   |
| ---------------- | -------- | ----------------------------- |
| `x-access-token` | Yes      | Your JWT authentication token |
| `Content-Type`   | Yes      | `application/json`            |

### Request Body

```json theme={null}
{
  "inputs": ["Your prompt or question here"],
  "requestArgs": {
    "institutionPublicId": "f831501f-b645-481a-9cbb-331509aaf8c1",
    "assistantId": "6856fa89cbafcff8d98680f5",
    "selectedCourse": {
      "course_id": 1750532703472,
      "course_name": "AI Fundamentals"
    },
    "ragOnly": false,
    "userTimezone": "America/New_York"
  }
}
```

| Field                             | Type              | Required | Description                                                                                                                                            |
| --------------------------------- | ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `inputs`                          | string\[]         | Yes      | User messages to send to the AI                                                                                                                        |
| `requestArgs.institutionPublicId` | string (UUID)     | No       | Specifies the digital twin (institution) to send the command to. Validates user membership and switches the user's active institution for the request. |
| `requestArgs.assistantId`         | string (ObjectId) | No       | Assistant to use. Takes priority over `selectedCourse.assistant._id` (legacy). Falls back to history record resolution.                                |
| `requestArgs.selectedCourse`      | object            | No       | Conversation context with `course_id` and `course_name`                                                                                                |
| `requestArgs.ragOnly`             | boolean           | No       | When true, returns only RAG search results without AI generation                                                                                       |
| `requestArgs.userTimezone`        | string            | No       | User's IANA timezone identifier                                                                                                                        |

### cURL Example

<CodeGroup>
  ```bash theme={null}
  curl -s -N -X POST "https://pria.praxislxp.com/api/ai/personal-stream/qanda-stream" \
    -H "Content-Type: application/json" \
    -H "x-access-token: your_api_access_token" \
    -d '{
      "inputs": ["What is the capital of Germany?"],
      "requestArgs": {
        "institutionPublicId": "e455529a-4f51-479e-94fc-bbebb41d19a1",
        "assistantId": "6970576f40219f56e41ec00a",
        "selectedCourse": {
          "course_id": 1769040847660,
          "course_name": "free Wednesday, Jan 21, 2026"
        },
        "ragOnly": false
      }
    }'
  ```
</CodeGroup>

<Tip>
  Use `-s` to suppress progress output and `-N` to disable buffering, so SSE chunks appear in real time.
</Tip>

### Response Headers

```
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
Connection: keep-alive
Content-Encoding: none
Transfer-Encoding: chunked
X-Accel-Buffering: no
```

### Response Format

The endpoint returns `text/event-stream; charset=utf-8` content type with SSE-formatted JSON chunks. Each line is prefixed with `data: ` followed by a JSON object and two newlines.

| Event Type    | Description                                                        | Key Fields                                                                                                                       |
| ------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| `connected`   | Stream established                                                 | `message`                                                                                                                        |
| `stream`      | AI-generated text chunk                                            | `prompt` (cumulative), `delta` (incremental)                                                                                     |
| `thinking`    | Model reasoning / chain-of-thought (where the provider exposes it) | `id` (`round-N`), `round`, `delta` (new chars) on intermediate frames; `text`, `done: true`, optional `signature` on round close |
| `tool_call`   | Tool invocation started (RAG, web search, etc.)                    | `call_id`, `name`, `arguments`, `displayInfo`                                                                                    |
| `tool_result` | Tool execution completed with results                              | `call_id`, `name`, `response`, `success`, `responseDurationMs`                                                                   |
| `complete`    | Final response with usage metrics                                  | `success`, `usage`, `outputs`, `model`, `cached`, `completion`                                                                   |
| `error`       | Processing error                                                   | `error.message`, `error.status`                                                                                                  |
| `done`        | Stream terminated — no more events will follow                     | *(none)*                                                                                                                         |

**Example SSE Response:**

```
data: {"type":"connected","message":"Stream connected"}

data: {"type":"thinking","id":"round-0","round":0,"delta":"The user is asking about France."}

data: {"type":"thinking","id":"round-0","round":0,"delta":" Paris is the capital."}

data: {"type":"thinking","id":"round-0","round":0,"text":"The user is asking about France. Paris is the capital.","signature":"abc123","done":true}

data: {"type":"stream","prompt":"The capital","delta":"The capital"}

data: {"type":"stream","prompt":"The capital of France","delta":" of France"}

data: {"type":"stream","prompt":"The capital of France is Paris.","delta":" is Paris."}

data: {"type":"tool_call","call_id":"tooluse_abc123","name":"search_uploads","arguments":null,"displayInfo":{"icon":"search","label":"Searching documents"}}

data: {"type":"tool_result","call_id":"tooluse_abc123","name":"search_uploads","arguments":{"query":"France capital"},"response":"...","responseLength":512,"responseDurationMs":150,"success":true}

data: {"type":"complete","success":true,"usage":1234,"outputs":["The capital of France is Paris."],"model":"us.anthropic.claude-sonnet-4-5-20250929-v1:0","cached":0,"completion":42}

data: {"type":"done"}
```

<Note>
  The `prompt` field on `stream` events is cumulative (full response up to this point); the `delta` field contains only the new characters since the last event. Use `delta` for incremental UI rendering and `prompt` for the current full text.
</Note>

<Note>
  `thinking` events are gated by an institution-level **Display Thinking Details** toggle (default on). When disabled, no `thinking` events fire and the underlying History record stores no thinking text. Personal users (no institution) always receive thinking events. Providers that do not expose thinking (Mistral) simply emit no `thinking` events. OpenAI emits **summary-only** thinking — OpenAI hides raw chain-of-thought by policy.
</Note>

<Warning>
  **Bedrock Opus 4.6/4.7 reasoning is opaque.** These models use Anthropic's *adaptive thinking* mode, which emits an encrypted reasoning signature only — no plaintext. No `thinking` events fire and no lightbulb appears in the UI even when the toggle is on. To see plaintext reasoning on Bedrock, use **Claude Sonnet 3.7+** or **Opus 4.0/4.5** (standard thinking with `budget_tokens`). Plaintext reasoning is also available on **Anthropic Direct API**, **Google Gemini 2.5+**, **xAI Grok 3-mini / 4.x**, and **OpenAI Responses API** (summary-only).
</Warning>

<Note>
  Thinking is collected per **tool round** — each round of model output before/after a tool call gets its own entry, keyed by `id` (`round-0`, `round-1`, …). Intermediate frames within a round are append-only `delta`s; the round closes with a single `done: true` frame carrying the authoritative full `text` and (for Anthropic / Bedrock) a `signature` used for provider-side multi-step continuation.
</Note>

### Node.js Client Example

<CodeGroup>
  ```javascript theme={null}
  // sseClient.js - Complete Node.js SSE streaming client
  const https = require('https');

  /**
   * Stream a chat request using HTTP SSE
   * @param {string} token - JWT authentication token
   * @param {string} prompt - User's question or prompt
   * @param {object} options - Optional configuration
   * @param {string} [options.institutionPublicId] - Digital twin UUID
   * @param {string} [options.assistantId] - Assistant ObjectId
   * @param {object} [options.selectedCourse] - Conversation context
   * @param {boolean} [options.ragOnly] - RAG-only mode
   * @returns {Promise<object>} - Final response data
   */
  async function streamChat(token, prompt, options = {}) {
    const baseUrl = 'https://pria.praxislxp.com';

    const requestBody = JSON.stringify({
      inputs: [prompt],
      requestArgs: {
        institutionPublicId: options.institutionPublicId,
        assistantId: options.assistantId,
        selectedCourse: options.selectedCourse || {},
        ragOnly: options.ragOnly || false,
      }
    });

    return new Promise((resolve, reject) => {
      const req = https.request(
        `${baseUrl}/api/ai/personal-stream/qanda-stream`,
        {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'x-access-token': token
          }
        },
        (res) => {
          if (res.statusCode !== 200) {
            reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
            return;
          }

          let buffer = '';
          let finalResponse = null;

          res.on('data', (chunk) => {
            buffer += chunk.toString();

            // Process complete SSE messages
            const lines = buffer.split('\n\n');
            buffer = lines.pop(); // Keep incomplete message in buffer

            for (const line of lines) {
              if (line.startsWith('data: ')) {
                try {
                  const data = JSON.parse(line.slice(6));

                  switch (data.type) {
                    case 'connected':
                      console.log('Stream connected');
                      break;

                    case 'stream':
                      // Use delta for incremental rendering
                      process.stdout.write(data.delta || '');
                      break;

                    case 'thinking':
                      // Model reasoning — keyed by `id` (round-0, round-1, ...).
                      // Render in a collapsible UI block separate from the answer.
                      if (data.done) {
                        console.log(`\n[Thinking ${data.id} complete: ${(data.text || '').length} chars]`);
                      } else if (data.delta) {
                        // Incremental thinking — accumulate per `data.id`
                        // process.stdout.write(`💡${data.delta}`);
                      }
                      break;

                    case 'tool_call':
                      console.log(`\n[Tool: ${data.name}]`);
                      break;

                    case 'tool_result':
                      console.log(`[Tool ${data.name} completed in ${data.responseDurationMs}ms]`);
                      break;

                    case 'complete':
                      console.log(`\n\nComplete - Tokens: ${data.usage}, Model: ${data.model}`);
                      finalResponse = data;
                      break;

                    case 'error':
                      console.error('\nError:', data.error);
                      reject(new Error(data.error?.message || 'Stream error'));
                      return;

                    case 'done':
                      resolve(finalResponse);
                      return;
                  }
                } catch (e) {
                  // Skip malformed JSON
                }
              }
            }
          });

          res.on('end', () => {
            resolve(finalResponse);
          });

          res.on('error', reject);
        }
      );

      req.on('error', reject);
      req.write(requestBody);
      req.end();
    });
  }

  // Example usage
  (async () => {
    const token = 'your_jwt_token_here';

    try {
      const result = await streamChat(token, 'What is machine learning?', {
        institutionPublicId: 'f831501f-b645-481a-9cbb-331509aaf8c1',
        assistantId: '6856fa89cbafcff8d98680f5',
        selectedCourse: {
          course_id: 1750532703472,
          course_name: 'AI Fundamentals'
        }
      });

      console.log('\nFinal result:', result);
    } catch (error) {
      console.error('Error:', error.message);
    }
  })();
  ```
</CodeGroup>

### Using Fetch API (Browser/Node.js 18+)

<CodeGroup>
  ```javascript theme={null}
  // Modern fetch-based SSE client
  async function streamChatFetch(token, prompt, onChunk, requestArgs = {}) {
    const response = await fetch('https://pria.praxislxp.com/api/ai/personal-stream/qanda-stream', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-access-token': token
      },
      body: JSON.stringify({
        inputs: [prompt],
        requestArgs
      })
    });

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split('\n\n');
      buffer = lines.pop();

      for (const line of lines) {
        if (line.startsWith('data: ')) {
          const data = JSON.parse(line.slice(6));

          // Call the chunk handler
          if (onChunk) onChunk(data);

          if (data.type === 'complete') {
            return data; // Return final response
          }
        }
      }
    }
  }

  // Usage with callback — use delta for incremental rendering
  await streamChatFetch(
    token,
    'Explain quantum computing',
    (chunk) => {
      if (chunk.type === 'stream') {
        document.getElementById('output').textContent += chunk.delta;
      } else if (chunk.type === 'tool_call') {
        console.log(`Tool invoked: ${chunk.name}`);
      } else if (chunk.type === 'tool_result') {
        console.log(`Tool ${chunk.name} completed in ${chunk.responseDurationMs}ms`);
      }
    },
    {
      institutionPublicId: 'f831501f-b645-481a-9cbb-331509aaf8c1',
      assistantId: '6856fa89cbafcff8d98680f5',
      selectedCourse: { course_id: 1750532703472, course_name: 'AI Fundamentals' }
    }
  );
  ```
</CodeGroup>

### Cancelling an HTTP Stream

To cancel an in-flight HTTP stream request, you can abort the fetch request:

```javascript theme={null}
const controller = new AbortController();

// Start the request with abort signal
const response = await fetch('https://pria.praxislxp.com/api/ai/personal-stream/qanda-stream', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'x-access-token': token
  },
  body: JSON.stringify({
    inputs: ['Your prompt here'],
    requestArgs: { assistantId: '...' }
  }),
  signal: controller.signal  // Pass the abort signal
});

// Later, to cancel:
controller.abort();
```

<Tip>
  The HTTP SSE endpoint is stateless - each request is independent. This makes it ideal for serverless functions, CLI tools, and microservices where maintaining WebSocket connections is impractical.
</Tip>

### Comparing Socket.IO vs HTTP SSE

| Feature             | Socket.IO                      | HTTP SSE                  |
| ------------------- | ------------------------------ | ------------------------- |
| Connection type     | Persistent WebSocket           | Per-request HTTP          |
| Setup complexity    | Higher (connection management) | Lower (standard HTTP)     |
| Bidirectional       | Yes                            | No (server → client only) |
| Cancel mid-stream   | `cancel_request` event         | Abort controller          |
| Best for            | Real-time apps, browsers       | APIs, CLIs, serverless    |
| Proxy compatibility | May require configuration      | Works everywhere          |

***

## Streaming with Socket.IO

<Note>
  Socket.IO is an **alternative** to the [HTTP SSE method above](#http-streaming-with-server-sent-events-sse). Prefer it when you need a persistent, bidirectional real-time connection — for example, an interactive browser client. For automation, SDKs, CLIs, and most server-side integrations, use SSE instead.
</Note>

You can connect to Praxis AI middleware to receive streaming events while REST requests are in-flight. The server emits stream chunks to your socket; you must include the current socket ID in Q\&A requests to link streams to your session.

### 1 Install the Socket.IO Client

<CodeGroup>
  ```bash theme={null}
  npm i socket.io-client
  ```
</CodeGroup>

### 2 Connect and register your user

<CodeGroup>
  ```javascript theme={null}
  // socket.ts (client)
  import { io } from 'socket.io-client';

  const URL = 'https://pria.praxislxp.com/socket.io'; // Middleware streaming endpoint
  localStorage.debug = '*'; // Optional: enable Socket.IO debug logs

  // Keep a simple local state holder
  const state = {};
  state.socket = io(URL, { autoConnect: false });

  // Mocked user profile (replace with your authenticated session values)
  const user = {
    userId: profile?._id,
    email: profile?.email,
    profileName: `${profile?.fname} ${profile?.lname}`,
    institutionId: profile?.institution?._id,
    institutionName: profile?.institution?.name,
  };

  // Open the connection and register your user
  state.socket.on('connect', () => {
    state.socket.emit('REGISTER', user);
  });

  // Handle graceful disconnect/unsubscribe
  state.socket.on('disconnect', (e) => {
    console.log('SocketContext', 'Disconnect', e);
    state.socket.emit('UNSUBSCRIBE', user);
  });

  // Optional: initiate connection if autoConnect is false
  state.socket.connect();
  ```
</CodeGroup>

### 3 Listen for streaming and diagnostic events

<CodeGroup>
  ```javascript theme={null}
  // Stream chunks arrive while REST Q&A requests are processed
  state.socket.on('RECEIVE_STREAM', (payload) => {
    console.log('SocketContext', 'RECEIVE_STREAM', payload);
  });

  // Error & connectivity diagnostics
  state.socket.on('error', (e) => console.log('SocketContext', 'Error', e));
  state.socket.on('connect_success', (e) => console.log('SocketContext', 'Connect Success', e));
  state.socket.on('connect_timeout', (e) => console.log('SocketContext', 'Connect Timeout', e));
  state.socket.on('connect_error', (e) => console.log('SocketContext', 'Connect Error', e));
  ```
</CodeGroup>

These are typical events `payload` received from the stream

```json wrap theme={null}
{
    "prompt": " I'm doing great, thank you for asking! 😊 
    I'm here, energized, and ready to help you with whatever you need today.Since we last spoke just a minute ago when you checked if I was here, I'm curious—what's on your mind? Are you:
    - Looking to learn something new?
    - Working on a project or assignment?
    - Curious about a specific topic?
    - Just wanting to chat?
    
    I'm all ears and ready to assist! What would you like to explore",
    "delta": " What would you like to explore",
    "type": "STREAM"
}
```

**prompt**: Contains the full response from the beginning of the interaction.

**delta** Contains only the last text segment reported

**type**: "STREAM" for messages streamed by the LLM

<Note>
  Note that the final response may differ from the `prompt` returned while streaming.
</Note>

#### Event types

All `RECEIVE_STREAM` payloads carry a `type` field. Branch on it to handle each kind:

| `type`          | When fired                                                         | Key fields                                                                                                                       |
| --------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| `STREAM`        | Incremental assistant text                                         | `prompt` (cumulative), `delta` (new chars)                                                                                       |
| `THINKING`      | Model reasoning / chain-of-thought (where the provider exposes it) | `id` (`round-N`), `round`, `delta` (new chars) on intermediate frames; `text`, `done: true`, optional `signature` on round close |
| `TOOL_CALL`     | Tool invocation started (RAG, web search, MCP, etc.)               | `call_id`, `name`, `arguments`, `displayInfo`                                                                                    |
| `TOOL_PROGRESS` | Long-running tool emits a status update                            | `call_id`, `displayInfo`, optional `progress` (0–1)                                                                              |
| `TOOL_RESULT`   | Tool execution completed                                           | `call_id`, `name`, `response`, `success`, `responseDurationMs`                                                                   |
| `RELOAD`        | New conversation turn started (clears prior turn's UI state)       | *(none)*                                                                                                                         |

##### `THINKING` — model reasoning stream

`THINKING` events surface the model's internal reasoning text (a.k.a. chain-of-thought / "thinking" mode) for the providers that expose it: Anthropic, AWS Bedrock, Google Gemini, OpenAI (summary only — OpenAI hides raw chain-of-thought by policy), and xAI. Mistral does not expose thinking at all and emits no `THINKING` events.

Thinking is collected per **tool round** — each round of model output before/after a tool call gets its own entry, keyed by `id` of the form `round-0`, `round-1`, etc. Frames within a round are append-only `delta`s; the round closes with a single `done: true` frame carrying the authoritative full `text` and (for Anthropic / Bedrock) a `signature` for provider-side continuation.

```json theme={null}
// Intermediate frame — accumulate delta
{ "type": "THINKING", "id": "round-0", "round": 0, "delta": " Let me consider..." }

// Round close — overwrite with authoritative full text
{ "type": "THINKING", "id": "round-0", "round": 0, "text": "Let me consider the user's question carefully...", "signature": "abc123…", "done": true }
```

<Note>
  The `THINKING` stream is gated by an institution-level **Display Thinking Details** toggle (default on). When the institution disables the toggle, no `THINKING` events fire and the underlying History record stores no thinking text. Personal users (no institution) always receive thinking events.
</Note>

<Tip>
  Render thinking deltas in a collapsible UI block separate from the main answer — users tend to find the chain-of-thought interesting *as context*, not as the answer itself. The Praxis UI shows thinking under a 💡 lightbulb `&lt;details&gt;` toggle.
</Tip>

### 4 Include socketId in your Q\&A API requests

The server requires the active Socket.IO session ID to stream chunks to your client. Include <code>socketId</code> in <code>requestArgs</code>.

<CodeGroup>
  ```javascript theme={null}
  // Example: POST Q&A request linking REST call to Socket.IO session
  const payload = {
    inputs: [userMessage],
    requestArgs: {
      socketId: state.socket.id, // CRITICAL: links the streaming session
      assistantId: currentAssistantId,
      selectedCourse: {
        course_id: currentCourseId,
        course_name: currentCourseName,
      },
    },
  };

  const response = await fetch('https://pria.praxislxp.com/api/ai/personal/qanda', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      // If your deployment requires header-based auth for this endpoint, include your token here.
      // 'x-access-token': your_api_access_token
    },
    body: JSON.stringify(payload),
  });

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }

  const data = await response.json();
  ```
</CodeGroup>

<Tip>
  If you wrap Q\&A calls, pass <code>socketId</code> from your Socket.IO context into the wrapper and expose a stream callback to consume <code>RECEIVE\_STREAM</code> chunks.]
</Tip>

<CodeGroup>
  ```javascript theme={null}
  // Example wrapper usage
  const { sessionId } = useSocketIO();

  await priaApi.qanda(
    {
      inputs: [userMessage],
      requestArgs: {
        socketId: sessionId, // CRITICAL: Link to Socket.IO session
        assistantId: currentAssistantId,
        selectedCourse: {
          course_id: currentCourseId,
          course_name: currentCourseName,
        },
      },
    },
    onStreamCallback // handle incremental chunks
  );
  ```
</CodeGroup>

### 5 Manage reconnections

When the QANDA request response includes `"streamingFailed": true`, it indicates that the middleware has lost the backend Socket ID mapping (user email to client socket ID), preventing it from communicating response chunks back to the client. This typically occurs when multiple browser windows or tabs are open for the same user email, causing them to compete for the same backend session and overwriting each other's socket registrations. To resolve this issue, you can re-establish the socket connection to re-register the most current client handle and restore proper communication between the middleware and the active client session.

```javascript theme={null}
       
if (response?.streamingFailed){
       
  // reconnecting
  if (state.socket.connected){
    await state.socket.disconnect()
  }
  await state.socket.connect()
}
```

### 6 Abort requests

To abort a request while executing, emit a `cancel_request` event:

```javascript theme={null}
state.socket.emit('cancel_request', user);
```

<Note>
  The `cancel_request` message signals the controller API to interrupt the ongoing communication and attempt a graceful termination of the conversation. While the controller works to terminate resources cleanly, there may be a slight delay before the process completes. You will only be charged for any partial response generated up to the point of cancellation.
</Note>

### 7 Stop streaming the current request

To stop streaming the current response without aborting the request itself, emit a `cancel_streaming` event:

```javascript theme={null}
state.socket.emit('cancel_streaming', user);
```

<Note>
  `cancel_streaming` applies only to the current response stream and is cleared automatically before the next interaction.
</Note>

## Related

* [API Reference Introduction](/mdx/api-reference/introduction) — API structure, authentication, and getting started
* [JavaScript SDK](/mdx/sdk/javascript) — a higher-level client that handles streaming for you
