Skip to content

Streaming responses

An agent handler can stream its reply: the caller receives the model's text as it is generated rather than waiting for the whole answer. Streaming is declared on the handler, consumed through the component client, and served over HTTP as server-sent events (SSE). Only agents stream. An entity or a workflow refuses a streaming call rather than ignoring it, because a caller waiting for tokens that never come would wait forever.

Declaring a streaming handler

In Scala, a streaming handler returns a StreamEffect, built with thenStream(), and is registered with stream rather than command:

/** Streams the reply token by token, tools and all. */
def chat(question: String): StreamEffect =
  effects
    .systemMessage(WeatherAgent.SystemMessage)
    .userMessage(question)
    .tools(WeatherAgent.getWeather)
    .thenStream()
val chat = stream("chat")(_.chat)

The two registrations are separate on purpose. A command is called with .call(...) and answers once; a stream is called with .stream(...) and answers many times. Keeping them apart means a caller cannot await one value from a handler that produces many, or the reverse; the compiler refuses it.

In Python, the decorator is @stream and the handler returns an ordinary AgentEffect. The assistant in Agents declares one:

@stream("chat")
def chat(self, question: str) -> AgentEffect[str]:
    return self._describe(question)

Consuming a stream

Through the Scala component client, stream returns a Pekko Streams Source[String, NotUsed] of text chunks:

val tokens: Source[String, NotUsed] =
  componentClient.forAgent(SessionId("s-1")).stream(WeatherAgent.chat)("Will it rain in Lisbon?")

Nothing is sent until the source is run. Tokens are pushed straight from wherever the session is hosted in the cluster to wherever the source was run, with nothing buffering the whole reply. A consumer that falls more than 1024 chunks behind fails the stream rather than silently dropping text.

In Python, stream is an async iterator:

async for token in client.for_agent("assistant", "s-1").call("chat").stream("Will it rain?"):
    print(token, end="")

A refusal — a guardrail, an error effect, a failed model call — ends the stream with an error: a CommandError in both languages.

Serving a stream over HTTP

An endpoint serves a stream with sse, which answers GET as text/event-stream. In Scala the handler returns the Source:

sse("/{session}") { (session: String) =>
  client
    .forAgent(SessionId(session))
    .stream(WeatherAgent.chat)("What is the weather?")
}

sseBody is the POST form, taking a path parameter and a decoded body. In Python, an @sse route is an async generator:

@post("/ask/{session}")
async def ask(self, session: str, question: str) -> str:
    return await self.client.with_metadata(self.request.metadata).for_agent("assistant", session).call("ask").invoke(question, reply=str)

@sse("/chat/{session}")
async def chat(self, session: str) -> AsyncIterator[str]:
    question = next((v for k, v in self.request.query if k == "q"), "")
    async for token in self.client.with_metadata(self.request.metadata).for_agent("assistant", session).call("chat").stream(question):
        yield token

Read query parameters and headers while building the stream, not inside it. In Scala the handler only builds the Source; the HTTP server pulls its elements later, on another thread, where the request is no longer available. See HTTP endpoints.

Every event is a JSON string

Each server-sent event's data field holds one chunk, encoded as a JSON string:

data:"Lisbon is"

data:" mild today."

A client decodes each data field with a JSON parser. Raw text is not safe in an SSE data field: the protocol strips one leading space from a field's value, and a newline inside a chunk ends the field and splits the chunk into two events. Both corrupt text silently, and only on text a model happened to produce, so the platform encodes every chunk rather than leave it to chance.

Every turn streams

When the model says something before calling a tool — "let me check the forecast" — that text is streamed immediately, before the tool runs, and the model's answer after the tool streams too. Withholding everything until the last turn would leave a reader looking at nothing while tools run, which is what makes a streaming interface feel broken.

The session is held for the stream

A session handles one request at a time, and a stream is one request for as long as it lasts. A second call on the same session waits until the stream has finished, so one conversation cannot interleave two replies. Use separate sessions for independent conversations.

Guardrails on a stream

Output guardrails cannot un-send a token. On a streaming handler they run after the reply has been delivered. A rejection still fails the stream and still keeps the reply out of memory, but the reader has already seen the text. Use input guardrails for anything that must never be shown, since those run before the model is called and a rejection there sends nothing.