Concepts
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_runningandtask.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.
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.
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_*.
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_errorandunknown_error.app_connection_failed_errorandapp_authentication_failed_error, which addapp_name. An app that refused its sign-in or could not be reached leaves the task working.password_host_unreachable_error, which addspassword_idandpassword_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_errorandrepository_clone_error, which addrepository_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_startis listed before what the answer wrote, andspan.model_request_endafter it and before the stop that follows. task.usagecomes right before everytask.status_idle.- A
task.errorwhoseretry_statusisexhaustedcomes right before thetask.status_idleit caused, and that stop isretries_exhausted. - A message sent while the worker is busy waits for its next turn. The list places it where it was accepted, with
processed_atnulluntil a run takes it. - After an interrupt and a message, the
task.status_idlewithend_turncomes before theuser.message, thentask.status_runningfollows. - The status changes
task.updated,task.status_terminatedandtask.deleted, the model request spans,worker.thinkingand the outcome events are kept outside the chat. Each carries thesequenceof the chat event before it. One of them can be written at asequenceafter 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]andcreated_at[lte]compare each event'sprocessed_at. A message still waiting has none, so it matches no time filter.afterlists only events that come after asequence.limitis 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.
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:
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
afterset to the lastsequenceyou 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.
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. |
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.
Next steps
- Approvals: the steps a task stops on, and how an answer reaches the worker.
- Tasks: what each stop reason means and what to do about it.
- Quickstart: follow a real task from your own code.