mdc·platform

MDC Platform

A server that stores AI models, images, documents, and ordinary database tables side by side — queried entirely in plain sentences. No SELECT, no JOIN, nothing your support team needs to be trained on before they can get an answer out of it.

Features at a glance

FeatureWhat you get
Plain-sentence dataCreate, read, update, delete and analyse by asking; ambiguity is clarified, deletes are confirmed, no SQL is ever generatedmore
Object storeAny bytes - AI models, images, documents, logs, tensors, tables - classified by content and tiered HOT / WARM / COLD / ARCHIVEmore
AI model storage.safetensors parsed; every tensor stored and tiered on its own; fetch one layer without the restmore
DNA-inspired archiveARCHIVE tier: AES-256-GCM first, then A/C/G/T, with error correction and a corruption simulator (a simulation, not a molecule)more
Databases and tablesUnlimited isolated databases, typed tables, inserts, browsing, and find across all of themmore
Import and exportIn: .md, .csv, .sql, ER diagram, MDC export. Out: CSV, SQL, Excel, design document, ER diagram, MDC exportmore
Schema from a promptDescribe the tables, or paste an ER diagram; it is previewed and created on yesmore
Agent state streamsEvent sourcing: a base state plus timestamped changes, rebuilt as of any time, sealed per streammore
DeletingDrop a database or table, delete a row or stream - after it shows what would be lostmore
Storage ExplorerA browser app: browse by type, tier or database; inspect, upload, move, delete; a chat panel with import and downloadmore
REST API and integrationsBearer-token API for RASA or your own UI, four roles, structured endpoints for tables, rows, streams, import and exportmore
MonitoringA separate port with Prometheus metrics, JSON and a live event stream (CPU, memory, per-database sizes, objects, streams); optional Kafka publishingmore
DeploymentDockerfile and Railway config, health check, persistent volumemore

Installation

git clone https://github.com/saji1970/ModelDB.git
cd ModelDB/mdc

python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

Two ways to run it:

CommandWhat it starts
python -m mdcThe conversational shell (mdc> prompt) - auto-creates and seeds data/mdc.duckdb at a small scale the first time, so startup stays fast.
python -m mdc serveThe REST API and the Storage Explorer browser UI (a Windows-Explorer-style app with a chat panel), at http://127.0.0.1:8000.

python -m mdc serve takes --host (default 127.0.0.1), --port (default 8000) and --database (default data/mdc.duckdb). Keep it on the default host - see Security. The full command list is under Command line.

python -m mdc db init seeds the full spec-minimum synthetic payments dataset (10,000 merchants / 50,000 customers / 500,000 transactions, deterministic, seed 42) if you want the larger analytics dataset rather than the small dev seed.

Core concepts

MDC is three things sharing one storage layer, kept deliberately separate because they solve different problems:

  • The polymorphic object store - classifies any uploaded bytes (a model checkpoint, an image, a document, a log file) by real content signature, not filename, and routes it through a tiering decision. This is what § AI models and § Other objects cover.
  • The database layer - unlimited named databases, each with its own schema-validated tables, created and queried entirely through chat or the CLI. This is what Multiple databases, Tables & queries, Import, export & design and Deleting things cover.
  • State and events - for data that changes over time, such as what an AI agent knows, decided or is doing. Instead of overwriting the current value, MDC keeps a starting state plus every change, each with its time, so the state at any past moment can be rebuilt. This is what State & events and Agent state streams cover.

All three are driven by the same principle: an LLM proposes an interpretation, but a deterministic validator - never the LLM itself - decides what operation actually reaches storage. No natural-language command generates raw SQL.

State & events: storing an AI model's data with its history

A normal table keeps only the latest value: update a field and the old one is gone. For an AI agent that is the wrong shape. What it believed yesterday, why it changed its goal, and what its state was when it made a particular decision are all questions you may need to answer later. MDC answers them with event sourcing: the state is never edited, it is derived from a base plus an ordered log of changes.

ConceptMeaning in MDC
StateEverything the agent knows or is doing at one moment: a JSON object such as mood, goal, memory.last_tool. It is never stored directly; it is rebuilt on request.
BaseThe starting state, recorded once when the stream is created (event 0).
EventOne recorded change: what was set or removed, and when. Events are append-only; they are never edited or deleted individually.
ReplayApplying the events in order on top of the base. It is deterministic: the same log always gives the same state.
As ofReplay stopped at a time (as of 2026-10-19T10:30:00Z) or at an event number (after event 3). This is the time travel.
StreamOne base plus its events, for one agent or model. A database can hold any number of streams.
CodecThe rule for how a change is written down and applied. The built-in codec records set/unset on paths; a library user can supply their own, so the model decides what a state and a change are.
SealedEvery record, including its times, is AES-256-GCM encrypted under a key derived for that stream. The stored bytes are unreadable without the server's key; the API, or the library with the key, rebuilds the readable state.
base (event 0)     mood=calm  goal=idle  level=1          10:00
event 1            mood=busy  goal="write report"           11:00
event 2            level=2                                  11:40

state as of 10:30  ->  mood=calm  goal=idle  level=1
state as of 12:00  ->  mood=busy  goal="write report"  level=2
Models versus model dataThe weights of a model (the .safetensors checkpoint) are stored once as tensor blocks in the object store and are not event-sourced. Event streams hold the data a model or agent produces and changes: its working state, memory and decisions. The two are separate on purpose, so a large checkpoint is never rewritten for a one-field change.

Nothing here relies on a model to reconstruct anything. A language model, or any other part of an agent, decides what to record; MDC stores it and rebuilds it with deterministic code. How to try it: Agent state streams.

Nobody on your team has to learn SQL

Every example on this page - filtering by a threshold, scoping to a specific database, searching across tables - is plain sentences, not query syntax. That's not a simplified subset for beginners standing next to "the real interface"; it is the interface. Support staff, analysts, and anyone else who needs an answer from the data can just ask for it, with no SELECT/WHERE/JOIN training required, and nothing to get subtly wrong the way a hand-written query can.

What you wantTraditional SQLMDC
Find a cheap item by name SELECT * FROM products WHERE name LIKE '%widget%' AND price < 20000; find widget under 20000
Filter by a threshold SELECT * FROM merchants WHERE settlement_balance > 6000; list merchants with balance > 6000
Read another database without switching SELECT * FROM inventory.products; show data in products in database inventory
Why this is safe, not just easierThe natural-language layer never writes SQL itself - an LLM proposes what you might mean, but a deterministic validator decides the actual operation before anything touches storage. "Balance" resolving against three different fields (ledger/available/settlement) triggers a clarifying question instead of a guess; a destructive delete always asks for confirmation first. The easier interface and the safer one are the same interface, on purpose.

