docsArchitecture

Architecture

The SmartChats stack is built around three principles, and once you see them you’ll see them everywhere:

  1. One contract for data access. Every consumer (browser app, CLI, MCP server) talks to data through the same DataAPI interface.
  2. Two concrete implementations. Cloud (your authenticated smartchats.ai account) and local (your locally-running instance). Both implement the same contract.
  3. One place to add new behavior. Query builders, multi-step operations, schema changes — they each have an exact home, and consumers pick them up automatically.

The result: adding a new feature usually means one small change in one place. If you find yourself touching five files for one feature, you’re likely going in the wrong direction.

The layered model

                    smartchats web app                                 ← what users see


                  getBackend().data.query(...)

       ┌────────────────────┴──────────────────┐
       │                                       │
   smartchats-cli                       smartchats-mcp                  ← other consumers
       │                                       │
       └────────┬──────────────────────────────┘

    operations.importBundle / exportBundle                              ← multi-step ops

     queries.X(args) → { query, variables }                             ← pure builders (no I/O)

                DataAPI.query                                            ← contract (in smartchats-backend)
              ╱            ╲
   makeCloudDataAPI       makeLocalDataAPI                              ← factories (in smartchats-database)
        │                       │
   cloud-client              createClient
   (talks to                 (talks to your
    smartchats.ai             local instance)
    cloud)

Each layer has a single responsibility and a single home:

LayerResponsibilityLives in
Web appVoice + chat UX, agent loop, KG visualizationapps/smartchats
CLIOperator surface — login, data move, launchpackages/smartchats-cli
MCPSame operations, packaged for LLM consumptionpackages/smartchats-mcp
OperationsMulti-step ops: bundle import/export, KG batch insertpackages/smartchats-database/src/operations/
Query buildersPure functions: args → { query, variables }, no I/Opackages/smartchats-database/src/queries/
DataAPI contractTwo methods: query() and healthCheck()packages/smartchats-backend/src/types.ts
Cloud factoryWraps cloud-client + Firebase authpackages/smartchats-database/src/data_api.ts
Local factoryWraps SDK-direct createClientpackages/smartchats-database/src/data_api.ts

What this enables — concretely

Adding a new query the agent can run

Three steps, three files, total ~20 lines.

  1. Add a builder in packages/smartchats-database/src/queries/your_topic.ts:

    export function findRecentByTag(args: { tag: string; limit?: number }): QuerySpec {
      return {
        query: 'SELECT * FROM logs WHERE tags CONTAINSALL [$tag] ORDER BY ts DESC LIMIT $limit',
        variables: { tag: args.tag, limit: args.limit ?? 20 },
      };
    }
  2. Expose to MCP in packages/smartchats-mcp/src/tools.ts:

    server.tool(
      'find_recent_by_tag',
      'Find recent logs tagged with a given label',
      { tag: z.string(), limit: z.number().optional() },
      async (args) => runAndFormat(queries.findRecentByTag(args), handle),
    );
  3. Expose to CLI as a subcommand in packages/smartchats-cli/src/commands/:

    const result = await handle.data.query(queries.findRecentByTag(args));
    console.log(JSON.stringify(result.rows, null, 2));

The same builder works against cloud (when the user’s smartchats login’d) and against local (when AIO is running). No branching. No transport-aware code in the builder.

Adding a multi-step operation (bundles, batch transformations)

If your operation is more than a single query — say, “loop over a bundle and write each row, with progress reporting and error aggregation” — put it in operations/ instead of inline:

// packages/smartchats-database/src/operations/your_op.ts
import type { DataAPI } from 'smartchats-backend';
 
export async function yourOp(data: DataAPI, args: YourOpArgs): Promise<YourOpResult> {
  // ... loops, error aggregation, progress callbacks ...
  await data.query(...);
}

Then both the CLI and MCP can call operations.yourOp(handle.data, args) with no duplication. See import_bundle.ts and export_bundle.ts for real examples.

The facade pattern in practice

Two places use the facade pattern explicitly:

1. SmartChatsBackend (in the web app)

The web app uses the full SmartChatsBackend interface — it has APIs beyond just data: llm, tts, embeddings, usage, keys, billing, tools, insights, health(), plus capabilities introspection.

// apps/smartchats/src/lib/backend_facade.ts
const { backend } = await getBackendInstance();
 
await backend.llm.stream(...);                // chat completions
await backend.tts.stream(...);                // text-to-speech
await backend.data.query(...);                // SurrealDB read/write
await backend.insights.emit(events);          // telemetry
const health = await backend.health();        // aggregate health probe

The BackendFacadeProvider mounts the active backend (Firebase or Local) into a React context. Capability gating is automatic: capabilities.billing === false for self-hosted means the credit chip hides, the billing page redirects, and BYO key UI surfaces instead.

2. DataAPIHandle (in CLI + MCP)

The CLI + MCP don’t need the full backend — they only do data operations and don’t render UI. They use the smaller DataAPIHandle shape:

