Back to the network

AI systems

Tacit

A multi-user notes app that works with zero AI; every AI change can be undone or waits as a proposal.

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 Tacit window with the Notes dialog open over a note: a search box and a New note button, and a Recent list of demo notes, each with its title and a one-line hook. A sidebar on the left lists the folders inbox, home, ideas, reading, recipes and travel, open, with the notes in each.
The notes list over a demo account: the project sidebar on the left, and the Recent list of the Notes dialog with each note's one-line hook. Everything on this screen works with no AI set up. The notes are made up.

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.

How Tacit is put togetherA browser and an agent client reach the edge, a Hono layer that handles sessions, CSRF, rate limits and static files and sends each request to the requesting user's vault service. The AI layer, with roles that each have a fallback chain of providers and spending ceilings, and the MCP endpoint, where agents read freely and can only propose, both write to the vault only through the actions gate. For the AI layer, a reversible change is applied on its own with an Undo, and a lossy one is a proposal that waits for Accept. What an MCP client proposes waits for Accept too, unless the person turned on the Automation switch for MCP clients and for that kind of change. A person's own reads and edits go from the edge straight to the vault service, which works with no AI configured. The vault service has one database for each user, and a directory database holds users and sessions and is the only shared one. The same code runs on a Node runtime, with SQLite files on a laptop, a Pi or Docker, and on Cloudflare, with Workers, D1 and a Durable Object for each user.Who asksThe same code runs onBrowserplain-JS panes:sidebar, editor, AI;no build stepAgent clientClaude Code over MCP;its token opens theMCP endpoint onlyEdge (Hono)sessions · CSRF · rate limits · static filessends each request to its own user's vaultAI layerroles, each a fallbackchain of your ownproviders; spend capsMCP endpointagents search andread freely; theycan only proposeActions: the one AI write pathreversible: applied on its own, with Undolossy: a proposal that waits for AcceptVault service, one per userone write path, search and index;works with zero AI configuredOne DB per usernotes, search index,settings, layout;only that user'sDirectory DBusers and sessions:the only shareddatabaseNode 22SQLite files;laptop, Pi or DockerCloudflareWorkers and D1; aDurable Object holdseach user's DB
Who asks, and what each can reach. The AI layer and the MCP endpoint write to the vault only through the actions gate. A person's own reads and edits go straight to the vault service, with no AI in the path. Each user has a database of their own, and the same code runs on a Node runtime and on Cloudflare.

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.

The Tacit window with the AI pane open on the right. A question asks for the first idea in a podcast note to be more specific. Under the answer, the trail of the model's calls is unfolded: a search, a read and a proposed edit. Below it is the proposal as a diff, one line in red replaced by one in green, with Accept and Reject buttons. The editor on the left still shows the original line.
A proposal waiting. The search, the read and the proposed edit are listed under the answer, and the change is a diff. Nothing is written until Accept, so the editor still shows the original line. The model here is a scripted stand-in, so the screenshot shows what the app does and not what any model wrote.
The Tacit window with the AI pane on the right showing two rows, each reading 'hook set on' a note, with the new one-line hook, an Undo button and the name of the demo model. The note in the editor, opened again after the change, shows the new hook line in its frontmatter.
Two changes an AI applied without waiting for Accept, each with its own Undo. Each '@writer describe' message asks the writer role for a one-line hook, and filling in a missing hook is reversible, so it was applied at once and reported. The note in the editor, opened again afterwards, shows the new hook line. Undo takes it back, and the row then reads undone. The model is again a scripted stand-in.

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.

Stack

  • TypeScript
  • Node 22
  • Hono
  • Cloudflare Workers
  • Durable Objects
  • D1
  • MCP

Connected to