Architecture overview
How Cradle splits into a host-agnostic core with two entry points — the headless server and the desktop app — and how a message flows end to end.
Cradle is a single host-agnostic core with two ways to run it: as the
headless cradle-server daemon, or embedded in the Electron desktop app. The
core never imports Electron; each entry point supplies a HostAdapter before
any core code runs.
Two entry points, one core
┌──────────────── core (host-agnostic) ───────────────┐
│ · DB (better-sqlite3 + sqlite-vec) + migrations │
│ · Triage orchestrator, router, agent-service │
│ · Risk system (L1 rules + L2 classifier) │
│ · llama.cpp runners (chat + embedding) │
│ · Channels: api / telegram / widget │
│ · RAG: chunker / ingest / retrieve / parsers │
│ · Auth: api_keys + scopes + audit │
└──────────────────────────────────────────────────────┘
▲ ▲
ElectronHostAdapter NodeHostAdapter
(app.getPath, dialog, (~/.cradle, EventEmitter,
BrowserWindow.send) JSON stdout logger)
│ │
src/electron/ (BrowserWindow + React UI) src/server/ (HTTP API, no GUI)The HostAdapter contract abstracts everything platform-specific:
user-data directory, temp directory, resource paths, event delivery, file
pickers, and logging. The desktop app implements it with Electron APIs; the
server implements it with ~/.cradle, an EventEmitter, and a structured JSON
logger (and pickFile simply throws, since it is headless).
Server vs desktop app
| Concern | cradle-server | Electron app |
|---|---|---|
| Startup | node dist/server/index.js | Electron Forge + Vite, BrowserWindow |
| Data dir | ~/.cradle/ | OS user-data dir |
| Auth | api_keys + scopes | Same table; admin key auto-bootstraps |
| File picker | Throws (headless) | dialog.showOpenDialog |
| Event delivery | EventEmitter (SSE) | webContents.send to the renderer |
End-to-end message flow
Every incoming message runs through the same harness, regardless of which entry point is hosting the core:
Incoming → ChatChannel (telegram | widget | api)
→ unified IncomingMessage
→ persist message + upsert ticket
→ TriageOrchestrator.processIncoming
├── RiskAssessor.layer1(rules) → triggered rules + level
├── RiskAssessor.layer2(classifier) → { category, risk, confidence,
│ reasoning, requiredSkills }
├── Router.pick(category, skills) → Agent
├── if agent has knowledge bases:
│ RAG.search(text, { kbIds, topK }) → source snippets
├── AgentService.executeAgent(agent, turns, { sources })
│ → builds the prompt (system + language + sources + turn)
│ → runner.generate (streamed) → draft
├── save messages + risk_assessment
└── decide by final level:
├── green → send automatically
└── yellow | red → ticket waits for operator
→ Inbox → approve / edit / reject → sendThe key property: a red or yellow verdict never auto-sends. It becomes a ticket in the operator Inbox, and a human approves, edits, or rejects the draft before it reaches the channel.
Inside the core
- Triage —
orchestrator(drives the flow),router(picks an agent by role + skill intersection),agent-service(prompt building + execution), and therisksubsystem (deterministic rules, LLM classifier, categories, skills). - LLM — a
ModelRunnerinterface implemented byLlamaCppRunner(wrappingnode-llama-cpp), a model manager (resumable SHA-256-checked downloads), and a model registry with LRU eviction and lazy loading. - RAG — a paragraph-first chunker, idempotent batched ingest, top-k
retrieval over
sqlite-vec, and parsers for Markdown, TXT, PDF, DOCX, and XLSX plus a web crawler. - Channels — a common
ChatChannelinterface with Telegram (grammY), an embeddable widget (Fastify + WebSocket), and a direct HTTP API, all funnelled through a channel manager.
The desktop renderer
The Electron renderer is a React + Zustand app with pages for Dashboard, Inbox, Ticket detail, Agents, Models, Knowledge, Integrations, Risk & Triage, and Settings — the human side of the harness where operators review and approve.