const handle = await makeDataAPI(target);   // 'cloud' or 'local'
 
await handle.data.query({ query, variables });
const uid = await handle.getUid();
await handle.close();

Same DataAPI contract; lighter wrapper. The factories (makeCloudDataAPI, makeLocalDataAPI) handle all the asymmetry between “per-call stateless cloud calls” and “stateful WebSocket connections.”

Open vs closed

Most of the stack is open source (MIT / Apache 2.0). The cloud operational layer is closed and is what powers the hosted SaaS at smartchats.ai. Everything you need to use, extend, self-host, or contribute lives in the open packages:

  • Contracts (smartchats-backend) — the typed interface every consumer codes against.
  • Local-first stack (smartchats-backend-local, smartchats-local-server, smartchats-database, smartchats-cli, smartchats-mcp) — the install-and-go path. No cloud account required; your data stays on your machine.
  • Cloud client (smartchats-cloud-client, smartchats-backend-firebase) — open client adapters for the hosted cloud. Defaults to smartchats.ai for friction-free signup; Firebase config is overridable for your own setup if you want to fork.

The deliberate separation: open everything users touch, closed only the operational details of the hosted service.

Single direct importer of surrealdb

A load-bearing invariant: only packages/smartchats-database/src/client.ts imports the surrealdb npm package. Every other consumer goes through the Client interface (createClient, createLazyClient, createUserClient, signupAsUser, signinAsUser).

This was the architecture choice that made the v2 → v3 SDK migration take one file instead of dozens. SDK quirks (named exports, connect() API change, queryRaw removal, Table wrapper for insert(), DateTime normalization) are all absorbed inside client.ts. The rest of the codebase is decoupled from SDK version.

Auth and credentials

A single credential store: ~/.smartchats-mcp/credentials.json (mode 0600). Holds the Firebase OAuth refresh token. The CLI and the MCP server both read it. smartchats login once, both surfaces are authenticated.

The Firebase ID token is short-lived (1 hour); cloud-client refreshes it on demand using the stored refresh token via Firebase’s REST API. No Firebase Web SDK in the auth path — pure fetch + a localhost callback during the browser sign-in flow.

Data model: event-time vs row-lifecycle

Every event-time table has two distinct timestamp concepts that must not be conflated:

Field(s)What it meansSet byMigrated?
created_at / updated_atPhysical row lifecycle in this DBDB (VALUE time::now() / READONLY)Never. Stripped on import.
ts / local_date / local_tzWhen the thing this row represents actually happened in the user’s lifeApp (nowEventTime() in app/modules/system.ts)Always. Carries through export/import unchanged.

The event-time triple decomposes the question “when did it happen?” into the parts callers actually need:

  • ts: datetime — real-UTC instant. Sort/filter for chronological order.
  • local_date: stringYYYY-MM-DD as the user perceived it in their tz, precomputed so SurrealDB GROUP BY local_date does daily aggregation correctly without any timezone logic at query time.
  • local_tz: string — IANA zone the user was in (e.g. America/Chicago), so any later reconstruction of “what time of day was this for them” is possible.

The DB’s created_at is only for audit / GC. Bundles import without created_at / updated_at so the destination DB stamps fresh values; the event-time triple carries the original user timeline.

Why this matters: without separating event-time from row-lifetime, importing a year-old bundle would make all entries look “created today” and sort incorrectly. With it, the import is faithful to the original user timeline — and aggregations like “metrics per local day” work correctly across timezone boundaries.

Pre-1.0.0 data used a single lts field that stored fake-UTC local wall-clock with a Z suffix. It was retired in the 1.0.0 schema reset; legacy bundles flow through convertLegacyBundle() in packages/smartchats-database/src/operations/.

Verification

The single command is bin/test_all. It runs six layers, each of which can be skipped or stopped at:

LayerWhat it coversTime
L1 type-checkAll packages, tsc --noEmit~30s
L2 buildPer-package builds (catches what type-check misses)~30-60s
L3 cloud-crudLocal docker SurrealDB v3.0.5 + 29 cloud-shape CRUD tests~30s
L4 aio-crudAIO container + 116 local CRUD tests + fixture import + sanity~3 min
L5 simi26 end-to-end Simi workflows against LocalBackend~10 min
L6 live-cloudRead-only smoke against LIVE production cloud (opt-in)~5s
bin/test_all --quick                # L1-L2 only (~30s)
bin/test_all                        # L1-L5 (~13 min)
bin/test_all --include-live         # everything (~14 min)
bin/test_all --level=4              # stop after L4 (no Simi)

See the Verification Workflow in CONTRIBUTING.md for which level matches which kind of change.

Where to learn more

  • CLI Reference — every subcommand with examples + flags.
  • MCP Server — wiring SmartChats into Claude Desktop or any MCP-aware LLM.
  • Self-Hosting — VPS deploys, persistent data, network exposure.
  • Contributing — conventions, the AI agent onboarding section, and the dead-simple PR workflow.