46elks API + clarifai API = talk to LLM over SMS
  • TypeScript 98.2%
  • JavaScript 1.4%
  • Dockerfile 0.4%
Find a file
2026-06-20 02:19:57 +02:00
docs also remember self & thoughts & research 2026-06-15 21:00:26 +02:00
patches bigint for calc tool 2026-05-12 21:18:06 +02:00
scripts now with better diagrams using d2 2026-05-12 22:17:17 +02:00
src fix: use native fetch to prevent premature-close crashes on Fly 2026-06-20 02:19:57 +02:00
.dockerignore init commit (working!) 2026-05-12 00:37:13 +02:00
.env.example init commit (working!) 2026-05-12 00:37:13 +02:00
.gitignore remove .env.prod from tracking 2026-05-12 23:49:03 +02:00
Dockerfile also remember self & thoughts & research 2026-06-15 21:00:26 +02:00
fly.toml feat: add memory 2026-06-14 16:43:04 +02:00
ideas.md more tools (web_fetch, directions...) + docs 2026-05-12 08:17:53 +02:00
package.json feat: add memory 2026-06-14 16:43:04 +02:00
pnpm-lock.yaml feat: add memory 2026-06-14 16:43:04 +02:00
pnpm-workspace.yaml bigint for calc tool 2026-05-12 21:18:06 +02:00
README.md now a webchat client interface too 2026-06-19 21:36:39 +02:00
test-openrouter.ts feat: add memory 2026-06-14 16:43:04 +02:00
test-reproduce.ts feat: add memory 2026-06-14 16:43:04 +02:00
tsconfig.json init commit (working!) 2026-05-12 00:37:13 +02:00

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

  1. User texts your 46elks virtual number
  2. 46elks POSTs to /elks/sms?token=SECRET
  3. Server reads memory context: recent messages (hot), semantically similar past messages (warm), extracted user facts with timestamps (cold)
  4. Kimi K2.6 responds using memory + tools in an agentic loop
  5. Reply sent via 46elks API
  6. 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_url to: 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_region from fly.toml if different from arn.

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 import can 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_at timestamps 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) ~$12/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