Celistra — Project Overview
A single document covering the why, the what, and the how. Updated 2026-04-24.
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.
Celistra is the product. Ujex is the engine. When a Celistra agent needs a real inbox, a memory that outlives its context, a spend cap, an approval from your phone, or a hash-chained audit trail, Celistra calls into Ujex — an open-source (Apache-2.0) agent infrastructure layer — instead of rebuilding that plumbing from scratch. End users of Celistra never need to think about Ujex; builders who want the raw primitives can use ujex.dev on its own.
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 (crypto/rand, 600s TTL, single-use)
│ REST / SSE / WebSocket — Authorization: Bearer <fbIdToken|secret>
│
┌───────────────┴──────────────────────────────────────────────┐
│ │
┌─┴───────────────┐ frp to mail.ujex.dev:7000 ┌──────┴──────────────┐
│ 127.0.0.1:33120 │ public URL: <uid6>-<slug>.tunnel.ujex.dev │ Peer machines … │
│ celistrad │ nginx TLS at edge · CAA-pinned CAs │ celistrad │
│ Go daemon │ bundled frpc, SHA-256 verified at startup │ (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, the daemon accepts either of two bearer formats on every authenticated endpoint: the legacy 256-bit machine secret or a Firebase ID token verified offline against Google's JWKS. Clients prefer the Firebase ID token path; the machine secret is kept as a backward-compatible fallback and is no longer synced to Firestore.
5. Pain Points We Solved
5.1 "I can't reach my home server from anywhere"
celistrad opens an outbound frp tunnel to the Ujex-operated relay at mail.ujex.dev:7000 (admission token required). nginx at the edge terminates Let's Encrypt TLS for *.tunnel.ujex.dev and reverse-proxies to the frps HTTP vhost. The machine claims https://<uid6>-<slug>.tunnel.ujex.dev — namespaced by the first six chars of the Firebase UID so user A's mbp can't collide with user B's mbp. ufw denies the internal :8080 externally; the Hetzner firewall denies everything except 22/25/80/443/465/587/7000 plus ICMP. No inbound firewall rules on your side. 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). The bundled frpc binary is SHA-256 verified before exec so a swap-attack is refused rather than executed.
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 a crypto/rand-backed challenge token (TTL 600s, single-use, constant-time compared) and opens the dashboard with it. The browser POSTs the token back with your Firebase ID token and refresh token. Daemon verifies both, returns the bearer + the current tunnel URL, and persists the refresh token so it can rotate the ID token every 50 minutes without user interaction. 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 authenticate with their own Firebase ID tokens — no shared secret ever crosses the network. The endpoint is gated twice: the request must arrive over loopback (RemoteAddr check, not X-Forwarded-For which is relay-controlled), and the caller's Firebase sub must equal the owner UID (an allow-listed viewer can't self-extend the list).
5.10 "Grey text on black is unreadable"
The dashboard is themed against the brand tokens: Deep Space #0A0A0F base, Void Dark #1A1A24 raised surfaces, Star White #FFFFFF primary text, Orbit Purple #7C6EE0 accents only where they do work (CTAs, focus rings, the orbit-core-node mark). Typography is Playfair Display for display copy, DM Sans for body, DM Mono for code. The former mix of Fraunces + Figtree + JetBrains Mono is gone.
6. Pain Points We Have NOT Solved (Yet)
6.1 Custom-domain tunnel
The default frp tunnel gives every machine a stable https://<uid6>-<slug>.tunnel.ujex.dev URL (TLS at the nginx edge), so bookmarks don't rot anymore. What's still missing is custom domain — Pro users who want <machine>.example.com instead of *.tunnel.ujex.dev need either a CNAME path or a Cloudflare Tunnel (named) swap. 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 No host-side command policy
The previous client-side isSafeCommand blocklist was deleted during the security audit (it was theatre — a user who could reach /v1/agents/spawn could already run anything as their own user). The honest posture is that celistrad executes under your user account and can do anything you can do; protect the bearer, not the command line. An opt-in per-daemon policy hook (e.g. a plugin that inspects the argv before exec) is a plausible future direction but is not on the near-term roadmap.
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 the companion app, not the web dashboard
The web dashboard's sidebar + terminal layout remains desktop-first. Touch scrolling in the PTY over the web is rough. The real mobile story is the Expo SDK 54 / RN 0.81 / React 19 companion in mobile/ — Firebase Auth, Firestore subscriptions, live WebSocket PTY tail, approve/deny via Firestore, brand-matched Playfair + DM Sans with an Orbit Purple FAB.
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, two-path withSecret middleware |
firebase_auth.go | Offline Firebase ID-token verification against Google's JWKS |
cloudsync.go | Refresh-token rotation every 50 min; persists rotated refresh tokens |
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 + Pair / Open Shell / Revoke all sessions items |
tunnel.go | frp tunnel via mail.ujex.dev:7000; claims <uid6>-<slug>.tunnel.ujex.dev |
internal/ingress/frp.go | frp supervisor + SHA-256 verification of bundled frpc + admission token |
config.go | Config struct — machine secret, UID, AllowedUIDs, refresh token, 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 | /v1/machine/share — loopback + owner-UID double-gated CRUD over Config.AllowedUIDs |
dashboard_html.go | Secretless local dashboard served at GET / on loopback only (brand palette inlined) |
ctl.go | celistrad ctl readline REPL + one-shot mode (--json, tab completion, brand-colored) |
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, prefers Firebase ID token over machine secret |
lib/firebase.ts | Firebase init, Google SSO, email+password |
lib/revokeSessions.ts | Calls the revokeAllSessions Cloud Function — invalidates every refresh token on the account |
lib/revenuecat.ts | RevenueCat init + product id map (PRODUCTS, DEPRECATED_PRODUCT_ALIASES) + REVENUECAT_ENABLED master switch |
components/Mark.tsx | Orbit-core-node SVG mark (dark / light / accent / mono variants) |
components/Sidebar.tsx | Machine list + agent tree + "Pair a machine" CTA + Revoke all sessions |
components/TerminalView.tsx | xterm.js wired to /v1/terminal WebSocket + agent header band (ATTACH / SPAWN / KILL) + metric tiles + event log + run prompt |
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 <fbIdToken>│
│ ↓ │
│ │ ┌──── local LAN ────────────┐
│ probes 127.0.0.1:33120 ─▶ celistrad (loopback) │
│ │ └──────────────────────────┘
│ │
│ fallback: tunnel ┼─────▶ <uid6>-<slug>.tunnel.ujex.dev ──▶ celistrad (tunnel)
└────────────────────┘ frps on mail.ujex.dev:7000 · nginx TLS at edge
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. Flows (end to end)
8.1 First pairing
user tray daemon dashboard firebase
│ │ │ │ │
│ click Pair ──▶ │ │ │ │
│ │ mint challenge ▶ │ │ │
│ │ (crypto/rand, 600s TTL, single-use) │ │
│ │ open browser ───────────────────────▶ │ │
│ │ url contains ?t=<token> │ │
│ │ │ │ Google SSO ────▶ │
│ │ │ │ ◀── id_token ── │
│ │ │ POST /v1/auth │ │
│ │ │ {challenge, idToken, refreshToken} ◀─── │
│ │ │ verify idToken (JWKS) + challenge (CT) │
│ │ │ burn challenge, persist refreshToken │
│ │ │ ── bearer + tunnel URL ──▶ │
│ │ │ │ persist (no-secret) │
│ │ │ │ navigate /dashboard │
8.2 Spawning an agent
dashboard daemon PTY
│ POST /v1/agents/spawn │
│ {cwd, cmd, env, autoRestart} ───▶ withSecret accepts Bearer <fbIdToken|secret>
│ resolveSecretRefs(env) ← @secret:X
│ ── exec.Command ──────▶ │
│ wrap in supervisor
│ write history row
│ ◀── 200 {agent}
│
│ open WebSocket /v1/terminal
│ (bearer sent as first frame — not a query string)
│ ─── register client in PTY hub
│ ◀── replay ring (64 KB)
│ ◀── ◀── live stdout/stderr ─────────────── │
│ keystrokes ▶ │
8.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.
8.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.
9. Data Model
9.1 Daemon on-disk state
| File | Mode | Contents |
|---|---|---|
~/.celistra_auth.json | 0600 | { secret, ownerUid, allowedUids, token, refreshToken, 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 |
9.2 Browser state (Zustand persist, localStorage)
- Machines —
{ id, name, daemonUrl, lastSeen, agents }. The daemon secret is not persisted tolocalStoragepost-audit; the dashboard authenticates against the daemon with the user's Firebase ID token instead. - 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's on-disk state is untouched — re-pair to reconnect. A full recovery from a suspected device compromise also uses Revoke all sessions (from the account popover or the tray), which invokes the revokeAllSessions Cloud Function.
9.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. tunnel.ujex.dev— Ujex-operated frp relay co-hosted with the mailserver onmail.ujex.dev.frpslistens on:7000with an admission token; nginx terminates TLS for*.tunnel.ujex.devusing a Let's Encrypt wildcard (auto-renewed) and reverse-proxies to the frps HTTP vhost on127.0.0.1:8080. Subdomains are namespaced<uid6>-<slug>.tunnel.ujex.dev(first six hex of the Firebase UID + machine slug) so identically-named machines from different users cannot collide.ufwdenies:8080externally; the Hetzner firewall denies everything except22/25/80/443/465/587/7000plus ICMP. CAA records onujex.devpin issuance to Let's Encrypt plus four other named CAs.- GitHub Releases — daemon binaries distributed from
github.com/axysar/celistra/releases/latest/download/celistrad-{mac-arm64,win-x64.exe,linux-x64}.