Skip to content

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 in choices.

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.