How to stream responses from an OpenClaw sub-agent chunk by chunk up to the parent loop
The
OpenClawreferenced in this article is a fictional agent framework name the author uses to describe an internal pipeline. The document links, PR numbers, and internal API names (sessions_yield,blockStreamingCoalesce, etc.) below are all illustrative examples and do not correspond to any real external repository or documentation. Please map them to your own stack (LangGraph, ADK, Semantic Kernel, an in-house orchestrator, etc.) as you read.
One of the first walls you hit when designing an LLM agent pipeline is the question: "What should the parent loop be doing while the sub-agent is generating a response?" At first I simply dropped an await and waited for completion, but after seeing on an actual dashboard just how much TTFT (Time To First Token) affects the user experience, I decided to redesign this structure.
In this article I organize a design perspective that treats streaming and chunking as two intentionally separate stages. If streaming is the process of extracting per-token deltas, then chunking is the post-processing step that accumulates those fine-grained fragments into sentence- or paragraph-sized units and flushes them to a channel. The focus of this article is how a sub-agent should feed intermediate results up to the parent loop across these two layers, and where the logic tends to go wrong.
Let me lay out three viewpoints upfront. First, chunking parameters (e.g., minChars, maxChars, idleMs) are tuning targets adjustable per channel — not magic constants. Second, the live preview stream flowing out of a sub-agent and the final result path are entirely different channels; conflating the two produces subtle bugs. Third, whether to execute an action based on partial results during streaming is not a performance question — it is a reversibility design question.
Where the Agent Loop and Streaming Intersect
Execution Flow of the Parent Loop
The fictional OpenClaw agent loop has a serialized execution flow per session, roughly like this.
When a sub-agent enters this flow, things get complicated. If the parent loop naturally blocks from the moment it spawns the sub-agent until completion, its context slot is wasted the entire time and the user receives no feedback whatsoever.
To handle this interval, a cooperative waiting mechanism conceptually like sessions_yield is commonly used. The parent loop voluntarily ends its current model turn and wakes up again when a child task completion event arrives. Marking this resume point with an explicit flag (e.g., wakeOnDescendantSettle) lets the orchestrator arrange to wake the parent loop only at the moment all child tasks have settled.
Why Bother Separating Streaming and Chunking
Reading the documentation alone, you might wonder "why split it into two stages at all?" But the moment you wire it up to a channel like Discord or Slack, it becomes immediately obvious. Token deltas from an LLM arrive one word — or even one subword — at a time, and reflecting each one directly as a message edit will quickly hit the channel API rate limit and make client rendering jitter.
Chunking holds those fine-grained deltas in a buffer until a condition is met, then flushes them all at once. The condition is generally defined along three axes.
| Parameter | Role |
|---|---|
minChars |
Do not flush the buffer until this size is reached |
maxChars |
Force an immediate flush when this size is exceeded |
idleMs |
Flush the buffer if no new delta arrives within this duration |
The optimal value for each parameter differs per channel. Concrete numbers must be tuned to the rate limits and UX feel of the channel you actually connect — there is no universally applicable "default." On channels with strict message-edit rate limits like Discord, setting idleMs too short will frequently produce 429 errors; on channels where short edits feel natural, like Telegram draft mode, you can keep minChars small. Check the rate-limit spec in each channel's official documentation and dial in the values empirically.
Two Paths for Receiving Sub-Agent Intermediate Results
Live Preview Path vs. Final Result Path
This is the heart of the article. While a sub-agent is running, two kinds of data reach the parent loop, and their semantic character is different.
The live preview arrow is drawn in the sequence diagram, but it is strictly a UI-facing channel. You must never consume this stream for pipeline logic such as branching conditions, tool calls, or state transitions in the parent loop. There are two reasons.
First, the live preview is a provisional state where cancellation, retry, or partial discard can happen at any time. Hanging pipeline decisions on it causes the rollback paths to explode.
Second, parsing the live preview stream in the parent loop is itself a performance burden. Parsing and normalizing each token delta from a sub-agent in the parent loop makes the parent's event loop just as busy as the child's context, leading to delays in tool-call processing. As a result, production pipelines often include an optimization that bypasses live stream parsing entirely.
To summarize:
- Path for UI rendering: Live preview stream. Used solely to show the user that work is in progress.
- Path for pipeline logic: Terminal message. The only basis for deciding the parent's next step.
Sketching a Chunking Pipeline in Python
When converting a raw token stream into structured events, wrapping it in an async generator is a convenient pattern. A further advantage is that backpressure naturally follows the downstream consumer's pace.
Below is a conceptual example that simultaneously applies minChars + idleMs conditions. It does not exactly match the API shape of any real framework, and error handling and cancellation signal handling are omitted.
import asyncio
from typing import AsyncIterator
async def coalesce_stream(
tokens: AsyncIterator[str],
min_chars: int = 200,
idle_ms: int = 500,
) -> AsyncIterator[dict]:
queue: asyncio.Queue[str | None] = asyncio.Queue(maxsize=1024)
async def producer() -> None:
async for token in tokens:
await queue.put(token)
await queue.put(None)
prod_task = asyncio.create_task(producer())
buffer: list[str] = []
buffer_len = 0
idle_seconds = idle_ms / 1000
try:
while True:
try:
item = await asyncio.wait_for(queue.get(), timeout=idle_seconds)
except asyncio.TimeoutError:
if buffer:
yield {"type": "chunk", "text": "".join(buffer)}
buffer, buffer_len = [], 0
continue
if item is None:
if buffer:
yield {"type": "chunk", "text": "".join(buffer)}
return
buffer.append(item)
buffer_len += len(item)
if buffer_len >= min_chars:
yield {"type": "chunk", "text": "".join(buffer)}
buffer, buffer_len = [], 0
finally:
prod_task.cancel()A few things were revised from the original draft.
- Since leaving
idle_msonly in the signature without implementing it would be misleading, an actual timeout flush viaasyncio.wait_forwas added. - Wrapping a function that does nothing but concatenate strings in
asyncinvites misunderstanding, so that was replaced with inline processing.async/awaitshould only be used when you need to yield control to I/O or another task. - An explicit upper bound like
Queue(maxsize=1024)prevents memory from growing unboundedly when the upstream bursts.
If you want to simulate processing time on the consumer side, insert a meaningful delay like await asyncio.sleep(0.05). asyncio.sleep(0) only yields control to the event loop once; it does not simulate processing time.
Split Boundaries and Code Block Protection
Chunking is not simply "cut every N characters" — it is the process of finding a safe split position while preserving markdown validity. The priority order generally looks like this.
Paragraph > Line break > Sentence > Space > Forced splitThe trickiest exception is never splitting inside a code fence. Getting this wrong causes the renderer to display all remaining text as code, or conversely smashes code entirely into prose.
The original draft included detection logic that simply counted the parity of backticks, but that approach cannot distinguish inline code (`), indented code blocks, or backticks inside string literals. In practice it is safer to track fence enter/exit state as a state machine while scanning the markdown tokens. Below is a conceptual example with explicit state tracking and closed fall-through branches.
def find_split_point(text: str, max_chars: int) -> int:
"""Conceptual example: do not split inside a code fence. Replace with a real markdown parser in production."""
if len(text) <= max_chars:
return len(text)
in_fence = False
i = 0
while i < max_chars:
if text.startswith("```", i):
in_fence = not in_fence
i += 3
continue
i += 1
if in_fence:
close_pos = text.find("```", max_chars)
if close_pos == -1:
# No closing fence found — safely keep everything
return len(text)
return close_pos + 3
for delimiter in ("\n\n", "\n", ". ", " "):
pos = text.rfind(delimiter, 0, max_chars)
if pos != -1:
return pos + len(delimiter)
return max_charsThis example also does not fully handle inline code or backtick escaping. In production, it is better to extract code node ranges from a proper markdown parser such as mistune or markdown-it-py rather than rolling your own scanner.
Executing Actions During Streaming: A Reversibility Problem, Not a Performance Problem
The temptation to fire a tool immediately based on an intermediate chunk alone — in order to reduce TTFT — is strong. But this is not a performance optimization problem; it is a reversibility design problem. The viable strategy depends on how reversible an action is.
- Idempotent / Reversible: Early execution during streaming is acceptable. Even if run incorrectly, recovery via retry or rollback is possible.
- Compensable: Early execution is possible, but a compensating transaction must be designed alongside it. Payment followed by refund, or email followed by a correction message, fall into this category.
- Irreversible: Early execution during streaming is forbidden. Must execute only after the final message is committed. Physical printing, on-chain transactions, and destructive API calls to external systems are the canonical examples.
This classification was not invented by a particular paper; it is a conventional taxonomy long used in the Saga pattern of distributed systems and action typology, now mapped to an agent context. There is no need to invent or cite a new framework — simply attaching these four tags to each entry in your team's internal action catalog is enough to dramatically reduce incidents.
Transport Layer Options: SSE and Streamable HTTP
As of August 2026, there are two broad branches for the streaming response transport layer: SSE (Server-Sent Events) and Streamable HTTP based on chunked encoding. The MCP (Model Context Protocol) specification adopted Streamable HTTP as the new recommended transport in its 2025 revision while maintaining backward compatibility with SSE. You occasionally see claims that "SSE was completely removed," but in practice it is closer to a default switch + parallel support, not a replacement.
The practical criterion for choosing is your deployment environment.
- Environments where persistent connections are natural (traditional servers, container workloads): SSE remains simple and robust. OpenAI, Anthropic, and Google all use it.
- Serverless environments (Lambda, Cloudflare Workers, etc.): Function execution time and connection-persistence constraints make chunked responses a better fit.
There is no urgent reason to rip out an already-running SSE pipeline, but if serverless is on the table for a new design, considering Streamable HTTP from the start will lower the cost of any future migration.
Things to Take Away
The single point I most want to emphasize in this article: never treat the live preview flowing to the UI and the terminal message consumed by the pipeline as the same thing. Upholding this principle alone will eliminate a large share of the "why does this only reproduce occasionally?" state corruption issues that commonly arise when first introducing sub-agents.
Here are some things you can apply to your codebase today.
- At every point where you subscribe to a sub-agent stream, annotate which path it is. A minimum of two tags —
# ui-only/# pipeline— is sufficient. - Separate chunking parameters into a per-channel config file, and leave a comment with a link to that channel's rate-limit documentation. If only the numbers survive in the commit, even you will have forgotten the rationale six months later.
- Go through your action catalog and tag each tool with one of
idempotent/reversible/compensable/irreversible. Branching on these tags to separate tools that can be executed early during streaming from tools that must wait until the final message is confirmed makes pipeline review far easier.
All three of these can be started in under 30 minutes. Even just getting this much sorted out before diving into a large refactor will change the quality of every conversation you have about streaming and chunking issues going forward.