Sabbia AI
SABBIA AI / DOCUMENTATION

Run it. Connect it.
Make it yours.

Local storage, explicit model roles, and a transcript-first integration API. Everything here matches the server in this repository.

Quickstart

Run locally

Install Bun 1.2 or later, then clone the repository and copy the configuration:

clone / configure
git clone https://github.com/sabbia-ai/sabbia
cd sabbia
cp .env.example .env

Set NVIDIA_API_KEY in .env to your provider key, or set SABBIA_MOCK=1 for deterministic offline inference. Then start the single process:

local / run
bun run src/index.ts      # → http://localhost:3000

Open the website or the console. Bun reads .env automatically; process environment variables take precedence. In PowerShell, copy the file with Copy-Item .env.example .env.

Run with Docker Compose

From the cloned repository, create .env and configure a key or mock mode before starting:

docker / compose
cp .env.example .env
# Set NVIDIA_API_KEY or SABBIA_MOCK=1 in .env.
docker compose up --build -d
docker compose logs -f sabbia

Compose publishes port 3000 and persists SQLite and uploads in ./data:/data. A keys file needs its own volume mount and a container-relative NVIDIA_KEYS_FILE path. Scanned-PDF OCR requires optional pdftoppm; the base Docker image does not install it.

Deployment scope

The public API needs no login. Use HTTPS, visitor cookie isolation, abuse limits, and an optional operator token for destructive routes. The reverse proxy must overwrite X-Forwarded-For with the trusted first client hop and restrict direct listener access.

Configuration

Every variable in .env.example is listed below. Restart the process after changing configuration.

Environment variables and defaults
NameDefaultMeaning
PORT3000HTTP listener port; integer from 1 to 65535.
HOST0.0.0.0Bind address. Use 127.0.0.1 for a local-only listener.
SABBIA_DATA_DIR./dataSQLite database and content-addressed uploaded files. Must be writable.
SABBIA_MOCK0Set to 1 for deterministic offline inference without provider calls.
NVIDIA_BASE_URLhttps://integrate.api.nvidia.com/v1OpenAI-compatible provider base URL; all live model calls use this endpoint.
NVIDIA_API_KEYEmptyComma-separated provider keys. Required for live inference unless a keys file supplies them.
NVIDIA_KEYS_FILEEmptyOptional path to newline-separated keys; merged with the environment keys and deduplicated.
SABBIA_MAX_UPLOAD_MB25Maximum individual uploaded file size in MiB (0.001 to 1024).
SABBIA_RETRIEVAL_MIN_SCORE0.18Minimum normalized retrieval score, from 0 to 1. Lower scores are insufficient grounding.
SABBIA_LOG_LEVELinfoMinimum JSON log level: debug, info, warn, or error.
SABBIA_RATE_LIMIT_PER_MIN120All requests per client IP; token bucket per minute.
SABBIA_MODEL_RATE_LIMIT_PER_MIN6Model route admissions per client IP per minute.
SABBIA_MODEL_RATE_LIMIT_GLOBAL_PER_MIN60Model route admissions across all clients per minute.
SABBIA_MODEL_BUDGET_PER_DAY1500Global model route budget; resets at 00:00 UTC.
SABBIA_UPLOAD_RATE_PER_HOUR10Source and asset uploads per client IP per hour.
SABBIA_MAX_CONCURRENT_STREAMS40Simultaneous SSE responses across all clients.
SABBIA_MAX_CONCURRENT_STREAMS_PER_IP3Simultaneous SSE responses per client IP.
SABBIA_MAX_UPLOAD_TOTAL_MB500Total stored upload objects across sources and assets, in MiB.
SABBIA_MAX_SOURCES50Maximum sources per corpus.
SABBIA_RETENTION_DAYS14Anonymous data retention; hourly cleanup preserves operator rows.
SABBIA_MAX_JSON_KB256Maximum JSON request body, in KiB.
SABBIA_MAX_DATABASE_MB512SQLite metadata, chunks, events and telemetry ceiling, in MiB.
SABBIA_AUTH_TOKENunsetOptional operator token for the three destructive routes; unset by default.

Limits and abuse protection

