Browser Access & Deployment¶
Cortex ships a browser workbench — the same SPA the desktop app wraps — reachable from any browser without installing anything. This page covers both the deployment runbook (building the SPA and enabling the Web UI endpoint on your server) and the browser access path (reaching it through Cloudflare Access edge login).
There are three independent ways to reach the workbench, and they authenticate differently:
| Path | Who | Authentication | Holds clientToken? |
|---|---|---|---|
| Browser + Access | Anyone with a browser | Cloudflare Access edge login (email / IdP), verified as a JWT by the server | No — the browser never sees the token |
| Browser + token login | Anyone who knows the token | The token is pasted once and exchanged for an HttpOnly session cookie | No — only the server sees it, and only once |
| Desktop (Tauri) | The installed desktop app | Bearer x-cortex-token (the clientToken) stored in the OS keychain |
Yes |
This page is the browser + deployment reference. For installing the native desktop app, see Desktop App.
Updates¶
Help → Check for updates (or Settings → Check for updates in the mobile layout) checks the connected server for a fresh release and compares the loaded page's production entry scripts and stylesheets with fresh, same-origin HTML fetched without using the browser cache. It does not call native app commands. Development pages and unsupported entry layouts report a skipped page check; network, authentication, and invalid-page responses report a failed check, not “up to date.”
Server prompts take priority and are visible on desktop and mobile browsers. Update authorizes server installation and a restart, which briefly interrupts the connection. Later hides the prompt; another manual check can reopen it. Skip this version, disabled server checks, and development-mode policies remain in effect.
After the server reconnects, the browser checks only the page assets again. Changed assets offer Refresh page; nothing reloads automatically. Refresh can lose unsaved page state but does not stop running server tasks. Choosing Later keeps the loaded page, and a later check can offer refresh again. The server update and page refresh have separate confirmations.
The in-core Web UI transport¶
The Web UI transport ships in-core with @cortex-agent/server and is loaded on demand
behind the CORTEX_UI_HTTP flag. It pulls @trpc/server and jose, but these are
runtime-lazy: the server imports the transport only through a dynamic import that is reached
solely when the flag is set. When the flag is unset, neither the transport code nor @trpc/server
/ jose ever enter the runtime module graph, so a Slack- or TUI-only deployment pays zero UI
weight — while installing and upgrading stay a single npm install -g @cortex-agent/server.
When enabled, the transport:
- serves the built SPA (
web/dist) same-origin with the tRPC API — one port, one origin, so the browser loadsindex.html+ assets and calls/trpcwith no cross-origin plumbing; - exposes the tRPC API at
/trpc(HTTP batch for queries/mutations, SSE for subscriptions), connected in-process directly to the server's domain services (no proxy, no sidecar); - gates every
/trpcrequest behind an auth check that accepts any of three credentials (see Authentication).
It runs in the same process as the server, binds to 127.0.0.1, and is meant to be exposed
to the internet only through a tunnel.
Deployment runbook (source → running server)¶
These steps take you from a fresh checkout to a Cortex server serving the browser workbench.
1. Prerequisites¶
- Node.js ≥ 22.19.0 and pnpm (this repo is a pnpm workspace).
- A running Cortex server (see Quickstart).
2. Build the SPA¶
The web SPA is built to web/dist. From the repo root:
pnpm install # install workspace dependencies
pnpm -r run build # build every workspace package, including web → web/dist
To build only the SPA and its dependencies:
web/dist is a plain static bundle (index.html + hashed assets). When the @cortex-agent/server
package is published, web/dist is staged into the package (via a prepack step) and shipped in
its files, so an installed server can serve the SPA with no extra build. From a source checkout
you build it yourself with the step above.
3. Where web/dist is served from¶
The server resolves the SPA directory in this order:
- an explicit directory you pass to it;
- the
CORTEX_UI_SPA_DIRenvironment variable; - the package-root
web/dist(present in an installed/published package); - the monorepo's
web/dist(when running from a source checkout).
If you deploy an installed package, option 3 works out of the box. If you deploy from a repo
checkout, option 4 works with no configuration — build web/dist in place and it is served
automatically. If you deploy the built SPA to a different location, point CORTEX_UI_SPA_DIR at
it. If the directory is absent (SPA not built), non-/trpc paths return a 404 placeholder while
/trpc still works.
4. Enable the Web UI endpoint¶
On the server, run:
The command generates CORTEX_CLIENT_TOKEN if .env has none, sets the endpoint variables, and
adds the native apps' origins to uiCorsOrigins. It is safe to repeat, and it reports whether the
configuration changed. To configure the endpoint by hand instead, add to ~/.cortex/config/.env:
CORTEX_UI_HTTP=1 # opt-in: start the tRPC HTTP + SSE endpoint (required)
CORTEX_UI_PORT=3004 # optional; defaults to 3004
This alone is enough to open the workbench from a browser: token login is on by default, so the
SPA will ask for the clientToken and exchange it for a session (see
Browser access with a token).
CORTEX_UI_HTTP accepts 1, true, on, or yes. With it unset, the endpoint — and the
Web UI transport, along with @trpc/server / jose — never loads.
5. Restart the daemon to apply¶
The daemon reads the SPA and env at startup, so a new build or env change takes effect on
restart. Start the daemon with cortex daemon if it is not running yet; otherwise restart it:
Restarting interrupts the connection
A graceful cortex daemon restart waits until no turn is running and holds new turns until
the server is back, but every connected client loses its connection while the server restarts.
cortex daemon restart --hard and --force restart at once and interrupt running agents.
Restart when the system is idle, and if your Cortex instance runs under an approval policy,
route the restart through that approval step.
At this point the server is reachable at http://127.0.0.1:3004 on the server host only.
Exposing it to a browser is the next section.
Authentication¶
The /trpc auth gate accepts a request if any of three credentials is valid, and returns
401 before tRPC runs otherwise:
x-cortex-tokenheader equal to the server'sclientToken— the desktop / machine path. Checked first, with a constant-time comparison.- A live
cortex_uisession cookie — the token-login browser path. Minted byPOST /api/ui/loginafter the browser proves, once, that it knows the token. - A valid
Cf-Access-Jwt-Assertionheader — the Cloudflare Access browser path. The edge injects this JWT after it authenticates the user; the server verifies it.
Either browser path keeps the clientToken out of the page: the cookie is HttpOnly, so the
SPA's own JavaScript cannot read it, and the Access JWT is issued by the edge.
The server verifies the Access JWT against your Cloudflare Access team-domain JWKS, checking the signature (RS256 / ES256 only), the audience (AUD) tag, the issuer, and expiry. If Access is not configured on the server, the JWT path is disabled and the gate securely degrades — an unconfigured Access path never admits a request.
The port forward is token-only, on purpose
/forward (the desktop shell's raw TCP tunnel to a loopback service on the server) accepts
only the x-cortex-token header. Neither browser credential opens it. A browser attaches
cookies to a WebSocket handshake automatically, so admitting the session cookie there would
hand every logged-in page a raw socket to every loopback service on the host.
Browser access via Cloudflare Access¶
The browser path puts a Cloudflare Access edge login in front of a dedicated UI hostname, so users log in with email / IdP at the edge and the server only ever sees an already-verified request.
browser
│ https (Cloudflare Access: email / IdP login at the edge)
▼
Cloudflare Tunnel (cortex-ui.example.com → server localhost:3004)
│ edge injects Cf-Access-Jwt-Assertion on every request
▼
agent-server Web UI transport (verifies the JWT; the browser never holds clientToken)
├─ serves web/dist (same-origin SPA)
└─ serves /trpc (same-origin real data, in-process)
1. Create a dedicated UI hostname and tunnel route¶
Point a Cloudflare Tunnel route from a new public hostname (for example
cortex-ui.example.com) to the server's loopback endpoint (http://127.0.0.1:3004, or your
CORTEX_UI_PORT).
Use a separate hostname from the cortex-client endpoint
The hostname your remote cortex-client instances connect to must not be placed behind
Cloudflare Access — Access would block the WebSocket clients. Always give the browser UI its
own hostname and apply Access only to that one.
2. Add a Cloudflare Access application (account-side ops)¶
In the Cloudflare Zero Trust dashboard, create a self-hosted Access application for the UI hostname with a policy that allows your login email (or IdP group). This is an account-level operation performed in the Cloudflare dashboard, not in Cortex config. Note the application's AUD tag — you need it below.
3. Configure the server to verify Access JWTs¶
Add to ~/.cortex/config/.env on the server:
CORTEX_ACCESS_TEAM_DOMAIN=your-team # bare team name, or your-team.cloudflareaccess.com
CORTEX_ACCESS_AUD=<your-access-app-AUD> # the Access application's AUD tag
# CORTEX_ACCESS_CERTS_URL=... # optional: override the derived JWKS URL
From CORTEX_ACCESS_TEAM_DOMAIN the server derives the issuer
(https://your-team.cloudflareaccess.com) and the JWKS URL
(https://your-team.cloudflareaccess.com/cdn-cgi/access/certs). If either
CORTEX_ACCESS_TEAM_DOMAIN or CORTEX_ACCESS_AUD is unset, the browser path stays disabled
(token-only). Restart the daemon after changing these values.
4. Open the workbench¶
Navigate to https://cortex-ui.example.com. Cloudflare Access challenges you for email / IdP
login; after you authenticate, the edge forwards every request with a verified
Cf-Access-Jwt-Assertion, the server serves the same-origin SPA, and the workbench loads real
tRPC data — no token, no local install.
Browser access with a token (no Cloudflare)¶
If you do not run Cloudflare Access — or you just want to open the workbench from a laptop on
the LAN or through an SSH forward — a browser can authenticate itself by pasting the server's
clientToken once.
This is on by default on any server with CORTEX_UI_HTTP=1. Open the UI, and if the browser
holds no session yet the SPA shows a sign-in screen instead of the workbench:
browser ──POST /api/ui/login {token}──▶ server (constant-time compare vs clientToken)
◀──Set-Cookie: cortex_ui=… ; HttpOnly; SameSite=Strict; Secure──
browser ──every later /trpc, /api request carries the cookie──▶ server
What that buys, and what it costs:
- The token is submitted once. It is exchanged for an opaque 32-byte session id. The cookie is
HttpOnly, so page JavaScript — including an XSS payload — cannot read it back out. SameSite=Strictkeeps the session off requests from other sites. Every subdomain of a shared parent domain still counts as the same site, and a Cloudflare Access assertion is attached regardless, so the server also checksOrigin: any request other thanGET,HEADorOPTIONSto/trpcor to an/apiroute — login included — whoseOriginis neither this server's own nor listed inuiCorsOriginsis refused with 403, andOrigin: nullis always refused. Requests that carry noOrigin(the CLI, devices, the native shells' own HTTP clients) are left to the credential check. "Own" means theOriginhost equals the request'sHostheader, so a reverse proxy in front of the server must passHostthrough unchanged (cloudflared does unlesshttpHostHeaderis set; nginx needsproxy_set_header Host $host).- Served files open sandboxed. An HTML, XHTML, SVG or XML file opened directly from a download
link (workspace files, attachments, board assets and memory files alike) is served with
Content-Security-Policy: sandbox allow-scriptsandX-Content-Type-Options: nosniff, so it runs in an opaque origin and cannot read the session or call the API as you. - Sessions survive a daemon restart. They live in
~/.cortex/data/ui-sessions.json(mode0600) and expire 30 days after sign-in; using a session does not extend it. Settings → Advanced has a Sign out of this browser button, on desktop and mobile layouts alike, which revokes the session server-side. - A session is strictly weaker than the token. It carries exactly the authority of a
Cloudflare Access login: tRPC and the
/apiroutes, never/forward. - The login endpoint is public. It has to be — a browser with no credential must be able to
reach it. A wrong token costs the caller a fixed 250 ms and is logged; the token itself is 32
random bytes, so online guessing is not a realistic attack. There is deliberately no IP ban
(behind a tunnel every request appears to come from
127.0.0.1) and no global lockout (which would let anyone lock you out of your own server).
Tuning or turning it off¶
CORTEX_UI_TOKEN_LOGIN=0 # 0, false, off or no: remove the login routes and the cookie leg
CORTEX_UI_SESSION_TTL_DAYS=30 # optional; session lifetime in days, default 30
With token login off, the login and logout routes are not mounted and session cookies are not accepted, so only the header token and, if configured, Cloudflare Access admit requests. The SPA says so on the sign-in screen rather than offering a form that could not work.
Where to use which¶
Put Cloudflare Access in front of a UI hostname when you want IdP-managed access for people who should never see the token, or when the UI is exposed to the open internet. Use token login for your own access — over a tunnel, a VPN, Tailscale, an SSH forward, or plain loopback. The two coexist: a server can have both, and each request is admitted by whichever credential it carries.
Plain HTTP on a LAN
The session cookie is marked Secure when the request arrived over HTTPS (or from
localhost). Over plain HTTP to a LAN address it cannot be — a browser would silently drop a
Secure cookie — so the cookie is issued without it and the daemon logs a warning. Anyone on
that network path can read the session. Use HTTPS for anything beyond a trusted LAN.
The three paths side by side¶
All three reach the same /trpc API and the same workbench, but they differ in where and
how they authenticate:
| Browser + Access | Browser + token login | Desktop (Tauri) | |
|---|---|---|---|
| Hostname | Dedicated UI hostname behind Cloudflare Access | Any hostname that reaches the port | A hostname not behind Access |
| Login | Cloudflare Access edge login (email / IdP) | Paste the clientToken once |
Enter serverUrl + clientToken once |
| Credential on requests | Cf-Access-Jwt-Assertion (issued by the edge) |
cortex_ui session cookie |
x-cortex-token header |
| Where auth is checked | JWT verified by the server | Session verified by the server | Token verified by the server |
clientToken exposure |
Never touches the browser | Typed once, never stored in the page | Stored in the OS keychain |
Opens /forward |
No | No | Yes |
| SPA origin | Same-origin (SPA + /trpc on one host) |
Same-origin | Direct connection to /trpc (CORS-enabled) |
Because the desktop app sends x-cortex-token, it must connect through a hostname that is
not behind Cloudflare Access (Access would block the bearer request at the edge). Choose the
desktop app when you want a native window, the port forward, and are comfortable storing the
token locally; Access when other people need in and should never see the token; token login when
it is your own browser and the path to the server is already private or encrypted.
Troubleshooting¶
Browser gets a Cloudflare login loop or 403 at the edge
The Access application policy does not allow your identity. Check the policy on the UI hostname's Access application in the Cloudflare Zero Trust dashboard.
Browser logs in at the edge but the workbench shows 401 / no data
The edge authenticated you but the server rejected the JWT. Confirm on the server that
CORTEX_ACCESS_TEAM_DOMAIN matches your team and CORTEX_ACCESS_AUD matches the Access
application's AUD tag exactly, and that the daemon was restarted after setting them. With those
unset, the browser path is disabled and every browser request is 401.
The sign-in screen says token sign-in is turned off
CORTEX_UI_TOKEN_LOGIN is set to 0 on the server. Either remove it (token login is on by
default) and restart, or reach the UI through Cloudflare Access / the desktop app instead.
The token is correct but signing in does nothing / it asks again on every page load
Check the daemon log for ui-http cross-origin request refused. A reverse proxy that rewrites the
Host header makes the page's own origin look foreign, so the server refuses the sign-in request
with 403. Make the proxy pass Host through unchanged (cloudflared does unless httpHostHeader
is set; nginx needs proxy_set_header Host $host).
The page shows Cortex UI not built
web/dist was not found, so every path except /trpc returns 404 with that text while the API
keeps working. Build the SPA (pnpm --filter web run build) or point CORTEX_UI_SPA_DIR at your
built bundle, then restart.
Nothing is reachable at the UI hostname
- Confirm
CORTEX_UI_HTTP=1is set and the daemon was restarted after adding it. - On the server, confirm the endpoint is up:
curl http://127.0.0.1:3004/trpcshould answer401 Unauthorized(not connection-refused). - Confirm the Cloudflare Tunnel is running and its route points at the correct port.