Versus a traditional database: size, speed, ease, cost

MDC is not a faster or smaller database than PostgreSQL or SQLite, and it does not replace one for heavy workloads. It is much closer than it was: lookups by id take well under a millisecond and bulk loads are fast. What it offers is a different trade: a database you can run and use without SQL, drivers or a managed database service, at the price of some space and speed. This page gives the measurements, the access difference, and a cost example with its assumptions, so you can decide whether that trade fits. Everything here is for MDC Platform, with mdc-lite measured separately below.

Storage size

The same merchant-style records, each store loaded in one transaction and checkpointed:

RecordsAs JSON textSQLitePostgreSQL 14MDC
10,0001.6 MB676 KB1.2 MB2.0 MB
100,00015.9 MB6.6 MB11.6 MB12.5 MB

MDC is larger than both: about 1.7 times PostgreSQL and 3 times SQLite at 10,000 records, and about 1.1 and 1.9 times at 100,000, where the fixed overhead of the file matters less. It keeps each record's fields as JSON text next to typed columns for the id, version, times and a SHA-256 checksum, and holds a primary-key index so a record can be found by id. A plain DuckDB table with real typed columns held the 100,000 rows in 3.0 MB, a quarter of MDC's size, which shows where the remaining space goes; storing each table's fields as typed columns is the way to close that gap and has not been done.

This is smaller than it was. An earlier version stored every record as its own JSON block and took 3.3 MB for the 10,000 records; the records now live in a table, and the ARCHIVE tier no longer stores letters:

WhatMeasured
Event streams (base plus 1,000 events)1.18x the plain JSON: the sealing adds a nonce and tag to each record
ARCHIVE tier1.00x the bytes archived (it was 4.00x). The encrypted bytes are the 2-bit sequence packed four bases to the byte; writing them as the letters A, C, G and T would take four times the space and add nothing, so the letters are produced on demand. Redundant copies, if you ask for them, are the only multiplier
mdc-lite, 10,000 entries of about 116 bytes6.4 MB for 1.1 MB of values (about 5.8x; a SQLite key-value table takes 1.5 MB). mdc-lite still writes its entries as A/C/G/T text, one file per key, so the file system rounds each up to its block size: 39 MB allocated, the same as one plain file per key. Changing that is a format change that has not been made

Performance

10,000 recordsInsert, one at a timeInsert, one transactionFind by idFind by a field valueFiltered count
PostgreSQL 14 (local socket)0.03 ms0.15 s0.02 ms0.29 ms0.5 ms
SQLite0.16 ms0.01 sunder 0.01 ms0.16 ms0.2 ms
MDC Data Engine0.44 ms0.47 s0.33 ms1.3 ms3.5 ms
MDC at 100,000 records0.44 ms5.0 s1.0 ms9.8 ms31 ms
PostgreSQL at 100,000 records0.03 ms1.5 s0.02 ms2.5 ms4.7 ms

Reads are hundreds to thousands of times faster than before. The first version read every record in a table to answer any question: a lookup took 954 ms at 10,000 records and grew in proportion. Records now sit in a table: a lookup by id goes through the primary key (0.33 ms at 10,000 records and 1.0 ms at 100,000), a table is read with one query, and a filter on =, <, >, between or IN is first narrowed inside the database. MDC still re-checks every filter itself, so the meaning of each operator did not change.

Still slower than the ordinary databases, and honestly so: inserting one record at a time takes about 14 times PostgreSQL's time (0.44 ms, which is also what a one-row insert costs in DuckDB, the engine underneath); loading in one transaction takes 3 times; finding by a field value or filtering takes 4 to 7 times, and still grows with the table because there is no index on field values. A bulk load (create_many, used by imports) of 10,000 records takes 0.47 s, about 9 times quicker than inserting them one at a time, and quicker than raw DuckDB through its Python client (3.4 s). The rest: event streams append in about 0.9 ms; rebuilding a stream of 1,000 events with no snapshots took about 100 ms (streams take a snapshot every 50 events by default, which this run turned off); an ARCHIVE put of 256 KB took 3 ms and a get under 1 ms (they were 80 ms and 48 ms when the letters were stored).

mdc-lite, 10,000 entries: a put averaged 110 µs and a get 17 µs, against about 200 µs and 4 µs for a SQLite key-value table. mdc-lite's put being quicker than SQLite's one-at-a-time commit is not a like-for-like win: I did not check whether it forces each file to disk before returning, which SQLite does.

How to read these numbersOne run on an Apple M5 laptop with a local SSD, default-configured databases, no network between client and server, and nothing larger than 100,000 records. A real RDS instance adds a network round trip to every call, and its disks are network storage, so absolute times there would be higher for every store; the gaps between stores come from MDC's design and would remain. The script that produced them is in the repository (mdc/benchmarks, with the full output); run it on your own hardware before relying on any figure.

Ease of access

A managed database (for example RDS PostgreSQL)MDC Platform
To get a databaseCreate an instance, choose size and storage, set up networking and security groups, create credentialsRun one container or python -m mdc serve; create a database by saying so
To create tablesWrite DDL and manage migrationsSay create table orders with total decimal, paste an ER diagram, or attach a CSV, SQL script or Markdown design; it shows the design and creates it when you say yes
To ask a questionWrite SQL, or build an API on topPlain sentences in the browser app; find widget under 20000
To reach it from codeA driver, a connection string and pool, and SQLA token-gated REST API with four roles
Who can use itPeople who know SQL, or an application someone builtAnyone who can type a sentence, within their token's role
DeletesRun the statementIt says what would be lost and waits for a yes

What you give up for that ease: no joins, no multi-statement transactions, no indexes, no SQL tooling or BI connectors, no managed backups, patching or failover, and one writing process per database file. If you need any of those, use a database.

Cost: not running a managed database

A managed database bills for an instance by the hour whether or not anyone is using it, plus storage, backups beyond a free allowance, and data transfer. MDC Platform is a small program you can run on a usage-billed container host. An illustration for a small always-on app with about 5 GB of data, prices as of 2026-10-20:

