# Follow a task as it works

Stream a task's events as they happen, reconnect after a drop without missing or repeating one, and show the worker's answer as it is written.

A task's events are everything that happens in it, in order: your messages, the worker's messages, each tool it uses, and each time it stops. [Runs and events](/concepts/runs-and-events) lists every type. This guide follows one task live. The API sends each event the moment it is written, as Server-Sent Events, and a program that reads them can pick up where it left off when the connection drops.

Each step shows curl, TypeScript and Python. Pick one and the page keeps it picked. The TypeScript and Python steps build one small program, so add each step's code to the same file.

## Before you start

- A key in `LITTLE_WORKER_API_KEY` and a worker's id in `WORKER_ID`. The [Quickstart](/get-started/quickstart) makes both, and [API keys](/get-started/api-keys) says more about keys.
- For curl: curl, awk and jq.
- For TypeScript: Node 22.18 or later, which runs a `.ts` file as it is. Save the program as `follow.ts` and run `node follow.ts`.
- For Python: Python 3 and the requests library (`pip install requests`). Save the program as `follow.py` and run `python3 follow.py`.

## 1. Give a task

Give the worker a task that takes a few seconds to write, so you can watch the answer arrive:

```bash title="curl"
curl https://core.thelittleworker.com/api/v1/tasks \
  -H "x-api-key: $LITTLE_WORKER_API_KEY" \
  -H "Little-Worker-Version: 2026-10-19" \
  -H "content-type: application/json" \
  -d '{
    "worker": "'"$WORKER_ID"'",
    "title": "Subject lines",
    "initial_events": [
      {
        "type": "user.message",
        "content": [{ "type": "text", "text": "Write five subject lines for an email that says the shop now opens at 7 am. Give a sentence on why each one works." }]
      }
    ]
  }'
```

```ts title="TypeScript"
const api = "https://core.thelittleworker.com/api/v1"
const headers = {
  "x-api-key": process.env.LITTLE_WORKER_API_KEY ?? "",
  "Little-Worker-Version": "2026-10-19",
  "content-type": "application/json",
}

const task = await fetch(`${api}/tasks`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    worker: process.env.WORKER_ID,
    title: "Subject lines",
    initial_events: [
      {
        type: "user.message",
        content: [
          {
            type: "text",
            text: "Write five subject lines for an email that says the shop now opens at 7 am. Give a sentence on why each one works.",
          },
        ],
      },
    ],
  }),
}).then((response) => response.json() as Promise<{ id: string }>)
console.log(task.id)
```

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

import requests

api = "https://core.thelittleworker.com/api/v1"
headers = {
    "x-api-key": os.environ["LITTLE_WORKER_API_KEY"],
    "Little-Worker-Version": "2026-10-19",
}

task = requests.post(
    f"{api}/tasks",
    headers=headers,
    json={
        "worker": os.environ["WORKER_ID"],
        "title": "Subject lines",
        "initial_events": [
            {
                "type": "user.message",
                "content": [
                    {
                        "type": "text",
                        "text": "Write five subject lines for an email that says the shop now opens at 7 am. Give a sentence on why each one works.",
                    }
                ],
            }
        ],
    },
).json()
print(task["id"])
```

The answer is the task, and its `id` is how every other call names it. With curl, keep it for the next step:

```bash
export TASK_ID="task_01M5TTRJKWYAK3XHFX432K5MBV"
```

## 2. Open the stream

`GET /tasks/{task_id}/events/stream` ([Stream events](/api/events#stream-events)) answers with a response that stays open and sends each event as it is written. `after=0` starts at the first event, so you see the whole task. Without `after`, the stream sends only what happens after it opens, and a stream opened late misses the events before it.

```bash
curl -N "https://core.thelittleworker.com/api/v1/tasks/$TASK_ID/events/stream?after=0" \
  -H "x-api-key: $LITTLE_WORKER_API_KEY" \
  -H "Little-Worker-Version: 2026-10-19"
```

Each event is a frame of three lines and a blank line. Shortened, the first frames look like this:

```text
: connected

id: 1
event: user.message
data: {"type":"user.message","id":"event_2f4e0c1b7a9d4e3f8b6a5c4d3e2f1a0b","sequence":"1","content":[{"type":"text","text":"Write five subject lines..."}],...}

