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.
| 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, max_events=5000) |
web backend, keeps the event history so the front end can replay it; the oldest events are dropped once the cap is reached |
Terminal prompts and readline
The console Display asks through input(): the prompt is rendered to ANSI by rich, then every escape sequence is wrapped in \001…\002 so that readline ignores its width. Arrow keys, history and inline editing keep working, a coloured prompt cannot misplace the cursor, and an out-of-range answer is reported in red and asked again.
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 and temporary static hosting (registered only when expose_files=True); an empty path in a hosting request serves 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.