@luckydraw/cumulus
v1.0.49
Published
RLM-based CLI chat wrapper for Claude with external history context management
Readme
Cumulus
A self-hosted multi-channel AI gateway built around Claude and other LLMs. Runs as a long-lived daemon, speaks to you through a web chat widget, Slack, Discord, iOS push, email webhooks, and more — with unlimited conversation context via the Recursive Language Model (RLM) pattern.
Originally a CLI wrapper for Claude, Cumulus has grown into a full gateway platform that coordinates agents, channels, and models behind one persistent process.
What you get
- Gateway daemon (
cumulus-gateway) — HTTP + WebSocket server with per-thread conversations, streaming responses, and an admin API. - Web chat widget — embeddable
/chatinterface with voice mode, push notifications, file uploads with progress, and rich blex block rendering (tables, forms, charts, kanban, diagrams). - Channel adapters — Slack and Discord bots, inbound email webhooks (Resend), and generic HTTP webhooks — all injecting into the same thread model.
- Persistent web-app agents — embed an agent into any web app that can drive its UI, answer questions about its data, and remember every conversation per visitor. See Persistent web-app agents.
- Inter-agent messaging — threads can talk to each other via
send_to_agent, with support for CC/BCC visibility. - Per-thread model selection — Claude (via CLI) or any HuggingFace model (GLM-5, Kimi-K2.5, Qwen3, etc.) with tool calling.
- Scheduled triggers, email, push, media serving — built-in MCP tools so agents can send emails, schedule themselves, notify you, and upload files.
- Unlimited history — JSONL per thread + vector store + adaptive context budget. Conversations never truncate.
- Classic CLI (
cumulus) — terminal chat for individual threads, backed by the same history store.
Architecture
┌──────────────────────────────────────────────────────────────┐
│ Clients │
│ │
│ /chat (web) Slack Discord Email CLI Push (PWA) │
│ │ │ │ │ │ │ │
│ └─────────┴───────┴────────┴───────┴──────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ cumulus-gateway │ │
│ │ (HTTP / WS daemon) │ │
│ └──────────┬───────────┘ │
│ │ │
│ ┌─────────────┴─────────────┐ │
│ ▼ ▼ │
│ ┌─────────┐ ┌──────────────┐ │
│ │ Thread │ │ Model router │ │
│ │ store │ │ Claude / HF │ │
│ │ (JSONL) │ │ MCP tools │ │
│ └────┬────┘ └──────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ~/.cumulus/ Claude CLI │
│ threads/ HuggingFace API │
│ content/ MCP stdio + in-process │
│ media/ │
└──────────────────────────────────────────────────────────────┘Every turn is a fresh model invocation. The gateway assembles a context budget from recent messages + RAG retrieval against the thread's history and content store, then streams the response back to the originating channel.
Installation
Requires Node 20+.
npm install -g @luckydraw/cumulusThis installs three binaries:
| Command | Purpose |
| ----------------- | ------------------------------------------------- |
| cumulus | Terminal chat client for a single thread |
| cumulus-mcp | MCP server exposing history/content tools (stdio) |
| cumulus-gateway | Long-running daemon (HTTP + WebSocket + adapters) |
Quick start — gateway
# Interactive setup: detects project directories, installs a service, generates keys.
cumulus-gateway setup
# Or non-interactive:
cumulus-gateway setup --project-root ~/projects --port 8080
# Start / stop / reload (if you skip the service install):
cumulus-gateway start
cumulus-gateway stop
cumulus-gateway reload # graceful restart — see the note belowAbout reload: it sends SIGHUP, which drains active streams (up to 120s) and then exits the process. A supervisor is what brings it back. If you installed the service, prefer your service manager — sudo systemctl reload cumulus-gateway (Linux) or launchctl kickstart -k gui/$UID/com.luckydraw.cumulus (macOS). If you started the gateway by hand, it will stay down; run cumulus-gateway start again.
Setup writes ~/.cumulus/gateway.config.json, generates VAPID keys for push, scaffolds a systemd (Linux) or LaunchAgent (macOS) unit, and prints the generated API key.
Open http://localhost:8080/chat, paste the API key, and start talking. Messages hit your thread; responses stream back token-by-token.
Configuration
~/.cumulus/gateway.config.json — adjust any field with cumulus-gateway config set <key> <value> or edit directly:
{
"apiKeys": ["sk-cumulus-…"],
"port": 8080,
"projectRoot": "/home/you/projects",
"model": "claude", // default per-thread model
"models": [
// available models for thread picker
{ "id": "claude", "label": "Claude (CLI)", "provider": "claude-cli" },
{ "id": "gpt-5.5", "label": "GPT-5.5 (Codex)", "provider": "codex-cli" }, // runs `codex exec`
{ "id": "zai-org/GLM-5", "label": "GLM-5", "provider": "huggingface" },
{ "id": "moonshotai/Kimi-K2.5", "label": "Kimi-K2.5", "provider": "huggingface" },
],
"hfApiKey": "hf_…", // optional, for HuggingFace models
"channels": {
"slack": { "token": "xoxb-…", "signingSecret": "…", "appToken": "xapp-…" },
"discord": { "token": "…", "clientId": "…" },
},
"resend": { "apiKey": "re_…", "defaultFrom": "[email protected]" },
"vapid": { "publicKey": "…", "privateKey": "…", "subject": "mailto:[email protected]" },
}Reload the daemon after editing (sudo systemctl reload cumulus-gateway, or cumulus-gateway reload — see the note in Quickstart). It waits for active streams to finish first, so in-flight responses aren't dropped.
Gateway features
Per-thread model selection
Each thread can run on a different model. Use the dropdown in the widget header, or the REST API:
curl -X PUT http://localhost:8080/api/thread/my-thread/config \
-H "X-API-Key: sk-…" \
-d '{"model": "zai-org/GLM-5"}'claude— spawnsclaude --printper turn. Gets the full Claude Code tool surface. Per-threadeffortselector (low→max) maps to the CLI's--effortflag.provider: "codex-cli"models — spawncodex execper turn (OpenAI's Codex CLI, authenticated withcodex login, so no API key). The turn gets the same prompt and the same cumulus MCP tools as a Claude turn;effortmaps to Codex'smodel_reasoning_effort. Text arrives per completed message rather than per token, so reasoning summaries are always requested (model_reasoning_summary) — without them a turn shows nothing at all between tool calls. Threads that restrict tools (allowedTools/disallowedTools) are refused on this path, since Codex has no equivalent flag.- HuggingFace models — routed through an OpenAI-compatible endpoint with a built-in agentic loop that handles tool use, truncation recovery, and error retry.
Web chat widget
At /chat. Features:
- Streaming responses over WebSocket, with interjection support (type while streaming to interrupt and redirect).
- Multiple threads, side by side — Cmd/Ctrl+Click any thread in the sidebar to open it in a second panel alongside the current one. Useful for cross-referencing or driving two agents in parallel. Mobile auto-collapses to a single panel.
- Inline annotations — highlight any chat text, leave a comment via the popover, and send it back as a quoted chip. Chips can be edited or removed before sending. Works like leaving a margin note on what the agent just said.
- Blex blocks —
~~~blex:TYPEfenced JSON renders as a rich, interactive component. 22 block types including:- Interactive input —
poll(multi-question carousels, multi-select, write-in answers),confirm(Yes/No/Cancel),form(typed fields with validation). User responses serialize back into the chat input. - Embedded content —
embed(sandboxed iframe for hosted apps and webpages, inline in the chat),image/gallery(withupload_media-served URLs),mermaidandsvgdiagrams. - Live data —
table(sortable/selectable),chart,kanban,calendar,timeline,status,metric,progress,file-tree,terminal,code,diff(with Apply/Reject buttons),layout(composes other blocks),branch(step-through flowcharts).
- Interactive input —
- Voice mode — hands-free conversation using browser STT + server-side Piper TTS, with sentence-by-sentence playback and barge-in.
- Push notifications — PWA install + VAPID subscriptions. Agents call
notify_userto alert you while you're away. - File attachments — drag or pick any file type. Non-image files upload via XHR with a per-chip progress bar and cancel; agents receive the absolute disk path and can
read_fileit directly. Images stay on the inline-base64 path for vision-capable models. - Texitool integration — edit Unicode-art diagrams in-place via an embedded canvas.
- Update banner — auto-detects when a newer version is on npm and offers a one-click update.
Channel adapters
- Slack (
channels.slack) — Socket Mode bot. Thread naming:slack-{userId}-{channelId}. - Discord (
channels.discord) — Gateway WebSocket. Thread naming:discord-{userId}-{channelId}. - Inbound webhooks —
POST /api/hooks/:typefor email (Resend), forms, and generic events. Config-driven thread routing with HMAC signature verification.
Inter-agent messaging
Any thread can message another thread on the same gateway using the send_to_agent MCP tool:
send_to_agent(target="devops", message="Deploy the new build", visibility="cc")cc(default) — all recipients see each other.blind— each recipient thinks it's a direct message.{hidden: […]}— selective (observer pattern, hidden agents invisible to visible recipients).
If the target is busy, the message is queued and delivered as a batch when that thread is idle ("while you were busy, 3 messages arrived…").
Scheduled triggers
Agents can schedule themselves:
schedule_trigger(at="2026-05-01T09:00:00Z", message="Follow up with lead")
schedule_trigger(cron="0 9 * * MON", message="Weekly check-in")
cancel_schedule(id="…")Schedules are per-thread, persisted in {thread}.config.json, and fire as message injections into the thread.
Email (Resend)
With resend.apiKey configured:
send_email(to="[email protected]", subject="Hello", body="…")
list_emails(limit=10)Rate-limited per thread (default 10/hour). All sends are logged to thread history. First email from a new thread triggers a notify_user ping.
Reliability
- Graceful restart — SIGHUP drains active Claude/HF streams up to 120s, then exits for the supervisor to restart; no truncated responses on deploy.
- Auto-resume after restart — interrupted threads get a resume nudge on startup so the agent picks back up with full RAG context.
- Persistent streaming buffer — partial responses are flushed to disk every 5s during streaming and recovered on restart.
- Truncation continuation —
finish_reason: "length"triggers max-token escalation (8k → 16k → 32k) and seamless continuation stitching. - WebSocket keepalive — server-side ping/pong every 30s; clients reload history if a stream goes silent for >120s.
- Policy-error retry — Claude CLI transient "Usage Policy" refusals auto-retry up to 3 times with a visible "Retrying…" indicator.
- HF transient-error retry —
[Error: terminated], connection resets, and similar stream/network errors retry with exponential backoff.
Self-update
cumulus-gateway check-update # compares running version to npm
cumulus-gateway update # bumps to latest, saves previous for rollback
cumulus-gateway rollback # restores the previous versionThe widget's top bar also shows an "Update available" indicator (with a manual ↻ check button) when a new version lands on npm.
Persistent web-app agents
Cumulus can embed a persistent agent into any web app — one that drives the app's UI, answers questions about the app and its data, and remembers every conversation per visitor. No fork of the gateway, no per-app backend beyond a static file server.
A runnable starter kit lives in examples/web-app-agent — copy it and replace the demo app with yours.
How it works
Browser tab (your app) cumulus gateway
┌───────────────────────────────┐ ┌──────────────────────────────────┐
│ your app UI │ │ one thread per visitor: │
│ ├─ command registry │ wss │ myapp-<deviceId> │
│ │ (window.MyAppAgent) │ /bridge │ ├─ full history + RAG │
│ ├─ BridgeClient ────────────┼────────────▶│ ├─ per-thread config │
│ ├─ agent panel (chat UI) │ https │ │ (inherited from │
│ │ POST /api/thread/… ─────┼────────────▶│ │ myapp.config.json) │
│ └─ selection / right-click │ SSE │ └─ model turn per message │
│ feedback capture │ │ └─ MCP shim ──────────────┼──┐
└───────────────────────────────┘ └──────────────────────────────────┘ │
▲ │
POST /bridge/call ◀───────────────────┘
(agent tool call → executes in the tab)Three moving parts:
- The gateway — owns history, RAG retrieval, and prompt assembly, and runs a model turn per message. One config block per app; no code changes.
- Your front end — registers a typed command registry (what the agent can see and do in the UI), mounts the bridge client (a WebSocket back to the gateway), and renders the agent panel.
- An MCP shim — a small stdio script the gateway spawns per turn. It fetches the app's command manifest and exposes each command as a model-callable tool; calls are forwarded to
POST /bridge/call, which dispatches them into the live browser tab.
The loop that makes the agent "drive the app": the model calls a tool → shim → POST /bridge/call → gateway pushes call over the tab's WebSocket → the tab executes it through the app's own actions (so guards, routing, and notifications all still fire) → the result flows back to the model.
Because the manifest is re-fetched every turn, shipping a new UI command makes it agent-callable with zero gateway or backend changes.
Key concepts
| Concept | What it is |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Namespace | A config block grouping an app's threads (myapp-*), scoping its API key, and carrying its per-app settings (proxy, MCP servers). |
| Base thread (myapp) | Your own management thread for the app, visible only to your admin key. Never used by visitors — but it is the config template they inherit. |
| Visitor threads (myapp-<id>) | One per browser/device, minted client-side. Full persistent history + RAG each. Hidden from the default thread list. |
| Bridge | The gateway↔tab WebSocket: the tab registers its command manifest; the gateway dispatches agent tool calls into the tab. |
| Command registry | The app-side catalog of typed commands ({ name, description, params, risk, execute }) — one capability surface shared by the UI and the agent. |
| Capability-by-name | The security model: a scoped key can only touch threads in its namespace, can enumerate nothing, and the random thread name is the per-visitor secret. |
Gateway setup
One edit to ~/.cumulus/gateway.config.json (annotated below; the real file is strict JSON):
{
"bridge": { "enabled": true }, // global, default off — every bridge surface is inert until enabled
"namespaces": [
{
"name": "myapp", // covers threads matching myapp-*
"label": "My App",
"apiKeys": ["sk-myapp-<random>"], // the app's OWN key — mint a fresh one
// OPTIONAL: reverse-proxy selected paths to the app's backend through the
// gateway origin, so the front end needs only one origin.
"executorProxy": {
"origin": "http://127.0.0.1:8097",
"pathPrefixes": ["/state", "/journal"],
},
// OPTIONAL, but required for "drive the app": the MCP shim that turns the
// app's command manifest into model-callable tools. Spawned per turn, only
// for threads in this namespace. {thread} is substituted with the real
// thread name (myapp-<deviceId>) in both args and env.
"extraMcpServers": {
"myapp-tools": {
"command": "node",
"args": ["/path/to/myapp/mcp-shim.js"],
"env": {
"GATEWAY_ORIGIN": "http://127.0.0.1:8080",
"GATEWAY_API_KEY": "sk-myapp-<same-scoped-key>",
"BRIDGE_THREAD": "{thread}",
},
},
},
},
],
}Then give visitor threads their persona and working directory via the base thread config, ~/.cumulus/threads/myapp.config.json:
{
"projectDir": "/home/you/projects/myapp",
"model": "claude",
"effort": "high",
"alwaysInclude": ["docs/myapp-system-prompt.md"]
}A turn on myapp-a3f8c2d1 with no config of its own inherits this by prefix-fallback. Writes stay exact, so a visitor session can never mutate the base. alwaysInclude is where the app's product knowledge and persona live.
Front-end integration
Your app ships four small pieces (all vanilla-JS-able, no framework required):
| Piece | Job |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Device thread id | Mint myapp-<random hex> once, persist in localStorage. This is the visitor's identity — use 16+ hex chars of entropy. |
| Command registry | Expose window.MyAppAgent with typed commands. Include a describeView-style command so the agent can read the current screen. |
| Bridge mount | Open wss://<gateway>/bridge, register the manifest, execute inbound call frames against the registry. |
| Chat client | POST /api/thread/<thread>/message and render the SSE stream. Mark risky commands so they route through a confirm step. |
Serve the gateway URL and scoped key from a session-gated endpoint your app already protects (GET /api/agent-config → 401 until the visitor is logged in), and fetch it at runtime. Never commit the key into the repo, and prefer this over injecting it into the served HTML: injected config ships the key to every visitor of the page, including anonymous ones, and it lands in caches and view-source. The starter kit in examples/web-app-agent does it the gated way.
Security model
The browser must hold a credential — the bridge sends its key inside a WebSocket frame, which no reverse proxy can inject. So the model is confinement, not concealment:
- A scoped key can read/write only
myapp-*threads; everything else is403. - A scoped key enumerates nothing —
/api/threads,/api/agents, and the dashboard all return empty. Reaching another visitor's thread means guessing its random name. - The thread name is the per-visitor secret, the same capability-URL pattern as content-hashed
/media/*filenames, one notch stricter (unguessable and key-gated).
Mint fresh device ids with at least 16 hex characters; 8 is too thin against a determined brute-forcer.
Checklist
- Enable
bridgeand add the namespace + scoped key to the gateway config; reload. - Create the base thread config with
projectDir,model, and thealwaysIncludesystem-prompt document. - Verify the scoped key: in-namespace
200, out-of-namespace403,/api/threadsempty. - Inject the gateway URL + scoped key into the page at serve time.
- Ship the device thread id, command registry, bridge mount, and chat client.
- Point the namespace's
extraMcpServersshim at your manifest endpoint. - End-to-end check: ask the agent a question about the current screen, then ask it to navigate.
Classic CLI mode
The original RLM chat loop still works. Great for quick terminal work without running the gateway.
cumulus my-project # open or create a thread
cumulus --list # list threads
cumulus --delete old-projectEach turn:
- Append your message to
~/.cumulus/threads/my-project.jsonl. - Spawn
claude --printwith--mcp-configpointing to the cumulus MCP server. - Claude pulls whatever history it needs via
search_history,peek_recent, etc. - Append the response to the JSONL.
- Next turn starts from a fresh context.
MCP tools
The cumulus-mcp server exposes history and content tools. Usable from any MCP-compatible client.
History:
| Tool | Purpose |
| ------------------- | ---------------------------------------------------------------- |
| search_history | Keyword / semantic / hybrid search over a thread |
| peek_recent | Last N messages |
| read_messages | Message range by index |
| get_history_stats | Count, token estimate, time range |
| get_summary | Auto-generated summaries (recent chunk, full, or specific range) |
| sub_query | Recursive sub-LLM call over retrieved messages |
Content store (file reads, bash output, web fetches):
| Tool | Purpose |
| --------------------- | ---------------------------------------- |
| read_file | Read text/PDF, chunk + embed + store |
| store_content | Store arbitrary text for later retrieval |
| search_content | Search across stored content |
| retrieve_content | Get full content by [STORED:xxx] id |
| read_content_chunk | Read a specific chunk index |
| list_stored_content | List all stored items |
| detect_anomalies | Find out-of-place content in a store |
| forget_content | Remove a stored item |
Gateway-only tools (available to agents running inside the daemon):
send_to_agent, list_agents, notify_user, schedule_trigger, cancel_schedule, list_schedules, send_email, list_emails, upload_media.
RAG & context management
- JSONL history per thread — every message, tool call, and tool result.
- Content store — chunked file reads, embedded with local HuggingFace transformers, stored as binary Float32.
- Segment summaries — LLM-generated per topic boundary, separately embedded for vocabulary-gap retrieval.
- Adaptive context budget — self-tuning per thread based on TTFT. Shrinks when slow, grows when fast + near-capacity. Default 300k, floor 100k, ceiling 1M.
- Query-type-aware retrieval — classifies queries (recall / synthesis / recent / decision) and adjusts scoring weights accordingly.
REST API (gateway)
| Method | Path | Purpose |
| ------ | --------------------------- | --------------------------------------- |
| GET | /health | Gateway status |
| POST | /api/thread/:name/message | Send a message (SSE stream in response) |
| GET | /api/thread/:name/history | Paginated thread history |
| GET | /api/thread/:name/config | Thread config |
| PUT | /api/thread/:name/config | Update thread config (model, etc.) |
| DELETE | /api/thread/:name | Delete a thread |
| POST | /api/thread/:name/rename | Rename a thread (body { name }) |
| GET | /api/threads | List threads |
| GET | /api/agents | List threads + streaming status |
| POST | /api/agents/inject | Inject message into a thread |
| GET | /api/models | Available models |
| POST | /api/media/upload | Upload a file |
| GET | /media/:filename | Serve uploaded file |
| POST | /api/hooks/:type | Inbound webhook |
| GET | /api/push/vapid-key | Public VAPID key |
| POST | /api/push/subscribe | Register a push subscription |
| GET | /api/version | Running version + update availability |
| POST | /api/admin/update | Trigger self-update (admin key) |
All /api/* routes require X-API-Key: <key> (from apiKeys[]).
WebSocket (/chat/ws) carries the same semantics with streaming, interjection, inject, and voice-mode audio frames.
Upgrading
npm install -g @luckydraw/cumulus@latest
sudo systemctl reload cumulus-gateway # or `cumulus-gateway reload` if self-managedOr use the built-in updater (cumulus-gateway update / rollback), which does the same thing and keeps the previous version for a one-command rollback. See CHANGELOG.md for release notes.
Background
Cumulus implements the Recursive Language Model pattern: treat conversation history as an external environment the model queries programmatically, rather than stuffing everything into context. This enables reasoning over contexts 2+ orders of magnitude beyond the model's window, with graceful cost scaling.
License
Cumulus is proprietary software, copyright © 2026 Lucky Draw LLC. It is licensed, not sold. See LICENSE for the full terms — the summary:
| | | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Free, no agreement needed | Evaluation, development, prototyping, testing, personal and other non-commercial use. Install it, run it, integrate it, try it against your own app. | | Requires a paid commercial license | Production use · commercial or revenue-generating use · providing a product or service to third parties · hosting it as a managed/SaaS offering · redistribution or resale · sublicensing. |
Demonstration mode. Without a license key, cumulus runs fully but each configured thread namespace may create at most 5 namespaced threads — enough to evaluate a web-app agent end to end, not enough to serve real visitors. Threads outside a namespace (your own threads, the CLI, the TUI) are never limited, and existing threads keep working when a namespace is at its limit. Add a key to gateway.config.json and reload:
{ "licenseKey": "cumulus-lic-v1...." }Keys are verified offline — nothing is transmitted — and are issued per whole-number release: a 1.x key covers every 1.x.y.
Commercial licensing: [email protected]
Versions up to and including 0.31.66 were published under the MIT License and remain available under those terms. This license applies to 1.0.0 and later.