id: 2
event: task.status_running
data: {"type":"task.status_running","id":"event_7c1d2e3f4a5b4c6d8e9f0a1b2c3d4e5f","sequence":"2","run_id":"run_488241351b7a4b528b8bf6df21c7b7a8",...}
```

- `id` is the event's `sequence`, a number written as text. It orders the events of the task.
- `event` is the event's `type`, and `data` is the event as JSON, the same as the events list shows it.
- A line that starts with `:` is a comment. The stream sends `: connected` first and heartbeat comments after that, to keep the connection alive. Ignore them.

A workspace may hold 10 open streams at once. An eleventh is refused with `429`, so close a stream when you are done with it.

## 3. Read the frames

A frame ends at a blank line. The code below reads the response as it arrives, cuts it at each blank line, and parses the `data:` lines. It asks for previews too (`event_deltas[]=worker.message`), which step 5 explains.

```bash title="curl"
curl -sN "https://core.thelittleworker.com/api/v1/tasks/$TASK_ID/events/stream?after=0" \
  -H "x-api-key: $LITTLE_WORKER_API_KEY" \
  -H "Little-Worker-Version: 2026-10-19" \
  | awk '/^data: /{ print substr($0, 7); fflush() }' \
  | jq --unbuffered -r 'select(.type == "worker.message") | .content[] | select(.type == "text") | .text'
```

```ts title="TypeScript"
type Frame = {
  type: string
  id?: string
  sequence?: string
  content?: { type: string; text?: string }[]
  name?: string
  stop_reason?: { type: string }
  event?: { id: string }
  event_id?: string
  delta?: { content: { text: string } }
}

class Refused extends Error {}

async function* readFrames(
  taskId: string,
  after: string,
): AsyncGenerator<Frame> {
  const response = await fetch(
    `${api}/tasks/${taskId}/events/stream?after=${after}&event_deltas[]=worker.message`,
    { headers },
  )
  if (!response.ok || response.body === null) {
    throw new Refused(`${response.status} ${await response.text()}`)
  }
  const decoder = new TextDecoder()
  let buffer = ""
  for await (const chunk of response.body) {
    buffer += decoder.decode(chunk, { stream: true })
    for (
      let end = buffer.indexOf("\n\n");
      end !== -1;
      end = buffer.indexOf("\n\n")
    ) {
      const data = buffer
        .slice(0, end)
        .split("\n")
        .filter((line) => line.startsWith("data: "))
        .map((line) => line.slice("data: ".length))
        .join("\n")
      buffer = buffer.slice(end + 2)
      if (data !== "") yield JSON.parse(data)
    }
  }
}
```

```python title="Python"
class Refused(Exception):
    pass


def read_frames(task_id, after):
    response = requests.get(
        f"{api}/tasks/{task_id}/events/stream",
        headers=headers,
        params={"after": after, "event_deltas[]": "worker.message"},
        stream=True,
        timeout=(10, 90),
    )
    try:
        if not response.ok:
            raise Refused(f"{response.status_code} {response.text}")
        response.encoding = "utf-8"
        data = []
        for line in response.iter_lines(decode_unicode=True):
            if line.startswith("data: "):
                data.append(line[len("data: "):])
            elif line == "" and data:
                yield json.loads("\n".join(data))
                data = []
    finally:
        response.close()
```

Every frame comes out as an object. Most are events, and an event has an `id`, a `sequence` and a `type`. A frame with none of the three is a preview of an answer that is still being written.

## 4. Follow until the worker stops

An event of type `task.status_idle` means the worker stopped, and its `stop_reason` says why. The stream stays open after it, so your program ends the read itself. `follow` shows each event and returns the stop reason:

```ts title="TypeScript"
const previewed = new Map<string, string>()

function showPreview(frame: Frame) {
  if (frame.type === "event_start" && frame.event !== undefined) {
    previewed.set(frame.event.id, "")
  } else if (frame.type === "event_delta" && frame.event_id !== undefined) {
    const piece = frame.delta?.content.text ?? ""
    previewed.set(frame.event_id, (previewed.get(frame.event_id) ?? "") + piece)
    process.stdout.write(piece)
  }
}

