Skip to content

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/).