OptionBasisAbout, per month
Amazon RDS for PostgreSQL, Single-AZ db.t4g.microabout $0.016 per hour (730 hours is $11.68), plus gp3 storage at about $0.115 per GB, here 20 GB provisioned ($2.30)$14, before backups beyond the free allowance and transfer
The same with db.t4g.smallabout $0.032 per hour$26
Azure Database for PostgreSQL, Burstable B1msabout $12 compute a month in US East; storage not checked$12 plus storage
MDC Platform on RailwayHobby plan $5 a month, which includes $5 of usage. Usage: memory $10 per GB (this server idled at about 97 MB resident: under $1), CPU $20 per vCPU (a lightly used server: under $1), volume $0.15 per GB (5 GB: $0.75)$5 (usage of $2 to $3 is inside the included $5)
mdc-liteRuns inside your app on the device$0 for infrastructure

On those assumptions the saving against the smallest RDS instance is about $9 a month (roughly 60%) and against a Multi-AZ or larger instance more. Google Cloud SQL and the other providers bill the same way (instance hours, storage, backups, transfer); use each provider's calculator for your region and size. AWS and Azure figures here come from third-party price summaries, because AWS's own page did not show them to me, so confirm them in the AWS Pricing Calculator and the Azure page; Railway's are from its own documentation.

What the saving is, and is notIt is the saving from not paying for an always-on managed instance for a small workload. It is not evidence that MDC is cheaper than PostgreSQL itself: PostgreSQL run in the same kind of container would cost about the same as MDC does. And an RDS instance includes things this does not: automated backups with point-in-time recovery, patching, optional Multi-AZ failover, and monitoring. Run MDC on a host without volume backups and you must make backups yourself. AWS also gives new accounts a free tier of RDS hours for a year, which removes the saving for that period. For a workload that needs real query speed, concurrency or SQL, the right comparison is no longer price.

Storing & retrieving AI models

Upload a .safetensors file and MDC parses its real header, splits each tensor into row-aligned blocks, and stores every tensor as its own independently addressable, independently tiered object - not one opaque blob.

curl -X POST "http://127.0.0.1:8000/objects?filename=model.safetensors" \
  --data-binary @model.safetensors
# {"object_id":"AIM-58F0BB747C","type":"AI_MODEL","tensor_count":2,"total_parameters":16}

curl http://127.0.0.1:8000/models/AIM-58F0BB747C
# manifest: tensor_count, total_parameters, dtype, checksum, ...

curl http://127.0.0.1:8000/models/AIM-58F0BB747C/tensors/linear.weight
# raw bytes of just that one tensor - the rest of the model is never touched

The same thing through chat:

> list models 1 AI_MODEL object(s). > move AIM-58F0BB747C to hot Moved "AIM-58F0BB747C" and its 2 tensor block(s) to HOT.

move on a model cascades to every one of its tensor blocks - each tensor gets its own independent tier decision at upload time, so moving just the model's manifest would silently leave the actual weights wherever they were.

Storing other object types

The same upload endpoint handles images (real dimension parsing from file headers), documents (text extraction + chunking), logs, tensors, and tabular files - classified from content, never from a file extension.

> store ./report.md Stored "report.md" as DOC-30A3DC793C (DOCUMENT). > search for revenue 1 document matched "revenue". > explain DOC-30A3DC793C ARCHIVE (DNA-encoded): explicit archival request on an immutable, unaccessed object. Compressed with ZLIB: the content measured as compressible.

Every object - model, image, document, or table row - carries a SHA-256 checksum verified on every read. A tampered or corrupted read never silently returns bad bytes; it fails with a clear integrity error instead.

Storage tiers

Tier is a real consequence, not a label - only HOT maps to genuinely different physical storage (an in-memory backend) today; ARCHIVE is the one exception that goes further still, actually encoding bytes to simulated DNA base sequences (2 bits per base, A/C/G/T - a real, working prototype encoder, not physical synthesis). The encrypted sequences are saved in the database file (packed four bases to the byte; the letters A, C, G and T are produced on demand), so archived objects survive a restart, cost no more space than the data, and are removed with their database.

TierWhenBackend
HOT≥ 10 reads/dayIn-memory (not persisted across a restart - by design)
WARM≥ 0.1 reads/day, or mutable & recently touchedDuckDB (durable)
COLDImmutable, zero access, zero mutationDuckDB (durable)
ARCHIVEExplicitly requested (never inferred from low access alone)Simulated DNA-base encoding, packed in the database file
DNA double helix illustration with labeled A/C/G/T base pairs

The core concept: 2 bits per base

Every byte in the ARCHIVE tier is mapped directly to 4 DNA bases - the same information-density idea real DNA-storage research is built on:

00 → A      01 → C      10 → G      11 → T

A real, working error-correction scheme (N-way repetition with majority voting across independently-corrupted copies) and a seeded corruption simulator sit on top of the encoding - modeling substitution, insertion, deletion, and dropout errors the way real DNA storage research does. This is a simulation: a string of ACGT characters in a database, not a molecule - no physical synthesis or sequencing anywhere in this codebase.

