---
title: Live events & screen
description: Stream what the agent is doing, each step as it finishes and the browser's screen while a run works, instead of waiting for the result.
---

A run takes seconds to minutes. Instead of waiting for the end, stream it: you see what the agent is doing right now, each step as it finishes, the run's state whenever it changes and, if you ask, the browser's screen. The stream ends with the finished run, `answer`, `data` and `charge_usd` included.

Streams use Server-Sent Events (`text/event-stream`), the format LLM APIs use for streaming. Watching a run is free.

## Stream a new run

Add `"stream": true` when you [create a run](/docs/api/browser#create-a-run). The response is the event stream, with status `200`, instead of the `202` JSON. Add `"screen": true` to get the browser's screen too.

```bash title="cURL"
curl -N https://lovelyrouter.com/v1/browser/runs \
  -H "Authorization: Bearer $LOVELY_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "goal": "Find the price of the book \"A Light in the Attic\" and whether it is in stock.",
    "start_url": "https://books.toscrape.com/",
    "stream": true
  }'
```

```python title="Python"
import json
import os
import requests

API = "https://lovelyrouter.com/v1/browser"
H = {"Authorization": f"Bearer {os.environ['LOVELY_API_KEY']}"}

def events(resp):
    """Server-Sent Events -> (event, data)."""
    event = None
    for line in resp.iter_lines(decode_unicode=True):
        if line.startswith("event: "):
            event = line[7:]
        elif line.startswith("data: "):
            yield event, json.loads(line[6:])

body = {
    "goal": 'Find the price of the book "A Light in the Attic" and whether it is in stock.',
    "start_url": "https://books.toscrape.com/",
    "stream": True,
}
with requests.post(f"{API}/runs", headers=H, json=body, stream=True) as r:
    for event, data in events(r):
        if event == "activity":
            print("...", data["text"])
        elif event == "step":
            print(data["step"], data["description"])
        elif event == "run":
            run = data

print(run["status"], run["answer"], run["charge_usd"])
```

```js title="Node.js"
const res = await fetch('https://lovelyrouter.com/v1/browser/runs', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.LOVELY_API_KEY}`,
    'content-type': 'application/json',
  },
  body: JSON.stringify({
    goal: 'Find the price of the book "A Light in the Attic" and whether it is in stock.',
    start_url: 'https://books.toscrape.com/',
    stream: true,
  }),
});

// Each message is "event: <name>\ndata: <json>\n\n".
let run;
let buf = '';
const decoder = new TextDecoder();
for await (const chunk of res.body) {
  buf += decoder.decode(chunk, { stream: true });
  for (let i; (i = buf.indexOf('\n\n')) >= 0; buf = buf.slice(i + 2)) {
    const msg = buf.slice(0, i);
    const event = /^event: (.*)$/m.exec(msg)?.[1];
    const data = /^data: (.*)$/m.exec(msg)?.[1];
    if (!data) continue;
    const d = JSON.parse(data);
    if (event === 'activity') console.log('...', d.text);
    if (event === 'step') console.log(d.step, d.description);
    if (event === 'run') run = d;
  }
}
console.log(run.status, run.answer, run.charge_usd);
```

The stream looks like this (abridged):

```sse
event: run
data: {"id":"brun_3f9a1c7e0b2d4e6f8a9b0c1d","object":"browser_run","status":"running","steps":0,"trace":[],"poll":"/v1/browser/runs/brun_3f9a1c7e0b2d4e6f8a9b0c1d?wait=30","events":"/v1/browser/runs/brun_3f9a1c7e0b2d4e6f8a9b0c1d/events","charge_usd":null,"reserved_usd":0.445,…}

event: activity
data: {"step":0,"kind":"starting","text":"Opening https://books.toscrape.com/"}

event: activity
data: {"step":1,"kind":"reading","text":"Reading the page"}

event: activity
data: {"step":1,"kind":"acting","text":"click link \"A Light in the ...\" → https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html"}

event: step
data: {"step":1,"url":"https://books.toscrape.com/","action":"click:e54","description":"click link \"A Light in the ...\" → https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html","ms":2140,"error":null}

event: run
data: {"id":"brun_3f9a1c7e0b2d4e6f8a9b0c1d","status":"running","steps":1,…}