The API is open by default. The daily model budget is the cost guard: lower it to tighten spending or raise it for more capacity, then restart. It counts model route admissions, not tokens or dollars, and persists in SQLite across restarts. The dispatcher charges POST thread turns, vision analyses, voice turns, and corpus searches before execution, including local replies and invalid bodies. PDF uploads also reserve a unit because scanned documents can use OCR. Already admitted work can finish. Minute/hour token buckets refill continuously and remove idle clients; maps hold at most 10,000 identities. Use a single process: stream and minute limits are in-process. There is also a 128 concurrent request admission cap and a 100,000 visitor identity cap.

StatusMeaning
401Operator token missing or incorrect on a protected destructive route.
409Corpus source count exceeded, or visitor cleanup in progress.
413File exceeds the per-file ceiling or JSON body exceeds 256 KiB.
429Request, model, upload, stream, daily budget, or identity/concurrency capacity exceeded. application/problem+json names the limit and next availability/reset; numeric Retry-After gives seconds to wait. A spent public demo budget resets at 00:00 UTC. Stream availability uses a five-second retry estimate.
507Stored uploads exceed the aggregate quota (detail includes current and maximum bytes), or SQLite reaches its separate metadata ceiling. Shared upload objects count once. Leave disk space for WAL and PDF temporary files as well.

Search queries longer than 2,000 characters return 400. SSE connections expire after five minutes and close for slow readers with excessive queued events. The console shows friendly inline 429/507 notices and keeps Send disabled while streaming.

Visitor privacy and data controls

Each visitor receives a 32-byte random base64url sabbia_visitor cookie: HttpOnly; Secure; SameSite=Lax, expiring in 30 days. HTTPS is required for public use. API clients must retain and resend it. Only its SHA-256 hash and timestamps are stored; no IP or personal data is recorded. Losing the cookie loses access. Threads, corpora, and assets have visitor ownership. Lists show only the caller's own records. Direct-by-id reads and writes, SSE replay, voice, sources, images, searches, and thread model usage are scoped; another visitor receives 404. Existing rows remain NULL-owned operator data; a valid operator token permits direct access to legacy rows, without granting access to other visitors.

A read-only shared demo corpus (is_public=1), seeded at boot from bundled English and Chinese text, keeps knowledge search working for new visitors who own no corpus. GET /v1/demo/corpus discovers it; the console creates a private corpus on upload.

DELETE /v1/threads/:id, DELETE /v1/sources/:id, and DELETE /v1/voice/sessions/:id are public only while SABBIA_AUTH_TOKEN is unset (local single-user mode). When configured they require Authorization: Bearer or X-API-Key, checked in constant time; otherwise 401. Ownership checks still apply. All other public API access remains without a login. Set the token on a public deployment.

GET /v1/visitor/export returns the caller's threads, turns, corpus metadata, and source titles. DELETE /v1/visitor removes all caller-owned data and clears the cookie without requiring the operator token. Both controls are in the console footer. The hourly retention sweep (also at boot) removes anonymous rows and uploaded objects older than SABBIA_RETENTION_DAYS and logs counts; NULL-owned operator rows are never removed.

The operator can inspect local SQLite access_log records: id, at, visitor_hash, method, route, resource_kind, resource_id, status. Only reads and deletes are audited; no bodies, IPs, or raw cookies. Records are capped at 100,000 and pruned by retention. /healthz reports audit counts, limits (model_remaining_today, model_budget_per_day, streams_open), and storage (used_mb, limit_mb, sources), never raw audit rows. /api/config uses dataDirConfigured instead of a filesystem path. Provider key material and prefixes remain private.

Models

The explicit registry in src/models.ts defines these roles. Provider discovery does not select models. Read the live registry at GET /api/models; a listed model still needs to be enabled for your provider account.

Model registry
RoleModel IDMax tokensTimeout
textMainopenai/gpt-oss-20b90060 s
textFastnvidia/nemotron-3.5-bolt-e4b40030 s
textBignvidia/nemotron-3-ultra-550b-a55b1200120 s
visionMainnvidia/nemotron-3.5-super-vl-120b-a12b140090 s
visionAltmeta/llama-3.2-11b-vision-instruct60015 s
visionLeanmeta/muse-glimmer-30b60060 s
guardMainmeta/llama-guard-4-12b6420 s
guardAltnvidia/nemotron-3.5-content-safety6420 s
guardJsonnvidia/llama-3.1-nemotron-safety-guard-8b-v312820 s
codeFastpoolside/laguna-xs-2.190060 s
Reasoning vision token budget

Reasoning vision models need max_tokens ≥ 1200; with a smaller budget the chain of thought consumes the whole response and the content comes back empty.

