Skip to content

Web app

Zidane in the browser, served by zidane-server itself. Turn it on, pair a browser with a one-time code, and what each scope can do.

zidane-server can serve Zidane's web app at / on its own port. You get the desktop app's layout in a browser: the session sidebar, the transcript and composer, the side panel, the command palette and settings. Nothing else runs: the server serves the page, and the page talks to the server on the same origin.

The app is built only on the published packages, the same zidane-ui entries and zidane/ipc/browser client an npm user installs. Its source is web/ in the repository.

Turn it on#

The web app is off by default. Start the server with --web, or set ZIDANE_SERVER_WEB=1, or "web": true in server.json:

Terminal
zidane-server start --web

The Docker image and the release archives bundle the app. With the zidane command or a build of your own, pass --web-root <dir>. Install lists where each install keeps it, and Deploy shows the reverse proxy setup.

Pair a browser#

A browser logs in with a pairing code instead of a token you copy by hand. On the server, run:

Terminal
zidane-server pair                       # chat scope, valid 10 minutes
zidane-server pair --scope admin --name "work laptop" --ttl 30m

It prints a code like 7KQM-3XFD. Open the server's address in the browser, type the code, and choose Pair. An admin client can also ask for a code over the protocol (tokens.pair).

RuleValue
Code8 characters, no 0, O, 1 or I, shown as XXXX-XXXX
Lifetime10 minutes by default, 1 hour at most (--ttl)
UseOnce. 5 wrong attempts burn the code
Attempts10 a minute per client address
TokenA normal device token, named browser: <name> in zidane-server token list

The pairing screen says why a code failed: not valid, expired, already used, or too many attempts. Security has the full rules.

