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

> ## Agent Instructions
> Reactor hosts multiple models, each with its own connect slug (modelName) and command/event schema. The catalog of every model — slug, typed SDK package, and links to its schema — is at /model-api-reference/overview. Some models expose one slug per experience (e.g. HappyOyster); always take the slug from the model's own pages, never guess it.
> Fastest path to a working app: `npx create-reactor-app my-app --model=<slug>` scaffolds a complete app with secure auth wired up. Typed TypeScript SDKs are published as @reactor-models/<model>; Python uses the base reactor-sdk package.
> Auth: exchange an API key (rk_...) for a JWT via POST https://api.reactor.inc/tokens from your server. Never put the API key in client-side code.
> Append .md to any docs URL for clean Markdown. Search these docs via the MCP server at https://docs.reactor.inc/mcp.

# Stream Logs

> Tail the live logs of a running session or a Reactor instance over Server-Sent Events with a single authenticated call.

Reactor exposes the runtime logs of any session or instance you own as a live Server-Sent Events
(SSE) stream. The protocol is a single HTTP call: open `GET /logs` with your API key as a bearer
token (`Authorization: Bearer <key>`) and read the stream. The same key you use for the rest of
the Reactor API is the credential the log stream accepts.

The endpoint is served under the same `https://api.reactor.inc` origin as the rest of the Reactor
API. The transport is plain HTTP and SSE; no SDK is required.

## When to use which scope

There are two log scopes. Pick the one that matches the resource you want to read.

| Scope         | Selected by           | What you get                                                           |
| ------------- | --------------------- | ---------------------------------------------------------------------- |
| Session logs  | `?session_id=` (UUID) | Only the lines emitted by the runtime for that one session.            |
| Instance logs | `?machine_id=` (pod)  | Every line the runtime pod emitted, across all sessions it has served. |

You'll typically use session logs to debug a specific request, and instance logs to investigate a
misbehaving model deployment as a whole.

<Info>
  `machine_id` is the identifier Reactor returns for the pod a session is currently bound to. You
  can read it from `GET /sessions/{session_id}/runtime` once the session has been scheduled, or list
  the live instances for a model with `GET /models/{model_id}/instances`.
</Info>

## Open the stream

