condensate

How it works

Threads and the runtime

Send messages, read events, survive restarts and coordinate threads.

On this page

This page covers the lifecycle of a thread: sending a message, reading the result, recovering after a restart and coordinating several tasks.

A thread is the durable record of a task. It holds the messages and events that let an application show what happened, recover after a disconnect, and continue the conversation. An engine session does the work. Condensate has three engines: Claude Code, Codex, and Condensate's own agent. Because the thread is separate from the session, an application can follow a task after a browser connection closes.

One message, from acceptance to result
  1. 01
    Your app sends a messagePOST /threads/:id/messages
    Request
  2. 02
    The runtime accepts and records it202 · seq · ts
    Durable
  3. 03
    An engine executes the turn

    Model → tool → result → model

    Running
  4. 04
    Events reach your interface

    Live deltas for responsiveness; stored events for recovery.

    Streaming
  5. 05
    Your app reads the outcome

    A completed or failed turn is distinct from acceptance.

    Settled
202 acknowledges the message. The later turn outcome tells you whether the work succeeded.

Send a message

Start with an existing thread on your running runtime. To create one, call POST /spawn, which answers with a threadId. The runtime listens on 127.0.0.1:4741 by default; set CONDENSATE_AGENTS_PORT to change the port.

Replace THREAD_ID with the thread's ID. This example sends real work to that thread and can consume model usage.

export THREAD_ID='replace-with-an-existing-thread-id'

curl -i "http://127.0.0.1:4741/threads/$THREAD_ID/messages" \
  -H 'content-type: application/json' \
  -d '{"text":"Summarize the current task and its next step."}'

The runtime acknowledges an accepted message with HTTP 202 and a JSON body containing seq and ts. Save the sequence number. Acceptance means the message was recorded before any model ran. The turn result, read from the log, tells you whether execution succeeded.

While the runtime is draining, for example during a restart, this route answers 503 with the code draining. Nothing is recorded in that case, so retry once the runtime is open again.

Read the result

Use the acknowledged sequence number as AFTER. A forward tail reads the events that come after that position.

export AFTER='replace-with-the-acknowledged-sequence-number'

curl -fsS \
  "http://127.0.0.1:4741/threads/$THREAD_ID/tail?after=$AFTER&limit=20"

curl -fsS \
  "http://127.0.0.1:4741/threads/$THREAD_ID/turn"

A forward tail page contains events, nextAfter, and hasMore. Continue with nextAfter to read the next page. The default page size is 200 events, and the maximum is 1000. Add wait=<milliseconds> to hold an empty page open until the thread moves past your cursor, for up to 30 seconds. A read from sequence 0 can also include a snapshot that stands in for the older events, and events then starts after it. Add history=1 to read the full event history behind the snapshot instead.

GET /threads/:id/turn reports whether a turn is running (active), which messages are still queued, and whether the thread is degraded.

To open a long thread at its end, read the reverse page with before. Omit before, or leave it empty, to start from the newest event. The page returns events newest first, plus prevBefore and hasMore for the next older page.

To keep a connection open instead of polling, use GET /threads/:id/follow?after=<seq>. It streams events as server-sent events. The Last-Event-ID header works as the cursor too. The SDK, which ships with early access, provides a follower (agents.follow) that manages reconnects and falls back to polling where streaming is unavailable.

Build a responsive interface

Use the asynchronous message endpoint when your UI should acknowledge a send immediately. Render live deltas while the turn is running, then reconcile against stored events. Keep the latest durable cursor so a reconnect can pick up after it.

SurfaceRouteBest use
Thread summariesGET /threadsA task list or sidebar
Durable event pagesGET /threads/:id/tailHistory, recovery, and recorded outcomes
Live deltasGET /threads/:id/streamShowing an answer as it is generated
Turn statusGET /threads/:id/turnUnderstanding the current execution state

Live deltas have no replay and no sequence numbers. Treat them as a preview, and read the settled text from the tail or follow streams.

The /send endpoint offers a different contract. It holds the request until the turn settles and returns the reply. Use it when your caller needs a single awaited answer. Send "async": true to get the same 202 acknowledgment as /messages.

Two controls apply when a message arrives during a running turn:

  • Hold it. By default, a message posted during a running turn is steered into that turn. Post with "hold": true to keep it behind the running turn instead (a held message cannot also carry to or from). The answer names a held row. Until the message is delivered, you can edit it with PATCH /threads/:id/messages/:seq or withdraw it with DELETE /threads/:id/messages/:seq.
  • Stop it. POST /threads/:id/interrupt stops the running turn, drops queued messages, and cancels any pending retry.

What survives a restart

Thread records and ordered events are stored in SQLite, in threads.db under ~/.condensate/agents-host by default (set CONDENSATE_AGENTS_DATA_DIR to move it). When a thread is compacted, its older event bodies move to threads-archive.db in the same directory. Event bodies larger than 64 KiB (the default; CONDENSATE_AGENTS_BLOB_THRESHOLD changes it) are written to content-addressed files in the blobs/ directory, and the database keeps only their hash. Engine resume pointers live in the engine state directory, ~/.condensate/engine by default.

Back up threads.db, threads-archive.db, the blobs/ directory, and the engine state together when you move a deployment. Without the engine state, engine sessions cannot resume their provider conversations.

A durable record lets an application recover recorded history. Resuming execution also depends on the engine's resume state and on provider behavior. A restart stops any turn that is running, and messages waiting behind it do not run on their own. The messages stay in the log. At boot the runtime writes an error message to each affected thread, starting "This turn was lost to a host restart", so you can resume or send again. Held messages and scheduled retries are stored as rows, so they survive the restart and run afterwards. Engine sessions are recreated on the next turn.

Archiving a thread (DELETE /threads/:id) marks it archived. POST /threads/:id/restore brings it back.

Coordinate several tasks

The runtime exposes child threads, subscriptions, approvals, interruption, and handoffs. A child has its own history, so its work can be inspected independently. Coordination records relate that work to its parent.

NeedRoute
Start a child thread under a parentPOST /spawn with parent: { threadId, atSeq?, forSeq? }
Fork a thread at a point in its historyPOST /threads/:id/fork
Watch another thread and be woken by its eventsPOST /threads/:id/subscriptions (up to eight per thread by default; CONDENSATE_AGENTS_MAX_SUBSCRIPTIONS changes the cap)
Allow or deny a pending approvalPOST /threads/:id/approvals/:approvalId with decision: "allow" or "deny"
Hand work to another threadPOST /threads/:id/handoff, then accept or decline it

Start with one thread and its event stream before building a coordinator. The same read-and-follow pattern applies to every thread.