Every payload is AES-256-GCM encrypted before it's DNA-encoded - the ACGT sequence is ciphertext-as-base-letters, not plaintext-as-base-letters, so it's unreadable without the key this backend holds. The mapping above is public (you're reading it), so encoding plaintext directly would only be obfuscation; encryption is what actually makes the archive tier unreadable outside MDC's own API/CLI. mdc-lite stores every entry the same way.

Read the full whitepaper → for the error-correction design, the corruption model, and an honest, specific answer to "does this use quantum encryption?" (short version: no - and here's what quantum computing actually does and doesn't threaten about long-retention archival data).

Phase 2 (planned, not yet built): extending DNA-tier features further on the platform side - stronger ECC, a Windows CLI bridge that can open an mdc-lite store directly when a phone/wearable is connected, and research into applying the same tier beyond archival storage.

Multiple databases

No hardcoded limit on how many databases exist - each is a fully isolated DuckDB file with its own schema registry, created lazily the moment you ask for it, entirely from chat:

> create database inventory Created database "inventory" and switched to it. > create table products with sku string, name string, price decimal Created table "products" in "inventory" with fields: sku (string), name (string), price (decimal). > insert into products sku=ABC123, name=Widget, price=9.99 Inserted PRO-461A9FC740 into "products".

Tables & queries

Read another database's table without switching to it, using an explicit qualifier:

show data in products in database inventory
describe table products in database inventory

Table creation only ever produces a validated, typed SchemaRegistry collection - never raw SQL DDL generated from chat text, the same injection-avoidance principle as everything else in MDC.

Universal search

find searches every database's tables and documents at once, with deterministic clause extraction for amount constraints and explicit scoping - no need to know which database or table something lives in:

> find widget under 20000 1 row(s) matched "widget". database table record_id sku name price inventory products PRO-A497AE9E55 ABC123 Widget 9.99 > find widget in table products in database inventory 1 row(s) matched "widget".

Import, export & schema design

Everything below works from the chat window - no SQL, and nothing is written unless the whole request is valid.

Import: the paperclip (or drop a file on the chat)

FileBecomes
.md design documentA database with the tables the document defines (format below). Exports from this page's design document import straight back.
.mmd, or .md containing a ```mermaid erDiagram blockA database built from the ER diagram. A relationship becomes a <parent>_id field on the "many" side (MDC stores no foreign keys); many-to-many is flagged, not guessed.
.csv / .tsvOne table, named after the file, in the database your chat is working in. Types are inferred (integer, decimal, boolean, datetime, else string; 01234 stays text). A record_id column is kept as the row identity.
.sql scriptIts CREATE TABLE and INSERT ... VALUES statements, parsed - never executed. DROP, DELETE, UPDATE, ALTER and the rest are skipped and listed in the reply. Values must be plain strings, numbers, TRUE/FALSE or NULL (standard '' escaping).
.json / .dnadb.jsonAn MDC export file from another instance - structure, data, or both, with each row's record_id preserved.

A file that defines a database (design document, ER diagram, SQL script, export file) creates it under the name the file gives it; add ?database= to the REST call to choose another. An import never overwrites: an existing table, or a record_id that already exists, stops the whole import with a clear message. The role of the caller decides what is allowed - creating a database needs create_database, new tables create_table, rows write. Files over 25 MB are refused.

The design-document format - this is also what a design document export produces:

# Database: shop

## Table: customers

| Field   | Type     | Required |
|---------|----------|----------|
| name    | string   | yes      |
| credit  | decimal  | no       |
| joined  | datetime | no       |

Types are string, decimal, integer, boolean, datetime; SQL-style names (varchar(50), int, date, ...) are read as the nearest of those and flagged.

Export: ask in the chat, get a download button

> export database as csv Your export is ready: shop-csv.zip [⬇ shop-csv.zip] > export schema as markdown Your export is ready: shop-design.md [⬇ shop-design.md] > export database with data Your export is ready: shop.dnadb.json [⬇ shop.dnadb.json]
FormatSayContains
CSVexport database as csv, export table <name> as csvData only - a zip with a file per table, or one table's file. Each row starts with its record_id.
SQL scriptexport database as sqlCREATE TABLE + INSERT (standard SQL), or just one of them with schema/data
Excelexport database as excelA sheet per table plus a _schema sheet (.xlsx)
Design documentexport schema as markdownStructure only: an ER diagram (Mermaid) and a table per entity
ER diagramexport schema as er diagramThe Mermaid source alone (.mmd)
MDC export fileexport database with data, export schema only, export data onlyStructure, data, or both, for importing into another MDC instance
Exports are plain filesThe sealing described under Security protects data stored inside MDC. A CSV, SQL, Excel or MDC export file you download is ordinary readable data - protect and delete it accordingly.

CSV and Excel cells that start with =, +, - or @ are written so a spreadsheet shows them as text rather than running them as formulas; importing the CSV back restores the original value.

Build a schema from a prompt or an ER diagram

Describe it, or paste an erDiagram into the chat. MDC shows what it understood and creates nothing until you say yes:

> create database shop with customers(name, email, joined), orders(customer_id, total decimal, paid) Here is the design - new database "shop": customers: name string, email string, joined datetime orders: customer_id string, total decimal, paid boolean - Guessed from the field names: customers.joined -> datetime, orders.paid -> boolean. ... Create it? Reply yes to create it - anything else drops it. > yes Imported into new database "shop": customers, orders (0 row(s) loaded).

This is a deterministic grammar, not a language model: a field without a type is a string unless its name suggests otherwise (total, price -> decimal; age, count -> integer; is_..., active -> boolean; ..._at, date, joined -> datetime), and every guess is listed. Free-form requests such as "I need a database for a school" are not interpreted - that needs a language model, which this build does not include.

Agent state streams (event sourcing)

A stream keeps an agent's state as a base snapshot plus an append-only log of timestamped changes. Nothing is edited in place, so MDC can rebuild the state exactly as it was at any moment - what the agent knew at 10:30 yesterday, or right after its third change.

> create stream agent-1 with mood=calm, goal=idle, level=1 Created stream "agent-1" in "default" with 3 field(s), at 2026-10-19T10:00:00+00:00. > update stream agent-1 set mood=busy, goal="write report" Recorded event 1 on stream "agent-1" (2 change(s)), at 2026-10-19T11:00:00+00:00. > show stream agent-1 as of 2026-10-19T10:30:00Z Stream "agent-1" as of 2026-10-19T10:30:00+00:00 (event 0, 2026-10-19T10:00:00+00:00): 3 field(s). mood: calm goal: idle level: 1 > show history of stream agent-1
SayDoes
create stream <id> with mood=calm, level=3 (or a JSON object)Start a stream with its base state
update stream <id> set mood=busy, memory.last_tool=searchRecord a change (dotted names edit nested fields)
update stream <id> remove goal, moodRecord the removal of fields
show stream <id> · ... as of <time> · ... after event 3Rebuild the state: latest, as of a time, or after a given event
show history of stream <id> · list streamsThe log (what changed, when), or every stream in the current database

A time is an ISO timestamp (2026-10-19T10:30:00Z, no zone means UTC), a date (2026-10-19 means the end of that day, UTC), or 15 minutes ago. Asking for a time before the stream was created is an error, not an empty state.

EndpointDoes
POST /databases/{name}/streams/{stream}Create with a base state (optional note, at)
POST /databases/{name}/streams/{stream}/eventsAppend: send the full new state (MDC works out the change) or a codec payload; expected_seq rejects the write if someone appended first
GET /databases/{name}/streams/{stream}The rebuilt state - ?at= (ISO time) or ?seq=; neither means latest
GET /databases/{name}/streams/{stream}/historyThe log - ?since=&until=&limit=
GET /databases/{name}/streams · DELETE .../streams/{stream}List streams; delete one (needs write)

What is sealed, and what that does and doesn't protect

Every record - the base state, each event, and the timestamps inside them - is encrypted with AES-256-GCM under a key derived per stream from the server's master key (MDC_DNA_KEY, or the key file the DNA tier already uses - how to set it up and rotate it). Someone who gets at the database file sees ciphertext: not the fields, not the values, not what changed, not the event times. Each record is also bound to its stream and position, so a copied, swapped, altered or deleted event is detected on read and reported, never silently replayed.

The honest limitsThe storage layer still shows the stream id, how many records there are, their sizes, and when each record was written - so for live appends the rough time of a change is visible (not for back-filled ones). The key lives on the server: anyone with both the key and the file can read everything, and losing the key makes the streams unreadable. And the API is not per-stream private: any token with read permission can read any stream in that database. Sealing protects the stored bytes, not the API from its own callers.

Where the AI model fits

The agent decides what its state is and when it changed, and records that by posting a new state or a change. How a change is encoded is the job of a codec: the built-in one writes set/unset operations on paths. A library user can supply their own codec (for example one that records decisions or compacted summaries) with EventStore(..., codecs={...}). Replay is always the codec's deterministic apply - a state is rebuilt by running the log, never by asking a model to remember - and since a codec is Python code, the REST API and chat only use the built-in one.

Also: one writing process per database (a second writer is caught and refused, not merged); recorded times never run backwards (back-filling with at is allowed, rewriting the past is not); one record may be at most 1 MB; master-key rotation is not implemented.

Deleting things

Every deletion here is permanent - there is no recycle bin - so nothing is removed on the first request. In chat, MDC says exactly what would be lost and waits. Over REST, the dangerous ones must repeat the name as ?confirm=.

To deleteIn chatOver RESTNeeds
A database and everything in itdrop database sales - then type its name to confirmDELETE /databases/{name}?confirm={name}drop
A table and its rowsdrop table orders (or ... in database shop) - then yesDELETE /databases/{name}/tables/{table}?confirm={table}drop
One rowdelete row <record_id> from table orders - then yesDELETE /databases/{name}/tables/{table}/rows/{record_id}write
A stream's whole historydelete stream agent-1 - then yesDELETE /databases/{name}/streams/{stream}drop
An object (model, image, document)delete <object_id> - then yesDELETE /objects/{id}write
> drop database shop This permanently deletes database "shop" and everything in it: 2 table(s) holding 4 row(s), 1 object(s), 1 stream(s). This cannot be undone. To confirm, type the database name (shop). Anything else cancels. > shop Deleted database "shop" (2 table(s), 4 row(s), 1 object(s), 1 stream(s)). You are back in "default".

The REST reply to an unconfirmed request is a 422 that states the counts, so a script or a person sees what the call would destroy before repeating it.

  • Anything other than the confirmation cancels - "no", a different request, even an empty message - so a stray "yes" later deletes nothing.
  • Protected: the default database, and its built-in merchants table, cannot be dropped.
  • Roles: dropping needs the new drop permission, held by db_admin and admin only; an editor can still delete rows and objects. Chat checks it when you ask and again when you confirm.
  • If someone else is working in a database that gets dropped, their next message says so and moves them back to default.

Storage Explorer

python -m mdc serve, then open http://127.0.0.1:8000: a Windows-Explorer-style app over everything MDC stores.

PartWhat it does
SidebarBrowse objects by type or tier, and every database; click a database for its tables and a table for its rows
DetailsAn object's metadata and why it sits in its tier, with buttons to preview its content, archive it, or delete it (the browser asks first); a table's details have Browse data. Any other tier move is move <id> to hot in the chat
UploadAdd any file; it is classified and tiered automatically
Ask MDC (chat)Everything in the chat reference. Tables come back as tables; an export command answers with a download button; cancel drops whatever it is waiting on
Paperclip / drag and dropImport a .md, .csv, .sql, .mmd or MDC export file straight from the chat

There is no login screen. Run on your own machine, the page carries a per-process admin token so it works with no setup; the deployed container asks for the API token instead (Deployment).

Security & access

WhatHow
Who can call the APIEvery route except GET / needs Authorization: Bearer <token>. Issue one with mdc token issue <name> --role ...; only a hash is stored. How to issue and use tokens
What a token may doFour roles: viewer reads, editor also writes rows, objects and tables, db_admin also creates databases and drops things, admin also manages accounts. A valid token without the role gets 403
No SQL, everSentences, imported .sql scripts and table creation all go through the schema registry and the Data Engine; a script is parsed and never executed
Imports and deletesValidated before anything is written; never overwrite; deletes show what would be lost and wait for confirmation
Sealed at restThe ARCHIVE tier and event streams are AES-256-GCM encrypted (streams under a per-stream derived key). Key: MDC_DNA_KEY (64 hex characters), or a key file (default ~/.mdc/dna.key, location set by MDC_DNA_KEY_FILE). Step-by-step key setup and rotation
Know the limitsThe encryption key lives on the server: anyone with the key and the files can read them, and losing it makes them unreadable (keep it apart from the data and back it up). The API is gated by role, not per stream or per table. Tokens default to admin unless you pass --role. CORS is open on purpose so other front ends can call the API - the token is the access control. And mdc serve hands its page an admin token, so do not bind it to a public address; for anything reachable by others, run the container, which asks for the token instead.

Encryption keys: setup & rotation

One 32-byte master key seals the data MDC stores. Event streams use a key derived from it for each stream (HKDF-SHA256), so one stream's key never opens another; every record gets a fresh random nonce and is bound to its stream and position, so a copied, swapped, edited or deleted record is detected. The ARCHIVE tier uses the master key directly, before the A/C/G/T encoding. Both are AES-256-GCM.

Set it up, step by step

  1. Generate a key.
    python -m mdc key generate
    # 64 hex characters, printed once and not saved anywhere
  2. Choose where it lives (first match wins):
    OptionHowUse it when
    MDC_DNA_KEYEnvironment variable holding the 64 hex charactersA real deployment. The key then comes from your secrets store, never from the data disk
    MDC_DNA_KEY_FILEPath to a file holding the key (mode 0600). Created with a new key if it does not existThe key is a mounted secret or sits on another disk
    Default file~/.mdc/dna.key, made on first use. In the container HOME is /data, so /data/.mdc/dna.keyLocal development. The key is on the same volume as the data
  3. Hand it to the server.
    # local
    export MDC_DNA_KEY=<the 64 hex characters>
    python -m mdc serve
    
    # Docker
    docker run -p 8000:8000 -v modeldb:/data \
      -e MDC_API_TOKENS=<token> -e MDC_DNA_KEY=<key> modeldb
    
    # key in a file outside the data volume
    docker run -p 8000:8000 -v modeldb:/data -v /secrets/mdc.key:/run/secrets/mdc.key:ro \
      -e MDC_DNA_KEY_FILE=/run/secrets/mdc.key modeldb
    On Railway add MDC_DNA_KEY under the service's Variables (a sealed variable), not in the repository.
  4. Check which key is in use. The server prints this at startup; the command prints the same line and never the key:
    python -m mdc key show
    # Sealing key: from MDC_DNA_KEY (fingerprint 3f9a1c07).
    The fingerprint is 8 characters derived one-way from the key. Compare it with the one you recorded to be sure the right key is configured.
  5. Use MDC as normal. Streams are sealed from the first record, and the first stream written also stores a sealed key check, so later a wrong key is reported as a wrong key, not as damage.
  6. Back the key up, separately from the data. Losing it makes every sealed record unreadable, and there is no recovery. Record the fingerprint with the backup.

Rotate the key

Rotation re-seals every stream and every archived object in every database under a new key. Plaintext is never written: each record is opened and re-sealed in memory, and a record that does not authenticate stops the rotation before anything is changed.

  1. Stop the server and back up the data directory (data/, or /data in the container).
  2. Rotate, giving the new key one of three ways, with the current key still configured:
    # make a new key
    python -m mdc key rotate --database data/mdc.duckdb --generate
    
    # or use a key you made
    python -m mdc key rotate --database data/mdc.duckdb --new-key-env MDC_DNA_KEY_NEW
    python -m mdc key rotate --database data/mdc.duckdb --new-key-file new.key
    With --generate and a key file, the old key is kept as <file>.<fingerprint>.old, the new key is staged as <file>.new before anything is re-sealed, and then replaces the file. With MDC_DNA_KEY the new key is printed once, before anything changes, so a crash cannot leave data sealed under a key you never saw.
  3. Switch the server to the new key (MDC_DNA_KEY or MDC_DNA_KEY_FILE) and start it. The old key now fails with a clear message.
  4. If it was interrupted (power cut, killed process), run the same command again: records already under the new key are recognized and skipped. Delete the old key and the backup only when you are sure.

What this does and does not cover

QuestionAnswer
Wrong key configuredReads and writes fail with "The sealing key in use is not the one these streams were sealed with" (HTTP 500 on the API), and nothing is altered. A damaged or tampered record still reports a failed integrity check
Databases made before key checks existedThey keep working. Their first rotation records a key check; until then a wrong key shows as a failed integrity check
The ARCHIVE tierArchived objects are stored in the database file as encrypted, packed sequences under the same key, so they survive a restart and are re-encrypted by rotation. Only ciphertext is stored; the object's type, tier and metadata are in the index as for any object
Rotating while the server runsNot supported: a record written meanwhile would be sealed under the old key. Stop the server first
Key in a hardware moduleNot supported. The key is read from the environment or a file
Still visible in the database fileThe stream id, record counts and sizes, and when each record was written
Per-stream access controlNone: any token with read permission reads any stream in that database

Monitoring: live statistics & Kafka

MDC can publish what it is doing on a separate port, and stream the same statistics to Kafka, so a dashboard or monitoring system can watch it. Both are off until you ask. The port is read-only, and the numbers are counts and sizes only: never data, keys or tokens.

Turn it on

# a metrics port, kept to this machine
python -m mdc serve --metrics-port 9464

# reachable by a monitoring server, and protected
MDC_METRICS_TOKEN=choose-a-long-random-value \
  python -m mdc serve --metrics-port 9464 --metrics-host 0.0.0.0

# also stream to Kafka every 10 seconds
pip install confluent-kafka        # or kafka-python
python -m mdc serve --kafka-bootstrap broker1:9092 --kafka-topic mdc.stats

The container reads the same settings from environment variables, so on Docker or Railway you set variables instead of flags:

Setting (flag / variable)DefaultWhat it does
--metrics-port / MDC_METRICS_PORToffThe port to publish on. Separate from the API port
--metrics-host / MDC_METRICS_HOST127.0.0.1The address to bind. Use 0.0.0.0 only on a private network or with a token
MDC_METRICS_TOKENnoneIf set, every path except /healthz needs Authorization: Bearer <token>
MDC_METRICS_INTERVAL5Seconds between events on /stream
--kafka-bootstrap / MDC_KAFKA_BOOTSTRAPoffKafka brokers, host:port[,host:port]
--kafka-topic / MDC_KAFKA_TOPICmdc.statsThe topic to publish to
--kafka-interval / MDC_KAFKA_INTERVAL10Seconds between snapshots

On startup the server prints the address it is publishing on. A bad setting (a taken port, a missing Kafka library) stops mdc serve with a message; in the container it is logged and the database still starts.

What the port serves

PathReturns
GET /metricsPrometheus text format, for Prometheus, Grafana Agent, Datadog and similar scrapers
GET /statsThe whole snapshot as JSON
GET /streamServer-Sent Events: a JSON snapshot every MDC_METRICS_INTERVAL seconds for as long as the client stays connected (curl -N, or a browser EventSource)
GET /healthzok. Never needs the token

What is in a snapshot

SectionContents
ProcessCPU time (user and system), CPU percent since the previous snapshot, resident and virtual memory, threads
SystemCPU percent and count, load average, memory total, available and percent, and total, used and free space on the data disk
RequestsAPI requests handled by status class and method, in flight, total and slowest time. Counted for the API port, not the metrics port
Each databaseFile size, blocks and payload bytes, memory held by the database engine, rows per table, objects (by tier and type), ARCHIVE blocks, streams and events
TotalsDatabases, rows, objects, streams, events and file bytes across all of them
KafkaMessages published, failures, the last error
mdc_process_resident_memory_bytes 97271808
mdc_process_cpu_seconds_total{mode="user"} 0.263
mdc_database_file_bytes{database="default"} 12288
mdc_table_rows{database="default",table="merchants"} 3
mdc_database_objects_by_tier{database="default",tier="ARCHIVE"} 1
mdc_http_requests_total{status_class="2xx"} 1

For CPU in Prometheus, graph rate(mdc_process_cpu_seconds_total[1m]): it is exact however often you scrape. mdc_process_cpu_percent is measured between snapshots, and snapshots are shared for 2 seconds, so very frequent readers see the same value.

Kafka

MDC publishes one JSON message per snapshot (the same JSON as /stats), keyed by the host name so a topic shared by several servers keeps each one's messages in order. It is a producer only: you run the broker, create the topic (or let your broker auto-create it), and install a client library, confluent-kafka (preferred) or kafka-python. MDC does not bundle one. A failed publish is counted (mdc_kafka_errors_total) and retried at the next interval; a broker outage never stops the database. Authentication and TLS to the broker are not configurable yet: it connects with the library's defaults.

What this does not doIt is not alerting: nothing here decides a number is bad. It has no history of its own, so keep it in Prometheus or Kafka. Row counts scan each database, which is why snapshots are cached for 2 seconds; with very large databases, scrape every 15 to 60 seconds. The Kafka path is tested with a stand-in producer, and the two client adapters were run against the real confluent-kafka and kafka-python libraries with no broker present (they report the failure correctly), but it has never been run against a live broker. Counts of the metrics port's own requests are not included, and the port serves plain HTTP: put it behind your network's TLS if it leaves a private network.

Deployment (Docker & Railway)

docker build -t modeldb .
docker run -p 8000:8000 -v modeldb:/data \
  -e MDC_API_TOKENS=choose-a-long-random-token modeldb

The image runs the same app as mdc serve, without its interactive first-run prompt, on PORT and on 8000 (every address, IPv6 where available), with GET /healthz for health checks. railway.json builds the Dockerfile, checks /healthz, and requires a volume mounted at /data.

SettingWhat it does
MDC_API_TOKENS (or MODELDB_TOKEN)The API token(s). With neither set, one is generated, kept in /data/modeldb-api-token and printed in the deploy log
MDC_DNA_KEY64 hex characters: the key for the sealed ARCHIVE tier and streams. Otherwise the key file is used (Encryption keys)
MDC_DNA_KEY_FILEWhere the key file is, instead of HOME/.mdc/dna.key (/data/.mdc/dna.key in the container). Point it off the data volume to keep the key apart from the data
MODELDB_DATABASEThe default database file (default /data/mdc.duckdb); other databases live beside it in databases/
MDC_METRICS_PORT, MDC_METRICS_HOST, MDC_METRICS_TOKEN, MDC_KAFKA_BOOTSTRAP, MDC_KAFKA_TOPICLive statistics on a second port and/or into Kafka; off unless set (Monitoring). The metrics port is not the public one: keep it private or set the token
PORT, HOST, MODELDB_TOKEN_FILEListen port, bind address, where a generated token is kept
Volume at /dataWithout one the database, a generated token and the key are lost on every redeploy (the container warns about it)

The deployed Storage Explorer asks you to paste the token (kept for the browser tab); a wrong one is refused and asked for again. /admin redirects to /.

REST API reference

Every route below requires Authorization: Bearer <token> - see Connect your own NLU or UI for how to issue one.

Method and pathDoesNeeds
POST /objectsUpload any file - classified and tiered automaticallywrite
GET /objects (?type=&tier=), GET /objects/{id}, POST /objects/{id}/read, GET /objects/{id}/strategyList; metadata (type, tier, compression, checksum); raw content; the explanation of the tier decisionread
PUT /objects/{id}, POST /objects/{id}/move, POST /objects/{id}/optimize, DELETE /objects/{id}Replace; force a tier (cascades to every tensor of a model); re-evaluate; deletewrite
GET /models/{id}, GET /models/{id}/tensors/{name}A model's manifest; one tensor's bytes without touching the restread
GET /databases, POST /databasesList; create a databaseread; create_database
GET /databases/{name}/tables, .../tables/{table}, .../tables/{table}/rowsTables; a table's fields; its rowsread
POST /databases/{name}/tables/{table}Create a table from structured field datacreate_table
POST .../tables/{table}/rows, DELETE .../rows/{record_id}Insert a row; delete onewrite
DELETE /databases/{name}?confirm={name}, DELETE .../tables/{table}?confirm={table}Drop a database or table (Deleting things)drop
GET /databases/{name}/exportDownload: ?format=csv|sql|xlsx|md|er|dnadb&scope=schema|data|all&table=read
POST /databases/importCreate a database or tables from an uploaded file (raw body; ?filename= picks the format; optional &database=)whatever the import does: create_database, create_table, write
GET /databases/{name}/streams, .../streams/{s} (?at= or ?seq=), .../streams/{s}/historyList; rebuild a state; the log (Agent state streams)read
POST .../streams/{s}, POST .../streams/{s}/eventsCreate with a base state; append a change (expected_seq guards against a lost update)write
DELETE .../streams/{s}Delete a stream's whole historydrop
GET /findStructured universal search - ?q=&min=&max=&database=&table=read
POST /chat, POST /chat/attachThe conversational layer the CLI and chat panel use; the paperclip's import (a CSV lands in the session's current database)write; then by what it does

Command line

CommandDoes
python -m mdcThe conversational shell (mdc>); slash commands /help, /context, /history, /reset, /exit
python -m mdc serve [--host] [--port] [--database] [--metrics-port] [--metrics-host] [--kafka-bootstrap] [--kafka-topic] [--kafka-interval]The REST API and Storage Explorer, optionally with the metrics port and Kafka publishing (Monitoring)
python -m mdc db init [--scale small]Create the schema and seed the synthetic payments data
mdc token issue <name> [--role viewer|editor|db_admin|admin], token list, token revoke <name>API tokens
mdc key generate, key show, key rotate --database ... (--generate | --new-key-env VAR | --new-key-file F)Make a sealing key; show which one is in use (fingerprint only); re-seal every stream and archived object under a new key (Encryption keys)
mdc user create <name> [--role ...], user list, user set-role <name> <role>, user delete <name>User accounts (the password is prompted for, never a command-line argument)

Where things live: data/mdc.duckdb is the default database; every other one is data/databases/<name>.duckdb with its table structure in <name>.schema.json. Credentials are in ~/.mdc/.

Chat command reference

Say thisTo
Objects
list models, list images, list documents (also video, audio, logs, tensors, tabular, text, binary), show objects (everything stored), list objects in archiveList objects by type, by tier, or all of them
show <id>, read <id>, explain <id>, where is <id> storedMetadata, content, and why it is in its tier
retrieve tensor <name> from model <id>, retrieve layer 3 from model <id>One tensor or layer of a model
archive <id>, restore <id>, move <id> to hot|warm|cold|archiveChange tier
compress <id>, make <id> as compact as possibleReport a preference; the engine still decides
delete <id>Delete an object (asks first)
store ./file, update <id> with ./fileCLI only (the browser chat cannot read your files; use Upload there)
Databases and tables
create database <name>, use database <name>, list databases, describe databaseCreate, switch, list, describe
create table <name> with <field> <type>, ..., describe table <name>Typed tables: string, decimal, integer, boolean, datetime
insert into <table> field=value, ..., show data in <table> (... in database <name>)Insert and browse, without switching
find <text> [under|over <amount>] [in table <t>] [in database <d>]Search every database and document at once
Import, export, design
export database as csv|sql|excel|markdown, export schema as er diagram, export database with data, export table <t> as csvDownload button - see Import, export & design
the paperclip (or drop a file); importImport a .md, .csv, .sql, .mmd or MDC export file
create database shop with customers(name, email), orders(total decimal), or a pasted erDiagramPropose a schema; created on yes
Agent state streams
create stream agent-1 with mood=calm, update stream agent-1 set mood=busy, update stream agent-1 remove goalRecord state and changes - Agent state streams
show stream agent-1 [as of <time> | after event N], show history of stream agent-1, list streamsRebuild a state at a time; the log; all streams
Deleting
drop database <name>, drop table <name>, delete row <id> from table <t>, delete stream <id>Permanent; shows what is lost and asks first - Deleting things
Merchants
Show all merchants, Create a merchant called ABC Store in India, Change ABC Store balance to 15000, Delete merchant ABC Store, analytics like Show merchants with balance above 5000An earlier, separate conversational domain (clarifies ambiguous fields, confirms deletes) - see the platform README
Control
cancel, never mind, /resetDrop whatever question or proposal the chat is waiting on
Full referenceThe complete README, including the acceptance-test walkthroughs this documentation draws from, lives at mdc/README.md; the architecture write-up (including the honest scoping note on why merchants-CRUD and the object store stay separate systems) is at docs/ARCHITECTURE.md.

Connect your own NLU or UI

MDC's own chat layer doesn't have to be the only front door. Two integration patterns, depending on whether your system has already done its own language understanding by the time it talks to MDC:

Authenticate first — every route but the built-in UI needs a token

CORS is deliberately wide open (allow_origins=["*"]) so a third-party integration can call this API from a different origin without a browser rejecting the response first - that's the point of everything on this page. Open CORS was never itself a security boundary against a non-browser client though, so every route except the built-in browser UI's own HTML page requires a bearer token:

$ mdc token issue rasa-integration --role editor
mdc_kP3x...   (shown once - store it now)

Send it as Authorization: Bearer <token> on every request. Revoke it later with mdc token revoke rasa-integration, or set MDC_API_TOKENS (comma-separated) to accept fixed tokens without the on-disk store, for CI/deployment.

--role scopes what that specific token can do - omit it and a token defaults to admin (full access, matching the original all-tokens-are-equal behavior), or pick something narrower for an integration that shouldn't have it:

RoleCan do
viewerRead/query only - GET routes, /find
editor+ create tables, insert/update/delete rows, upload/replace/delete objects, use /chat
db_admin+ create brand-new databases (POST /databases) and drop: permanently delete a table, a database or a stream (Deleting things)
admin+ manage user accounts (CLI only, not exposed over the API)

A token with an insufficient role still authenticates - a valid-but-underprivileged token gets 403, not the 401 reserved for a missing or invalid token.

User accounts and roles (CLI)

Separately from API tokens, MDC keeps user accounts. The first time you run mdc or mdc serve with no user configured yet, it interactively asks for an admin username and password and creates that first account as admin - the same four roles as tokens, since both share one vocabulary. Manage accounts afterward with:

$ mdc user create alice --role editor
Password: ********
Repeat for confirmation: ********
Created user alice with role editor.

$ mdc user list
  admin  (admin)
  alice  (editor)

$ mdc user set-role alice db_admin
$ mdc user delete alice
Accounts are not a login yetThe shell and the Storage Explorer do not ask you to sign in. Accounts exist so that people and tokens share one set of roles; today, what a caller can do is decided by the role of the token it presents (the Explorer, served from your own machine, carries its own admin token).

Passwords are never stored in a recoverable form - hashlib.scrypt derives a key from the password and a random per-user salt, and only that salt and derived key are persisted (mode 0600), the same reasoning already used for API tokens.

Pattern 1 — pass-through (simplest, works today, zero new code)

Forward raw user text to POST /chat and let MDC's own deterministic parser do the work - the same pipeline the browser chat panel and CLI already use. This is the right choice for most integrations: your system doesn't need to understand databases, models, or storage tiers at all, just detect "this is a data question" and relay the text.

# A RASA custom action (actions.py) - the entire integration
import requests
from rasa_sdk import Action

class ActionQueryMDC(Action):
    def name(self):
        return "action_query_mdc"

    def run(self, dispatcher, tracker, domain):
        reply = requests.post(
            "http://localhost:8000/chat",
            json={"message": tracker.latest_message.get("text"), "session_id": tracker.sender_id},
            headers={"Authorization": "Bearer mdc_kP3x..."},
        ).json()
        dispatcher.utter_message(text=reply["message"])
        return []

Note session_id: tracker.sender_id - reusing RASA's own conversation id keeps MDC's per-session state (which database you switched to, pronoun resolution) aligned with RASA's own multi-turn state, so "switch to database inventory" followed later by "show data in products" behaves correctly across turns.

Pattern 2 — structured (for a system that already extracted intent)

If your NLU has already parsed "add Widget at $9.99 to inventory" into its own slots, reconstructing MDC's sentence syntax from those slots is unnecessary indirection. Call the structured REST endpoints directly with the extracted values instead:

# RASA custom action calling the structured endpoint directly,
# using slots RASA's own NLU already extracted
requests.post(
    f"http://localhost:8000/databases/inventory/tables/products/rows",
    json={"values": {"name": tracker.get_slot("item_name"), "price": tracker.get_slot("price")}},
    headers={"Authorization": "Bearer mdc_kP3x..."},
)

Every structured endpoint runs through the exact same validation as the chat/CLI path it mirrors (schema-registry-only table creation, typed field coercion on insert) - there's no separate, less-checked code path for programmatic callers.

A custom "ChatGPT-style" UI

A minimal chat interface is one fetch() call - the same POST /chat endpoint the Storage Explorer's own chat panel uses:

async function ask(message) {
  const res = await fetch("http://localhost:8000/chat", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + MDC_TOKEN,
    },
    body: JSON.stringify({ message, session_id: crypto.randomUUID() }),
  });
  const { message: reply, data } = await res.json();
  return { reply, data };  // data is a table's rows / a search result list, when there is one
}
Before you deploy this reachable from anywhere but localhostTokens now carry roles, and there are real user accounts for the CLI - but there's still no rate limiting, and API access control stops at "this token's role permits this route," not a per-request audit trail tied to an individual person. Fine for local development and internal, network-isolated use with tokens distributed by hand; put a reverse proxy with rate limiting in front of it before exposing it any more broadly than that.