- TypeScript 98.2%
- JavaScript 1.4%
- Dockerfile 0.4%
| docs | ||
| patches | ||
| scripts | ||
| src | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| Dockerfile | ||
| fly.toml | ||
| ideas.md | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| test-openrouter.ts | ||
| test-reproduce.ts | ||
| tsconfig.json | ||
SMS LLM Agent
A Node.js server that receives SMS via 46elks, queries Kimi K2.6 via OpenRouter, and replies via SMS — with persistent per-user memory across sessions.
Architecture Overview
User
│ SMS
▼
46elks (SMS gateway)
│ POST /elks/sms
▼
Express Webhook
├─── Memory Read ──────────────────────────────────────────────────────────┐
│ ├── Hot tier: last 10 messages (messages table) │
│ ├── Warm tier: top-10 KNN via sqlite-vec (text-embedding-3-small) │ SQLite
│ └── Cold tier: user facts [timestamped] (user_facts table) │ /data/memory.db
▼ │
LLM Orchestrator (generateReply) │
│ system prompt + memory context + user message │
▼ │
Kimi K2.6 (chat) — OpenRouter │
│ tool_calls loop (max 50 iterations) │
├── calculate (mathjs + BigInt) │
├── get_weather (Open-Meteo) │
├── convert_currency(Frankfurter / ECB) │
├── get_directions (OpenRouteService + Overpass) │
├── web_search (open-websearch daemon → DuckDuckGo/Brave) │
├── web_fetch (open-websearch daemon → HTML scrape) │
├── check_sms_length │
└── send_sms │
│ POST /sms │
▼ │
46elks ──► User (SMS reply) │
│
Memory Write (async, post-reply) ──────────────────────────────────────────┘
├── INSERT into messages (hot)
├── embed(message) via text-embedding-3-small → INSERT into message_embeddings (warm)
└── Kimi K2.6 (facts): extractFacts → reconcile → upsert/delete user_facts (cold)
Models
| Model | Provider | Purpose |
|---|---|---|
moonshotai/kimi-k2.6 |
OpenRouter | Chat, tool calls, agentic loop |
openai/text-embedding-3-small |
OpenRouter | 1536-dim message embeddings (warm tier) |
moonshotai/kimi-k2.6 |
OpenRouter | Fact extraction + reconciliation (cold tier) |
How It Works
- User texts your 46elks virtual number
- 46elks POSTs to
/elks/sms?token=SECRET - Server reads memory context: recent messages (hot), semantically similar past messages (warm), extracted user facts with timestamps (cold)
- Kimi K2.6 responds using memory + tools in an agentic loop
- Reply sent via 46elks API
- Memory updated asynchronously: embeddings stored, facts extracted and reconciled
Memory System
3-tier hybrid memory backed by SQLite on a Fly.io persistent volume at /data/memory.db:
| Tier | Store | What | Retrieval |
|---|---|---|---|
| Hot | messages table |
Last 10 raw messages per user | Direct SELECT, injected verbatim |
| Warm | message_embeddings (sqlite-vec vec0) |
All past messages as 1536-dim float vectors | KNN cosine similarity, top-10 results |
| Cold | user_facts table |
Extracted facts with confidence + timestamps | SELECT ordered by confidence, updated_at |
Embeddings — generated via openai/text-embedding-3-small (1536 dims) on OpenRouter. Stored async after each exchange. Searched at query time by embedding the incoming message and running a KNN lookup against all stored messages for that user. Recent messages are excluded from warm results to avoid duplication with the hot tier.
Facts — extracted by Kimi K2.6 after each exchange. Cast a wide net: name, location (including visits), occupation, preferences, opinions, current context, goals, relationships, personality — anything personally revealing. Stored with a timestamp (updated_at). Injected into the prompt with their date so the model can resolve contradictions: newer fact wins. Reconciliation runs separately to avoid spurious deletions — default is to keep both facts when in doubt; only explicit negations ("I no longer", "I stopped") trigger deletion of a stale fact.
Fallback-safe — all memory operations are fire-and-forget. If the DB is unavailable the agent falls back to stateless operation.
Diagrams
The / route on the running server shows interactive architecture and sequence diagrams (zoomable/pannable SVGs compiled from D2 source in docs/):
| Diagram | Source | Shows |
|---|---|---|
| Architecture overview | docs/architecture.d2 |
Full system: agent, memory subsystem, OpenRouter models, tools, external APIs |
| Memory system | docs/memory.d2 |
3-tier read/write flow, embedding pipeline, fact extraction |
| SMS request sequence | docs/sequence-sms.d2 |
End-to-end flow including memory read, tool loop, memory write |
| Tool call loop | docs/sequence-tool.d2 |
Inner agentic loop: LLM ↔ Kimi ↔ tools |
Prerequisites
- 46elks account with a virtual number
- OpenRouter account with an API key
- fly.io account
- Node.js ≥22.5 (uses built-in
node:sqlite)
Setup
1. Configure Your 46elks Number
In the 46elks dashboard:
- Go to Numbers
- Set
sms_urlto:https://your-app.fly.dev/elks/sms?token=YOUR_SECRET_TOKEN
2. Create the Fly Volume
The SQLite DB lives on a persistent volume. Create it once before first deploy:
fly volume create sms_llm_agent_data --region arn --size 1
Use your actual
primary_regionfromfly.tomlif different fromarn.
3. Set Secrets
fly secrets set \
ELKS_USERNAME="your_46elks_username" \
ELKS_PASSWORD="your_46elks_password" \
OPENROUTER_API_KEY="your_openrouter_key" \
WEBHOOK_TOKEN="your_random_secret_token" \
ADMIN_PAGE_TOKEN="your_admin_secret_token"
Or import from a .env file:
fly secrets import < .env
Note:
fly secrets importcan choke on quoted values (e.g.KEY="value"). Remove quotes if you hit issues.
4. Deploy
fly deploy
5. Test
Text your 46elks number. You should get an LLM-powered reply within a few seconds.
Environment Variables
| Variable | Required | Description |
|---|---|---|
OPENROUTER_API_KEY |
Yes | OpenRouter API key (used for chat, embeddings, and fact extraction) |
WEBHOOK_TOKEN |
Yes | Secret token for 46elks webhook auth |
ELKS_USERNAME |
No | 46elks username (SMS disabled if missing) |
ELKS_PASSWORD |
No | 46elks password |
ORS_API_KEY |
No | OpenRouteService key (directions disabled if missing) |
ADMIN_PAGE_TOKEN |
No | Token for /admin/stats and /admin/facts pages |
CHAT_TOKEN |
No | Token for the web chat UI at /chat |
DATA_DIR |
No | Directory for SQLite DB (default: /data) |
DB_PATH |
No | Full path override for SQLite DB |
PORT |
No | HTTP port (default: 8080) |
Local Development
pnpm install
# Fresh DB (wipe any previous run)
rm -f /tmp/memory.db && OPENROUTER_API_KEY="sk-..." WEBHOOK_TOKEN="secret_token" DB_PATH="/tmp/memory.db" pnpm dev
# Keep existing DB (retain memory across restarts)
OPENROUTER_API_KEY="sk-..." WEBHOOK_TOKEN="secret_token" DB_PATH="/tmp/memory.db" pnpm dev
Testing Without Sending SMS
Only OPENROUTER_API_KEY and WEBHOOK_TOKEN are required. SMS sending is auto-disabled without 46elks credentials.
# First message
curl -X POST "http://localhost:8080/elks/sms?token=secret_token" \
-d "from=+4712345678" \
-d "to=+46766861001" \
-d "message=my name is Ola and I like Norwegian forest cats"
# Second message — should recall name/preference from memory
curl -X POST "http://localhost:8080/elks/sms?token=secret_token" \
-d "from=+4712345678" \
-d "to=+46766861001" \
-d "message=what do you know about me?"
Replies log as [SMS DISABLED] Reply to +4712345678: ....
Wiping the Local DB
rm -f /tmp/memory.db
The DB is recreated with a fresh schema on next startup.
Full Integration Test (With SMS)
ngrok http 8080
Then temporarily set your 46elks sms_url to the ngrok URL.
Web Chat UI
A full browser-based chat interface backed by the same agent and memory system as SMS, without the 160-character constraint.
Access: https://your-app.fly.dev/chat?token=YOUR_CHAT_TOKEN
On first visit the server sets a chat_session UUID cookie (1-year, HttpOnly). This UUID is the memory key — completely isolated from SMS phone numbers. Subsequent visits within the same browser session need no token in the URL (the cookie carries identity); the token only gates the initial page load.
| Route | Auth | Description |
|---|---|---|
GET /chat?token= |
CHAT_TOKEN query param |
Serves the chat UI; sets session cookie |
POST /chat/message?token= |
CHAT_TOKEN query param |
Sends a message, returns { reply, steps } |
GET /chat/history?token= |
CHAT_TOKEN query param |
Returns last 40 messages as JSON |
Differences from SMS:
| SMS | Web chat | |
|---|---|---|
| Max reply length | 160 chars (1 SMS part) | Unlimited |
| Formatting | Plain text only | Markdown rendered (bold, lists, code, tables) |
| Source links | Stripped (phishing filters) | Encouraged — model cites sources with links |
| Tool visibility | None | Collapsible activity log per reply showing each tool call and a result snippet |
| Recent history window | 10 messages | 20 messages |
| SMS tools | check_sms_length, send_sms |
Not available |
Tool transparency: each reply is preceded by a collapsible "N tool calls" toggle. Expanding it shows every tool invoked (web_search, web_fetch, calculate, etc.), the exact query/expression, and the first 300 chars of the result.
To enable locally:
CHAT_TOKEN="your_secret" OPENROUTER_API_KEY="sk-..." WEBHOOK_TOKEN="x" DB_PATH="/tmp/memory.db" pnpm dev
# then open http://localhost:8080/chat?token=your_secret
To deploy:
fly secrets set CHAT_TOKEN="your_secret"
Admin Pages
Both pages require ?token=YOUR_ADMIN_PAGE_TOKEN.
| Route | Description |
|---|---|
/admin/stats |
Overview: message counts, embedding coverage, estimated SMS cost in NOK |
/admin/facts?phone=+47... |
All extracted facts for a user with confidence scores and timestamps |
Inspecting the Database
SSH into the Fly machine and query directly:
fly ssh console
sqlite3 /data/memory.db
Useful queries:
-- All users and message counts
SELECT phone_number, COUNT(*) as msgs FROM messages GROUP BY phone_number;
-- Facts for a user (newest first)
SELECT fact, confidence, datetime(updated_at/1000, 'unixepoch') as updated
FROM user_facts WHERE phone_number = '+47...' ORDER BY updated_at DESC;
-- Embedding coverage
SELECT
(SELECT COUNT(*) FROM messages) as total_messages,
(SELECT COUNT(*) FROM message_embeddings) as embedded;
-- Recent conversation
SELECT role, content, datetime(created_at/1000, 'unixepoch') as time
FROM messages WHERE phone_number = '+47...'
ORDER BY created_at DESC LIMIT 20;
Wiping Memory on Fly
SSH into the machine and delete the DB file. The app recreates it with a fresh schema on next request.
fly ssh console
rm /data/memory.db
exit
Or restart the machine after wiping to force an immediate reinit:
fly ssh console -C "rm /data/memory.db"
fly machine restart
The Fly volume itself is preserved — only the DB file is deleted. No need to destroy/recreate the volume.
Available Tools
| Tool | Backend | SMS | Web | Notes |
|---|---|---|---|---|
calculate |
mathjs + BigInt | ✓ | ✓ | Arbitrary precision, unit conversions |
get_weather |
Open-Meteo | ✓ | ✓ | 7-day default, up to 16 days |
convert_currency |
ECB/Frankfurter | ✓ | ✓ | 30+ currencies |
get_directions |
OpenRouteService + Overpass | ✓ | ✓ | foot-walking default |
web_search |
DuckDuckGo/Brave (open-websearch) | ✓ | ✓ | No API key needed |
web_fetch |
HTML scrape (open-websearch) | ✓ | ✓ | Full page text, capped at 20k chars |
check_sms_length |
46elks dry-run | ✓ | — | Always called before send_sms |
send_sms |
46elks | ✓ | — | Final tool call, plain text only |
Architecture Notes
- Stateful: Per-user memory persists across sessions in SQLite on a Fly volume.
- Async memory: Embeddings and fact extraction run after the reply is sent — never blocks the user.
- Timestamp-aware facts: Facts carry
updated_attimestamps and are injected into the prompt with their date. Contradictory facts coexist; the model resolves by recency. - Fallback-safe: DB errors are caught and logged; agent falls back to stateless if memory is unavailable.
- Synchronous LLM loop: Webhook handler waits for reply before returning HTTP 200, keeping the Fly machine alive.
- Auto-stop: Machines stop after idle to save cost. Cold start is ~1-2s.
- Retry safety: 46elks retries webhooks for 6+ hours if non-200.
- node:sqlite: Uses Node.js built-in SQLite (≥22.5) — no native addon, no rebuild issues.
Costs (Estimated)
| Component | Cost |
|---|---|
| 46elks Swedish VMN | €3/mo |
| fly.io (shared-cpu-1x, auto-stop) | ~$1–2/mo |
| fly.io volume (1 GB) | ~$0.15/mo |
| Kimi K2.6 via OpenRouter (chat + facts) | ~$0.68/1M input tokens |
| text-embedding-3-small via OpenRouter | $0.02/1M tokens |
| 46elks SMS (Norway) | €0.064/part |
License
MIT