Skip to content

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 --token and the routing conventions are described in xuns · Web service.
  • port=0 means a random port; the real port is written to service.port.
  • mount() can mount several display instances as long as their paths do not overlap; the /srv segment is reserved.
  • The root path of the site redirects automatically to /chat/; FastAPI's built-in /docs is 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 its accepted reply, which is what makes resending after a reconnect safe.
  • Messages without a client_id are 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.