The Electron desktop app and web SPA for MindsHub Cowork — MindsDB's AI coworker platform. Cross-platform (macOS + Windows), auto-installs the backend on first run, and provides a chat-based UI backed by a FastAPI server, with Minds integration.
The project is split across two repos:
| Repo | Purpose | Language |
|---|---|---|
mindsdb/cowork (this repo) |
Electron shell + React SPA | TypeScript / React |
mindsdb/cowork-server |
FastAPI backend — projects, conversations, agent orchestration | Python |
The frontend and backend are developed and released independently. At runtime, the Electron main process spawns cowork-server as a local sidecar and communicates with it over HTTP (127.0.0.1:26866). Both the UI and server support over-the-air updates — see Over-the-Air Updates.
MindsHub Cowork runs in several contexts. The React SPA is identical across all of them — only the shell and server lifecycle differ.
| Environment | Frontend | Backend | How to run |
|---|---|---|---|
| Local dev (Electron) | Vite dev server on :5173 |
uv run cowork-server from sibling source dir |
npm run dev |
| Local dev (web) | Vite dev server on :5173 |
uv run cowork-server from sibling source dir |
npm run dev:web |
| Packaged Electron (macOS/Windows) | Bundled or OTA-cached React build | cowork-server binary via uv tool install from PyPI |
Download from downloads.mindshub.ai |
| Docker (web deployment) | Static files served by uvicorn | cowork-server installed in /opt/venv |
docker build + docker run |
The Environments table above covers how you run the app. A build channel (or "kind") covers which env it targets and where it stores its data. Every launch resolves to one of four kinds. src/main/channels.ts is the source of truth: its CHANNELS table maps each kind to a data home, API host, and default sidecar branch, and every other layer derives from it.
| Kind | User-facing name | Data home | API env | Source | Produced by |
|---|---|---|---|---|---|
dev |
MindsHub Cowork (Dev) | ~/.cowork-dev |
staging (api.staging.mindshub.ai) |
your working tree | npm run dev — a runtime kind, never a shipped installer |
preview |
MindsHub Cowork (Preview) | ~/.cowork-preview |
staging | the PR branch | CI per-PR installer, for testers |
stable |
MindsHub Cowork (Staging) | ~/.cowork-stable |
staging | staging |
CI rolling installer |
prod |
anton | ~/.cowork |
prod (api.mindshub.ai) |
main |
released installer from downloads.mindshub.ai |
How the kind is decided (resolveBuildKind): a COWORK_BUILD_KIND env var wins if set; otherwise any unpackaged run is dev; otherwise the kind comes from the bundled build-config.json, and an absent config falls back to prod. So dev only exists when you run from source — it is never packaged into a distributed installer. CI stamps build-config.json with preview, stable, or prod when it packages those.
Ordinary local
pack/distproduce a prod package.npm run packandnpm run distbundle the checked-inbuild-config.json({"buildKind":"prod"}) as-is — nothing stamps it — so the launched app resolves theprodkind and uses the~/.coworkhome and the prod API. SettingCOWORK_BUILD_KINDfor the pack does not make a non-prod package: the env var is never written into the bundle, andpack's plainelectron-builder --diralso skips the per-kind bundle identity applied byscripts/run-electron-builder.mjs. SoCOWORK_BUILD_KIND=stable npm run packyields a prod-identity bundle that still resolvesprodat launch, while a stagingVITE_MINDS_API_URLbakes the staging host into the renderer — a prod-home app talking to staging. A real non-prod package is a CI-only path (CI writesbuild-config.jsonwith the target kind and runs the identity-aware packaging scripts). To exercise a non-prod kind locally without packaging, run from source (npm run dev= thedevkind), or launch the packaged binary withCOWORK_BUILD_KINDset in its environment — that runtime env var does win inresolveBuildKind, overriding the prod bundle onto the non-prod home and keychain (pair it with a matching bakedVITE_MINDS_API_URLso the API host agrees).
Things that trip people up:
- "Staging" is the
stablekind. The internal name isstableand its home is~/.cowork-stable. Users see the label "Staging", and it targets the staging env. The name is historical. - Only
prodtalks to production. The other three kinds targetapi.staging.mindshub.ai. Keycloak follows the API host (api.X → auth.X), so their auth resolves toauth.staging.mindshub.ai. A bare dev run must never authenticate against prod. No build targets adevAPI env — that realm is effectively dead. prodkeeps the historical paths. Its home is~/.coworkand its Electron userData name staysanton, never re-set. Every other kind uses~/.cowork-<kind>and its own userData name.
A channel's state is not all under one folder. It spreads across several locations, each keyed on the build kind so channels never clobber each other. Non-prod kinds keep their state under (or near) ~/.cowork-<kind>; prod keeps every historical global location, byte-for-byte unchanged. Paths below are for macOS.
| Location (non-prod / prod) | What lives there | How it's isolated |
|---|---|---|
Data home — ~/.cowork-<kind> / ~/.cowork |
SQLite cowork.db, .env, state.json, .master_key, and the projects/ files/ skills/ data-vault/ trees (full layout) |
COWORK_HOME |
uv tool install — ~/.cowork-<kind>/uv/ / ~/.local/{share/uv,bin} |
The cowork-server + anton-agent Python sidecar (binary + venv) |
UV_TOOL_DIR / UV_TOOL_BIN_DIR |
Server logs — ~/.cowork-<kind>/logs/ / ~/Library/Logs/anton |
cowork-server.log |
Non-prod redirects to the data home |
Electron userData — ~/Library/Application Support/<app name> (prod = anton) |
Renderer localStorage (terms consent, UI prefs); on Windows/Linux the encrypted MindsHub refresh token (mindshub-refresh.bin, safe-storage) |
app.setName per kind |
MindsHub refresh token — macOS: ~/.cowork-<kind>/refresh-token.dat / ~/.cowork/refresh-token.dat; Windows/Linux: userData mindshub-refresh.bin |
The Keycloak refresh token | COWORK_HOME (macOS) / userData (Win/Linux) — see token-store.ts |
OTA UI cache — non-prod: ~/.cowork-<kind>/ui-cache / prod: userData ui-cache/ |
Hot-swapped renderer bundle | COWORK_HOME (non-prod) / userData (prod) — see ui-updater.ts |
Shell-update downloads — ~/Library/Caches/anton-updater-{prod,stable} |
electron-updater's pending shell installer (prod + stable only; other kinds have no shell auto-update) | Per-channel cache dir name, in the OS cache root — not userData |
OS keychain — service cowork-oauth-<kind> / cowork-oauth |
Connector OAuth refresh tokens | Per-kind service name |
Legacy ~/.anton |
Pre-channel global config (.env, state.json); still read as a fallback and migrated into the prod home once |
prod only |
The packaged bundle identity (appId, productName, icon, Linux package name) is also set per kind at build time (scripts/channel-identity.mjs), so non-prod builds install as distinct apps beside prod.
Everything under the ~/.cowork-<kind> home — the data home, uv install, and non-prod server logs — is identical on every OS; only ~ resolves differently. The three OS-managed locations follow each platform's convention:
| Location | macOS | Windows | Linux |
|---|---|---|---|
| Electron userData | ~/Library/Application Support/<app name> |
%APPDATA%\<app name> |
~/.config/<app name> |
prod server log (getPath('logs')) |
~/Library/Logs/anton |
%APPDATA%\anton\logs |
~/.config/anton/logs |
Shell-update cache (anton-updater-<channel>) |
~/Library/Caches |
%LOCALAPPDATA% |
~/.cache |
| Keychain / secret store | Keychain | Credential Manager (DPAPI) | Secret Service (libsecret) |
The fresh-install reset function below wipes all of these locations; use it as a checklist of everywhere state hides.
Caveat — standalone
cowork-serverdefaults to prod. The Electron shell enforces the home split by injectingCOWORK_HOME. Acowork-serverlaunched outside the desktop app — a bareuv run cowork-server, or an Alembic command — inherits noCOWORK_HOMEand silently targets the prod home~/.cowork. Set the target explicitly before any standalone DB or migration work:COWORK_HOME=~/.cowork-dev(orDATABASE_URI=sqlite:///$HOME/.cowork-dev/cowork.db). Back up the target.dbfirst.
Both dev modes expect a sibling cowork-server directory (override with COWORK_SERVER_DIR):
parent/
cowork/ ← this repo
cowork-server/ ← github.com/mindsdb/cowork-server
npm install
# Build everything (main + renderer)
npm run build
# Run locally
npm startOr jump straight into dev mode:
# Electron dev (hot reload for renderer)
npm run dev
# Web dev (no Electron, opens in browser)
npm run dev:webIn dev mode the server runs from source (uv run cowork-server), so local Python edits are picked up immediately.
Copy .env.example to .env and fill in any optional tokens (e.g. VITE_POSTHOG_MINDSHUB_MAIN_PROJECT_TOKEN for analytics). Vite auto-loads .env at build/dev time. The .env file is gitignored.
npm run dev:debugThis opens the Electron app against the Vite dev server and auto-opens Chromium DevTools in a detached window. It runs three processes concurrently:
tsc --watchfor main processvite devfor renderer (port 5173)- Electron with
VITE_DEV=1flag
When testing a packaged build (npm run pack), the DEV_MODE variable in ~/.anton/.env controls which renderer the app loads:
| Value | Behavior |
|---|---|
live |
Load from Vite dev server (localhost:5173) — requires npm run dev:renderer running separately |
full |
Load the bundled renderer only — skips OTA cache entirely |
| (unset) | Production mode — loads OTA-cached UI if available (prod builds only — see Over-the-Air Updates), otherwise bundled |
To set it, add DEV_MODE=full (or live) to ~/.anton/.env. Remove the line to return to production behavior. When DEV_MODE is set, the OTA update check is skipped entirely.
Tip: If you build the app and it looks outdated, the OTA cache may be serving an older published bundle. Either set
DEV_MODE=fullto bypass it, or clear the cache:rm -rf ~/Library/Application\ Support/anton/ui-cache/current
To test first-run onboarding you need a true fresh-install state. App state hides in several places: the cowork homes (~/.cowork plus per-build-kind variants), the legacy ~/.anton home, the Electron userData dirs (one per build kind — prod's anton plus the Dev/Preview/Staging apps), the prod log dir, the per-channel shell-update caches (anton-updater-{prod,stable}), the uv-managed tool installs, and the macOS Keychain (connector OAuth tokens, one service per kind: cowork-oauth plus cowork-oauth-{dev,preview,stable}). This zsh function wipes them all — drop it in ~/.zshrc:
# Reset MindsHub Cowork to a fresh-install state: kills the app, wipes
# per-user state (API keys, chat history, .env, DB, OTA cache, keychain
# tokens), and uninstalls the uv-managed packages so the .app runs full
# onboarding on next launch.
# Usage: anton-reset [--deep] (--deep also removes uv itself + shims)
anton-reset() {
local deep=0
[[ "$1" == "--deep" ]] && deep=1
echo "About to wipe Cowork state for $(whoami) (HOME: $HOME)"
(( deep )) && echo "Deep mode: also removes uv and ~/.local/share/uv"
read -q "?Proceed? [y/N] " || { echo; echo "Aborted."; return 1; }
echo
# Stop the app (current product name, plus older builds)
killall "MindsHub Cowork" 2>/dev/null
killall Anton 2>/dev/null
sleep 1
# Per-user runtime state — all build kinds (prod uses ~/.cowork and the
# 'anton' userData dir; dev/preview/stable use suffixed homes and their own
# userData dirs). The homes also hold the macOS refresh-token.dat and the
# non-prod ui-cache; the 'anton' userData dir holds prod's ui-cache.
rm -rf "$HOME/Library/Application Support/anton" \
"$HOME/Library/Application Support/MindsHub Cowork (Dev)" \
"$HOME/Library/Application Support/MindsHub Cowork (Preview)" \
"$HOME/Library/Application Support/MindsHub Cowork (Staging)" \
"$HOME/Library/Logs/anton" \
"$HOME/Library/Caches/anton-updater-prod" \
"$HOME/Library/Caches/anton-updater-stable" \
"$HOME/.anton" \
"$HOME/.cowork" "$HOME/.cowork-dev" "$HOME/.cowork-preview" "$HOME/.cowork-stable"
rm -f "$HOME/Library/Preferences/com.anton.app.plist"
# Connector OAuth tokens live in the macOS Keychain, not on disk — one
# service per build kind (prod keeps the historical unsuffixed name)
for svc in cowork-oauth cowork-oauth-dev cowork-oauth-preview cowork-oauth-stable; do
while security delete-generic-password -s "$svc" >/dev/null 2>&1; do :; done
done
# uv-installed packages (the installer re-installs these on next run)
uv tool uninstall anton anton-agent cowork-server 2>/dev/null
# Deep clean: force the installer to bootstrap uv itself too
if (( deep )); then
rm -f "$HOME/.local/bin/uv" "$HOME/.local/bin/uvx" "$HOME/.local/bin/anton"
rm -rf "$HOME/.local/share/uv" "$HOME/.cache/uv"
fi
# Verify the key file is actually gone (the critical check)
if [[ -e "$HOME/.anton/.env" || -e "$HOME/.cowork/.env" ]]; then
echo "STILL THERE: a .env survived the wipe"
return 1
fi
echo "GONE: all Cowork state removed. Relaunch the .app for fresh onboarding."
}The cowork SPA also runs as a plain web app, served by the same FastAPI backend. The renderer is shell-agnostic — there is one source tree, one component library, and two entrypoints.
npm run dev:webThis boots both processes:
- The cowork-server FastAPI backend on
127.0.0.1:26866(viauv run cowork-serverfrom the sibling source directory). - Vite dev server on
localhost:5173, withBUILD_TARGET=web.
The dev server opens at http://localhost:5173/ (a small Vite
middleware rewrites / → /index-web.html so the bare URL is
canonical). API calls hit the FastAPI sidecar via Vite's
/v1 and /health proxies. Press Ctrl-C once for a clean
shutdown — vite quiesces first, then the python child.
npm run build:webOutputs to dist/renderer-web/ (separate from dist/renderer/ which is
the Electron build). Drop this directory behind any static-file server
and point its /v1 requests at a running cowork-server process.
The cowork tree (src/renderer/cowork/) never touches
window.antontron directly. All host-bridge access goes through
src/renderer/platform/host.ts, which exposes:
| Method | Electron | Web |
|---|---|---|
getPlatform() / isMac() |
'darwin' | 'win32' | 'linux' |
'web' / false |
getApiOrigin() |
http://127.0.0.1:26866 |
window.location.origin |
openExternal(url) |
Electron shell.openExternal | window.open(url, '_blank') |
openPath / showItemInFolder / trashItem |
OS shell | { ok: false, reason: 'unsupported' } |
serverInfo / serverStart / serverStop |
IPC to main | static {running: true, …} |
oauthConnect(...) |
IPC PKCE loopback flow | inline error (redirect-based OAuth not yet wired) |
Affordances that depend on Electron-only bridge calls (server pill +
power button in the sidebar, "Open in OS" / "Show in Finder" /
"Move to Trash" buttons in the artifact views, the
ServerOfflineHelpModal) are hidden when host.isWeb is true.
src/renderer/
index.html # Electron entry (loads main.tsx)
index-web.html # Web entry (loads web-main.tsx)
main.tsx # Electron entry: App.tsx → CoworkApp (with onboarding gates)
web-main.tsx # Web entry: cowork SPA directly (no onboarding gates)
platform/host.ts # Shell abstraction (the only sanctioned bridge surface)
cowork/ # The shared SPA — never imports window.antontron
vite.config.ts branches on BUILD_TARGET=web: when set, rollupOptions.input
points at index-web.html and outDir becomes dist/renderer-web/. When
unset (the Electron path), behavior is byte-identical to before.
src/
main/ # Electron main process (Node.js)
index.ts # Window creation, IPC handlers, menu
installer.ts # First-run installer for cowork-server (uv; plus git + Xcode CLT on git-channel builds only)
server-process.ts # FastAPI sidecar lifecycle (start/stop/health)
server-updater.ts # OTA server update (PyPI check, upgrade, rollback)
ui-updater.ts # OTA UI update system (fetch, verify, cache, rollback)
updater.ts # Update orchestrator (couples server + UI, shell notice)
shell-auto-update-runtime.ts # Shell (Electron app) auto-update via electron-updater
preload.ts # contextBridge — exposes antontron API to renderer
renderer/ # React UI (bundled by Vite)
App.tsx # App flow: loading -> setup -> onboarding -> cowork
CoworkApp.tsx # Main chat-based cowork shell
pages/
Setup.tsx # Install wizard with step progress
Onboarding.tsx # LLM provider selection (Anthropic / Minds)
cowork/ # Shared SPA — never imports window.antontron
platform/host.ts # Shell abstraction (the only sanctioned bridge surface)
styles.css # Full dark theme
global.d.ts # TypeScript types for window.antontron API
shared/
ipc-channels.ts # All IPC channel constants
assets/
icon.png / icon.icns # App icon (gradient cyan-to-purple "A")
-
FastAPI sidecar: The Electron main process manages the
cowork-serverPython FastAPI backend on127.0.0.1:26866, installed from PyPI viauv tool install. The renderer communicates exclusively through this HTTP API — there is no PTY or terminal emulator. -
Minds integration: The GUI replicates the
/connectflow — lists minds via REST API, handles datasource selection (normalizes string/object refs), and writes non-credential config to~/.cowork/.env. The MindsHub credential itself is never written there; see MindsHub credentials. -
OTA updates: The React UI and the Python backend update over-the-air (coupled, auto-applied at boot) without a new installer; the Electron shell auto-updates on stable/prod via
electron-updater, installing on relaunch. See Over-the-Air Updates.
All channels defined in src/shared/ipc-channels.ts:
| Channel | Direction | Purpose |
|---|---|---|
install:check |
invoke | Check if cowork-server is installed |
install:start |
invoke | Run the installer |
install:log/progress/done/error |
send | Installer status events |
install:cancel |
invoke | Cancel an in-progress install |
install:cancelled |
send | Confirms install was cancelled |
settings:read/save/check-configured/validate |
invoke | Settings & API key management |
terms:accept |
invoke | Record terms acceptance |
ui:update-check |
invoke | Check for OTA UI updates |
ui:update-apply |
invoke | Download and apply a pending UI update |
ui:update-status |
send | Update status events (available/reloading) |
server:restart |
invoke | Restart the FastAPI sidecar |
server:update-status |
send | Server OTA update progress (PyPI check) |
auth:get-access-token |
invoke | Retrieve current access token |
auth:logout |
invoke | Clear auth session |
oauth:cancel |
invoke | Cancel an in-progress PKCE OAuth flow |
mindshub:login |
invoke | Start MindsHub OAuth login |
mindshub:refresh |
invoke | Refresh MindsHub token |
mindshub:finalize |
invoke | Pick the organization and hand the credential to the sidecar |
mindshub:get-cached-token |
invoke | Read cached MindsHub token |
mindshub:list-orgs |
invoke | Organizations this account belongs to |
mindshub:switch-org |
invoke | Change organization and refresh the session credential |
app:ready |
send | App finished initializing |
app:get-platform/ui-version/open-external |
invoke | Platform info, open URLs |
shell:show-item-in-folder |
invoke | OS shell operations |
settings:validate is handled entirely in the main process. The IPC handler is in
src/main/app.ts and the validators it calls (validateMinds,
validateOpenAICompatible, validateAnthropic) live in
src/main/provider-validation.ts. It never reaches the Python sidecar. The same
logic also lives in the sidecar (cowork-server/cowork/services/providers.py), and
that copy answers POST /api/v1/settings/validate-provider, which is the path the
web build takes because it has no main process.
So a change to how a provider is validated has to land in both places. The two have drifted before: a fix that moved the MindsHub probe onto the free model landed in the sidecar and left main probing a paid one, and probing a paid model means an account with an empty wallet is told its working key is invalid.
The rule both copies follow: probe mindshub_air, whose usage draws the free included allowance rather than the wallet, so the result reports reachability and key validity instead of billing state. For openai-compatible and anthropic a model the caller asked for explicitly is sent as asked; provider: 'minds' always sends the probe model and ignores model on both copies.
Which copy a given build actually runs is decided by the onboarding screen, not by
the platform alone. The MindsHub card renders a pasted-key form on web and
Keycloak sign-in buttons on Electron (OnboardingScreen.tsx), and the pasted-key
form is the only caller that passes provider: 'minds'. Since host.validateProvider
reaches this IPC channel only under Electron, main's validateMinds has no live
caller: on desktop the MindsHub path goes through mindshub:finalize, which
provisions a key and probes no model. The validators a packaged build does run are
the openai-compatible and anthropic ones, from the BYOK step. Main's MindsHub probe
is kept in step with the sidecar anyway, because the drift is what caused this bug
and a future caller should not have to rediscover it.
Rollout is not symmetric between the two, and not in the obvious direction. On
prod the renderer bundle hot-updates at boot while the Electron shell (so
everything under src/main/**) only changes when a new installer is applied,
because shell auto-update is opt-in there. On stable it is the other way round:
UI OTA is prod-only (otaUiEnabled in src/main/update-logic.ts) and shell
auto-update is on by default (shellAutoUpdateEnabledFor in
src/main/shell-auto-update-rollout.ts), so the shell replaces itself in the
background and applies on the next relaunch. Either way a main-process change lands
a relaunch later than a sidecar change. See Shell updates.
The GUI provides a visual /connect flow:
- If LLM provider is Minds (from onboarding), credentials are pre-filled
- Lists available minds via
GET /api/v1/minds/ - Handles datasource selection (auto-selects if only one)
- Fetches engine type via
GET /api/v1/datasources - Writes to
~/.cowork/.env:ANTON_MINDS_URLANTON_MINDS_MIND_NAMEANTON_MINDS_DATASOURCEANTON_MINDS_DATASOURCE_ENGINEANTON_MINDS_SSL_VERIFY
- Writes mind's system prompt to project cortex
- Auto-restarts the server to pick up new config
ANTON_MINDS_API_KEY is deliberately absent from that list — see below.
The app writes no MindsHub credential to disk. Signing in gets you a Keycloak
session (Authorization Code + PKCE, refresh token in OS secure storage, access
token in memory), and that access token is what the sidecar presents to the
gateway. Auth's /v1/authenticate/ accepts a JWT and an mdb_ key alike,
picking the branch from the token's shape.
The sidecar is a separate process, so the value has to cross that boundary. It
crosses over loopback rather than through a file: main holds it and PUTs it to
/api/v1/runtime-credential/minds, which the sidecar keeps in memory and
overlays onto its settings. src/main/minds-credential.ts owns that hand-over.
Four consequences worth knowing:
-
Every sidecar start needs a fresh hand-over. Nothing persists the value, so a sidecar that has just come up holds nothing and
/healthanswersconfig_ready: falseuntil main pushes again. The renderer paints that answer as "Connect a provider to start chatting", so a signed-in user reads a wiring gap as a billing prompt. The sidecar goes down far more often than a launch: an over-the-air update and its rollback, the sidebar's stop/start, and the installer's first start on a fresh machine. Wiring the push into each of those is how one gets missed, so it hangs off the single function they all call.src/main/app.tsregistershandOffMindsCredentialToStartedSidecarwithsetServerStartedHook, andstartServerawaits it after every successful start (src/main/server-process.ts). That hook also releases the wake barrier below, because a sidecar restarting mid-hand-over is exactly when a turn is parked waiting for one. It is awaited rather than fired off, because callers read/healthas soon asstartServerresolves. Sign-in and the token-refresh tick push on top of that, since both produce a new credential without restarting anything. -
A wake can hold your next message for up to 12 seconds. Sleeping past the access token's ten-minute life leaves the sidecar holding a JWT the gateway will reject, so a resume near expiry arms a barrier over
POST /api/v1/responsesand/responses/answeruntil the refreshed value has been accepted.src/main/minds-resume-gate.tsowns it. Three things bound the damage. It waits at mostMINDS_RESUME_READY_TIMEOUT_MS(12s) and then cancels the request, which stays under the renderer's ~13s stranded-reservation reap so a held turn fails visibly instead of vanishing with its thinking placeholder still on screen. It only arms when the sidecar's/healthsays a resolved required planning or coding role actually uses the runtime MindsHub credential, so direct OpenAI and Anthropic turns are never held. AndStopis deliberately outside it. An unknown or unreachable health answer is treated as "needs it", because an older sidecar cannot prove otherwise. -
A key you supply yourself goes to the OS keychain, not to
.envand not to the sidecar's settings table. It wins over the session credential while it is set.mindshub:set-user-keyis the IPC channel that carries it. -
Signing out takes the credential away, and an install upgrading from a build that minted a per-device key is signed out once so that key can be revoked while the session still names the organization it belongs to.
-
Sign-out answers as soon as the credentials are gone. It also restarts the sidecar, to flush provider objects that might still hold the previous user's credential in memory, and that restart runs in the background rather than holding the reply: the stop and start together are capped near 190 seconds, and longer behind a start already in flight, which is far past what anyone waits in front of a confirm dialog.
src/main/sign-out-restart.tsowns it and is single-flight. Signing the next user in waits for it, so their credential is not handed to a sidecar the restart is about to take down, and boot routing answers "not configured" until it settles.
A sidecar you start by hand, outside the app, therefore has no MindsHub
credential. Set ANTON_MINDS_API_KEY yourself with a key minted in the console
if you need one for that.
Nothing under /api/v1/hub/* calls Django auth directly. The renderer calls
its own sidecar, which forwards on the caller's behalf. /api/v1/hub/usage/
carries the free MindsHub Air allowance, the balance, the period's credit spend
and auto top up state, and backs Settings → Usage and the stopped-task cards. /api/v1/hub/workspaces/ answers the
MindsHub workspace listing and backs the workspace selector described in the
next section.
Django auth's ingress allows the console origins and no Cowork host, and a
per-PR Cowork host cannot be added to a static allow-list. A direct call would
therefore work in the packaged app (webSecurity is off there) and fail in the
web SPA. Going through the sidecar is what lets both hosts read the same thing,
which is why Settings → Usage is offered on the hosted build and not filtered
off it.
The credential goes in X-MindsHub-Authorization, not Authorization. The
main process overwrites Authorization on every loopback request with the
sidecar's own token, so the Keycloak JWT cannot arrive under that name.
A bordered control at the bottom of the sidebar, docked with the account row,
names the MindsHub workspace you are working in and opens a menu listing every
workspace you can use with a check on the active one. A MindsHub Workspace
is an org-internal container that owns hub resources (API keys, artifacts, model
entitlements) and lives in the auth service. It is not the working folder this
app also calls a workspace, which is why the stored key and the code are named
hubWorkspace throughout. There is no create entry: workspaces are created in
the console, and the last row deep-links there.
One workspace draws nothing. Everyone starts in Default on their own, and
a switch offering only the place you are already in asks a first-time reader to
work out what a workspace is for no benefit. The control appears once the
organization has a second one, which is the first moment "which workspace am I
in" has more than one answer. The count is taken on the rows the sidecar offers,
which already exclude archived workspaces except the one you are currently in.
So a live workspace beside an archived one counts as one and draws nothing, and
a live workspace beside the archived one you are in counts as two and does draw.
The second case is deliberate: the control is the only way out of a workspace
that was archived under you.
It sits at the bottom rather than the top of the rail. A workspace is a container inside the organization, not what a reader starts a task from, and the top of the rail is where the first task begins. It is not a group inside the account menu either, which is where it shipped first. Two things were wrong with that: the current workspace was invisible until you opened the menu, which is the opposite of what a scope indicator is for, and the account menu is where the organization selector lands, so two levels of one hierarchy would have nested inside a menu about identity.
| State | Control |
|---|---|
| The gate is off | absent |
| The read has not come back yet | absent |
| The hub could not be reached | absent |
| Gate on and reachable, but the org has no workspace | absent |
| One workspace, with nowhere to move to | absent |
| One live workspace and an archived one you are not in | absent |
| One live workspace and the archived one you are in | shown, so you can leave it |
| Two or more, gate on | shown |
A read that has not settled is retried three times over about forty seconds and then left alone. The renderer can mount before the sidecar is listening, so one attempt per session made an ordinary cold-start blip hide the control until the app was relaunched. Two shapes count as unsettled: a thrown transport error or 5xx, and a 200 that says the gate is on but the hub could not be reached, which is how the sidecar reports a failed hop to auth in band. A 404 is not retried, because a sidecar without the route will not grow one, and neither is a gate-off answer, because it is definite.
After a successful desktop organization switch, useMindsOrgs notifies
useHubWorkspaces to discard the old listing and read the new organization.
Late reads and workspace switches from the old organization cannot replace it.
If leaving an archived workspace hides the focused selector, focus moves to the
sidebar's Settings button. Focus already moved elsewhere stays there.
The switch is a server-side Statsig gate, not a build flag. Auth declares
authorization_ui in its own configs/statsig_gates.json, evaluates it with its
server SDK, and reports the verdict; cowork-server reads it and passes it on. So
one gate governs the console and this app, and turning the surface off does not
need an installer. That matters here specifically: src/main/** reaches users
only through a new installer, so a CODING_MODE_OPTIONS_ENABLED-style preload
flag could not be switched off in an incident. COWORK_HUB_WORKSPACES_FORCE_ON=true
on the sidecar is an ON-only development override for walking the surface where no
rule targets you.
Picking a workspace changes what this app shows, not what a turn is billed to. Attribution rides the credential a turn presents, and neither credential carries a workspace today.
The access token's activate_organization claim decides whose credits a turn
spends. On web it also partitions tasks, settings, and storage; desktop's local
database is not organization-filtered. The current server auth path resolves
that identity for each request. A turn already in flight keeps the scope it
started with; later requests use the refreshed credential's organization.
Packaged desktop sign-in still runs through the Electron bridge and main
process. Main owns the Keycloak session and hands its access token to the
sidecar at /api/v1/runtime-credential/minds. Its selection rules live in
src/shared/minds-orgs.ts, and the Keycloak calls live
in src/main/minds-auth.ts.
Three things decide it, in order:
- A pick the person made — stored in
state.jsonunderpreferences.mindsOrganization, keyed by the Keycloak subject so one machine signed into a second account does not inherit the first's choice. Only a pick is ever written: the onboarding picker and the account menu. Where a sign-in happens to land is not recorded, because the key is a single slot and an automatic write to it would replace whatever a person chose — possibly another account's, which no guard keyed on the stored value can even see. - Company organizations rank ahead of the personal one, which Keycloak
names
personal_<userId>. Within each group the order Keycloak returned is kept, so "the first company organization" means the same thing on every sign-in. - The claim the token already carried, which used to be the whole answer. That is how turns ended up billed wherever a console tab happened to be pointing.
ensureActiveOrg switches only when the answer differs from the claim, so an
account with one organization makes no extra round-trip.
Changing it later happens in the account menu's Organization group
(components/UserMenu.jsx, hook hooks/useMindsOrgs.js). It lives there
because an organization is who pays. A MindsHub workspace is a container inside
one, which is why its picker is its own control lower down the rail rather than
a group in this menu.
Desktop lists and switches through mindshub:list-orgs and
mindshub:switch-org. Main switches the Keycloak session, refreshes its token,
and hands that token to the sidecar before it reports success. A later failure
switches back, and main stores the preference only after the hand-over succeeds.
Web lists and switches directly against Keycloak through public-client;
cowork-server has no organization-switch write proxy. The selector has no
product or authorization feature flag, but it stays hidden until the
same-origin server reports organization-boundary protocol v1 as both enforced
and enabled. That compatibility handshake lets the transition-aware client
deploy before old browser bundles are rejected and the picker is exposed. The
menu shows switch rows only when the listing contains multiple organizations.
Names also come from the listing, including the generated label for a personal
organization; the token claim supplies only its id. An unsuccessful read times
out after ten seconds, keeps the rows hidden, and retries after 2, 8, and 30
seconds.
After Keycloak accepts the web switch PUT, Cowork forces
keycloak.updateToken(-1). A confirmed switch, or any result where the PUT
may have committed, clears the settings and composer-draft caches and hard
reloads every open same-origin Cowork document. On canonical web, settings and
drafts are namespaced by the initial token's subject and organization as well
as the durable transition epoch. That keeps a close, external Keycloak switch,
and reopen from hydrating the former organization's data; the epoch also means
a late old-document write cannot overwrite or delete the current value. A
switch request that does not settle within ten seconds is treated as possibly
committed. Known refusal statuses 400, 401, 403, and 404 leave the current page
and organization in place.
Web Locks serializes switch attempts across tabs, and a local-storage marker
blocks ordinary token reads from the moment the PUT starts until refusal or
reload. Each document remembers the epoch in which its JavaScript heap started
and rechecks it after bfcache restore. A browser without exclusive Web Locks or
writable same-origin storage refuses before sending the PUT.
Mandatory reloads are budgeted. A tab that reloads three times inside ten seconds did no work in between, which is a loop rather than a person changing organization, so the fourth reload does not happen. That tab has already cleared its tenant caches and it goes on refusing access tokens, so it fails closed and visibly instead of reloading forever.
Every authenticated browser API request also carries
X-Cowork-Expected-Organization-Id, pinned to the organization in which that
document started. In org tenancy, cowork-server always enforces this boundary
for browser JWT requests. A missing header from a pre-protocol bundle receives
HTTP 426; a malformed header or one that differs from the gateway-resolved
organization receives HTTP 409. Both responses require a reload.
This closes the gap between the token check and actual request dispatch, and it
also catches a Keycloak session change made from another origin. The client
clears tenant state and reloads before consuming such a response.
Canonical-web attachment and project-file bytes use that same authenticated
fetch path, then render, open, or download from a checked Blob URL; a native
resource navigation cannot attach the expected-organization header. Project
HTML preview URLs are the exception: the server mints a short-lived preview
token bound to the requesting tenant scope, so a token requested in one
organization returns no content after the principal changes. Private artifact
serveUrl routes are unavailable in org tenancy and therefore are not part of
the organization-switch protocol.
The initial four-stage rollout was superseded by
cowork-server#524, promoted
to main through #489 on
2026-09-13. It removed COWORK_ORGANIZATION_BOUNDARY_MODE and enabled the picker
in the dev, staging, and production overlays. There is no audit setting that
can reopen the boundary. The server still logs organization boundary: for
refused requests.
COWORK_ORGANIZATION_SWITCH_ENABLED=false remains the deployment control for
hiding switch targets without weakening enforcement. The capability handshake
still requires org tenancy and enforced identity before advertising switching.
Verify every deployed replica and authenticated browser behavior when checking
a rollout; an enabled source value alone does not prove the deployed state.
Hosted Cowork never derives project, skill, project-memory, or instruction permissions from a role in the renderer. Each resource response carries its allowed actions, and a missing capability fails closed: the corresponding rename, edit, disable, or delete control stays unavailable until a fresh server response explicitly allows it. Packaged skills and the General project therefore remain immutable according to the same response contract. Packaged desktop mode keeps its local-owner fallback because its files have no organization principal or server-owned capability record.
Attribution is display metadata, not authority. The UI may name the immutable creator and current last editor, but only the capability fields decide whether a control is enabled. Protected deletes wait for the server before removing anything from local state; a refusal keeps the resource, selection, tasks, drafts, and route in place. Successful protected mutations force a fresh read generation, superseding any coalesced pre-mutation request so stale attribution or capabilities cannot overwrite the response that just succeeded.
The server ships first. Because the hosted client fails closed on an absent
capability, a renderer that reaches hosted users before cowork-server#417 is
deployed reads every response as carrying no capabilities, and rename, delete,
edit, and disable go unavailable for everyone, creators and organization admins
included. Deploy cowork-server#417, confirm hosted responses carry
capabilities, and only then deploy the renderer.
MindsHub's /v1/models marks each model with whether it can run for the org
right now: a paid model on a drained wallet, any model once a free org has spent
its free allowance, or a model an org admin's model rule blocks, arrives as
enabled: false. That map reaches the renderer as settings.modelEnabled, and
isModelLocked (lib/modelCatalog.js) is the single definition both pickers
read, so the Settings rows and the composer's menu can never disagree about what
a user may choose.
A locked model renders visible, tagged "Needs credits", disabled, and
carrying an "Add credits" button. It stays on screen so the model is still
discoverable, and the button is what keeps the row from naming an action it does
not offer: once the row is closed off it is no longer a click target, so a tag
and a tooltip would leave a user told to add credits with nowhere to do it.
ModelSelect attaches that button from the option's locked flag, so Settings
and the composer cannot end up offering different ways out.
Settings additionally puts a "Top up your balance" link under the picker, but only when the current model is locked — the stranded-pin case, where the wallet drained under a model already saved. It says nothing about a row the user is merely looking at, which is why the button on the row is the general answer and the hint is the specific one.
An admin's model rule is the one reason money cannot fix, so it is told apart.
MindsHub sends each disabled row's disabled_reason, and cowork-server relays it
from the MindsHub listing only (never a custom endpoint) as
modelDisabledReasons. A row whose reason is model_restricted renders
disabled and tagged "Restricted", with the tooltip "An admin in your
organization restricted this model.", and is never locked, so it carries no
"Add credits" button. unavailableModelFields in lib/modelCatalog.js builds
the row fields for both pickers, so they cannot drift. The Settings hint under a
restricted current model names the admin and has no billing link, and Code
Mode's new-task check says "An admin restricted this model. Choose another
model." A server too old to send reasons leaves every disabled row on "Needs
credits", exactly as before. cowork-server relays any non-empty reason string,
so knownModelDisabledReasons keeps only model_restricted, wallet_empty and
included_allowance_exhausted: a row with any other reason reads as having
none, and shows "Needs credits". mergeRecommendedModels replaces the reason map
whenever it replaces modelEnabled and keeps it whenever it keeps that map, so
a reason never outlives the enabled map it described.
Why it is not merely tagged: cowork-server resolves a stored model it knows
the gateway will deny into an affordable one instead, so allowing the pick meant
the turn ran a different model from the one the picker named. The user was told
one model wrote their code while another did. The stored choice is never
rewritten, so the moment the balance goes positive the original pick resolves
again with nothing to re-select.
Availability is re-read whenever either picker opens, so a top-up made in a browser unlocks the rows on the next open rather than after a restart. A failed refresh keeps the map already held, and a model the map does not mention counts as available, so a degraded response can never empty a picker.
The app reads GET /hub/usage/ on the sidecar every 30 seconds while signed in,
and again whenever the window regains focus, so a top-up made in a browser shows
up without a relaunch. useHubUsage holds the answer. One read carries the free
MindsHub Air allowance, the paid balance, auto top up state and credit spend for
the period. reachable: false is the resting state, so every surface renders exactly
as it did before this existed until the sidecar says otherwise. A sidecar too old
to serve the route answers 404, which reads as unreachable and paints nothing.
deriveComposerWarning in lib/usageWarnings.js turns that read into at most one
notice, and it is the only thing that decides which. The notice sits above the
composer rather than in the conversation, so it is in view when the next task
starts and never becomes part of the task history. usageTransitions handles the
other half: when the free tokens cross a step of the low band, run out, or auto
top up fails while a task is streaming, ChatView drops a card into the timeline
instead, because that turn is still running and the person has not come back to
the composer yet. It reads the same BYOK gate the bar does, so a task billing
someone else's key never hears about these tokens.
Which resource the warning names depends on what the next turn will spend. An explicit MindsHub Air pick runs on the free tokens, an explicit paid model only ever bills the balance, and the router can land on either, so both matter for it. The two resources are always named apart. "Out of tokens" on its own is never one of the outputs.
When nothing is wrong, the bar still says where the free grant stands: "3.4M of 5M free tokens left. Resets on Oct 1." A warning a person first meets at 20% left is a warning they cannot plan around, and the grant is the only conversion moment the product has, so it is not left to a blank space. The standing figure is neutral rather than amber and carries no close button. An uncapped grant has nothing to count down and gets no figure. A BYOK user, a signed-out one and an unreachable sidecar get nothing, exactly as before. An empty balance is named here too, in the same words the warning uses, because it is true and actionable from the moment it empties rather than from the moment the grant crosses 20%. The tone stays neutral: while the grant can still pay, nothing is blocked.
An explicit paid pick cannot spend the grant, so it gets no figure, but the bar's height is reserved with a hidden copy of it. Otherwise switching picks moved the whole composer up and down, and for a free user the healthy state is the state they are in nearly all month.
Announcing is a separate, permanently mounted sr-only region rather than a role
on the bar. aria-live announces content CHANGES, so a region has to be in the
DOM and empty first; since the bar is now on screen all month, adding a role to
it when a warning arrives would only be promoting a node that is already there,
and the warning would be silent. The region is polite rather than assertive, and
a standing figure puts nothing in it, so nothing is read out on a poll that only
moves the number.
Closing a notice hides it per dismissal key, not forever. A "low" state is a band
the resource sits in the whole way down, so keying on the kind alone would let one
close hide the last warning until the resource emptied. Both low states carry a
stepped key instead, from balanceDismissStep and freeDismissStep, so a close
holds for the step it was made in and the next step down asks again. The free
grant's steps are 20%, 10% and 5% remaining, and 20% is also the band's own edge,
so a task that runs from healthy into the band crosses a mark like any other.
That is what lets usageTransitions report a crossing mid-task rather than only
reporting the tokens being gone. Every dismissal is forgotten once usage is
healthy, and a standing figure never counts as something to forget.
Closing the free warning steps down to the standing figure, never to nothing. The
close button means stop shouting, not hide the number, and 20% left is where the
number is worth most. The warning carries that figure with it as whenDismissed,
so one place decides what the bar says at rest.
The free grant's 20% line is the console's 80%-used line read from the other side.
FREE_TOKENS_LOW_FRACTION is the one place it is written, and Settings reads the
same constant, so the meter's warning tint cannot drift from the bar.
Money moves in the console, never here. Each action opens a console URL through
usageActionUrl. A billing owner lands on the form itself: add credits for "Add
funds", the automatic tab for "Set up auto top up". Anyone else gets the billing
page, because every wallet control the console offers is owner-only and a member
following a deep link would reach a dialog they cannot submit.
Settings carries the same figures under Usage, on desktop and on the hosted web build alike: the free grant with its reset date, the balance, the spend for the period and auto top up state. The web nav drops the sections a hosted user cannot act on, and Usage is not one of them. It reads the route both hosts already call and every control opens the console in a browser, so hiding it left a hosted free user with nowhere to see the grant at all.
To see the notices without an account near its limits, run npm run dev:renderer
and open /usage-bar-fixture.html. It renders every state from the real
deriveComposerWarning, so the copy on the page is the copy a user sees. Add
?theme=dark for the dark pass. Each case carries an id off its label, so a
screenshot run can crop to one state rather than a page too tall to read.
A task that stops on a billing limit gets a card in its timeline, and the card names the limit that fired. The drained-wallet card (token_limit) reads the same usage view through freeAllowanceState in lib/usageWarnings.js, and stops speaking from it once that view shows a wallet that can pay (a balance isBalanceEmpty does not flag): the card stays in the task, and the view describes the account as it is now. A view with no balance, which cowork-server sends when its wallet read fails, leaves the stop standing. With free allowance left, it says the priced model is what stopped and offers Switch to MindsHub Air. With the allowance spent, it names both resources and the refill time, in the sentence the spent-allowance card uses for the same state (allowanceStopCopy). It keeps the fixed balance copy, with Add funds only, when the view gives it nothing to go on (usage unreachable, no grant, an uncapped grant, or no usable time), when there is no Switch to offer (the task already runs on MindsHub Air, Air is not offered or is locked, or there is no message to resend), and when the wallet can pay again, as it can after a top up. The spent-allowance card (included_allowance_exhausted) names the refill time the gate sent on the failure, on a desktop or a hosted turn. When the failure carries none, as a hosted turn from an older cowork-server does, the card takes the refill time from the usage view. With no usable time from either, it offers funds alone and promises no refill. For an org with no free grant (the usage view's freeTokens.limit is 0, which cowork-server sends when auth reports free_grant_eligible: false), freeAllowanceState answers no_grant and the card shows the console's no-grant sentence (NO_FREE_GRANT_SENTENCE) with no refill time, whatever time the gate sent. free_serving_paused has its own card: auth's daily spend fuse has paused free MindsHub Air for every org that cannot pay, which is not the user's allowance, so the card says so and names when it lifts, from the reset time on the failure. Without one, as on a hosted turn from an older cowork-server, it says the pause lasts until the daily budget resets. model_restricted is not a billing stop but shares the page: an org admin's model rule refused the model, so the card names the model and an admin, and offers Open Settings only.
To see those cards, open /billing-stop-fixture.html under the same dev server. It renders the drained-wallet card in each usage state and after a top up, the spent-allowance card with the gate's refill time, with the usage view's, with no usable time at all, and for an org with no grant, the paused card with and without a time, the restricted-model card with and without a model name, and the connect-a-provider card, all from the components ChatView renders. ?theme=dark works the same way, and each case has a fixed id for cropping.
Settings > Agent tests each configured provider through the sidecar's
/settings/test-providers. For a failed MindsHub probe the sidecar adds
providerStatusReasons, the gateway's own reason per provider type
({ code, resetAt }), and only for a response from the configured MindsHub host.
mindsProbeNotice in lib/providerStatus.js turns it into the notice under each
role row: no credits (wallet_empty), the spent-allowance copy shared with the
card (included_allowance_exhausted), the paused copy shared with the card
(the daily fuse), a muted slow-down line (rate_limited), or a muted
billing-outage line (policy_unavailable). The LLM Providers row names the same
stop instead of calling every 429 "Rate limited". A sidecar that sends the map
but names no reason for a failed probe, such as an upstream vendor's 429 that
the gateway relays without its own reason, gets the generic "failed its last
test" warning and no billing notice. Only a sidecar too old to send the map at
all keeps the old check, where any 402 or 429 in the detail reads as no credits.
The map lives in the settings blob beside providerStatusDetails: it is left
out of the Save button's dirty check, never written back, cleared for a provider
whose key is edited, and replaced per provider type on every test (a tested type
the sidecar named no reason for is held as null, which is how the notice tells
it from a type an old sidecar never classified). To see the notices, the Providers row
copy, the restricted hint and an open picker with a restricted row, open
/settings-agent-fixture.html under the same dev server; ?theme=dark and the
per-case ids work the same way.
MindsHub Cowork updates three independently-versioned pieces, each through its own mechanism, so most updates land without the user reinstalling anything:
- UI (React renderer) and Server (Python
cowork-serversidecar) update fully over-the-air — no new.dmg/.exeneeded. These two are coupled and auto-apply together at boot (server first, then UI). - Shell (the Electron app binary — everything in
src/main/and the preload) can't hot-update itself, but on stable/prod it no longer requires a hand reinstall: anelectron-updaterbackground download installs the new build on the next relaunch (ENG-850). A manually-downloaded installer remains the fallback (ENG-849). The shell is independent of the UI/server pair and always needs a restart to take effect.
That coupling split is why the user never sees a single combined "3 updates" prompt — the seamless UI/server pair and the restart-required shell are different surfaces. For when updates apply (boot vs. periodic checks, the server-down recovery exception, the UI_UPDATE_MODE escape hatch) and what the user sees for each combination of pending updates, see docs/update-behavior.md.
UI OTA is gated by build kind (ENG-670): enabled in prod builds only — stable, preview, and dev builds always run their bundled renderer so testers see the branch under test.
The server updater detects how cowork-server was installed (from the tool venv's direct_url.json) and updates accordingly:
- git install — re-pulls the configured branch/tag HEAD for
cowork-serverandanton(trigger: changed remote commit SHA viagit ls-remote) - PyPI install — version comparison against PyPI, then
uv tool install --upgrade
After an upgrade the server is restarted and probed via /health; on failure the previous version is reinstalled automatically, and the rollback itself is health-verified (a still-broken rollback raises a critical notification rather than being reported as recovered). If the server is down, an available server update is applied immediately regardless of update mode — recovery, not routine maintenance. Set COWORK_SERVER_DISABLE_AUTOUPDATE=1 to opt out.
See src/main/server-updater.ts for the implementation.
The React UI updates via a separate public repo: mindsdb/antontron-releases. This avoids baking GitHub tokens into the app.
┌─────────────────────────────────────┐ ┌──────────────────────────────────┐
│ mindsdb/cowork (PRIVATE) │ │ mindsdb/antontron-releases │
│ │ │ (PUBLIC) │
│ source code lives here │ │ │
│ │ push │ GitHub Releases: │
│ .github/workflows/publish-ui.yml ──┼───────▶│ ui-v2.26.7.16.1/…tar.gz │
│ │ │ │
│ │ │ GitHub Pages (gh-pages branch): │
│ │ │ latest.json │
└─────────────────────────────────────┘ └──────────────────────────────────┘
▲
│ HTTPS (no auth)
│
┌────────────┴─────────────┐
│ MindsHub Cowork app │
│ (every user's machine) │
└──────────────────────────┘
How it works:
- Code is merged to
main— every push tomainruns the prod pipeline (prod-build-deploy.yml), whoseauto-releasejob computes a CalVer version (e.g.2.26.7.16.1) via the sharedcalver-release.ymlreusable in mindsdb/github-actions, tags it, builds the prod installer, and callspublish-uiwith that exact version, so the UI bundle and the prod app installer always publish the same version - The
publish-uiworkflow builds the renderer (with the version baked into__APP_VERSION__) and creates a.tar.gzbundle with a SHA-256 checksum - Using a
RELEASES_TOKEN, it pushes the bundle as a GitHub Release and updateslatest.jsonon GitHub Pages — both on the publicantontron-releasesrepo - The app checks
latest.jsonat launch and every 4 hours (static file, no auth, no API rate limits) - An update is taken only if it passes the safety gates: strictly newer than the installed UI (no downgrades), SHA-256 verified, the server-first coupling held (a failed server update defers the UI), and any declared
min_server_versionfloor satisfied - The update is applied automatically at boot — the UI reloads silently, no user choice involved. There is no manual/auto setting in Settings anymore (ENG-858);
UI_UPDATE_MODEin~/.anton/.envremains as an env-only support/QA escape hatch, not a user-facing preference. A mid-session periodic check (every 4h) still only surfaces a banner and never auto-applies, so a long-running session can always see and apply an update without an unplanned reload. A freshly-swapped bundle must finish loading within 15s or it is rolled back and quarantined. See docs/update-behavior.md for the full timing rules.
The Electron shell (src/main/, preload, native deps) can't hot-swap itself while running, so it updates one of two ways:
- Automatic (ENG-850) — packaged
stableandprodbuilds carry a channel-specificelectron-updaterfeed underdownloads.mindshub.ai/mindshub-cowork/updates/. It checks at boot and every 4h — and every 30 minutes once a build is downloaded and waiting for a restart, so the pending installer can't go stale while the feed moves on — downloads the new build in the background, and installs it on the next relaunch (auto mode installs on a normal quit; the quit path first drains any in-flight UI/server apply so the two can't overlap). Enabled by default on both stable and prod (stable led the rollout ring);SHELL_AUTO_UPDATE_ENABLED=falseis the emergency kill switch for either ring.preview/devfail closed. A downloaded target is persisted so the next launch can detect and report a shell update that didn't actually apply. Signature/checksum failures are terminal for auto-update and fall back to the manual path. - Manual notice (ENG-849) — a
prod-only fallback (also shown when auto-update is disabled or has failed): the poll compares the installed shell CalVer againstshellVersioninlatest.jsonand, if newer, shows a dismissible "New version available — Download" banner (per-version dismissal) linking to the installer ondownloads.mindshub.ai. Detection only — it never downloads or installs.
Both surface in the sidebar and in Settings → Updates. See src/main/shell-auto-update-runtime.ts (auto-update state machine) and checkForShellUpdate() in src/main/updater.ts (manual notice).
| Trigger | When | Version |
|---|---|---|
Push to main (normal path) |
Every merge — the release train's auto-release calls publish-ui |
Canonical CalVer, identical to the prod installer (e.g. 2.26.7.16.1) |
| Manual dispatch | Actions UI → Run workflow — re-publish/backfill | Entered version, or derived from git describe if empty |
| Tag push | git tag ui-v2.26.7.16.2 && git push origin ui-v2.26.7.16.2 — UI-only release |
From the tag |
The workflow checks for duplicate versions and skips if already published. Because the client refuses downgrades, un-shipping a bad bundle means publishing a newer fixed version, not re-pointing latest.json at an older one.
- Manifest: https://mindsdb.github.io/antontron-releases/latest.json
- Release: https://github.com/mindsdb/antontron-releases/releases
- In the app: Settings → Updates shows App, UI, and Server versions
- Every UI bundle is integrity-checked with SHA-256 before extraction
- Checksum mismatch → update discarded, app loads last known good UI
- Previous UI version kept on disk for automatic rollback; a bundle that fails its post-swap load check is rolled back and quarantined (never re-applied)
- Cache slots carry versioned provenance (
.ota-meta.json) and are served only when strictly newer than the bundled renderer — a stale or legacy cache can never downgrade the UI - OTA runs only in
prodbuilds; the gate fails safe to OFF if the packaged build kind is missing or unrecognized - All downloads over HTTPS
RELEASES_TOKENonly has write access to the public releases repo — source code is never exposed
App starts
├─ If DEV_MODE is set → load Vite dev server or bundled renderer, skip OTA
├─ Load cached UI (instant, no network needed)
│ └─ Served only if: OTA enabled (prod build) + valid provenance
│ + strictly newer than bundled + server-compat floor verified;
│ otherwise the bundled renderer loads
├─ Start cowork-server (spawn process, poll /health while the child lives)
└─ After renderer loads: boot update check (UI manifest + server, in parallel)
├─ apply now (server first, then UI, health-checked reload) — unless the
│ UI_UPDATE_MODE=manual escape hatch is set (support/QA only), then banner only
└─ then re-check every 4h (banner only, never auto-applies)
The app never blocks on a network request — it always loads immediately from cache or bundled files.
{userData}/ui-cache/
version.json # { "version": "1.2.0" }
current/ # Active renderer bundle
previous/ # Rollback copy
On GitHub (mindsdb/antontron-releases):
gh-pages branch:
latest.json # { "version": "1.2.0", "url": "...", "sha256": "..." }
GitHub Releases:
ui-v1.2.0/
ui-bundle.tar.gz # The renderer build output
This section applies to the packaged Electron app (macOS
.pkg/ Windows.exe). Not relevant for local development or Docker deployments.
The single source of truth for the app version is package.json ("version").
- Open a PR that bumps
"version"inpackage.json(e.g.2.0.5→2.0.6). - Merge to
main. .github/workflows/prod-build-deploy.ymlautomatically creates the git tag and GitHub release, then cuts the installers from it in the same run viabuild-installers.yml, which builds, signs, and uploads them to S3.
Don't:
- Create GitHub releases manually — the
v*tag namespace is locked via a repo ruleset. - Push
v*tags directly — same protection applies. - Edit
"version"inpackage.jsonoutside a dedicated bump PR — keep version bumps small and reviewable.
Anything under .github/ is owned by @mindsdb/devops via CODEOWNERS. PRs touching workflows require their review.
For hotfixes or out-of-band releases, coordinate with @mindsdb/devops to bypass the tag ruleset. The prod upload job still verifies package.json version matches the release tag.
# macOS — unsigned DMG (universal: x64 + arm64)
npm run dist:mac
# macOS — .app only, no DMG (faster, unsigned)
npm run pack
# Windows — NSIS installer (x64)
npm run dist:winPrerequisites: Node.js 18+, npm. For signed builds: Apple Developer certificates (macOS) or EV code signing certificate (Windows).
When working in the minds-platform superproject, use make pack-local instead of npm run pack. It installs cowork-server from the local backend/core_api/ directory (no push required), builds to /tmp to avoid the iCloud re-tagging codesign failure, then copies the result to frontend/release/mac-arm64/.
# from the minds-platform root:
make pack-local
# launch the built app with local server (auto-update disabled):
COWORK_SERVER_DISABLE_AUTOUPDATE=1 open "frontend/release/mac-arm64/MindsHub Cowork.app"iCloud builds — If the repo lives under
~/Documents(iCloud Drive),npm run packfails withresource fork, Finder information, or similar detritus not allowedduring codesign. Build to/tmpmanually:PATH="/opt/homebrew/opt/node@20/bin:$PATH" \ npx electron-builder --mac --arm64 --config.directories.output=/tmp/minds-build cp -R /tmp/minds-build/mac-arm64 release/
macOS Code Signing + Notarization
You need two certificates:
- Developer ID Application — signs the app binary
- Developer ID Installer — signs the DMG/pkg (optional but recommended)
security find-identity -v -p codesigning
# Should show: "Developer ID Application: Your Org (TEAMID)"# Option A: Apple ID + app-specific password
export APPLE_ID="your@email.com"
export APPLE_APP_SPECIFIC_PASSWORD="xxxx-xxxx-xxxx-xxxx" # Generate at appleid.apple.com
export APPLE_TEAM_ID="YOUR_TEAM_ID"
# Option B: API key (recommended for CI)
export APPLE_API_KEY_ID="XXXXXXXXXX"
export APPLE_API_KEY_ISSUER="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
export APPLE_API_KEY="/path/to/AuthKey_XXXXXXXXXX.p8"npm run dist:mac
# electron-builder will: sign -> notarize -> staple -> create DMGThe electron-builder.yml config and scripts/notarize.js hook are already included in this repo. The hardened-runtime entitlements (build/entitlements.mac.plist) are required because Electron uses JIT and dynamic linking.
codesign -dv --verbose=4 "release/mac-universal/MindsHub Cowork.app"
xcrun stapler validate "release/MindsHub Cowork-0.1.0-universal.dmg"Windows Code Signing
export CSC_LINK="/path/to/certificate.pfx"
export CSC_KEY_PASSWORD="your-password"
npm run dist:winSee scripts/azure-sign.js for the signing hook configuration.
$cert = New-SelfSignedCertificate -Subject "CN=Cowork Dev" -Type CodeSigningCert -CertStoreLocation Cert:\CurrentUser\My
Export-PfxCertificate -Cert $cert -FilePath cowork-dev.pfx -Password (ConvertTo-SecureString -String "password" -Force -AsPlainText)Self-signed apps trigger SmartScreen warnings. Only EV certs or Azure Trusted Signing build SmartScreen reputation.
Relevant for maintainers shipping desktop releases.
Installers are built on GitHub-hosted runners (required for Apple notarization and SSL.com signing) and uploaded to S3 from the self-hosted mdb-prod pod.
| Flavor | Trigger | S3 destination |
|---|---|---|
| preview | PR with signed-macos-pkg or signed-windows-ev label |
s3://anton-installer/mindshub-cowork/{mac,windows}/previews/ |
| stable | Push to staging |
s3://anton-installer/mindshub-cowork/{mac,windows}/snapshots/ + mindshub-cowork-staging.{pkg,exe} + staging.json |
| prod | Push to main (via the CalVer release) |
s3://anton-installer/mindshub-cowork/{mac,windows}/mindshub-cowork-{version}.{pkg,exe} + mindshub-cowork-latest.{pkg,exe} + latest.json |
The bucket is anton-installer in us-east-1. It is private — no public reads, no public ACLs. Everything is served through CloudFront. AWS credentials come from the mdb-prod pod's IAM role (not GitHub secrets). The role has s3:GetObject, s3:PutObject, s3:ListBucket and s3:DeleteObject on arn:aws:s3:::anton-installer and arn:aws:s3:::anton-installer/*, granted by the AllowS3AntonInstallerAccess statement in templates/eks-oidc-roles/github-runner.tf in the mindsdb/terraform repo.
s3://anton-installer/
mindshub-cowork/
mac/
latest.json # prod — manifest: version, url, size, sha256
staging.json # stable — the same, for the staging ring
mindshub-cowork-{version}.pkg # prod — versioned, written once
mindshub-cowork-latest.pkg # prod — alias, rewritten every release
mindshub-cowork-staging.pkg # stable — alias, rewritten every staging push
previews/mindshub-cowork-{version}-preview-{sha}.pkg
snapshots/mindshub-cowork-{version}-stable-{sha}.pkg
windows/
latest.json
staging.json
mindshub-cowork-{version}.exe
mindshub-cowork-latest.exe
mindshub-cowork-staging.exe
previews/mindshub-cowork-{version}-preview-{sha}.exe
snapshots/mindshub-cowork-{version}-stable-{sha}.exe
Each manifest is named after the alias it supersedes, so a consumer already reading -latest or -staging knows which one is its own. Preview builds get no manifest: they are per-pull-request, and nothing should be advertising one.
A prod version's bytes are published once. If a versioned key already exists with different bytes (rebuilding a version re-signs it, and signing stamps a timestamp), the release fails rather than replacing them. Cut a new version instead of republishing one. The -latest and -staging aliases are then written as server-side copies of that key, so the alias always serves the exact build the manifest names.
That rule is prod's, because prod is where a version is cut once and its URL is advertised to users. Previews and stable snapshots overwrite. Both are named after a commit rather than a release and get rebuilt whenever anyone re-runs the job, so create-only would answer an ordinary re-run with "cut a new version" for a key that has no version to cut. They carry the 60-second alias TTL instead of immutable, since advertising a key as immutable while overwriting it is what strands a stale copy at the edge.
Wherever create-only does apply, only a genuine 404 from S3 counts as "not published yet"; a 403, a throttle or an expired token stops the release rather than being read as absence. The same rule governs the updater payloads under updates/, which are immutable in both channels.
Object headers are set at publish time, because CloudFront otherwise applies its own one-hour default to everything:
| Object | Cache-Control |
Why |
|---|---|---|
| Prod versioned installers | public, max-age=31536000, immutable |
The bytes never change, so a resume can trust a validator that never moves |
-latest / -staging aliases |
public, max-age=60 |
Rewritten every release; a minute bounds how long a stale edge copy outlives one |
previews/ and snapshots/ builds |
public, max-age=60 |
Overwritten on a re-run, so immutable would strand the previous bytes at the edge |
updates/ payloads and blockmaps |
public,max-age=31536000,immutable |
Named in a channel manifest by hash; the bytes behind that hash never change |
latest.json / staging.json |
no-cache, no-store, must-revalidate |
A cached manifest would hide a release entirely |
Installers also carry Content-Type: application/octet-stream and Content-Disposition: attachment; filename="mindshub-cowork-{version}.{pkg,exe}", so a file saved from the alias URL is still named after the version it actually is.
No sidecar .sha256 files are published. The checksum lives in latest.json, and OS-level signature verification (Apple notarization, SSL.com EV) remains the tamper guarantee.
Lifecycle tip: set bucket lifecycle rules to auto-expire objects under
previews/(e.g. 14 days) andsnapshots/(e.g. 60 days) to keep costs bounded. Prod objects have no expiration. The bucket has noabort_incomplete_multipart_uploadrule today, so a failed upload leaves billable orphaned parts.
End users never hit S3 directly. The anton-installer bucket is fronted by a CloudFront distribution aliased to https://downloads.mindshub.ai (the legacy domain downloads.mindsdb.com also continues to work during the transition).
Start from the manifest, not the alias. latest.json names the immutable URL for the current release and the checksum to verify it against, which is the only combination that survives an interrupted download:
$ curl -sS https://downloads.mindshub.ai/mindshub-cowork/mac/latest.json
{
"version": "2.26.8.10.1",
"key": "mindshub-cowork/mac/mindshub-cowork-2.26.8.10.1.pkg",
"url": "https://downloads.mindshub.ai/mindshub-cowork/mac/mindshub-cowork-2.26.8.10.1.pkg",
"size_bytes": 220393119,
"sha256": "…",
"published_at": "2026-08-12T00:22:46.681Z"
}The -latest aliases stay for the consumers that hardcode them, and still work:
- macOS: https://downloads.mindshub.ai/mindshub-cowork/mac/mindshub-cowork-latest.pkg
- Windows: https://downloads.mindshub.ai/mindshub-cowork/windows/mindshub-cowork-latest.exe
The difference matters mid-download. An alias is rewritten on every release, so a transfer interrupted across one resumes with a validator that no longer matches and gets the whole body again instead of the tail. A versioned URL is written once, so the resume succeeds, and the manifest's sha256 is how a truncated file gets told apart from a good one.
Infrastructure:
- CloudFront + ACM + S3 OAC live in
terraform/newprod/us-east-1/anton/cloudfront.tf, which also defines the bucket policy / public-access-block that keep the bucket private and reachable only via CloudFront's Origin Access Control. - The bucket resource is in
terraform/newprod/us-east-1/anton/s3.tf. - The CloudFront domain name is published via
terraform/newprod/us-east-1/anton/outputs.tf(cloudfront_downloads_domain_name) and consumed by the Cloudflare stack. - DNS (
downloads.mindshub.aiCNAME + ACM validation records) is managed interraform/newprod/global/cloudflare/downloads.mindshub.ai-domain.tf. Legacy DNS (downloads.mindsdb.com) is indownloads.mindsdb.com-domain.tf.
CloudFront behavior:
- Path mapping is 1:1 — the S3 key
mindshub-cowork/mac/mindshub-cowork-latest.pkgis reachable athttps://downloads.mindshub.ai/mindshub-cowork/mac/mindshub-cowork-latest.pkg. - Viewer-protocol policy is
redirect-to-https. GET /→ 302 redirect tohttps://mindshub.aivia thedownloads-root-redirectCloudFront Function (viewer-request).GET /<missing key>(S3 403/404) →/redirect.html, 665 bytes of meta-refresh HTML pointing athttps://mindshub.ai, served under a404. It answered200until ENG-1432 corrected the twocustom_error_responseblocks, which is how a missing manifest could pass a status check.- Cache TTL: min 0, default 1 hour, max 24 hours. The default applies only to objects whose origin sends no
Cache-Control, and min 0 is what letsno-storeonlatest.jsonbe honoured. The 24-hour max caps edge retention, somax-age=31536000on a versioned key is a year to the browser and a day at the edge. - Compression is enabled, but no installer content type is on CloudFront's compressible list. No query strings or cookies are forwarded, so a
?cachebust=suffix does nothing here.
Check these URLs by their bytes, not their status. A correct status still cannot tell a current object from a stale one, which is the failure an immutable key is most exposed to. And
curl --retryfires only on a timeout or a 408/429/5xx, so it does not cover the case that actually needs waiting: a key created moments ago, shadowed by an edge that cached the404for it. Both checks in the release parse the body and compare a checksum, and wait in an explicit loop.
Cache invalidations: the aliases carry
max-age=60, so a stale edge copy expires in a minute and a release needs no invalidation to become visible. Versioned URLs are immutable and never need one. Nothing in the release calls for an invalidation, though themdb-prodrunner does holdcloudfront:CreateInvalidationon*already, through the inline sam-deploy policy attached to the same role.
Two things watch this path. upload-installer-to-s3.yml verifies its own work before the release goes green: it re-reads every uploaded object from S3 and compares checksums, then fetches the manifest over the CDN, checks its sha256 against the installer the run built, and asserts the versioned URL answers a Range request with a 206 and a stable ETag. release-smoke.yml then downloads the result in a real Chromium, the way a user does, and runs nightly to catch a key that was correct at publish time and has since drifted. The release runs it against the prod channel only, passing the version it just tagged: staging.json is written by a push to staging, so asserting it from the prod pipeline would fail a release that worked, and without the version the suite would pass just as happily against the manifest the previous release left behind. The nightly run takes both channels and pins neither.
| Workflow | Trigger | Purpose |
|---|---|---|
dev-build-deploy.yml |
Pull request | Tests, PR env, and label-gated preview installers |
staging-build-deploy.yml |
Push to staging |
Tests, staging image + rollout, stable installers |
prod-build-deploy.yml |
Push to main |
Tests, prod image, CalVer tag + release, prod installers, OTA UI bundle |
build-deploy.yml |
Called | Build + push the web SPA image, then roll it out |
build-installers.yml |
Called | Both platforms' installers: build, sign, upload |
pipeline-watchdog.yml |
Scheduled | Alerts on runs that never started (startup_failure) |
build-macos-pkg.yml |
Called | Build + sign + notarize .pkg |
build-windows-installer.yml |
Called | Build + sign .exe |
upload-installer-to-s3.yml |
Called | Publish to S3, write the updater feed and latest.json, verify both over the CDN |
release-smoke.yml |
Called after a prod release / nightly | Download the published installers in a real Chromium and check them |
publish-ui.yml |
Push to main / ui-v* tag / manual |
OTA UI bundle publish |
Apple signing: APPLE_DEV_ID_APP_CERT_B64, APPLE_DEV_ID_APP_CERT_PASSWORD, APPLE_DEV_ID_INSTALLER_CERT_B64, APPLE_DEV_ID_INSTALLER_CERT_PASSWORD, APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID, APPLE_INSTALLER_IDENTITY
Windows signing: SSL_USERNAME, SSL_PASSWORD, SSL_CREDENTIAL_ID, SSL_TOTP_SECRET
OTA UI publishing: RELEASES_TOKEN (fine-grained PAT scoped to mindsdb/antontron-releases)
No AWS secrets. The upload job runs on
mdb-prodand picks up AWS credentials from the pod's IAM role. The role needss3:GetObjectands3:PutObjectonarn:aws:s3:::anton-installer/*—GetObjectas well asPutObject, because the publish path reads a key back to check whether it already holds these bytes, and copies the versioned key onto the alias server-side.
This section covers the one-time setup for publish-ui.yml only — it's independent of the installer flow above.
- Create
mindsdb/antontron-releasesas a public repo. It only holds release assets andlatest.json— no source code. - Create the
RELEASES_TOKEN:- GitHub → Settings → Developer settings → Fine-grained tokens
- Name:
antontron-releases-deploy - Repository access: only
mindsdb/antontron-releases - Permissions: Contents (read/write), Metadata (read)
- Save the token as
RELEASES_TOKENin the source repo's Settings → Secrets → Actions.
- Enable GitHub Pages on
antontron-releases: Settings → Pages → Source "Deploy from a branch" → Branchgh-pages// (root). Thegh-pagesbranch is created automatically by the first workflow run. - Verify with:
curl https://mindsdb.github.io/antontron-releases/latest.jsonnode scripts/generate-icon.jsSource SVG is in assets/icon.svg. The script renders to PNG then creates .icns (macOS) via sips + iconutil. Windows .ico is auto-generated by electron-builder.
| Variable | Source | Purpose |
|---|---|---|
ANTON_ANTHROPIC_API_KEY |
Onboarding | Anthropic API key |
ANTON_OPENAI_API_KEY |
Onboarding | Minds/OpenAI-compatible API key |
ANTON_OPENAI_BASE_URL |
Onboarding | Minds server URL (as OpenAI base) |
ANTON_MINDS_API_KEY |
Minds panel | Minds API key for datasources |
ANTON_MINDS_URL |
Minds panel | Minds server URL |
ANTON_MINDS_MIND_NAME |
Minds panel | Selected mind name |
ANTON_MINDS_DATASOURCE |
Minds panel | Selected datasource |
ANTON_MINDS_DATASOURCE_ENGINE |
Minds panel | Datasource engine type |
ANTON_MINDS_SSL_VERIFY |
Minds panel | SSL cert verification (true/false) |
ANTON_PLANNING_MODEL |
Settings | Model for planning tasks |
ANTON_CODING_MODEL |
Settings | Model for coding tasks |
ANTON_MEMORY_MODE |
Settings | Memory mode (autopilot/copilot/off) |
ANTON_LANGFUSE_HEADERS |
Manual | Set to 1 to emit Langfuse-* headers on LLM calls |
DEV_MODE |
Manual | Renderer source override (live = Vite dev server, full = bundled only, unset = production with OTA) |
UI_UPDATE_MODE |
Manual | OTA UI update behavior (auto / manual; default auto). Env-only support/QA escape hatch — no Settings UI control (ENG-858) |
COWORK_SERVER_DISABLE_AUTOUPDATE |
Manual | Set to 1 to skip automatic server updates on launch |
COWORK_SERVER_PACKAGE |
Manual | Override install source with a literal uv spec (local path, custom URL, etc.) — wins over all channel/ref logic |
ANTON_PACKAGE |
Manual | Override anton install source (local path / uv spec); only honoured when COWORK_SERVER_PACKAGE is also set |
npm run build
ls dist/renderer/index.htmlThe packaged .app doesn't inherit shell PATH. Ensure cowork-server is installed: uv tool install cowork-server. Check that ~/.local/bin/cowork-server exists.
Expected, and it is waited out rather than killed. The first execution of a
freshly installed uv venv makes Windows Defender scan hundreds of MB of DLLs
and .pyd files, which can push the pre-uvicorn import phase past half a
minute; every launch after that is warm and takes a couple of seconds.
The start wait is progress-aware: /health is polled for as long as the child
process is alive, up to a hard cap (SERVER_START_CAP_MS, 180s, in
src/shared/server-status.ts). A sidecar that
dies is reported the moment it exits, with its exit code and stderr, rather
than after the full budget. The renderer's status poll is derived from the same
cap so the UI cannot declare the backend offline while the main process is
still waiting.
One cap covers every build and platform, deliberately. A dev-only allowance
means a start that passes locally can still be killed in a packaged build, so
the failure would only ever reproduce on a customer's machine. The cap is
therefore sized for the slowest case any build can hit (a dev source tree
building a fresh .venv on a cold cache) and everything else inherits it. It
costs nothing in the normal case: the wait ends the moment /health answers or
the process dies, so the cap only ever bounds a process that is genuinely still
alive and still starting.
Failures are classified rather than collapsed into one timeout message:
Diagnostics lastErrorKind |
Means |
|---|---|
spawn-error |
The OS refused to launch the program (EPERM from antivirus, ENOENT from a broken shim). Nothing ran, so there is no log. |
exited |
It ran and died during boot. Exit code + captured output are the evidence. |
timeout |
Still alive and still silent at the cap. Almost always a very slow first import. |
not-installed |
The backend or uv isn't on disk; re-run the installer. |
After a failed start the whole process tree is killed (taskkill /F /T on
Windows, process-group signal elsewhere), so a retry never collides with a
leftover python.exe holding the port. If something else still holds it, the
diagnostics name the PID.
Finding that PID on Windows means parsing netstat -ano, where the state
column is both translated and variable in length (ABHÖREN on German,
IN ASCOLTO on Italian, and a two-word state shifts every column after it).
The lookup reads none of it: a listening socket is the only TCP row with an
all-zero foreign address, and the PID is always the last column.
# Dev only
xattr -cr "/Applications/MindsHub Cowork.app"| Layer | Tech |
|---|---|
| Framework | Electron 34 |
| Renderer | React 19 + TypeScript + Vite 6 |
| Backend | FastAPI (Python, cowork-server via PyPI) |
| Markdown | marked 17 |
| Packaging | electron-builder 25 |
| Styling | Tailwind CSS + custom theme |
Built by MindsDB.