It works with zero AI
Tacit is a notes web app for several people at once. Each person has an account, and each account holds one private vault of notes. I ruled early that it is a notes app with AI added, not an AI app around notes. So the app does every mechanical job itself, with no model in the loop: capturing, writing, moving, searching, indexing, importing and exporting. It calls a model only where a judgment is needed, and with no provider set up at all it behaves as a plain notes app.

The code is private, so this page is a case study, with no live link. Every screenshot on it comes from a local demo account with made-up notes, which I created for the purpose.
The diagram below shows the whole system once. The sections after it take it apart: how an AI is kept from writing without a way back, how a person brings their own models, what an agent client can and cannot do, how each person's notes are kept apart, and how one codebase runs in two places.
AI changes are undoable, or wait as proposals
What an AI does to a note depends on whether the change can be reversed. A reversible change, such as filing a capture, adding a new section or filling in a missing one-line hook:, runs on its own, is reported in the conversation, and carries an Undo button. A lossy change, such as replacing a hook, rewriting text, merging two notes or deleting one, becomes a proposal. It waits for the person to accept it from a rendered diff.
Every write an AI triggers is a row in one table, applied in one transaction with a version check, by the one module that writes the vault for a job. A change is reversible while the note is unchanged. Undo is bounded by the note's version, never by time, and a note that has been edited since refuses the Undo instead of overwriting the edit. A rejected proposal stays in the conversation, marked as rejected.


