AI-native, omnichannel support workspace. Email · Web chat · WhatsApp · Telegram · Generic API · multi-tenant · RAG-grounded draft replies · SLA timers · routing-rule DSL · realtime SSE.
Natcusp is a single-binary, SQLite-backed Intercom/Zendesk-alternative built on Next.js 15 + React 19. Drop one process on a small VPS and you have a complete support workspace — inbox, customer 360, knowledge base, macros, routing rules, SLA, agent presence, AI draft replies, and a public chat widget — for an entire support team.
| Area | What you get |
|---|---|
| Channels | 5 inbound adapters: Mailgun-style email, web chat widget, WhatsApp Cloud webhook, Telegram bot webhook, generic JSON ingest. HMAC signature verification across the board. |
| Conversation engine | Single ingestInbound() funnel: customer + thread resolution, de-dup on external id, routing rules applied, SLA stamped, broadcast fired — all inside one SQLite transaction. |
| Routing DSL | JSON rules (when matchers + then actions) evaluated in priority order. Matchers: channel, priority, intent, keyword, sentiment, business_hours, customer.attribute. Actions: assign_team, assign_user, set_priority, add_tag, set_status, auto_reply, set_sla. |
| SLA | Per-policy first_response_minutes + resolution_minutes. Best-match policy picked from applies_to_json. Live countdown pill in the inbox; breach events broadcast over SSE. |
| RAG | Lightweight TF-IDF over kb_terms (built on save). No external vector DB. Inverted index + per-workspace DF cache. Cosine-ish score with idf². |
| AI | Dual provider (OpenAI default, Anthropic fallback). Per-user keys encrypted at rest with AES-256-GCM (HKDF-derived). Prompts cover draft-reply, classify, sentiment, summarize, suggest-macro, and Atlas (the SSE side-panel co-pilot). |
| Realtime | In-process EventEmitter SSE bus. Defensive teardown — listener exceptions can't kill the bus. Presence heartbeats, typing pings, new-message fanout, SLA-breach pushes. |
| Widget | Public chat widget at /widget?inbox=<id> — visitor-id auto-generated, long-poll for agent replies, no auth required on the visitor side. |
| Aesthetic | "Aurora" — snow-paper light + slate-night dark, electric-violet accent + mint/amber/rose pills. Inter UI, JetBrains Mono for hud/numbers. |
# 1. Install
npm install
# 2. Configure (.env.local — minimum)
JWT_SECRET=replace-with-a-long-random-string-at-least-16-chars
# 3. Seed the demo workspace (Caspian Telecom MMC)
npm run seed
# 4. Dev server
npm run dev
# http://localhost:5050Sign in: admin@caspian.demo / demopass. Other seeded agents:
sevda@, tural@, elnur@, nigar@, ramin@, lale@ (all demopass).
The seed creates 80 conversations across all 5 channels, 12 KB articles, 14 macros, 5 routing rules and 4 SLA policies so the UI is populated from the first page load.
app/
(app)/ ← authenticated shell (Sidebar + TopBar + AssistantProvider)
dashboard/ ← KPIs + SLA heatmap + channel volume + agent load
inbox/[bucket]/[id]/ ← 3-column view: list + thread + customer 360
customers/ ← list + 360 detail
kb/ ← knowledge base editor
macros/ rules/ sla/ ← admin DSL editors
audit/ notifications/ settings/
api/
auth/ ← login / register / logout / me
ai/ ← providers, draft, classify, sentiment, summarize, suggest-macro, atlas (SSE)
conversations/ ← list + detail + messages + assign + status + tags + typing
channels/ ← email, whatsapp, telegram, web, api ← webhook receivers
events/ ← SSE stream (workspace-scoped)
{inboxes,kb,macros,rules,sla,tags,teams,presence,customers,users,dashboard,audit}
widget/ ← public chat widget (iframe-friendly)
lib/
db.ts ← migrations + audit() helper
types.ts ← Channel | ConvStatus | Priority | SenderKind | Matcher | Action
auth.ts ← bcrypt + JWT 7d cookie, requireUser/requireRole
crypto.ts ← AES-256-GCM via HKDF
channels.ts ← ingestInbound() + sendOutbound() funnel
routing.ts ← rule DSL evaluator
sla.ts ← stampInitialSla + checkBreaches + markResolved
rag.ts ← TF-IDF tokenizer + scorer
events.ts ← in-process EventEmitter (with defensive broadcast)
ai/ ← provider abstraction (openai / anthropic / prompts)
webhooks.ts ← HMAC verification helpers
components/
Sidebar / TopBar / Modal / ConfirmModal / PromptModal
ChannelBadge / StatusPill / PriorityPill / SlaPill / Avatar
ThemeToggle / AssistantProvider / AssistantTrigger
PresenceHeartbeat / EventStream
Brand / Logo
scripts/
seed.ts ← reproducible Caspian Telecom demo
curl -X POST http://localhost:5050/api/channels/email \
-H 'content-type: application/json' \
-d '{
"recipient": "support@caspian.demo",
"sender": "vusal@example.az",
"subject": "Cannot send SMS abroad",
"body-plain":"Hi, my SMS to UK numbers fails since yesterday.",
"Message-Id":"<msg-001@mail.example.az>"
}'curl -X POST http://localhost:5050/api/channels/whatsapp \
-H 'content-type: application/json' \
-d '{ "entry": [{ "changes": [{ "value": {
"metadata": { "phone_number_id": "994702000000" },
"contacts": [{ "wa_id": "994552223344", "profile": { "name": "Aysel" } }],
"messages": [{ "id":"wamid.xyz","from":"994552223344","text":{ "body":"Where is my refund?" } }]
} }] }] }'curl -X POST 'http://localhost:5050/api/channels/telegram?bot=@caspian_support_bot' \
-H 'content-type: application/json' \
-d '{ "message": { "message_id": 99, "chat": { "id": 555000, "first_name": "Murad" }, "text": "Outage in Sumqayit?" } }'curl -X POST http://localhost:5050/api/channels/api \
-H 'content-type: application/json' \
-d '{
"inboxId": "<api inbox id from seed>",
"contact": { "handle": "user-001", "displayName": "Test User", "email": "u1@example.com" },
"body": "App crashes when I open the wallet tab."
}'Settings → AI providers → + Add key. Pick OpenAI or Anthropic, paste
the key (we store it AES-256-GCM-encrypted, derived from NATCUSP_ENC_KEY
via HKDF). The Test button hits each provider's cheapest verification call.
OpenAI is the default; Anthropic is the fallback. Server-only jobs
(seeding, scheduled summarisation) can also read OPENAI_API_KEY /
ANTHROPIC_API_KEY from the environment when no per-user key is present.
A single shared EventSource('/api/events') fans out to the rest of the
app via window.dispatchEvent('natcusp:event', ...). The inbox list and
conversation view debounce-refresh on new_message, conversation_updated
and sla_breach. Typing pings auto-expire 3.5s after the last event.
The bus uses Node's EventEmitter with setMaxListeners(0) and a
defensive try/catch around emit() so a single dead SSE connection
can't take down unrelated API requests (a bug we shipped to DA-EMP once
and never want to ship again).
- Swap SQLite for Postgres by re-pointing
lib/db.ts. The SQL is mostly portable; thedatetime('now')defaults andINSERT…ON CONFLICT…DO UPDATEsyntax both translate cleanly. - Replace the in-process bus with Redis pub/sub for multi-node deployments
— the
bus()abstraction stays the same. - Set
JWT_SECRETandNATCUSP_ENC_KEYto 32+ random characters before going to production. The dev defaults inseed.tsare deliberately weak. - Webhook secrets are stored encrypted in
channel_inboxes.secret_enc. Set them via the Inboxes UI; never commit raw secrets to.env.
MIT. Make it yours.