event: activity
data: {"step":2,"kind":"answering","text":"Reading the result and writing the answer"}

event: step
data: {"step":2,"url":"https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html","action":"done","description":"Read the result and wrote the answer","ms":2875,"error":null}

event: run
data: {"id":"brun_3f9a1c7e0b2d4e6f8a9b0c1d","status":"succeeded","answer":"\"A Light in the Attic\" costs £51.77 and is in stock (22 available).","charge_usd":0.012,"reserved_usd":0,…}

event: end
data: {"id":"brun_3f9a1c7e0b2d4e6f8a9b0c1d"}
```

The first `run` event carries what the `202` response would: `poll`, `events`, and `webhook_secret` when you passed a `webhook`. Errors before the run starts (`400`, `402`, `429` and so on) come back as JSON with their status, the same as without `stream`.

## Follow a run that is already going

Open `GET /v1/browser/runs/{id}/events` at any time: from another process, after a dropped connection, or for a run you started without `stream`. Add `?screen=1` for the screen. Several streams can watch the same run.

```bash title="cURL"
curl -N "https://lovelyrouter.com/v1/browser/runs/brun_3f9a1c7e0b2d4e6f8a9b0c1d/events?screen=1" \
  -H "Authorization: Bearer $LOVELY_API_KEY"
```

Every stream starts with a `run` event that has the full `trace`, so reconnecting loses nothing. A finished run sends that `run` event (and its last screen, with `screen`) followed by `end`.

## Events

| Event | When | `data` |
| --- | --- | --- |
| `run` | First, after every step, when a pause begins or ends, and last | The [run object](/docs/api/browser#the-run-object) with `charge_usd` and `reserved_usd`. The first one has `trace`. |
| `activity` | The agent starts something | `step` (the step in progress), `kind` and `text` |
| `step` | A step finished | One `trace` entry: `step`, `url`, `action`, `description`, `ms`, `error` |
| `frame` | With `screen`: on connect, then whenever the page changes | `data`, a base64 JPEG of the browser, and `at` (ISO 8601) |
| `end` | The run is over; the stream closes next | `id` |

Activity kinds:

| `kind` | The agent is |
| --- | --- |
| `starting` | starting the browser or opening `start_url` |
| `reading` | reading the page |
| `waiting` | waiting for a page that has not loaded, or loading it again |
| `deciding` | choosing the next action |
| `thinking` | looking more closely before it decides (accurate mode may use a screenshot) |
| `checking` | checking a form's values before it submits |
| `acting` | doing the action `text` describes: a click, typing, a choice, an upload |
| `answering` | reading the result and writing the answer |
| `extracting` | turning what it read into `data` |

Lines that start with `:` are keep-alive comments, sent every 15 seconds while nothing else happens. Ignore them.

## Watch the screen

With `"screen": true` (or `?screen=1`), `frame` events carry the browser's screen as base64 JPEG, usually 20 to 60 KB each. A frame arrives when you connect and whenever the page changes, at most about 4 a second; a page that holds still sends nothing new. If your reader falls behind, frames are skipped, never other events.

To show it on a web page, put the frame in an image:

```js
img.src = `data:image/jpeg;base64,${frame.data}`;
```

For a single picture without a stream, [`GET /v1/browser/runs/{id}/screenshot`](/docs/api/browser#get-a-screenshot) returns the screen as it is now.

## Pauses for a person

The stream stays open while a run is `waiting_for_human`. The `run` event that starts the pause has `needs`; answer it with [`POST /v1/browser/runs/{id}/reply`](/docs/api/browser#reply-to-a-run) from anywhere, and the stream carries on with a `run` event back at `running`. With `screen`, you also see what a person does in the browser during a takeover. See [Human in the loop](/docs/browser-agent/human-in-the-loop).

## Streams in a web app

Keep your API key on your server. Browsers' `EventSource` can't send an `Authorization` header, so open the stream from your server and pass it through to your page unchanged, with `content-type: text/event-stream`. Your page can then use `EventSource` on your own URL.

## Reconnect

A stream can drop: a network blip, a proxy timeout, a deploy on our side. The run keeps working. Open `GET /v1/browser/runs/{id}/events` again and replace your state with the first `run` event. `EventSource` reconnects by itself, after 3 seconds. Stop when you have seen `end` or a `run` with a final status.