The browser keeps its token in localStorage (zidane-web:token). Revoke it like any other token (zidane-server token revoke, or Settings › the server's tokens): the page goes back to the pairing screen and says the server no longer accepts its token.

Sign out (the sidebar's footer, or the palette) deletes the token from the browser. With an admin token it also revokes it on the server, by the token id the pairing returned. A token of a lower scope stays live on the server: revoke it from the token list.

Scopes#

The code's scope is the token's scope. The app shows what the scope allows and hides the rest.

ScopeWhat the web app does
chatSessions, prompts, questions, tool approvals, files, diffs, todos, the queue and artifacts. The admin parts of Settings show greyed out, with an Admin only tooltip
adminEverything: settings, accounts and keys, MCP servers, installs (when the server allows them), the terminal, the server's update and its tokens

With a chat code, the server's card in Settings says Chat access, and Get admin shows the server's own pairing command (pair --scope admin, with its binary, its user and its data dir). Run it on the server, sign out, and log in again with the code it prints.

What differs from the desktop app#

The web app runs the same components as the desktop app, over zidane-server's protocol. What the protocol does not offer yet is hidden. The main differences:

  • No browser tab, no native notifications or window effects: they need the desktop app.
  • No language servers in Files: they run where the editor is. The project's own linters and typecheckers run on the server, and the Editor settings list them for the session's folder (files.diagnosticsTools).
  • Appearance, keybindings and the side panel's layout live in this browser. Unsent drafts, the sidebar's filters and the pickers' remembered picks live on the server, so every browser finds them.

The sidebar groups, searches and filters the sessions, and its menus rename, star, archive, delete and move them between spaces. The composer is the desktop's: / commands and skills, the extensions' completions, ghost text, the agent, model and effort pickers, the run chip and context orb, the badge strip, and the approval docked above it. /side <question> asks in the Side tab, and ? searches the conversation.

Click a session's title for its details (rename, generate a title, compact, export, its folder on the server, delete). Right-click a message for its turn's details (copy, fork, edit, delete). The palette lists the sessions and imports a repository into the server's workspace (an admin token), and the server's section in Settings lists its machines. Under the composer, the git bar shows the session folder's branch, its changes and pull request, with the branch menu and sync (switching and syncing need an admin token).

docs/WEB-PARITY.md lists every desktop feature and its state in the web app.

Build your own remote app#

Every area of the web app runs on one connection. Your app can do the same with the published packages: one createServerClient, every server host from zidane-ui/adapters on it, the browser hosts for what lives in the page, and one AppHostProvider. web/src/hosts.ts (createWebHosts) is the full version.

TSX
import type { AppHost } from 'zidane-ui/app/headless'
import {
  createBrowserAppearanceHost,
  createBrowserKeybindingsHost,
  createBrowserWorkspaceHost,
  createServerApprovalsHost,
  createServerClient,
  createServerComposerHost,
  createServerFilesHost,
  createServerGitHost,
  createServerHost,
  createServerMachinesHost,
  createServerOnboardingHost,
  createServerPaletteHost,
  createServerSessionHost,
  createServerSettingsHost,
  createServerSidebarHost,
  createServerTerminalHost,
} from 'zidane-ui/adapters'
import { AppHostProvider } from 'zidane-ui/app'

const client = createServerClient({ url: 'wss://box.example', token })
// Build after the handshake: the hosts read what the token may call from it.
await client.ready

const chat = createServerHost({ client })
const session = createServerSessionHost({ client })
const palette = createServerPaletteHost({ client })
const onboarding = createServerOnboardingHost({ client }) // configures only with an admin token
const machines = createServerMachinesHost({ client })
// read-only below admin; MCP sign-ins come back to the server at this page's origin
const settings = createServerSettingsHost({ client, oauthCallbackUrl: new URL('/zidane/oauth/callback', location.origin).toString() })
const composer = createServerComposerHost({ client })

const host: AppHost = {
  chat,
  session,
  palette,
  onboarding,
  machines,
  settings,
  composer,
  sidebar: createServerSidebarHost({ client }),
  approvals: createServerApprovalsHost({ client }), // answers with a chat token
  git: createServerGitHost({ client }),
  // These two don't read the handshake: gate them yourself.
  ...(client.can('sessions.workspaceListFiles')
    ? { files: createServerFilesHost({ client, sessionOf: cwd => openSessionFor(cwd), storage: localStorage }) }
    : {}),
  ...(client.can('terminal.state') ? { terminal: createServerTerminalHost({ client }) } : {}),
  workspace: createBrowserWorkspaceHost({ key: 'my-app:workspace', client }), // the server's extension panels
  appearance: createBrowserAppearanceHost({ key: 'my-app:appearance' }),
  keybindings: createBrowserKeybindingsHost({ key: 'my-app:keybindings' }),
}

export function App() {
  return <AppHostProvider host={host}>{/* your layout */}</AppHostProvider>
}

// Teardown: stop every host that listens for good, then the connection.
export function dispose() {
  for (const h of [chat, session, palette, onboarding, machines, settings, composer]) h.close()
  client.close()
}

What each host does with the token:

HostGating
ComposerOffers the extensions' completions, the agent profiles, the remembered picks and the extensions' strip when the handshake lists their methods
Settings, machines, terminalRead client.can themselves: settings show the admin parts greyed out (Admin only), machines hide what the scope refuses, the terminal reads as unavailable
Approvals, onboardingFollow client.can('approvals.resolve') and client.can('auth.setApiKey') when built after the handshake. canApprove / canConfigure override it
FilesNever gated: leave it out unless client.can('sessions.workspaceListFiles') (Files)
Chat, session, palette, git, sidebarCalls the token can't make are refused one by one; the components show the error

Teardown is close() on the hosts that have one (chat, session, palette, onboarding, machines, settings, composer): each stops listening to the client and leaves it open, so close them before you build new hosts on the same client. Approvals, files, git and terminal listen only while a component reads them, and the sidebar host not at all: they need no teardown. client.close() ends the connection.

Pair a browser with pairBrowser to get its token.

Develop the app#

Terminal
cd web
bun install                                          # links zidane and zidane-ui to this checkout
ZIDANE_SERVER_URL=http://127.0.0.1:4280 bun run dev  # Vite on :5190, proxying the WebSocket and /zidane/* to that server
bun run build                                        # web/dist, what --web-root serves
bun run test && bun run e2e                          # unit tests; Playwright on the build, in parallel, each test on its own scripted zidane-server

bun run server:web at the repository root builds web/ into server/web-dist/, the app the server bundles.