Connecting files
The files connection points your Twin at one folder on the machine the API runs on, and keeps a searchable index of everything in it.
POST /api/connections { "provider": "files", "config": { "root": "C:/Users/you/Documents" } }The server resolves the folder (path.resolve), refuses anything that isn’t a real, readable
directory, saves it as your files connection, and immediately kicks off a first sync plus a live
watcher. Without an explicit root, filesRootFor() (apps/api/src/twin/files.ts) falls back to
FILES_ROOT from .env — but only for the owner. Every other being has to connect a folder of
their own; FILES_ROOT points at the machine owner’s documents, so it never leaks to a citizen or a
second registered human by default. See
Environment variables.
What gets indexed
syncFiles() (apps/api/src/twin/files.ts) walks the folder recursively (max depth 8, max 4,000
files by default, TWIN_MAX_FILES to change it), skipping the usual noise —
node_modules, .git, dist, build, __pycache__, dotfiles, and a dozen more — and indexes any
file whose extension is one of three sets:
| Set | Examples |
|---|---|
| Text (summarisable) | md, txt, json, js/ts/tsx/jsx, css, html, csv, py, sh, sql, env, and about 25 more |
| Images (indexed, not summarised) | png, jpg, webp, heic, avif, … |
| Documents (summarisable) | pdf, docx |
Files over 25MB are skipped entirely. For each indexable file, a content hash (sha1 of size+mtime)
decides whether anything changed since the last pass — unchanged files cost nothing.
Summarisation is rate-limited on purpose
Only text-like and .docx files are summarised, and only up to TWIN_MAX_SUMMARIES_PER_SYNC (20)
per sync pass, even on a huge folder — this is the same “cheap models, batch, don’t loop” budget
discipline from PLAN.md §7 applied to a walk that could otherwise touch thousands of files. Each
summary is a two-sentence cheap-tier call over the first 4,000 characters
(SUMMARY_CHARS), then embedded (embed tier) for semantic file search. .docx files are read with
a small hand-rolled zip/XML reader (docxText()) rather than a dependency — it pulls
word/document.xml out of the zip’s central directory and strips tags.
After a sync that changed anything, one extra memory is written directly (not through the reflection
loop): “Owner keeps 340 files under Documents; notable: invoice-2026.pdf, resume.docx, …”
(kind document, importance 0.35).
Live watching
Once a sync completes, watchFiles() starts a chokidar watcher on the root (depth 8, 800ms
stability threshold so partial writes don’t trigger early). Changes are batched per 1.5 seconds and
re-indexed incrementally (flushDirty()), each producing its own Twin activity line — “Noticed
report.docx”, “Updated notes.md”, “Forgot draft.txt (removed).” Watching restarts automatically
if you change the connection’s root, and stops cleanly when you disconnect the provider or the API
shuts down.
Browsing and downloading
The Files widget on the desktop (and /twin’s Sources panel) browse the same index through the
general files API — not a files-specific endpoint:
GET /api/files?scope=user&path=<relative> → FileEntry[] (directory listing)GET /api/files/search?q=<text> → FileEntry[] (name/summary match)GET /api/files/read?id=<fileId> → { content } or { base64 }GET /api/files/download?id=<fileId> → the file, or a zip if it's a directoryDownloading a directory streams a zip on the fly (archiver, level-6 compression) rather than
building one on disk first. The same file API also serves Studio project outputs
(scope=project) and uploads (scope=upload, e.g. your own theme images) — see
Outputs — so “browse and download, single file or zip” (PLAN.md §6) is one
implementation shared by every kind of file your Twin or the Studio ever produces.
Status and disconnecting
GET /api/twin/status → providers[].files: { configured, connected, meta: { root, watching, indexed }, hint }DELETE /api/connections/:idconfigured is true only if the resolved root actually exists as a directory on this machine right
now — a stale or deleted folder path shows configured: false with a plain hint rather than a raw
filesystem error. Disconnecting stops the watcher immediately; already-indexed rows are left in
place (they simply stop refreshing) until you delete them or reconnect a different root.