# Runs and events

A task works in runs and writes an event for everything that happens, which you can list or follow live.

A task is not one long job. It works in stretches called runs, and it writes an event for each thing that happens along the way: a message, a tool used, a stop. Events are how you follow a task, and how you tell what a worker did and why it stopped.

This page explains runs, the events a task writes, the order they come in, and how to read or follow them without missing one. The routes themselves are listed under `/tasks/{task_id}/events`.

## Runs

A run is one stretch of work inside a task. A task has a new run each time it starts again, such as after you send a message to an idle task or send a result back. A run has an id that starts with `run_`. You see it on these events:

- `task.status_running` and `task.status_idle`, which open and close a stretch of work.
- `task.status_rescheduled`, when a run is held and goes on by itself.
- `user.interrupt`, which names the run it stopped.

A send back also answers with the `run_id` of the new run. Each finished run has a receipt that says what it cost and which app actions were decided in it. Read them on the [Tasks reference](/api/tasks#read-what-a-task-cost).

A run is limited to 200 model turns and 2 hours of active model time. A run that reaches either one stops cleanly with the stop reason `turn_limit` or `time_limit`, and its work is kept. See [Tasks](/concepts/tasks#what-a-stop-means).

## Events

An event is one thing that happened in a task. Every event has the same first fields:

| Field | What it is |
| --- | --- |
| `type` | What kind of event it is, such as `worker.message`. |
| `id` | The event's id, which starts with `event_`. The id of a `worker.tool_use` is also the tool use id that its result, its approval and a stop name it by. |
| `task_id` | The task it belongs to. |
| `sequence` | A whole number written as a string. It orders the list, the stream and every cursor, because two events can share a time. Compare it as a number, not as text. |
| `processed_at` | When Little Worker wrote the event. A message still waiting for the worker has `null` here until a run takes it. |

`sequence` counts the events of the whole chat, not of one task. The numbers of a single task's events skip, so use the last `sequence` you saw as your place, and do not expect them to count up by one.

The names follow Claude Managed Agents with Little Worker's nouns: `worker.*` is `agent.*`, and `task.status_*` is `session.status_*`.

> [!NOTE]
> Little Worker may add event types inside one dated version. Write your program to skip a `type` it does not know.

### What people and programs send

| `type` | What happened |
| --- | --- |
| `user.message` | A message about the task, as `content` blocks: `text`, `image` or `document`. It has a `message_id`. |
| `system.message` | A system message sent right after a person's message. It is listed right after that message, with the same `sequence`. |
| `user.define_outcome` | An outcome was set for the task: `outcome_id`, `description`, `rubric`, `done_means` and `max_iterations`. |
| `user.approval` | An approval was answered: `tool_use_id`, `result`, `deny_message`, and `api_key_id`, the key that answered. It is `null` when the person answered in the app. |
| `user.interrupt` | The work was stopped, by your `user.interrupt` or by the person's Stop. `run_id` names the run it stopped. |
| `user.custom_tool_result` | Your answer to a custom tool use: `custom_tool_use_id`, `content` and `is_error`. |
| `user.tool_result` | A tool result a Mac ran and sent back, with `is_error`. A Mac is a self-hosted computer, so its results are listed as Claude lists a self-hosted runner's. |

### What the worker says and does

| `type` | What happened |
| --- | --- |
| `worker.message` | The worker's answer, as `content` text blocks, with its `sources`. |
| `worker.thinking` | The model reasoned during a request. It is a progress signal with no content. |
| `worker.tool_use` | The worker used a tool: `name` and `input`, with your passwords masked. `evaluated_permission` (`allow`, `ask` or `deny`) and `evaluation` say what decided the step, and are missing until something has. |
| `worker.tool_result` | The tool's `status`, which is `succeeded`, `failed`, `cancelled` or `outcome_unknown`, and its `output` or `error`. `tool_use_id` names the `worker.tool_use`. |
| `worker.mcp_tool_use` | The worker called one of your apps: `app_name`, the app's own tool `name`, and `input`. It has the same `evaluated_permission` and `evaluation`. |
| `worker.mcp_tool_result` | The app's answer. `mcp_tool_use_id` names the call, and `is_error` says whether it failed. |
| `worker.custom_tool_use` | The worker called one of its custom tools: `name` and `input`. Little Worker never runs it, and no permission policy applies. The task stops with `requires_action` until a `user.custom_tool_result` answers. |
| `worker.thread_context_compacted` | The worker's context grew too long and was squeezed into a summary. One event for each squeeze. |

### Where the task stands

| `type` | What happened |
| --- | --- |
| `task.status_running` | The task began running: its work was accepted, or it went on after it stopped. There is one event for each change of status, so a start that follows a queue adds none. It has a `run_id`. |
| `task.status_idle` | The worker stopped. `run_id` names the run. `stop_reason` says why, and `stop_details` is `null` except after a `refusal`. |
| `task.status_rescheduled` | A run hit a passing fault, and Little Worker runs it again by itself. |
| `task.status_terminated` | The task was taken back. It takes nothing more. |
| `task.error` | Something failed. `error` has a `type`, a `message`, a `retry_status` and a `code`. A `billing_error` that is `retrying` also names the cap that holds the task in `cap`. |
| `task.updated` | The task was changed with `POST /tasks/{task_id}`. It shows what the change set: `title`, `metadata` and `budget`. |
| `task.deleted` | The task was deleted. An open stream reads it and ends. |
| `history.purged` | History older than `before` was deleted, on a plan that keeps history for a set time. |

A `task.error` has one of these `error.type` values:

- `model_overloaded_error`, `model_rate_limited_error`, `model_request_failed_error`, `billing_error` and `unknown_error`.
- `app_connection_failed_error` and `app_authentication_failed_error`, which add `app_name`. An app that refused its sign-in or could not be reached leaves the task working.
- `password_host_unreachable_error`, which adds `password_id` and `password_set_id`. It comes once for each password in the task's sets whose website the worker's sites block.
- `repository_authentication_error`, `repository_forbidden_error`, `repository_not_found_error`, `repository_checkout_error` and `repository_clone_error`, which add `repository_url`. They come when a repository of the task could not be used. The task goes on without it.

`retry_status.type` says what happens next: `retrying` means Little Worker goes on by itself, `exhausted` means the work stopped and the task takes another message, and `terminal` means the task cannot go on.

### Cost and checks

| `type` | What happened |
| --- | --- |
| `task.usage` | What the task's work used, up to the stop that follows. One comes right before every `task.status_idle`. `usage` has the fields of the task's `usage`, and `budget` is the task's budget. |
| `span.model_request_start` | The worker sent one request to the model. |
| `span.model_request_end` | The request ended. `model_request_start_id` names its start, `is_error` is `true` when it failed, broke off or was stopped, and `model_usage` says what it used. |
| `span.outcome_evaluation_start` | A check round of an outcome began: `outcome_id` and `iteration`, counted from 0. |
| `span.outcome_evaluation_ongoing` | Listed so a program built for Claude's heartbeat can read this one. Little Worker sends none today. |
| `span.outcome_evaluation_end` | The round ended, with a `result`, an `explanation` and the `usage` of the check. |

### Helper threads and workflow runs

A worker can hand work to a helper. That work is a thread of the task, and its steps are also written on the task's own events, each naming the thread in `task_thread_id`.

| `type` | What happened |
| --- | --- |
| `task.thread_created` | A thread started. It has `worker_name`, and a `workflow_run_id` when a workflow run started it. |
| `worker.thread_message_sent` | The instructions the worker sent to a thread: `to_task_thread_id` and `content`. |
| `task.thread_status_running` | The thread is at work. |
| `task.thread_status_rescheduled` | A cap holds the thread, and it goes on when there is room. |
| `task.thread_status_idle` | The thread stopped, with a `stop_reason` and `stop_details` as a task has. |
| `worker.thread_message_received` | The answer the thread sent back: `from_task_thread_id` and `content`. |
| `task.thread_status_terminated` | The thread ended. |

A step of a thread that waits for an Allow is listed on the task too, as a `worker.tool_use` or `worker.mcp_tool_use` with a `task_thread_id`. Answer it on the task with a `user.approval`, like any other. `GET /tasks/{task_id}/threads` lists the task's threads, and each thread has its own events and its own stream.

A worker that is allowed to may also start a workflow run: named phases that run in order, each a set of helpers that work side by side. Its steps are on the task's events, and no webhook hears them.

| `type` | What happened |
| --- | --- |
| `workflow_run.created` | A run started: `workflow_run_id`, `name`, `description` and its `phases`. |
| `workflow_run.status_running` | The run is at work, or at work again after a pause. |
| `workflow_run.status_idle` | The run paused: a helper is held at the task's budget, or an interrupt paused it. |
| `workflow_run.phase_started` | A phase began: `workflow_run_phase_id`. |
| `workflow_run.phase_ended` | A phase ended, or the end of the run closed it. `phase_started_id` names the start it closes. |
| `workflow_run.error` | The run failed, right before its end. It also lists a start that made no run, with a `null` `workflow_run_id`. |
| `workflow_run.status_ended` | The run ended, always as its last step. `result` is `completed`, `stopped`, or `error` with the error. |

## The order events come in

Events are written in `sequence` order, and a list or a stream gives them that way. A few facts help when you read them:

- A model request is bracketed. `span.model_request_start` is listed before what the answer wrote, and `span.model_request_end` after it and before the stop that follows.
- `task.usage` comes right before every `task.status_idle`.
- A `task.error` whose `retry_status` is `exhausted` comes right before the `task.status_idle` it caused, and that stop is `retries_exhausted`.
- A message sent while the worker is busy waits for its next turn. The list places it where it was accepted, with `processed_at` `null` until a run takes it.
- After an interrupt and a message, the `task.status_idle` with `end_turn` comes before the `user.message`, then `task.status_running` follows.
- The status changes `task.updated`, `task.status_terminated` and `task.deleted`, the model request spans, `worker.thinking` and the outcome events are kept outside the chat. Each carries the `sequence` of the chat event before it. One of them can be written at a `sequence` after you took that number as your place.

The last point matters for a program that keeps a place. A place you hold goes on after the chat event it names, but still includes these events at its own `sequence`. Nothing is missed, and one you already saw can come again. **Keep the `id` of each event you handled, and skip an id you have seen.**

## Read events

`GET /tasks/{task_id}/events` lists a task's events, oldest first. Add `order=desc` for newest first. It takes these filters:

- `types[]` keeps only the types you name. Repeat it for more than one.
- `created_at[gt]`, `created_at[gte]`, `created_at[lt]` and `created_at[lte]` compare each event's `processed_at`. A message still waiting has none, so it matches no time filter.
- `after` lists only events that come after a `sequence`.
- `limit` is 20 by default and at most 100.

The answer is `{ "data": [...], "next_page": "..." }`. `next_page` is a `sequence`: send it back as `page`, with the same `order` and filters, until it is `null`. A `page` works only with the order and filters that made it.

```bash
curl -g "https://core.thelittleworker.com/api/v1/tasks/task_01M5TTRJKWYAK3XHFX432K5MBV/events?types[]=worker.message&order=desc&limit=1" \
  -H "x-api-key: $LITTLE_WORKER_API_KEY" \
  -H "Little-Worker-Version: 2026-10-19"
```

A bad place is `400` `invalid_request_error`, and `error.details.error_code` says which:

| `error_code` | When it happens |
| --- | --- |
| `invalid_cursor` | The `page` cannot be read, or it was made with another `order` or `created_at` filter. |
| `cursor_ahead` | The place is beyond the history that is committed. |
| `cursor_expired` | The place is older than the history kept, or comes from before the task's last `history.purged`. List the task's events again from the start. `after=0` never expires. |

## Follow events

`GET /tasks/{task_id}/events/stream` follows a task as it works, as Server-Sent Events. The answer stays open. Each frame is one event:

```text
id: 11
event: task.status_idle
data: {"type":"task.status_idle","sequence":"11","run_id":"run_488241351b7a4b528b8bf6df21c7b7a8","stop_reason":{"type":"end_turn"},"stop_details":null,...}
```

The `id` line is the event's `sequence`, `event` is its `type`, and `data` is the event as JSON. Lines that start with `:` are comments, such as `: connected` and a heartbeat. Ignore them. The stream stays open after a stop, so close it yourself when you have what you need. It ends by itself when the task is deleted.

With no cursor, the stream sends only events written after it opens. `after=0` replays every kept event from the first. To resume after a drop, send the last `id` you saw in a `Last-Event-ID` header, or as `after`.

A workspace may hold 10 open streams. Another is `429` with `Retry-After`.

### Catch up without a gap

Two ways work, and either one loses nothing:

- **List, then follow.** List the history, then open the stream with `after` set to the last `sequence` you listed.
- **Follow, then list.** Open the stream, list the history, and skip the ids you have already seen.

In both, skip ids you have seen, for the reason in [The order events come in](#the-order-events-come-in).

### Show an answer as it is written

Add `event_deltas[]=worker.message` to the stream URL, and `event_deltas[]=worker.thinking` if you want it. Any other value is `400`, and so is more than 100. A preview of a message is an `event_start` frame, with the `id` the finished message will carry, then `event_delta` frames. Each delta has an `event_id` and a `delta` of type `content_delta`, with an `index` and a `text` block in `content`.

A preview has no `id` line. It goes only to the connection that asked, only for text written after that connection opened, and it is never listed or replayed. The finished `worker.message` holds the whole text and is the record. A thinking preview is only a start: an `event_start` right before the `worker.thinking` it names.

## Send events

`POST /tasks/{task_id}/events` sends 1 to 50 events in `{ "events": [...] }`. Every event is checked before any applies, they apply in order, and if one fails, none applies. The answer is `{ "data": [...] }`, with one entry for each event in the order sent.

| Event | What it does |
| --- | --- |
| `user.message` | Sends a message the worker reads after the work in hand. It carries up to 6 images and documents, as base64 or plain text. A `url` or `file` source is `400` for now. |
| `user.interrupt` | Stops the work in hand. |
| `user.approval` | Allows or denies a waiting approval. See [Approvals](/concepts/approvals). |
| `user.custom_tool_result` | Answers a custom tool use the task waits on. |
| `user.define_outcome` | Sets the task's next outcome. |
| `user.accept` | Accepts the result. Send it alone. |
| `user.send_back` | Sends the result back with a `reason`. Send it alone. |
| `system.message` | Gives the model a system message. Send it right after a `user.message`, one for each send, with 1 to 1,000 `text` blocks. A task whose model has no system role answers `400` with `error_code` `model_does_not_support_mid_conversation_system`. |

A worker treats only a `user.message` as your intent. Text it reads in a page, a file, an email or a tool's answer is information, not an instruction. Send an `Idempotency-Key` header, a UUID, and a retry does the same thing once.

## History that goes

On a plan that keeps history for a set time, Little Worker deletes older history and writes a `history.purged` event with a `before` time. A list with no cursor, oldest first, starts with the newest `history.purged`, then the events that were kept. No webhook is sent for a purge. The free plan keeps 7 days, and paid plans keep history until you delete it. Keep your own copy of events you need for longer. See [Tasks](/concepts/tasks#how-long-a-task-is-kept).

## Next steps

- [Approvals](/concepts/approvals): the steps a task stops on, and how an answer reaches the worker.
- [Tasks](/concepts/tasks): what each stop reason means and what to do about it.
- [Quickstart](/get-started/quickstart): follow a real task from your own code.
