Skip to main content

How to Stream Tool Progress

This guide covers how to stream real-time feedback to users when your agent executes tools. Tool streaming displays status messages like "Searching for documents..." that update to "Found 15 results" when complete.

Prerequisites​

Before you begin, make sure you are familiar with Creating Custom Sources.

Step 1: Mark Tools as Streamable​

Use the @streamable decorator to define display text templates for your tools:

from zav.agents_sdk import streamable

@streamable(
running_text="Searching for {{ query }}...",
completed_text="Found {{ result_count }} results for '{{ query }}'"
)
async def search_documents(query: str) -> dict:
"""Search for documents matching the query."""
results = await perform_search(query)
return {"result_count": len(results), "documents": results}

Templates use Jinja2 syntax: {{ variable }} for parameters and return values. Nested access is supported (e.g. {{ data.count }}, {{ results[0].title }}).

Alternatively, pass the streaming config explicitly when registering:

from zav.agents_sdk import ToolStreamingConfig

tools_registry.add(
executable=search_documents,
streaming_config=ToolStreamingConfig(
running_text="Searching for {{ query }}...",
completed_text="Found {{ result_count }} results"
)
)

Step 2: Enable Tool Progress in Your Agent​

Set stream_tool_progress=True in the complete() call and use chat_response.to_chat_message() to convert responses:

from zav.agents_sdk import ChatAgentClassRegistry, ChatMessage, StreamableChatAgent
from zav.agents_sdk.adapters import ZAVChatCompletionClient

@ChatAgentClassRegistry.register()
class SearchAgent(StreamableChatAgent):
agent_name = "search_agent"

def __init__(self, client: ZAVChatCompletionClient):
self.client = client
self.tools_registry.add(search_documents)

async def execute_streaming(self, conversation):
response_stream = await self.client.complete(
bot_setup_description="You are a helpful search assistant.",
messages=conversation,
tools=self.tools_registry,
stream=True,
execute_tools=True,
stream_tool_progress=True,
)

async for chat_response in response_stream:
if chat_response.error:
raise chat_response.error

message = chat_response.to_chat_message()
if message:
yield message

The to_chat_message() method includes tool progress in content_parts. Each tool progress entry is a ContentPart with type="tool" and a ContentPartTool payload.

  • name: Tool name
  • tool_call_id: Unique ID for this tool call
  • status: "running", "completed", or "error"
  • display_text: The interpolated template text
  • params: Tool input parameters
  • response: Tool output (when completed)

By default, streamed agent responses keep generated text in the message content field and accumulated tool snapshots in content_parts. If your UI needs ordered text and tool entries together in content_parts, pass preserve_streamed_content_parts=True to complete().

note

stream_tool_events=True is still accepted as a deprecated alias for backwards compatibility. New code should use stream_tool_progress=True.

Step 3: Filter Data Sent to Frontend (Optional)​

Tool responses may contain internal data or sensitive information not needed by the frontend. Use transform callbacks to filter what gets included:

from zav.agents_sdk import streamable, include_fields, exclude_fields, hide

# Only include specific fields in the response
@streamable(
running_text="Searching...",
completed_text="Found {{ result_count }} results",
response_transform=include_fields("result_count", "summary"),
)
async def search(query: str) -> dict:
return {
"result_count": 10,
"summary": "Found documents about...",
"internal_scores": [0.9, 0.8], # Excluded from frontend
}

# Exclude specific fields
@streamable(
running_text="Fetching document...",
response_transform=exclude_fields("raw_content", "metadata"),
)
async def fetch_document(doc_id: str) -> dict:
...

# Hide the entire response
@streamable(
running_text="Processing...",
response_transform=hide,
)
async def internal_operation(data: str) -> dict:
...

You can also write custom transform functions:

def redact_documents(response: dict | None) -> dict | None:
if response is None:
return None
return {
"result_count": response.get("result_count"),
"documents": [
{"id": doc["id"], "title": doc["title"]}
for doc in response.get("documents", [])
]
}

@streamable(
running_text="Searching...",
response_transform=redact_documents,
)
async def search(query: str) -> dict:
...
info

params_transform and response_transform only affect params and response in the ContentPartTool sent to the frontend. llm_response_transform only changes what is sent back to the LLM context after tool execution. It does not affect display_text interpolation, which still uses the full tool result.

Step 4: Filter Data Sent Back to the LLM (Optional)​

Some tool responses need to stay available to the frontend but should not be sent back to the LLM in full. For example, a tool might return a large raw payload that a custom frontend renderer needs, while the LLM only needs a compact summary. Use llm_response_transform to filter the tool result that is appended to the LLM conversation after execution:

from zav.agents_sdk import streamable, exclude_fields

@streamable(
running_text="Rendering molecule...",
completed_text="Rendered molecule",
# Frontend still receives the full response, including "data".
llm_response_transform=exclude_fields("data"),
)
async def render_molecule(identifier: str) -> dict:
return {
"format": "pdb",
"data": "large structure block...",
"summary": "3D structure loaded from PDB",
}

response_transform and llm_response_transform are independent:

TransformAffectsUse when
response_transformContentPartTool.response sent to the frontendThe UI does not need the full response, or a field should not be exposed to clients
llm_response_transformTool result sent back to the LLM contextThe UI needs the full response, but the LLM should receive a smaller or redacted version

If llm_response_transform is not set, the LLM receives the full tool result.

MCP Tools​

MCP tools discovered at runtime automatically get streaming enabled. To customize the display text, configure tool_streaming in your agent_setups.json:

agent_setups.json
{
"agent_identifier": "mcp_agent",
"agent_name": "mcp_agent",
"agent_configuration": {
"mcp_tools_provider_configuration": {
"servers": [...],
"tool_streaming": {
"read_file": {
"running_text": "Reading file {{ path }}...",
"completed_text": "Loaded file content"
},
"list_directory": {
"running_text": "Listing directory {{ path }}...",
"completed_text": "Found {{ count }} items"
}
}
}
}
}

Behavior:

  • If tool_streaming is not set (default): all MCP tools get auto-generated streaming text
  • If tool_streaming is set: only tools explicitly listed will stream