Local server reference
smartchats-local-server is the backend of a self-hosted SmartChats. It is an Express app that serves the API under /local-api/* and, when SMARTCHATS_STATIC_DIR is set, the static app at /. It stores data in SurrealDB, calls LLM and TTS providers with your own keys, and signs upload links for an S3-compatible bucket.
smartchats start launches it for you. This page covers what it does underneath.
How smartchats start runs it
start launches up to three detached processes and records them in ~/.smartchats/run/pids.json:
- SurrealDB on
127.0.0.1:8000, userroot, passwordroot, with RocksDB storage at~/.smartchats/data/surreal.db.startwaits up to 30 s for its/health. - SeaweedFS for media storage on
127.0.0.1:8333(see Storage). It is optional: if it is missing or doesn’t come up within 30 s, the stack runs without media. - The local server on port 3000, bound to
0.0.0.0, pointed at that SurrealDB (ns=smartchats,db=main), serving the bundled app.startwaits up to 30 s for/local-api/health.
The server’s environment is your .env merged with the shell’s environment. The shell wins, so KEY=value smartchats start overrides one launch. On boot the server connects to SurrealDB and applies the schema, and exits if either fails.
| Flag | Default | Notes |
|---|---|---|
--port | 3000 | App and API port |
--surreal-port | 8000 | SurrealDB port, loopback only |
--data-dir | ~/.smartchats/data | SurrealDB storage, and media under seaweedfs/ |
--rebuild | off | Run turbo run build before starting |
-f, --foreground | off | Stay attached. Ctrl-C stops the stack |
--no-prompt | off | Skip the “configure keys?” prompt when no keys are found |
Logs
| Process | Path |
|---|---|
| SurrealDB | ~/.smartchats/logs/surreal.log |
| SeaweedFS (media) | ~/.smartchats/logs/seaweedfs.log |
| Local server | ~/.smartchats/logs/server.log |
smartchats logs [-f] [--proc surreal|seaweedfs|server|all] [-n 50]The server logs one line per request (METHOD url -> status ms), plus per-route lines for LLM streams with model, token counts and cost.
Environment
| Variable | Default | Notes |
|---|---|---|
SMARTCHATS_PORT | 4242 | smartchats start sets 3000 |
SMARTCHATS_HOST | 127.0.0.1 | smartchats start sets 0.0.0.0 |
SMARTCHATS_API_KEY | unset | Bearer token for scripts and agents on other machines (see Security) |
SMARTCHATS_AUTH | pair | off lets every device in without pairing |
SMARTCHATS_DATA_DIR | unset | Where devices.json (paired devices) is kept; smartchats start sets the data directory. Unset means ~/.smartchats |
SMARTCHATS_STATIC_DIR | unset | Directory of the static app export to serve at /. Unset means API only |
SURREAL_URL | ws://127.0.0.1:8000/rpc | |
SURREAL_NS | smartchats | |
SURREAL_DB | main | |
SURREAL_USER | root | |
SURREAL_PASSWORD | root | |
SMARTCHATS_OPENAI_API_KEY | then OPENAI_API_KEY | Chat, embeddings, OpenAI TTS |
SMARTCHATS_ANTHROPIC_API_KEY | then ANTHROPIC_API_KEY | |
SMARTCHATS_GOOGLE_API_KEY | then GOOGLE_API_KEY, GEMINI_API_KEY | Gemini |
SMARTCHATS_XAI_API_KEY | then XAI_API_KEY | |
SMARTCHATS_TOGETHER_API_KEY | then TOGETHER_API_KEY, TOGETHERAI_API_KEY | |
SMARTCHATS_AZURE_SPEECH_KEY | then AZURE_SPEECH_KEY | Azure TTS |
SMARTCHATS_AZURE_SPEECH_REGION | then AZURE_SPEECH_REGION | Azure TTS |
SMARTCHATS_SERPER_API_KEY | then SERPER_API_KEY | Web search |
SMARTCHATS_S3_ENDPOINT | set by start (SeaweedFS) | Media bucket. All four S3 values are needed to enable storage |
SMARTCHATS_S3_ACCESS_KEY_ID | unset | |
SMARTCHATS_S3_SECRET_ACCESS_KEY | unset | |
SMARTCHATS_S3_BUCKET | unset | |
SMARTCHATS_S3_REGION | us-east-1 | Signing region |
SMARTCHATS_S3_LINKS | server | direct hands browsers presigned bucket URLs (see Storage) |
Provider keys resolve in this order: the SMARTCHATS_ variable, the plain variable, a key saved in the database with POST /keys, then none.
Routes
All routes are under /local-api. JSON request bodies are limited to 10 MB.
| Route | Notes |
|---|---|
GET /health | Readiness. { ok, checks: { data, providers, tts } }. 503 only when SurrealDB is unreachable. Missing keys are reported but don’t fail it |
POST /llm/stream | Text-only NDJSON stream |
POST /llm/streamWithTTS | LLM plus TTS NDJSON stream, with the optional turn verdict |
POST /tts/stream | NDJSON TTS stream, PCM16 24 kHz mono |
POST /embeddings/embed | OpenAI text-embedding-3-small |
POST /data/query | { query, variables? } runs SurrealQL. Returns { statements: [{ status, result, time }] } |
GET /data/health | Per-table check of the required tables |
GET /usage/records | Paginated usage rows |
GET /usage/summary?since=<iso> | Aggregate for a time window |
GET /keys/providers | Which LLM and TTS providers are available |
GET /keys | Masked previews of keys saved in the database (env keys are not shown) |
POST /keys | { keys: { openai?, anthropic?, google?, xai?, together?, azure? } }. Warns when an env var shadows a saved key |
DELETE /keys/:provider | Remove a saved key |
POST /tools/search | Serper web search. Needs a Serper key |
POST /tools/fetchUrl | Fetch a page and extract readable text |
POST /insights/batch | Telemetry events, stored as sent in insights_events |
POST /storage/createUpload | Start an upload and get its upload links |
POST /storage/resumeUpload | Resume a multipart upload |
POST /storage/completeUpload | Finish an upload |
POST /storage/getMediaUrl | Fresh download link |
POST /storage/listMedia | List files |
POST /storage/deleteMedia | Delete a file |
Self-hosted mode has no billing. Usage rows are written for every LLM, TTS, embedding and tool call with creditsCharged: 0.
POST /llm/stream
{
"model": "gpt-5.5",
"input": [{ "role": "user", "content": "Hello" }],
"max_tokens": 16384,
"temperature": 0.7,
"effort": "low",
"stop": ["\n\n"],
"session_id": "optional, recorded on the usage row"
}model and a non-empty input are required. text_format ({ type: "json_schema", name, strict, schema }) asks for structured output. max_tokens defaults to 16384 and is capped at 32768. { "warmup": true } returns at once without calling a model. An unknown model or a missing key for its provider is a 400 before the stream starts.
The response is NDJSON, one object per line:
{"t":"delta","d":"Hel"}
{"t":"delta","d":"lo!"}
{"t":"done","data":{"success":true,"output_text":"Hello!","usage":{"input_tokens":9,"output_tokens":3},"model":"gpt-5.5","provider":"openai","finish_reason":"stop","latency_ms":640}}A failure mid-stream writes {"t":"error","error":"..."}.
POST /llm/streamWithTTS
Same body as /llm/stream, plus TTS fields and an optional verdict block:
{
"model": "gpt-5.5",
"input": [{ "role": "user", "content": "so I was thinking about" }],
"tts": true,
"tts_provider": "azure",
"tts_voice": "en-US-AvaMultilingualNeural",
"tts_speed": 1,
"tts_instructions": "warm, brief",
"verdict": { "model": "deepseek-v4.1-flash", "effort": "none", "cap_ms": 800, "judge": "v2", "threshold": 0.5 }
}The verdict is a small model call that runs before the main model on voice turns and decides whether the user finished speaking. Only verdict.model is required. judge is v1 (the default) or v2, and threshold (0 to 1, default 0.5) applies to v2.
When a verdict runs, its frame comes first:
{"t":"verdict","complete":false,"source":"model","model":"deepseek-v4.1-flash","cap_ms":800,"ms":{"queue":2,"model":310,"total":312},"utterance_words":5,"context_turns":0}source is model, cap (timed out, treated as complete), error, or skipped. If complete is false the turn ends there: no main call is made, and a done frame follows with finish_reason: "held". Otherwise the stream continues:
| Frame | Fields |
|---|---|
text | d: a text delta |
audio_start | s chunk index, text, ms |
audio | s, c sequence, b64 PCM16 24 kHz mono |
audio_end | s, ms |
audio_error | s, error. The text stream is unaffected |
server_timing | Timing phases |
llm_done | The full text and usage, before trailing audio finishes |
done | Final stats, with verdict when one ran |
error | LLM stream failure |
A missing verdict key doesn’t fail the turn. If the server has no key for the verdict model’s provider, it logs no key for verdict model ... and runs the turn without a verdict. A missing key for the main model is still a 400.
Storage
Photos, videos and files go to an S3-compatible bucket. smartchats start runs one for you: SeaweedFS (weed mini, bundled with binary installs) on 127.0.0.1:8333, with its data and generated credentials under <data-dir>/seaweedfs (media/, seaweedfs.env, s3.json, all 0600). The server gets the SMARTCHATS_S3_* values from start. smartchats status and smartchats logs --proc seaweedfs show it.
Only the server talks to the bucket. Upload and download links point at the server itself (/local-api/storage/blob/<token>), and it streams the bytes to and from the bucket. So media works from any device that can open the app (a phone on your network, a container’s published port, a reverse proxy) with nothing else exposed. Each link is signed (HMAC, from the bucket secret) for one operation on one file and expires within minutes, like a presigned URL. That is why these routes don’t need the bearer token: an <img> can’t send one.
| Env | Default | Notes |
|---|---|---|
SMARTCHATS_S3_PORT | 8333 | S3 port (loopback) |
SMARTCHATS_S3_LINKS | server | direct: hand browsers presigned bucket URLs instead, so bytes skip the server. The bucket must then be reachable from the browser |
SMARTCHATS_S3_BIND | 127.0.0.1 | Direct links only: address SeaweedFS listens on, e.g. 0.0.0.0 |
SMARTCHATS_S3_PUBLIC_ENDPOINT | the endpoint | Direct links only: address browsers reach the bucket at, e.g. http://192.168.1.20:8333. Links are signed for it |
SMARTCHATS_S3_ALLOWED_ORIGINS | http://localhost:<port>, http://127.0.0.1:<port> | Direct links only: extra app origins the bucket allows by CORS, comma separated |
SMARTCHATS_WEED_BIN | bundled weed, else weed on PATH | |
SMARTCHATS_STORAGE | on | off runs the stack without media storage |
To use your own bucket instead (S3, R2, MinIO), set SMARTCHATS_S3_ENDPOINT, SMARTCHATS_S3_ACCESS_KEY_ID, SMARTCHATS_S3_SECRET_ACCESS_KEY and SMARTCHATS_S3_BUCKET. start then skips SeaweedFS. The server streams through to it the same way; add SMARTCHATS_S3_LINKS=direct to send browsers to the bucket instead (configure the bucket’s CORS for the app’s origin). Without a bucket the storage routes answer 501 not_supported.
From a clone, bin/seaweedfs runs the same local bucket for development, and bin/devserve picks it up.
Storage errors return { error, code }: 404 not_found, 400 invalid_argument, 409 failed_precondition, 501 not_supported.
Security
smartchats start binds the server to 0.0.0.0 so a phone on your network can use it. Who may call /local-api:
| Caller | How |
|---|---|
| The computer running SmartChats | Trusted, nothing to set up: the browser at http://localhost:3000, the CLI, MCP servers, scripts |
| A phone or another computer | Pairs once. It sees a Pair this device screen and enters a 6-digit code from smartchats pair (or Settings → Devices). It then keeps a cookie until you revoke it (smartchats devices, or Settings → Devices) |
| A script or agent on another machine | Authorization: Bearer $SMARTCHATS_API_KEY, when that variable is set |
Anyone, with SMARTCHATS_AUTH=off | No checks, as before 0.4.0 |
Everything else gets 401 {"error": "unauthorized", "code": "pairing_required"}. The page itself (/app) and signed media links stay public: they hold no data.
What counts as this machine: the connection comes from a loopback address, the Host is localhost, 127.0.0.1 or [::1], any Origin is one of those too, and any X-Forwarded-For / Forwarded addresses are loopback. So a website whose domain resolves to 127.0.0.1 (DNS rebinding), or a page on another site calling localhost:3000, is not trusted.
A proxy or tunnel on the same machine must forward the visitor’s Host or address. With neither, every visitor looks like this machine. The nginx example sets Host $host and X-Forwarded-For; Caddy forwards both by default.
Pairing codes are 6 digits, exist only after you ask for one (or once at startup while nothing is paired, for Docker), last 5 minutes (15 at startup), serve one device, and die after 5 wrong tries. A device’s token is 32 random bytes in an HttpOnly, SameSite=Strict cookie; <data-dir>/devices.json (0600) stores only its SHA-256.
| Route | Open to | Does |
|---|---|---|
GET /auth/status | anyone | {mode, authenticated, via, device?} |
POST /auth/pair | anyone | {code, name?} → sets the device cookie |
POST /auth/pair/start | trusted | {code, expires_at} for another device |
GET /auth/devices | trusted | Paired devices |
POST /auth/devices/revoke | trusted | {id} |
POST /auth/logout | trusted | Unpairs the calling device |
- SurrealDB binds to
127.0.0.1withroot/rootcredentials and SeaweedFS to127.0.0.1. Don’t expose ports 8000 or 8333. - On a public server, keep the reverse proxy with TLS in front, and block direct access to port 3000.