The primary vision role normally uses 1400 tokens, enforces a minimum of 1200, and retries once at 2800 if reasoning is present but content is empty. The alternate vision path has a 15-second timeout and one retry before falling back to the primary model. Public image analysis defaults to visionMain.

API reference

Send JSON with Content-Type: application/json except for file uploads, which use a multipart field named file. The sketches show representative responses and omit some metadata fields.

Core API reference
MethodPathPurpose
GET/healthzCheck runtime, database, and provider key health.
Request and response sketch

Representative fields; IDs, results, and timings vary.

GET /healthz

Request
No request body.

Response
{"ok":true,"version":"0.1.0","uptime_s":12,"keys":{"total":0,"available":0,"cooling":0},"mock":true,"db":"ok"}
GET/api/modelsRead the explicit model registry.
Request and response sketch

Representative fields; IDs, results, and timings vary.

GET /api/models

Request
No request body.

Response
[{"id":"openai/gpt-oss-20b","role":"textMain","capabilities":["text"],"timeoutMs":60000,"maxTokens":900}]
POST/v1/threadsCreate a conversation on an agent surface.
Request and response sketch

Representative fields; IDs, results, and timings vary.

POST /v1/threads

Request
{"surface":"knowledge","title":"Policy questions"}

Response
{"id":"thread-id","surface":"knowledge","title":"Policy questions","last_seq":0}
POST/v1/threads/:id/turnsSubmit a message and receive its SSE events.
Request and response sketch

Representative fields; IDs, results, and timings vary.

POST /v1/threads/:id/turns

Request
{"text":"What is the return window?","corpusId":"corpus-id"}

Response
id: 1
data: {"v":1,"seq":1,"kind":"turn.start","turnId":"turn-id","author":"user","text":"What is the return window?"}

… text.delta, citations.set, turn.end events
GET/v1/threads/:id/eventsReplay the journal and subscribe to live SSE.
Request and response sketch

Representative fields; IDs, results, and timings vary.

GET /v1/threads/:id/events

Request
Query: ?after=6 (or send Last-Event-ID: 6)

Response
id: 7
data: {"v":1,"seq":7,"kind":"text.delta","turnId":"turn-id","text":"Returns are accepted"}
POST/v1/corporaCreate a document collection.
Request and response sketch

Representative fields; IDs, results, and timings vary.

POST /v1/corpora

Request
{"name":"Support policies"}

Response
{"id":"corpus-id","name":"Support policies"}
POST/v1/corpora/:id/sourcesUpload a document for background ingestion.
Request and response sketch

Representative fields; IDs, results, and timings vary.

POST /v1/corpora/:id/sources

Request
multipart/form-data
file: contents of policy.md

Response
HTTP 202
{"sourceId":"source-id","status":"queued"}
POST/v1/corpora/:id/searchRetrieve scored source passages.
Request and response sketch

Representative fields; IDs, results, and timings vary.

POST /v1/corpora/:id/search

Request
{"query":"return window","k":6}

Response
{"hits":[{"chunkId":"chunk-id","sourceId":"source-id","sourceTitle":"policy.md","ordinal":0,"heading":"Returns","score":0.73,"snippet":"Returns are accepted for thirty days."}],"insufficient":false,"closestHeading":"Returns"}
POST/v1/assetsUpload an image to the local object store.
Request and response sketch

Representative fields; IDs, results, and timings vary.

POST /v1/assets

Request
multipart/form-data
file: contents of receipt.png

Response
HTTP 201
{"assetId":"asset-id","url":"/v1/assets/asset-id"}
POST/v1/vision/analysesAnalyze one image: describe, text, table, or qa.
Request and response sketch

Representative fields; IDs, results, and timings vary.

POST /v1/vision/analyses

Request
{"assetId":"asset-id","task":"table"}

Response
{"id":"analysis-id","assetId":"asset-id","task":"table","model":"nvidia/nemotron-3.5-super-vl-120b-a12b","result":{"columns":["Item","Total"],"rows":[["Bolt","12.00"]]},"latency_ms":850}
POST/v1/voice/sessionsStart a transcript session and its voice thread.
Request and response sketch

Representative fields; IDs, results, and timings vary.

POST /v1/voice/sessions

Request
{}
Optional: {"threadId":"existing-voice-thread-id"}