Whether a proposal waits is the person's choice. AI setup has an Automation page, with one switch for each kind of change: new notes and edits, moves and archives, merges, and whether an MCP client's proposals follow those. With a switch on, the app accepts a proposal as it is made. That is the person's own Accept, run for them: the same version check, and the same row in the conversation with its Undo. An account that never chose waits for Accept on every proposal.
Two more rules keep the model in its place. A note's text is framed to the model as data, and a tool call that originates from a note's text is refused. And when a task that must answer in JSON is cut off, the app repairs it only if the complete part still validates: the cut part is dropped, never guessed.
Bring your own model, with fallback chains and spend ceilings
Tacit ships with no model. Each person adds their own providers in an AI setup dialog: any OpenAI-compatible endpoint (Ollama, LM Studio, llama.cpp, OpenRouter, Groq and the like), or Anthropic's Messages API, both over plain fetch with no SDK. Presets fill in the common ones, and a Quick setup asks only for what a preset cannot know. 1-llm, my local model platform, is one of the presets, and its mesh preset needs no key. A server that is not on the person's own machine cannot reach a model running there, so the app goes through a small bridge: a program the person's own machine opens outward to the app, which relays only the model calls. A person can also let their own Claude Code answer through a bridge. The bridge connects outward, so the machine opens no port, and the app never holds a Claude credential.
Nothing in the app names a model. A call resolves a role, then the role's assignment, then the first healthy provider. There are six roles: main for the conversation, and writer, coder, reviewer, organizer and resolver. The ordered list of providers assigned to a role is its fallback chain.
A provider is skipped, and the next one takes over, when it runs out of quota, is rate limited past its retries, refuses the key, cannot be reached, answers too slowly, crashes, returns nothing or returns only reserved junk tokens. The takeover is visible: the conversation says which provider failed, why, and which one took over. After the first token of an answer there is no failover. A broken stream ends as failed, with the partial text kept. A model that declines, or says it does not know, has given an answer, so it is never retried on another model. A provider that failed cools down for five minutes, and the cooldown is kept in the person's database, so a restart does not forget it.
Keys are write-only. A key is entered once, encrypted before it is stored, and never returned by any command: the interface only learns whether one exists. Without the server's encryption key, adding a keyed provider is refused, and keyless providers still work.
Every call counts its tokens and is priced at the figures the person typed in for that provider, per million input and per million output tokens. When a provider sends no counts, the app estimates them and marks the figure with ≈. It is the app's estimate, not the provider's bill. Spending ceilings can cover every provider, one provider or one role, per day or per month, and each either refuses new calls or asks first. At most 20 can be set. A ceiling stops only paid providers: a provider priced at zero runs past it, so a free local model later in a chain keeps answering. A call that is already running is never cut, and a job nobody is watching, such as a capture, treats ask first as refuse.
An MCP endpoint where agents read but only propose
POST /api/mcp serves the app's tools to any MCP client that can send headers, Claude Code among them. There are eight tools. Four read: search_notes, recent_notes, read_note and list_areas. Four only propose: propose_note, propose_edit, propose_move and propose_archive. A client never writes a note itself. Each proposal appears in the person's AI pane with the client named in its summary, and waits for Accept, unless the person turned on the Automation switch for MCP clients and for that kind of change. At most 50 proposals wait at once, and a client gets 30 tool calls a minute.
The client signs in with an MCP token, which opens that endpoint, and ends its own session, and nothing else. A full session token is refused there. The person mints it in their own terminal and revokes it the same way, and the app serves no OAuth. Connecting a client to an account sends that account's notes to the client's model, so that is decided for each account and each client. The test walks only ever use throwaway accounts that hold a synthetic vault.
The app's own conversation uses the same eight tools. With a provider's Calls tools switch on, the model searches and reads the notes itself and may propose changes. A question makes at most 7 model calls, six rounds of tools and a last answer. Reads are bounded, 12 KiB for a result and 32 KiB in all, and a question can make at most 5 proposals. Each proposal waits under the answer for Accept.
One database per user
Each account has its own SQLite database for its notes, the full-text index, its settings and layout, and its AI history. A directory database holds the users, their sessions, invites and login attempts, and is the only shared one. A request handler only ever holds one user's database, so one person reaching another's notes is closed off by construction, not checked case by case. Requests for one user run one at a time, and different users never wait on each other.
One write is one transaction: the stamp, the cached columns, the search row, the usage counter and the quota check commit or roll back together. A conflict is detected on a version number, never on a clock. Deleting a user deletes that user's database and sessions, and a vault exports to, and imports from, a plain directory tree.
The threat model says what it does not cover. It protects one user's vault, settings, events and search index from another user, and it protects against a note's text being treated as markup or as instructions. It does not protect against a compromised platform account or server host, since the operator sees every database, or against a compromised browser. Registration is closed by default and an admin creates the users. Single-use invite codes and open registration are settings, not the default.
The same code on Cloudflare Workers + D1 and on a laptop
There are two runtimes and one core. The core (the vault, the HTTP routes and the sign-in code) talks only to store interfaces. The Node runtime adapts node:sqlite files, a WebSocket server and the filesystem. The Cloudflare runtime adapts D1 for the directory, a Durable Object's own SQL storage for each user's database, hibernating WebSockets and Workers Static Assets. The Cloudflare one is the runtime the app is designed to be deployed on, and the Workers Free plan is enough for it. The Node one runs on a laptop, a Raspberry Pi or in Docker. It needs Node 22 and no native modules.
The same API end-to-end suite runs against both: against the local server, and against the Worker running locally under wrangler dev.
The free plan has one visible cost. It caps the password hashing in Web Crypto at 100,000 PBKDF2 iterations, so the Worker is set to that, while the local default is 600,000. A stored hash keeps its own iteration count, so the count can be raised later.
How I built it
I built Tacit as AI-orchestrated engineering, measured and reviewed. It started as written specs, with each decision dated in a log, and then a plan for each part. Claude Code co-authored most of the commits against those plans. Every roadmap item has a "done when" walk, recorded in the repository, and a walk harness runs that checklist unattended against a running instance, locally or on a Worker, over a synthetic vault and never a real one. What the walks proved is on record, and so is what is still unverified.
The repository has 541 commits since 2026-09-10 and about 1,400 tests, plus the API end-to-end tests on both runtimes. Those are the figures as I surveyed them for this site, and the repository keeps growing.