Celistra — Project Overview
A single document covering the why, the what, and the how. Updated 2026-04-22.
1. Vision
Every machine you own should be a remote-operable worker — without giving up ownership of the processes running on it.
Developers, ops people, hobbyists, and homelabbers already have more compute sitting idle (a second laptop, a Mac mini, a Linux box in the closet, a work server) than any cloud VM they'd rent. What they don't have is a clean way to drive those machines from a browser — run long jobs, tail logs, kill a stuck process — without SSHing from wherever they happen to be.
Celistra's vision is to turn that fleet into one pane: pair once, operate from anywhere, and never hand the keys (or the code) to a third party.
2. Mission
Ship the smallest, most trustworthy orchestration primitive that:
- Runs on your hardware. A single Go binary, ~20 MB, no system install, no root.
- Speaks a normal web protocol. REST + SSE + WebSocket. No proprietary agent bus.
- Assumes the browser is hostile until cryptographically paired with the daemon.
- Stays local-first. When you're on the LAN with your machine, traffic never touches the internet.
- Is readable. The entire daemon fits in one window. No hidden service workers, no k8s, no YAML.
3. The Problem
People who manage their own machines fall into three traps:
| Trap | What it looks like |
|---|---|
| The SSH diaspora | You have five machines, three ~/.ssh/configs, two port-forwards, a Tailscale mesh, and a notes app with passwords. Spawning a background job from a phone is impossible. |
| The rent-your-compute tax | Vercel / Railway / Render give you a pretty UI if you first push your code, pay for usage you could run at home for free, and accept the vendor lock-in. |
| The control-plane creep | Kubernetes, Nomad, Docker Swarm — all built for fleets of hundreds. For the 1-10 machine case they're more software than you're trying to run. |
Celistra sits in the gap: a dashboard that talks directly to daemons you trust, over a channel you control, with zero cloud workers between your browser and your box.
4. Concept
┌───────────────────┐ ┌──────────────────┐
│ Browser (App) │ ── Firebase ID token (Google SSO) ── │ Firebase Auth │
│ React / Zustand │ └──────────────────┘
└─────────┬─────────┘
│ pairing challenge (one-time 8-digit token)
│ REST / SSE / WebSocket — Authorization: Bearer <machine-secret>
│
┌───────────────┴─────────────────────────────────────────────┐
│ │
┌─┴───────────────┐ localhost.run SSH reverse tunnel ┌──────┴─────────────┐
│ 127.0.0.1:33120 │ ◀──── when NOT on same LAN │ Peer machines … │
│ celistrad │ (daemon speaks TCP outward) │ celistrad │
│ Go daemon │ │ (same pattern) │
└─────────────────┘ └─────────────────────┘
Two components, both small:
celistrad— a Go binary that runs on every machine you want to drive. It owns a system tray icon, a loopback HTTP server on:33120, a SQLite history DB, a secrets file, and a schedule runner.- The dashboard — a Vite/React app hosted on Firebase. It's a thin client. All state of record lives on the daemon.
The daemon is paired to a dashboard once, through a challenge-response handshake gated by a Firebase ID token. After pairing, everything is signed with a 256-bit machine secret that never leaves the machine.
5. Pain Points We Solved
5.1 "I can't reach my home server from anywhere"
celistrad --tunnel opens a reverse SSH tunnel to localhost.run and exposes the daemon at a public URL. No inbound firewall rules. No router config. When you're on the same LAN as the machine, the dashboard auto-detects the loopback path and skips the tunnel entirely (<1ms RTT).
5.2 "Every tool wants me to push my code first"
Celistra runs your code where it already lives. You paste a command, pick a working directory, and hit Spawn. The daemon execs it in a real PTY under your user account. No image build, no push, no dependency hell.
5.3 "Terminals in browsers are fake"
Every agent gets a real PTY via creack/pty. xterm.js on the browser side. TTY resize is wired through SIGWINCH on unix (SetWinsize) and the Windows console equivalent. Multiple dashboard tabs attach to the same PTY through a fanout hub — so you can open two tabs of the same agent and keystrokes/output stay in sync with a 64 KB replay buffer for late joiners.
5.4 "My background jobs die when they shouldn't"
autoRestart: true on an agent wires it through a restart supervisor: max 5 restarts per 60s rolling window. If a process keeps crashing, the supervisor trips the breaker and marks the agent restart_limit rather than fork-bombing the machine.
5.5 "I can never remember what ran last week"
Every spawn and exit gets written to a SQLite history DB (~/.celistra_history.db) with 30-day retention. Env values are redacted at insert time — the key is kept, the value is replaced with ***. If the DB goes corrupt, it gets renamed with a timestamp and a fresh one is created rather than the daemon silently breaking.
5.6 "Pairing my second machine was harder than the first"
The tray icon has a Pair this machine menu item. It mints an 8-digit one-time token (TTL 120s) and opens the dashboard with it. The browser POSTs the token back with your Firebase ID token. Daemon verifies both, hands back the machine secret, stores it in the browser. Total interaction: two clicks.
5.7 "I keep forgetting to put secrets into .env files"
~/.celistra_secrets.json holds named secrets (0600 permissions). Any agent env entry of the form KEY=@secret:NAME gets resolved at spawn time. The literal @secret:NAME sentinel is what's persisted on disk and in history — the secret value never touches SQLite or the network.
5.8 "I want this to run every hour"
Interval schedules live in ~/.celistra_schedules.json. The daemon ticks at 1 Hz and fires any due schedule through the normal /v1/agents/spawn endpoint, tagged Source=scheduled:<id> so it shows up in history and the sidebar like any other agent.
5.9 "Can my teammate drive my machine without me sharing a secret?"
/v1/machine/share lets the owner vouch for additional Firebase UIDs by writing them into Config.AllowedUIDs. Those UIDs can re-pair without going through the tray-approved challenge. Only the current owner can mutate the allow-list.
5.10 "Grey text on black is unreadable"
The dashboard uses a four-step contrast ladder (primary #FAFAFA → disabled #6B7280), all AA+ on the base surface. Ultra-light font weights are overridden to 400 because font-weight: 100 on dark backgrounds is essentially smudged graphite. Eyebrow labels switched from muted #7A7A7A 11 px to secondary #D4D4D4 12 px after direct user feedback.
6. Pain Points We Have NOT Solved (Yet)
6.1 Pro tunnel subdomain flaps
Free users and paying users both share localhost.run, so the subdomain changes every time the daemon restarts and any bookmark rots. The fix is either Cloudflare Tunnel (named) with a domain you own, or a proxy layer that maps <machineId>.celistra.dev → current tunnel URL, re-registering on tunnel flap. Tracking: ROADMAP §"In Progress".
6.2 Scheduled agents are interval-only
intervalSeconds: 3600 works; 0 9 * * MON-FRI does not. The right fix is swapping the ticker for robfig/cron/v3 and a small parser round-trip. The storage format already has a CronExpr slot ready.
6.3 No frontend UI for secrets or schedules yet
The daemon endpoints ship (/v1/secrets, /v1/schedules), and DaemonClient methods exist, but the dashboard has no modal that calls them. Users today write to the JSON files by hand. This is the single highest-leverage UX gap.
6.4 Agent dependencies
You can't say "start agent B once agent A is listening on port 8080." The fix is a simple pre-spawn probe (TCP connect / HTTP 200) inside the supervisor. The supervisor framework is already there; this is mostly wiring.
6.5 Linux daemon not published as a release binary
The macOS and Windows binaries are on GitHub Releases v1.0.0. Linux requires CGo + GTK3/libayatana-appindicator3 headers, which macOS Actions runners don't have. The daemon.yml workflow is in place to build it on ubuntu-latest, but we haven't tagged a release that triggers it.
6.6 No real observability
We show CPU% and RAM per agent, scraped with a naive ps call. There's no time-series store, no alert rule, no "page me when this agent has been down for 5 minutes." For the 1-10 machine case this is probably fine; for anyone running a real workload it's not.
6.7 Command policy is hardcoded
isSafeCommand in main.go is an allowlist/denylist literal — no per-machine override, no role-based policy. A homelab user who wants to run sudo systemctl restart nginx from a paired browser can't. The right fix is a per-daemon policy file, defaulting to today's rules, with an explicit --unrestricted flag for users who know what they're doing.
6.8 No multi-daemon coordination
Two daemons don't talk to each other. If you want to "run X on all my machines," the dashboard has to fan out the request. This is fine at the current scale; at ~20+ machines you'd want some gossip.
6.9 Mobile dashboard is unshipped
The Vite bundle is code-split and renders on mobile, but the sidebar + terminal layout are desktop-first. Touch scrolling in the PTY is broken. Mobile is a read-only experience today.
6.10 Billing is live (2026-04-22 relaunch)
useProStore is RevenueCat-backed. REVENUECAT_ENABLED in src/lib/revenuecat.ts is the master switch — flipping it to false short-circuits every method and treats every user as Pro (the pre-relaunch "free during launch" posture, kept as a kill switch for incidents). Active products: celistra_pro_monthly ($9) and celistra_team_monthly ($49). Legacy celistra_professional / celistra_enterprise ids are kept as deprecated aliases so grandfathered paid users keep the Pro entitlement.
7. Architecture
7.1 Daemon (celistrad/, Go)
| File | Purpose |
|---|---|
main.go | HTTP mux, agent supervisor, config load/save, SSE broker, command allowlist |
pty_hub.go | One PTY per agent, fanned out to N WebSocket clients; 64 KB replay ring |
attach.go + attach_resize_{unix,windows}.go | Cross-platform attach + TTY resize |
tray.go + tray_actions_{darwin,linux,windows}.go | System tray UI, platform-specific clipboard / open-terminal / prompts |
tunnel.go | Reverse SSH tunnel to localhost.run, reconnection, URL publication |
config.go | Config struct — machine secret, UID, AllowedUIDs, persisted as JSON |
history.go | SQLite store, insert-time env redaction, corruption auto-recovery, 30-day retention |
secrets.go | ~/.celistra_secrets.json CRUD + @secret:NAME resolver called from spawn |
schedules.go | Interval scheduler, 1 Hz tick, fires through /v1/agents/spawn |
share.go | Owner-only CRUD for Config.AllowedUIDs — multi-owner machine sharing |
dashboard_html.go | Secretless local dashboard served at GET / on loopback only |
icon.go | Tray icon bytes |
7.2 Dashboard (src/, React + Vite + Tailwind + Zustand)
Two Vite entrypoints, two bundles, one CSS:
main-landing.tsx→index.landing.html— marketing pages (/,/download,/terms,/privacy,/refund)main-app.tsx→index.app.html— the actual dashboard. Routes:/login,/signup,/dashboard,/auth-bridge. Routes are lazy-loaded so the initial bundle is ~183 KB gzip.TerminalView(xterm.js + addons, ~75 KB gzip) is lazy-loaded inside the dashboard only.
| File | Purpose |
|---|---|
store/useAuthStore.ts | Firebase Auth session, ID token refresh |
store/useCelistraStore.ts | Paired machines, agents, SSE connection, terminal state, search query |
store/useProStore.ts | RevenueCat entitlement — live ($9 Pro / $49 Team); short-circuits to free-for-all when REVENUECAT_ENABLED is off |
lib/rpc.ts | DaemonClient — typed wrapper over every daemon REST endpoint |
lib/firebase.ts | Firebase init, Google SSO, email+password |
lib/revenuecat.ts | RevenueCat init + product id map (PRODUCTS, DEPRECATED_PRODUCT_ALIASES) + REVENUECAT_ENABLED master switch |
components/Sidebar.tsx | Machine + agent tree, search, settings, history |
components/TerminalView.tsx | xterm.js wired to /v1/terminal WebSocket, native profile |
components/HistoryModal.tsx | 30-day SQLite history panel |
components/AgentSettingsModal.tsx | cwd, env, autoRestart, signal picker |
components/CommandPalette.tsx | ⌘K global command bar |
components/InspectorPanel.tsx | Per-agent metrics + metadata |
components/ConnectionBanner.tsx | SSE reconnection banner |
components/PaywallModal.tsx | Pro gate — mounted only when store is re-enabled |
7.3 Network Topology
┌────────────────────┐ ┌───────────────────┐ ┌──────────────────────┐
│ Browser │ │ Firebase Hosting │ │ Firebase Auth │
│ axy-celistra-app │◀──▶│ (static app) │ │ (Google SSO, email) │
│ │ └───────────────────┘ └──────────────────────┘
│ Bearer <secret> │
│ ↓ │
│ │ ┌──── local LAN ────────────┐
│ probes 127.0.0.1:33120 ─▶ celistrad (loopback) │
│ │ └──────────────────────────┘
│ │
│ fallback: tunnel ┼─────▶ <random>.localhost.run ──▶ celistrad (tunnel)
└────────────────────┘ TCP 22 reverse
The "probe loopback first" behavior means two paired dashboards sitting next to each other on the same Wi-Fi never route through the internet even though the pairing was established over the tunnel.
8. Features (at a glance)
| Capability | Where it lives |
|---|---|
| Firebase Google SSO + email/password | src/lib/firebase.ts, src/pages/Login.tsx |
| Tray-assisted pairing (8-digit token, 120s TTL) | celistrad/tray.go, celistrad/main.go :handleAuthSync |
| Loopback-first routing | src/store/useCelistraStore.ts :probeLocal |
| Reverse SSH tunnel | celistrad/tunnel.go |
| Real PTY + fanout | celistrad/pty_hub.go + components/TerminalView.tsx |
| Scrollback survives disconnect | PTY hub ring buffer, 64 KB default, --scrollback-kb N flag |
| Agent supervisor / autoRestart | celistrad/main.go :startAgentProcess |
| SQLite history, 30-day retention | celistrad/history.go |
| Env-mounted secrets | celistrad/secrets.go |
| Interval schedules | celistrad/schedules.go |
| Multi-owner machines | celistrad/share.go + Config.AllowedUIDs |
| Kill signal picker (HUP/INT/TERM/QUIT/KILL) | components/AgentSettingsModal.tsx |
| Sidebar search | components/Sidebar.tsx :filteredMachines |
| SSE exponential-backoff reconnect | store/useCelistraStore.ts |
| Agent rename propagated via SSE | agent_renamed event in main.go |
| Command allowlist | celistrad/main.go :isSafeCommand |
| Factory reset (dashboard-side) | store/useCelistraStore.ts :hardReset |
9. Flows (end to end)
9.1 First pairing
user tray daemon dashboard firebase
│ │ │ │ │
│ click Pair ──▶ │ │ │ │
│ │ mint 8-digit ──▶ │ │ │
│ │ open browser ───────────────────────▶ │ │
│ │ url contains ?t=XXXXXXXX │ │
│ │ │ │ Google SSO ────▶ │
│ │ │ │ ◀── id_token ── │
│ │ │ POST /v1/auth │ │
│ │ │ {token, id_token} ◀──┤ │
│ │ │ verify both │ │
│ │ │ burn token │ │
│ │ │ ── secret + URL ───▶ │ │
│ │ │ │ persist │
│ │ │ │ navigate │
│ │ │ │ /dashboard │
9.2 Spawning an agent
dashboard daemon PTY
│ POST /v1/agents/spawn │
│ {cwd, cmd, env, autoRestart} ────▶ validate (isSafeCommand)
│ resolveSecretRefs(env) ← @secret:X
│ ── exec.Command ──────▶ │
│ wrap in supervisor
│ write history row
│ ◀── 200 {agent}
│
│ open WebSocket /v1/terminal?id=<agent>&secret=<s>
│ ─── register client in PTY hub
│ ◀── replay ring (64 KB)
│ ◀── ◀── live stdout/stderr ─────────────── │
│ keystrokes ▶ │
9.3 SSE event stream (dashboard subscribes once)
daemon → SSE → dashboard
event: agent_started { agent_id, cmd, started_at }
event: agent_exited { agent_id, exit_code, reason }
event: agent_renamed { agent_id, name }
event: agent_metrics { agent_id, cpu, ram } (every 2s)
event: tunnel_url { url } (on tunnel flap)
Dashboard maintains a single EventSource with exponential-backoff reconnect (1s → 30s cap) and a per-machine status banner so no panel ever silently drifts from reality.
9.4 Secret resolution at spawn
env input persisted on disk resolved at spawn
───────── ───────────────── ─────────────────
API_KEY=@secret:PROD_STRIPE API_KEY=@secret:PROD_STRIPE API_KEY=sk_live_...
DATABASE_URL=postgres://... DATABASE_URL=postgres://... DATABASE_URL=postgres://...
DEBUG=1 DEBUG=1 DEBUG=1
The literal @secret:PROD_STRIPE is what ends up in history, so leaking the history DB doesn't leak the value.
10. Data Model
10.1 Daemon on-disk state
| File | Mode | Contents |
|---|---|---|
~/.celistra_auth.json | 0600 | { secret, ownerUid, allowedUids, tunnelEnabled } |
~/.celistra_secrets.json | 0600 | { [name]: value } |
~/.celistra_schedules.json | 0644 | [{ id, agentTemplate, intervalSeconds, lastFireAt }] |
~/.celistra_history.db | 0644 | SQLite — agents, lifecycle_events tables, 30-day auto-prune |
10.2 Browser state (Zustand persist, localStorage)
- Machines —
{ id, name, daemonUrl, daemonSecret, lastSeen, agents } - Agents —
{ id, name, cmd, cwd, env, status, startedAt, exitCode, cpu, ram, command } - Search query — string, debounced, applied to machine/agent filter
- Pro state — RevenueCat entitlement info (customer info, offerings,
isPro, paywall open state). Backed by the live SDK whenREVENUECAT_ENABLEDis true.
Factory Reset wipes the browser state only. The daemon secret on the paired machine is untouched — re-pair to reconnect.
10.3 External systems
- Firebase Auth — user identity only (UID, email, display name). No app data in Firestore.
- Firebase Hosting — two targets:
landing(marketing) andapp(dashboard). Both static. - localhost.run — reverse SSH tunnel. The URL is ephemeral; the machine secret is not.
- GitHub Releases — daemon binaries distributed from
github.com/axysar/celistra/releases/latest/download/celistrad-{mac-arm64,win-x64.exe,linux-x64}.