Skip to content

Thread

Durable thread runtime, queueing, and parent/child subagent state

What Is a Thread?

A thread is a conversation instance backed by a Cloudflare Durable Object and SQLite state.

Each thread has isolated:

  • messages
  • execution state
  • logs
  • filesystem

Durable Runtime

New projects do not create local Durable Object subclass files. The builder() Vite plugin generates the Worker entry and exports the built-in thread and builder Durable Objects with the required Cloudflare bindings and migrations.

ThreadState APIs

Tools/hooks/endpoints receive ThreadState.

Key methods:

  • getMessages() / injectMessage() / updateMessage()
  • queueMessage()
  • getParentThread() / getChildThread(referenceId)
  • terminate()
  • filesystem methods (readFile, writeFile, etc.)
  • status() — the thread's authoritative run status (see below)
  • log(entry) — record an application log entry (see below)

Key properties:

  • children (child registry)
  • terminated
  • arguments (read-only invocation or creation arguments)
  • context (mutable scratch data for the current execution only)

Run Status

await state.status() returns the thread's ThreadStatus: { state: 'idle' | 'running' | 'failed', since, reason }. since is in microseconds; reason is the raw stop reason code of the last transition out of running (stop_on_response, stop_tool, awaiting_human, hook_blocked, aborted_by_user, step_error, execution_lost, …). A run that hands the turn to a human side settles as idle with reason awaiting_human (or the code that ended the turn); the status describes execution, not whose turn it is.

The status is persisted with the thread, so it survives restarts, and a run the runtime lost settles to failed (execution_lost) instead of staying running. Clients receive it without polling: GET /api/threads/:id includes status, and the message stream sends a thread_status snapshot on connect and a frame on every change.

status() is unrelated to setStatus(label), which writes a free-form label into this thread's entry in its parent's subagent registry.

Application Logs

log() is a Standard Agents Builder extension. Import the Builder SDK to load its TypeScript declaration:

import type { ThreadLogEntry } from '@standardagents/builder';

await state.log(entry) records an application entry in the thread's logs, next to the runtime's own LLM request, tool, and handler rows. Any code with a ThreadState can write one; only name is required.

A plain event is a name and a message:

await state.log({ name: 'cache.miss', level: 'debug', message: 'cold start' });

An entry that records an operation (a call to another service, a decision, a background job) also says what caused it, what was sent, what came back, how long it took, and what it cost:

await state.log({
  name: 'search.query',
  status: 'success',
  message: 'Found 12 flights',
  trigger: { kind: 'message', id: messageId, summary: 'User asked for flights' },
  request: { q: 'flights to Lisbon' },
  response: { results: 12 },
  duration_ms: 182,
  cost: 0.0004,
});
Field Meaning
name Short event name, e.g. cache.miss. Required.
message One human-readable line.
level debug, info, warn, or error. Defaults to error when status is error, otherwise info.
status Outcome of the operation: success, error, or skipped.
trigger The event that caused the entry: { kind, id?, summary? }.
request What was sent, or the input that triggered the work. Any JSON value; strings stay text.
response What came back. Any JSON value; strings stay text.
error Failure reason.
duration_ms Duration in milliseconds.
cost Cost in USD, when the operation cost money. Counts toward the thread's cost totals alongside its LLM request costs.
data Any other structured detail.

Entries are delivered to live log subscribers and returned by GET /api/threads/:id/logs as rows with kind: 'app': model holds the name, and the other fields are app_level, app_message, app_status, app_error, app_duration_ms, app_cost, plus JSON text in app_request, app_response, app_trigger, and app_data. The builder UI opens these rows in the log detail pane with the trigger, duration, cost, and Request and Response tabs, and show the cost in the logs table's Cost column.

An entry's cost is part of what the thread cost: GET /api/threads/:id/cost adds it to totalCost (and to the thread's row in the per-thread breakdown), and reports the application share apart as appCost. The per-model breakdown and requestCount cover LLM requests only. The row's own cost_total stays null; it is the LLM request cost.

Logging never creates a message and never starts or wakes an execution, so it is safe from hooks, tools, effects, and endpoints. It never throws because of an entry's content: fields of the wrong type are dropped, values that cannot be JSON-encoded are replaced by a marker object, and oversized fields are truncated with a marker (name to 120 characters, message and error to 4 KiB, the JSON of data to 16 KiB, and the JSON of request and response to 64 KiB each). Entries attach to the newest top-level message when one exists. Before the first message, entries persist without a message anchor and reach live log subscribers.

User Identity and Application Roles

await state.user() returns the thread owner's identity and user handle, or null for a thread without a user. Its optional roles array carries application-defined strings from the runtime's identity authority. Hosted Standard Agents Builder instances receive them from Platform through signed updates. Check roles in each protected request or execution and treat absent roles as an empty assignment. ThreadState.user() reads identity on each call. UserState.identity() caches identity within one user-scope execution. These strings do not change built-in instance access levels or permissions. See application role checks for a server-side endpoint example. See Users for user endpoints, call(), and the user event stream.

Queue Semantics

queueMessage() is durable and ordered:

  • if thread is active, queued messages are injected before the next model step
  • if thread is idle, queueing forces execution
  • queue entries survive eviction
  • a message sent through the HTTP API while the thread is active is delivered the same way: it joins the running execution in the order it was sent
  • message APIs expose sendMessage() and queueMessage() queued entries as pending messages with metadata.queued: true

Parent/Child Communication

Subagent communication is message + filesystem aware:

Parent -> Child

  • initial invocation mapping and later subagent_message calls
  • subagent_create.arguments persists with the child thread as ThreadState.arguments and renders as prompt variables for both child sides
  • any referenced attachments are copied parent -> child

Child -> Parent

  • with parentCommunication: 'implicit', completion/failure payloads are queued as silent messages for the relationship's owning side
  • with parentCommunication: 'explicit', completion does not queue a parent message; child code can escalate through state.notifyParent()
  • returned attachments are copied child -> parent
  • copied attachment paths are rewritten for the destination thread

Termination

terminate() performs soft shutdown:

  • sets terminated timestamp
  • aborts in-flight execution
  • rejects new execution/queue operations; POST /api/threads/{threadId}/messages and the debug POST /api/threads/{threadId}/continue return 423 and store nothing
  • keeps the non-execution thread routes working: reading, listing, updating, reading and deleting messages, files, stop, the stream, and deleting the thread
  • propagates child termination status to parent registry when linked

Registry and Message Projection

Parent threads maintain a registry of their child instances.

Messages may carry projected subagent fields (for UI/client grouping), including:

  • subagent_id
  • subagent_name
  • subagent_title
  • subagent_status
  • subagent_relationship
  • subagent_blocking