Skip to content

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 (0112) describe the data plane, this spec defines how human operators configure and observe it from day 1 across browser and macOS / Windows / Linux.

A team product on day 1 has two surfaces with non-overlapping responsibilities:

ConcernWeb UIDesktop (Electron)
Browse / edit pipelines, schedules, sourcesyesyes
Run history, library browse, audityesyes
OAuth source connectionbrowser flowsystem browser via custom scheme
Local agent MCP access (Claude Code on this machine)noyes
Local DuckDB replica queryingnoyes
Run a pipeline on this machinenoyes
”Watch this folder and ingest changes”noyes
Tray status indicatornoyes
Native notifications for run failurespartial (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.

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

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.

  • 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-electron and 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.
ConcernmacOSWindowsLinux
Run daemon at loginLaunchAgent (~/Library/LaunchAgents/dev.hakiri.daemon.plist)Service via sc.exe or HKCU\Runsystemd user unit (~/.config/systemd/user/hakiri.service)
Tray iconElectron Tray (no SwiftUI MenuBarExtra)Electron TrayElectron Tray (GNOME / KDE / Cinnamon — tested Ubuntu 22.04+)
Auto-start UI at loginapp.setLoginItemSettingsapp.setLoginItemSettings.desktop in ~/.config/autostart
System notificationsmacOS NotificationToast notificationslibnotify
Secrets storagesafeStorage → KeychainsafeStorage → DPAPIsafeStorage → libsecret / kwallet
OAuth callbackCustom scheme hakiri:// via app.setAsDefaultProtocolClientSameSame (.desktop MimeType handler)
File watchingChokidar → FSEventsChokidar → ReadDirectoryChangesWChokidar → inotify
Native file pickersmacOS dialogWindows dialogxdg-desktop-portal / GTK dialog
Code signingDeveloper ID + notarizationEV cert + SmartScreenAppImage signed; repo signing for .deb/.rpm
DistributionNotarized .dmg, Homebrew CaskSigned .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:

  1. Electron installer (electron-builder) ships the Rust binary as a resource at install time.
  2. 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
  3. The Electron app talks to the daemon over 127.0.0.1:7700 like any other HTTP client.
  4. 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.

WhatChannelTool
Electron UI shellelectron-updaterSquirrel.Mac (macOS), Squirrel.Windows, AppImageUpdate (Linux)
Rust daemonDaemon self-update + atomic binary swapTriggered 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.

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.

  1. User clicks “Sign in” on app.hakiri.dev (or self-hosted control-plane URL).
  2. Standard https:// OAuth redirect to the team’s configured IdP (Google Workspace, GitHub, Microsoft Entra, …).
  3. IdP redirects back to the control plane with code.
  4. Control plane exchanges, looks up team membership, issues a biscuit bound to (user, team, web-session).
  5. Biscuit stored in a secure HttpOnly cookie; refresh via OAuth on expiry.
  1. User clicks “Sign in” in the Electron app.
  2. Electron main process calls shell.openExternal(authUrl) — opens the system browser.
  3. Provider redirects to hakiri://oauth/callback?code=...&state=.... The hakiri:// scheme is registered on install via app.setAsDefaultProtocolClient.
  4. OS delivers the URL back to Electron via app.on('open-url') (macOS) or app.on('second-instance') (Win/Linux).
  5. Main process forwards code to the control plane, receives a biscuit bound to (user, team, device-id).
  6. 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.

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.

CapabilityAvailable?Notes
Control-plane API accessyesBearer biscuit in Authorization header
Loro sync over WebSocketyesStandard WebSocket to control plane DO
DuckDB-WASM read of team bucketyesSigned R2 URLs scoped per token
Local daemon accessnoBrowser cannot reliably reach 127.0.0.1:7700 (CORS, mixed-content; daemon may not be installed at all)
Local agent MCPnoAgents on a user’s machine talk to that user’s local daemon directly
File picker for local filespartialFile System Access API (Chromium); fallback to upload
Notificationsyes (with user grant)Web Notifications API
Background syncpartialService 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.

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:

TopicWhat it carries
team.<id>.configLoro doc sync messages (live config edits)
team.<id>.runsRun lifecycle events: started, progress, completed, failed
team.<id>.presenceAwareness: cursor positions, “Bob is editing pipeline X”
team.<id>.auditAppend-only audit events the user has read access to

CF Durable Object hibernating WebSocket support keeps idle connections free.

These are not optional for an Electron app shipping to teams:

  • contextIsolation: true, nodeIntegration: false, sandbox: true for 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 contextBridge preload. No ipcRenderer.invoke from app code.
  • Sign + notarize on macOS, code-sign on Windows (EV cert recommended for SmartScreen reputation). AppImage signed.
  • electron-updater URLs 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.
PlatformChannelNotes
macOSNotarized .dmg from releases.hakiri.dev, Homebrew CaskApp Store deferred — sandbox forbids LaunchAgent install
WindowsSigned .exe (NSIS) from releases.hakiri.dev, winget packageMicrosoft Store deferred — sandbox blocks daemon install
Linux.AppImage, .deb, .rpm, Flatpak (M2+)AppImage portable; deb/rpm for repo integration
Webapp.hakiri.dev (fractalbox-hosted, M1.5) or bundled with hakiri-controlStatic SPA, CDN-served
ComponentCompressed install
Electron app shell80–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.

  • 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 Tray and protocol handlers cover the essentials; deeper macOS integration is a future native-host project, not a v0 detour.
  • 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=manual env 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.