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, ...) |
none | 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 (web only) emit display_event(HTMLInfoEvent(html=..., title=...)) directly; like display_event, it needs no particular state.
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, every other display falls back to to_text() (tags stripped, line breaks kept) |
ConfirmEvent |
choice, choices, source, prompt, message |
AgentBindEvent / AgentUnbindEvent |
none (bind/unbind signals) |
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. The default of a bare Agent() |
Display() |
console output (rich); the default of setup_agent and of xun |
WebDisplay(expose_files=False, max_events=2000) |
web backend, keeps the event history so the front end can replay it |
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).
Front-end and back-end contract¶
| Endpoint | Description |
|---|---|
WS /ws |
main channel: the client sends message, command, choice, cancel; the server pushes serialised DisplayEvent objects plus the four control frames execution_state, 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.