Skip to main content
PathDocs

Web UI Architecture

One-liner: the Web UI is a "host process + browser side" dual-process architecture: the host (host/) holds agents and capabilities, the browser side (client/) is a React plugin shell, communicating through the connection layer (client-connection); client plugins are the browser halves of "dual-face" packages, hot-updated via HMR.

1. Dual-process architecture

Why dual-process: capabilities (agent loop, tools, sandbox) run in the host; the UI renders in the browser. The browser holds no real capability; it only subscribes to the event stream through the connection layer.

Dynamic Cordis plugins follow the same split: tool-cordis hands immutable Packages to the Host runner; Host halves run in the host, Client halves start asynchronously through the Client runner after browser approval, and client-ui-cordis owns the conversation cards. See Runtime Inspection and Dynamic Plugins for the lifecycle.

2. Connection layer: how frontend and backend communicate

client-connection is the channel between the browser and the host. The frontend subscribes to session/event (token stream, turn boundaries, tool activity) to render the conversation, and receives agent/* control events (agent/status, agent/created/agent/disposed) to reflect state.

  • conversation content comes from the persisted session event stream (see Event System)
  • HMR: client plugin changed? pnpm run dev:web rebuilds lib/client.js, and the webserver polls for the rebuild and hot-updates automatically

3. API gateway (apiproxy)

apiproxy is the API gateway shared by every client, made of three parts: the TypeScript API contract (src/api/, zero Node dependencies, importable directly by the browser), the fetch carrier pair (toFetchHandler on the host side, AbstractApiClient + platform subclasses on the client side), and the host-side implementation (createApiProxy + the default-exported ApiProxyService gateway plugin, config {nativeOpen?, sessionExportCompressionLevel?, coldBlankProbeMaxBytes?}, providing ctx.apiProxy). This package does not register routes; carriers like HTTP wrap ctx.apiProxy themselves.

It consumes ctx.agentDefaultModel (does not own provider/model config); default model selection belongs to the agent-default-model service described in Model Routing.

The contract layer (/api): wire messages are a four-quadrant discriminated union (who initiated × request/response), decoupled from the physical channel — ClientRequest (POST /api/<method> body), ServerResponse (that POST's response body), ServerRequest (SSE frame), ClientResponse (POST /api/respond body). Responses always echo the matching request's rpcId; Zod schemas parse in two layers (envelope first, business payload second).

It carries several session domains: e.g. session.history reads an attached Session or checks the cold log through persistence (no resume, no agent topic), paginates by append-origin message boundaries, and the tail page carries a projections block. The gateway owns sessionListMetadata and imageLimits; coldBlankProbeMaxBytes defaults to 1 KiB and bounds the small cold-log reads used to verify blankness and the latest human prompt.

Session export is a host-only download surface (GET /api/session.export), not an RPC. The Web /export command and Header Session log action share one controller: it performs a HEAD preflight, then delegates a ZIP containing descendants and attachments to the browser download manager; /export <path> returns an argument error. Directory selection delegates to ctx.directoryPicker, covered next.

Mount status: mounted by default (the Web composition, id api-gateway).

4. Client plugins (dual-face)

One package can provide both a host half and a client half. The client half is a lazy CJS module table: the host scans the Loader entries to compose the window.__DSH_BOOT__ boot map, serves /plugins/<id>/client.js?rev=<rev>, and the browser half's side effects run only on first require (window.__ModuleLoader__.load({id, factory})).

{
"exports": {
".": "./lib/index.js", // host half
"./client": "./lib/client.js" // browser half
},
"dsh": { "client": { "inject": ["slots", "connection"], "platform": "web" } }
}

The browser half registers UI (e.g. a settings-panel section):

export function apply(ctx: ClientContext): void {
ctx.slots.inject('settings.section', () =>
ctx.slots.register({
name: 'settings.section', id: 'plugins', order: 60, label: () => 'Plugins',
}, PluginPanel))
}

5. The slot system: third-party-extensible UI

slotRole
settings.sectionsettings-panel sections (plugins mount their own settings cards here)
settings.*other settings positions
other named slotsprovided by dsh-client-ui-slots, registered on demand by third-party plugins

Third-party plugins don't need to be hardcoded in the Web UI source: mount into the right spot via a slot. Configurable items are discussed in Agent Presets.

6. How the frontend consumes the session event stream

The UI splits into two streams:

Event classRole
session/eventrenders the conversation (token stream, boundaries, tool activity): "content"
agent/*the control plane (status, created/destroyed, request errors): "state"

The frontend renders messages from session/event's surface, and updates agent status lights / turn progress from agent/*.

7. Access-mode entry point

The "Full access / Standard mode" selection in the UI = the entry point of the permission preset (see Permissions), not the sandbox itself.

8. Workspace directory selection (directory-picker)

directory-picker is a capability seam: ctx.directoryPicker is its Service Definition, whose only method capability() returns a discriminated union describing "how the operator picks a directory". The difference among backends is user interaction, not just implementation:

kindCapabilityUse case
nativepick(signal): open a native OS pickerthe operator sits in front of the host display
browselist(path?) / createDirectory(path, name): in-app directory listing and creationa remote client that can't reach the OS picker

directory-picker-auto is the adaptive picker: it samples once at boot (loopback-only bind, non-SSH launch, an available display session; Linux needs DISPLAY/WAYLAND_DISPLAY + zenity/kdialog on PATH), and mounts the matching dual-face backend (native or browse) as a real Loader entry in the in-memory root tree (not persisted). Anything ambiguous falls back to browse.

The two backends:

  • directory-picker-native: the native capability; pick opens a native picker each time and resolves an absolute path (cancel returns null); platform tools don't use a shell (macOS osascript, Linux Zenity→KDialog, Windows modern IFileOpenDialog in a spawned child); caller abort terminates the native process
  • directory-picker-browse: the browse capability, one-level directory listing + subdirectory creation (via Node stdlib, with per-OS adaptation); lists only directories, name-sorted, follows symlink-to-directory, the host owns the hidden marker; crumbs is the root→target ancestor chain; createDirectory is non-recursive and validates the name is a single segment

browse primitive failures throw a typed DirectoryPickerError (directory-unreadable/directory-exists/directory-create-failed), which the gateway maps 1:1 to wire error codes.

Mount status: auto is mounted by default (the Web composition, id directory-picker); native/browse are mounted one-or-the-other at its boot, or you can mount one directly in an overlay to pin the interaction.

9. Workspace registry (workspace)

ctx.workspaceRegistry is a registry of workspace entities: durable workspace records, stable ordering, and a newest-first candidate session index, stored via the domain data form. Consumers see the Workspace interface; the entity implementation is package-private.

APIContract
create(path, title?)normalize via fs.realpath, reject non-existent/non-directory, at most one record per canonical path, prepend to the durable order
get(id) / list() / resolveByPath(path)cache-hit queries; list() synchronously in durable order; resolveByPath async (same realpath, rejects missing rather than creating)
delete(id)deletes only the Workspace registration, ordering entry, and session account; directories/files/live Sessions/persisted logs are untouched, and those Sessions become Ungrouped
attachSession(id)validates the header cwd against the workspace path, prepends the new id; detachSession deletes only the candidate-index entry
insertSessionBefore(id, before?)DOM-insertBefore-style manual ordering; the workspace order is unchanged
archiveSession(id) / archivedSessionIdsa registry-global archive set; archiving removes from grouping surfaces but keeps the log and the sessionIds slot
status()an un-cached directory check, `'ok'

Mount status: mounted by default (the Web composition).

10. Frontend static serving (host-frontend-static)

host-frontend-static is the SPA dist server: a function plugin (config {distIndex}) occupying the webserver's only fallback seat, serving the built frontend directory with the shell's locking semantics — routes outside the dist root return 403, any miss falls back to index.html with HTTP 200 (SPA routing), unknown file extensions serve application/octet-stream, and non-GET/HEAD to unmatched named routes return 405. Every index response passes through the webserver's index taps (applyIndexTaps), and the boot manifest reaches the page through them.

distIndex is an assembly fact of the composition: dsh-web-app resolves it via the frontend package's exports and mounts this plugin. The fallback seat has a single owner (a second claim throws), and is effect-scoped (once dispose frees it, an unclaimed webserver returns 404).

Mount status: mounted by default (mounted by the Web composition's web-runtime row).

11. Build artifacts

pnpm dsh web # auto build:lib + build:web, then start
pnpm run build:web # build only the frontend
  • development HMR runs pnpm run dev:web in the source directory (rebuilds lib/client.js); dsh web's webserver polls for the rebuild and hot-updates automatically

12. Verification

# inspect the port the host process listens on
lsof -nP -iTCP:3080 -sTCP:LISTEN

# check whether a client plugin is loaded
dsh web --dump-config | grep -i client

# check whether the API gateway / directory picker / workspace / frontend hosting are mounted
dsh web --dump-config | grep -iE "apiproxy|directory-picker|workspace|host-frontend-static"

Next steps