Mounting WebDisplay¶
WebDisplay pushes events to the browser; WebDisplayService provides the HTTP service that hosts it — or many
instances of it. The relationship is “display instance” versus “server”: the display is constructed first and may
exist without a service, while a service can be replaced and there can be several.
flowchart LR
subgraph S["WebDisplayService :18960"]
CHAT["/chat/ front end"]
API["/api/sessions"]
DOCS["/docs/ docs"]
subgraph M1["/session/a/"]
D1["WebDisplay A"]
SRV["srv/<key>/ temporary site"]
end
subgraph M2["/session/b/"]
D2["WebDisplay B"]
end
end
A1["agent 1"] --> D1
A2["agent 2"] --> D1
A3["agent 3"] --> D2
Construction and lifecycle¶
from xun import WebDisplay, WebDisplayService
service = WebDisplayService(host="localhost", port=18960, token="secret")
service.mount("a", WebDisplay(expose_files=True)).mount("b", WebDisplay())
service.start(blocking=True)
WebDisplayService(
host="localhost", port=18960, token="", base_path="",
assets_dir=ASSET_DIR / "web", # front-end build output directory
docs_dir=ASSET_DIR / "docs", # documentation directory
session_manager=None, # dynamic session factory, see below
)
WebDisplay(expose_files=False, event_buffer_size=5000)
| Member | Description |
|---|---|
service.mount(path, display, *, name=None) |
Mounts a display at /session/<path>; returns itself for chaining |
service.unmount(path) |
Removes one session (also closing its temporary sites and its context) |
service.rename(path, name) |
Changes the session's display name (not the mount path) and returns the session info |
service.list_sessions() |
Lists mount points, display names and status (idle / running / waiting) |
service.start(*, blocking=False) |
Non-blocking starts a thread and returns it; blocking serves in place and never returns |
service.stop() |
Requests shutdown and joins (5-second timeout internally) |
service.port / service.host |
With port=0, read the port the system actually assigned |
service.access_url(path="") |
Direct link to that session including the token (given as a query string; 0.0.0.0 is written as localhost) |
service.app |
The underlying FastAPI instance, so you can mount additional routes yourself |
expose_files |
Enables the file panel when the display is created: browsing is limited to the working directory of the bound agent, and symlinks and path traversal are refused |
Constraints on mount points: the path may not contain a srv segment (it collides with the temporary-site prefix, see
below); mounting the same path twice raises ValueError; one display instance can occupy only one location. The mount
point / is the root session, and other sessions are inserted ahead of it.
Token¶
With token="" the service still generates a random token (token_urlsafe(24)), and the xuns CLI prints it
straight into the access URL. Four ways to carry it: Authorization: Bearer, a cookie (named xun_web_token), the
?token= query string, and the login-page form. The cookie is written only when the token is correct, and a 303
redirect then clears the token from the address bar.
Two prefixes are exempt from authentication for GET / HEAD:
/docs/: the documentation shipped with the package, publicly readable.…/srv/: temporary sites, where the random key in the URL is itself the credential (the mount disappears on shutdown or expiry).
Every other path (the WebSocket included) is rejected when authentication fails; a WS connection is closed directly
with 1008, and page requests are redirected with 303 to /login.
Routes¶
| Path | Content |
|---|---|
/ |
303 redirect to /chat/ |
/chat/ |
Vue single-page application (front-end assets served from assets_dir) |
/docs/ |
Documentation shipped with the package; returns 404 when docs_dir/index.html does not exist |
/session/<mount>/… |
That display's backend: history, events, choice, clear, download, api/serve/…, WS ws |
/session/<mount>/srv/<random key>/… |
A working-directory site temporarily hosted by that session |
/api/sessions (create / remove / rename) |
Session create / remove / rename, open only when a session_manager is supplied |
/login |
Token login page |
base_path prefixes all of the above, for mounting behind a reverse proxy on a sub-path.
Realtime channel¶
The browser opens one WebSocket per session and events are pushed by the server; on connect it first receives a
replayed snapshot (history + pending prompts), deltas then travel over the WS, and event polling takes over when the
WS drops. Every connection carries a client_id, which lets the server skip broadcasting the echo of its own action —
confirming in one tab does not dismiss the prompt in another.
Dynamic sessions¶
session_manager is a no-argument factory, and the context manager it returns must yield
(mount point, display):
from contextlib import contextmanager
from tempfile import TemporaryDirectory
from xun import WebDisplay, WebDisplayService, setup_agent
def new_session():
@contextmanager
def make():
with TemporaryDirectory() as tmp:
display = WebDisplay(expose_files=True)
setup_agent(workdir=tmp, display=display) # the new session comes with one agent
yield f"/{uuid4()}", display
return make()
service = WebDisplayService(token="abc", session_manager=new_session)
| Behavior | Result |
|---|---|
No session_manager |
Create / remove / rename on /api/sessions all return 405, and the session-management controls in the UI are disabled (--no-manage-sessions) |
| Supplied | The UI can create sessions; deleting first unmounts the mount point, then closes the context left by the factory |
| Only one session left | Deleting returns 409, so the last session is not shut down |
One display can be bound to several agents, told apart in the UI by identifier (each agent has a different working directory, so the file panel switches along with it):
display = WebDisplay(expose_files=True)
service = WebDisplayService(token="abc").mount("main", display)
service.start()
for name, workdir in [("coder", ".test"), ("writer", ".tmp")]:
setup_agent(name=name, display=display, workdir=workdir) # binds to the same session automatically
Temporary sites¶
When the UI “opens a directory as a website”, it goes through the serve API the display ships with:
POST /session/<mount>/api/serve/<agent_id>/start (path in the body), which returns a URL carrying a random key
plus an expiry; sites are unmounted automatically after 1 hour by default.
GET …/api/serve/<agent_id> lists the running sites, and POST …/api/serve/<agent_id>/<key>/stop stops one on demand.
Paths are still resolved by the agent's workspace rules, and anything outside is refused.
Event buffer¶
WebDisplay buffers 5000 events by default (the display base class defaults to a capacity of 0, so you must reserve
one explicitly here — otherwise nothing survives a page reload):
d = WebDisplay(expose_files=True).with_buffer_size(2000)
d.events(agent_id) # replay
d.clear_events(agent_id)
Why the docs need no token
After starting with xuns --no-initial-agent, /docs/ is readable right away — even with no model service configured at all.
This is also why the documentation site must use relative links throughout: it has to keep working when mounted under any URL prefix (for example /docs/).