Stream Answers to a UI¶
Pipe each AnswerText chunk from the server into your application's
view layer as it arrives — the standard "typewriter" chat
experience.
Prerequisites
- An agent created and ready to receive messages — see Build a Chat Agent with a Graph.
Tryll streams one AnswerText frame per token chunk. The last frame
in a turn has is_final = true; a final TurnComplete frame carries
the turn's outcome (success / error / cancelled) and the total token
count.
Steps¶
On TryllAgentComponent, bind two events. All callbacks fire on
the Unity main thread — you can update UGUI / UI Toolkit widgets
directly without marshalling.
var agentComp = GetComponent<TryllAgentComponent>();
// OnAnswerText fires for every streamed chunk. The first argument,
// nodeName, is the name of the node that produced this chunk — ignore
// it for a single-answer agent (see "Multiple senders" below).
agentComp.OnAnswerText.AddListener((nodeName, text, isDelta, isFinal) =>
{
chatBubble.AppendText(text);
if (isFinal) chatBubble.HideCursor();
});
// OnTurnComplete fires once the turn ends.
agentComp.OnTurnComplete.AddListener((status, _, _) =>
{
typingIndicator.SetActive(false);
chatBubble.CommitReply();
});
On the UTryllAgentComponent, bind two events in Blueprint:
- On Answer Text (NodeName: FString, Text: FString, bIsFinal: bool)
— append
Textto yourUTextBlock.bIsFinalistrueonly on the very last chunk.NodeNameidentifies the producing node — ignore it for a single-answer agent (see "Multiple senders" below). - On Turn Complete (Status: ETryllTurnStatus) — stop the
typing indicator and commit the final reply. Check
Status:Cancelledmeans the user (or your Blueprint) called Cancel.
There is also On Answer Full (FullText: FString) which fires once after the turn completes, with the whole response in one string — useful if your UI only needs the final text.
To add a chat Stop button, call Cancel (ETryllCancelMode::StopAndKeep
by default) while a turn is streaming, then gate your UI on
OnTurnComplete.Status == Cancelled. Prefer Status over
On Answer Full alone: after StopAndDiscard the server drops the
interaction, but the component may still broadcast whatever text it
had already accumulated client-side.
Register SetOnAnswerText before calling SendText.
SetOnAnswerText fires on the client library's reader thread for
each streamed chunk and once more with isFinal = true on the
last chunk. SendText is fire-and-forget; use SetOnTurnComplete
to know when the turn is finished.
std::string buffer;
agent.SetOnAnswerText(
[&](std::string_view /*nodeName*/, std::string_view text,
bool /*isDelta*/, bool isFinal)
{
buffer.append(text);
YourChatWidget::SetCurrentReply(buffer);
if (isFinal)
{
YourChatWidget::CommitReply(buffer);
buffer.clear();
}
});
agent.SetOnTurnComplete(
[](::Tryll::TurnStatus, std::string_view, std::int32_t) {});
agent.SendText("Tell me about the architecture.");
Register a persistent callback with set_on_answer_text. It fires on
the client's background reader thread for every chunk with
(node_name, text, is_delta, is_final), while send_message blocks
your calling thread until TurnComplete and returns the full reply.
def on_text(node_name, text, is_delta, is_final):
your_chat_widget.append_text(text) # node_name: see "Multiple senders"
if is_final:
your_chat_widget.hide_cursor()
agent.set_on_answer_text(on_text)
# Blocks until TurnComplete; on_text fires per chunk meanwhile.
reply = agent.send_message("Tell me about the architecture.")
# Diagnostics about the last turn:
print(agent.last_tokens_generated,
agent.last_answer_chunk_count,
agent.last_ttft_s)
The callback runs on the reader thread.
on_text fires on the client's background reader thread, not your
calling thread — keep it quick and thread-safe, and don't call
blocking client methods from inside it. If you only need the final
text, skip the callback and use send_message's return value.
What the frames look like¶
| Frame | Fires | Payload |
|---|---|---|
AnswerText |
Many times per turn | node_name, text (delta), is_final |
TurnComplete |
Once at the end | status, tokens_generated |
is_final is true on the very last AnswerText before
TurnComplete. It is normally safe to ignore and just rely on
TurnComplete to lock in the reply; is_final is useful when you
want to switch UI state slightly earlier (e.g., hide the cursor
blink before the "turn done" animation).
Streaming only part of the output¶
Nodes that are not Generate do not typically emit AnswerText
frames. For example:
CannedResponseemits oneAnswerTextwith the full response andis_final = true— not a stream, but it uses the same callback.ToolCallwithmode = call_or_answeremits the residual text the same way when no tool was called — one shot,is_final = true.ToolCalldoes not useAnswerTextfor the tool call itself; a non-RouteOnlydispositionfires a separateToolCallNotification(or a pausing disposition triggersOnPaused). See Define and Handle Tool Calls.
Multiple senders in one turn¶
The first argument to every answer callback (nodeName / node_name /
NodeName) is the name of the node that produced the chunk. For a
single-answer agent you can ignore it. When a turn has more than one
sending node — two characters speaking, or a "thinking" channel alongside
the reply — switch on it to route each stream to its own widget. See
Send multiple answers in one turn.
Common pitfalls¶
- Waiting for
is_finalas a synchronisation point is fine inside aGenerateturn but misleading forCannedResponse— you will get one chunk withis_final = true. Always also bindTurnCompletefor "turn is really done". - Threading.
OnAnswerTextin Unity and Unreal is dispatched on the main/game thread; you can touch UI widgets directly. In C++, theSendTextcallback fires on the client library's reader thread — marshal to your UI thread explicitly. In Python,set_on_answer_textfires on the reader thread whilesend_messageblocks the calling thread; marshal to your UI thread as needed. - Accumulating bytes, not chars. For multi-byte scripts,
concatenate the
FString/std::stringas you receive them; the server already chunks on UTF-8 boundaries.