Skip to content
Get API Key

Guides

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.

Updated
View as Markdown

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 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 makes both, and 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.

Step 1: Give a task

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

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." }]
      }
    ]
  }'

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

Shell
export TASK_ID="task_01M5TTRJKWYAK3XHFX432K5MBV"

Step 2: Open the stream

GET /tasks/{task_id}/events/stream (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.

Shell
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.

Step 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.

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'

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.

Step 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:

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

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 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 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:

Shell
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.

Step 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

Type a few words, like “give a task” or “errors”.