function showEvent(event: Frame) {
  if (event.type !== "worker.message") {
    console.log(`[${event.type}]${event.name === undefined ? "" : ` ${event.name}`}`)
    return
  }
  const text = (event.content ?? [])
    .filter((block) => block.type === "text")
    .map((block) => block.text)
    .join("\n")
  const shown = previewed.get(event.id ?? "")
  // The pieces are only what was written after the stream opened. If they
  // are not the whole answer, print the whole answer.
  if (shown === text) console.log()
  else console.log(shown === undefined ? text : `\n${text}`)
}

async function follow(taskId: string): Promise<string> {
  const handled = new Set<string>()
  let last = 0n
  for (;;) {
    // Some events share a sequence with the one before them, so start one
    // earlier and skip what was handled.
    const after = last > 0n ? String(last - 1n) : "0"
    try {
      for await (const frame of readFrames(taskId, after)) {
        if (frame.id === undefined || frame.sequence === undefined) {
          showPreview(frame)
          continue
        }
        if (handled.has(frame.id)) continue
        handled.add(frame.id)
        if (BigInt(frame.sequence) > last) last = BigInt(frame.sequence)
        showEvent(frame)
        if (frame.type === "task.status_idle") {
          return frame.stop_reason?.type ?? "unknown"
        }
      }
    } catch (error) {
      if (error instanceof Refused) throw error
    }
    await new Promise((resolve) => setTimeout(resolve, 2000))
  }
}

console.log("Stopped:", await follow(task.id))
```

```python title="Python"
previewed = {}


def show_preview(frame):
    if frame["type"] == "event_start":
        previewed[frame["event"]["id"]] = ""
    elif frame["type"] == "event_delta":
        piece = frame["delta"]["content"]["text"]
        previewed[frame["event_id"]] = previewed.get(frame["event_id"], "") + piece
        print(piece, end="", flush=True)


def show_event(event):
    if event["type"] != "worker.message":
        print(f"[{event['type']}]" + (f" {event['name']}" if "name" in event else ""))
        return
    text = "\n".join(b["text"] for b in event["content"] if b["type"] == "text")
    shown = previewed.get(event["id"])
    # The pieces are only what was written after the stream opened. If they
    # are not the whole answer, print the whole answer.
    if shown == text:
        print()
    else:
        print(text if shown is None else f"\n{text}")


def follow(task_id):
    handled = set()
    last = 0
    while True:
        # Some events share a sequence with the one before them, so start one
        # earlier and skip what was handled.
        after = str(last - 1) if last > 0 else "0"
        try:
            for frame in read_frames(task_id, after):
                if "id" not in frame or "sequence" not in frame:
                    show_preview(frame)
                    continue
                if frame["id"] in handled:
                    continue
                handled.add(frame["id"])
                last = max(last, int(frame["sequence"]))
                show_event(frame)
                if frame["type"] == "task.status_idle":
                    return frame["stop_reason"]["type"]
        except requests.RequestException:
            pass
        time.sleep(2)


