8 min read Rocky Elsalaymeh
Local-first is an architecture, not a setting
Local mode is a toggle. Local-first is a decision about where the state of record lives and what the process may do by default. I walked the Team-X v3.2.1 stack to see which of my own claims survive a grep.
Local mode tells you which machine runs the model. It tells you nothing about who holds your org data, your history, and your keys.
In July 2025, TechCrunch reported that search engines were indexing links to ChatGPT conversations. The follow-up said that OpenAI “removed the feature from ChatGPT that allowed users to make their public conversations discoverable by search engines.” I am not calling that a breach. I am pointing at who held the switch: the vendor, on the vendor’s servers.
Local-first is decided by where the state of record lives and what the process is allowed to do by default, not by which model you call. Team-X, an open-source, local-first desktop app for running AI-agent organizations, keeps its database and vault on your disk, its API keys in the OS keychain, and its updater behind a button.
That is the claim. Below is the code behind each part of it at tag v3.2.1, and the parts I had to narrow when I checked. I check this way because the docs once described a product that did not exist.
What does local-first actually mean?
It means the copy on your machine is the authoritative one. The Ink & Switch essay that named the idea draws the contrast sharply: “In cloud apps, the data on the server is treated as the primary, authoritative copy of the data; if a client has a copy of the data, it is merely a cache that is subordinate to the server.”
Flip that and you get the test I use. Delete the network, and ask which copy is left standing. In Team-X the answer is a file on disk.
Here is where each kind of state lives at v3.2.1.
| State | Where it lives | Source |
|---|---|---|
| Org data and history | One SQLite file, team-x.sqlite, in the Team-X folder under Electron’s user-data directory | paths.ts |
| Vault file contents | Files on disk; name, size, SHA256, and tags in the file_vault table | vault.ts |
| Full-text index | An FTS5 virtual table in the same database file | fts5-init.ts |
| Provider API keys | OS keychain, service team-x, one account per provider | secrets.ts |
| Update check | Runs only when you click a button in Settings | updater.ts |
What does the database actually do?
It opens one SQLite file and applies three pragmas, every time. The factory function is short enough to quote in full:
raw.pragma('journal_mode = WAL');
raw.pragma('foreign_keys = ON');
raw.pragma('synchronous = NORMAL');
That is client.ts lines 52 to 54. The reason for write-ahead logging is in the file’s own comment: it is critical “for the live cockpit which streams events from multiple workers.” SQLite’s documentation states the property I care about: WAL “provides more concurrency as readers do not block writers and a writer does not block readers.”
Foreign keys are on because SQLite defaults them off, and the schema declares 46 tables. Synchronous is set to normal, which the comment calls safe under WAL.
None of this is exotic, and that is the point. SQLite’s own guidance says it is “often used as the on-disk file format for desktop applications such as version control systems, financial analysis tools, media cataloging and editing suites, CAD packages, record keeping programs, and so forth.” An AI-agent organization is a record-keeping program. A file you can copy is a feature, not a limitation.
Search works the same way. The vault’s full-text index is an FTS5 virtual table, which SQLite documents as a module “that provides full-text search functionality to database applications.” Team-X creates it in initFts5, called from the main process at boot right after the database opens (index.ts line 569). Three triggers keep it in sync on insert, update, and delete, so the index is not a second system you maintain.
One honest detail from that file. The production SQLite build includes FTS5 and the sql.js build used in tests does not, so the initializer catches the missing-module error and search falls back to a simpler filter. The fallback exists for tests, not for you.
The vault stores blobs on disk and metadata in the database. The verify method hashes the file again and returns whether it matches the stored SHA256, and an IPC handler exposes it (handlers.ts line 6959). It is an on-demand check, not a background scan.
Where do the secrets go?
Provider API keys go to the operating system’s credential store, through the keytar library, and not into the database. The schema comment on the providers table says so directly, and the config_json column holds “non-secret config only.”
The service name is the constant team-x, the account name is provider: plus the provider id, and the write is a single keytar.setPassword call (secrets.ts line 99). At this tag the store holds LLM provider keys and nothing else; the file says Phase 1 supports only those.
What the OS does with them is the keytar project’s job. Its README says that on macOS “the passwords are managed by the Keychain, on Linux they are managed by the Secret Service API/libsecret, and on Windows they are managed by Credential Vault.”
The Linux line is also where this design costs something. The 3.2.0 changelog records that a minimal Ubuntu Server, Debian netinst, or container install crashed on the first API-key access because libsecret was missing. The .deb now declares libsecret-1-0 as a dependency. The AppImage does not, and the changelog says AppImage users still install it by hand.
Can the app phone home?
Not through anything I can find, but the honest answer is narrower than the slogan. Here is what the code at v3.2.1 shows.
No analytics SDK. The repo has seven package.json files. A case-insensitive search of all of them for the names of common analytics and crash-reporting products returns nothing. A word-boundary search of the apps and packages source for ten such names returns nothing either. The word “telemetry” does appear: @team-x/telemetry-core is a package for cost calculation and model pricing data.
An updater that waits for a click. checkForUpdates has exactly one non-test call site, inside updater.ts (line 82). The chain to reach it starts at a button in Settings: the onClick calls a mutation, the mutation invokes the updater.check IPC channel, and the handler calls the service. There is no startup call. The setInterval timers in the main process belong to the routine scheduler, the copilot analyzer, and the heartbeat service, and none of them touch the updater.
The library’s own default is the opposite. electron-updater documents autoDownload as “Whether to automatically download an update when it is found.” with a default of true. Team-X sets it to false, sets autoInstallOnAppQuit to false, and in dev and test mode it returns a no-op stub. Installing is a second click.
One more fact from the 3.2.1 changelog, because it changes what “works” means. Before that release, the pipeline did not publish the latest*.yml manifests the updater reads, so, in the changelog’s words, “electron-updater could not discover new versions.” The check was user-triggered all along. It just had nothing to find.
A renderer that can only talk to localhost. The Content-Security-Policy meta tag in index.html sets connect-src 'self' ws://localhost:* http://localhost:* (line 15). The UI cannot open an outside connection by itself.
Two things I will not claim away. Model calls go to the providers you configure. Ollama’s default base URL is http://localhost:11434/api, so the zero-cloud setup exists, but it is a configuration you choose. And the execution tools include a browse tool whose body is a fetch of whatever URL it is handed (execution-tools.ts line 1253). That is a network call directed by an agent.
So the claim I can defend is this: no analytics SDK, no automatic update check, a renderer fenced to localhost, and model traffic only to providers you set up. The slogan that says no network call you did not configure is one the browse tool would break.
Delete the network. Whatever is left is your product.
What does local-first cost you?
Four things, and the code shows each of them.
One machine. The database path is a single directory under one user’s profile. I found no sync service in the main-process source tree. The Ink & Switch essay lists this as an ideal Team-X does not meet: “Users today rely on several computing devices to do their work, and modern applications must support such workflows.” At v3.2.1 yours does not follow you to a second laptop.
Backups are your job. The backup service writes a .teamx-backup archive, a zip holding a checkpointed copy of the database and the vault files, to a destination you pass in. It is one click, and the only call to backupService.create in the main process is the IPC handler that click reaches. Restore is destructive by design: the file header says it replaces the current database and vault directories.
No encryption I can show you. A search of the db directory, backup.ts, and vault.ts for sqlcipher, a key pragma, or the word encrypt returns nothing. The database is opened with the three pragmas above and no key. Your protection is your operating system account and your disk encryption. Treat the .teamx-backup archive with the same care.
Linux needs a keychain. The libsecret requirement above is the cost.
These are the price of the property. A vendor that holds the state can sync it, back it up, and encrypt it for you. It can also decide what to do with it.
What can you check before you trust a local mode?
Run these against any product that uses the word, including this one.
- Find the primary copy. Ask where the authoritative state lives, then cut the network and see what still opens.
- Search the manifests. Open every
package.jsonand look for analytics and crash-reporting packages. Then search the source for the same names. - Trace the update call. Find every call to the update API and walk back to what triggers it. A button is an answer. A timer is a different answer.
- Look in the credential store. On Windows open Credential Manager, on macOS Keychain Access. If the product claims the keychain and leaves nothing there, it is not using it.
- Ask who runs the backup. If the answer is “you,” schedule it today.
If you want to see the backup side first, start with the backup and restore guide. The code behind everything above is in the Team-X repository, and the tag is v3.2.1.
Frequently asked questions
What is the difference between local mode and local-first software?
Local mode is a setting, often just a local model call. Local-first is an architecture where the authoritative copy of your data lives on your machine. Team-X, an open-source, local-first desktop app for running AI-agent organizations, keeps its database and vault on your disk and its API keys in the OS keychain.
Does Team-X send telemetry or check for updates automatically?
At v3.2.1 the package manifests list no analytics or crash-reporting SDK, and the only update check in the main process runs when you click Check for Updates in Settings. Team-X also sets auto-download off, so installing an update takes a second click. Agent tools and providers you configure can still reach the network.
What does Team-X store in the OS keychain?
Team-X stores LLM provider API keys through the keytar library under the service name team-x, one account per provider. On Windows that is Credential Manager, on macOS the Keychain, and on Linux libsecret. Non-secret provider settings stay in the SQLite database. On Linux, the AppImage needs libsecret installed manually.
A local model on a cloud architecture is still a cloud product.