docsLocal Server

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:

  1. SurrealDB on 127.0.0.1:8000, user root, password root, with RocksDB storage at ~/.smartchats/data/surreal.db. start waits up to 30 s for its /health.
  2. 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.
  3. The local server on port 3000, bound to 0.0.0.0, pointed at that SurrealDB (ns=smartchats, db=main), serving the bundled app. start waits 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.

FlagDefaultNotes
--port3000App and API port
--surreal-port8000SurrealDB port, loopback only
--data-dir~/.smartchats/dataSurrealDB storage, and media under seaweedfs/
--rebuildoffRun turbo run build before starting
-f, --foregroundoffStay attached. Ctrl-C stops the stack
--no-promptoffSkip the “configure keys?” prompt when no keys are found

Logs

ProcessPath
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

VariableDefaultNotes
SMARTCHATS_PORT4242smartchats start sets 3000
SMARTCHATS_HOST127.0.0.1smartchats start sets 0.0.0.0
SMARTCHATS_API_KEYunsetBearer token for scripts and agents on other machines (see Security)
SMARTCHATS_AUTHpairoff lets every device in without pairing
SMARTCHATS_DATA_DIRunsetWhere devices.json (paired devices) is kept; smartchats start sets the data directory. Unset means ~/.smartchats
SMARTCHATS_STATIC_DIRunsetDirectory of the static app export to serve at /. Unset means API only
SURREAL_URLws://127.0.0.1:8000/rpc
SURREAL_NSsmartchats
SURREAL_DBmain
SURREAL_USERroot
SURREAL_PASSWORDroot
SMARTCHATS_OPENAI_API_KEYthen OPENAI_API_KEYChat, embeddings, OpenAI TTS
SMARTCHATS_ANTHROPIC_API_KEYthen ANTHROPIC_API_KEY
SMARTCHATS_GOOGLE_API_KEYthen GOOGLE_API_KEY, GEMINI_API_KEYGemini
SMARTCHATS_XAI_API_KEYthen XAI_API_KEY
SMARTCHATS_TOGETHER_API_KEYthen TOGETHER_API_KEY, TOGETHERAI_API_KEY
SMARTCHATS_AZURE_SPEECH_KEYthen AZURE_SPEECH_KEYAzure TTS
SMARTCHATS_AZURE_SPEECH_REGIONthen AZURE_SPEECH_REGIONAzure TTS
SMARTCHATS_SERPER_API_KEYthen SERPER_API_KEYWeb search
SMARTCHATS_S3_ENDPOINTset by start (SeaweedFS)Media bucket. All four S3 values are needed to enable storage
SMARTCHATS_S3_ACCESS_KEY_IDunset
SMARTCHATS_S3_SECRET_ACCESS_KEYunset
SMARTCHATS_S3_BUCKETunset
SMARTCHATS_S3_REGIONus-east-1Signing region
SMARTCHATS_S3_LINKSserverdirect 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.

RouteNotes
GET /healthReadiness. { ok, checks: { data, providers, tts } }. 503 only when SurrealDB is unreachable. Missing keys are reported but don’t fail it
POST /llm/streamText-only NDJSON stream
POST /llm/streamWithTTSLLM plus TTS NDJSON stream, with the optional turn verdict
POST /tts/streamNDJSON TTS stream, PCM16 24 kHz mono
POST /embeddings/embedOpenAI text-embedding-3-small
POST /data/query{ query, variables? } runs SurrealQL. Returns { statements: [{ status, result, time }] }
GET /data/healthPer-table check of the required tables
GET /usage/recordsPaginated usage rows
GET /usage/summary?since=<iso>Aggregate for a time window
GET /keys/providersWhich LLM and TTS providers are available
GET /keysMasked 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/:providerRemove a saved key
POST /tools/searchSerper web search. Needs a Serper key
POST /tools/fetchUrlFetch a page and extract readable text
POST /insights/batchTelemetry events, stored as sent in insights_events
POST /storage/createUploadStart an upload and get its upload links
POST /storage/resumeUploadResume a multipart upload
POST /storage/completeUploadFinish an upload
POST /storage/getMediaUrlFresh download link
POST /storage/listMediaList files
POST /storage/deleteMediaDelete 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:

FrameFields
textd: a text delta
audio_starts chunk index, text, ms
audios, c sequence, b64 PCM16 24 kHz mono
audio_ends, ms
audio_errors, error. The text stream is unaffected
server_timingTiming phases
llm_doneThe full text and usage, before trailing audio finishes
doneFinal stats, with verdict when one ran
errorLLM 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.

EnvDefaultNotes
SMARTCHATS_S3_PORT8333S3 port (loopback)
SMARTCHATS_S3_LINKSserverdirect: hand browsers presigned bucket URLs instead, so bytes skip the server. The bucket must then be reachable from the browser
SMARTCHATS_S3_BIND127.0.0.1Direct links only: address SeaweedFS listens on, e.g. 0.0.0.0
SMARTCHATS_S3_PUBLIC_ENDPOINTthe endpointDirect links only: address browsers reach the bucket at, e.g. http://192.168.1.20:8333. Links are signed for it
SMARTCHATS_S3_ALLOWED_ORIGINShttp://localhost:<port>, http://127.0.0.1:<port>Direct links only: extra app origins the bucket allows by CORS, comma separated
SMARTCHATS_WEED_BINbundled weed, else weed on PATH
SMARTCHATS_STORAGEonoff 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:

CallerHow
The computer running SmartChatsTrusted, nothing to set up: the browser at http://localhost:3000, the CLI, MCP servers, scripts
A phone or another computerPairs 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 machineAuthorization: Bearer $SMARTCHATS_API_KEY, when that variable is set
Anyone, with SMARTCHATS_AUTH=offNo 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.

RouteOpen toDoes
GET /auth/statusanyone{mode, authenticated, via, device?}
POST /auth/pairanyone{code, name?} → sets the device cookie
POST /auth/pair/starttrusted{code, expires_at} for another device
GET /auth/devicestrustedPaired devices
POST /auth/devices/revoketrusted{id}
POST /auth/logouttrustedUnpairs the calling device
  • SurrealDB binds to 127.0.0.1 with root/root credentials and SeaweedFS to 127.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.

See also