`GET /logs` accepts exactly **one** of `session_id` or `machine_id` as a query parameter. Send your
API key as the bearer token in the `Authorization` header. A browser client sends its Clerk JWT in
the same header. The legacy `Reactor-API-Key: <key>` header is still accepted when no
`Authorization` header is present. Query-string credentials are never accepted (see
[error responses](#error-responses)).

```http theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
GET /logs?session_id={session_id} HTTP/1.1
Host: api.reactor.inc
Authorization: Bearer <your API key>
Accept: text/event-stream
```

<CodeGroup>
  ```bash Session logs (curl) theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
  curl -sN \
    -H "Authorization: Bearer $REACTOR_API_KEY" \
    "https://api.reactor.inc/logs?session_id=$SESSION_ID"
  ```

  ```bash Instance logs (curl) theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
  curl -sN \
    -H "Authorization: Bearer $REACTOR_API_KEY" \
    "https://api.reactor.inc/logs?machine_id=$MACHINE_ID"
  ```

  ```js JavaScript (backend, fetch + ReadableStream) theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
  // Run this in your backend. EventSource is unsupported because it cannot set
  // custom headers, and the raw API key must never reach a browser.
  // Use fetch + a ReadableStream reader and parse SSE frames yourself.
  const url = new URL("https://api.reactor.inc/logs");
  url.searchParams.set("session_id", sessionId);

  const resp = await fetch(url, {
    headers: {
      Authorization: `Bearer ${apiKey}`,
      Accept: "text/event-stream",
    },
  });
  if (!resp.ok || !resp.body) {
    throw new Error(`stream rejected: ${resp.status}`);
  }

  const reader = resp.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = "";

  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += value;

    // SSE frames are separated by a blank line ("\n\n").
    let sep;
    while ((sep = buffer.indexOf("\n\n")) !== -1) {
      const frame = buffer.slice(0, sep);
      buffer = buffer.slice(sep + 2);

      let event = "";
      const dataLines = [];
      for (const raw of frame.split("\n")) {
        const line = raw.replace(/\r$/, "");
        if (line.startsWith(":")) continue; // SSE comment / keepalive
        const idx = line.indexOf(":");
        const field = idx === -1 ? line : line.slice(0, idx);
        const value = idx === -1 ? "" : line.slice(idx + 1).replace(/^ /, "");
        if (field === "event") event = value;
        else if (field === "data") dataLines.push(value);
      }
      if (!event) continue;
      const payload = JSON.parse(dataLines.join("\n"));

      if (event === "log") console.log(payload.timestamp, payload.line);
      else if (event === "end") return;
    }
  }
  ```

  ```python Python (httpx) theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
  import httpx, json

  async def stream_logs(session_id: str, api_key: str) -> None:
      headers = {"Authorization": f"Bearer {api_key}"}
      params = {"session_id": session_id}
      async with httpx.AsyncClient(timeout=None) as client:
          async with client.stream(
              "GET", "https://api.reactor.inc/logs",
              params=params, headers=headers,
          ) as resp:
              resp.raise_for_status()
              event, data_lines = "", []
              async for raw in resp.aiter_lines():
                  line = raw.rstrip("\r")
                  if line == "":
                      if event:
                          payload = json.loads("\n".join(data_lines))
                          yield event, payload
                      event, data_lines = "", []
                      continue
                  field, _, value = line.partition(":")
                  value = value.removeprefix(" ")
                  if field == "event":
                      event = value
                  elif field == "data":
                      data_lines.append(value)
  ```
</CodeGroup>

<Warning>
  `EventSource` is **not supported**. The `EventSource` API cannot set custom headers, and `/logs`
  only reads credentials from headers. The server rejects a credential-looking query parameter
  (`?ticket=`, `?token=`, `?jwt=`, …) with `400 query_token_not_supported`. Call `/logs` from your
  backend with `fetch` and a `ReadableStream` reader, as shown above. Then relay the stream to your
  frontend. See [Streaming to a browser](#streaming-to-a-browser).
</Warning>

`200 OK` with `Content-Type: text/event-stream` opens the stream. Anything else terminates the
request before the stream begins.

### Error responses

| Status | Error code                  | Meaning                                                                                                                                                           |
| ------ | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `missing_argument`          | Neither `session_id` nor `machine_id` was supplied.                                                                                                               |
| `400`  | `conflicting_arguments`     | Both `session_id` and `machine_id` were supplied.                                                                                                                 |
| `400`  | `query_token_not_supported` | A credential-looking query parameter (`ticket`, `token`, `jwt`, …) was passed. Move the credential into the `Authorization` header as a bearer token.             |
| `401`  | `unauthorized`              | The credential is missing, invalid, expired, or otherwise rejected.                                                                                               |
| `403`  | `forbidden`                 | The credential is valid but cannot stream these logs. SDK JWTs only work on session endpoints. Send your API key as the bearer token instead, in the same header. |
| `404`  | `not_found`                 | The resource doesn't exist **or** the caller's account doesn't own it. The two cases are deliberately indistinguishable.                                          |
| `409`  | `resource_not_ready`        | The resource exists but isn't ready yet (e.g. a session that hasn't been scheduled to a pod). Retry after a short delay.                                          |
| `502`  | `validation_failed`         | The credential validation upstream returned an unexpected response.                                                                                               |
| `503`  | `at_capacity`               | The gateway is at its concurrent-stream cap. Back off and retry.                                                                                                  |
| `503`  | `coordinator_unavailable`   | Credential validation is temporarily unavailable. Retry after a short delay.                                                                                      |

The error body is JSON: `{"error": "<code>", "message": "<human detail>"}`.

## Event types

The stream uses a small, stable set of SSE event names. Every event carries a single `data:` line
containing JSON.

### `event: log`

A single log line emitted by the runtime.

```json theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
{
  "timestamp": "2026-05-22T18:09:03.142Z",
  "line": "{\"level\":\"info\",\"session_id\":\"…\",\"msg\":\"frame emitted\"}"
}
```

`line` is the raw log line as emitted by the runtime. Reactor's first-party models emit structured
JSON, so you can `JSON.parse(line)` to get per-line fields. Models that emit free-form text are
passed through verbatim.

### `event: heartbeat`

An empty keepalive emitted on a regular cadence (every \~15 s by default) so proxies and load
balancers don't close idle connections.

```json theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
{}
```

Heartbeats carry no information beyond "the stream is still alive". You typically ignore them;
clients that care about staleness can use them to reset a watchdog timer.

### `event: error`

A server-side problem that is about to terminate the stream. The message is safe to render to end
users; `debug_id` correlates with Reactor's internal log entry so support can investigate without
you leaking sensitive details.

```json theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
{
  "message": "I was unable to access the logs for session …. Please contact support and reference debug id 7c9d…",
  "debug_id": "7c9d3a2e1b6f4a528c91e0d57f2b3a4d"
}
```

An `error` event is **always followed** by a terminal `end` event, so clients that only switch on
`end` still close cleanly.

### `event: end`

The terminal frame. Every stream ends with exactly one `end` event, after which the server closes
the HTTP connection. The `reason` field tells you which boundary fired.

```json theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
{
  "reason": "deadline",
  "message": ""
}
```

| `reason`                                                             | Meaning                                                                                     |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `client_disconnect`                                                  | Your client closed the connection (or a proxy in front of it did).                          |
| `deadline`                                                           | The stream hit `max_follow_duration` (5 minutes by default). Reopen the stream to continue. |
| `rate_limit`                                                         | Sustained traffic exceeded the per-stream byte/line caps. Back off, then reconnect.         |
| `shutdown`                                                           | The Reactor side is draining for a routine deploy. Reconnect after a few seconds.           |
| `capacity`                                                           | The gateway rejected the stream at admission (see `503 at_capacity`).                       |
| `unauthorized`                                                       | The credential was rejected during validation.                                              |
| `upstream_auth_failed` / `upstream_query_invalid` / `upstream_error` | A failure inside Reactor's log store. The matching `error` event carries the `debug_id`.    |

## Limits and lifecycle

Streams are deliberately short-lived. Plan to reconnect periodically; the protocol is designed for
it.

| Setting              | Default | What it means                                                                |
| -------------------- | ------- | ---------------------------------------------------------------------------- |
| Max follow duration  | 5 min   | After this, the stream closes with `reason: "deadline"`; reopen to continue. |
| Max lookback         | 5 min   | Oldest line a freshly-opened stream may return. Older lines are skipped.     |
| Max lines per second | 1000    | Per-stream soft cap on emitted `log` events.                                 |
| Max bytes per second | 64 KiB  | Per-stream byte rate before backpressure throttles the tail.                 |

These values are the platform defaults today; they may be tuned per environment or per account. The
active limits for your stream are enforced server-side, so your client does not need to mirror
them.

## Streaming to a browser

Log streaming is a server-side operation, for two reasons:

* A raw API key is long-lived and gives access to your whole account. Never send it to a browser.
* Session logs are the model runtime's own container output. Reactor passes them through unchanged.
  There is no field allowlist and no redaction. The caller also picks the verbosity, so a stream can
  carry debug and trace lines. Treat the stream as internal detail. Filter it before your end users
  see it.

To show logs in your own UI, proxy the stream:

<Steps>
  <Step title="Keep the key in your backend">
    Store the API key in your server environment. Your frontend never sees it.
  </Step>

  <Step title="Open the upstream stream">
    From your backend, call `GET /logs` with `Authorization: Bearer <key>`, exactly as shown above.
  </Step>

  <Step title="Re-emit to your frontend">
    Forward the SSE frames to your own endpoint. Protect that endpoint with your own user session.
    Filter or redact anything you do not want end users to read.
  </Step>
</Steps>

This minimal Node relay uses the reader loop from the JavaScript example above.

```js theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
// GET /api/session-logs?session_id=… on YOUR server, authorized by YOUR session.
app.get("/api/session-logs", requireYourOwnAuth, async (req, res) => {
  const url = new URL("https://api.reactor.inc/logs");
  url.searchParams.set("session_id", req.query.session_id);

  const upstream = await fetch(url, {
    headers: {
      Authorization: `Bearer ${process.env.REACTOR_API_KEY}`,
      Accept: "text/event-stream",
    },
  });
  if (!upstream.ok || !upstream.body) {
    return res.status(502).json({ error: "upstream_unavailable" });
  }

  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.flushHeaders();

  // Relay frames verbatim, or parse and filter first.
  for await (const chunk of upstream.body) res.write(chunk);
  res.end();
});
```

<Note>
  Reactor's own dashboard streams logs directly from the browser with a Clerk session. That path is
  first-party only, so partner applications cannot use it.
</Note>

## Putting it together

The expected client loop is short:

<Steps>
  <Step title="Resolve the scope">
    Pick a `session_id` (for session logs) or a `machine_id` (for instance logs). For instance
    logs, fetch the `machine_id` from `GET /sessions/{session_id}/runtime` or
    `GET /models/{model_id}/instances`.
  </Step>

  <Step title="Open the stream">
    `GET /logs?{session_id | machine_id}=…` with your API key as the bearer token in the
    `Authorization` header. There is no separate ticket step.
  </Step>

  <Step title="Process events">
    Switch on the SSE event name. Handle `log`, observe `heartbeat`, treat `error` as a soft warning,
    and close cleanly on `end`.
  </Step>

  <Step title="Reconnect when needed">
    On `end` with `reason: "deadline"` (or `shutdown`/`rate_limit`), wait a short backoff and
    reopen the stream with the same API key.
  </Step>
</Steps>

<Tip>
  `/logs` enforces the same ownership check as the rest of the Reactor API: you can only stream
  sessions and instances tied to models your account owns.
</Tip>

## Next

<CardGroup cols={2}>
  <Card title="Authenticate the CLI" icon="key" href="/deploy/platform/authentication">
    How to obtain and configure the credentials the Reactor API expects.
  </Card>

  <Card title="Deploy a release" icon="rocket" href="/deploy/platform/deploy">
    Once a model is live, its sessions and instances are ready to stream from.
  </Card>
</CardGroup>
