Message Flow

What happens between a user message and a reply — the turn lifecycle, the act loop, tool calls, and compaction.

Every message — whoever or whatever triggered it — runs the same loop through the one turn processor. What differs per path is the trigger, the channel config, and where the result goes. This page traces a turn from arrival to reply, then covers the background paths that don’t touch chat at all.

Four paths in

Path Trigger Where the result goes
User A chat message from the web UI Reply pushed to every open surface
Subconscious A set of independent cognition jobs, each gated on its own stretch of user idleness Memory updates only — nothing pushed to chat
Scheduled A due reminder or scheduled prompt Runs in the background, then relays the result through a normal user-channel turn
Delegate The model calls a sub-task tool (web search, browsing, vision) mid-turn A synthesized answer returned to the calling turn

The user path

The client sends a message over HTTP (POST /api/thread/<turn_id>-1 opens a new thread, an existing id replies into one — as form data with text and optional file attachments). The WebSocket (/ws) is push-only: the server uses it to stream status, tool activity, and the final reply back, and to echo the message to every open surface. Each turn runs on its own thread, under a per-channel lock so two turns on the same channel can’t interleave.

POST /api/thread/<turn_id>
  └─ thread per turn
       └─ turn processor (text, user channel config, cancel signal)
            ├─ setup      input row · deliberation gate · turn-zero seeds
            ├─ act loop   one LLM call; tools run and the loop repeats,
            │             a plain-text reply ends it
            └─ record     post-turn hooks
       └─ WebSocket: message + done

Turn-zero seeds

Before the first model call, the framework dispatches real tool calls whose results join the turn’s working set — never injected into the prompt text:

  • a memory recall keyed on what you just said,
  • one document upload per attachment (uploads fan out in parallel and join at a barrier, so vision/OCR extraction overlaps),
  • an internal thinking pass, but only when the message looks like it warrants deliberation.

This is why Chalie often already knows the relevant context before it answers: the recall has already run.

Interrupt and cancel

The composer’s stop control ends a turn right away — the spinner clears and the exchange is marked cancelled the instant you hit stop, without waiting for the model to finish generating. The in-flight call itself keeps running to completion behind the scenes, since a response already being generated can’t be aborted mid-flight, but whatever it eventually returns is discarded and never saved. Anything the turn had already written before you stopped it — earlier tool results, earlier reply text — stays exactly as it was; only the pending, still-generating exchange goes away, and a stopped exchange that never got a reply won’t reappear if you refresh. Cancellation reaches a turn even while it is parked waiting on a permission prompt — the wait polls the same stop signal, resolves the prompt as denied, and unwinds, always releasing the channel lock so the next turn never deadlocks behind it.

Sending another message while a turn is still running doesn’t interrupt it — the new message queues and sends automatically the moment the current turn finishes, so two replies can never generate at once on the same conversation.

Multi-surface sync

A Chalie instance belongs to one user across many surfaces, and there’s one conversation that all of them mirror. Two rules keep them aligned, both riding the single broadcast channel every surface subscribes to:

  • User messages. When a surface sends a message, the server stores it and broadcasts an echo to every connection. The sending surface already rendered its own bubble optimistically, so it recognizes and drops its own echo; every other surface renders one.
  • Assistant replies. The final reply is broadcast to every connection. The surface that started the turn renders it through its turn callbacks; the others render a plain assistant bubble from the broadcast. Either way the reply shows everywhere.

The live tool trail rides the same channel, but only the surface that started the turn renders it — idle peers drop the content-free progress frames, because the trail is ephemeral turn scaffolding, not durable conversation.

The act loop

The heart of every turn is the act loop. It is implemented as a recursive chain of single steps, where each step is exactly one LLM call:

build request (history + world state + your message + tool results so far)
  → pre-flight size check ── over cap? ──► compact, rebuild, retry
  → call the LLM
  → no tool calls?  → done, return the text
  → tool calls?     → dispatch each through the tool dispatcher → append results
  → next step (until done or cancelled)

A few properties worth knowing:

  • No iteration cap. The loop ends when the model answers in plain text, the turn is cancelled, or — rarely — it trips the hard repetition backstop below. There is no fixed number of steps — a complex task can take as many tool calls as it needs, and you can always interrupt.
  • Each LLM call is time-bounded. A single call that stalls past the hard ceiling is abandoned and surfaced as a normal error, so one wedged provider can’t hang the turn forever.
  • Tool errors don’t crash the loop. Failures come back to the model as structured result strings; they never surface raw to you.
  • A soft brake on runaway retries. If a tool returns the exact same error it already produced this turn, the dispatcher appends a one-shot nudge to re-check the inputs and, if it still fails, stop and ask — so a stuck tool can’t spin the uncapped loop indefinitely.
  • A hard backstop on identical repetition. Independent of the soft brake above, if the model calls the exact same tool with the exact same arguments, or repeats the exact same reply text, several times running, the turn stops immediately and is marked crashed rather than continuing to loop toward the context ceiling.

Every tool call is recorded as it happens, tied to the turn it belongs to.

Compaction

A long conversation eventually outgrows the model’s context window. Chalie handles this size-driven only — there is no turn-count or age-based trigger. The history block grows naturally as you keep talking; when the pre-flight check (or a provider-side rejection) fires, the loop dispatches a single internal step:

  1. History compaction summarizes the older part of the conversation. The summary is written back as a special transcript row, which becomes the new history watermark.

Because every step rebuilds the request from the database, advancing the watermark automatically shrinks the next request, and the loop retries. If there’s nothing left to compact, the loop sends anyway and lets the provider be the source of truth. The latest summary is visible in the Brain dashboard under Cognition.

Background paths

Not every turn is a reply. Three paths run work without ever touching chat directly — and each is still an ordinary turn on its own channel.

Subconscious tick. A set of independent cognition jobs, each gated on its own stretch of user idleness, cover consolidating memory, extracting facts, decaying old data, spotting patterns, reflecting, and doing proactive research. Most write no transcript rows and none push to chat. Compaction is not one of these jobs — it only ever happens in-loop during a turn. The full set is covered in Memory & Cognition.

Scheduled prompts. The scheduler polls for due items and fires each in two stages. Stage one runs the instruction as an independent background turn on a muted channel, persisting it so a fired task is recoverable. Stage two hands that result to a normal user-channel turn — but with the trigger text hidden — so the reply is delivered to you normally while the machinery stays out of the visible conversation.

Episode encoding. After turns are stored on a memory-producing channel, a rolling trigger periodically folds the recent window into episodic memory. No user-visible output.

Per-turn metrics

Every final reply carries a small metrics block. Token counts cover all LLM calls in the turn — the main loop, any thinking pass, and any compaction calls:

{
  "tokens_total": 4820,
  "tools": { "memory": 1, "web_search": 1 },
  "response_time_s": 1.43
}

To see where the turn fits in the bigger picture, read the Architecture Overview; for what the background tick actually does with what it learns, read Memory & Cognition.