print("Stopped:", follow(task["id"]))
```

The program prints the answer as it is written, and then `Stopped: end_turn`. With curl, the pipeline in step 3 is the whole program: stop it with Ctrl+C once the answer is complete. The events of other types are shown as `[type]`. A tool the worker used shows its `name`. More types than these exist, such as `task.usage` and `span.model_request_start`, and new ones may be added, so ignore a type you do not know.

`stop_reason.type` says why the worker stopped. [Tasks](/concepts/tasks#what-a-stop-means) has the same table with the task's `state` beside it:

| `stop_reason.type` | What it means | What to do |
| --- | --- | --- |
| `end_turn` | The worker finished its turn, or you interrupted it. | Read the task with `GET /tasks/{task_id}`. `state` says whether the work is `done` or waits for you. |
| `requires_action` | The worker waits for you: an Allow, an answer, or a step only a person can do. | Answer it. [Answer approvals from your program](/guides/answer-approvals) shows how for an Allow. |
| `budget_reached` | The task spent its own budget. | Raise `budget` with `POST /tasks/{task_id}`, and the task goes on. |
| `retries_exhausted` | The work failed. A `task.error` came just before. | Read the error, then send a message to try again. |
| `refusal` | The model declined the turn for safety. `stop_details` says why. | Send a new message. |
| `turn_limit`, `time_limit` | A run reached 200 model turns or 2 hours of work. The work is kept. | Send a message, or send the task back, to start a fresh run. |

A `task.error` with `retry_status` `retrying` is not a stop. Core is trying again by itself, so keep reading.

## Reconnect without missing or repeating one

A long stream drops sometimes: a network change, a sleeping laptop, a proxy that closes quiet connections. `follow` already handles it. Two things make that safe:

- **A set of the event ids it handled.** An event has the same `id` however many times the stream sends it, so `follow` skips an id it has seen.
- **A cursor one before the last sequence.** `after` names the last sequence you have, and the stream sends what comes after it. Some events share a sequence: a `task.usage` and the `task.status_idle` after it, a `task.error` and the stop it caused, a `system.message` and the `user.message` before it, and a status change such as `task.updated`, which takes the sequence of the event before it. A connection can drop between two of them, so `follow` asks for `last - 1` and lets the set skip the repeats.

To resume by hand with curl, send the last `id` you saw as `Last-Event-ID`. It does the same as `after`, and wins if you send both:

```bash
curl -N "https://core.thelittleworker.com/api/v1/tasks/$TASK_ID/events/stream" \
  -H "x-api-key: $LITTLE_WORKER_API_KEY" \
  -H "Little-Worker-Version: 2026-10-19" \
  -H "Last-Event-ID: 10"
```

The stream refuses a cursor it cannot use, before it sends any frame, with `400` and `invalid_request_error`. `error.details.error_code` says which:

| `error_code` | Why | What to do |
| --- | --- | --- |
| `cursor_expired` | The cursor is older than the history kept. A free plan deletes history after 7 days, and the task then has a `history.purged` event. | Open the stream again with `after=0` and clear what you kept. |
| `cursor_ahead` | The cursor is beyond the events the task has. | Use the last `sequence` you actually received. |
| `invalid_cursor` | The cursor is not a sequence. | Use a number from an `id` line. |

`follow` raises `Refused` on these, since trying again with the same cursor cannot work.

## 5. Show the answer as it is written

A `worker.message` arrives when the worker has written the whole answer. To show the words as they are written, ask for previews with `event_deltas[]=worker.message` on the stream URL, as `readFrames` does. The stream then sends two more kinds of frame, which have no `id` line:

```text
event: event_start
data: {"type":"event_start","event":{"type":"worker.message","id":"event_5a6b7c8d9e0f4a1b8c2d3e4f5a6b7c8d"}}

event: event_delta
data: {"type":"event_delta","event_id":"event_5a6b7c8d9e0f4a1b8c2d3e4f5a6b7c8d","delta":{"type":"content_delta","index":0,"content":{"type":"text","text":"1. Open at 7 am, "}}}
```

`event_start` names the message that is about to be written, with the `id` the finished `worker.message` will carry. Each `event_delta` is the next piece of its text. Join the pieces in order to get the text so far. When the finished `worker.message` arrives, it holds the whole text. `follow` prints nothing more if the pieces it showed add up to it. A stream that opens while a message is being written gets only the pieces written after it opened, so `follow` prints the whole message when the pieces fall short.

Previews are only for the connection that asked, and only for text written after it opened. They are never listed and never sent again, so after a reconnect an answer that was half written arrives whole, as its `worker.message`. The finished event is the record. `worker.thinking` can be previewed too, with `event_deltas[]=worker.thinking`. It sends only an `event_start`, right before the `worker.thinking` event.

## Next steps

- [Runs and events](/concepts/runs-and-events): every event type, the order events come in, and how to list them.
- [Events reference](/api/events): the routes that list, send and stream a task's events.
- [Answer approvals from your program](/guides/answer-approvals): handle a task that stops with `requires_action`.
- [Webhooks](/guides/webhooks): hear when a task stops without holding a connection open.
- [Tasks reference](/api/tasks): every field of a task, and the routes to list, read and change tasks.
