Display & web service¶
The agent never calls print directly: it broadcasts events to a DisplayAbstract, and every confirmation request goes out through the same layer as well. This way the same execution logic can land in a terminal, in a browser, or be completely silent.
Call-side API (used internally by the agent)¶
| Method | Hook triggered | Event emitted |
|---|---|---|
display_event(payload) |
none | any event (no state restriction) |
info(message) |
before_display_info |
InfoEvent |
warning(message) |
before_display_warning |
WarningEvent |
error(message) |
before_display_error |
ErrorEvent |
get_choice(prompt, choices, ...) |
before_display_warning when auto-confirm must pick and default is not among the choices |
ConfirmEvent (source is "user" or "auto") |
get_confirm(prompt, ..., default=True) |
none | same as above (the options are Yes/No) |
For a rich-text notice emit display_event(HTMLInfoEvent(html=..., title=...)) directly; like display_event, it needs no particular state. The web UI injects the HTML after DOMPurify sanitising, the console renders title as a panel (a plain info line without one), and NullDisplay drops it.
Every method except display_event requires the agent to be in the Init state; the three display hooks may rewrite args.message in place, which is commonly used for filtering or adding a prefix (see the examples in the Extension system).
get_choice / get_confirm return ChoiceOutcome[T] (choice plus source) whose truth value is deliberately forbidden (bool() raises TypeError); see Tool system for why.
Event model¶
Events are pydantic models wrapped in one common envelope, DisplayEvent: timestamp, name (the class name), agent (AgentInfo: name / identifier / workdir) and payload.
The payload types are defined in display_event.py and re-exported by display_abstract.py (from xun.display_abstract import ToolCallEvent still works); the event buffer and the standalone HTML rendering are provided by DisplayEventStoreMixin in display_store.py. display_event() appends to the buffer first and only then hands the event to on_event(), so every display layer comes with its own event history.
| Event | Key fields |
|---|---|
UserMessageEvent |
content, images (kind is url or base64) |
UserCommandEvent |
name, arguments |
ModelWorkingEvent |
model_call_id, remaining_iterations |
ModelMessageEvent |
model_call_id, content, reasoning, total_tokens |
ToolCallEvent |
tool_call_id, tool_name, args |
ToolResultEvent |
tool_call_id, result |
ShowHistoryEvent / ShowHelpEvent / ShowToolsEvent |
history, command table, tool table |
ShowExtensionsEvent |
extensions: name, description, status, reason (see Extension system) |
InfoEvent / WarningEvent / ErrorEvent |
message |
HTMLInfoEvent |
html, title; rendered as rich text on the web, the console falls back to to_text() (tags stripped, line breaks kept) and NullDisplay drops it |
ConfirmEvent |
choice, choices, source, prompt, message |
AgentBindEvent / AgentUnbindEvent |
none (bind/unbind signals) |
AgentRunningStartEvent / AgentRunningEndEvent |
none; emitted by the running-state machine on the idle ↔ running transition, alongside the run_start / run_end hooks, which is what lets the web client light up and dim its running indicator |
Custom display layer¶
You only need to implement two abstract methods:
from xun import Agent, DisplayAbstract, ToolBox
class RawDisplay(DisplayAbstract):
def on_event(self, event):
print(f"{event.name}: {event.payload}")
def get_choice(self, request) -> str:
print(f"[{request.prompt}] {request.choices}")
return input(">>> ")
with Agent(display=RawDisplay(), toolbox=ToolBox().with_defaults()) as agent:
agent.instruct("...").execute()
get_choice receives a DisplayAbstract.ChoiceRequest: agent_info, prompt, choices, message, title, subtitle, default, allow_extra; it returns the selected string.
Built-in implementations:
| Implementation | Behaviour |
|---|---|
NullDisplay() |
discards events; get_choice raises NotImplementedError (never reached when auto_confirm is on). The default of a bare Agent() |
Display() |
console output (rich); the default of setup_agent and of xun |
WebDisplay(expose_files=False, event_buffer_size=5000) |
web backend, keeps the event history so the front end can replay it; the oldest events are dropped once the cap is reached |
Both Display(event_buffer_size=1000) and WebDisplay accept this parameter; the default capacity built into the mixin is 100, which heavy users raise on their own.
Event buffer¶
| Method | Description |
|---|---|
set_event_buffer_size(max_events) |
sets the capacity of the ring buffer; the oldest events are dropped once it is exceeded |
events(agent_id=None) |
the events in the buffer, oldest first; can be filtered by agent |
clear_events(agent_id=None) |
empties the buffer (everything, or only one agent's events) |
render_history_as_html(title="Session") |
renders the buffered events into a single self-contained HTML page, grouped the same way as the web event stream |
/render exports exactly this page (not the list of conversation messages), which is why the output also contains tool calls, reasoning and confirmation cards.
Terminal input: prompt_toolkit
Console input and confirmation prompts are both driven by prompt_toolkit: the main prompt supports browsing history with the up/down keys and history search, / slash-command completion (with descriptions) and @path file-reference completion (@dir/ descends one level at a time, ordered as exact match, then prefix, then substring). The prompt text is rendered to ANSI by rich first and only then handed to prompt_toolkit, so a coloured prompt cannot miscalculate the cursor width; when a confirmation or a choice pops up it takes over the main prompt and, once answered, restores the input you had half written exactly as it was. Choice prompts are moved through with the arrow keys and confirmed with Enter.
Web service programming interface¶
xuns is nothing but a wrapper around the following:
from xun import WebDisplay, WebDisplayService, setup_agent
display = WebDisplay(expose_files=True)
setup_agent(display=display, default_tools=True, workdir=".test")
service = WebDisplayService(host="localhost", port=18960, token="", base_path="")
service.mount("/", display)
service.start(blocking=False) # prints the access URL of every session
...
service.stop()
- With
token=""a random token is generated automatically; the three ways of passing--tokenand the routing conventions are described in xuns · Web service. port=0means a random port; the real port is written toservice.port.mount()can mount several display instances as long as their paths do not overlap; the/srvsegment is reserved.- The root path of the site redirects automatically to
/chat/; FastAPI's built-in/docsis disabled so that/docs/is left for the bundled documentation site.
Session factory¶
Once a session_manager is passed in, the interface can create and delete sessions on its own. The factory is a synchronous context manager that yields (mount_path, display), and its finally block performs the clean-up:
from contextlib import contextmanager
from uuid import uuid4
from xun import WebDisplay, WebDisplayService, setup_agent
@contextmanager
def session_manager():
display = WebDisplay(expose_files=True)
agent = setup_agent(display=display, default_tools=True, workdir=".test")
try:
yield f"/{uuid4()}", display
finally:
agent.finalize()
service = WebDisplayService(session_manager=session_manager)
web_session(manage_sessions=True) provides exactly this default factory (each session gets its own temporary working directory unless an explicit workdir is passed, in which case all sessions share it).
Front-end and back-end contract¶
Apart from the row marked {base}, every endpoint below lives under {base}/session/<mount>/, where <mount> is the session's mount path and {base} is --base-path; the front end picks the session with {base}/chat/?session=<mount>&token=….
| Endpoint | Description |
|---|---|
WS /ws |
main channel: the client sends message, command, choice, cancel; the server pushes serialised DisplayEvent objects (running state is carried by the AgentRunningStartEvent / AgentRunningEndEvent events among them) plus the three control frames pending_prompt, prompt_resolved, accepted |
GET /api/events |
fetches the missed events when reconnecting after a disconnect |
GET /api/prompts, POST /api/prompts/{id}/resolve |
confirmation requests awaiting an answer |
GET /api/agents, GET /api/running, GET /api/config |
agent list, running state, whether the file interface is exposed |
GET /api/commands/{agent_id}, GET /api/capabilities/{agent_id} |
command table, model and capabilities |
GET/POST /api/files/*, /api/serve/* |
file browsing, upload, move/rename/delete, creating directories (several levels at once) and text read/write, plus temporary static hosting (registered only when expose_files=True); POST …/write carries the modified_at the client read for optimistic concurrency and answers 409 on conflict; GET …/info returns is_text; an empty path in a hosting request means the working-directory root |
{base}/api/sessions (incl. /create, /remove, /rename), {base}/login, {base}/chat |
service-level routes (provided by WebDisplayService, not by WebDisplay): session list, creation, removal and renaming, the login page, the front-end UI; these answer 405 when session management is off, and removing the last session answers 409 |
WebSocket message bodies: message (client_id?, agent_id, content, at most 8 images), command (client_id?, name, arguments), choice (prompt_id, value), cancel (agent_id).
Acknowledgement and deduplication¶
message and command may carry a client-generated client_id (at most 128 characters). Once the server has taken the message in, it answers with an accepted frame:
{"type": "accepted", "client_id": "9f1c…"}
- Each display instance remembers the most recent 2000
client_ids; a repeated one is not executed again but still gets itsacceptedreply, which is what makes resending after a reconnect safe. - Messages without a
client_idare executed straight away, with neither acknowledgement nor deduplication — embedders writing their own client can opt in per message.
Documentation site
WebDisplayService mounts src/xun/assets/docs as a static site at {base}/docs/, with no token required; that site is exactly the artefact this directory produces with MkDocs and make doc-dist then copies in.