Team surfaces
Status: outline / RFC. Decisions are proposed, not final. Open questions at the bottom.
Related specs: 01-architecture.md, 03-pipelines.md, 06-deployment.md, 09-access-control.md, 14-collab-config.md. Related ADRs: 0013, 0014.
The team-product surface contract: an Electron desktop app and a web UI, sharing one renderer codebase, both thin clients of the control-plane API. Where the engine specs (01–12) describe the data plane, this spec defines how human operators configure and observe it from day 1 across browser and macOS / Windows / Linux.
Why a desktop and a web UI
Section titled “Why a desktop and a web UI”A team product on day 1 has two surfaces with non-overlapping responsibilities:
| Concern | Web UI | Desktop (Electron) |
|---|---|---|
| Browse / edit pipelines, schedules, sources | yes | yes |
| Run history, library browse, audit | yes | yes |
| OAuth source connection | browser flow | system browser via custom scheme |
| Local agent MCP access (Claude Code on this machine) | no | yes |
| Local DuckDB replica querying | no | yes |
| Run a pipeline on this machine | no | yes |
| ”Watch this folder and ingest changes” | no | yes |
| Tray status indicator | no | yes |
| Native notifications for run failures | partial (Web Notifications API) | yes |
Web is config + view. Desktop is config + view + local execution + local agent MCP. They share the configuration surface and diverge on machine-local capabilities.
One renderer, two shells
Section titled “One renderer, two shells”The renderer is a single SPA. The Electron app hosts it with a thin desktop-only adapter that exposes native capabilities. The browser feature-detects what’s available.
flowchart LR
subgraph Mono["pnpm monorepo"]
UI[packages/ui<br/>shared screens]
APIc[packages/api-client<br/>typed control-plane client]
Plat[packages/platform<br/>interface]
PlatW[packages/platform-browser]
PlatE[packages/platform-electron]
Web[apps/web<br/>Vite SPA]
Elec[apps/desktop<br/>Electron]
end
UI --> Web
UI --> Elec
APIc --> Web
APIc --> Elec
Plat --> Web
Plat --> Elec
PlatW --> Web
PlatE --> Elec
Platform abstraction
Section titled “Platform abstraction”packages/platform declares the TypeScript interface both shells implement:
interface Platform { // Always available notify(title: string, body: string): void; openExternal(url: string): void;
// null in browser, populated on desktop tray: TrayApi | null; localDaemon: { baseUrl: URL } | null; keychain: KeychainApi | null; loginItem: LoginItemApi | null; fsWatcher: FsWatcherApi | null; filePicker: FilePickerApi | null; protocol: ProtocolHandlerApi | null; // hakiri:// custom scheme}Browser implementation: most fields null, notify falls back to the Web Notifications API. Desktop implementation: full surface via Electron IPC over contextBridge. No if (window.electron) checks scattered through application code.
Code-reuse targets
Section titled “Code-reuse targets”- 100% of screen layouts, forms, tables, charts — shared in
packages/ui. - 100% of control-plane API calls — shared in
packages/api-client. - 0% of OS-integration code in renderer — all lives in
packages/platform-electronand is consumed via the abstraction. - Desktop-only surfaces (Tray menu, “Open data folder in Finder”, Spotlight integration) are additive — they never replace a shared screen with a desktop variant.
OS integration matrix
Section titled “OS integration matrix”| Concern | macOS | Windows | Linux |
|---|---|---|---|
| Run daemon at login | LaunchAgent (~/Library/LaunchAgents/dev.hakiri.daemon.plist) | Service via sc.exe or HKCU\Run | systemd user unit (~/.config/systemd/user/hakiri.service) |
| Tray icon | Electron Tray (no SwiftUI MenuBarExtra) | Electron Tray | Electron Tray (GNOME / KDE / Cinnamon — tested Ubuntu 22.04+) |
| Auto-start UI at login | app.setLoginItemSettings | app.setLoginItemSettings | .desktop in ~/.config/autostart |
| System notifications | macOS Notification | Toast notifications | libnotify |
| Secrets storage | safeStorage → Keychain | safeStorage → DPAPI | safeStorage → libsecret / kwallet |
| OAuth callback | Custom scheme hakiri:// via app.setAsDefaultProtocolClient | Same | Same (.desktop MimeType handler) |
| File watching | Chokidar → FSEvents | Chokidar → ReadDirectoryChangesW | Chokidar → inotify |
| Native file pickers | macOS dialog | Windows dialog | xdg-desktop-portal / GTK dialog |
| Code signing | Developer ID + notarization | EV cert + SmartScreen | AppImage signed; repo signing for .deb/.rpm |
| Distribution | Notarized .dmg, Homebrew Cask | Signed .exe (NSIS), winget | .AppImage, .deb, .rpm |
Daemon lifecycle — separate from Electron
Section titled “Daemon lifecycle — separate from Electron”The Rust daemon must outlive the UI. Closing the menubar shouldn’t stop ingestion. Pattern:
- Electron installer (
electron-builder) ships the Rust binary as a resource at install time. - On first launch, Electron’s main process installs the platform-appropriate auto-start unit — LaunchAgent / systemd user / Windows Service — pointing at a stable binary path:
- macOS:
~/Library/Application Support/Hakiri/bin/hakiri - Windows:
%LOCALAPPDATA%\Hakiri\bin\hakiri.exe - Linux:
~/.local/share/hakiri/bin/hakiri
- macOS:
- The Electron app talks to the daemon over
127.0.0.1:7700like any other HTTP client. - Uninstaller removes the auto-start unit and the binary.
Anti-pattern: spawning the Rust daemon as a child of the Electron main process. Coupling the daemon’s lifecycle to the UI means closing the window stops ingestion — wrong behavior for a scheduled-data tool. Same pattern as Docker Desktop’s com.docker.vmnetd and Tailscale’s tailscaled.
Two update channels
Section titled “Two update channels”| What | Channel | Tool |
|---|---|---|
| Electron UI shell | electron-updater | Squirrel.Mac (macOS), Squirrel.Windows, AppImageUpdate (Linux) |
| Rust daemon | Daemon self-update + atomic binary swap | Triggered by control plane; fallback to Electron-main-driven download |
Decoupled deliberately: a UI bug shouldn’t force daemon downtime, and a daemon hotfix shouldn’t require a UI redownload. Daemon updates atomically swap the binary at the stable path; auto-start units never point at a versioned path.
Identity, OAuth, and tokens
Section titled “Identity, OAuth, and tokens”End-to-end identity flows through the control-plane API. The token format is the same biscuit shape used for agent principals — see 09-access-control.md.
Web sign-in
Section titled “Web sign-in”- User clicks “Sign in” on
app.hakiri.dev(or self-hosted control-plane URL). - Standard
https://OAuth redirect to the team’s configured IdP (Google Workspace, GitHub, Microsoft Entra, …). - IdP redirects back to the control plane with
code. - Control plane exchanges, looks up team membership, issues a biscuit bound to
(user, team, web-session). - Biscuit stored in a secure HttpOnly cookie; refresh via OAuth on expiry.
Desktop sign-in
Section titled “Desktop sign-in”- User clicks “Sign in” in the Electron app.
- Electron main process calls
shell.openExternal(authUrl)— opens the system browser. - Provider redirects to
hakiri://oauth/callback?code=...&state=.... Thehakiri://scheme is registered on install viaapp.setAsDefaultProtocolClient. - OS delivers the URL back to Electron via
app.on('open-url')(macOS) orapp.on('second-instance')(Win/Linux). - Main process forwards
codeto the control plane, receives a biscuit bound to(user, team, device-id). - Biscuit stored in OS keychain via
safeStorage.
Source OAuth (GitHub, Notion, Google, …)
Section titled “Source OAuth (GitHub, Notion, Google, …)”Same pattern as user sign-in, with state parameter binding the auth flow to a specific pipeline. The control plane stores source tokens encrypted; daemons receive secret:// references and resolve at WASM-boundary time per 02-connectors.md — they never see raw tokens.
Why not embedded WebView OAuth. GitHub, Notion, and Google increasingly block embedded WebView OAuth flows. System browser via custom scheme is more secure (provider sees the user’s full browser context including 2FA) and works across all three desktop platforms.
Web UI specifics
Section titled “Web UI specifics”The web UI is a static SPA, hostable anywhere:
- fractalbox-hosted multi-tenant:
app.hakiri.dev(M1.5) - Self-hosted with the team’s
hakiri-control(M1) - Self-deployed on the team’s CF account alongside their control plane (M1)
The SPA itself is stateless — all state is in the control-plane API. There’s no “hosted vs self-hosted” forking in SPA code; only the control-plane URL differs.
Browser-only constraints
Section titled “Browser-only constraints”| Capability | Available? | Notes |
|---|---|---|
| Control-plane API access | yes | Bearer biscuit in Authorization header |
| Loro sync over WebSocket | yes | Standard WebSocket to control plane DO |
| DuckDB-WASM read of team bucket | yes | Signed R2 URLs scoped per token |
| Local daemon access | no | Browser cannot reliably reach 127.0.0.1:7700 (CORS, mixed-content; daemon may not be installed at all) |
| Local agent MCP | no | Agents on a user’s machine talk to that user’s local daemon directly |
| File picker for local files | partial | File System Access API (Chromium); fallback to upload |
| Notifications | yes (with user grant) | Web Notifications API |
| Background sync | partial | Service Worker can re-sync on reconnect |
The “no local daemon” rule means features like “Run on Alice’s Mac” surface as buttons in the web UI but execute via the control plane, which dispatches to Alice’s registered daemon if it’s online.
Realtime sync
Section titled “Realtime sync”Both shells maintain a single WebSocket to the team’s Durable Object on the control plane (CF substrate) or to the hakiri-control Rust daemon (self-hosted substrate). See 14-collab-config.md for the Loro editing protocol and ADR-0014 for the substrate choice.
Subscription topics on the connection:
| Topic | What it carries |
|---|---|
team.<id>.config | Loro doc sync messages (live config edits) |
team.<id>.runs | Run lifecycle events: started, progress, completed, failed |
team.<id>.presence | Awareness: cursor positions, “Bob is editing pipeline X” |
team.<id>.audit | Append-only audit events the user has read access to |
CF Durable Object hibernating WebSocket support keeps idle connections free.
Security hygiene
Section titled “Security hygiene”These are not optional for an Electron app shipping to teams:
contextIsolation: true,nodeIntegration: false,sandbox: truefor every renderer.- Strict CSP, no
unsafe-inline. Same CSP across both shells — the web shell sets it via response header; Electron sets it in the main process. - All OS/Node access flows through a typed
contextBridgepreload. NoipcRenderer.invokefrom app code. - Sign + notarize on macOS, code-sign on Windows (EV cert recommended for SmartScreen reputation). AppImage signed.
electron-updaterURLs over HTTPS, signature-verified before binary swap.- Pin Electron major version; track Chromium CVEs; ship patches within a week of upstream Chromium security releases.
- No remote code execution surface — the renderer cannot
eval()user input, cannot load remote scripts beyond CSP allowlist.
Distribution
Section titled “Distribution”| Platform | Channel | Notes |
|---|---|---|
| macOS | Notarized .dmg from releases.hakiri.dev, Homebrew Cask | App Store deferred — sandbox forbids LaunchAgent install |
| Windows | Signed .exe (NSIS) from releases.hakiri.dev, winget package | Microsoft Store deferred — sandbox blocks daemon install |
| Linux | .AppImage, .deb, .rpm, Flatpak (M2+) | AppImage portable; deb/rpm for repo integration |
| Web | app.hakiri.dev (fractalbox-hosted, M1.5) or bundled with hakiri-control | Static SPA, CDN-served |
Bundle reality
Section titled “Bundle reality”| Component | Compressed install |
|---|---|
| Electron app shell | 80–120 MB / platform |
Bundled hakiri-full daemon | ≤ 150 MB (Pillar 1) |
| Total per-platform install | ~200–270 MB |
Larger than a SwiftUI-native app, smaller than Docker Desktop (~600 MB), comparable to Slack / Notion / VS Code. The Electron bundle does not count against the headless hakiri-core (≤ 50 MB) and hakiri-full (≤ 150 MB) budgets — those bind only for the CLI / daemon distribution. The footprint pillar applies to the engine, not the UI.
What’s deliberately deferred
Section titled “What’s deliberately deferred”- App Store / Microsoft Store builds. Sandboxes break the LaunchAgent install pattern. A future store build could be a UI-only client pointing at a remote daemon, but it’s not day 1.
- Mobile (iOS / Android). A phone can’t host the daemon. A future mobile config-only client against the same API is plausible; defer to evidence of demand.
- Linux GUI polish parity. Ship Linux as functional. Document “best experience on macOS and Windows” until a Linux user pushes back hard.
- Real-time collaborative cursors. Layered later on the awareness channel (14-collab-config.md); not a v0 feature.
- In-app WIT/connector editing UI. Authoring stays in MCP/CLI per 02-connectors.md. The UI lists installed connectors with provenance.
- Native macOS polish (MenuBarExtra, App Intents / Shortcuts). Electron’s
Trayand protocol handlers cover the essentials; deeper macOS integration is a future native-host project, not a v0 detour.
Open questions
Section titled “Open questions”- Renderer framework. React (largest ecosystem, AI-coding-assistant-friendly), Svelte (smaller bundle, nicer DX), or SolidJS (fastest)? Leaning React for hiring and tooling support.
- UI library. Tailwind + Radix + shadcn/ui (dominant), Mantine (batteries included), or roll-our-own primitives? Leaning shadcn/ui.
- Local daemon discovery. Hardcoded
127.0.0.1:7700, or daemon registers a Bonjour/mDNS service? Hardcoded for v0; mDNS later if cross-machine LAN daemons become a feature. - Electron vs Tauri. Locked to Electron for v0 (decided 2026-05-12 — codebase sharing with the web SPA was the deciding factor). Reconsider if Tauri 2.0’s WebView story matures enough to share renderer code while shedding 80 MB.
- Self-hosted auth fallback. Air-gapped teams without an external IdP — local username/password as bootstrap, then biscuit issuance? Or require an IdP (Keycloak / Authentik) as a prerequisite? Leaning local-admin bootstrap with optional IdP federation.
- Auto-update opt-out for enterprise. Some IT departments require approved update windows. Provide
HAKIRI_UPDATER=manualenv to disable auto-update. - Spotlight / Quick Look integration on macOS. Index Parquet content into Core Spotlight so users find ingested data from
⌘-Space? Deferred; revisit when a non-trivial fraction of users querying via Spotlight is realistic. - Web UI offline behavior. Browser tab offline for an hour, then reconnects — does the Loro replay handle a stale tab gracefully? Needs a soak test during M1.