Display Extensions¶
The agent does not print: it only emits events. The display layer renders those events and carries the human interaction (confirmation, selection) back to the agent. That is why the same agent can run quietly inside a script, or be pushed to a console or a browser.
flowchart LR
A["Agent"] -->|"info / display_event / get_choice"| D["DisplayAbstract"]
D --> B1["NullDisplay<br/>silent"]
D --> B2["Display<br/>console"]
D --> B3["WebDisplay<br/>browser"]
D --> B4["Custom display"]
B2 -.confirmation result.-> A
B3 -.confirmation result.-> A
Built-in displays¶
| Display | Purpose | Default buffer |
|---|---|---|
NullDisplay() |
Does nothing; the default of Agent(), suited to programmatic use |
0 |
Display(event_buffer_size=1000) |
Console rendering + prompt_toolkit input; the default of setup_agent |
1000 |
WebDisplay(expose_files=False, event_buffer_size=5000) |
Streams to a browser; see Mounting WebDisplay | 5000 |
Relationship with the display layer¶
The framework owns the timing of the binding: initialize() calls display.bind(agent) and emits AgentBindEvent,
and finalize() calls unbind before emitting AgentUnbindEvent. A display layer can serve several agents
(display.agents distinguishes them by identifier), but an agent has exactly one display layer.
from xun import Agent, WebDisplay
d = WebDisplay(expose_files=True)
setup_agent(display=d, workdir=".test") # two agents share one display layer
setup_agent(display=d, workdir=".tmp")
Agent.inherit(parent, share_display=False) gives the derived agent a fresh NullDisplay,
so it does not flush output onto the user's interface.
Event model¶
Every event is an envelope:
class DisplayEvent(BaseModel, Generic[DisplayEventT]):
timestamp: float # event time
name: str # payload class name, e.g. "ModelMessageEvent"
agent: AgentInfo # name / identifier / workdir
payload: DisplayEventT
The payload types form a closed union of 19 variants:
| Event | Key fields | Trigger |
|---|---|---|
AgentBindEvent / AgentUnbindEvent |
— | Bind / unbind |
AgentRunningStartEvent / AgentRunningEndEvent |
— | Entering / leaving the outermost run_scope() |
UserMessageEvent |
content, images[] |
instruct() |
UserCommandEvent |
name, arguments |
execute_command() |
ModelWorkingEvent |
model_call_id, remaining_iterations |
Before each model call |
ModelMessageEvent |
model_call_id, content, reasoning, total_tokens |
When the model produces content |
ToolCallEvent |
tool_call_id, tool_name, args |
Tool call (arguments already JSON-repaired) |
ToolResultEvent |
tool_call_id, result |
When a tool returns successfully |
ConfirmEvent |
choice, choices, source, prompt, message |
Confirmation Q&A (source is user or auto) |
InfoEvent / WarningEvent / ErrorEvent |
message |
agent.info() and friends |
HTMLInfoEvent |
html, title |
Message rendered as rich text on the web |
ShowHelpEvent / ShowToolsEvent / ShowHistoryEvent / ShowExtensionsEvent |
Their respective lists | /help /tools /history /extensions |
Do not invent event classes
DisplayEvent.payload is validated against this union at runtime, and a custom BaseModel is rejected.
To emit custom content, reuse HTMLInfoEvent (rich text on the web) or InfoEvent;
to genuinely add an event type you must change src/xun/display_event.py and extend the union.
Streaming text deltas are not events; they go through the model_text_delta hook, see Hooks.
Event buffer and replay¶
The display layer ships a ring buffer with a default capacity of 0 — payloads can be large (tool results, base64 images), so each consumer decides how much to keep:
d = MyDisplay().with_buffer_size(2000) # chainable, returns self
d.events() # all events (filterable by agent_id)
d.clear_events(agent.identifier)
html = d.render_history_as_html(title="Session review") # render into a standalone HTML page
/render <file> uses render_history_as_html.
Writing a custom display¶
You only need to implement two abstract methods:
from xun import Agent, DisplayAbstract
class RawDisplay(DisplayAbstract):
def on_event(self, event):
print(f"{event.name}: {event.payload}")
def get_choice(self, request) -> str:
raise NotImplementedError("No human interaction in this example")
with Agent(display=RawDisplay()) as agent:
agent.toolbox.register(weather)
result = agent.instruct("What is the weather in Paris?").execute()
on_event(event): called when an event arrives — synchronously, on the thread that emitted it, so never do anything slow here.get_choice(request): called when the agent asks the user to choose; returns one of the strings inchoices.
Do not call display-layer methods directly
Always go through the agent: agent.info(...), agent.display_event(...), agent.get_choice(...).
Calling display.on_event directly skips the buffer and the before_display_* hooks.
Agent-side display interface¶
| Method | Description |
|---|---|
info / warning / error(message) |
Runs the matching before_display_* hook first (the message can be modified in place), then emits the event |
display_event(payload) |
Emits any event payload |
get_choice(prompt, choices, ..., default=, allow_extra=, skip_auto_confirm=) |
Returns ChoiceOutcome[str] |
get_confirm(prompt, ..., default=True) |
Returns ChoiceOutcome[bool] |
ChoiceOutcome carries choice and source ("user" or "auto") and has no truth value:
if outcome: raises TypeError outright, so you must write if outcome.choice:.
Under auto-confirmation (auto_confirm) it returns default; with no default it takes choices[0] and emits an extra warning.
skip_auto_confirm=True forces the question to a human (which is exactly what the ask_preference tool does).
When you only want to watch output instead of implementing a whole display layer, hooks are the cheaper option:
agent.hooks.before_display_error.add(
lambda args: log.write(args.message)
)
args.message can be modified in place in all three hooks, which puts a filter in front of the output.