Response
HTTP 201
{"sessionId":"session-id","threadId":"thread-id"}
POST/v1/voice/sessions/:id/turnsLog a transcript and return the caller turn’s agent reply.
Request and response sketch

Representative fields; IDs, results, and timings vary.

POST /v1/voice/sessions/:id/turns

Request
{"speaker":"caller","transcript":"I need help with a return.","interrupted":false}

Response
{"reply":"Do you have a receipt?","latency_ms":420,"verdict":"allow"}

Request rules

  • Thread surfaces: console, knowledge, support, voice, and vision.
  • Knowledge and Console accept corpusId. When omitted, retrieval uses the oldest corpus; Support and Voice also use that default.
  • Vision tasks: describe, text, table, and qa. The qa task requires question. Table extraction can return {"text":"…","format":"plain_text"} if structured parsing fails.
  • Poll GET /v1/sources/:id after ingestion; wait for indexed before querying. A duplicate upload returns the existing source with deduplicated: true.
  • Errors use application/problem+json with detail and trace_id; the response also includes x-trace-id.

Event stream

Each SSE frame has an id matching its per-thread sequence number and a JSON data payload with v: 1, seq, and kind. The event journal is stored in SQLite.

turn.start
Starts a user or assistant turn; identifies its turn ID and author.
block.open
Opens an assistant content block, such as text.
text.delta
Adds the next text fragment to the assistant’s reply.
citations.set
Provides source citations and excerpts for the answer.
tool.request
Records a requested operation, such as search or escalation.
tool.result
Records the operation’s returned data or ticket.
guardrail.verdict
Records an input or output guard rule and its verdict.
agent.handoff
Reports the selected agent, routed intent, confidence, and human-review flag.
turn.end
Finishes a turn with usage and, where applicable, a verdict or failure flag.
error
Reports a failed turn with a readable detail message.

Resume after a disconnect

GET /v1/threads/:id/events?after=<seq>

Persist the last processed sequence number and request events strictly after it. You can also send Last-Event-ID; the server uses the larger of that header and after. Replay is followed by live events. Frames include a 3000 ms retry hint, and idle streams receive a heartbeat every 15 seconds. Ignore already processed sequence numbers to avoid duplicates.

POST /v1/threads/:id/turns streams the submitted turn’s events and closes on completion. GET /v1/threads/:id/events stays subscribed.

Troubleshooting

Empty vision response

Raise max_tokens in src/models.ts. Reasoning can consume the budget before any content is produced; use at least 1200 tokens. The primary client already retries an empty reasoning response at 2800.

Function '…' not found for account

That model is not enabled for your key. Verify the account’s model access; the provider’s discovery list is not an entitlement list.

Provider returns 429

The key pool rotates automatically. Check /healthz for usable and cooling keys. Cooldowns start at 5 seconds, double after failures, and cap at 5 minutes. If every key is cooling, wait for retry availability.

Ingestion is in failed

Read fail_reason on the source using GET /v1/sources/:id. Check the file format and extraction error. An interrupted ingestion is marked failed on restart; remove the source and upload it again.

Voice integration

Voice consumes transcripts. Your telephony stack owns audio transport, speech-to-text (STT), text-to-speech (TTS), and playback interruption. Browser audio capture is outside the product.

  1. Create a session with POST /v1/voice/sessions and JSON {}. Save its sessionId and threadId.
  2. Transcribe a caller line in your application, then send it to the session:
POST /v1/voice/sessions/:id/turns
Content-Type: application/json

{"speaker":"caller","transcript":"I need help with a return.","interrupted":false}

The JSON response includes reply, latency_ms, and verdict (allow, redact, refuse, or escalate). Send the returned reply to your TTS service. Caller input and assistant output are checked by the conversation guardrails.

Set interrupted: true when a caller interrupts playback; this marks the previous assistant segment interrupted. An assistant transcript only logs that segment and returns an empty reply with zero latency; it does not invoke a model.

GET /v1/voice/sessions/:id

{"id":"session-id","thread_id":"thread-id","started_at":"2026-10-27T12:00:00.000Z","ended_at":null,
 "segments":[{"speaker":"caller","transcript":"I need help with a return.",
              "start_ms":1000,"end_ms":2714,"interrupted":0}]}

Fetch this session endpoint for persisted segments. Start offsets are measured from session creation; end offsets are estimated from transcript length, not measured audio. Calls are serialized per thread. The Voice transcript console lets you test this flow with text and start a new session.