Skip to main content
    Ivy Biosciences Docs/Call the platform/API/Real-time/SSE Streaming

    SSE Streaming

    Server-Sent Events for harness session execution and job event proxying

    realtimessestreaming

    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.

    Difficulty

    intermediate

    Time

    10 min

    Prerequisites

    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 for the full reference. The ones most clients handle:

    CategoryEvents
    Messagemessage.delta, message.complete, message.citations, content.reset, content.correction
    Tooltool.started, tool.delta, tool.completed, tool.failed
    Jobjob.dispatched, job.progress, job.completed, job.failed
    Interruptinterrupt.requested, interrupt.resolved
    Turnturn.started, turn.completed, error
    Syncsync.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) 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:

    EventWhen
    subscribedThe upstream job socket is open
    job_updateStatus or progress changed; data.status holds the job status
    errorThe upstream connection failed
    closeThe 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.

    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:

    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 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} when you only need to check a job occasionally

    Error Recovery

    SSE Auto-Reconnect (EventSource)

    EventSource automatically reconnects on connection drop. Override with:

    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:

    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")
    }

    Was this page helpful?

    Loading...