Skip to main content

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:

  1. Runs on your hardware. A single Go binary, ~20 MB, no system install, no root.
  2. Speaks a normal web protocol. REST + SSE + WebSocket. No proprietary agent bus.
  3. Assumes the browser is hostile until cryptographically paired with the daemon.
  4. Stays local-first. When you're on the LAN with your machine, traffic never touches the internet.
  5. 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:

TrapWhat it looks like
The SSH diasporaYou 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 taxVercel / 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 creepKubernetes, 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)

FilePurpose
main.goHTTP mux, agent supervisor, config load/save, SSE broker, two-path withSecret middleware
firebase_auth.goOffline Firebase ID-token verification against Google's JWKS
cloudsync.goRefresh-token rotation every 50 min; persists rotated refresh tokens
pty_hub.goOne PTY per agent, fanned out to N WebSocket clients; 64 KB replay ring
attach.go + attach_resize_{unix,windows}.goCross-platform attach + TTY resize
tray.go + tray_actions_{darwin,linux,windows}.goSystem tray UI + Pair / Open Shell / Revoke all sessions items
tunnel.gofrp tunnel via mail.ujex.dev:7000; claims <uid6>-<slug>.tunnel.ujex.dev
internal/ingress/frp.gofrp supervisor + SHA-256 verification of bundled frpc + admission token
config.goConfig struct — machine secret, UID, AllowedUIDs, refresh token, persisted as JSON
history.goSQLite store, insert-time env redaction, corruption auto-recovery, 30-day retention
secrets.go~/.celistra_secrets.json CRUD + @secret:NAME resolver called from spawn
schedules.goInterval 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.goSecretless local dashboard served at GET / on loopback only (brand palette inlined)
ctl.gocelistrad ctl readline REPL + one-shot mode (--json, tab completion, brand-colored)
icon.goTray icon bytes

7.2 Dashboard (src/, React + Vite + Tailwind + Zustand)

Two Vite entrypoints, two bundles, one CSS:

  • main-landing.tsxindex.landing.html — marketing pages (/, /download, /terms, /privacy, /refund)
  • main-app.tsxindex.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.
FilePurpose
store/useAuthStore.tsFirebase Auth session, ID token refresh
store/useCelistraStore.tsPaired machines, agents, SSE connection, terminal state, search query
store/useProStore.tsRevenueCat entitlement — live ($9 Pro / $49 Team); short-circuits to free-for-all when REVENUECAT_ENABLED is off
lib/rpc.tsDaemonClient — typed wrapper over every daemon REST endpoint, prefers Firebase ID token over machine secret
lib/firebase.tsFirebase init, Google SSO, email+password
lib/revokeSessions.tsCalls the revokeAllSessions Cloud Function — invalidates every refresh token on the account
lib/revenuecat.tsRevenueCat init + product id map (PRODUCTS, DEPRECATED_PRODUCT_ALIASES) + REVENUECAT_ENABLED master switch
components/Mark.tsxOrbit-core-node SVG mark (dark / light / accent / mono variants)
components/Sidebar.tsxMachine list + agent tree + "Pair a machine" CTA + Revoke all sessions
components/TerminalView.tsxxterm.js wired to /v1/terminal WebSocket + agent header band (ATTACH / SPAWN / KILL) + metric tiles + event log + run prompt
components/HistoryModal.tsx30-day SQLite history panel
components/AgentSettingsModal.tsxcwd, env, autoRestart, signal picker
components/CommandPalette.tsx⌘K global command bar
components/InspectorPanel.tsxPer-agent metrics + metadata
components/ConnectionBanner.tsxSSE reconnection banner
components/PaywallModal.tsxPro 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

FileModeContents
~/.celistra_auth.json0600{ secret, ownerUid, allowedUids, token, refreshToken, tunnelEnabled }
~/.celistra_secrets.json0600{ [name]: value }
~/.celistra_schedules.json0644[{ id, agentTemplate, intervalSeconds, lastFireAt }]
~/.celistra_history.db0644SQLite — 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 to localStorage post-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 when REVENUECAT_ENABLED is 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) and app (dashboard). Both static.
  • tunnel.ujex.dev — Ujex-operated frp relay co-hosted with the mailserver on mail.ujex.dev. frps listens on :7000 with an admission token; nginx terminates TLS for *.tunnel.ujex.dev using a Let's Encrypt wildcard (auto-renewed) and reverse-proxies to the frps HTTP vhost on 127.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. ufw denies :8080 externally; the Hetzner firewall denies everything except 22/25/80/443/465/587/7000 plus ICMP. CAA records on ujex.dev pin 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}.
  • Security — threat model, two-path auth, tunnel surface, revoke-all-sessions.
  • Roadmap — what's shipped, what's next, what's deliberately out.