# Live stream

> Get your own fills, orders, trades and account changes pushed to your bot the moment they happen, instead of polling.

Your bot doesn't have to keep asking "did it fill yet?" anymore. Open one stream and Nexus pushes your own changes the moment they happen: a fill, an order resting or cancelled, a trade opened or closed, and your wallet or futures account right after. It's Server-Sent Events: one long HTTP response that sends an event per change.

```bash
curl -N "https://app.skylit.ai/api/nexus/v1/trading/stream" \
  -H "Authorization: Bearer $SKYLIT_API_KEY"
```

- **Same key, same checks.** It checks your key, a suspended account and Nexus access exactly like every other route. Opening it counts as one read. Events and heartbeats don't count.
- **Only yours.** Nobody else's trades ever show up, and nobody else ever sees yours. Your [agent account's](https://www.skylit.ai/docs/nexus-trades/agent-account) events come to you and no one else. Test orders never show up: they aren't trades.
- **One book or both.** Add `?account=main` for your own trades and accounts, or `?account=agent` for your bot's. Leave it out to get both. Anything else answers `400 bad_request`. Every event says which `book` it's on.
- **Futures** events only come if your account has futures. The `connected` event tells you with `"futures": true` or `false`.
- **It never streams market data.** Quotes aren't in it.

## What comes down the stream

| Event | When | `data` |
|---|---|---|
| `connected` | Once, first | `{"account":"all","resumed":false,"futures":true,"heartbeatSeconds":15}` |
| `resync` | First thing on a new connection, and any time the server lost track | `{"reason":"connected"}`. Re-read your state with the REST routes |
| `trade` | An options or stock trade was opened, added to, trimmed, closed or expired | `change`, `book`, `at`, `fill`, `trade` |
| `order` | A resting order changed | `change`, `assetClass`, `book`, `order` |
| `wallet` | Your options and stocks cash or buying power moved | `book`, `wallet` |
| `account` | A futures account changed (balance, P/L, positions, working orders) | `book`, `account` |
| `heartbeat` | Every 15 seconds | `{"at":"2026-10-12T14:31:00Z"}` |
| `closed` | We ended the stream. Fix the reason before you reconnect | `{"reason":"invalid_api_key"}` |
| `reconnect` | The server is restarting | `{"reason":"server_restart"}`. Reconnect right away |

`resync`'s `reason` is for your logs (`connected`, `reconnected`, `lagged`, `bus` or `error`). Your bot does the same thing for all of them. `closed` says one of `invalid_api_key` (the key was revoked or is no longer valid), `account_suspended`, `nexus_access_denied` or `trades_api_disabled` (the API is switched off for now, so wait a while before you try again).

The objects inside are the same ones the REST routes return, field for field:

- `trade` is what `GET /api/nexus/v1/trades/{id}` returns, as the trade stands when the event goes out. `fill` is this one fill: `{"quantity", "price"}`, plus `id` when the server knows the fill's own id (it's left out today, so don't rely on it). `change` is `opened`, `added`, `trimmed`, `closed` or `expired`.
- `order` is what the order read returns: `GET .../wallets/{book}/orders/{orderId}` for options and stocks, `GET .../accounts/{account}/orders/{orderId}` for futures. Orders you placed in Nexus show up too, without `"source": "api"`.
- `wallet` is `GET /api/nexus/v1/trading/wallets/{book}`. `account` is `GET /api/nexus/v1/trading/accounts/{account}`.

`change` on an order tells you what just happened to it:

| `change` | Means |
|---|---|
| `working` | It's resting and can fill |
| `held` | A futures stop or target waiting for its entry to fill |
| `modified` | You (or Nexus) moved its price or size |
| `triggered` | A stop went off. It now works as an order and can't be cancelled |
| `partial_fill` | Part of it filled. The rest still works |
| `filled` | Done. `fillPrice` is what you got |
| `cancelled`, `rejected`, `expired` | Done, no fill. `statusReason` says why |

Here's a fill as it comes over the wire:

```text
id: 5c1f0e9a2b7d-1042
event: order
data: {"change":"filled","assetClass":"options","book":"main","order":{"id":"8f0c...","status":"filled","fillPrice":1.98,"filledQuantity":2,"tradeId":"41aa...", ...}}

id: 5c1f0e9a2b7d-1043
event: trade
data: {"change":"opened","book":"main","at":"2026-10-12T14:31:07Z","fill":{"quantity":2,"price":1.98},"trade":{"id":"41aa...","ticker":"SPY", ...}}
```

## Dropped connections

Every data event has an `id`. When you reconnect, send the last one you got as the `Last-Event-ID` header (or `?lastEventId=`). If the server you land on still has what you missed (it keeps your last 128 events for 2 minutes after you drop), you get them and carry on, and `connected` says `"resumed": true`. If it doesn't, the first event after `connected` is `resync`.

So write your bot one way: on `resync`, read your open trades, working orders, wallet and futures accounts with the REST routes, then keep going. That covers both cases.

## Limits and timing

- **Streams.** A key can hold 3 streams at once and your account 6 across all your keys. Past that you get `429 stream_limit_reached` with `Retry-After`. One stream is all a bot needs.
- **If it goes quiet.** A heartbeat comes every 15 seconds. If you hear nothing for 45, the connection's dead: drop it and reconnect with your `Last-Event-ID`.
- **A revoked key's stream** ends within about 2 minutes, with `closed`.
- **A browser's `EventSource` can't send your key in a header.** That's fine: your key shouldn't be in a browser anyway. Run the stream from your bot.
- **`capabilities` has a `stream` block:** `available`, `route`, `events`, `accounts`, `resume`, `heartbeatSeconds`, `maxStreamsPerKey`, `replayWindowSeconds` (120) and `replayEvents` (128). If `available` is `false`, poll the reads instead.

| Status | `code` | What it means | What your bot should do |
|---|---|---|---|
| 429 | `stream_limit_reached` | Too many live streams open for this key (3) or your account (6) | Close one, or wait `Retry-After` seconds |
| 503 | `stream_unavailable` | The live stream isn't on, or the server is restarting | Try again shortly, or poll the reads |

## Examples

```python Python (requests)
import json
import os
import time

import requests

NEXUS = "https://app.skylit.ai"
KEY = os.environ["SKYLIT_API_KEY"]

def events(last_id=None):
    """Yield (id, event, data) from the stream until it ends."""
    headers = {"Authorization": f"Bearer {KEY}", "Accept": "text/event-stream"}
    if last_id:
        headers["Last-Event-ID"] = last_id
    # The read timeout is 45 s: three missed heartbeats means a dead stream.
    with requests.get(f"{NEXUS}/api/nexus/v1/trading/stream",
                      headers=headers, stream=True, timeout=(10, 45)) as r:
        if r.status_code != 200:
            raise RuntimeError(r.json()["error"])
        eid, event, data = None, None, []
        for line in r.iter_lines(decode_unicode=True):
            if line == "":
                if event:
                    yield eid, event, json.loads("".join(data)) if data else None
                eid, event, data = None, None, []
            elif line.startswith("id: "):
                eid = line[4:]
            elif line.startswith("event: "):
                event = line[7:]
            elif line.startswith("data: "):
                data.append(line[6:])

def resync():
    # Re-read what you hold: GET /api/nexus/v1/trades?status=open,
    # GET /api/nexus/v1/trading/wallets/main/orders/working, and so on.
    pass

last_id = None
while True:
    try:
        for eid, event, data in events(last_id):
            if eid:
                last_id = eid
            if event == "resync":
                resync()
            elif event == "trade":
                print(data["change"], data["trade"]["ticker"], data["fill"])
            elif event == "order":
                print(data["assetClass"], data["change"], data["order"]["id"])
            elif event == "closed":
                raise SystemExit(f"stream closed: {data['reason']}")
    except (requests.ConnectionError, requests.Timeout):
        pass  # dropped or went quiet: reconnect below
    time.sleep(2)
```

```js JavaScript (Node 18 or newer)
const NEXUS = "https://app.skylit.ai";
const KEY = process.env.SKYLIT_API_KEY;
let lastId = null;

async function resync() {
  // Re-read what you hold with the REST routes.
}

async function stream() {
  const headers = { Authorization: `Bearer ${KEY}`, Accept: "text/event-stream" };
  if (lastId) headers["Last-Event-ID"] = lastId;

  // Three missed heartbeats (45 s of nothing) means a dead stream.
  const quiet = new AbortController();
  let timer = setTimeout(() => quiet.abort(), 45_000);

  const res = await fetch(`${NEXUS}/api/nexus/v1/trading/stream`, { headers, signal: quiet.signal });
  if (!res.ok) throw new Error(JSON.stringify((await res.json()).error));

  const decoder = new TextDecoder();
  let buf = "";
  try {
    for await (const chunk of res.body) {
      clearTimeout(timer);
      timer = setTimeout(() => quiet.abort(), 45_000);
      buf += decoder.decode(chunk, { stream: true });
      let end;
      while ((end = buf.indexOf("\n\n")) >= 0) {
        const frame = buf.slice(0, end);
        buf = buf.slice(end + 2);
        let id = "", event = "", data = "";
        for (const line of frame.split("\n")) {
          if (line.startsWith("id: ")) id = line.slice(4);
          else if (line.startsWith("event: ")) event = line.slice(7);
          else if (line.startsWith("data: ")) data += line.slice(6);
        }
        if (id) lastId = id;
        const msg = data ? JSON.parse(data) : null;
        if (event === "resync") await resync();
        else if (event === "trade") console.log(msg.change, msg.trade.ticker, msg.fill);
        else if (event === "order") console.log(msg.assetClass, msg.change, msg.order.id);
        else if (event === "closed") {
          console.error(`stream closed: ${msg.reason}`);
          process.exit(1);
        }
      }
    }
  } finally {
    clearTimeout(timer);
  }
}

(async () => {
  for (;;) {
    try {
      await stream();
    } catch (err) {
      console.error(err.message); // dropped or went quiet: reconnect below
    }
    await new Promise((r) => setTimeout(r, 2000));
  }
})();
```
