Skip to main content

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 (resumable 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

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:
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:
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 thread’s newest message; on a thread with no messages yet the entry is written to the Worker console only.

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 AgentBuilder instances receive them from Platform through signed updates. Check roles in each protected request or execution and treat absent roles as an empty assignment. Identity is memoized within an execution; pushed changes are visible on the next request or execution. These strings do not change built-in instance access levels or permissions. See application role checks for a server-side endpoint example.

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
  • 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 resumable subagent_message
  • 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

  • completion/failure payloads are queued as silent parent messages
  • 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
  • propagates child termination status to parent registry when linked

Registry and Message Projection

Parent threads maintain a child registry for resumable instances. Messages may carry projected subagent fields (for UI/client grouping), including:
  • subagent_id
  • subagent_name
  • subagent_title
  • subagent_status
  • subagent_resumable
  • subagent_blocking