---
title: SSE Streaming
description: Server-Sent Events for harness session execution and job event proxying
tags:
  - realtime
  - sse
  - streaming
---
Server-Sent Events (SSE) stream updates one way over plain HTTP: the model's response as it is generated, or a job's progress as it runs, with no socket to manage. Reach for SSE when you want streaming that works through restrictive proxies, or when you need the POST-based harness execute stream. SSE is the only real-time mechanism the platform offers external clients. There is no public WebSocket endpoint; see [WebSocket Protocol](/docs/api/realtime-websocket).

  intermediate
  10 min
  [authentication](/docs/api/authentication)

## SSE Streaming

Server-Sent Events (SSE) provide two streaming capabilities:

1. **Harness session execution** — the turn's text, tool calls, jobs and sub-agents as typed events
2. **Job event proxy** — forwarding WebSocket job updates as SSE

---

## Harness Session SSE

When you call `POST /api/harness/sessions/:id/execute`, the response is an SSE stream.

### Headers

```
Content-Type: text/event-stream
Cache-Control: no-cache, no-transform
Connection: keep-alive
X-Accel-Buffering: no
```

### Event Format

Each event is a single `data:` line holding a flat JSON object; `type` names the event and its fields sit beside it:

```
data: {"type":"turn.started","turnId":"..."}

data: {"type":"message.delta","delta":"I'll run an "}

data: {"type":"message.delta","delta":"ADMET prediction..."}

data: {"type":"tool.started","toolCall":{"id":"tc_001","name":"admet_ai","arguments":{"smiles":"CCO"}}}

data: {"type":"tool.completed","toolCall":{"id":"tc_001","name":"admet_ai"},"result":{"...":"..."}}

data: {"type":"sync.completed","sessionId":"..."}
```

### Stream Termination

A completed stream ends with a `sync.completed` event. A stream that fails ends with an `error` event whose `code` is `EXECUTION_ERROR`. Treat either one, or the connection closing, as the end of the turn.

### Event types

See [Message Execution](/docs/api/sessions-execute#streamevent-types) for the full reference. The ones most clients handle:

| Category | Events |
|----------|--------|
| Message | `message.delta`, `message.complete`, `message.citations`, `content.reset`, `content.correction` |
| Tool | `tool.started`, `tool.delta`, `tool.completed`, `tool.failed` |
| Job | `job.dispatched`, `job.progress`, `job.completed`, `job.failed` |
| Interrupt | `interrupt.requested`, `interrupt.resolved` |
| Turn | `turn.started`, `turn.completed`, `error` |
| Sync | `sync.completed`, `sync.conflict` |

---

## Job Event Proxy SSE

The job event proxy opens the compute backend's job WebSocket on the server and re-emits each update as SSE, so the client never connects to the backend directly.

### Authenticated

`GET /api/v1/jobs/:jobId/events` ([reference](/docs/api/reference/get-v1-jobs-jobid-events)) needs a signed-in session: a Clerk session JWT in the `Authorization` header, or the session cookie a browser on the same origin sends. An `ivy_` API key alone is refused with `503`; poll `GET /api/v1/jobs/:jobId` from a server that only holds a key. The route checks that the caller can read the job.

### Guest

`GET /api/v1/jobs/:jobId/guest-events?guest_id=guest_...` serves jobs started from a guest session. It needs no credential; the backend refuses the stream if `guest_id` does not match the job.

### Event names

Each SSE event carries a name, so listen by name rather than on `message`:

| Event | When |
|-------|------|
| `subscribed` | The upstream job socket is open |
| `job_update` | Status or progress changed; `data.status` holds the job status |
| `error` | The upstream connection failed |
| `close` | The stream is ending |

---

## Client Implementation

### For GET Endpoints (Job Events)

In a signed-in browser on the same origin, use the native `EventSource` API; it sends the session cookie and reconnects on its own. `EventSource` cannot set an `Authorization` header, so from a server, use a Clerk JWT, or poll `GET /api/v1/jobs/{jobId}`. The job stream does not accept an API key.

```ts
const source = new EventSource(`/api/v1/jobs/${jobId}/events`)

source.addEventListener("job_update", (e) => {
  const data = JSON.parse(e.data)
  handleJobUpdate(data)
})
```

### For POST Endpoints (Harness Execute)

`EventSource` only supports GET. For the harness execute endpoint (POST), use `fetch` with `ReadableStream`:

```ts
const response = await fetch(`/api/harness/sessions/${sessionId}/execute`, {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${apiKey}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ content: userMessage }),
})

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")
  buffer = lines.pop() || ""

  for (const line of lines) {
    if (line.startsWith("data: ")) {
      const event = JSON.parse(line.slice(6))
      handleStreamEvent(event)
    }
  }
}
```

---

## WebSocket vs SSE

External clients cannot open a job WebSocket. The [job event proxy](#job-event-proxy-sse) is how you follow a job from outside the platform.

### When to Use Which

- **SSE (EventSource)**: Job event proxy from a signed-in browser on the same origin
- **SSE (fetch)**: harness session execution (POST)
- **Polling**: [`GET /api/v1/jobs/{jobId}`](/docs/api/rest-jobs) when you only need to check a job occasionally

---

## Error Recovery

### SSE Auto-Reconnect (EventSource)

`EventSource` automatically reconnects on connection drop. Override with:

```ts
source.onerror = (event) => {
  if (source.readyState === EventSource.CLOSED) {
    // Permanently closed — do not reconnect
    console.log("Stream ended")
  }
  // Otherwise, EventSource will auto-reconnect
}
```

### Fetch Stream Error Recovery

For POST-based SSE (harness execute), implement manual reconnection:

```ts
async function executeWithRetry(sessionId: string, content: string, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      await streamExecute(sessionId, content)
      return // Success
    } catch (error) {
      if (attempt < maxRetries - 1) {
        await new Promise(r => setTimeout(r, 1000 * 2 ** attempt))
      }
    }
  }
  throw new Error("Max retries exceeded")
}
```

## Read next

- [WebSocket Protocol](/docs/api/realtime-websocket) — why there is no public job socket
- [Message Execution](/docs/api/sessions-execute) — harness SSE event reference
- [Jobs API](/docs/api/rest-jobs) — HTTP polling fallback
